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.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Du côté du Web Worker, il ne reste plus qu'à extraire l'objet WebAssembly.Module et à l'instancier. Étant donné que le message avec WebAssembly.Module n'est pas diffusé en streaming, le code du Web Worker utilise désormais WebAssembly.instantiate() au lieu de la variante instantiateStreaming() utilisée auparavant. Le module instancié est mis en cache dans une variable, de sorte que l'instanciation n'a lieu qu'une seule fois lors du lancement du Web Worker.

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

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

Même avec la mise en cache HTTP, l'obtention du code Web Worker (idéalement mis en cache) et l'accès potentiel au réseau sont coûteux. Une astuce courante pour améliorer les performances consiste à intégrer le Web Worker et à le charger en tant qu'URL blob:. Cela nécessite toujours que le module Wasm compilé soit transmis au Web Worker pour l'instanciation, car les contextes du Web Worker et du thread principal sont différents, même s'ils sont basés sur le même fichier source JavaScript.

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

Création différée ou immédiate de Web Workers

Jusqu'à présent, tous les exemples de code ont lancé le Web Worker de manière différée à la demande, c'est-à-dire lorsque l'utilisateur a appuyé sur le bouton. Selon votre application, il peut être judicieux de créer le Web Worker plus tôt, par exemple lorsque l'application est inactive ou même dans le cadre du processus d'amorçage de l'application. Par conséquent, déplacez le code de création du Web Worker en dehors de l'écouteur d'événements du bouton.

const worker = new Worker(blobURL);

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

Conserver ou non le Web Worker

Une question que vous pouvez vous poser est de savoir si vous devez conserver le Web Worker en permanence ou le recréer chaque fois que vous en avez besoin. Les deux approches sont possibles et présentent chacune des avantages et des inconvénients. Par exemple, le fait de conserver un Web Worker en permanence peut augmenter l'empreinte mémoire de votre application et rendre la gestion des tâches simultanées plus difficile, car vous devez d'une manière ou d'une autre mapper les résultats provenant du Web Worker aux requêtes. D'un autre côté, le code d'amorçage de votre Web Worker peut être assez complexe, ce qui peut entraîner beaucoup de surcharge si vous en créez un nouveau à chaque fois. Heureusement, vous pouvez mesurer cela avec l'API User Timing.

Jusqu'à présent, les exemples de code ont conservé un Web Worker permanent. L'exemple de code suivant crée un Web Worker ad hoc chaque fois que nécessaire. Notez que vous devez vous-même arrêter le Web Worker. (L'extrait de code ignore la gestion des erreurs, mais en cas de problème, veillez à mettre fin à l'opération dans tous les cas, qu'elle ait réussi ou échoué.)

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

Démonstrations

Deux démonstrations sont à votre disposition. L'un avec un Web Worker ad hoc (code source) et l'autre avec un Web Worker permanent (code source). Si vous ouvrez les outils de développement Chrome et que vous consultez la console, vous pouvez voir les journaux de l'API User Timing qui mesurent le temps nécessaire entre le clic sur le bouton et l'affichage du résultat à l'écran. L'onglet "Réseau" affiche les requêtes d'URL blob:. Dans cet exemple, la différence de timing entre les annonces ponctuelles et permanentes est d'environ 3x. En pratique, à l'œil nu, les deux sont indiscernables dans ce cas. Les résultats pour votre propre application réelle varieront très probablement.

Application de démonstration Factorial Wasm avec un Worker ad hoc. Les outils pour les développeurs Chrome sont ouverts. Deux requêtes d&#39;URL blob: figurent dans l&#39;onglet &quot;Réseau&quot;, et la console affiche deux durées de calcul.

Application de démonstration Factorial Wasm avec un Worker permanent. Les outils pour les développeurs Chrome sont ouverts. Il n&#39;y a qu&#39;un seul blob de requête d&#39;URL dans l&#39;onglet &quot;Réseau&quot;, et la console affiche quatre durées de calcul.

Conclusions

Cet article a exploré certains modèles de performances pour gérer Wasm.

  • En règle générale, préférez les méthodes de streaming (WebAssembly.compileStreaming() et WebAssembly.instantiateStreaming()) à leurs équivalents sans streaming (WebAssembly.compile() et WebAssembly.instantiate()).
  • Si possible, sous-traitez les tâches gourmandes en ressources dans un Web Worker et n'effectuez le chargement et la compilation Wasm qu'une seule fois en dehors du Web Worker. De cette façon, le Web Worker n'a besoin d'instancier le module Wasm qu'il reçoit du thread principal où le chargement et la compilation ont eu lieu avec WebAssembly.instantiate(), ce qui signifie que l'instance peut être mise en cache si vous conservez le Web Worker de manière permanente.
  • Mesurez soigneusement s'il est judicieux de conserver un Web Worker permanent ou de créer des Web Workers ad hoc chaque fois qu'ils sont nécessaires. Réfléchissez également au meilleur moment pour créer le Web Worker. Il faut tenir compte de la consommation de mémoire, de la durée d'instanciation du Web Worker, mais aussi de la complexité liée à la gestion éventuelle des requêtes simultanées.

Si vous tenez compte de ces modèles, vous êtes sur la bonne voie pour obtenir des performances Wasm optimales.

Remerciements

Ce guide a été examiné par Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort et Rachel Andrew.