Caricare i moduli WebAssembly in modo efficiente

Quando lavori con WebAssembly, spesso vuoi scaricare un modulo, compilarlo, eseguirlo e poi utilizzare tutto ciò che esporta in JavaScript. Questo post illustra il nostro approccio consigliato per un'efficienza ottimale.

Quando lavori con WebAssembly, spesso vuoi scaricare un modulo, compilarlo, eseguirlo e poi utilizzare tutto ciò che esporta in JavaScript. Questo post inizia con uno snippet di codice comune, ma non ottimale, che fa esattamente questo, discute diverse possibili ottimizzazioni e infine mostra il modo più semplice ed efficiente per eseguire WebAssembly da JavaScript.

Questo snippet di codice esegue l'intera procedura di download, compilazione e istanza, anche se in modo non ottimale:

Non utilizzare questa opzione.

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = new WebAssembly.Module(buffer);
  const instance = new WebAssembly.Instance(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Tieni presente come utilizziamo new WebAssembly.Module(buffer) per trasformare un buffer di risposta in un modulo. Si tratta di un'API sincrona, il che significa che blocca il thread principale fino al completamento. Per scoraggiarne l'utilizzo, Chrome disattiva WebAssembly.Module per i buffer più grandi di 4 KB. Per aggirare il limite di dimensioni, possiamo utilizzare await WebAssembly.compile(buffer):

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = new WebAssembly.Instance(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

await WebAssembly.compile(buffer) non è ancora l'approccio ottimale, ma lo vedremo tra un secondo.

Quasi tutte le operazioni nello snippet modificato sono ora asincrone, come è evidente dall'utilizzo di await. L'unica eccezione è new WebAssembly.Instance(module), che ha la stessa limitazione relativa alle dimensioni del buffer di 4 kB in Chrome. Per coerenza e per mantenere libero il thread principale, possiamo utilizzare l'asset asincrono WebAssembly.instantiate(module).

(async () => {
  const response = await fetch('fibonacci.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Torniamo all'ottimizzazione di compile a cui ho accennato prima. Con la compilazione dinamica, il browser può già iniziare a compilare il modulo WebAssembly mentre i byte del modulo sono ancora in fase di download. Poiché il download e la compilazione vengono eseguiti in parallelo, questa operazione è più rapida, in particolare per i payload di grandi dimensioni.

Quando il tempo di download è maggiore del tempo di compilazione del modulo WebAssembly, WebAssembly.compileStreaming() termina la compilazione quasi immediatamente dopo il download degli ultimi byte.

Per attivare questa ottimizzazione, utilizza WebAssembly.compileStreaming anziché WebAssembly.compile. Questa modifica ci consente inoltre di eliminare l'array buffer intermedio, poiché ora possiamo passare direttamente l'istanza Response restituita da await fetch(url).

(async () => {
  const response = await fetch('fibonacci.wasm');
  const module = await WebAssembly.compileStreaming(response);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

L'API WebAssembly.compileStreaming accetta anche una promessa che si risolve in un'istanza Response. Se non hai bisogno di response altrove nel codice, puoi passare direttamente la promessa rimessa da fetch, senza awaitarne esplicitamente il risultato:

(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const module = await WebAssembly.compileStreaming(fetchPromise);
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Se non hai bisogno del risultato fetch anche altrove, puoi anche passarlo direttamente:

(async () => {
  const module = await WebAssembly.compileStreaming(
    fetch('fibonacci.wasm'));
  const instance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Personalmente, però, trovo più leggibile lasciarlo in una riga separata.

Hai visto come compiliamo la risposta in un modulo e poi la istanziamo immediatamente? È emerso che WebAssembly.instantiate può compilare e creare un'istanza in una volta sola. L'API WebAssembly.instantiateStreaming esegue questa operazione in modalità flusso:

(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const { module, instance } = await WebAssembly.instantiateStreaming(fetchPromise);
  // To create a new instance later:
  const otherInstance = await WebAssembly.instantiate(module);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Se hai bisogno di una sola istanza, non ha senso mantenere l'oggetto module, semplificando ulteriormente il codice:

// This is our recommended way of loading WebAssembly.
(async () => {
  const fetchPromise = fetch('fibonacci.wasm');
  const { instance } = await WebAssembly.instantiateStreaming(fetchPromise);
  const result = instance.exports.fibonacci(42);
  console.log(result);
})();

Le ottimizzazioni che abbiamo applicato possono essere riassunte come segue:

  • Utilizza API asincrone per evitare di bloccare il thread principale
  • Utilizza le API di streaming per compilare e creare istanze dei moduli WebAssembly più rapidamente
  • Non scrivere codice non necessario

Buon divertimento con WebAssembly.