Como substituir um caminho quente no JavaScript do seu app pelo WebAssembly

Ele é consistentemente rápido,

Nos meus artigos anteriores , falamos sobre como o WebAssembly permite trazer o ecossistema de bibliotecas de C/C++ para a Web. Um app que faz uso extensivo de bibliotecas C/C++ é o squoosh, nosso app da Web que permite compactar imagens com uma variedade de codecs que foram compilados do C++ para o WebAssembly.

O WebAssembly é uma máquina virtual de baixo nível que executa o bytecode armazenado em arquivos .wasm. Esse código de byte é fortemente digitado e estruturado de modo que possa ser compilado e otimizado para o sistema host muito mais rápido do que o JavaScript. O WebAssembly fornece um ambiente para executar o código que teve o sandbox e a incorporação em mente desde o início.

Na minha experiência, a maioria dos problemas de performance na Web é causada por layout forçado e pintura excessiva, mas de vez em quando um app precisa realizar uma tarefa computacionalmente cara que leva muito tempo. O WebAssembly pode ajudar aqui.

O caminho mais interessante

No squoosh, escrevemos uma função JavaScript que gira um buffer de imagem por múltiplos de 90 graus. Embora o OffscreenCanvas seja ideal para isso, ele não é compatível com os navegadores pretendidos e pode ser usado no Chrome.

Essa função itera em cada pixel de uma imagem de entrada e a copia para uma posição diferente na imagem de saída para conseguir rotação. Para uma imagem de 4094 x 4096 pixels (16 megapixels), ela precisaria de mais de 16 milhões de iterações do bloco de código interno, que é o que chamamos de "hot path". Apesar do grande número de iterações, dois em cada três navegadores testados terminam a tarefa em dois segundos ou menos. Uma duração aceitável para esse tipo de interação.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

No entanto, um navegador leva mais de oito segundos. A maneira como os navegadores otimizam o JavaScript é realmente complicada, e diferentes mecanismos otimizam para coisas diferentes. Alguns otimizam para a execução bruta, outros otimizam para a interação com o DOM. Nesse caso, encontramos um caminho não otimizado em um navegador.

O WebAssembly, por outro lado, é desenvolvido inteiramente com base na velocidade de execução bruta. Se queremos um desempenho rápido e previsível em todos os navegadores para códigos como esse, o WebAssembly pode ajudar.

WebAssembly para desempenho previsível

Em geral, JavaScript e WebAssembly podem atingir o mesmo desempenho máximo. No entanto, para JavaScript, só é possível alcançar esse desempenho usando o "caminho rápido", que geralmente é complicado permanecer nesse "caminho rápido". Um dos principais benefícios que o WebAssembly oferece é o desempenho previsível, mesmo em navegadores. A tipografia rigorosa e a arquitetura de baixo nível permitem que o compilador ofereça garantias mais fortes para que o código WebAssembly só precise ser otimizado uma vez e sempre use o "caminho rápido".

Como escrever para o WebAssembly

Antes, as bibliotecas C/C++ eram compiladas no WebAssembly, usando o recurso delas na Web. Não mexemos no código das bibliotecas. Acabamos de escrever pequenas quantidades de código C/C++ para formar a ponte entre o navegador e a biblioteca. Desta vez, nossa motivação é diferente: queremos escrever algo do zero com o WebAssembly em mente para poder aproveitar as vantagens que ele tem.

Arquitetura do WebAssembly

Ao escrever para o WebAssembly, é útil entender um pouco mais sobre o que ele realmente é.

Para citar WebAssembly.org:

Ao compilar um código C ou Rust para o WebAssembly, você recebe um arquivo .wasm que contém uma declaração de módulo. Essa declaração consiste em uma lista de "importações" que o módulo espera do ambiente, uma lista de exportações que este módulo disponibiliza para o host (funções, constantes, blocos de memória) e, claro, as instruções binárias reais para as funções contidas nele.

