WebAssembly-Leistungsmuster für Webanwendungen

In diesem Leitfaden für Webentwickler, die von WebAssembly profitieren möchten, erfahren Sie anhand eines laufenden Beispiels, wie Sie Wasm nutzen können, um CPU-intensive Aufgaben auszulagern. 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 und die Implementierungsentscheidungen erläutert, mit denen Sie konfrontiert werden, z. B. wann der Web Worker erstellt und ob er dauerhaft aktiv gehalten oder bei Bedarf aktiviert werden soll. Der Leitfaden entwickelt den Ansatz iterativ und führt ein Leistungsmuster nach dem anderen ein, bis die beste Lösung für das Problem vorgeschlagen wird.

Annahmen

Nehmen wir an, Sie haben eine sehr CPU-intensive Aufgabe, die Sie aufgrund der nahezu nativen Leistung zu WebAssembly (Wasm) auslagern möchten. Die CPU-intensive Aufgabe, die in diesem Leitfaden als Beispiel verwendet wird, berechnet die Fakultät einer Zahl. Die Faktorisierung ist das Produkt einer Ganzzahl und aller darunterliegenden Ganzzahlen. Beispielsweise ist die Fakultät von vier (geschrieben als 4!) gleich 24 (d. h. 4 * 3 * 2 * 1). Die Zahlen werden schnell groß. Beispiel: 16! ist 2,004,189,184. Ein realistischeres Beispiel für eine CPU-intensive Aufgabe ist das Scannen eines Barcodes oder das Tracing eines Rasterbildes.

Das folgende in C++ geschriebene Codebeispiel zeigt eine leistungsstarke iterative (nicht rekursive) Implementierung einer factorial()-Funktion.

#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 der factorial()-Funktion mit Emscripten in einer Datei namens factorial.wasm basiert. Dabei werden alle Best Practices für die Codeoptimierung berücksichtigt. Weitere Informationen zum Aufrufen von kompilierten C-Funktionen aus JavaScript mit ccall/cwrap Mit dem folgenden Befehl wurde factorial.wasm als eigenständiger Wasm kompiliert.

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

In HTML gibt es ein form-Objekt mit einem input-Element, das mit einer output-Funktion gekoppelt ist, und einer Sende-button. Auf diese Elemente wird im JavaScript basierend auf ihren 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 das über die fetch() API. Da Sie wissen, dass Ihre Webanwendung 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 im Bereich <head> Ihrer Anwendung 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. 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 direkt aus einer gestreamten, zugrunde liegenden Quelle wie fetch() und instanziiert ein Wasm-Modul – ohne await. Dies ist die effizienteste und optimierte Art, Wasm-Code zu laden. 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));
});

Die Aufgabe auf einen Web Worker verlagern

Wenn Sie dies im Hauptthread mit wirklich CPU-intensiven Aufgaben ausführen, riskieren Sie, die gesamte Anwendung zu blockieren. Häufig werden solche Aufgaben an einen Web Worker verlagert.

Umstrukturierung des Hauptthreads

Zum Verschieben der CPU-intensiven Aufgabe auf einen Web Worker besteht der erste Schritt darin, die Anwendung neu zu strukturieren. Der Hauptthread erstellt jetzt eine Worker. Abgesehen davon sendet er nur die Eingabe an den Web Worker, empfängt die Ausgabe und zeigt sie an.

