Caricare i moduli WebAssembly in modo efficiente

Quando lavori con WebAssembly, spesso vuoi scaricare un modulo, compilarlo, crearne un'istanza e quindi utilizzare qualsiasi elemento che esporta in JavaScript. Questo post spiega l'approccio che consigliamo per ottimizzare l'efficienza.

Quando lavori con WebAssembly, spesso vuoi scaricare un modulo, compilarlo, crearne un'istanza e quindi utilizzare qualsiasi elemento che viene esportato in JavaScript. Questo post inizia con uno snippet di codice comune ma non ottimale che fa esattamente questo, illustra diverse possibili ottimizzazioni e infine mostra il modo più semplice ed efficiente di eseguire WebAssembly da JavaScript.

Questo snippet di codice esegue il ballo completo di download-compile-intantiate, anche se in modo non ottimale:

Non usarle!

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

Osserva come utilizziamo new WebAssembly.Module(buffer) per trasformare un buffer di risposta in un modulo. Si tratta di un'API sincrona, ovvero blocca il thread principale fino al completamento. Per scoraggiare l'uso, Chrome disattiva WebAssembly.Module per buffer superiori a 4 kB. Per aggirare il limite di dimensioni, possiamo utilizzare invece 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) è ancora non l'approccio ottimale, ma lo vedremo tra un secondo.

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

(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 compile che ho suggerito in precedenza. Con la compilazione di flussi di dati, il browser può già iniziare a compilare il modulo WebAssembly mentre è ancora in corso il download dei byte del modulo. Poiché il download e la compilazione avvengono in parallelo, questa operazione è più rapida, soprattutto per i payload di grandi dimensioni.

Se il tempo di download è maggiore di quello 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 anche di eliminare il buffer dell'array intermedio, poiché ora possiamo passare l'istanza Response restituita da await fetch(url) direttamente.

(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 tuo codice, puoi passare la promessa restituita direttamente da fetch, senza awaitesplicitarne 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 nemmeno altrove, puoi 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 su una riga separata.

Hai visto come compiliamo la risposta in un modulo e come creiamo immediatamente un'istanza? Sembra che WebAssembly.instantiate possa 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 tenere a portata di mano 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:

  • Usare API asincrone per evitare di bloccare il thread principale
  • Usa le API per i flussi di dati per compilare e creare un'istanza dei moduli WebAssembly più rapidamente
  • Non scrivere codice che non ti serve

Divertiti con WebAssembly!