Algo que eu não percebi até analisar isso: a pilha que faz o WebAssembly uma "máquina virtual baseada em pilha" não é armazenada no bloco de memória que os módulos WebAssembly usam. A pilha é completamente interna à VM e inacessível para desenvolvedores Web, exceto pelo DevTools. Dessa forma, é possível gravar módulos WebAssembly que não precisam de mais memória e usam apenas a pilha interna da VM.

Nesse caso, vamos precisar usar mais memória para permitir acesso arbitrário aos pixels da imagem e gerar uma versão rotacionada dela. É para isso que a WebAssembly.Memory serve.

Gerenciamento de memória

Normalmente, depois de usar mais memória, você vai sentir necessidade de gerenciá-la de alguma forma. Quais partes da memória estão em uso? Quais não têm custo? Em C, por exemplo, você tem a função malloc(n) que encontra um espaço de memória de n bytes consecutivos. Funções desse tipo também são chamadas de "alocadores". Obviamente, a implementação do alocador em uso precisa ser incluída no módulo WebAssembly e aumentará o tamanho do arquivo. O tamanho e o desempenho dessas funções de gerenciamento de memória podem variar significativamente dependendo do algoritmo usado. É por isso que muitas linguagens oferecem várias implementações para escolher ("dmalloc", "emmalloc", "wee_alloc" etc.).

Nesse caso, sabemos as dimensões da imagem de entrada (e, portanto, as dimensões da imagem de saída) antes de executarmos o módulo WebAssembly. Aqui, vimos uma oportunidade: tradicionalmente, o buffer RGBA da imagem de entrada era transmitido como um parâmetro para uma função WebAssembly e retornaríamos a imagem girada como um valor de retorno. Para gerar esse valor de retorno, teríamos que usar o alocador. Mas, como sabemos a quantidade total de memória necessária (o dobro do tamanho da imagem de entrada, uma para entrada e outra para saída), podemos colocar a imagem de entrada na memória WebAssembly com JavaScript, executar o módulo WebAssembly para gerar uma segunda imagem rotacionada e usar o JavaScript para ler o resultado. Podemos sair sem usar nenhum gerenciamento de memória.

Estranho na escolha

Se você observar a função original do JavaScript que queremos executar no WebAssembly, verá que ela é um código puramente computacional sem APIs específicas do JavaScript. Por isso, a portabilidade desse código para qualquer linguagem é bastante simples. Avaliamos três linguagens diferentes que são compiladas para o WebAssembly: C/C++, Rust e AssemblyScript. A única pergunta que precisamos responder para cada uma das linguagens é: como acessamos a memória bruta sem usar funções de gerenciamento de memória?

C e Emscripten

O Emscripten é um compilador C para o destino WebAssembly. O objetivo do Emscripten é funcionar como uma substituição simples para compiladores C conhecidos, como GCC ou clang, e é principalmente compatível com flags. Essa é uma parte essencial da missão da Emscripten, porque ela pretende facilitar ao máximo a compilação de códigos C e C++ existentes para o WebAssembly.

O acesso à memória bruta é da própria natureza do C, e os ponteiros existem por esse motivo:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Aqui, estamos transformando o número 0x124 em um ponteiro para números inteiros de 8 bits não assinados (ou bytes). Isso transforma efetivamente a variável ptr em uma matriz começando no endereço de memória 0x124, que pode ser usada como qualquer outra matriz, o que nos permite acessar bytes individuais para leitura e gravação. No nosso caso, estamos analisando um buffer RGBA de uma imagem que queremos reordenar para conseguir rotação. Para mover um pixel, precisamos mover quatro bytes consecutivos de uma vez (um byte para cada canal: R, G, B e A). Para facilitar, podemos criar uma matriz de números inteiros de 32 bits não assinados. Por convenção, nossa imagem de entrada começará no endereço 4 e a imagem de saída começará logo após a imagem de entrada terminar:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Depois de transferir toda a função JavaScript para C, podemos compilar o arquivo C com emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Como sempre, o emscripten gera um arquivo de código agrupador chamado c.js e um módulo Wasm chamado c.wasm. Observe que o módulo Wasm é compactado com apenas cerca de 260 bytes, enquanto o código cola tem cerca de 3,5 KB após o gzip. Depois de alguns ajustes, conseguimos abandonar o código cola e instanciar os módulos WebAssembly com as APIs básicas. Isso geralmente é possível com o Emscripten, desde que você não esteja usando nada da biblioteca padrão C.

