Patrones de rendimiento de WebAssembly para aplicaciones web

En esta guía, dirigida a desarrolladores web que quieren beneficiarse de WebAssembly, Aprenderás a usar Wasm para externalizar tareas que consumen mucha CPU con el ayuda de un ejemplo de carrera. La guía abarca todo, desde las prácticas recomendadas cargando módulos de Wasm para optimizar su compilación y creación de instancias. Integra analiza en más detalle cómo pasar las tareas que consumen mucha CPU a Web Workers y analiza de implementación a las que te enfrentarás, como cuándo crear la Web Worker y si quieres mantenerlo activo de forma permanente o iniciarlo cuando sea necesario. El desarrolla de forma iterativa el enfoque y presenta un patrón de rendimiento hasta que se sugiera la mejor solución al problema.

Suposiciones

Supongamos que tienes una tarea con un uso intensivo de CPU que quieres subcontratar WebAssembly (Wasm) por su rendimiento casi nativo. La tarea con uso intensivo de CPU usado 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 este. Para Por ejemplo, el factorial de cuatro (escrito como 4!) es igual a 24 (es decir, 4 * 3 * 2 * 1). Las cifras 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 el seguimiento de una imagen de trama.

Una implementación iterativa y eficaz (en lugar de recursiva) de un factorial() función se muestra en la siguiente muestra de código escrita en C++.

#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 esta función factorial() con Emscripten en un archivo llamado factorial.wasm utilizando todas prácticas recomendadas para la optimización de código. Para repasar cómo hacerlo, lee Llama a funciones C compiladas 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 envío button 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 que puedas usar un módulo de Wasm, debes cargarlo. Esto sucede en la Web mediante la fetch() en la API de Cloud. Como sabes, tu app web depende del módulo de Wasm para el con mucha CPU, debes cargar previamente el archivo de Wasm lo antes posible. Tú haz esto con un Recuperación habilitada para CORS en la sección <head> de la app.

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

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

fetch('factorial.wasm');

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

Traslada la tarea a Web Worker

Si lo ejecutas en el subproceso principal, con tareas que hacen un uso intensivo de la CPU, corres el riesgo bloqueando toda la app. Una práctica común es trasladar estas tareas a un Trabajador.

Reestructuración del subproceso principal

Para trasladar la tarea que consume mucha CPU a un Web Worker, el primer paso es reestructurar la aplicación. El subproceso principal ahora crea un Worker y, además de eso, solo se encarga de enviar la entrada al Web Worker y, luego, recibir la salida y la muestra.

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

Mala: 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, al recibir un mensaje, realiza la tarea con uso intensivo de CPU y envía el resultado al subproceso principal. El problema de este enfoque es que crear una instancia de un módulo de Wasm con WebAssembly.instantiateStreaming() es una operación asíncrona. Esto significa de 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 posiblemente con cargas y compilaciones redundantes

Una solución al problema de la creación asíncrona de instancias del módulo Wasm es mover la carga, la compilación y la creación de instancias del módulo de Wasm al evento oyente, pero esto significaría que este trabajo tendría que ocurrir en cada mensaje recibido. Gracias al almacenamiento en caché de HTTP y a la caché HTTP, el código de bytes de Wasm compilado, esta no es la peor solución, pero hay una mejor de una nueva manera.

Con el traslado del código asíncrono al comienzo del trabajador web y no en realidad esperando a que se cumpla la promesa, sino almacenarla en un variable, el programa pasa inmediatamente a la parte del objeto de escucha de eventos del código, y no se perderá ningún mensaje del subproceso principal. Interior del evento objeto de escucha, 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: Task se ejecuta en Web Worker y se carga y compila solo una vez

