WebAssembly-Leistungsmuster für Webanwendungen

In dieser Anleitung, die sich an Webentwickler richtet, die WebAssembly nutzen möchten, erfahren Sie anhand eines Beispiels, wie Sie CPU-intensive Aufgaben mit Wasm auslagern. Der Leitfaden deckt alles ab, von Best Practices zum Laden von Wasm-Modulen bis hin zur Optimierung ihrer Kompilierung und Instanziierung. Außerdem wird erläutert, wie CPU-intensive Aufgaben an Web Workers übertragen werden können. Es werden auch Implementierungsentscheidungen behandelt, mit denen Sie konfrontiert werden, z. B. wann der Web Worker erstellt werden soll und ob er dauerhaft aktiv bleiben oder bei Bedarf gestartet werden soll. Im Leitfaden wird der Ansatz iterativ entwickelt und es wird jeweils ein Leistungsmuster eingeführt, bis die beste Lösung für das Problem vorgeschlagen wird.

Annahmen

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 die Fakultät einer Zahl. Die Fakultät ist das Produkt einer Ganzzahl und aller Ganzzahlen darunter. Die Fakultät von 4 (geschrieben als 4!) ist beispielsweise gleich 24 (also 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 Nachzeichnen eines Rasterbilds.

Eine leistungsstarke 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;
}

}

Für den Rest des Artikels wird davon ausgegangen, dass es ein Wasm-Modul gibt, das auf der Kompilierung dieser factorial()-Funktion mit Emscripten in einer Datei namens factorial.wasm basiert und alle Best Practices für die Codeoptimierung verwendet. Eine Anleitung dazu finden Sie unter Kompilierte C-Funktionen über JavaScript mit ccall/cwrap aufrufen. Der folgende Befehl wurde verwendet, um factorial.wasm als eigenständiges Wasm zu kompilieren.

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 kombiniert 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 erfolgt dies über die fetch() API. Da Sie wissen, dass Ihre Web-App für die CPU-intensive Aufgabe vom Wasm-Modul abhängt, sollten Sie die Wasm-Datei so früh wie möglich vorab laden. Dazu verwenden Sie einen CORS-fähigen Fetch im <head>-Abschnitt Ihrer App.

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

In der Realität ist die fetch() API asynchron und Sie müssen das Ergebnis await.

fetch('factorial.wasm');

Kompilieren und instanziieren Sie als Nächstes das Wasm-Modul. Für diese Aufgaben gibt es verlockend benannte Funktionen wie WebAssembly.compile() (plus WebAssembly.compileStreaming()) und WebAssembly.instantiate(). Stattdessen kompiliert und instanziiert die Methode WebAssembly.instantiateStreaming() ein Wasm-Modul direkt aus einer gestreamten zugrunde liegenden Quelle wie fetch() – es ist kein await erforderlich. Dies ist die effizienteste und optimierteste 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 übertragen

Wenn Sie dies im Hauptthread mit wirklich CPU-intensiven Aufgaben ausführen, riskieren Sie, dass die gesamte App blockiert wird. Eine gängige Vorgehensweise besteht darin, solche Aufgaben an einen Web Worker zu übergeben.

Umstrukturierung des Hauptthreads

Um die CPU-intensive Aufgabe in einen Web Worker zu verschieben, müssen Sie die Anwendung zuerst umstrukturieren. Der Hauptthread erstellt jetzt ein Worker und kümmert sich ansonsten nur darum, die Eingabe an den Web Worker zu senden und dann 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: Aufgabe wird in Web Worker ausgeführt, aber Code ist fehleranfällig

Der Web Worker instanziiert das Wasm-Modul und führt nach dem Empfang einer Nachricht die CPU-intensive Aufgabe aus. Das Ergebnis wird dann an den Hauptthread zurückgesendet. Das Problem bei diesem Ansatz ist, dass die Instanziierung eines Wasm-Moduls mit WebAssembly.instantiateStreaming() ein asynchroner Vorgang ist. Das bedeutet, dass der Code Race Conditions enthält. Im schlimmsten Fall sendet der Hauptthread Daten, wenn der Web Worker noch nicht bereit ist, und der Web Worker empfängt 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 in einem Web Worker ausgeführt, aber es kann zu redundantem Laden und Kompilieren kommen.

Eine mögliche Lösung für das Problem der asynchronen Instanziierung von Wasm-Modulen besteht darin, das Laden, Kompilieren und Instanziieren des Wasm-Moduls in den Event-Listener zu verlagern. Das 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 das nicht die schlechteste Lösung, aber es gibt eine bessere.

Wenn Sie den asynchronen Code an den Anfang des Web Workers verschieben und nicht auf die Erfüllung des Promise warten, sondern das Promise in einer Variablen speichern, fährt das Programm sofort mit dem Event-Listener-Teil des Codes fort. So geht keine Nachricht vom Hauptthread verloren. Innerhalb des Event-Listeners kann dann auf das Promise 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 in einem Web Worker ausgeführt und nur einmal geladen und kompiliert.

