Modèles de performances WebAssembly pour les applications Web

Ce guide s'adresse aux développeurs Web qui souhaitent bénéficier de WebAssembly. Il vous explique comment utiliser Wasm pour sous-traiter les tâches gourmandes en ressources processeur à l'aide d'un exemple pratique. Ce guide aborde tous les aspects, des bonnes pratiques pour charger les modules Wasm à l'optimisation de leur compilation et de leur instanciation. Il aborde également le transfert des tâches gourmandes en ressources processeur vers les Web Workers et examine les décisions d'implémentation auxquelles vous serez confronté, comme le moment où créer le Web Worker et s'il faut le maintenir en vie en permanence ou le lancer en cas de besoin. Le guide développe l'approche de manière itérative et présente un modèle de performances à la fois, jusqu'à suggérer la meilleure solution au problème.

Hypothèses

Supposons que vous ayez une tâche très gourmande en ressources processeur que vous souhaitez sous-traiter à WebAssembly (Wasm) pour ses performances proches de celles du natif. La tâche gourmande en ressources de processeur utilisée comme exemple dans ce guide calcule la factorielle d'un nombre. Le factoriel est le produit d'un entier et de tous les entiers inférieurs. Par exemple, le factoriel de quatre (écrit 4!) est égal à 24 (c'est-à-dire 4 * 3 * 2 * 1). Les nombres augmentent rapidement. Par exemple, 16! est 2,004,189,184. Un exemple plus réaliste de tâche nécessitant une utilisation intensive du processeur pourrait être la lecture d'un code-barres ou le traçage d'une image raster.

L'exemple de code suivant, écrit en C++, montre une implémentation itérative (plutôt que récursive) performante d'une fonction factorial().

#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;
}

}

Pour le reste de l'article, supposons qu'il existe un module Wasm basé sur la compilation de cette fonction factorial() avec Emscripten dans un fichier nommé factorial.wasm en utilisant toutes les bonnes pratiques d'optimisation du code. Pour vous rafraîchir la mémoire, consultez Appeler des fonctions C compilées depuis JavaScript à l'aide de ccall/cwrap. La commande suivante a été utilisée pour compiler factorial.wasm en tant que Wasm autonome.

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

En HTML, il existe un form avec un input associé à un output et un button d'envoi. Ces éléments sont référencés à partir de JavaScript en fonction de leur nom.

<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');

Chargement, compilation et instanciation du module

Avant de pouvoir utiliser un module Wasm, vous devez le charger. Sur le Web, cela se produit via l'API fetch(). Comme vous savez que votre application Web dépend du module Wasm pour la tâche gourmande en ressources processeur, vous devez précharger le fichier Wasm le plus tôt possible. Pour ce faire, vous devez utiliser une récupération compatible avec CORS dans la section <head> de votre application.

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

En réalité, l'API fetch() est asynchrone et vous devez await le résultat.

fetch('factorial.wasm');

Ensuite, compilez et instanciez le module Wasm. Il existe des fonctions aux noms séduisants, WebAssembly.compile() (plus WebAssembly.compileStreaming()) et WebAssembly.instantiate(), pour ces tâches. Cependant, la méthode WebAssembly.instantiateStreaming() compile et instancie un module Wasm directement à partir d'une source sous-jacente en flux continu comme fetch(), sans avoir besoin de await. Il s'agit du moyen le plus efficace et le plus optimisé de charger le code Wasm. En supposant que le module Wasm exporte une fonction factorial(), vous pouvez l'utiliser immédiatement.

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));
});

Transférer la tâche à un Web Worker

Si vous exécutez cette opération sur le thread principal, avec des tâches vraiment gourmandes en ressources de processeur, vous risquez de bloquer l'ensemble de l'application. Une pratique courante consiste à transférer ces tâches vers un Web Worker.

Restructuration du thread principal

Pour déplacer la tâche gourmande en ressources processeur vers un Web Worker, la première étape consiste à restructurer l'application. Le thread principal crée maintenant un Worker et, à part cela, ne s'occupe que d'envoyer l'entrée au Web Worker, puis de recevoir la sortie et de l'afficher.

/* 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) });
});

Mauvais : la tâche s'exécute dans le Web Worker, mais le code est sujet à des conditions de course

Le Web Worker instancie le module Wasm et, à la réception d'un message, effectue la tâche gourmande en ressources processeur et renvoie le résultat au thread principal. Le problème avec cette approche est que l'instanciation d'un module Wasm avec WebAssembly.instantiateStreaming() est une opération asynchrone. Cela signifie que le code est sujet à des conditions de course. Dans le pire des cas, le thread principal envoie des données alors que le Web Worker n'est pas encore prêt, et le Web Worker ne reçoit jamais le message.

/* 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) });
});

Mieux : la tâche s'exécute dans un Web Worker, mais avec un chargement et une compilation potentiellement redondants.

Une solution de contournement au problème d'instanciation asynchrone des modules Wasm consiste à déplacer le chargement, la compilation et l'instanciation des modules Wasm dans l'écouteur d'événements. Toutefois, cela signifierait que ce travail devrait être effectué pour chaque message reçu. Avec la mise en cache HTTP et le cache HTTP capable de mettre en cache le bytecode Wasm compilé, ce n'est pas la pire des solutions, mais il existe une meilleure façon de faire.

En déplaçant le code asynchrone au début du Web Worker et en ne l'attendant pas réellement pour que la promesse se réalise, mais en stockant plutôt la promesse dans une variable, le programme passe immédiatement à la partie du code de l'écouteur d'événements, et aucun message du thread principal ne sera perdu. Dans l'écouteur d'événements, la promesse peut ensuite être attendue.

/* 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 });
});

Bon : la tâche s'exécute dans Web Worker, et se charge et se compile une seule fois.

Le résultat de la méthode statique WebAssembly.compileStreaming() est une promesse qui se résout en un WebAssembly.Module. Une fonctionnalité intéressante de cet objet est qu'il peut être transféré à l'aide de postMessage(). Cela signifie que le module Wasm peut être chargé et compilé une seule fois dans le thread principal (ou même dans un autre Web Worker uniquement chargé du chargement et de la compilation), puis être transféré au Web Worker responsable de la tâche gourmande en ressources processeur. Le code suivant illustre ce flux.

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