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 gourmandes en ressources de processeur avec à l'aide d'un exemple exécuté. Ce guide couvre tous les sujets, des bonnes pratiques le chargement des modules Wasm pour optimiser leur compilation et leur instanciation. Il approfondira le transfert des tâches exigeantes en ressources processeur vers les Web Workers, les décisions d'implémentation auxquelles vous serez confronté, par exemple pour créer le Web Nœud de calcul, et s'il faut le maintenir actif de manière permanente ou le lancer en cas de besoin. La développe l'approche de manière itérative et introduit un modèle de performances à la fois, jusqu’à ce que l’on suggère 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 proches du format natif. La tâche nécessitant une utilisation intensive du processeur utilisé à titre d'exemple dans ce guide calcule le factoriel d'un nombre. La factoriel est le produit d'un nombre entier et de tous les entiers en dessous. Pour Par exemple, la factorielle de quatre (écrite sous la forme 4!) est égale à 24 (c'est-à-dire 4 * 3 * 2 * 1). Les chiffres deviennent rapides et élevés. Par exemple, 16! est 2,004,189,184 Voici un exemple plus réaliste d'une tâche nécessitant une utilisation intensive du processeur : scanner un code-barres traçage d'une image matricielle.

Une implémentation itérative performante (plutôt que récursive) d'un factorial(). est présenté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 la suite de l'article, supposons qu'il existe un module Wasm basé sur la compilation cette fonction factorial() avec Emscripten dans un fichier nommé factorial.wasm en utilisant tous les bonnes pratiques d'optimisation du code. Pour un rappel sur la façon de procéder, consultez 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 élément form avec un élément input associé à un élément output et à un élément Submit button 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 le fetch() API. Comme vous le savez, votre application Web dépend du module Wasm pour le une tâche nécessitant une utilisation intensive du processeur, vous devez précharger le fichier Wasm dès que possible. Toi faites-le avec une Récupération compatible 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 résultat.

fetch('factorial.wasm');

Ensuite, compilez et instanciez le module Wasm. Il y a des noms appelées WebAssembly.compile() (plus WebAssembly.compileStreaming()) et WebAssembly.instantiate() pour ces tâches, mais WebAssembly.instantiateStreaming() compile et instancie un module Wasm directement à partir d'un flux source sous-jacente telle que fetch() : aucun await n'est nécessaire. C'est l'approche la plus efficace et optimisé pour charger du code Wasm. En supposant que le module Wasm exporte un 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 nœud de calcul Web

Si vous l'exécutez sur le thread principal, avec des tâches vraiment gourmandes en ressources de processeur, vous risquez bloquer toute l'application. Une pratique courante consiste à transférer ces tâches Nœud de calcul.

Restructurer le thread principal

Pour transférer une tâche nécessitant une utilisation intensive des processeurs vers un Web Worker, la première étape consiste à restructurer l'application. Le thread principal crée maintenant un Worker. En plus de cela, ne traite que de l'envoi de l'entrée au Web Worker, puis de la réception des 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) });
});

Mauvaise réponse: la tâche s'exécute dans Web Worker, mais le code est trop ancien.

Le composant 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 de 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 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 de l'instanciation du module Wasm asynchrone consiste à déplacer le chargement, la compilation et l'instanciation du module Wasm dans l'événement d'audit, mais cela signifierait que ce travail doit avoir lieu message reçu. Grâce à la mise en cache HTTP et au cache HTTP, le cache le bytecode Wasm compilé, ce n'est pas la pire solution, mais il existe une meilleure de la même façon.

En déplaçant le code asynchrone au début de Web Worker et non attendr que la promesse soit tenue, mais plutôt la stocker dans un , le programme passe immédiatement à la partie écouteur d'événements du et aucun message du fil de discussion principal ne sera perdu. Dans l'événement l'écouteur, la promesse peut alors ê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 ne se charge et n'est compilée qu'une seule fois.

