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

Ele é sempre rápido, e você

Nos meus artigos anteriores, falamos sobre como o WebAssembly permite que você leve o ecossistema de bibliotecas do 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 diversos codecs 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 tipado e estruturado de modo que possa ser compilado e otimizado para o sistema host muito mais rápido do que o JavaScript. O WebAssembly oferece um ambiente para executar um código que considera o modo sandbox e a incorporação desde o início.

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

O caminho quente

No squoosh, criamos uma função JavaScript que gira um buffer de imagem em múltiplos de 90 graus. Embora o OffscreenCanvas seja ideal para isso, ele não é compatível com os navegadores que estávamos segmentando e um pouco abundante no Chrome.

Essa função faz a iteração de cada pixel de uma imagem de entrada e os copia para uma posição diferente na imagem de saída para conseguir rotação. Para uma imagem de 4.094 x 4.096 pixels (16 megapixels), seria necessário 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 que testamos finalizam 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;
    }
}

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

O WebAssembly, por outro lado, é construído inteiramente em torno da velocidade de execução bruta. Portanto, 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, esse desempenho só pode ser alcançado no "caminho rápido", e geralmente é difícil permanecer nesse "caminho rápido". Um benefício importante que o WebAssembly oferece é o desempenho previsível, mesmo em vários navegadores. A digitação estrita e a arquitetura de baixo nível permitem que o compilador tenha garantias mais sólidas para que o código WebAssembly só precise ser otimizado uma vez e sempre use o "caminho rápido".

Como escrever para WebAssembly

Antes, pegamos bibliotecas C/C++ e as compilamos no WebAssembly para usar a funcionalidade delas na Web. Não mudamos o código das bibliotecas, apenas escrevemos 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 podermos aproveitar as vantagens dele.

Arquitetura do WebAssembly

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

Para citar WebAssembly.org:

Ao compilar uma parte do 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 o módulo disponibilizou 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: a pilha que torna 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 na VM e inacessível para desenvolvedores Web (exceto pelo DevTools). Dessa forma, é possível gravar módulos WebAssembly que não precisam de memória extra e que usam apenas a pilha interna da VM.

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

Gerenciamento de memória

Normalmente, quando você usa memória adicional, terá a necessidade de gerenciar essa memória de alguma forma. Quais partes da memória estão em uso? Quais são sem custo financeiro? Em C, por exemplo, você tem a função malloc(n) que encontra um espaço de memória de n bytes consecutivos. As 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.).

No nosso 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, transmitimos o buffer RGBA da imagem de entrada como um parâmetro para uma função WebAssembly e retornamos a imagem rotacionada como um valor de retorno. Para gerar esse valor de retorno, teríamos que usar o alocador. No entanto, 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 do WebAssembly usando JavaScript, executar o módulo WebAssembly para gerar uma segunda imagem rotacionada e, em seguida, usar o JavaScript para ler o resultado. Podemos sair sem usar nenhum gerenciamento de memória.

Perfeito para escolher

Analisando a função JavaScript original que queremos aplicar ao WebAssembly, você verá que ela é um código puramente computacional sem APIs específicas para JavaScript. Por isso, é muito simples transferir esse código para qualquer linguagem. 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 acessar a memória bruta sem usar funções de gerenciamento de memória?

C e Emscripten

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

O acesso à memória bruta é uma 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 a variável ptr em uma matriz começando no endereço de memória 0x124, que podemos usar como qualquer outra matriz, permitindo acessar bytes individuais para leitura e gravação. No nosso caso, estamos vendo 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 isso, 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 o término da imagem de entrada:

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 faz o gzip para apenas cerca de 260 bytes, enquanto o código agrupador tem cerca de 3,5 KB após o gzip. Depois de alguns ajustes, abandonamos o código cola e instanciamos os módulos WebAssembly com as APIs baunilha. Isso geralmente é possível com o Emscripten, desde que você não use nada da biblioteca C padrão.

Rust

O Rust é uma linguagem de programação nova e moderna com um sistema de tipos avançado, sem ambiente de execução e um modelo de propriedade que garante proteção da memória e da linha de execução. O Rust também oferece suporte ao WebAssembly como um recurso principal, e a equipe dele contribuiu com muitas ferramentas excelentes para o ecossistema do WebAssembly.

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

No Rust, frações são o que são as matrizes em C. E, assim como em C, precisamos criar fatias que usam nossos endereços iniciais. Isso vai contra o modelo de segurança da memória aplicado pelo Rust, então, para conseguir o que temos, precisamos usar a palavra-chave unsafe, permitindo escrever um código que não esteja em conformidade 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

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

AssemblyScript

