Neste guia, destinado a desenvolvedores da Web que querem aproveitar o WebAssembly, você vai aprender a usar o Wasm para terceirizar tarefas com uso intenso da CPU com a ajuda de um exemplo em execução. O guia aborda tudo, desde práticas recomendadas para carregar módulos Wasm até otimizar a compilação e a instanciação deles. Ele discute ainda mais a transferência das tarefas com uso intensivo da CPU para Web Workers e analisa as decisões de implementação que você vai enfrentar, como quando criar o Web Worker e se ele deve ficar ativo permanentemente ou ser ativado quando necessário. O guia desenvolve a abordagem de forma iterativa e apresenta um padrão de desempenho por vez, até sugerir a melhor solução para o problema.
Suposições
Suponha que você tenha uma tarefa que exige muito da CPU e queira terceirizar para
WebAssembly (Wasm) devido ao desempenho quase nativo. A tarefa que exige muito 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 com uso intensivo de CPU pode ser
ler um código de barras ou
traçar uma imagem rasterizada.
Uma implementação iterativa (em vez de recursiva) eficiente 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;
}
}
No restante do artigo, vamos considerar que há um módulo Wasm baseado na compilação
desta 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
Em HTML, há um form
com um input
pareado com um output
e um button
de envio. Esses elementos são referenciados do 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
pela API
fetch()
. Como você sabe que o app da Web depende do módulo Wasm para a
tarefa que exige muito da CPU, faça o pré-carregamento do arquivo Wasm o mais rápido possível. Para isso, use uma busca ativada para CORS 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
o
resultado.
fetch('factorial.wasm');
Em seguida, compile e crie uma instância do 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 cria uma instância de um módulo Wasm diretamente de uma origem transmitida por streaming, como fetch()
. Não é necessário usar await
. Essa é a maneira mais eficiente e otimizada de carregar 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));
});
Transferir a tarefa para um Web Worker
Se você executar isso na linha de execução principal, com tarefas realmente intensivas em CPU, corre o risco de bloquear todo o app. Uma prática comum é transferir essas tarefas para um Web Worker.
Reestruturação da linha de execução principal
Para mover a tarefa que usa muita 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 é racy
O Web Worker cria uma instância do módulo Wasm e, ao receber uma mensagem,
realiza a tarefa que exige muito da CPU e envia o resultado de volta para a linha de execução principal.
O problema dessa abordagem é que a instanciação de um módulo Wasm com
WebAssembly.instantiateStreaming()
é uma operação assíncrona. Isso significa que o código é racy. No pior caso, 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 assíncrona do módulo Wasm é mover o carregamento, a compilação e a instanciação do módulo Wasm para o listener de eventos. No entanto, isso significa que esse trabalho precisaria acontecer em todas as mensagens recebidas. Com o 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 cumprida, mas armazenando-a 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 será perdida. Dentro do listener de eventos, 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 resulta em 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
thread principal (ou até mesmo em outro Web Worker preocupado apenas com o carregamento e a compilação)
e depois ser transferido para o Web Worker responsável pela tarefa
que exige muito 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 o WebAssembly.Module
não é
transmitida por streaming, 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, então o trabalho de instanciação só precisa acontecer uma vez ao iniciar 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 e é carregada e compilada apenas uma vez.
Mesmo com o armazenamento em cache HTTP, obter o código do Web Worker (idealmente) armazenado em cache e
potencialmente acessar a rede é caro. Um truque comum de desempenho é
inserir 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 lenta ou rápida
Até agora, todos os exemplos de código iniciaram o Web Worker de maneira lenta e sob demanda, ou seja, quando o botão foi pressionado. Dependendo do aplicativo, pode fazer sentido criar o Web Worker com mais rapidez, por exemplo, quando o app está inativo ou até 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 Web Worker
Uma pergunta que você pode se fazer é se deve manter o Web Worker permanentemente ou recriá-lo sempre que precisar. As duas abordagens são possíveis e têm vantagens e desvantagens. Por exemplo, manter um Web Worker permanentemente pode aumentar o uso de memória do app e dificultar o tratamento de tarefas simultâneas, já que você precisa mapear os resultados do Web Worker de volta para as solicitações. Por outro lado, o código de bootstrap do Web Worker pode ser bastante complexo, então pode haver muito overhead se você criar um novo a cada vez. Felizmente, isso pode ser medido 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. Você precisa acompanhar o encerramento do Web Worker por conta própria. O snippet de código ignora o tratamento de erros, mas, caso algo dê errado, encerre em todos os casos, de 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, poderá conferir os registros da API User
Timing que medem o tempo decorrido entre o clique no botão e 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 cerca de 3 vezes. Na prática, para o olho humano, ambos são indistinguíveis neste caso. Os resultados do seu app na vida real provavelmente vão variar.
Conclusões
Nesta postagem, abordamos alguns padrões de desempenho para lidar com o Wasm.
- Como regra geral, prefira os métodos de streaming (
WebAssembly.compileStreaming()
eWebAssembly.instantiateStreaming()
) em vez das alternativas sem streaming (WebAssembly.compile()
eWebAssembly.instantiate()
). - Se possível, terceirize tarefas pesadas para 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 recebe da linha de execução
principal em que o carregamento e a compilação ocorreram com
WebAssembly.instantiate()
. Isso significa que a instância pode ser armazenada em cache se você mantiver o Web Worker permanentemente. - Meça com cuidado se faz sentido manter um Web Worker permanente para sempre ou criar Web Workers ad hoc sempre que necessário. Também pense quando é o melhor momento para criar o Web Worker. É preciso considerar o consumo de memória, a duração da instanciação do Web Worker e a complexidade de lidar com solicitações simultâneas.
Se você considerar esses padrões, estará no caminho certo para um desempenho ideal do Wasm.
Agradecimentos
Este guia foi revisado por Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort e Rachel Andrew.