WebAssembly-Leistungsmuster für Webanwendungen

In diesem Leitfaden, der sich an Webentwickler richtet, die von WebAssembly profitieren möchten, erfahren Sie anhand eines laufenden Beispiels, wie Sie mit Wasm CPU-intensive Aufgaben auslagern können. Der Leitfaden deckt alles ab, von Best Practices für das Laden von Wasm-Modulen bis hin zur Optimierung ihrer Kompilierung und Instanziierung. Außerdem wird die Verlagerung der CPU-intensiven Aufgaben auf Web Worker sowie die Implementierungsentscheidungen beschrieben, mit denen Sie konfrontiert werden müssen, z. B. wann der Web Worker erstellt werden soll und ob er dauerhaft aktiv bleiben oder bei Bedarf hochgefahren werden soll. Im Leitfaden wird der Ansatz iterativ entwickelt und nach und nach ein Leistungsmuster eingeführt, bis die beste Lösung für das Problem vorgeschlagen wird.

Angenommen, Sie haben eine sehr CPU-intensive Aufgabe, die Sie aufgrund der nahezu nativen Leistung an WebAssembly (Wasm) auslagern möchten. Die CPU-intensive Aufgabe, die in diesem Leitfaden als Beispiel verwendet wird, berechnet das Fakultätsglied einer Zahl. Die Fakultät ist das Produkt einer Ganzzahl und aller darunter liegenden Ganzzahlen. Das Fakultätsglied von 4 (4!) ist beispielsweise gleich 24 (4 * 3 * 2 * 1). Die Zahlen werden schnell sehr groß. Beispiel: 16! ist 2,004,189,184. Ein realistischeres Beispiel für eine CPU-intensive Aufgabe wäre das Scannen eines Barcodes oder das Verfolgung eines Rasterbilds.

Eine leistungsfähige iterative (nicht rekursive) Implementierung einer factorial()-Funktion wird im folgenden Codebeispiel in C++ gezeigt.

#include <stdint.h>

