Carga módulos de WebAssembly de manera eficiente

Cuando trabajas con WebAssembly, a menudo querrás descargar un módulo, compilarlo, crear una instancia de él y, luego, usar lo que exporta en JavaScript. En esta publicación, se explica nuestro enfoque recomendado para lograr una eficiencia óptima.

Cuando trabajas con WebAssembly, a menudo querrás descargar un módulo, compilarlo, crear una instancia de él y, luego, usar lo que exporta en JavaScript. En esta entrada, se comienza con un fragmento de código común pero subóptimo que hace exactamente eso, analiza varias optimizaciones posibles y, finalmente, muestra la forma más simple y eficiente de ejecutar WebAssembly desde JavaScript.

Este fragmento de código realiza todo el baile de descarga, compilación y creación de instancias, aunque de una manera poco óptima:

¡No lo uses!

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

Observa cómo usamos new WebAssembly.Module(buffer) para convertir un búfer de respuesta en un módulo. Esta es una API síncrona, lo que significa que bloquea el subproceso principal hasta que se completa. Para desalentar su uso, Chrome inhabilita WebAssembly.Module para búferes de más de 4 KB. Para evitar el límite de tamaño, podemos usar 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) sigue siendo el enfoque óptimo, pero abordaremos eso en un segundo.

Casi todas las operaciones en el fragmento modificado ahora son asíncronas, ya que el uso de await lo deja claro. La única excepción es new WebAssembly.Instance(module), que tiene la misma restricción de tamaño del búfer de 4 KB en Chrome. Para mantener la coherencia y mantener el subproceso principal libre, podemos usar el WebAssembly.instantiate(module) asíncrono.

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

Volvamos a la optimización de compile que sugerí anteriormente. Con la compilación de transmisión, el navegador ya puede comenzar a compilar el módulo WebAssembly mientras los bytes del módulo aún se descargan. Dado que la descarga y la compilación se realizan en paralelo, esto es más rápido, especialmente para cargas útiles grandes.

Cuando el tiempo de descarga es mayor que el tiempo de compilación del módulo WebAssembly, WebAssembly.compileStreaming() finaliza la compilación casi inmediatamente después de que se descargan los últimos bytes.

Para habilitar esta optimización, utiliza WebAssembly.compileStreaming en lugar de WebAssembly.compile. Este cambio también nos permite deshacernos del búfer de array intermedio, ya que ahora podemos pasar la instancia de Response que muestra await fetch(url) directamente.

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

La API de WebAssembly.compileStreaming también acepta una promesa que se resuelve en una instancia de Response. Si no necesitas response en otra parte del código, puedes pasar la promesa que mostró fetch directamente, sin awaitsu resultado de manera explícita:

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

Si tampoco necesitas el resultado de fetch en otro lugar, puedes pasarlo directamente:

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

Sin embargo, personalmente, creo que es más legible mantenerlo en una línea separada.

¿Ves cómo compilamos la respuesta en un módulo y, luego, creamos una instancia de inmediato? Resulta que WebAssembly.instantiate puede compilar y crear instancias de una sola vez. La API de WebAssembly.instantiateStreaming lo hace de manera transmitida:

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

Si solo necesitas una instancia, no tiene sentido mantener el objeto module, lo que simplificaría aún más el código:

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

Las optimizaciones que aplicamos se pueden resumir de la siguiente manera:

  • Usa APIs asíncronas para evitar bloquear el subproceso principal.
  • Usar las APIs de transmisión para compilar y crear instancias de módulos WebAssembly con mayor rapidez
  • No escribas código innecesario

Diviértete con WebAssembly.