Rust

Rust é uma linguagem de programação nova e moderna, com um sistema de tipo avançado, sem ambiente de execução e um modelo de propriedade que garante segurança de memória e de linhas de execução. O Rust também oferece suporte ao WebAssembly como recurso principal, e a equipe do Rust contribuiu várias ferramentas excelentes para o ecossistema do WebAssembly.

Uma dessas ferramentas é a wasm-pack, do grupo de trabalho rustWasm (link em inglês). O wasm-pack pega seu código e o transforma em um módulo compatível com a Web que funciona prontamente com bundlers como o webpack. wasm-pack é uma experiência extremamente conveniente, mas atualmente funciona apenas para o Rust. O grupo está pensando em adicionar suporte a outras linguagens de segmentação do WebAssembly.

No Rust, frações são o que são matrizes em C. E, assim como em C, precisamos criar fatias que usam nossos endereços de partida. Isso vai contra o modelo de segurança de memória que o Rust aplica. Portanto, para começar, precisamos usar a palavra-chave unsafe, o que nos permite escrever um código que não está de acordo com esse modelo.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Compilar os arquivos Rust usando

$ wasm-pack build

gera um módulo Wasm de 7,6 KB com cerca de 100 bytes de código cola (ambos após o gzip).

AssemblyScript

O AssemblyScript é um projeto bastante jovem que pretende ser um compilador TypeScript-to-WebAssembly. No entanto, é importante observar que ele não consome qualquer TypeScript. O AssemblyScript usa a mesma sintaxe que o TypeScript, mas troca a biblioteca padrão por conta própria. A biblioteca padrão modela os recursos do WebAssembly. Isso significa que não é possível compilar qualquer TypeScript que esteja usando o WebAssembly, mas isso significa que você não precisa aprender uma nova linguagem de programação para escrever o WebAssembly.

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Considerando a pequena superfície de tipo que a função rotate() tem, foi bem fácil transferir esse código para o AssemblyScript. As funções load<T>(ptr: usize) e store<T>(ptr: usize, value: T) são fornecidas pelo AssemblyScript para acessar a memória bruta. Para compilar nosso arquivo AssemblyScript, só precisamos instalar o pacote npm AssemblyScript/assemblyscript e executar

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

O AssemblyScript vai fornecer um módulo Wasm de aproximadamente 300 bytes e nenhum código agrupador. O módulo funciona apenas com as APIs WebAssembly básicas.

Análise forense do WebAssembly

Com 7,6 KB, o Rust é surpreendentemente grande em comparação com os outros dois idiomas. Há algumas ferramentas no ecossistema WebAssembly que podem ajudar você a analisar os arquivos WebAssembly (independentemente da linguagem com que foram criados) e informar o que está acontecendo e também ajudar a melhorar a situação.

Graveto

O Twiggy (link em inglês) é outra ferramenta da equipe WebAssembly do Rust que extrai vários dados úteis de um módulo do WebAssembly. A ferramenta não é específica do Rust e permite inspecionar itens como o gráfico de chamadas do módulo, determinar seções não usadas ou supérfluas e descobrir quais seções contribuem para o tamanho total do arquivo do seu módulo. Isso pode ser feito com o comando top do Twiggy:

$ twiggy top rotate_bg.wasm
Captura de tela da instalação do Twiggy

