Carga módulos de WebAssembly de manera eficiente

Cuando trabajas con WebAssembly, a menudo quieres descargar un módulo, compilarlo, crear una instancia 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 quieres descargar un módulo, compilarlo, crear una instancia y, luego, usar lo que exporta en JavaScript. Esta publicación comienza con un fragmento de código común, pero poco ó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 el baile completo de descarga, compilación y creación de instancias, aunque de forma no ó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 los búferes de más de 4 KB. Para evitar el límite de tamaño, podemos usar await WebAssembly.compile(buffer) en su lugar:

(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) aún no es el enfoque óptimo, pero hablaremos de eso en un segundo.

Casi todas las operaciones del fragmento modificado ahora son asíncronas, como lo deja claro el uso de await. 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 a la que hice referencia antes. Con la compilación en tiempo real, el navegador ya puede comenzar a compilar el módulo de 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, en especial para cargas útiles grandes.

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

Para habilitar esta optimización, usa 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 directamente la instancia de Response que muestra 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);
})();

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 de tu código, puedes pasar la promesa que muestra fetch directamente, sin await su resultado de forma 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, incluso 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.

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

(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 única, no tiene sentido mantener el objeto module, lo que simplifica 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
  • Usa APIs de transmisión para compilar y crear instancias de módulos de WebAssembly más rápido
  • No escribas código que no necesites

¡Diviértete con WebAssembly!