Das Ergebnis der statischen Methode WebAssembly.compileStreaming() ist ein Promise, das in einem WebAssembly.Module aufgelöst wird. Ein Vorteil dieses Objekts ist, dass es mit postMessage() übertragen werden kann. Das bedeutet, dass das Wasm-Modul nur einmal im Hauptthread (oder sogar in einem anderen Web Worker, der sich nur mit dem Laden und Kompilieren beschäftigt) geladen und kompiliert und dann an den Web Worker übertragen werden kann, der für die CPU-intensive Aufgabe zuständig ist. Der folgende Code zeigt 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 Web Worker-Seite muss nur noch das WebAssembly.Module-Objekt extrahiert und instanziiert werden. Da die Nachricht mit dem WebAssembly.Module nicht gestreamt wird, wird im Code im Web Worker jetzt WebAssembly.instantiate() anstelle der vorherigen instantiateStreaming()-Variante verwendet. Das instanziierte Modul wird in einer Variablen zwischengespeichert, 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 in einem Inline-Web-Worker ausgeführt und nur einmal geladen und kompiliert.

Selbst mit HTTP-Caching ist das Abrufen des (idealerweise) im Cache gespeicherten Web Worker-Codes und das potenzielle Aufrufen des Netzwerks kostspielig. Ein gängiger Trick zur Leistungssteigerung besteht darin, den Web Worker inline einzufügen und als blob:-URL zu laden. Dazu muss das kompilierte Wasm-Modul weiterhin an den Web Worker übergeben werden, da sich die Kontexte des Web Workers 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-Erstellung

Bisher wurde der Web Worker in allen Codebeispielen verzögert auf Anfrage gestartet, d. h. wenn die Schaltfläche gedrückt wurde. Je nach Anwendung kann es sinnvoll sein, den Web Worker früher zu erstellen, z. B. wenn die App im Leerlauf ist oder sogar im Rahmen 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;
});

Web Worker beibehalten oder nicht

Eine Frage, die Sie sich vielleicht stellen, ist, ob Sie den Web Worker dauerhaft behalten oder ihn bei Bedarf neu erstellen sollten. Beide Ansätze sind möglich und haben ihre Vor- und Nachteile. Wenn Sie beispielsweise einen Web Worker dauerhaft beibehalten, kann dies den Speicherbedarf Ihrer App erhöhen und die Verarbeitung gleichzeitiger Aufgaben erschweren, da Sie die Ergebnisse des Web Workers irgendwie den Anfragen zuordnen müssen. Andererseits kann der Bootstrapping-Code Ihres Web-Workers recht komplex sein. Wenn Sie jedes Mal einen neuen Web-Worker erstellen, kann das zu einem hohen Overhead führen. Glücklicherweise lässt sich das mit der User Timing API messen.

In den bisherigen Codebeispielen wurde ein permanenter Web Worker beibehalten. Im folgenden Codebeispiel wird bei Bedarf ein neuer Web Worker ad hoc erstellt. Sie müssen den Web Worker selbst beenden. (Im Code-Snippet wird die Fehlerbehandlung übersprungen. Wenn etwas schiefgeht, sollten Sie den Vorgang jedoch in jedem Fall beenden, 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, die Sie ausprobieren können. Einmal mit einem Ad-hoc-Web-Worker (Quellcode) und einmal mit einem permanenten Web-Worker (Quellcode). Wenn Sie die Chrome-Entwicklertools öffnen und die Konsole 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 beträgt der Zeitunterschied zwischen Ad-hoc- und permanentem Upscaling etwa das Dreifache. In der Praxis sind beide für das menschliche Auge in diesem Fall nicht zu unterscheiden. Die Ergebnisse für Ihre eigene App werden höchstwahrscheinlich abweichen.

Faktorielle Wasm-Demo-App mit einem Ad-hoc-Worker. Die Chrome-Entwicklertools sind geöffnet. Auf dem Tab „Netzwerk“ sind zwei Blob-URL-Anfragen zu sehen und in der Console werden zwei Berechnungszeiten angezeigt.

Faktorielle Wasm-Demo-App mit einem permanenten Worker. Die Chrome-Entwicklertools sind geöffnet. Es gibt nur einen Blob: URL-Anfrage auf dem Tab „Netzwerk“ und in der Console werden vier Berechnungszeiten angezeigt.

Zusammenfassung

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

  • In der Regel sollten Sie die Streaming-Methoden (WebAssembly.compileStreaming() und WebAssembly.instantiateStreaming()) ihren Nicht-Streaming-Pendants (WebAssembly.compile() und WebAssembly.instantiate()) vorziehen.
  • Lagern Sie, wenn möglich, leistungsintensive Aufgaben in einen Web Worker aus und führen Sie das Laden und Kompilieren von Wasm nur einmal außerhalb des Web Workers aus. Auf diese Weise muss der Web Worker nur das Wasm-Modul instanziieren, das er vom Hauptthread empfängt, in dem das Laden und Kompilieren mit WebAssembly.instantiate() erfolgt ist. Das bedeutet, dass die Instanz im Cache gespeichert werden kann, wenn Sie den Web Worker dauerhaft beibehalten.
  • Überlegen Sie genau, ob es sinnvoll ist, einen permanenten Web Worker zu verwenden, oder ob Sie Ad-hoc-Web Workers erstellen sollten, wenn sie benötigt werden. Überlegen Sie auch, wann der beste Zeitpunkt zum Erstellen des Web Workers ist. Dabei sollten Sie den Speicherverbrauch, die Dauer der Web Worker-Instanziierung und die Komplexität der Verarbeitung gleichzeitiger Anfragen berücksichtigen.

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.