Nesse caso, a maioria do tamanho do arquivo vem do alocador. Isso foi surpreendente, já que nosso código não usa alocações dinâmicas. Outro grande fator que contribui muito é uma subseção de "nomes de função".

tira de lava

wasm-strip é uma ferramenta do WebAssembly Binary Toolkit, ou apenas wabt. Ele contém algumas ferramentas que permitem inspecionar e manipular módulos WebAssembly. wasm2wat é um desmontador que transforma um módulo Wasm binário em um formato legível. O Wabt também contém wat2wasm, que permite transformar o formato legível por humanos de volta em um módulo Wasm binário. Embora tenhamos usado essas duas ferramentas complementares para inspecionar nossos arquivos WebAssembly, descobrimos que wasm-strip é a mais útil. wasm-strip remove seções e metadados desnecessários de um módulo WebAssembly:

$ wasm-strip rotate_bg.wasm

Isso reduz o tamanho do arquivo do módulo rust de 7,5 KB para 6,6 KB (após o gzip).

wasm-opt

wasm-opt é uma ferramenta de Binaryen. Ela usa um módulo WebAssembly e tenta otimizá-lo em tamanho e desempenho com base apenas no bytecode. Algumas ferramentas, como o Emscripten, já executam essa ferramenta, outras não. Geralmente, é uma boa ideia tentar economizar bytes adicionais usando essas ferramentas.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Com wasm-opt, podemos cortar mais bytes para deixar um total de 6,2 KB após o gzip.

#![no_std]

Após algumas consultas e pesquisas, reescrevemos nosso código Rust sem usar a biblioteca padrão do Rust, utilizando o recurso #![no_std]. Isso também desativa completamente as alocações de memória dinâmicas, removendo o código do alocador do nosso módulo. Compilar este arquivo Rust com

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

gerou um módulo Wasm de 1,6 KB depois de wasm-opt, wasm-strip e gzip. Embora ainda seja maior que os módulos gerados por C e AssemblyScript, ele é pequeno o suficiente para ser considerado um módulo leve.

Desempenho

Antes de chegar a conclusões com base apenas no tamanho do arquivo, fizemos essa jornada para otimizar o desempenho, não o tamanho do arquivo. Como medimos o desempenho e quais foram os resultados?

Como fazer comparações

Embora o WebAssembly seja um formato de bytecode de baixo nível, ele ainda precisa ser enviado por um compilador para gerar código de máquina específico do host. Assim como o JavaScript, o compilador funciona em vários estágios. Resumindo: o primeiro estágio é muito mais rápido na compilação, mas tende a gerar código mais lento. Quando a execução do módulo começa, o navegador observa quais partes são usadas com frequência e as envia por um compilador mais otimizado, mas mais lento.

Nosso caso de uso é interessante, porque o código para girar uma imagem é usado uma vez, talvez duas vezes. Portanto, na grande maioria dos casos, nunca receberemos os benefícios do compilador de otimização. É importante ter isso em mente ao fazer comparações. Executar nossos módulos WebAssembly 10.000 vezes em loop geraria resultados irrealistas. Para conseguir números realistas, precisamos executar o módulo uma vez e tomar decisões com base nos números dessa única execução.

Comparação de performance

Comparação de velocidade por idioma
Comparação de velocidade por navegador

Esses dois gráficos são visualizações diferentes dos mesmos dados. No primeiro gráfico, comparamos por navegador, e no segundo por idioma usado. Observe que escolhi uma escala de tempo logarítmica. Também é importante que todas as comparações usem a mesma imagem de teste de 16 megapixels e a mesma máquina host, exceto um navegador, que não pode ser executado na mesma máquina.

Sem analisar demais esses gráficos, fica claro que resolvemos nosso problema de desempenho original: todos os módulos WebAssembly são executados em cerca de 500 ms ou menos. Isso confirma o que definimos no início: o WebAssembly oferece um desempenho previsível. Independentemente do idioma escolhido, a variação entre navegadores e idiomas é mínima. Para ser exato: o desvio padrão do JavaScript em todos os navegadores é de aproximadamente 400 ms, enquanto o desvio padrão de todos os módulos WebAssembly em todos os navegadores é de aproximadamente 80 ms.

