Patrones de rendimiento de WebAssembly para aplicaciones web

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

Supuestos

Supongamos que tienes una tarea que requiere mucha CPU y deseas subcontratar a WebAssembly (Wasm) por su rendimiento casi nativo. La tarea con uso intensivo de CPU que se usa como ejemplo en esta guía calcula el factorial de un número. La 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 hacer 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 recursiva) 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;
}

}

Para el resto del artículo, imagina que hay un módulo de Wasm basado en la compilación de esta función factorial() con Emscripten en un archivo llamado factorial.wasm que sigue todas las prácticas recomendadas para la optimización de código. Si deseas repasar cómo hacerlo, lee Cómo llamar a funciones C compiladas desde JavaScript usando 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 de Wasm, debes cargarlo. En la Web, esto sucede a través de la API de fetch(). Como sabes que tu app web depende del módulo de Wasm para la tarea con uso intensivo de CPU, debes precargar el archivo Wasm lo antes posible. Para ello, usa una recuperación habilitada para 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 y debes await el resultado.

fetch('factorial.wasm');

A continuación, compila el módulo de Wasm y crea una instancia de él. Hay funciones con nombres tentadores llamadas WebAssembly.compile() (además de WebAssembly.compileStreaming()) y WebAssembly.instantiate() para estas tareas, pero el método WebAssembly.instantiateStreaming() compila y crea una instancia de un módulo de Wasm directamente desde una fuente subyacente transmitida como fetch(), sin await se necesita. Esta es la forma más eficiente y optimizada de cargar código de Wasm. Si suponemos que el módulo de 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));
});

Cambiar la tarea a un trabajador web

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

Reestructura del subproceso principal

Para mover la tarea con uso intensivo de CPU a un trabajador web, el primer paso es reestructurar la aplicación. El subproceso principal ahora crea un Worker y, aparte de eso, solo se ocupa de enviar la entrada al Web Worker, 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) });
});

Incorrecto: La tarea se ejecuta en Web Worker, pero el código es subido de tono

El trabajador web crea una instancia del módulo de Wasm y, cuando recibe un mensaje, realiza la tarea con uso intensivo de CPU y envía el resultado al subproceso principal. El problema con este enfoque es que crear una instancia de un módulo de Wasm con WebAssembly.instantiateStreaming() es una operación asíncrona. Esto significa que el código es subido de tono. En el peor de los casos, el subproceso principal envía datos cuando el trabajador web 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 es posible que la carga y la compilación sean redundantes.

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

Cuando se mueve el código asíncrono al comienzo del trabajador web y no se espera a que se cumpla la promesa, sino que se almacena la promesa 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 });
});

Buena: 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 un WebAssembly.Module. Una buena característica de este objeto es que se puede transferir usando postMessage(). Esto significa que el módulo de Wasm se puede cargar y compilar solo una vez en el subproceso principal (o incluso en otro trabajador web que se relaciona únicamente con la carga y la compilación) y, luego, se puede transferir al trabajador web responsable de la tarea con uso intensivo de 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,
  });
});

En el lado de Web Worker, lo único que queda es extraer el objeto WebAssembly.Module y crear una instancia de él. Como el mensaje con WebAssembly.Module no se transmite, el código en Web Worker ahora usa WebAssembly.instantiate() en lugar de la variante instantiateStreaming() de antes. El módulo con una instancia se almacena en caché en una variable, por lo que el trabajo de creación de instancias solo debe ocurrir una vez cuando se inicia Web Worker.

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

Perfecto: La tarea se ejecuta en Web Worker intercalado y se carga y compila solo una vez

Incluso con el almacenamiento en caché HTTP, la obtención (ideal) del código de Web Worker almacenado en caché y potencialmente llegar a la red es costoso. Un truco de rendimiento común es intercalar el trabajador web y cargarlo como una URL blob:. Esto requiere que el módulo compilado de Wasm se pase a Web Worker para crear una instancia, ya que los contextos de Web Worker 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 de un trabajador web diferida o inmediata

Hasta ahora, todas las muestras de código iniciaron el Web Worker de forma diferida a pedido, es decir, cuando se presionó el botón. Según tu aplicación, puede tener sentido crear el trabajador web con más anticipación, por ejemplo, cuando la app está inactiva o incluso como parte de su proceso de arranque. 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;
});

Mantén al trabajador web disponible o no

Una pregunta que podrías hacerte es si debes mantener al Trabajador web de forma permanente o recrearlo 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 trabajo con tareas simultáneas, ya que, de algún modo, es necesario asignar los resultados que provienen del trabajador web a las solicitudes. Por otro lado, el código de arranque de tu trabajador web puede ser bastante complejo, por lo que podrías generar mucha sobrecarga si creas uno nuevo cada vez. Afortunadamente, esto es algo que puedes medir con la API de User Timing.

Hasta ahora, las muestras de código mantienen un trabajador web permanente. En la siguiente muestra de código, se crea un nuevo Web Worker ad hoc siempre que sea necesario. Ten en cuenta que debes realizar un seguimiento de finalizar el Web Worker por tu cuenta. (El fragmento de código omite el manejo de errores, pero, en caso de que algo salga mal, asegúrate de finalizar en todos los casos, ya sean correctos o fallidos).

/* 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 para que juegues. Una con un Trabajador web ad hoc (código fuente) y otra con un Trabajador web permanente (código fuente). Si abres las Herramientas para desarrolladores de Chrome y revisas la consola, puedes ver los registros de la API de User Timing que miden el tiempo que tarda desde el clic en el botón hasta el resultado que se muestra en la pantalla. En la pestaña Red, se muestran las solicitudes a la URL blob:. En este ejemplo, la diferencia de tiempo entre ad hoc y permanente es de aproximadamente 3 veces. En la práctica, para el ojo humano, ambos son indistinguibles en este caso. Es muy probable que los resultados para tu propia aplicación en la vida real varíen.

App de demostración de Wasm factorial con un Worker ad hoc. Las Herramientas para desarrolladores de Chrome están abiertas. Hay dos BLOB: Las solicitudes de URL en la pestaña Red y en la consola se muestran dos tiempos de cálculo.

App de demostración de Wasm factorial con un trabajador permanente. Las Herramientas para desarrolladores de Chrome están abiertas. Solo hay un BLOB: Solicitud de URL en la pestaña Red y en la consola se muestran cuatro tiempos de cálculo.

Conclusiones

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

  • Como regla general, es preferible que utilices los métodos de transmisión (WebAssembly.compileStreaming() y WebAssembly.instantiateStreaming()) en lugar de sus equivalentes que no son de transmisión (WebAssembly.compile() y WebAssembly.instantiate()).
  • Si puedes, subcontrata a un trabajador web las tareas de alto rendimiento 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 realizó la carga y compilación con WebAssembly.instantiate(), lo que significa que la instancia se puede almacenar en caché si mantienes el sitio web de manera permanente.
  • Mide con cuidado si tiene sentido tener un Web Worker permanente para siempre o crear trabajadores web ad hoc cuando sean necesarios. Piensa también en cuándo es el mejor momento para crear el Web Worker. Los aspectos que se deben tener en cuenta son 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, vas por buen camino para lograr el 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.