WebAssembly-Leistungsmuster für Webanwendungen

Dieser Leitfaden richtet sich an Webentwickler, die von WebAssembly profitieren möchten, lernen Sie, wie Sie mit Wasm CPU-intensive Aufgaben mithilfe der eines laufenden Beispiels. Der Leitfaden deckt alles ab – von Best Practices für das Laden von Wasm-Modulen, um die Kompilierung und Instanziierung zu optimieren. Es behandelt die Verlagerung der CPU-intensiven Aufgaben auf Web Worker und untersucht Implementierungsentscheidungen treffen, z. B. wann das Web erstellt werden soll, und ob er dauerhaft aktiviert oder bei Bedarf hochgefahren werden soll. Die der Leitfaden entwickelt den Ansatz iterativ und führt ein Leistungsmuster ein. bis die beste Lösung für das Problem vorgeschlagen wird.

Annahmen

Angenommen, Sie haben eine sehr CPU-intensive Aufgabe, die Sie auslagern möchten. WebAssembly (Wasm) für seine nahezu native Leistung. Die CPU-intensive Aufgabe die in diesem Leitfaden als Beispiel verwendet werden, um die Fakultät einer Zahl zu berechnen. Die Fakultät ist das Produkt einer Ganzzahl und aller darunter liegenden Ganzzahlen. Für Beispiel: Die Fakultät von vier (geschrieben als 4!) ist 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 könnte sein: Scannen eines Barcodes oder Rasterbild verfolgen.

Eine performante iterative (im Gegensatz zu einer rekursiven) Implementierung einer factorial() wird im folgenden in C++ geschriebenen Codebeispiel 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;
}

}

Im weiteren Verlauf des Artikels nehmen wir an, es gibt ein Wasm-Modul, das auf der Kompilierung Diese factorial()-Funktion mit Emscripten in einer Datei namens factorial.wasm mit allen Best Practices für die Code-Optimierung. Weitere Informationen zur Vorgehensweise finden Sie unter Kompilierte C-Funktionen mit ccall/cwrap aus JavaScript aufrufen Mit dem folgenden Befehl wurde factorial.wasm kompiliert als eigenständigen Wasm verwendet.

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

In HTML gibt es einen form mit einem input, kombiniert mit einem output und einem Senden button. Auf diese Elemente wird in JavaScript über 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 passiert das über die fetch() der API erstellen. Wie Sie wissen, benötigt Ihre Webanwendung das Wasm-Modul CPU-intensive Aufgabe sollten Sie die Wasm-Datei so früh wie möglich vorab laden. Ich tun Sie dies mit einem CORS-fähiger Abruf im Bereich <head> Ihrer App.

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

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

fetch('factorial.wasm');

Kompilieren und instanziieren Sie als Nächstes das Wasm-Modul. Es gibt verlockende Namen aufgerufene Funktionen WebAssembly.compile() (plus WebAssembly.compileStreaming()) und WebAssembly.instantiate() für diese Aufgaben, sondern das WebAssembly.instantiateStreaming(). kompiliert und ein Wasm-Modul direkt aus einem gestreamten zugrunde liegende Quelle wie fetch() – kein await erforderlich. Dies ist die effizienteste und eine optimierte Methode zum Laden von Wasm-Code. Angenommen, das Wasm-Modul exportiert factorial(), 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, die gesamte App blockieren. Eine gängige Praxis ist die Verlagerung solcher Aufgaben Worker.

Umstrukturierung des Hauptthreads

Um die CPU-intensive Aufgabe in einen Web Worker zu verlagern, besteht der erste Schritt in der Neustrukturierung der Anwendung. Der Hauptthread erstellt jetzt einen Worker. Darüber hinaus geht es nur um das Senden der Eingabe an den Web Worker und den anschließenden Empfang der und Anzeige dieser Ausgabe.

