Modèles de performances WebAssembly pour les applications Web

Ce guide, destiné aux développeurs Web qui souhaitent profiter de WebAssembly, vous explique comment utiliser Wasm pour externaliser des tâches gourmandes en processeur à l'aide d'un exemple en cours d'exécution. Ce guide couvre tout, des bonnes pratiques de chargement des modules Wasm à l'optimisation de leur compilation et de leur instanciation. Il aborde également le transfert des tâches gourmandes en 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 doit rester actif en permanence ou être lancé en cas de besoin. Le guide développe de manière iterative l'approche 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 externaliser vers WebAssembly (Wasm) en raison de ses performances proches des performances natives. La tâche gourmande en ressources de processeur utilisée comme exemple dans ce guide calcule la factorielle d'un nombre. Le facteuriel est le produit d'un entier et de tous les entiers inférieurs. Par exemple, le facteuriel de quatre (écrit 4!) est égal à 24 (c'est-à-dire 4 * 3 * 2 * 1). Les nombres deviennent rapidement très grands. Par exemple, 16! est 2,004,189,184. Un exemple plus réaliste d'une tâche gourmande en CPU peut être le scannage d'un code-barres ou le tracé d'une image matricielle.

L'exemple de code suivant, écrit en C++, présente une implémentation itérée (plutôt que récursive) efficace 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 appliquant toutes les bonnes pratiques d'optimisation du code. Pour rafraîchir vos connaissances, consultez la section 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 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 de soumission. 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 CPU, vous devez précharger le fichier Wasm le plus tôt possible. Pour ce faire, utilisez une récupère compatible avec le 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. Des fonctions appelées WebAssembly.compile() (plus WebAssembly.compileStreaming()) et WebAssembly.instantiate() sont disponibles pour ces tâches, mais la méthode WebAssembly.instantiateStreaming() compile et instancie un module Wasm directement à partir d'une source sous-jacente en streaming telle que fetch(). Aucune await n'est nécessaire. 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 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 vers un nœud de calcul Web

Si vous exécutez cette opération sur le thread principal, avec des tâches vraiment intensives pour le processeur, vous risquez de bloquer l'ensemble de l'application. Il est courant de transférer ces tâches vers un nœud de calcul Web.

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 désormais un Worker et, en dehors de cela, ne s'occupe que d'envoyer l'entrée au service 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 un Web Worker, mais le code est instable

Le nœud de travail Web instancie le module Wasm et, à la réception d'un message, effectue la tâche gourmande en CPU 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 risqué. Dans le pire des cas, le thread principal envoie des données lorsque le nœud de calcul Web n'est pas encore prêt, et le nœud de calcul Web 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 du module Wasm consiste à déplacer le chargement, la compilation et l'instanciation du module Wasm dans l'écouteur d'événements, mais cela signifie que cette tâche devrait être effectuée pour chaque message reçu. Avec le cache HTTP et le cache HTTP pouvant 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 du Web Worker et en n'attendant pas réellement la réalisation de la promesse, mais en la stockant dans une variable, le programme passe immédiatement à la partie de 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 un Web Worker, et ne se 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 ne peut être chargé et compilé qu'une seule fois dans le thread principal (ou même dans un autre nœud de calcul Web chargé uniquement du chargement et de la compilation), puis transféré au nœud de calcul Web responsable de la tâche gourmande en CPU. 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,
 
});
});

Côté 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 continu, le code du Web Worker utilise désormais WebAssembly.instantiate() au lieu de la variante instantiateStreaming() d'avant. Le module instancié est mis en cache dans une variable. Par conséquent, l'instanciation ne doit être effectuée qu'une seule fois lors du démarrage 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 ne se charge et ne se compile qu'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 nœud de travail Web et à le charger en tant qu'URL blob:. Pour l'instanciation, le module Wasm compilé doit toujours être transmis au nœud de calcul Web, car les contextes du nœud de calcul Web 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 de Web Workers paresseux ou impatients

Jusqu'à présent, tous les exemples de code ont lancé le Web Worker de manière paresseuse à la demande, c'est-à-dire lorsque le bouton a été enfoncé. En fonction de votre application, il peut être judicieux de créer le Web Worker plus rapidement, par exemple lorsque l'application est inactive ou même dans le cadre du processus de démarrage 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;
});

Maintenir ou non le nœud de travail Web

Vous pouvez vous demander si vous devez conserver le worker Web en permanence ou le recréer chaque fois que vous en avez besoin. Les deux approches sont possibles et présentent des avantages et des inconvénients. Par exemple, conserver un Web Worker en permanence peut augmenter l'espace mémoire de votre application et compliquer la gestion des tâches simultanées, car vous devez de toute façon mapper les résultats provenant du Web Worker sur les requêtes. D'un autre côté, le code de démarrage de votre Web Worker peut être assez complexe. Il peut donc y avoir beaucoup de frais généraux si vous en créez un nouveau à chaque fois. Heureusement, vous pouvez mesurer cela avec l'API User Timing.

Les exemples de code présentés jusqu'à présent 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 arrêter le worker Web vous-même. (L'extrait de code ignore la gestion des erreurs, mais en cas de problème, veillez à arrêter le programme dans tous les cas, que l'opération aboutisse ou non.)

/* 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 disponibles. L'une 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 consultez la console, vous pouvez voir les journaux de l'API User Timing qui mesurent le temps écoulé entre le clic sur le bouton et le résultat affiché à l'écran. L'onglet "Network" (Réseau) affiche la ou les requêtes d'URL blob:. Dans cet exemple, la différence de timing entre les deux est d'environ trois fois. En pratique, à l'œil humain, les deux sont indiscernables dans ce cas. Les résultats de votre application réelle seront probablement différents.

Application de démonstration Factorial Wasm avec un Worker ad hoc. Les outils pour les développeurs Chrome sont ouverts. Il existe deux requêtes d&#39;URL blob dans l&#39;onglet &quot;Réseau&quot;, et la console affiche deux délais 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: la requête d&#39;URL dans l&#39;onglet &quot;Réseau&quot;, et la console affiche quatre délais de calcul.

Conclusions

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

  • En règle générale, privilégiez les méthodes de streaming (WebAssembly.compileStreaming() et WebAssembly.instantiateStreaming()) par rapport à leurs homologues non streaming (WebAssembly.compile() et WebAssembly.instantiate()).
  • Si possible, externalisez les tâches lourdes en termes de performances dans un nœud de travail Web, et n'effectuez le chargement et la compilation Wasm qu'une seule fois en dehors du nœud de travail Web. De cette manière, le service worker n'a besoin que d'instancier le module Wasm qu'il reçoit du thread principal où le chargement et la compilation ont eu lieu avec WebAssembly.instantiate(). Cela signifie que l'instance peut être mise en cache si vous conservez le service worker en permanence.
  • Déterminez 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 nœud de travail Web. Les éléments à prendre en compte sont la consommation de mémoire, la durée d'instanciation du Web Worker, mais aussi la complexité de la gestion éventuelle de 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.