Patrones de rendimiento de WebAssembly para aplicaciones web

En esta guía, dirigida a desarrolladores web que desean beneficiarse de WebAssembly, aprenderás a usar Wasm para subcontratar tareas intensivas de CPU con la ayuda de un ejemplo en ejecución. En la guía, se abarca todo, desde las prácticas recomendadas para cargar módulos Wasm hasta la optimización de su compilación y creación de instancias. Además, se analiza el traslado de las tareas intensivas de CPU a los trabajadores web y se analizan las decisiones de implementación que deberás tomar, como cuándo crear el trabajador web y si mantenerlo activo de forma permanente o iniciarlo cuando sea necesario. En la guía, se desarrolla de forma iterativa el enfoque y se presenta un patrón de rendimiento a la vez hasta sugerir la mejor solución al problema.

Suposiciones

Supongamos que tienes una tarea muy intensiva en la CPU que deseas subcontratar a WebAssembly (Wasm) por su rendimiento casi nativo. La tarea intensiva de CPU que se usa como ejemplo en esta guía calcula el factorial de un número. El factorial es el producto de un número entero y todos los números enteros debajo de él. Por ejemplo, el factorial de cuatro (escrito como 4!) es igual a 24 (es decir, 4 * 3 * 2 * 1). Los números aumentan rápidamente. Por ejemplo, 16! es 2,004,189,184. Un ejemplo más realista de una tarea con uso intensivo de CPU podría ser escanear un código de barras o realizar un seguimiento de una imagen de trama.

En la siguiente muestra de código escrita en C++, se muestra una implementación iterativa de alto rendimiento (en lugar de recurrente) de una función factorial().

#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;
}

}

En el resto del artículo, supongamos que hay un módulo Wasm basado en la compilación de esta función factorial() con Emscripten en un archivo llamado factorial.wasm con todas las prácticas recomendadas de optimización de código. Para hacer un repaso sobre cómo hacerlo, lee Cómo llamar a funciones compiladas de C desde JavaScript con ccall/cwrap. Se usó el siguiente comando para compilar factorial.wasm como Wasm independiente.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

En HTML, hay un form con un input vinculado con un output y un button de envío. JavaScript hace referencia a estos elementos en función de sus nombres.

<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');

Carga, compilación y creación de instancias del módulo

Antes de poder usar un módulo Wasm, debes cargarlo. En la Web, esto se hace a través de la API de fetch(). Como sabes que tu app web depende del módulo de Wasm para la tarea de uso intensivo de CPU, debes precargar el archivo de Wasm lo antes posible. Para ello, usa una recuperación habilitada por CORS en la sección <head> de tu app.

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

En realidad, la API de fetch() es asíncrona, por lo que debes usar await para el resultado.

fetch('factorial.wasm');

A continuación, compila y crea una instancia del módulo Wasm. Hay funciones con nombres atractivos, como WebAssembly.compile() (además de WebAssembly.compileStreaming()) y WebAssembly.instantiate(), para estas tareas, pero, en su lugar, el método WebAssembly.instantiateStreaming() compila y crea una instancia de un módulo Wasm directamente desde una fuente subyacente transmitida como fetch(), sin necesidad de await. Esta es la forma más eficiente y optimizada de cargar código Wasm. Si suponemos que el módulo Wasm exporta una función factorial(), puedes usarla de inmediato.

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

Cómo transferir la tarea a un trabajador web

Si lo ejecutas en el subproceso principal, con tareas que realmente consumen mucha CPU, corres el riesgo de bloquear toda la app. Una práctica común es transferir esas tareas a un trabajador web.

Reestructuración del subproceso principal

Para mover la tarea intensiva de CPU a un trabajador web, el primer paso es reestructurar la aplicación. El subproceso principal ahora crea un Worker y, además de eso, solo se ocupa de enviar la entrada al trabajador web y, luego, recibir la salida y mostrarla.

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

Malo: La tarea se ejecuta en el trabajador web, pero el código es inestable

El trabajador web crea una instancia del módulo Wasm y, cuando recibe un mensaje, realiza la tarea intensiva de la CPU y envía el resultado al subproceso principal. El problema de este enfoque es que la creación de una instancia de un módulo de Wasm con WebAssembly.instantiateStreaming() es una operación asíncrona. Esto significa que el código es inestable. En el peor de los casos, el subproceso principal envía datos cuando el Web Worker aún no está listo y nunca recibe el mensaje.

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

Mejor: La tarea se ejecuta en Web Worker, pero posiblemente con cargas y compilaciones redundantes

Una solución alternativa al problema de la creación de instancias de módulos Wasm asíncronos es mover la carga, la compilación y la creación de instancias del módulo Wasm al objeto de escucha de eventos, pero esto significaría que este trabajo debería realizarse en cada mensaje recibido. Con el almacenamiento en caché HTTP y la capacidad de la caché HTTP para almacenar en caché el código de bytes Wasm compilado, esta no es la peor solución, pero hay una mejor manera.

Cuando se mueve el código asíncrono al principio del trabajador web y no se espera a que se cumpla la promesa, sino que se almacena en una variable, el programa pasa de inmediato a la parte del objeto de escucha de eventos del código y no se pierde ningún mensaje del subproceso principal. Dentro del objeto de escucha de eventos, se puede esperar la promesa.

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

Bueno: La tarea se ejecuta en Web Worker y se carga y compila solo una vez.

