Padrões de desempenho do WebAssembly para apps da Web

Neste guia, voltado a desenvolvedores da Web que querem se beneficiar do WebAssembly, você vai aprender a usar o Wasm para terceirizar tarefas com uso intensivo de CPU com um exemplo em execução. O guia aborda tudo, desde as práticas recomendadas para carregamento de módulos Wasm até a otimização da compilação e instanciação. Ele também discute a mudança de tarefas com uso intensivo de CPU para Web Workers e analisa decisões de implementação que você vai enfrentar, como quando criar o Web Worker e se ele deve mantê-lo permanentemente ativo ou ativá-lo quando necessário. O guia desenvolve a abordagem de maneira iterativa 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 intensivo de CPU e queira terceirizar para o WebAssembly (Wasm) para ter um desempenho quase nativo. A tarefa com uso intensivo da CPU usada como exemplo neste guia calcula o fatorial de um número. O fatorial é o produto de um número inteiro e 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 aumentam rapidamente. Por exemplo, 16! é 2,004,189,184. Um exemplo mais realista de uma tarefa que consome muita CPU pode ser a leitura de um código de barras ou o rastreamento de uma imagem rasterizada.

Uma implementação iterativa de desempenho (em vez de recursiva) de uma função 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, suponha que haja um módulo Wasm baseado na compilação desta função factorial() com 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 no 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

Em HTML, há um form com um input pareado com um output e um button de envio. Esses elementos são referenciados no JavaScript com base em seus 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

Carregue um módulo do Wasm antes de usá-lo. Na Web, isso acontece usando a API fetch(). Como você sabe que seu app da Web depende do módulo Wasm para a tarefa que usa muita CPU, pré-carregue o arquivo Wasm o mais cedo possível. Para fazer isso, use uma busca ativada por CORS na seção <head> do aplicativo.

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

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

fetch('factorial.wasm');

Em seguida, compile e instancie o módulo Wasm. Existem funções com nomes tentadoras chamadas WebAssembly.compile() (além de 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 em streaming, como fetch(), await não é necessário. Essa é a maneira mais eficiente e otimizada de carregar código Wasm. Supondo que o módulo do 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 Web Worker

Se você executar isso na linha de execução principal, com tarefas que usam muito a CPU, você corre o risco de bloquear todo o app. Uma prática comum é mudar essas tarefas para um Web 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, só lida com o envio da entrada para o Web Worker e, em seguida, o recebimento da saída e a exibição dela.

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

Ruim: 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 que consome muita CPU e envia o resultado de volta para a linha de execução principal. O problema dessa abordagem é que instanciar um módulo Wasm com WebAssembly.instantiateStreaming() é uma operação assíncrona. Isso significa que o código é potencialmente ofensivo. Na pior das hipóteses, a linha de execução principal envia dados quando o Web Worker ainda não está pronto, e o Web Worker 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 listener de eventos, mas isso significa que esse trabalho precisaria acontecer em todas as mensagens recebidas. Com o armazenamento em cache HTTP e o cache HTTP capaz de armazenar em cache 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 atendida, em vez de armazenar a promessa em uma variável, o programa avança imediatamente para a parte do listener de eventos do código, e nenhuma mensagem da linha de execução principal será perdida. Dentro do listener de eventos, é possível aguardar a promessa.

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

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

O resultado do método estático WebAssembly.compileStreaming() é uma promessa que é resolvida em um WebAssembly.Module. Um bom recurso 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 outro Web worker puramente preocupado com carregamento e compilação) e, em seguida, transferido para o Web Worker responsável pela tarefa com uso intensivo da 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 instanciá-lo. Como a mensagem com WebAssembly.Module não é transmitida, o código no Web Worker agora usa WebAssembly.instantiate() em vez da variante instantiateStreaming() de antes. O módulo instanciado é armazenado em cache em uma variável. Portanto, o trabalho de instanciação só precisa acontecer uma vez na ativação do 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 e carrega e compila apenas uma vez

Mesmo com o armazenamento em cache HTTP, é caro ter (de preferência) o código do Web Worker armazenado em cache e, possivelmente, acessar a rede. Um truque de desempenho comum é integrar 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 Worker lento ou ansioso

Até agora, todos os exemplos de código ativavam o Web Worker lentamente sob demanda, ou seja, quando o botão era pressionado. Dependendo do seu aplicativo, pode fazer sentido criar o Web Worker com mais atenção, por exemplo, quando o app está ocioso 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 o Web Worker por perto ou não

Uma pergunta a se fazer é se deve manter o Web Worker permanentemente disponível ou recriá-lo sempre que precisar. Ambas as abordagens são possíveis e têm vantagens e desvantagens. Por exemplo, manter um Web Worker permanentemente disponível pode aumentar o consumo de memória do seu app e dificultar o gerenciamento de tarefas simultâneas, já que, de alguma forma, você precisa mapear os resultados vindos do Web Worker nas solicitações. Por outro lado, o código de bootstrapping do seu Web worker pode ser bastante complexo, então pode haver muita sobrecarga se você criar um novo a cada vez. Felizmente, é possível fazer isso com a API User Timing.

Os exemplos de código até agora mantiveram um Web Worker permanente. O exemplo de código a seguir cria um novo Web Worker ad hoc sempre que necessário. Observe que você precisa acompanhar o encerramento do Web Worker. O snippet de código ignora o tratamento de erros, mas caso algo dê errado, é preciso encerrar em todos os casos, bem-sucedido ou com 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ê usar. Um com um Web Worker ad-hoc (código-fonte) e outro com um Web Worker permanente (código-fonte). Ao abrir o Chrome DevTools e verificar o console, você verá os registros da API User Timing que medem o tempo decorrido entre o clique no botão e o resultado exibido 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 cerca de 3×. Na prática, para o olho humano, é indistinguível nesse caso. Os resultados para seu próprio aplicativo da vida real provavelmente variam.

App de demonstração do Factorial Wasm com um worker ad-hoc. O Chrome DevTools está aberto. Há dois blobs: as solicitações de URL na guia &quot;Rede&quot; e o console mostram 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 Rede 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, dê preferência aos métodos de streaming (WebAssembly.compileStreaming() e WebAssembly.instantiateStreaming()) aos que não são de streaming (WebAssembly.compile() e WebAssembly.instantiate()).
  • Se possível, terceirize tarefas de alto desempenho em um Web Worker e faça o trabalho de carregamento e compilação do Wasm apenas uma vez fora dele. Dessa forma, o Web Worker só precisa instanciar o módulo Wasm recebido da linha de execução principal em que o carregamento e a compilação ocorreram com WebAssembly.instantiate(). Isso significa que a instância poderá ser armazenada em cache se você mantiver o Web Worker permanentemente.
  • Avalie cuidadosamente se faz sentido manter um Web Worker permanente para sempre ou criar Web Workers ad hoc sempre que necessário. Pense também quando é o melhor momento para criar o Web Worker. Os itens a serem considerados são o consumo de memória, a duração da instanciação do Web Worker, mas também a complexidade de possivelmente ter que lidar com solicitações simultâneas.

Se você considerar esses padrões, estará no caminho certo para otimizar 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.