Pattern delle prestazioni di WebAssembly per le app web

In questa guida, rivolta agli sviluppatori web che vogliono usufruire di WebAssembly, imparerai a utilizzare Wasm per esternalizzare le attività che richiedono un uso intensivo della CPU con l'aiuto di un esempio in esecuzione. La guida tratta tutti gli aspetti, dalle best practice per il caricamento dei moduli Wasm all'ottimizzazione della compilazione e della creazione di istanze. Inoltre, discute lo spostamento delle attività che richiedono un'intensa utilizzo della CPU nei web worker e analizza le decisioni di implementazione che dovrai prendere, ad esempio quando creare il web worker e se mantenerlo attivo in modo permanente o avviarlo quando necessario. La guida sviluppa in modo iterativo l'approccio e introduce un modello di rendimento alla volta, fino a suggerire la soluzione migliore al problema.

Ipotesi

Supponiamo che tu abbia un'attività molto incentrata sulla CPU che vuoi esternalizzare a WebAssembly (Wasm) per le sue prestazioni quasi native. L'attività ad alta intensità di CPU usata come esempio in questa guida calcola il fattoriale di un numero. Il fatoriale è il prodotto di un numero intero e di tutti i numeri interi al di sotto. 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 molta CPU potrebbe essere la scansione di un codice a barre o il tracciamento di un'immagine raster.

Un'implementazione iterativa (anziché ricorsiva) 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 chiamato factorial.wasm utilizzando tutte le best practice per l'ottimizzazione del codice. Per un ripasso su come eseguire questa operazione, consulta Chiamata di funzioni C compilate da JavaScript utilizzando ccall/cwrap. Per compilare factorial.wasm come Wasm autonomo è stato utilizzato il seguente comando.

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. In JavaScript viene fatto riferimento a questi elementi 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 istanziazione 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'elevata intensità di risorse della CPU, devi precaricare il file Wasm il prima possibile. Puoi farlo con un recupero abilitato 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');

Quindi, compila e crea un'istanza per il modulo Wasm. Esistono funzioni con nomi allettanti chiamate WebAssembly.compile() (oltre a WebAssembly.compileStreaming()) e WebAssembly.instantiate() per queste attività, ma il metodo WebAssembly.instantiateStreaming() compila e esegue l'istanza di un modulo Wasm direttamente da un'origine di base in streaming come fetch(), senza bisogno di await. Si tratta del modo più efficiente e ottimizzato per caricare il codice Wasm. Supponendo che il modulo Wasm esegua l'esportazione di 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));
});

Spostare l'attività in un Web Worker

Se lo esegui sul thread principale, con attività che richiedono un uso intensivo della CPU, rischi di bloccare l'intera app. Una pratica comune è quella di trasferire queste attività a un web worker.

Ristrutturazione del thread principale

Per spostare l'attività che richiede un uso intensivo della CPU in un web worker, il primo passaggio consiste nel ristrutturare l'applicazione. Ora il thread principale crea un Worker e, a parte questo, si occupa solo di inviare l'input al web worker, quindi di ricevere l'output e di 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) });
});

Cattivo: l'attività viene eseguita in Web Worker, ma il codice è instabile

Il Web Worker esegue l'inizializzazione del modulo Wasm e, al ricevimento di un messaggio, esegue l'attività ad alta intensità di CPU e restituisce il risultato al thread principale. Il problema di questo approccio è che l'inizializzazione di un modulo Wasm con WebAssembly.instantiateStreaming() è un'operazione asincrona. Ciò significa che il codice è instabile. Nel peggiore dei casi, il thread principale invia i dati quando il worker web non è ancora pronto e il worker web 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 nel 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 la creazione di un'istanza del modulo Wasm nell'ascoltatore di eventi, ma ciò significherebbe che questo lavoro deve essere eseguito su 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 peggiore delle soluzioni, ma esiste un modo migliore.

Se sposti il codice asincrono all'inizio del web worker e non aspetti che la promessa venga soddisfatta, ma la memorizzi in una variabile, il programma passa immediatamente alla parte del codice relativa all'ascoltatore di eventi e nessun messaggio del thread principale andrà perso. All'interno dell'ascoltatore dell'evento, c'è quindi in attesa 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 });
});

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

