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: