Padrões de desempenho do WebAssembly para apps da Web

Neste guia, é destinado a desenvolvedores Web que querem se beneficiar do WebAssembly, você aprenderá a usar o Wasm para terceirizar tarefas que consomem muita CPU com o ajuda de um exemplo em execução. O guia aborda tudo, desde as práticas recomendadas para carregar módulos Wasm para otimizar a compilação e a instanciação. Ela discute a transferência de tarefas com uso intensivo de CPU para Web Workers e examina decisões de implementação que você terá que enfrentar, como quando criar a Web Worker e se ele deve ficar permanentemente ativo ou ativá-lo quando necessário. A desenvolve de forma iterativa a abordagem e introduz um padrão de desempenho por vez, até sugerir a melhor solução para o problema.

Suposições

Suponha que você tenha uma tarefa com uso intenso de CPU que queira terceirizar para WebAssembly (Wasm) pelo desempenho próximo ao nativo. A tarefa com uso intensivo da CPU neste guia calcula o fatorial de um número. A fatorial é o produto de um número inteiro e todos os números inteiros abaixo dele. Para exemplo, o fatorial de quatro (escrito como 4!) é igual a 24 (ou seja, 4 * 3 * 2 * 1). Os números aumentam rapidamente. Por exemplo, 16! é 2,004,189,184. Um exemplo mais realista de uma tarefa com uso intenso de CPU seria como ler um código de barras ou fazer o rastreamento de uma imagem raster.

Uma implementação iterativa de alta performance (em vez de recursiva) de um factorial() é mostrada no exemplo de código a seguir escrito em 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 o restante do artigo, vamos supor que há um módulo Wasm baseado na compilação esta função factorial() com Emscripten em um arquivo chamado factorial.wasm usando todas práticas recomendadas de otimização de código. Para relembrar como fazer isso, leia Como chamar funções C compiladas no JavaScript usando ccall/cwrap. O comando a seguir foi usado para compilar factorial.wasm como Wasm autônomo (em inglês).

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

Em HTML, há um form com um input pareado com um output e um envio. button. Esses elementos são referenciados no JavaScript com base nos nomes.

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

Carregamento, compilação e instanciação do módulo

Antes de poder usar um módulo Wasm, você precisa carregá-lo. Na Web, isso acontece através do fetch() API. Como você sabe que seu app da Web depende do módulo Wasm para o tarefa que consome muita CPU, você deve pré-carregar o arquivo Wasm o mais cedo possível. Você fazer isso com um Busca com CORS ativado na seção <head> do app.

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

Na realidade, a API fetch() é assíncrona, e você precisa await (em inglês) resultado.

fetch('factorial.wasm');

Em seguida, compile e instancie o módulo Wasm. Existem tentadores nomes funções chamadas WebAssembly.compile() (mais WebAssembly.compileStreaming()) e WebAssembly.instantiate() para essas tarefas, mas, em vez disso, WebAssembly.instantiateStreaming() compila e instancia um módulo Wasm diretamente de um fonte subjacente como fetch(), não é necessário await. Essa é a forma mais eficiente e otimizada de carregar o código Wasm. Supondo que o módulo Wasm exporte um factorial(), será possível usá-la imediatamente.

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

Deslocar a tarefa para um web worker

Se você fizer isso na linha de execução principal, com tarefas que exigem muito uso da CPU, correrá o risco bloquear todo o app. Uma prática comum é transferir essas tarefas para um Worker.

Reestruturação da linha de execução principal

Para mover a tarefa com uso intensivo de CPU para um web worker, a primeira etapa é reestruturar o aplicativo. A linha de execução principal agora cria um Worker e, além disso, lida apenas com o envio da entrada para o web worker e, em seguida, com o e exibi-la.

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

Incorreto: a tarefa é executada no Web Worker, mas o código é potencialmente ofensivo

O Web Worker instancia o módulo Wasm e, ao receber uma mensagem, executa a tarefa com uso intensivo da CPU e envia o resultado de volta para a linha de execução principal. O problema com essa abordagem é que instanciar um módulo Wasm com WebAssembly.instantiateStreaming() é uma operação assíncrona. Isso significa que que o código é potencialmente ofensivo. Na pior das hipóteses, a linha de execução principal envia dados quando o O web worker ainda não está pronto e nunca recebe a mensagem.

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

Melhor: a tarefa é executada no Web Worker, mas com carregamento e compilação possivelmente redundantes

Uma solução alternativa para o problema da instanciação do módulo Wasm assíncrono é mover o carregamento, a compilação e a instanciação do módulo Wasm para o evento Mas isso significaria que esse trabalho precisaria acontecer em todas mensagem recebida. Com o cache HTTP e o cache HTTP capazes de armazenar Bytecode Wasm compilado, essa não é a pior solução, mas há uma solução melhor de um jeito fácil.

Movendo o código assíncrono para o início do Web Worker e não aguardando o cumprimento da promessa, mas, em vez disso, armazenar a promessa em um o programa seguirá imediatamente para a parte do listener de eventos da código, e nenhuma mensagem da linha de execução principal será perdida. Dentro do evento a promessa pode ser aguardada.

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

Bom: a tarefa é executada no Web Worker, carrega e compila apenas uma vez