Esforço

Outra métrica é o esforço necessário para criar e integrar nosso módulo WebAssembly ao squoosh. É difícil atribuir um valor numérico ao esforço, então não criarei nenhum gráfico, mas há algumas coisas que gostaria de destacar:

O AssemblyScript foi simples. Ele não apenas permite que você use o TypeScript para escrever o WebAssembly, facilitando a revisão de código para meus colegas, mas também produz módulos WebAssembly sem cola que são muito pequenos e com desempenho decente. As ferramentas do ecossistema TypeScript, como "PretMore" e "tslint", provavelmente vão funcionar.

O Rust em combinação com wasm-pack também é extremamente conveniente, mas se destaca mais em projetos WebAssembly maiores em que vinculações e gerenciamento de memória são necessários. Tivemos que divergir um pouco do caminho da felicidade para conseguir um tamanho de arquivo competitivo.

C e Emscripten criaram um módulo WebAssembly muito pequeno e de alto desempenho, mas sem a coragem de mergulhar no código agrupador e reduzi-lo às necessidades básicas, o tamanho total (módulo WebAssembly + código cola) acaba sendo muito grande.

Conclusão

Qual linguagem usar se você tiver um caminho quente JS e quiser torná-lo mais rápido ou consistente com o WebAssembly? Como sempre acontece com as perguntas de desempenho, a resposta é: depende. O que a gente enviou?

Gráfico de comparação

Comparando o tamanho do módulo / compensação de desempenho das diferentes linguagens usadas, a melhor escolha parece ser C ou AssemblyScript. Decidimos enviar o Rust. Há várias razões para essa decisão: todos os codecs enviados no Squoosh até agora são compilados usando o Emscripten. Queríamos ampliar nosso conhecimento sobre o ecossistema WebAssembly e usar uma linguagem diferente na produção. O AssemblyScript é uma alternativa forte, mas o projeto é relativamente novo e o compilador não é tão maduro quanto o compilador do Rust.

Embora a diferença no tamanho do arquivo entre o Rust e os outros idiomas pareça muito drástica no gráfico de dispersão, não é tão grande na realidade: Carregar 500B ou 1,6 KB mesmo em 2G leva menos de um décimo de segundo. Esperamos que o Rust preencha a lacuna em termos de tamanho de módulo em breve.

Em termos de desempenho do tempo de execução, o Rust tem uma média mais rápida em navegadores do que o AssemblyScript. Especialmente em projetos maiores, o Rust tem maior probabilidade de produzir código mais rápido sem precisar de otimizações manuais de código. No entanto, isso não impede que você use aquilo com que se sente mais confortável.

Tendo dito isso: o AssemblyScript foi uma grande descoberta. Ele permite que os desenvolvedores da Web produzam módulos WebAssembly sem precisar aprender uma nova linguagem. A equipe do AssemblyScript tem sido muito responsiva e está trabalhando ativamente para melhorar o conjunto de ferramentas. Vamos ficar de olho no AssemblyScript no futuro.

Atualização: Rust

Depois de publicar este artigo, Nick Fitzgerald, da equipe do Rust, nos indicou o excelente livro Rust Wasm, que contém uma seção sobre como otimizar o tamanho do arquivo (em inglês). Seguindo as instruções (principalmente a ativação de otimizações de tempo de link e gerenciamento manual de pânico), pudemos escrever um código Rust "normal" e voltar a usar Cargo (o npm do Rust) sem aumentar o tamanho do arquivo. O módulo Rust termina com 370B após o gzip. Para mais detalhes, consulte o RP que abri no Squoosh.

Um agradecimento especial a Ashley Williams, Steve Klabnik, Nick Fitzgerald e Max Graey por toda a ajuda nessa jornada.