Le résultat de l'analyse WebAssembly.compileStreaming() est une promesse qui se résout en WebAssembly.Module Cet objet peut être transféré à l'aide d'un câble postMessage() Le module Wasm ne peut donc être chargé et compilé qu'une seule fois dans le (ou d'un autre Web Worker uniquement dédié au chargement et à la compilation), avant d'être transférées vers le travailleur Web responsable du traitement tâche. 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é de Web Worker, il ne reste plus qu'à extraire le fichier WebAssembly.Module. et de l'instancier. Puisque le message avec WebAssembly.Module n'est pas en flux continu, le code de Web Worker WebAssembly.instantiate() au lieu de la variante instantiateStreaming() d'avant. La classe instanciée module est mis en cache dans une variable, de sorte que le travail d'instanciation ne doit avoir lieu une fois lors de la mise en route de 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 composant Web Worker intégré, et ne se charge et n'est compilée qu'une seule fois.

Même avec la mise en cache HTTP, l'obtention du code (idéalement) Web Worker en cache et potentiellement atteindre le réseau est coûteux. Une astuce courante consiste à Intégrez Web Worker et chargez-le en tant qu'URL blob:. Pour cela, il faut toujours un module Wasm compilé pour être transmis à Web Worker pour une instanciation, en tant que les contextes du nœud de calcul Web et du thread principal sont différents, même s'ils sont à partir du 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 workers Web paresseuse ou impatiente

Jusqu'à présent, tous les exemples de code ont lancé Web Worker à la demande, de manière différée, lorsque l'utilisateur a appuyé sur le bouton. Selon votre application, il peut être judicieux créer le Web Worker de manière plus hâtive, par exemple, lorsque l'application est inactive ou du processus d'amorçage de l'application. Par conséquent, déplacez la création de nœud de calcul Web code 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 le Web Worker à portée de main

Vous vous demandez peut-être si vous devez garder Web Worker en permanence, ou la recréer chaque fois que vous en avez besoin. Les deux approches sont possible et présenter leurs avantages et leurs inconvénients. Par exemple, conserver un serveur Web Travailler en permanence peut augmenter l'espace mémoire utilisé par votre appli gérer les tâches simultanées plus difficile, car vous devez, d'une manière ou d'une autre, mapper les résultats en retour aux requêtes. D'un autre côté, vos données Le code d'amorçage d'un nœud de calcul peut être assez complexe. Il peut donc y avoir si vous en créez un à chaque fois. Heureusement, vous pouvez mesurer avec API User Timing :

Jusqu'à présent, les exemples de code ont conservé un Web worker permanent. Les éléments suivants : crée un nouveau nœud de calcul Web ad hoc si nécessaire. Notez que vous devez pour suivre arrêt du Worker Web Worker vous-même. (L'extrait de code ignore la gestion des erreurs, mais si une action incorrect, assurez-vous de résilier dans tous les cas, qu'il s'agisse d'une réussite ou d'un é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

Vous avez deux versions de démonstration. Un avec un nœud de calcul Web ad hoc (code source) et l'autre avec travailleur Web permanent (code source). Si vous ouvrez les outils pour les développeurs Chrome et que vous consultez la console, vous pouvez voir Journaux de l'API Timing qui mesurent le temps écoulé entre le clic sur le bouton et l'événement résultat affiché à l'écran. L'onglet "Réseau" affiche l'URL blob: requête(s) en attente. Dans cet exemple, la différence temporelle entre ad hoc et permanentes est d’environ 3×. En pratique, à l'œil humain, ces deux-là ne sont pas différenciables . Il est très probable que les résultats de votre application réelle varieront.

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

Application de démonstration de Wasm factorielle avec un worker permanent. Les outils pour les développeurs Chrome sont ouverts. Il n&#39;y a qu&#39;un blob: requête d&#39;URL dans l&#39;onglet &quot;Network&quot; (Réseau), et la console affiche quatre calendriers de calcul.

Conclusions

Ce post explore quelques schémas de performances pour la gestion de Wasm.

  • En règle générale, préférez les méthodes de traitement par flux (WebAssembly.compileStreaming() et WebAssembly.instantiateStreaming()) par rapport à leurs homologues qui ne diffusent pas d'annonces en streaming (WebAssembly.compile() et WebAssembly.instantiate()).
  • Si possible, externalisez les tâches gourmandes en performances à un Web Worker et les tâches de chargement et de compilation ne sont effectuées qu'une seule fois en dehors de Web Worker. De cette façon, le Il suffit d'instancier le module Wasm qu'il reçoit de la thread dans lequel le chargement et la compilation ont eu lieu WebAssembly.instantiate(), ce qui signifie que l'instance peut être mise en cache si vous qui maintiennent le nœud de calcul Web en permanence à portée de main.
  • Déterminez avec soin s'il est judicieux de conserver un Web Worker permanent ou créer des Web Workers ad hoc dès qu'ils en ont besoin. Aussi à réfléchir au meilleur moment pour créer le Web Worker. Éléments à prendre en compte la consommation de mémoire, la durée d'instanciation du nœud de calcul Web, mais aussi la complexité de la nécessité de traiter des requêtes simultanées.

Si vous tenez compte de ces tendances, vous êtes sur la bonne voie pour optimiser Performances de Wasm

Remerciements

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