Pattern delle prestazioni di WebAssembly per le app web

In questa guida, rivolta agli sviluppatori web che vogliono trarre vantaggio da WebAssembly, imparerai a utilizzare Wasm per esternalizzare le attività ad alta intensità di CPU con l'aiuto di un esempio in esecuzione. La guida tratta tutte le best practice per il caricamento dei moduli Wasm e per l'ottimizzazione della compilazione e dell'istanza. Descrive inoltre il trasferimento delle attività che richiedono molte CPU ai web worker e prende in esame le decisioni di implementazione con cui dovrà confrontarsi, ad esempio quando creare il web worker e se mantenerlo attivo o avviarlo quando necessario. La guida sviluppa l'approccio in modo iterativo e introduce un modello di prestazioni alla volta, fino a suggerire la soluzione migliore al problema.

Ipotesi

Supponi di avere un'attività che richiede un'elevata CPU e di cui vuoi eseguire l'outsourcing a WebAssembly (Wasm) per le sue prestazioni quasi native. L'attività che consuma molta CPU come esempio in questa guida calcola il fattoriale di un numero. Il fattoriale è il prodotto di un numero intero e di tutti i numeri interi sottostanti. Ad esempio, il fattoriale di quattro (scritto come 4!) è uguale a 24 (ovvero 4 * 3 * 2 * 1). I numeri diventano rapidamente grandi. Ad esempio, 16! è 2,004,189,184. Un esempio più realistico di un'attività che consuma molta CPU potrebbe essere la scansione di un codice a barre o il tracciamento di un'immagine raster.

Un'implementazione iterativa (anziché ricorrente) di una funzione factorial() è mostrata nel seguente esempio di codice scritto in 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;
}

}

Per la parte restante dell'articolo, supponiamo che esista un modulo Wasm basato sulla compilazione di questa funzione factorial() con Emscripten in un file denominato factorial.wasm utilizzando tutte le best practice per l'ottimizzazione del codice. Per un ripasso su come eseguire questa operazione, consulta Richiamare le funzioni C compilate da JavaScript utilizzando ccall/cwrap. Il seguente comando è stato utilizzato per compilare factorial.wasm come Wasm autonomo.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

Nel codice HTML, è presente un form con un input abbinato a output e un button di invio. A questi elementi viene fatto riferimento in JavaScript in base ai loro nomi.

<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');

Caricamento, compilazione e creazione di istanze del modulo

Prima di poter utilizzare un modulo Wasm, devi caricarlo. Sul web, questo avviene tramite l'API fetch(). Poiché l'app web dipende dal modulo Wasm per l'attività che richiede un uso intensivo della CPU, dovresti precaricare il file Wasm il prima possibile. Per farlo, devi usare un recupero abilitato per CORS nella sezione <head> dell'app.

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

In realtà, l'API fetch() è asincrona e devi await il risultato.

fetch('factorial.wasm');

Quindi, compila e crea un'istanza del modulo Wasm. Esistono funzioni con nome allettante chiamate WebAssembly.compile() (plus WebAssembly.compileStreaming()) e WebAssembly.instantiate() per queste attività, ma il metodo WebAssembly.instantiateStreaming() compila e crea un'istanza di un modulo Wasm direttamente da una sorgente sottostante sottoposta a flusso come fetch(), senza necessità di await. Questo è il modo più efficiente e ottimizzato per caricare il codice Wasm. Supponendo che il modulo Wasm esporta una funzione factorial(), puoi utilizzarla immediatamente.

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

Sposta l'attività in un web worker

Se esegui questa operazione nel thread principale, con attività davvero ad alta intensità di CPU, rischi di bloccare l'intera app. Una pratica comune è trasferire queste attività a un web worker.

Ristrutturazione del thread principale

Per spostare l'attività che richiede un uso intensivo della CPU a un web worker, il primo passo consiste nel ristrutturare l'applicazione. Il thread principale ora crea un Worker e, a parte questo, si occupa solo dell'invio dell'input a Web Worker, della ricezione dell'output e della visualizzazione.

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

Errato: l'attività viene eseguita in Web Worker, ma il codice è per adulti

Il web worker crea un'istanza del modulo Wasm e, alla ricezione di un messaggio, esegue l'attività che richiede molta CPU e invia il risultato al thread principale. Il problema con questo approccio è che creare l'istanza di un modulo Wasm con WebAssembly.instantiateStreaming() è un'operazione asincrona. Ciò significa che il codice è per adulti. Nel peggiore dei casi, il thread principale invia dati quando il web worker non è ancora pronto e non riceve mai il messaggio.

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

Soluzione migliore: l'attività viene eseguita in Web Worker, ma con potenzialmente un caricamento e una compilazione ridondante

Una soluzione alternativa al problema della creazione di istanze di moduli Wasm asincrona è spostare il caricamento, la compilazione e l'istanza del modulo Wasm nell'elenco degli eventi, ma ciò significa che questa operazione deve avvenire su ogni messaggio ricevuto. Poiché la memorizzazione nella cache HTTP e la cache HTTP possono memorizzare nella cache il bytecode Wasm compilato, questa non è la soluzione peggiore, ma esiste un modo migliore.

Spostando il codice asincrono all'inizio del web worker senza aspettare effettivamente che la promessa venga soddisfatta, ma archiviandola in una variabile, il programma passa immediatamente alla parte del codice del listener di eventi e nessun messaggio del thread principale andrà perso. All'interno dell'ascoltatore di eventi, potresti attendere la promessa.

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

Buona: l'attività viene eseguita in Web Worker e viene caricata e compilata solo una volta