/* 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 in Web Worker ausgeführt, aber der Code ist nicht jugendfrei

Der Web Worker instanziiert das Wasm-Modul und nach dem Empfang einer Nachricht die CPU-intensive Aufgabe ausführt und das Ergebnis zurück an den Hauptthread sendet. Das Problem bei diesem Ansatz ist, dass die Instanziierung eines Wasm-Moduls WebAssembly.instantiateStreaming() ist ein asynchroner Vorgang. Das bedeutet, dass der Code nicht jugendfrei ist. Im schlimmsten Fall sendet der Hauptthread Daten, Web Worker ist noch nicht bereit und der Web Worker empfängt die Nachricht nicht.

/* 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 Web Worker ausgeführt, allerdings mit möglicherweise redundantem Laden und Kompilieren.

Eine Problemumgehung für das Problem der asynchronen Instanziierung des Wasm-Moduls besteht darin, Laden, Kompilieren und Instanziieren des Wasm-Moduls in das Ereignis verschieben aber das würde bedeuten, dass diese Arbeit empfangene Nachricht. Mit HTTP-Caching und dem HTTP-Cache können kompiliertem Wasm-Bytecode entspricht, ist dies nicht die schlechteste Lösung, aber es gibt eine bessere Lösung,

Durch Verschieben des asynchronen Codes an den Anfang des Web Workers sondern darauf zu warten, dass das Versprechen erfüllt wird, sondern es in einem wird sofort mit dem Ereignis-Listener-Teil der und es gehen keine Nachrichten aus dem Hauptthread verloren. Einblicke in die Veranstaltung kann das Versprechen abwarten.

/* 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 wird nur einmal geladen und kompiliert.

Das Ergebnis der statischen WebAssembly.compileStreaming() ist ein Versprechen, das zu einer WebAssembly.Module Ein tolles Merkmal dieses Objekts ist, dass es mit postMessage() Das bedeutet, dass das Wasm-Modul nur einmal im Hauptmodul geladen und kompiliert werden kann. oder einem anderen Web Worker, der sich nur mit dem Laden und Kompilieren befasst, und dann an den Web Worker übergeben, der für die CPU-intensive . 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 müssen nur noch die WebAssembly.Module extrahiert werden. -Objekt zu erstellen und zu instanziieren. Da die Nachricht mit der WebAssembly.Module nicht verwendet, verwendet der Code im Web Worker WebAssembly.instantiate() statt der instantiateStreaming()-Variante wie zuvor. Die instanziierte Modul wird in einer Variablen zwischengespeichert, sodass die Instanziierung nur ausgeführt werden muss. beim Hochfahren des Web Workers ein.

/* 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 Inline-Web Worker ausgeführt und nur einmal geladen und kompiliert.

Auch bei HTTP-Caching ist es sinnvoll, den (idealerweise) im Cache gespeicherten Web Worker-Code abzurufen und ist potenziell teuer. Ein häufiger Trick besteht darin, Fügen Sie den Web Worker inline hinzu und laden Sie ihn als blob:-URL. Dazu ist weiterhin die kompiliertes Wasm-Modul, das zur Instanziierung an den Web Worker übergeben wird, Kontext des Web Workers und des Hauptthreads unterscheiden, auch wenn sie die 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 eifere Web Worker-Erstellung

Bisher haben alle Codebeispiele den Web Worker bei Bedarf langsam hochgefahren, wenn die Taste gedrückt wurde. Je nach Anwendung kann es sinnvoll sein, um den Web Worker zu erstellen, z. B. wenn die App inaktiv ist des Bootstrapping-Prozesses der App. Verschieben Sie daher die Web Worker-Erstellung außerhalb des Ereignis-Listeners der Schaltfläche befindet.

const worker = new Worker(blobURL);

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

Web Worker in der Nähe halten oder nicht

Möglicherweise sollten Sie sich fragen, ob Sie den Web Worker dauerhaft verfügbar zu machen oder bei Bedarf neu zu erstellen. Beide Ansätze sind und haben ihre Vor- und Nachteile. Wenn Sie z. B. ein Web die dauerhaft in der Umgebung arbeiten, gleichzeitige Aufgaben erschweren, da Sie irgendwie Ergebnisse zuordnen müssen. die vom Web Worker auf die Anfragen zurückkommen. Die Web- und Der Bootstrapping-Code der Worker ist möglicherweise ziemlich komplex, sodass unter Umständen wenn Sie jedes Mal ein neues erstellen. Zum Glück können Sie dies mit dem User Timing API.

Die Codebeispiele enthalten bisher einen Web Worker. Die folgenden im Codebeispiel wird bei Bedarf ein neuer Web Worker Ad-hoc erstellt. Sie benötigen den Überblick über Beenden des Web Workers selbst. Das Code-Snippet überspringt die Fehlerbehandlung, falsch ist, sollten Sie in jedem Fall beenden, ob bei Erfolg oder Misserfolg.)

/* 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 eines mit einer Dauerhafter Web Worker (Quellcode). Wenn Sie die Chrome-Entwicklertools öffnen und die Console aufrufen, sehen Sie Timing API-Protokolle, die die Zeit messen, die vom Klicken auf die Schaltfläche bis zum auf dem Bildschirm angezeigt wird. Auf dem Tab "Network" wird die URL "blob:" angezeigt. Anfrage(n). In diesem Beispiel ist der zeitliche Unterschied zwischen Ad-hoc- und Dauerauftrag ungefähr das 3-Fache. In der Praxis sind sie für das menschliche Auge Fall. Die Ergebnisse für Ihre eigene echte App werden wahrscheinlich anders ausfallen.

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.

Ergebnisse

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

  • In der Regel sollten Sie Streamingmethoden (WebAssembly.compileStreaming() und WebAssembly.instantiateStreaming()) im Vergleich zu den entsprechenden Dateien ohne Streaming (WebAssembly.compile() und WebAssembly.instantiate().
  • Wenn möglich, lagern Sie leistungsintensive Aufgaben in einem Web Worker aus und führen Sie den Wasm durch. nur einmal außerhalb des Web Workers laden und kompilieren. Auf diese Weise Der Web Worker muss nur das Wasm-Modul instanziieren, das er vom Hauptmodul erhält in dem das Laden und Kompilieren mit WebAssembly.instantiate() bedeutet, dass die Instanz im Cache gespeichert werden kann, wenn Sie den Web Worker dauerhaft verfügbar zu halten.
  • Prüfen Sie sorgfältig, ob es sinnvoll ist, einen dauerhaften Web Worker zu behalten. oder Ad-hoc-Web Worker erstellen, wenn sie gebraucht werden. Ebenfalls Überlegen Sie, wann der beste Zeitpunkt für die Erstellung des Web Workers ist. Wichtige Hinweise sind der Arbeitsspeicherverbrauch, die Instanziierungsdauer von Web Workern, sondern auch die Komplexität der Verarbeitung gleichzeitiger Anfragen.

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

Danksagungen

Dieser Leitfaden wurde geprüft von Andreas Haas Jakob Kummerow, Deepti Gandluri, Alon Zakai Francis McCabe, François Beaufort und Rachel Andrew.