extern "C" {

// Calculates the factorial of a non-negative integer n.
uint64_t factorial(unsigned int n) {
    uint64_t result = 1;
    for (unsigned int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

}

Angenommen, es gibt ein Wasm-Modul, das auf der Kompilierung dieser factorial()-Funktion mit Emscripten in einer Datei namens factorial.wasm basiert und bei dem alle Best Practices für die Codeoptimierung angewendet wurden. Weitere Informationen dazu finden Sie unter Kompilierte C-Funktionen mit ccall/cwrap aus JavaScript aufrufen. Mit dem folgenden Befehl wurde factorial.wasm als eigenständiger Wasm-Code kompiliert.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

In HTML gibt es ein form mit einem input, das mit einem output und einem Submit-button gekoppelt ist. Auf diese Elemente wird in JavaScript anhand ihrer Namen verwiesen.

<form>
  <label>The factorial of <input type="text" value="12" /></label> is
  <output>479001600</output>.
  <button type="submit">Calculate</button>
</form>
const input = document.querySelector('input');
const output = document.querySelector('output');
const button = document.querySelector('button');

Laden, Kompilieren und Instanziieren des Moduls

Bevor Sie ein Wasm-Modul verwenden können, müssen Sie es laden. Im Web geschieht dies über die fetch() API. Da Ihre Webanwendung für die CPU-intensive Aufgabe vom Wasm-Modul abhängt, sollten Sie die Wasm-Datei so früh wie möglich vorladen. Dazu verwenden Sie im Bereich <head> Ihrer App einen CORS-fähigen Abruf.

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

Tatsächlich ist die fetch() API asynchron und Sie müssen das Ergebnis mit await angeben.

fetch('factorial.wasm');

Als Nächstes kompilieren und instanziieren Sie das Wasm-Modul. Es gibt verlockend benannte Funktionen namens WebAssembly.compile() (plus WebAssembly.compileStreaming()) und WebAssembly.instantiate() für diese Aufgaben. Stattdessen kompiliert die Methode WebAssembly.instantiateStreaming() ein Wasm-Modul und instanziiert ein Wasm-Modul direkt aus einer gestreamten zugrunde liegenden Quelle wie fetch() – kein await erforderlich. Dies ist die effizienteste und optimierte Methode zum Laden von Wasm-Code. Wenn das Wasm-Modul eine factorial()-Funktion exportiert, können Sie sie sofort verwenden.

const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

button.addEventListener('click', (e) => {
  e.preventDefault();
  output.textContent = factorial(parseInt(input.value, 10));
});

Aufgabe an einen Web-Worker übergeben

Wenn Sie dies im Hauptthread mit wirklich CPU-intensiven Aufgaben ausführen, besteht die Gefahr, dass die gesamte App blockiert wird. Es ist üblich, solche Aufgaben auf einen Webworker zu übertragen.

Umstrukturierung des Hauptthreads

Wenn Sie die CPU-intensive Aufgabe auf einen Webworker verschieben möchten, müssen Sie zuerst die Anwendung umstrukturieren. Der Haupt-Thread erstellt jetzt eine Worker und kümmert sich ansonsten nur darum, die Eingabe an den Web Worker zu senden, die Ausgabe zu empfangen und anzuzeigen.

/* Main thread. */

let worker = null;

// When the button is clicked, submit the input value
//  to the Web Worker.
button.addEventListener('click', (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({ integer: parseInt(input.value, 10) });
});

Schlecht: Die Aufgabe wird im Webworker ausgeführt, der Code ist aber fehlerhaft

Der Webworker instanziiert das Wasm-Modul und führt nach Erhalt einer Nachricht die CPU-intensive Aufgabe aus und sendet das Ergebnis an den Hauptthread zurück. Das Problem bei diesem Ansatz besteht darin, dass das Instanziieren eines Wasm-Moduls mit WebAssembly.instantiateStreaming() ein asynchroner Vorgang ist. Das bedeutet, dass der Code fehleranfällig ist. Im schlimmsten Fall sendet der Hauptthread Daten, wenn der Webworker noch nicht bereit ist, und der Webworker erhält die Nachricht nie.

/* Worker thread. */

// Instantiate the Wasm module.
// 🚫 This code is racy! If a message comes in while
// the promise is still being awaited, it's lost.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

// Listen for incoming messages, run the task,
// and post the result.
self.addEventListener('message', (e) => {
  const { integer } = e.data;
  self.postMessage({ result: factorial(integer) });
});

Besser: Die Aufgabe wird im Webworker ausgeführt, aber möglicherweise mit redundantem Laden und Kompilieren

Eine Lösung für das Problem der asynchronen Wasm-Modulinstanziierung besteht darin, das Laden, Kompilieren und Instanziieren des Wasm-Moduls in den Ereignisempfänger zu verschieben. Dies würde jedoch bedeuten, dass diese Arbeit bei jeder empfangenen Nachricht ausgeführt werden müsste. Wenn HTTP-Caching und der HTTP-Cache den kompilierten Wasm-Bytecode im Cache speichern können, ist dies nicht die schlechteste Lösung, aber es gibt einen besseren Weg.

Wenn Sie den asynchronen Code an den Anfang des Web Workers verschieben und nicht auf die Erfüllung des Versprechens warten, sondern es in einer Variablen speichern, geht das Programm sofort zum Event-Listener-Teil des Codes über und es geht keine Nachricht vom Hauptthread verloren. Innerhalb des Ereignisempfängers kann dann auf das Versprechen gewartet werden.

/* Worker thread. */

const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  const { integer } = e.data;
  const resultObject = await wasmPromise;
  const factorial = resultObject.instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

Gut: Die Aufgabe wird im Webworker ausgeführt und nur einmal geladen und kompiliert.

Das Ergebnis der statischen Methode WebAssembly.compileStreaming() ist ein Versprechen, das in einem WebAssembly.Module aufgelöst wird. Ein praktisches Feature dieses Objekts ist, dass es mit postMessage() übertragen werden kann. Das bedeutet, dass das Wasm-Modul nur einmal im Haupt-Thread (oder sogar in einem anderen Web-Worker, der nur zum Laden und Kompilieren dient) geladen und kompiliert und dann an den Web-Worker übertragen werden kann, der für die CPU-intensive Aufgabe verantwortlich ist. Der folgende Code veranschaulicht diesen Ablauf.

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Auf der Webworker-Seite bleibt nur noch, das WebAssembly.Module-Objekt zu extrahieren und zu instanziieren. Da die Nachricht mit der WebAssembly.Module nicht gestreamt wird, wird im Code des Web Workers jetzt WebAssembly.instantiate() anstelle der vorherigen instantiateStreaming()-Variante verwendet. Das instanziierte Modul wird in einer Variablen im Cache gespeichert, sodass die Instanziierung nur einmal beim Starten des Web-Workers erfolgen muss.

/* Worker thread. */

let instance = null;

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  // Extract the `WebAssembly.Module` from the message.
  const { integer, module } = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via `postMessage()`.
  instance = instance || (await WebAssembly.instantiate(module, importObject));
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

Perfekt: Die Aufgabe wird im Inline-Webworker ausgeführt und nur einmal geladen und kompiliert.

Selbst mit HTTP-Caching ist es teuer, den (idealerweise) im Cache gespeicherten Webworker-Code abzurufen und möglicherweise das Netzwerk zu nutzen. Eine gängige Leistungsoptimierung besteht darin, den Webworker inline einzufügen und als blob:-URL zu laden. Dazu muss das kompilierte Wasm-Modul zur Instanziierung an den Webworker übergeben werden, da sich die Kontexte des Webworkers und des Hauptthreads unterscheiden, auch wenn sie auf derselben JavaScript-Quelldatei basieren.

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker(blobURL);

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Lazy oder Eager Web Worker erstellen

Bisher wurde der Web Worker bei allen Codebeispielen verzögert nach Bedarf hochgefahren, d. h. beim Drücken der Schaltfläche. Je nach Anwendung kann es sinnvoll sein, den Web Worker mit größerer Verzögerung zu erstellen, z. B. wenn die Anwendung inaktiv ist oder sogar Teil des Bootstrapping-Prozesses der Anwendung ist. Verschieben Sie den Code zum Erstellen des Web-Workers daher außerhalb des Ereignis-Listeners der Schaltfläche.

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

Webworker beibehalten oder nicht

Sie können sich fragen, ob Sie den Webworker dauerhaft beibehalten oder bei Bedarf neu erstellen sollten. Beide Ansätze sind möglich und haben Vor- und Nachteile. Wenn Sie beispielsweise einen Webworker dauerhaft aktiv lassen, kann das den Arbeitsspeicherbedarf Ihrer App erhöhen und die Verarbeitung paralleler Aufgaben erschweren, da Sie die Ergebnisse aus dem Webworker irgendwie den Anfragen zuordnen müssen. Andererseits kann der Bootstrapping-Code Ihres Webworkers ziemlich komplex sein. Wenn Sie also jedes Mal einen neuen erstellen, kann das viel Overhead verursachen. Glücklicherweise können Sie dies mit der User Timing API messen.

Die Codebeispiele enthalten bisher einen Web Worker. Im folgenden Codebeispiel wird bei Bedarf ein neuer Web Worker ad hoc erstellt. Du musst den Web Worker selbst beenden. (Das Code-Snippet überspringt die Fehlerbehandlung, aber wenn etwas schiefgehen sollte, muss in jedem Fall, ob bei Erfolg oder Misserfolg, beendet werden.)

/* Main thread. */

let worker = null;

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});  
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();
  // Terminate a potentially running Web Worker.
  if (worker) {
    worker.terminate();
  }
  // Create the Web Worker lazily on-demand.
  worker = new Worker(blobURL);
  worker.addEventListener('message', (e) => {
    worker.terminate();
    worker = null;
    output.textContent = e.data.result;
  });
  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Demos

