Patrones de rendimiento de WebAssembly para aplicaciones web

En esta guía, dirigida a desarrolladores web que desean aprovechar WebAssembly, aprenderás a usar Wasm para externalizar tareas que requieren un uso intensivo de la CPU con la ayuda de un ejemplo en ejecución. La guía 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. También se analiza cómo trasladar las tareas que consumen mucha CPU a los Web Workers y se examinan las decisiones de implementación que deberás tomar, como cuándo crear el Web Worker y si mantenerlo activo de forma permanente o activarlo cuando sea necesario. La guía desarrolla el enfoque de forma iterativa y presenta un patrón de rendimiento a la vez, hasta sugerir la mejor solución para el problema.

Suposiciones

Supongamos que tienes una tarea que requiere mucha CPU y que deseas subcontratar a WebAssembly (Wasm) por su rendimiento casi nativo. La tarea que requiere un uso intensivo de la CPU y 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 que lo preceden. Por ejemplo, el factorial de cuatro (escrito como 4!) es igual a 24 (es decir, 4 * 3 * 2 * 1). Los números crecen rápidamente. Por ejemplo, 16! es 2,004,189,184. Un ejemplo más realista de una tarea que consume muchos recursos de CPU podría ser escanear un código de barras o trazar una imagen rasterizada.

En el siguiente ejemplo de código escrito en C++, se muestra una implementación iterativa (en lugar de recursiva) de una función factorial() con un buen rendimiento.

#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, supón 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 usa todas las prácticas recomendadas de optimización de código. Para recordar cómo hacerlo, consulta Cómo llamar a funciones de C compiladas desde JavaScript con ccall/cwrap. El siguiente comando se usó 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 junto con un output y un button de envío. Se hace referencia a estos elementos desde JavaScript según 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 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 que requiere mucha CPU, debes precargar el archivo de 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 y crea una instancia del módulo de Wasm. Existen funciones con nombres tentadores llamadas 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 de 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 se supone 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));
});

Cómo transferir la tarea a un Web Worker

Si ejecutas esto 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 Web Worker.

Reestructuración del subproceso principal

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

Incorrecto: La tarea se ejecuta en Web Worker, pero el código es susceptible a condiciones de carrera

El Web Worker crea una instancia del módulo Wasm y, cuando recibe un mensaje, realiza la tarea que requiere un uso intensivo de la CPU y envía el resultado al subproceso principal. El problema con este enfoque es que la creación de instancias de un módulo de Wasm con WebAssembly.instantiateStreaming() es una operación asíncrona. Esto significa que el código es susceptible a condiciones de carrera. En el peor de los casos, el subproceso principal envía datos cuando el Web Worker aún no está listo, y el Web Worker 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 con una carga y compilación posiblemente redundantes

Una solución alternativa para el problema de la creación de instancias asíncronas del módulo Wasm es trasladar 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 bytecode de Wasm compilado, esta no es la peor solución, pero hay una mejor manera.

Si mueves el código asíncrono al comienzo del Web Worker y no esperas a que se cumpla la promesa, sino que la almacenas en una variable, el programa pasará de inmediato a la parte del código del objeto de escucha de eventos, y no se perderá 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 });
});

Correcto: La tarea se ejecuta en un 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 con 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 Web Worker que se ocupe exclusivamente de la carga y la compilación), y luego transferirse al Web Worker responsable de la tarea que requiere mucha CPU. En el siguiente código, se 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 del Web Worker, lo único que queda es extraer el objeto WebAssembly.Module y crear una instancia de él. Dado que el mensaje con WebAssembly.Module no se transmite, el código del Web Worker ahora usa WebAssembly.instantiate() en lugar de la variante instantiateStreaming() anterior. El módulo instanciado se almacena en caché en una variable, por lo que el trabajo de instanciación solo debe realizarse una vez cuando se inicia el 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 un Web Worker intercalado, y se carga y compila solo una vez.

Incluso con el almacenamiento en caché de HTTP, obtener el código de Web Worker (idealmente) almacenado en caché y, potencialmente, acceder a la red es costoso. Un truco de rendimiento común es insertar el Web Worker y cargarlo como una URL blob:. Esto aún requiere que el módulo de Wasm compilado se pase al Web Worker para su instanciación, ya que los contextos del 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 diferida o anticipada de Web Workers

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 Web Worker con mayor rapidez, 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 Web Worker 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 Web Worker

Una pregunta que te puedes hacer es si debes mantener el Web Worker de forma permanente o recrearlo cada vez que lo necesites. Ambos enfoques son posibles y tienen sus ventajas y desventajas. Por ejemplo, mantener un Web Worker de forma permanente puede aumentar la huella de memoria de tu app y dificultar el manejo de tareas simultáneas, ya que de alguna manera debes asignar los resultados que provienen del Web Worker a las solicitudes. Por otro lado, el código de arranque de tu Web Worker podría ser bastante complejo, por lo que podría haber una gran sobrecarga si creas uno nuevo cada vez. Por suerte, esto es algo que puedes medir con la API de User Timing.

Hasta ahora, las muestras de código mantuvieron un Web Worker permanente. El siguiente ejemplo de código crea un nuevo Web Worker ad hoc cuando es necesario. Ten en cuenta que debes hacer un seguimiento de la finalización del Web Worker 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 la operación se realice correctamente 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 Web Worker ad hoc (código fuente) y otro con un Web Worker 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 transcurre desde el clic en el botón hasta el resultado que se muestra en la pantalla. En la pestaña Network, se muestran las solicitudes de URL blob:. En este ejemplo, la diferencia de tiempo entre la solución ad hoc y la permanente es de aproximadamente 3 veces. En la práctica, para el ojo humano, ambas son indistinguibles en este caso. Es muy probable que los resultados de tu propia app de 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 solicitudes de URL de blob en la pestaña Red, y la consola muestra dos tiempos de cálculo.

App de demostración de Factorial Wasm con un Worker permanente. Las Herramientas para desarrolladores de Chrome están abiertas. Solo hay un BLOB: 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()) a sus contrapartes que no son de transmisión (WebAssembly.compile() y WebAssembly.instantiate()).
  • Si es posible, subcontrata las tareas que consumen mucho rendimiento en un Web Worker y realiza el trabajo de carga y compilación de Wasm solo una vez fuera del Web Worker. De esta manera, el Web Worker solo necesita crear una instancia del módulo de Wasm que recibe del subproceso principal en el que se produjo la carga y la compilación con WebAssembly.instantiate(), lo que significa que la instancia se puede almacenar en caché si mantienes el Web Worker de forma permanente.
  • Mide con cuidado si tiene sentido mantener un Web Worker permanente para siempre o crear Web Workers ad hoc cuando sean necesarios. También piensa cuándo es el mejor momento para crear el Web Worker. Entre los aspectos que se deben tener en cuenta, se encuentran el consumo de memoria, la duración de la instancia de Web Worker y la complejidad de tener que controlar solicitudes simultáneas.

Si tienes en cuenta estos patrones, estarás en el camino correcto para lograr 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.