Il risultato del metodo statico WebAssembly.compileStreaming() è una promessa che si risolve in WebAssembly.Module. Una caratteristica vantaggiosa di questo oggetto è che può essere trasferito utilizzando postMessage(). Ciò significa che il modulo Wasm può essere caricato e compilato una sola volta nel thread principale (o anche un altro web worker dedicato esclusivamente al caricamento e alla compilazione), quindi può essere trasferito al web worker responsabile delle attività che richiedono molta CPU. Il seguente codice mostra questo flusso.

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

Sul lato di Web Worker, non rimane altro che estrarre l'oggetto WebAssembly.Module e creare un'istanza. Poiché il messaggio con WebAssembly.Module non viene trasmesso in streaming, il codice nel web worker ora utilizza WebAssembly.instantiate() anziché la variante instantiateStreaming() di prima. Il modulo istanziato viene memorizzato nella cache in una variabile, quindi il lavoro di creazione di un'istanza deve avvenire solo una volta avviato 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 });
});

Perfetto: l'attività viene eseguita nel web worker in linea e viene caricata e compilata solo una volta

Anche con la memorizzazione nella cache HTTP, ottenere il codice Web Worker (preferibilmente) memorizzato nella cache e potenzialmente connettersi alla rete è costoso. Un trucco delle prestazioni comune consiste nell'incorporare il web worker e caricarlo come un URL blob:. Ciò richiede comunque che il modulo Wasm compilato venga passato al web worker per l'istanza, poiché i contesti di web worker e del thread principale sono diversi, anche se si basano sullo stesso file di origine 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,
  });
});

Creazione di un web worker lento o impaziente

Finora, tutti gli esempi di codice hanno attivato Web Worker pigramente on demand, ovvero quando è stato premuto il pulsante. A seconda della tua applicazione, può avere senso creare web worker con maggiore entusiasmo, ad esempio quando l'app è inattiva o anche durante il processo di bootstrap dell'app. Sposta quindi il codice di creazione Web Worker al di fuori del listener di eventi del pulsante.

const worker = new Worker(blobURL);

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

Tenere il web worker a portata di mano o no

Una domanda che potresti chiederti è se devi tenere sempre presente Web Worker o ricrearlo ogni volta che ne hai bisogno. Entrambi gli approcci sono possibili e presentano vantaggi e svantaggi. Ad esempio, mantenere un web worker permanente può aumentare l'utilizzo della memoria dell'app e rendere più difficile la gestione di attività simultanee, dal momento che in qualche modo devi mappare i risultati che arrivano da web worker alle richieste. D'altra parte, il codice di bootstrap di Web Worker potrebbe essere piuttosto complesso, quindi l'overhead potrebbe aumentare notevolmente, se ne crei uno nuovo ogni volta. Fortunatamente puoi misurare questo dato con l'API User Timing.

Gli esempi di codice finora hanno tenuto a portata di mano un web worker permanente. Il seguente esempio di codice crea un nuovo web worker ad hoc ogni volta che è necessario. Tieni presente che devi monitorare autonomamente l'arresto del web worker. Lo snippet di codice ignora la gestione degli errori, ma in caso di problemi, assicurati di terminare in tutti i casi, con esito positivo o negativo.

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

Demo

Sono disponibili due demo. Una con web worker ad hoc (codice sorgente) e una con web worker permanente (codice sorgente). Se apri Chrome DevTools e controlli la console, puoi visualizzare i log dell'API User Timing che misurano il tempo necessario dal clic sul pulsante al risultato visualizzato sullo schermo. La scheda Rete mostra le richieste URL blob:. In questo esempio, la differenza di tempo tra ad hoc e permanente è di circa 3 volte. In pratica, per l'occhio umano, entrambe le cose non sono distinguibili in questo caso. È molto probabile che i risultati della tua app reale possano variare.

App demo di Factorial Wasm con un worker ad hoc. Chrome DevTools sono aperti. Sono presenti due BLOB: Richieste URL nella scheda Rete e nella console vengono visualizzati due tempi di calcolo.

App demo di Factorial Wasm con un worker permanente. Chrome DevTools sono aperti. È presente un solo BLOB: la richiesta URL nella scheda Network (Rete) e la console mostra quattro tempi di calcolo.

Conclusioni

In questo post abbiamo esplorato alcuni modelli di prestazioni per la gestione di Wasm.

  • Come regola generale, preferisci i metodi di streaming (WebAssembly.compileStreaming() e WebAssembly.instantiateStreaming()) rispetto ai metodi non di streaming (WebAssembly.compile() e WebAssembly.instantiate()).
  • Se possibile, esternalizza le attività ad alto rendimento in un web worker ed esegui le operazioni di caricamento e compilazione Wasm solo una volta al di fuori di Web Worker. In questo modo, il web worker deve solo creare un'istanza del modulo Wasm che riceve dal thread principale in cui sono stati eseguiti il caricamento e la compilazione con WebAssembly.instantiate(), il che significa che l'istanza può essere memorizzata nella cache se mantieni Web Worker sempre a disposizione.
  • Misura attentamente se ha senso mantenere un web worker permanente per sempre o creare web worker ad hoc ogni volta che sono necessari. Pensa anche al momento migliore per creare web worker. Alcuni aspetti da considerare sono il consumo della memoria, la durata della creazione di istanze di web worker, ma anche la complessità di dover gestire richieste in parallelo.

Se tieni conto di questi pattern, sei sulla strada giusta per ottenere un rendimento Wasm ottimale.

Ringraziamenti

Questa guida è stata esaminata da Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort e Rachel Andrew.