Padrões de desempenho do WebAssembly para apps da Web

Neste guia, destinado a desenvolvedores da Web que querem se beneficiar do WebAssembly, você vai aprender a usar o Wasm para terceirizar tarefas que exigem muito da CPU com a ajuda de um exemplo em execução. O guia aborda desde as práticas recomendadas para carregar módulos Wasm até a otimização da compilação e da instanciação deles. Ele aborda ainda a mudança das tarefas que exigem muito da CPU para os Web Workers e analisa as decisões de implementação que você vai enfrentar, como quando criar o Web Worker e se deve mantê-lo permanentemente ativo ou ativá-lo quando necessário. O guia desenvolve iterativamente a abordagem e introduz um padrão de performance de cada vez, até sugerir a melhor solução para o problema.

Suposições

Suponha que você tenha uma tarefa com uso intensivo de CPU que quer terceirizar para WebAssembly (Wasm) para ter um desempenho quase nativo. A tarefa que exige muita CPU usada como exemplo neste guia calcula o fatorial de um número. O fatorial é o produto de um número inteiro e de todos os números inteiros abaixo dele. Por exemplo, o fatorial de quatro (escrito como 4!) é igual a 24 (ou seja, 4 * 3 * 2 * 1). Os números ficam grandes rapidamente. Por exemplo, 16! é 2,004,189,184. Um exemplo mais realista de uma tarefa com uso intensivo de CPU pode ser ler um código de barras ou rastrear uma imagem rasterizada.

Uma implementação iterativa (em vez de recursiva) de uma função factorial() de alto desempenho é mostrada no exemplo de código abaixo, 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;
}

}

No restante do artigo, vamos supor que há um módulo Wasm baseado na compilação dessa função factorial() com o Emscripten em um arquivo chamado factorial.wasm usando todas as práticas recomendadas de otimização de código. Para relembrar como fazer isso, leia Como chamar funções C compiladas do JavaScript usando ccall/cwrap. O comando a seguir foi usado para compilar factorial.wasm como Wasm independente.

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

No HTML, há uma form com uma input associada a uma output e a uma button de envio. Esses elementos são referenciados no JavaScript com base nos nomes deles.

<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 usar um módulo Wasm, é necessário carregá-lo. Na Web, isso acontece com a API fetch(). Como você sabe que seu app da Web depende do módulo Wasm para a tarefa que exige muita CPU, é necessário pré-carregar o arquivo Wasm o mais cedo possível. Para fazer isso, use um fetch ativado pelo CORS na seção <head> do app.

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

Na verdade, a API fetch() é assíncrona, e você precisa await o resultado.

fetch('factorial.wasm');

Em seguida, compile e instancie o módulo Wasm. Há funções com nomes tentadores chamadas WebAssembly.compile() (mais WebAssembly.compileStreaming()) e WebAssembly.instantiate() para essas tarefas, mas, em vez disso, o método WebAssembly.instantiateStreaming() compila e instancia um módulo Wasm diretamente de uma fonte subjacente transmitida por streaming, como fetch(), sem a necessidade de await. Essa é a maneira mais eficiente e otimizada de carregar o código Wasm. Supondo que o módulo Wasm exporte uma função factorial(), você pode 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));
});

Mudar a tarefa para um worker da Web

Se você executar isso na linha de execução principal, com tarefas realmente intensivas de CPU, você corre o risco de bloquear todo o app. Uma prática comum é transferir essas tarefas para um worker da Web.

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

Para mover a tarefa que usa muita CPU para um worker da Web, a primeira etapa é reestruturar o aplicativo. A linha de execução principal agora cria uma Worker e, além disso, lida apenas com o envio da entrada para o Web Worker, recebendo a saída e a exibindo.

