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 Übertragung der CPU-intensiven Aufgaben an Webworker erläutert und es werden Implementierungsentscheidungen besprochen, mit denen Sie konfrontiert werden, z. B. wann Sie den Webworker erstellen und ob er dauerhaft aktiv bleiben oder bei Bedarf gestartet 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. Das Fakultätszeichen ist das Produkt einer ganzen Zahl und aller ganzen Zahlen darunter. 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 />

In Wirklichkeit ist die fetch() API asynchron und Sie müssen das Ergebnis await.

fetch('factorial.wasm');

Als Nächstes kompilieren und instanziieren Sie das Wasm-Modul. Für diese Aufgaben gibt es die verführerisch benannten Funktionen WebAssembly.compile() (sowie WebAssembly.compileStreaming()) und WebAssembly.instantiate(). Stattdessen wird mit der Methode WebAssembly.instantiateStreaming() ein Wasm-Modul direkt aus einer gestreamten zugrunde liegenden Quelle wie fetch() kompiliert und instanziiert – await ist nicht erforderlich. Dies ist die effizienteste und optimierteste Methode zum Laden von Wasm-Code. Angenommen, das Wasm-Modul exportiert eine factorial()-Funktion, 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 ist, dass die Instanziierung 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 Ereignis-Listener zu verschieben. Dies würde jedoch bedeuten, dass diese Arbeit bei jeder empfangenen Nachricht ausgeführt werden müsste. Mit HTTP-Caching und dem HTTP-Cache, der den kompilierten Wasm-Bytecode im Cache speichern kann, ist dies keine schlechte Lösung. Es gibt jedoch eine bessere Möglichkeit.

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 Ereignislisteners 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 in allen Codebeispielen der Webworker träge bei Bedarf gestartet, also wenn die Schaltfläche gedrückt wurde. Je nach Anwendung kann es sinnvoll sein, den Webworker häufiger zu erstellen, z. B. wenn die App inaktiv ist oder sogar als Teil des Bootstrapping-Prozesses der App. 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 vom Webworker kommenden Ergebnisse 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.

In den Codebeispielen bisher wurde ein dauerhafter Webworker beibehalten. Im folgenden Codebeispiel wird bei Bedarf ad hoc ein neuer Web Worker erstellt. Du musst den Web Worker selbst beenden. (Die Fehlerbehandlung wird im Code-Snippet übersprungen. Falls etwas schiefgeht, muss der Vorgang jedoch in jedem Fall beendet werden, unabhängig davon, ob er erfolgreich war oder nicht.)

/* 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.

Factorial Wasm-Demoanwendung mit einem Ad-hoc-Worker Die Chrome-Entwicklertools sind geöffnet. Es gibt zwei Blob-URL-Anfragen auf dem Tab „Netzwerk“ und die Console zeigt zwei Berechnungszeiten an.

Factorial Wasm-Demoanwendung mit einem dauerhaften Worker Die Chrome-Entwicklertools sind geöffnet. Auf dem Tab „Netzwerk“ gibt es nur einen Blob: „URL-Anfrage“. In der Console werden vier Berechnungszeiten angezeigt.

Ergebnisse

In diesem Beitrag wurden einige Leistungsmuster für den Umgang mit Wasm untersucht.

  • Verwenden Sie in der Regel die Streamingmethoden (WebAssembly.compileStreaming() und WebAssembly.instantiateStreaming()) anstelle der Streaming-unabhängigen Methoden (WebAssembly.compile() und WebAssembly.instantiate()).
  • 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. So muss der Web Worker nur das Wasm-Modul instanziieren, das er vom Haupt-Thread empfängt, in dem das Laden und Kompilieren mit WebAssembly.instantiate() stattgefunden hat. Das bedeutet, dass die Instanz im Cache gespeichert werden kann, wenn der Web Worker dauerhaft aktiv bleibt.
  • 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, die sich möglicherweise aus gleichzeitigen Anfragen ergibt.

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 Rachel Andrew geprüft.