Es gibt zwei Demos, mit denen Sie spielen können. Eine mit einem Ad-hoc-Webworker (Quellcode) und eine mit einem permanenten Webworker (Quellcode). Wenn Sie die Chrome-Entwicklertools öffnen und die Console aufrufen, sehen Sie die User Timing API-Logs, in denen die Zeit gemessen wird, die vom Klicken auf die Schaltfläche bis zum angezeigten Ergebnis auf dem Bildschirm vergeht. Auf dem Tab „Netzwerk“ werden die blob:-URL-Anfragen angezeigt. In diesem Beispiel ist die Zeitdifferenz zwischen Ad-hoc- und dauerhafter Kalibrierung etwa dreimal so hoch. In der Praxis sind beide in diesem Fall für das menschliche Auge nicht zu unterscheiden. Die Ergebnisse für Ihre eigene App werden höchstwahrscheinlich variieren.

Demo-App für faktorielle Wasm-Funktion mit einem Ad-hoc-Worker Die Chrome-Entwicklertools sind geöffnet. Es gibt zwei Blobs: URL-Anfragen auf dem Tab &quot;Network&quot; und die Konsole zeigt zwei Berechnungszeiträume an.

Demo-App zu faktoriellem Wasm mit einem permanenten Worker Die Chrome-Entwicklertools sind geöffnet. Es gibt nur ein Blob: URL-Anfrage auf dem Tab &quot;Network&quot; und die Konsole zeigt vier Berechnungszeiträume an.

Schlussfolgerungen

In diesem Beitrag wurden einige Leistungsmuster beim Umgang mit Wasm untersucht.

  • In der Regel sollten Sie die Streamingmethoden (WebAssembly.compileStreaming() und WebAssembly.instantiateStreaming()) gegenüber ihren Gegenstücken ohne Streaming (WebAssembly.compile() und WebAssembly.instantiate()) vorziehen.
  • Wenn möglich, sollten Sie leistungsintensive Aufgaben in einem Webworker auslagern und das Laden und Kompilieren von Wasm nur einmal außerhalb des Webworkers ausführen. Auf diese Weise muss der Web Worker nur das Wasm-Modul instanziieren, das er aus dem Hauptthread empfängt, in dem das Laden und Kompilieren mit WebAssembly.instantiate() durchgeführt wurde. Das bedeutet, dass die Instanz im Cache gespeichert werden kann, wenn Sie den Web Worker dauerhaft beibehalten.
  • Prüfen Sie sorgfältig, ob es sinnvoll ist, einen dauerhaften Web Worker zu verwenden oder bei Bedarf Ad-hoc-Web Worker zu erstellen. Überlegen Sie auch, wann der beste Zeitpunkt für das Erstellen des Web-Workers ist. Zu berücksichtigen sind der Speicherverbrauch, die Dauer der Instanziierung des Web-Workers, aber auch die Komplexität der möglicherweise gleichzeitigen Verarbeitung von Anfragen.

Wenn Sie diese Muster berücksichtigen, sind Sie auf dem richtigen Weg zu einer optimalen Wasm-Leistung.

Danksagungen

Dieser Leitfaden wurde von Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort und AndrewRachel gelesen.