/* 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 é instável

O Web Worker instancia o módulo Wasm e, ao receber uma mensagem, executa a tarefa que consome muitos recursos 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 o código é instável. Na pior das hipóteses, a linha de execução principal envia dados quando 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 de instanciação de módulo Wasm assíncrono é mover o carregamento, a compilação e a instanciação do módulo Wasm para o listener de eventos, mas isso significaria que esse trabalho precisaria acontecer em todas as mensagens recebidas. Com o armazenamento em cache HTTP e o cache HTTP capaz de armazenar o bytecode Wasm compilado, essa não é a pior solução, mas há uma maneira melhor.

Ao mover o código assíncrono para o início do Web Worker e não esperar que a promessa seja cumprida, mas armazenar a promessa em uma variável, o programa passa imediatamente para a parte do listener de eventos do código, e nenhuma mensagem da linha de execução principal é perdida. No listener 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 e é carregada e compilada apenas uma vez

O resultado do método estático WebAssembly.compileStreaming() é uma promessa que é resolvida para um WebAssembly.Module. Um recurso 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 na linha de execução principal (ou até mesmo em outro worker da Web que se preocupa apenas com o carregamento e a compilação) e, em seguida, transferido para o worker da Web responsável pela tarefa de uso intensivo de CPU. 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, tudo o que resta é extrair o objeto WebAssembly.Module e instanciar. Como a mensagem com o WebAssembly.Module não é transmitida, o código no Web Worker agora usa WebAssembly.instantiate() em vez da variante instantiateStreaming() anterior. O módulo instanciado é armazenado em cache em uma variável. Portanto, o trabalho de instanciação só precisa acontecer uma vez ao iniciar o worker da Web.

/* 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 e é carregada e compilada apenas uma vez

Mesmo com o armazenamento em cache HTTP, a obtenção do código do Web Worker armazenado em cache (idealmente) e a possível transferência para a rede é cara. Um truque comum de desempenho é inline o Web Worker e carregá-lo como um URL blob:. Isso ainda exige que o módulo Wasm compilado seja transmitido ao Web Worker para instanciação, já que os contextos do Web Worker e da linha de execução principal são diferentes, mesmo que sejam baseados 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 de Web Workers preguiçosos ou ansiosos

Até agora, todos os exemplos de código iniciaram o Web Worker de forma lenta e sob demanda, ou seja, quando o botão foi pressionado. Dependendo do seu aplicativo, pode ser mais adequado criar o Web Worker com mais rapidez, por exemplo, quando o app está inativo ou mesmo como parte do processo de inicialização do app. Portanto, mova o código de criação do Web Worker para 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 ou não o worker da Web

Uma pergunta que você pode fazer é se deve manter o worker da Web permanentemente ou recriar sempre que precisar. Ambas as abordagens são possíveis e têm vantagens e desvantagens. Por exemplo, manter um worker da Web permanentemente pode aumentar a pegada de memória do app e dificultar o tratamento de tarefas simultâneas, já que você precisa mapear os resultados vindos do worker da Web de volta para as solicitações. Por outro lado, o código de inicialização do Web Worker pode ser bastante complexo, então pode haver muito overhead se você criar um novo toda vez. Felizmente, isso pode ser medido com a API User Timing.

Os exemplos de código até agora mantiveram um worker da Web permanente. O exemplo de código a seguir cria um novo Web Worker ad hoc sempre que necessário. É necessário monitorar a finalização do Web Worker por conta própria. O snippet de código pula o processamento de erros, mas, caso algo esteja errado, encerre em todos os casos, com 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 um Web Worker ad hoc (código-fonte) e outro com um Web Worker permanente (código-fonte). Se você abrir o Chrome DevTools e verificar o console, vai encontrar os registros da API User Timing que medem o tempo que leva do clique no botão até o resultado mostrado na tela. A guia "Rede" mostra as solicitações de URL blob:. Neste exemplo, a diferença de tempo entre ad hoc e permanente é de aproximadamente 3 vezes. Na prática, para o olho humano, ambos são indistinguíveis neste caso. Os resultados do seu app real provavelmente vão variar.

App de demonstração do 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 do Factorial Wasm com um worker permanente. O Chrome DevTools está aberto. Há apenas um blob: a 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 performance para lidar com o Wasm.

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

Se você considerar esses padrões, estará no caminho certo para otimizar a performance do Wasm.

Agradecimentos

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