Il risultato del metodo WebAssembly.compileStreaming() statico è una promessa che si risolve in un WebAssembly.Module. Una caratteristica interessante 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 dedicato esclusivamente al caricamento e alla compilazione) e poi trasferito al web worker responsabile dell'attività che richiede un'elevata intensità di risorse 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 dei web worker, non rimane che estrarre l'oggetto WebAssembly.Module e crearne un'istanza. Poiché il messaggio con WebAssembly.Module non viene eseguito in streaming, il codice nel web worker ora utilizza WebAssembly.instantiate() anziché la variante instantiateStreaming() precedente. Il modulo sottoposto a istanza viene memorizzato nella cache in una variabile, pertanto l'operazione di istanza deve essere eseguita solo una 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 in un web worker in linea e viene caricata e compilata una sola volta

Anche con la memorizzazione nella cache HTTP, ottenere il codice del web worker (idealmente) memorizzato nella cache e potenzialmente accedere alla rete è costoso. Un trucco delle prestazioni comune è 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'instanziazione, poiché i contesti del web worker e del thread principale sono diversi, anche se si basano sullo stesso file sorgente 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 worker web lazy o eager

Finora, tutti gli esempi di codice hanno avviato il web worker in modo lazy on demand, ovvero quando è stato premuto il pulsante. A seconda dell'applicazione, può avere senso creare il web worker con più entusiasmo, ad esempio quando l'app è inattiva o anche nell'ambito del processo di bootstrap dell'app. Pertanto, sposta il codice di creazione del worker web al di fuori dell'event listener del pulsante.

const worker = new Worker(blobURL);

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

Mantenere il web worker o meno a portata di mano

Una domanda che potresti chiederti è se è il caso di mantenere il web worker sempre a portata di mano o di ricrearlo ogni volta che ne hai bisogno. Entrambi gli approcci sono possibili e presentano vantaggi e svantaggi. Ad esempio, mantenere un Web Worker attivo in modo permanente potrebbe aumentare l'impronta di memoria dell'app e complicare il trattamento delle attività concorrenti, poiché in qualche modo devi mappare i risultati provenienti dal Web Worker alle richieste. D'altra parte, il codice di bootstrap del tuo Web Worker potrebbe essere piuttosto complesso, quindi potrebbe esserci un overhead elevato se ne crei uno nuovo ogni volta. Fortunatamente, si tratta di un valore che puoi misurare con l'API User Timing.

Finora gli esempi di codice hanno mantenuto un web worker permanente. Il seguente codice di esempio crea un nuovo Web Worker ad hoc ogni volta che è necessario. Tieni presente che devi monitorare la terminazione del worker web. Lo snippet di codice salta la gestione degli errori, ma se si verifica un problema, assicurati di terminare in tutti i casi, indipendentemente dall'esito.

/* 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 la console di Chrome e controlli la console, puoi vedere i log dell'API UserTiming che misurano il tempo che intercorre tra il clic sul pulsante e il risultato visualizzato sullo schermo. Nella scheda Rete vengono mostrate le richieste URL di blob:. In questo esempio, la differenza di tempo tra le campagne ad hoc e quelle permanenti è di circa tre volte. In pratica, per l'occhio umano, in questo caso entrambe sono indistinguibili. I risultati per la tua app reale molto probabilmente varieranno.

App demo Factorial Wasm con un worker ad hoc. Chrome DevTools sono aperti. Esistono due blob: le richieste di URL nella scheda Rete e la console mostra due tempistiche di calcolo.

App demo Factorial Wasm con un worker permanente. Gli strumenti Chrome DevTools sono aperti. Nella scheda Rete è presente un solo blob: richiesta URL e la console mostra quattro tempistiche di calcolo.

Conclusioni

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

  • Come regola generale, preferisci i metodi di streaming (WebAssembly.compileStreaming() e WebAssembly.instantiateStreaming()) rispetto alle relative controparti non in streaming (WebAssembly.compile() e WebAssembly.instantiate()).
  • Se puoi, esternalizza le attività che richiedono grandi prestazioni 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 worker web deve solo istanziare il 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 il worker web in modo permanente.
  • Valuta attentamente se ha senso mantenere un Web Worker permanente o creare Web Worker ad hoc ogni volta che sono necessari. Inoltre, pensa al momento migliore per creare il web worker. Gli aspetti da prendere in considerazione sono il consumo di memoria, la durata dell'istanziazione del worker web, ma anche la complessità di dover gestire eventuali richieste concorrenti.

Se prendi in considerazione questi pattern, sei sulla strada giusta per ottenere un rendimento ottimale di Wasm.

Ringraziamenti

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