Pattern delle prestazioni di WebAssembly per le app web

In questa guida, rivolta agli sviluppatori web che vogliono sfruttare WebAssembly, imparerai a utilizzare Wasm per esternalizzare le attività che richiedono un utilizzo intensivo della CPU con l'aiuto di un esempio in esecuzione. La guida tratta tutti gli argomenti, dalle best practice per il caricamento dei moduli Wasm all'ottimizzazione della compilazione e dell'istanza. Inoltre, viene illustrato lo spostamento delle attività che richiedono un utilizzo elevato della CPU sui web worker e vengono esaminate le decisioni di implementazione che dovrai affrontare, ad esempio quando creare il web worker e se mantenerlo attivo in modo permanente o attivarlo quando necessario. La guida sviluppa in modo iterativo l'approccio e introduce un pattern di rendimento alla volta, fino a suggerire la soluzione migliore al problema.

Ipotesi

Supponiamo che tu abbia un'attività che richiede un utilizzo intensivo della CPU e che vuoi esternalizzare a WebAssembly (Wasm) per le sue prestazioni quasi native. L'attività che richiede un utilizzo elevato della CPU utilizzata 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 inferiori. Ad esempio, il fattoriale di quattro (scritto come 4!) è uguale a 24 (ovvero, 4 * 3 * 2 * 1). I numeri diventano grandi rapidamente. Ad esempio, 16! è 2,004,189,184. Un esempio più realistico di un'attività che richiede un uso intensivo della CPU potrebbe essere la scansione di un codice a barre o il tracciamento di un'immagine raster.

Un'implementazione iterativa (anziché ricorsiva) efficiente 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 il resto 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 farlo, leggi Chiamare 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

In HTML, è presente un form con un input accoppiato a un output e un pulsante di invio button. Questi elementi vengono referenziati da 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é sai che la tua app web dipende dal modulo Wasm per l'attività che richiede un utilizzo intensivo della CPU, devi precaricare il file Wasm il prima possibile. Puoi farlo con un recupero abilitato per CORS nella sezione <head> della tua app.

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

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

fetch('factorial.wasm');

Successivamente, compila e crea un'istanza del modulo Wasm. Esistono funzioni con nomi allettanti chiamate WebAssembly.compile() (più WebAssembly.compileStreaming()) e WebAssembly.instantiate() per queste attività, ma il metodo WebAssembly.instantiateStreaming() compila e istanzia un modulo Wasm direttamente da un'origine sottostante in streaming come fetch(), senza bisogno di await. Questo è il modo più efficiente e ottimizzato per caricare il codice Wasm. Supponendo che il modulo Wasm esporti 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 sul thread principale, con attività che richiedono un utilizzo intensivo della CPU, rischi di bloccare l'intera app. Una pratica comune è spostare queste attività in un Web Worker.

Ristrutturazione del thread principale

Per spostare l'attività che richiede un utilizzo elevato della CPU in un web worker, il primo passo è ristrutturare l'applicazione. Il thread principale ora crea un Worker e, a parte questo, si occupa solo di inviare l'input al web worker, ricevere l'output e visualizzarlo.

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

Il web worker crea un'istanza del modulo Wasm e, dopo aver ricevuto un messaggio, esegue l'attività che richiede un utilizzo elevato della CPU e invia il risultato al thread principale. Il problema di questo approccio è che l'istanza di un modulo Wasm con WebAssembly.instantiateStreaming() è un'operazione asincrona. Ciò significa che il codice è racy. Nel peggiore dei casi, il thread principale invia i 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) });
});

Migliore: l'attività viene eseguita in Web Worker, ma con caricamento e compilazione potenzialmente ridondanti

Una soluzione alternativa al problema dell'istanza asincrona del modulo Wasm è spostare il caricamento, la compilazione e l'istanza del modulo Wasm nel listener di eventi, ma ciò significherebbe che questo lavoro dovrebbe essere eseguito a ogni messaggio ricevuto. Con la memorizzazione nella cache HTTP e la cache HTTP in grado di 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 e non aspettando che la promessa venga soddisfatta, ma memorizzandola in una variabile, il programma passa immediatamente alla parte del codice relativa al listener di eventi e nessun messaggio del thread principale andrà perso. All'interno del listener di eventi, la promessa può essere attesa.

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

Buono: l'attività viene eseguita in Web Worker e viene caricata e compilata una sola volta

