Modèles de performances WebAssembly pour les applications Web

Dans ce guide, destiné aux développeurs Web qui souhaitent bénéficier de WebAssembly, vous apprendrez à utiliser Wasm pour externaliser les tâches nécessitant une utilisation intensive du processeur à l'aide d'un exemple en cours d'exécution. Il couvre tous les sujets, des bonnes pratiques pour charger les modules Wasm à l'optimisation de leur compilation et de leur instanciation. Il aborde plus en détail le transfert des tâches nécessitant une utilisation intensive des processeurs vers des Web Workers, ainsi que les décisions d'implémentation auxquelles vous serez confronté, par exemple quand créer le Web Worker, et s'il faut le maintenir actif en permanence ou le lancer si nécessaire. Le guide développe l'approche de manière itérative et introduit un modèle de performances à la fois, jusqu'à suggérer la meilleure solution au problème.

Hypothèses

Supposons que vous souhaitiez externaliser une tâche nécessitant une utilisation intensive du processeur à WebAssembly (Wasm) pour ses performances quasiment natives. La tâche nécessitant une utilisation intensive du processeur, utilisée comme exemple dans ce guide, calcule la factorielle d'un nombre. La factorielle est le produit d'un entier et de tous les entiers inférieurs. Par exemple, la factorielle de quatre (écrit sous la forme 4!) est égale à 24 (c'est-à-dire 4 * 3 * 2 * 1). Les nombres s'agrandissent rapidement. Par exemple, 16! correspond à 2,004,189,184. Un exemple plus réaliste de tâche nécessitant une utilisation intensive du processeur peut être le scan de code-barres ou le traçage d'une image matricielle.

Une implémentation itérative (plutôt que récursive) performante d'une fonction factorial() est illustrée dans l'exemple de code suivant, écrit en C++.

#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 appelé factorial.wasm à l'aide de toutes les bonnes pratiques d'optimisation du code. Pour un rappel sur la procédure à suivre, consultez la page Appeler des fonctions C compilées à partir de JavaScript à l'aide de ccall/cwrap. La commande suivante a été utilisée pour compiler factorial.wasm en tant que fichier 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 dans JavaScript d'après 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 fait via l'API fetch(). Comme vous savez que votre application Web dépend du module Wasm pour les tâches nécessitant une utilisation intensive des processeurs, vous devez précharger le fichier Wasm dès que possible. Pour ce faire, utilisez 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 au nom provisoire appelées WebAssembly.compile() (plus WebAssembly.compileStreaming()) et WebAssembly.instantiate() pour ces tâches, mais la méthode WebAssembly.instantiateStreaming() compile et instancie un module Wasm directement à partir d'une source sous-jacente diffusée comme fetch() (pas besoin de await). Il s'agit du moyen le plus efficace et le plus optimisé de charger du code Wasm. En supposant que le module Wasm exporte une fonction factorial(), vous pouvez ensuite 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 l'exécutez sur le thread principal, avec des tâches qui utilisent beaucoup le processeur, vous risquez de bloquer l'ensemble de l'application. Une pratique courante consiste à transférer ces tâches vers un nœud de calcul Web.

Restructurer le thread principal

Pour déplacer la tâche nécessitant une utilisation intensive du processeur vers un worker Web, la première étape consiste à restructurer l'application. Le thread principal crée désormais un Worker et, en plus de cela, se contente d'envoyer l'entrée au nœud de calcul Web, 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 Web Worker, mais le code est pour adultes

Le Web Worker instancie le module Wasm et, à la réception d'un message, effectue la tâche nécessitant une utilisation intensive du 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 pour adultes. Dans le pire des cas, le thread principal envoie des données lorsque le Web Worker n'est pas encore prêt, et qu'il 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 Web Worker, mais avec un chargement et une compilation potentiellement redondants

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

En déplaçant le code asynchrone au début de Web Worker sans attendre que la promesse s'exécute, mais en stockant la promesse dans une variable, le programme passe immédiatement à la partie écouteur d'événements du code, et aucun message du thread principal ne sera perdu. Dans l'écouteur d'événements, vous pouvez alors attendre la promesse.

/* 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 ne charge et ne se compile qu'une seule fois

Le résultat de la méthode statique WebAssembly.compileStreaming() est une promesse qui se résout en WebAssembly.Module. L'un des avantages 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 worker Web purement chargé de charger et de compiler), puis être transféré au Web Worker responsable de la tâche qui utilise le processeur de manière intensive. 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 flux continu, le code du worker Web utilise désormais WebAssembly.instantiate() au lieu de la variante instantiateStreaming() précédente. Le module instancié est mis en cache dans une variable. Par conséquent, le travail d'instanciation ne doit avoir 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 worker Web intégré, et ne charge et ne se compile qu'une seule fois

Même avec la mise en cache HTTP, l'obtention (idéalement) du code Web Worker mis en cache et la possibilité d'atteindre le réseau sont coûteuses. Une astuce courante sur les performances consiste à aligner le worker Web 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 rapide d'un worker Web

Jusqu'à présent, tous les exemples de code l'ont lancé de manière différée à la demande, c'est-à-dire lorsque l'utilisateur appuie sur le bouton. En fonction de votre application, il peut être judicieux de créer le Web Worker plus hâtivement, par exemple lorsque l'application est inactive ou même dans le cadre de son processus d'amorçage. Par conséquent, déplacez le code de création 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

Vous vous demandez peut-être si vous devez conserver le Web Worker de manière permanente ou le recréer chaque fois que vous en avez besoin. Les deux approches sont possibles, et présentent leurs avantages et leurs inconvénients. Par exemple, la conservation permanente d'un worker Web peut augmenter l'espace mémoire utilisé par votre application et compliquer la gestion des tâches simultanées, car vous devez d'une manière ou d'une autre mapper les résultats provenant du Web Worker avec les requêtes. D'autre part, le code d'amorçage de votre nœud de calcul Web peut être assez complexe. Par conséquent, vous risquez d'engendrer des frais importants si vous en créez un à chaque fois. Heureusement, cela peut être mesuré avec l'API User Timing.

Jusqu'à présent, les exemples de code ont conservé un worker Web permanent. L'exemple de code suivant crée un worker Web ad hoc si nécessaire. Notez que vous devez suivre vous-même l'arrêt du Web Worker. (L'extrait de code ignore le traitement des erreurs, mais en cas de problème, veillez à y mettre fin dans tous les cas, en cas de réussite ou d'échec.)

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

Nous vous proposons deux démonstrations. 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 pour les développeurs Chrome et que vous consultez la console, vous pouvez consulter les journaux de l'API User Timing qui mesurent le temps nécessaire entre un clic sur le bouton et le résultat affiché à l'écran. L'onglet "Réseau" affiche la ou les requêtes d'URL blob:. Dans cet exemple, la différence de durée entre les traitements ponctuels et permanents est d'environ trois fois. En pratique, pour l'œil humain, les deux ne sont pas différenciables dans ce cas. Les résultats de votre propre application réelle varient très probablement.

Application de démonstration Wasm factorielle avec un worker ad hoc Les outils pour les développeurs Chrome sont ouverts. Il y a deux blobs: les requêtes d&#39;URL dans l&#39;onglet &quot;Network&quot; (Réseau) et la console affiche deux durées de calcul.

Application de démonstration Wasm factorielle avec un worker permanent Les outils pour les développeurs Chrome sont ouverts. Il n&#39;y a qu&#39;un seul blob: requête d&#39;URL dans l&#39;onglet &quot;Network&quot; (Réseau) 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 traitement par flux (WebAssembly.compileStreaming() et WebAssembly.instantiateStreaming()) à leurs équivalents hors streaming (WebAssembly.compile() et WebAssembly.instantiate()).
  • Si possible, externalisez les tâches exigeantes en performances dans un worker Web, et n'effectuez le chargement et la compilation de Wasm qu'une seule fois en dehors du worker. De cette façon, le Web Worker n'a besoin d'instancier que le module Wasm qu'il reçoit du thread principal où le chargement et la compilation ont eu lieu avec WebAssembly.instantiate(). L'instance peut donc être mise en cache si vous le conservez de manière permanente.
  • Déterminez avec soin s'il est judicieux de conserver un worker Web permanent ou de créer des Web workers ad hoc chaque fois que nécessaire. Réfléchissez également au meilleur moment pour créer le worker Web. Les éléments à prendre en compte sont la consommation de mémoire, la durée d'instanciation du Web Worker, mais également la complexité liée, le cas échéant, à la gestion des requêtes simultanées.

Si vous prenez ces modèles en compte, vous êtes sur la bonne voie pour optimiser les performances Wasm.

Remerciements

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