El resultado del método estático WebAssembly.compileStreaming() es una promesa que se resuelve en WebAssembly.Module. Una buena función de este objeto es que se puede transferir con postMessage(). Esto significa que el módulo Wasm se puede cargar y compilar solo una vez en el subproceso principal (o incluso en otro trabajador web que solo se ocupe de la carga y la compilación) y, luego, transferirse al trabajador web responsable de la tarea intensiva de la CPU. El siguiente código muestra este flujo.

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

Del lado del trabajador web, solo falta extraer el objeto WebAssembly.Module y crear una instancia. Como el mensaje con WebAssembly.Module no se transmite, el código en el trabajador web ahora usa WebAssembly.instantiate() en lugar de la variante instantiateStreaming() anterior. El módulo con instanciación se almacena en caché en una variable, por lo que el trabajo de creación de instancias solo debe realizarse una vez cuando se inicia el trabajador web.

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

Excelente: La tarea se ejecuta en un trabajador web intercalado y se carga y compila solo una vez.

Incluso con el almacenamiento en caché de HTTP, obtener el código del trabajador web almacenado en caché (idealmente) y, posiblemente, acceder a la red es costoso. Un truco de rendimiento común es intercalar el trabajador web y cargarlo como una URL blob:. Esto aún requiere que el módulo Wasm compilado se pase al trabajador web para crear instancias, ya que los contextos del trabajador web y el subproceso principal son diferentes, incluso si se basan en el mismo archivo fuente de 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,
  });
});

Creación diferida o inmediata de Web Workers

Hasta ahora, todas las muestras de código activaban el trabajador web de forma diferida a pedido, es decir, cuando se presionaba el botón. Según tu aplicación, puede tener sentido crear el Web Worker con mayor anticipación, por ejemplo, cuando la app está inactiva o incluso como parte del proceso de arranque de la app. Por lo tanto, mueve el código de creación del trabajador web fuera del objeto de escucha de eventos del botón.

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

Mantener o no el trabajador web

Una pregunta que podrías hacerte es si debes mantener el trabajador web de forma permanente o volver a crearlo cada vez que lo necesites. Ambos enfoques son posibles y tienen sus ventajas y desventajas. Por ejemplo, mantener un trabajador web de forma permanente puede aumentar el espacio en memoria de tu app y dificultar el manejo de tareas simultáneas, ya que, de alguna manera, debes asignar los resultados que provienen del trabajador web a las solicitudes. Por otro lado, el código de inicialización de tu trabajador web puede ser bastante complejo, por lo que podría haber mucha sobrecarga si creas uno nuevo cada vez. Por suerte, esto es algo que puedes medir con la API de User Timing.

Hasta ahora, los ejemplos de código mantuvieron un trabajador web permanente. En la siguiente muestra de código, se crea un nuevo trabajador web ad hoc cuando sea necesario. Ten en cuenta que debes hacer un seguimiento de la terminación del trabajador web por tu cuenta. (El fragmento de código omite el control de errores, pero, en caso de que algo salga mal, asegúrate de finalizar en todos los casos, ya sea que se produzca un error o no).

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

Demostraciones

Hay dos demostraciones con las que puedes jugar. Uno con un trabajador web ad hoc (código fuente) y otro con un trabajador web permanente (código fuente). Si abres Chrome DevTools y revisas la consola, puedes ver los registros de la API de User Timing que miden el tiempo que transcurre desde que se hace clic en el botón hasta que se muestra el resultado en la pantalla. En la pestaña Red, se muestran las solicitudes de URL de blob:. En este ejemplo, la diferencia de tiempo entre los dispositivos ad hoc y permanentes es de alrededor de 3 veces. En la práctica, para el ojo humano, ambos son indistinguibles en este caso. Es probable que los resultados de tu app real varíen.

App de demostración de Factorial Wasm con un trabajador ad hoc Se abrirán las Herramientas para desarrolladores de Chrome. Hay dos BLOB: las solicitudes de URL en la pestaña Red y la consola muestran dos tiempos de cálculo.

App de demostración de Factorial Wasm con un trabajador permanente. Las Herramientas para desarrolladores de Chrome están abiertas. Solo hay un blob: la solicitud de URL en la pestaña Red, y la consola muestra cuatro tiempos de cálculo.

Conclusiones

En esta publicación, se exploraron algunos patrones de rendimiento para trabajar con Wasm.

  • Como regla general, prefiere los métodos de transmisión (WebAssembly.compileStreaming() y WebAssembly.instantiateStreaming()) en lugar de sus contrapartes que no son de transmisión (WebAssembly.compile() y WebAssembly.instantiate()).
  • Si puedes, externaliza las tareas de alto rendimiento en un Web Worker y realiza el trabajo de carga y compilación de Wasm solo una vez fuera del trabajador web. De esta manera, el trabajador web solo necesita crear una instancia del módulo Wasm que recibe del subproceso principal en el que se cargaron y compilaron con WebAssembly.instantiate(), lo que significa que la instancia se puede almacenar en caché si mantienes el trabajador web de forma permanente.
  • Mide cuidadosamente si tiene sentido mantener un trabajador web permanente para siempre o crear trabajadores web ad hoc cuando sea necesario. También piensa cuál es el mejor momento para crear el trabajador web. Entre los aspectos que debes tener en cuenta, se encuentran el consumo de memoria, la duración de la creación de instancias de Web Worker y la complejidad de tener que lidiar con solicitudes simultáneas.

Si tienes en cuenta estos patrones, estás en el camino correcto para obtener un rendimiento óptimo de Wasm.

Agradecimientos

Esta guía fue revisada por Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort y Rachel Andrew.