El resultado de la imagen estática WebAssembly.compileStreaming() método es una promesa que se resuelve en un WebAssembly.Module Una buena función de este objeto es que se puede transferir postMessage() Esto significa que el módulo de Wasm se puede cargar y compilar solo una vez en la subproceso (o incluso otro trabajador web que solo esté relacionado con la carga y compilación), y, luego, al trabajador web responsable de las tareas tarea. 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 WebAssembly.Module. y crear una instancia de él. Dado que el mensaje con WebAssembly.Module no es se transmite, el código de Web Worker ahora usa WebAssembly.instantiate() en lugar de la variante instantiateStreaming() de antes. La instancia módulo se almacena en caché en una variable, por lo que el trabajo de creación de instancias solo tiene que ocurrir alguna vez al iniciar 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 });
});

Perfecta: La tarea se ejecuta en un Web Worker intercalado, y se carga y compila solo una vez

Incluso con el almacenamiento en caché HTTP, obtener el código de Web Worker (idealmente) almacenado en caché y lo que podría ser costoso. Un truco de rendimiento común es intercalar el trabajador web y cargarlo como una URL blob: Esto todavía requiere compilado de Wasm para pasarlo a Web Worker para crear una instancia, como contextos de Web Worker y el subproceso principal son diferentes, incluso si basado en el mismo archivo fuente 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 entusiasmada de Web Worker

Hasta ahora, todas las muestras de código iniciaron el Web Worker de manera diferida a pedido, es decir, cuando se presionó el botón. Según tu aplicación, puede tener sentido crear el trabajador web con mayor entusiasmo, por ejemplo, cuando la aplicación está inactiva o incluso cuando parte del proceso de arranque de la app. Por lo tanto, mueve la creación de 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;
});

Mantén al trabajador web disponible o no

Una pregunta que podrías hacerte es si deberías mantener Web Worker de manera permanente o volver a crearla cuando la necesites. Ambos enfoques son y tienen sus ventajas y desventajas. Por ejemplo, mantener una página web Un trabajador permanente puede aumentar el espacio en memoria de tu app y hacer lidiar con las tareas simultáneas con mayor dificultad, ya que, de alguna manera, necesitas asignar los resultados que provienen del trabajador web de vuelta a las solicitudes. Por otro lado, tu Web El código de arranque de Worker puede ser bastante complejo, por lo que podría haber si creas una nueva cada vez. Por suerte, esto es algo que puedes medir con el API de User Timing.

Las muestras de código hasta ahora mantuvieron un Web Worker permanente cerca. Lo siguiente de código de muestra crea un nuevo trabajador web ad hoc siempre que sea necesario. Ten en cuenta que necesitas para hacer un seguimiento de cómo finalizar Web Worker tú mismo. (El fragmento de código omite el manejo de errores, pero, en caso de que algo salga, incorrecta, asegúrate de resolver en todos los casos, ya sea con éxito o con fracaso).

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

App de demostración de Wasm factorial con un trabajador ad hoc. Las Herramientas para desarrolladores de Chrome están abiertas. 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. Hay solo un BLOB: la solicitud de URL en la pestaña Red y en la consola muestra cuatro tiempos de cálculo.

Conclusiones

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

  • Como regla general, es preferible usar los métodos de transmisión (WebAssembly.compileStreaming() y WebAssembly.instantiateStreaming()) en comparación con sus contrapartes que no transmiten (WebAssembly.compile() y WebAssembly.instantiate()).
  • Si puedes, externaliza las tareas de alto rendimiento en un Web Worker y realiza las tareas de Wasm cargar y compilar el trabajo solo una vez fuera del trabajador web De esta manera, Web Worker solo necesita crear una instancia del módulo de Wasm que recibe desde la aplicación principal subproceso en el que se produjo la carga y compilación WebAssembly.instantiate(), lo que significa que la instancia puede almacenarse en caché si para mantener al trabajador web de forma permanente.
  • Mida con cuidado si tiene sentido mantener un trabajador web permanente para siempre, o para crear trabajadores web ad hoc cuando sea necesario. También pensar cuándo es el mejor momento para crear el Web Worker. Aspectos que se deben tener en cuenta son el consumo de memoria, la duración de la creación de instancias de Web Worker pero también la complejidad de tener que lidiar con solicitudes simultáneas.

Si tienes en cuenta estos patrones, vas por buen camino hacia la optimización Rendimiento 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.