Il risultato del metodo statico WebAssembly.compileStreaming() è una promessa che si risolve in un WebAssembly.Module. Una delle caratteristiche più interessanti 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 in un altro web worker che si occupa esclusivamente del caricamento e della compilazione) e poi trasferito al web worker responsabile dell'attività che richiede un uso intensivo della 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 del web worker, non resta che estrarre l'oggetto WebAssembly.Module e istanziarlo. Poiché il messaggio con WebAssembly.Module non viene trasmesso in streaming, il codice nel web worker ora utilizza WebAssembly.instantiate() anziché la variante instantiateStreaming() precedente. Il modulo di cui è stata creata un'istanza viene memorizzato nella cache in una variabile, quindi l'istanza deve essere creata una sola volta all'avvio del 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 incorporato e viene caricata e compilata una sola volta

Anche con la memorizzazione nella cache HTTP, l'ottenimento del codice Web Worker (idealmente) memorizzato nella cache e il potenziale accesso alla rete sono costosi. Un trucco comune per migliorare le prestazioni è incorporare il web worker e caricarlo come URL blob:. Ciò richiede comunque che il modulo Wasm compilato venga passato al web worker per l'istanza, poiché i contesti del 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 web worker pigra o eager

Finora, tutti gli esempi di codice hanno avviato il web worker in modo differito su richiesta, ovvero quando è stato premuto il pulsante. A seconda dell'applicazione, può essere utile creare il web worker in modo più rapido, ad esempio quando l'app è inattiva o anche durante la procedura di bootstrapping dell'app. Pertanto, sposta il codice di creazione di 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;
});

Mantenere o meno il web worker

Una domanda che potresti porti è se devi mantenere il web worker in modo permanente o ricrearlo ogni volta che ne hai bisogno. Entrambi gli approcci sono possibili e presentano vantaggi e svantaggi. Ad esempio, mantenere un Web Worker in modo permanente può aumentare l'utilizzo di memoria dell'app e rendere più difficile la gestione delle attività simultanee, poiché devi mappare i risultati provenienti dal Web Worker alle richieste. D'altra parte, il codice di bootstrapping del tuo Web Worker potrebbe essere piuttosto complesso, quindi potrebbe esserci un sacco di overhead se ne crei uno nuovo ogni volta. Fortunatamente, puoi misurare questo aspetto con l'API User Timing.

Gli esempi di codice finora hanno mantenuto un Web Worker permanente. Il seguente esempio di codice crea un nuovo Web Worker ad hoc ogni volta che è necessario. Tieni presente che devi tenere traccia della terminazione del Web Worker in autonomia. (Lo snippet di codice ignora la gestione degli errori, ma in caso di problemi, assicurati di terminare in tutti i casi, 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

Puoi provare due demo. Uno con un Web Worker ad hoc (codice sorgente) e uno con un 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 di URL blob:. In questo esempio, la differenza di tempistica tra la posizione ad hoc e quella permanente è di circa tre volte. In pratica, a occhio nudo, le due posizioni sono indistinguibili in questo caso. I risultati per la tua app reale molto probabilmente varieranno.

App demo Wasm fattoriale con un worker ad hoc. Chrome DevTools è aperto. Nella scheda Rete sono presenti due richieste di URL blob e la console mostra due tempistiche di calcolo.

App demo Wasm fattoriale con un worker permanente. Chrome DevTools è aperto. È presente un solo blob: richiesta URL nella scheda Rete e la console mostra quattro tempistiche di calcolo.

Conclusioni

Questo post ha esplorato alcuni pattern di rendimento per la gestione di Wasm.

  • Come regola generale, preferisci i metodi di streaming (WebAssembly.compileStreaming() e WebAssembly.instantiateStreaming()) rispetto alle loro controparti non in streaming (WebAssembly.compile() e WebAssembly.instantiate()).
  • Se possibile, esternalizza le attività che richiedono molte risorse in un web worker ed esegui il caricamento e la compilazione di Wasm solo una volta al di fuori del web worker. In questo modo, il Web Worker deve solo istanziare il modulo Wasm che riceve dal thread principale in cui sono avvenuti il caricamento e la compilazione con WebAssembly.instantiate(), il che significa che l'istanza può essere memorizzata nella cache se mantieni il Web Worker in modo permanente.
  • Valuta attentamente se è opportuno mantenere un web worker permanente per sempre o creare web worker ad hoc ogni volta che sono necessari. Inoltre, pensa a quando è il momento migliore per creare il web worker. Gli aspetti da prendere in considerazione sono il consumo di memoria, la durata dell'istanza di Web Worker, ma anche la complessità di dover gestire richieste simultanee.

Se tieni conto di questi pattern, sei sulla strada giusta per ottenere prestazioni Wasm ottimali.

Ringraziamenti

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