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 affidare in outsourcing attività che richiedono un uso intensivo della CPU con per un esempio in esecuzione. Questa guida tratta tutti gli argomenti, dalle best practice per caricamento dei moduli Wasm per ottimizzare la compilazione e la creazione di istanze. it illustra ulteriormente il trasferimento delle attività che richiedono un uso intensivo della CPU ai web worker relative all'implementazione, ad esempio quando creare il Lavoratore e se deve mantenerlo in vita permanente o ruotarlo quando necessario. La guida in modo iterativo sviluppa l'approccio e introduce un modello di prestazioni alla volta, fino a suggerire la soluzione migliore al problema.

Ipotesi

Supponi di avere un'attività ad alta intensità di CPU che vuoi affidare in outsourcing WebAssembly (Wasm) per le prestazioni quasi native. L'attività che richiede un uso intensivo della CPU utilizzato come esempio in questa guida calcola il fattoriale di un numero. La il fattoriale è il prodotto di un numero intero e di tutti i numeri interi sottostanti. Per ad esempio, il fattoriale di quattro (scritto come 4!) è uguale a 24 (ossia, 4 * 3 * 2 * 1). I numeri diventano grandi rapidamente. Ad esempio, 16! è 2,004,189,184. Un esempio più realistico di un'attività che utilizza la CPU potrebbe essere la scansione di un codice a barre o tracciamento di un'immagine raster.

Un'implementazione iterativa con prestazioni (piuttosto che ricorsiva) di un factorial() viene 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 questa funzione factorial() con Emscripten in un file denominato factorial.wasm utilizzando tutti gli best practice per l'ottimizzazione del codice. Per un ripasso su come fare, leggi Chiamata a funzioni C compilate da JavaScript utilizzando ccall/cwrap. È stato utilizzato il seguente comando per compilare factorial.wasm come Wasm standalone.

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 accoppiato a un output e un 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 creazione di istanze del modulo

Prima di poter utilizzare un modulo Wasm, devi caricarlo. Sul web, questo accade tramite fetch() tramite Google Cloud CLI o tramite l'API Compute Engine. Come sai, la tua app web dipende dal modulo Wasm per Attività che richiede molta CPU, dovresti precaricare il file Wasm il prima possibile. Tu puoi farlo con 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 o il risultato finale.

fetch('factorial.wasm');

Quindi, compila e crea un'istanza per il modulo Wasm. Ci sono nomi allettanti di funzioni chiamate WebAssembly.compile() (più WebAssembly.compileStreaming()) e WebAssembly.instantiate() per queste attività, ma WebAssembly.instantiateStreaming() compila e crea un'istanza di un modulo Wasm direttamente da un origine sottostante come fetch(), non è necessario await. Questo è il tipo di campagna più efficiente ottimizzato e ottimizzato per caricare il codice Wasm. Supponendo che il modulo Wasm esporta un funzione factorial(), potrai usarla 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à a un web worker

Se la esegui sul thread principale, con attività che consumano molta CPU, bloccando l'intera app. È prassi comune spostare queste attività in un Lavoratore.

Ristruttura del thread principale

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

Non valido: l'attività viene eseguita nel web worker, ma il codice è per adulti

Il web worker crea un'istanza del modulo Wasm e, alla ricezione di un messaggio, l'attività che richiede molta CPU e restituisce il risultato al thread principale. Il problema di questo approccio è che creare un'istanza di un modulo Wasm con WebAssembly.instantiateStreaming() è un'operazione asincrona. Ciò significa che il codice sia per adulti. Nel peggiore dei casi, il thread principale invia dati quando Il web worker non è ancora pronto e il web worker 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 al problema dell'istanza di modulo Wasm asincrona è sposta il caricamento del modulo Wasm, la compilazione e la creazione di un'istanza all'interno dell'evento ma questo significherebbe che questo lavoro deve svolgersi su ogni messaggio ricevuto. Con la memorizzazione nella cache HTTP e la cache HTTP in grado di memorizzare bytecode Wasm, questa non è la soluzione peggiore, ma c'è una soluzione migliore in molti modi diversi.

Spostando il codice asincrono all'inizio del web worker e non attendere effettivamente che la promessa venga soddisfatta, ma piuttosto conservarla in un , il programma passa immediatamente alla parte listener di eventi della e nessun messaggio del thread principale andrà perso. All'interno dell'evento ascoltatori, la promessa può essere in 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 });
});

Buona: l'attività viene eseguita nel web worker, quindi viene caricata e compilata una sola volta