/* 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 nicht jugendfrei

Der Web Worker instanziiert das Wasm-Modul, führt beim Empfang einer Nachricht die CPU-intensive Aufgabe aus und sendet das Ergebnis zurück an den Hauptthread. 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 nicht jugendfrei ist. Im schlimmsten Fall sendet der Hauptthread Daten, wenn der Web Worker noch nicht bereit ist und der Web Worker die Nachricht nie empfängt.

/* 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: Aufgabe wird in Web Worker ausgeführt, jedoch mit möglicherweise redundantem Laden und Kompilieren

Eine Umgehung des Problems der asynchronen Wasm-Modulinstanziierung besteht darin, das Laden, Kompilieren und Instanziieren des Wasm-Moduls in den Event-Listener zu verschieben. Dies würde jedoch bedeuten, dass dieser Vorgang für jede empfangene Nachricht ausgeführt werden muss. Mit HTTP-Caching und dem HTTP-Cache, der den kompilierten Wasm-Bytecode im Cache speichern kann, ist dies nicht die schlechteste Lösung, aber es gibt eine bessere Lösung.

Indem der asynchrone Code an den Anfang des Web Workers verschoben und nicht darauf gewartet wird, dass das Versprechen erfüllt ist, wird das Promise stattdessen in einer Variablen gespeichert. Dadurch geht das Programm sofort zum Ereignis-Listener-Teil des Codes über und es geht keine Nachricht vom Hauptthread verloren. Innerhalb des Event-Listeners kann dann das Versprechen erwartet 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 Web Worker ausgeführt und nur einmal geladen und kompiliert

Das Ergebnis der statischen Methode WebAssembly.compileStreaming() ist ein Promise, das in ein WebAssembly.Module-Objekt aufgelöst wird. Ein interessantes Feature dieses Objekts ist, dass es mit postMessage() übertragen werden kann. Das bedeutet, dass das Wasm-Modul nur einmal im Hauptthread (oder sogar von einem anderen Web Worker, der sich ausschließlich mit dem Laden und Kompilieren befasst) geladen und kompiliert und dann an den Web Worker für die CPU-intensive Aufgabe übergeben werden kann. 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 Web Worker-Seite müssen Sie nur noch das WebAssembly.Module-Objekt extrahieren und instanziieren. Da die Nachricht mit dem WebAssembly.Module nicht gestreamt wird, verwendet der Code im Web Worker jetzt WebAssembly.instantiate() anstelle der vorherigen Variante instantiateStreaming(). Das instanziierte Modul wird in einer Variablen im Cache gespeichert, sodass die Instanziierung nur einmal beim Hochfahren des Web Workers ausgeführt werden 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-Web Worker ausgeführt und nur einmal geladen und kompiliert

Selbst bei HTTP-Caching ist es teuer, den (idealerweise) im Cache gespeicherten Web Worker-Code abzurufen und das Netzwerk zu knacken. Ein häufiger Leistungstrick besteht darin, den Web Worker einzubinden und als blob:-URL zu laden. Dazu muss immer noch das kompilierte Wasm-Modul zur Instanziierung 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,
  });
});

Verzögerte oder eifrige Web Worker-Erstellung

Bisher haben alle Codebeispiele den Web Worker nach Bedarf verzögert gestartet, als auf die Schaltfläche geklickt wurde. Abhängig von Ihrer Anwendung kann es sinnvoll sein, den Web Worker engagierter zu erstellen, z. B. wenn die Anwendung inaktiv ist oder sogar als Teil des Bootstrapping-Prozesses der Anwendung. Verschieben Sie daher den Web Worker-Erstellungscode aus dem Event-Listener der Schaltfläche.

const worker = new Worker(blobURL);

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

Den Web Worker überall nutzen

Sie fragen sich vielleicht, ob Sie den Web Worker dauerhaft nutzen oder bei Bedarf neu erstellen sollten. Beide Ansätze sind möglich und haben ihre Vor- und Nachteile. Wenn Sie beispielsweise einen Web Worker dauerhaft verfügbar halten, kann dies den Speicherbedarf Ihrer Anwendung erhöhen und die Handhabung gleichzeitiger Aufgaben erschweren, da Sie Ergebnisse vom Web Worker den Anfragen zuordnen müssen. Andererseits ist der Bootstrapping-Code Ihres Web Workers möglicherweise recht komplex, sodass Sie viel Aufwand verursachen können, wenn Sie jedes Mal einen neuen erstellen. Glücklicherweise lässt sich das mit der User Timing API messen.

Bisher konnten wir anhand der Codebeispiele einen permanenten Web Worker verwenden. Im folgenden Codebeispiel wird bei Bedarf ein neuer Web Worker-Ad-hoc-Dokument erstellt. Sie müssen den Web Worker selbst beenden müssen. Das Code-Snippet überspringt die Fehlerbehandlung. Falls jedoch etwas schiefgehen sollte, beenden Sie es in jedem Fall.

/* 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 experimentieren können. Eine mit einem Ad-hoc-Web Worker (Quellcode) und eine mit einem permanenten Web Worker (Quellcode). Wenn Sie die Chrome-Entwicklertools öffnen und die Console prüfen, sehen Sie die User Timing API-Protokolle. Diese messen die Zeit, die zwischen dem Klicken auf die Schaltfläche und dem angezeigten Ergebnis auf dem Bildschirm benötigt wird. Auf dem Tab „Network“ werden die blob:-URL-Anfragen angezeigt. In diesem Beispiel beträgt der Zeitunterschied zwischen Ad-hoc und dauerhaften etwa 3-fachen Wert. In der Praxis sind beide für das menschliche Auge in diesem Fall nicht unterscheidbar. Die Ergebnisse für Ihre eigene reale App werden höchstwahrscheinlich variieren.

Factorial Wasm-Demo-App mit einem Ad-hoc-Worker Die Chrome-Entwicklertools sind geöffnet. Es gibt zwei Blobs: URL-Anfragen auf dem Tab „Network“ und in der Console werden zwei Berechnungszeitpunkte angezeigt.

Factorial Wasm-Demo-App mit einem permanenten Worker Die Chrome-Entwicklertools sind geöffnet. Es gibt nur ein Blob: die URL-Anfrage auf dem Tab „Network“ und die Konsole zeigt vier Berechnungszeitpunkte an.

Ergebnisse

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

  • Generell sollten die Streamingmethoden WebAssembly.compileStreaming() und WebAssembly.instantiateStreaming() gegenüber den Nicht-Streaming-Methoden (WebAssembly.compile() und WebAssembly.instantiate()) bevorzugt werden.
  • Wenn möglich, sollten Sie leistungsintensive Aufgaben in einem Web Worker auslagern und die Wasm-Aufgaben zum Laden und Kompilieren nur einmal außerhalb des Web Workers 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() erfolgt ist. Das bedeutet, dass die Instanz im Cache gespeichert werden kann, wenn Sie den Web Worker dauerhaft verfügbar lassen.
  • Messen Sie sorgfältig, ob es sinnvoll ist, einen permanenten Web Worker dauerhaft verfügbar zu halten oder bei Bedarf Ad-hoc-Web Worker zu erstellen. Überlegen Sie auch, wann der beste Zeitpunkt für die Erstellung des Web Workers ist. Zu berücksichtigen sind dabei der Arbeitsspeicherverbrauch, die Dauer der Web Worker-Instanziierung sowie die Komplexität der Möglichkeit, gleichzeitige Anfragen verarbeiten zu müssen.

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.