Dans ce guide, destiné aux développeurs Web qui souhaitent bénéficier de WebAssembly, vous allez apprendre à utiliser Wasm pour externaliser les 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. Vous découvrirez plus en détail comment transférer les tâches nécessitant une utilisation intensive des processeurs vers les Web Workers, ainsi que les décisions d'implémentation auxquelles vous devrez faire face, par exemple quand créer le Web Worker, et s'il faut le maintenir actif de manière permanente ou le lancer 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 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 de tâche nécessitant une utilisation intensive du processeur pourrait consister à scanner un code-barres ou traquer 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 un rappel sur la façon de procéder, 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 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 nécessitant une utilisation intensive du processeur, vous devez précharger le fichier Wasm dès que possible. Pour ce faire, utilisez une récupère 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 utiliser await
pour 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 de la méthode la plus efficace et la plus optimisée pour 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));
});
Déplacer la tâche vers un Web Worker
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, à part 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 celui-ci 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 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 ce travail devrait être effectué 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 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 (idéalement) du code Web Worker mis en cache et éventuellement le fait d'atteindre le réseau coûte cher. 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 worker Web 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 nœud de calcul Web 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 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 nouveau nœud de calcul Web ad hoc si 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 temps 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.
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()
etWebAssembly.instantiateStreaming()
) par rapport à leurs homologues non streaming (WebAssembly.compile()
etWebAssembly.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 avec soin s'il est judicieux de garder un Web worker permanent à portée de main ou de créer des Web Workers ad hoc chaque fois que cela est nécessaire. Réfléchissez également au meilleur moment pour créer le composant Web Worker. 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.