O AssemblyScript é um projeto bastante jovem que busca ser um compilador do TypeScript para o WebAssembly. No entanto, é importante observar que ele não consumirá apenas nenhum TypeScript. O AssemblyScript usa a mesma sintaxe do 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 existente no WebAssembly, mas não é necessário 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 nossa função rotate() tem, foi muito 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 nos fornecer um módulo Wasm de aproximadamente 300 bytes e nenhum código agrupador. O módulo apenas funciona com as APIs WebAssembly baunilha.

WebAssembly Forensics

A versão de 7,6 KB do Rust é surpreendentemente grande quando comparada aos outros dois idiomas. Existem algumas ferramentas no ecossistema WebAssembly que podem ajudar você a analisar seus arquivos WebAssembly (independentemente da linguagem com que foram criados) e informar o que está acontecendo, além de ajudar você a melhorar sua situação.

Twiggy

O Twiggy é outra ferramenta da equipe do WebAssembly do Rust que extrai vários dados detalhados 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 módulo. Esse último pode ser feito com o comando top do Twiggy:

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

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

Wasm-strip

wasm-strip é uma ferramenta do Kit de ferramentas binárias do WebAssembly (link em inglês), também conhecida como wabt. Ele contém algumas ferramentas que permitem inspecionar e manipular módulos do WebAssembly. O wasm2wat é um disassembler que transforma um módulo Wasm binário em um formato legível por humanos. O Wabt também contém wat2wasm, que permite transformar esse formato legível em um módulo Wab 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 da Binaryen (link em inglês). Ele usa um módulo WebAssembly e tenta otimizá-lo para tamanho e desempenho com base apenas no bytecode. Algumas ferramentas, como Emscripten, já a executam, outras não. Geralmente, é uma boa ideia tentar salvar alguns bytes extras usando essas ferramentas.

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

Com wasm-opt, podemos eliminar outros 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 dele, usando o recurso #![no_std]. Isso também desativa completamente as alocações de memória dinâmica, removendo o código do alocador do módulo. Compilar este arquivo do Rust com o

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

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

Desempenho

Antes de tirarmos conclusões com base apenas no tamanho do arquivo, seguimos essa jornada para otimizar o desempenho, não o tamanho do arquivo. Então, como medimos o desempenho e quais foram os resultados?

Como fazer uma comparação

Mesmo que o WebAssembly seja um formato de bytecode de baixo nível, ele ainda precisa ser enviado por um compilador para gerar um código de máquina específico do host. Assim como o JavaScript, o compilador funciona em vários estágios. Em outras palavras, o primeiro estágio é muito mais rápido na compilação, mas tende a gerar um código mais lento. Quando o módulo começa a ser executado, 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 será usado uma, talvez duas vezes. Portanto, na grande maioria dos casos, nunca vamos ter os benefícios do compilador de otimização. É importante ter isso em mente ao fazer um comparativo de mercado. Executar nossos módulos WebAssembly 10.000 vezes em repetição gera 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. 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 muito 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 apresentamos no início: o WebAssembly oferece um desempenho previsível. Independentemente da linguagem escolhida, 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 nossos módulos WebAssembly em todos os navegadores é de aproximadamente 80 ms.

Esforço

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

O AssemblyScript foi simples. Ele não apenas permite que você use o TypeScript para criar 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 adequado. As ferramentas do ecossistema do TypeScript, como as mais bonitas e as tslint, provavelmente vão funcionar bem.

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

C e Emscripten criaram um módulo WebAssembly muito pequeno e de alto desempenho pronto para uso, mas sem a coragem de usar um código cola e reduzi-lo às necessidades, o tamanho total (módulo WebAssembly + código cola) acaba ficando muito grande.

Conclusão

Qual linguagem você precisa usar se tiver um hot path de JS e quiser torná-lo mais rápido ou consistente com o WebAssembly? Como sempre nas perguntas sobre desempenho, a resposta é: depende. Então, o que enviamos?

Gráfico de comparação

Em comparação com o tamanho do módulo / compensação de desempenho das diferentes linguagens que usamos, a melhor escolha parece ser C ou AssemblyScript. Decidimos enviar o Rust. Há vários motivos para essa decisão: todos os codecs enviados ao Squoosh até agora são compilados usando o Emscripten. Queríamos ampliar nossos conhecimentos sobre o ecossistema do WebAssembly e usar uma linguagem diferente na produção. O AssemblyScript é uma alternativa sólida, mas o projeto é relativamente jovem, e o compilador não é tão maduro quanto o Rust.

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

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

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 foi muito responsiva e está trabalhando 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, indicou o excelente livro Rust Wasm, que contém uma seção sobre como otimizar o tamanho de arquivos (links em inglês). Seguir as instruções, principalmente as otimizações do tempo de vinculação e o gerenciamento manual de pânico, permitiu escrever um código Rust "normal" e voltar a usar Cargo (o npm do Rust) sem aumentar o tamanho do arquivo. O módulo do Rust termina com 370 bilhões após o gzip. Para mais detalhes, dê uma olhada no RP que abri no Squoosh.

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