O resultado da ação estática WebAssembly.compileStreaming() é uma promessa que se resolve em um WebAssembly.Module Uma característica interessante desse objeto é que ele pode ser transferido usando postMessage() Isso significa que o módulo Wasm pode ser carregado e compilado apenas uma vez no (ou até mesmo outro web worker apenas preocupado com o carregamento e a compilação), e transferi-las para o web worker responsável pelo tarefa. O código a seguir mostra esse fluxo.

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

No lado do Web Worker, resta apenas extrair a WebAssembly.Module e instanciá-lo. Como a mensagem com WebAssembly.Module não é transmitidos, o código no Web Worker agora usa WebAssembly.instantiate() em vez da variante instantiateStreaming() de antes. O modelo é armazenado em cache em uma variável, então o trabalho de instanciação só precisa acontecer ao ativar o 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 });
});

Perfeito: a tarefa é executada no Web Worker inline, carrega e compila apenas uma vez

Mesmo com o armazenamento em cache HTTP, obter o código do web worker armazenado em cache (preferencialmente) e o que pode chegar à rede é caro. Um truque comum de desempenho é inline do Web Worker e carregá-lo como um URL blob:. Isso ainda exige módulo Wasm compilado a ser passado para o Web Worker para instanciação, pois o contextos do Web Worker e da linha de execução principal são diferentes, mesmo que sejam com base no mesmo arquivo de origem 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,
  });
});

Criação lenta ou antecipada de Web Workers

Até agora, todos os exemplos de código ativaram o Web Worker lentamente sob demanda, ou seja, quando o botão foi pressionado. Dependendo do aplicativo, pode fazer sentido criar o web worker com mais antecedência, por exemplo, quando o aplicativo está ocioso ou mesmo quando parte do processo de bootstrapping do app. Portanto, mover a criação do Web Worker fora do listener de eventos do botão.

const worker = new Worker(blobURL);

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

Manter o Web Worker por perto ou não

Uma pergunta que você pode se fazer é se você deve manter o Web Worker permanentemente ou recriá-la sempre que precisar. Ambas as abordagens são possível e têm vantagens e desvantagens. Por exemplo, manter um servidor Trabalhar permanentemente ao redor pode aumentar o consumo de memória do app e fazer com que é mais difícil lidar com tarefas simultâneas, já que você precisa, de alguma forma, mapear os resultados vindo do web worker de volta para as solicitações. Por outro lado, seus sites O código de bootstrapping do worker pode ser bastante complexo, então pode haver muitos se você criar uma nova todas as vezes. Felizmente, isso é algo que você pode medir com o API User Timing.

Até agora, os exemplos de código mantiveram um Web Worker permanente. O seguinte exemplo de código cria um novo Web Worker ad hoc sempre que necessário. Observe que você precisa para acompanhar encerrar o web worker você mesmo. O snippet de código ignora o tratamento de erros, mas caso algo aconteça errado, certifique-se de encerrar em todos os casos, sucesso ou falha.)

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

Demonstrações

Há duas demonstrações para você testar. Um com web worker ad hoc (código-fonte) e outra com web worker permanente (código-fonte). Se você abrir o Chrome DevTools e verificar o console, vai encontrar a seção Usuário Registros da API Timing que medem o tempo entre o clique no botão e o resultado exibido na tela. A guia "Rede" mostra o URL blob:. solicitação(ões). Neste exemplo, a diferença de tempo entre ad hoc e permanente é de mais ou menos 3×. Na prática, para o olho humano, ambos são indistinguíveis caso. Os resultados do seu aplicativo da vida real provavelmente irão variar.

App de demonstração Factorial Wasm com um Worker ad hoc. O Chrome DevTools está aberto. Há dois blobs: solicitações de URL na guia &quot;Rede&quot; e o Console mostra dois tempos de cálculo.

App de demonstração Factorial Wasm com um Worker permanente. O Chrome DevTools está aberto. Há apenas um blob: solicitação de URL na guia &quot;Rede&quot;, e o console mostra quatro tempos de cálculo.

Conclusões

Esta postagem explorou alguns padrões de desempenho para lidar com o Wasm.

  • Como regra geral, prefira os métodos de streaming (WebAssembly.compileStreaming() e WebAssembly.instantiateStreaming()) em relação às contrapartes que não são de streaming (WebAssembly.compile() e WebAssembly.instantiate()).
  • Se possível, terceirize as tarefas com alto desempenho em um Web Worker e faça o Wasm o carregamento e a compilação do trabalho apenas uma vez fora do web worker. Dessa forma, O Web Worker só precisa instanciar o módulo Wasm que recebe da interface principal linha de execução em que o carregamento e a compilação ocorreram WebAssembly.instantiate(), que significa que a instância pode ser armazenada em cache se você manter o web worker permanentemente.
  • Avalie com cuidado se faz sentido manter um Web Worker permanente. para sempre ou para criar Web Workers ad hoc sempre que forem necessários. Além disso, pense quando é o melhor momento para criar o web worker. Considerações são o consumo de memória, a duração da instanciação do worker mas também a complexidade de lidar com solicitações simultâneas.

Se você considerar esses padrões, está no caminho certo para a otimização Desempenho do Wasm.

Agradecimentos

Este guia foi revisado por Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort e Rachel Andrew.