Il risultato del traffico WebAssembly.compileStreaming() è una promessa che si risolve in WebAssembly.Module. Una caratteristica interessante di questo oggetto è che può essere trasferito postMessage() Ciò significa che il modulo Wasm può essere caricato e compilato solo una volta un thread (o anche un altro web worker semplicemente interessato al caricamento e alla compilazione), per poi essere trasferito al web worker responsabile dell'uso intensivo della CPU dell'attività. 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 WebAssembly.Module e crea un'istanza. Poiché il messaggio con WebAssembly.Module non è i flussi di dati, il codice nel web worker ora utilizza WebAssembly.instantiate() anziché la variante instantiateStreaming() di prima. L'istanza creata viene memorizzato nella cache in una variabile, quindi il lavoro di creazione dell'istanza deve essere eseguito una volta avviato il 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 });
});

Perfetta: l'attività viene eseguita nel web worker in linea, quindi viene caricata e compilata una sola volta

Anche con la memorizzazione nella cache HTTP, l'ottenimento (idealmente) del codice dei web worker il potenziale collegamento alla rete è costoso. Un trucco per le prestazioni comune è incorporare il web worker e caricarlo come URL blob:. Ciò richiede comunque modulo Wasm compilato da passare al web worker per la creazione di un'istanza, come del web worker e del thread principale sono diversi, anche se in base allo 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 pigri o entusiasti

Finora, tutti gli esempi di codice hanno avviato pigramente on demand il web worker, ovvero quando il pulsante è stato premuto. A seconda dell'applicazione, potrebbe essere opportuno creare il worker web con più entusiasmo, ad esempio, quando l'app è inattiva o anche quando parte del processo di bootstrap dell'app. Sposterà quindi la creazione dei worker web esterno al 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 il web worker o meno a portata di mano

Una domanda che potresti chiederti è se è il caso di mantenere il web worker o ricrearlo ogni volta che ne hai bisogno. Entrambi gli approcci possibili e presentano vantaggi e svantaggi. Ad esempio, mantenere un file Il worker permanentemente attivo può aumentare l'ingombro della tua app e rendere più difficile svolgere attività simultanee, dato che in qualche modo devi mappare i risultati provenienti dal web worker alle richieste. D'altra parte, il sistema Il codice di bootstrap del worker potrebbe essere piuttosto complesso, quindi overhead se ne crei uno nuovo ogni volta. Per fortuna puoi misura con API User Timing.

Finora gli esempi di codice hanno mantenuto un web worker permanente. Le seguenti l'esempio di codice crea un nuovo Web worker ad hoc quando necessario. Tieni presente che devi avere di tenere traccia chiusura del web worker per te. Lo snippet di codice ignora la gestione degli errori, ma nel caso in cui qualcosa vada a buon fine assicurati di terminare in ogni caso, 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

Ci sono due demo con cui sperimentare. Uno con Lavoratore web ad hoc (codice sorgente) e uno con un web worker permanente (codice sorgente). Se apri Chrome DevTools e controlla la console, puoi vedere I log dell'API Timing che misurano il tempo necessario tra il clic sul pulsante e la il risultato visualizzato sullo schermo. La scheda Rete mostra l'URL blob: richieste. In questo esempio, la differenza di tempo tra ad hoc e permanente è di circa 3 volte. In pratica, secondo l'occhio umano, entrambe sono indistinguibili in questo per verificare se è così. Molto probabilmente i risultati relativi alla tua app nella vita reale possono variare.

App demo Factorial Wasm con un lavoratore ad hoc. Gli strumenti Chrome DevTools sono aperti. Sono presenti due blob: richieste URL nella scheda Network e la console mostra due tempi di calcolo.

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

Conclusioni

Questo post ha esplorato alcuni schemi di prestazioni nell'affrontare Wasm.

  • Come regola generale, preferisci i metodi di flusso (WebAssembly.compileStreaming() e WebAssembly.instantiateStreaming()) rispetto alle controparti non in streaming (WebAssembly.compile() e WebAssembly.instantiate()).
  • Se puoi, affidati a un web worker per attività che richiedono grandi prestazioni e fai il Wasm il caricamento e la compilazione del lavoro solo una volta al di fuori del web worker. In questo modo, Il web worker deve solo creare un'istanza del modulo Wasm che riceve dall'istanza principale in cui è avvenuto il caricamento e la compilazione WebAssembly.instantiate(), il che significa che l'istanza può essere memorizzata nella cache se per mantenere il web worker sempre attivo.
  • Valuta attentamente se ha senso mantenere un web worker permanente per sempre o per creare worker web ad hoc ogni volta che ne hanno bisogno. Inoltre quando è il momento migliore per creare il web worker. Aspetti da considerare sono il consumo di memoria, la durata dell'istanza dei worker web, ma anche la complessità derivante da dover gestire richieste in parallelo.

Se prendi in considerazione questi pattern, sei sulla strada giusta per ottimizzare Prestazioni Wasm.

Ringraziamenti

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