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

Ele está sempre rápido, e você

Nos meus artigos anteriores, falei sobre como o WebAssembly permite que você traga o ecossistema de bibliotecas de C/C++ para a Web. Um app que faz uso extensivo de bibliotecas C/C++ é squoosh, nossa aplicativo da Web que permite compactar imagens com diversos codecs que foram compilado de 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 forma que pode ser compilado e otimizado para o sistema host muito mais rápido do que o JavaScript. O WebAssembly oferece um ambiente para executar códigos que consideram o sandbox e a incorporação desde o início.

Na minha experiência, a maioria dos problemas de desempenho 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.

Hot path

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

Essa função itera em cada pixel de uma imagem de entrada e copia-o para um uma posição diferente na imagem de saída para conseguir rotação. Para uma imagem de 4.094 px por imagem de 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 desse grande número de iterações, dois em cada três navegadores que testamos concluíram a tarefa em 2 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 forma como os navegadores otimizam o JavaScript é muito complicada, e mecanismos diferentes otimizam para coisas diferentes. Alguns otimizam para execução bruta, outros 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. Então, se quisermos um desempenho rápido e previsível em navegadores para códigos como esse, O WebAssembly pode ajudar.

WebAssembly para desempenho previsível

Em geral, o JavaScript e o WebAssembly podem alcançar o mesmo desempenho máximo. No entanto, para JavaScript, essa performance só pode ser alcançada no "caminho rápido", e muitas vezes é difícil permanecer nesse "caminho rápido". Um benefício importante O WebAssembly oferece um desempenho previsível, mesmo entre navegadores. O rígido e arquiteturas de baixo nível permitem que o compilador reforce garante que o código WebAssembly só precise ser otimizado uma vez use sempre o "caminho rápido".

Como programar para o WebAssembly

Antes, usávamos bibliotecas C/C++ e as compilamos para WebAssembly para usar a funcionalidade na Web. Não mudamos o código das bibliotecas, e acabou 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 que possamos usar vantagens que o WebAssembly tem.

Arquitetura do WebAssembly

Ao escrever para o WebAssembly, é bom entender um pouco mais sobre o que é o WebAssembly.

Para citar WebAssembly.org:

Ao compilar um código C ou Rust para o WebAssembly, você recebe uma .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 esse 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.

Algo que não percebi até analisar isso: a pilha que faz com que o WebAssembly seja uma "máquina virtual baseada em pilha" não é armazenada no bloco de memória usado pelos módulos do WebAssembly. A pilha é completamente interna à VM e inacessível para desenvolvedores da Web, exceto pelas DevTools. Por isso, é possível escrever módulos WebAssembly que não precisem de memória adicional use apenas a pilha interna da VM.

No nosso caso, vamos precisar de mais memória para permitir o acesso arbitrário aos pixels da imagem e gerar uma versão girada dela. Isso é para que serve WebAssembly.Memory.

Gerenciamento de memória

Normalmente, depois de usar memória adicional, você terá a necessidade de, de alguma forma, gerenciar essa memória. 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. Funções desse tipo também são chamadas de "alocadores". Obviamente, a implementação do alocador em uso precisa ser incluída no seu módulo WebAssembly e aumentará o tamanho do arquivo. Esse tamanho e desempenho dessas funções de gerenciamento de memória podem variar bastante dependendo do algoritmo usado. É por isso que muitos idiomas 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, da imagem de saída) antes de executarmos o módulo WebAssembly. Aqui, nós notaram uma oportunidade: tradicionalmente, passaríamos o buffer RGBA da imagem de entrada como para uma função WebAssembly e retornar a imagem girada como um 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 entrada imagem, uma para entrada e outra para saída), podemos colocar a imagem de entrada na memória do WebAssembly com JavaScript, execute o módulo WebAssembly para gerar uma Segundo, gire a imagem e use JavaScript para ler de volta o resultado. Podemos obter sem usar nenhum gerenciamento de memória.

Muitas opções

Analisou a função JavaScript original que queremos usar com o WebAssembly-fy, observe que ele é um modelo sem APIs específicas para JavaScript. Por isso, é bastante simples portar esse código para qualquer idioma. Avaliamos três idiomas diferentes que compilam 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 rápida para compiladores C conhecidos, como GCC ou clang, e é compatível com a maioria das flags. Essa é uma parte importante da missão do Emscripten, que quer tornar a compilação de código C e C++ para WebAssembly o mais fácil possível.

O acesso à memória bruta está na 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 arquitetura de 8 bits números inteiros (ou bytes). Isso transforma a variável ptr em uma matriz de maneira eficaz. começando pelo endereço de memória 0x124, que podemos usar como qualquer outra matriz, permitindo o acesso a bytes individuais para leitura e gravação. No nosso caso, estão olhando para um buffer RGBA de uma imagem que queremos reordenar para conseguir a rotação de chaves. 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 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 nossa imagem de saída começará logo após a imagem de entrada termina em:

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 portar 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. O módulo wasm gzips tem apenas cerca de 260 bytes, enquanto o código de união é de cerca de 3,5 KB após o gzip. Depois de um pouco de violino, conseguimos nos abandonar o código agrupador e instancie 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 Standard.

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 a segurança da memória e da linha de execução. O Rust também oferece suporte ao WebAssembly como um recurso principal, e a equipe do Rust contribuiu com muitas ferramentas excelentes para o ecossistema do WebAssembly.

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

Em Rust, as fatias são o que as matrizes são em C. E, assim como em C, precisamos criar frações que usam nossos endereços iniciais. Isso vai contra o modelo de segurança de memória aplicada pelo Rust. Para acessar como temos que usar a palavra-chave unsafe, o que nos permite escrever códigos que não estejam 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

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 é uma linguagem projeto jovem que busca ser um compilador do TypeScript para WebAssembly. Está No entanto, ele não consumirá apenas TypeScript. O AssemblyScript usa a mesma sintaxe do TypeScript, mas troca o padrão por conta própria. Sua biblioteca padrão modela os recursos de o WebAssembly. Isso significa que você não pode simplesmente compilar qualquer TypeScript que tenha para o WebAssembly, mas 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 nossa função rotate() tem, foi bastante fácil portar 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, basta 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 cerca de 300 bytes e nenhum código de união. O módulo apenas funciona com as APIs WebAssembly baunilha.

Análise forense do WebAssembly

Os 7,6 KB de Rust são surpreendentemente grandes em comparação com os outros dois idiomas. Há algumas ferramentas no ecossistema do WebAssembly que podem ajudar a analisar seus arquivos do WebAssembly (independentemente da linguagem usada para criá-los) e informar o que está acontecendo, além de ajudar a melhorar a situação.

Twiggy

O Twiggy é outra ferramenta da equipe de WebAssembly do Rust que extrai vários dados úteis de um módulo da WebAssembly. A ferramenta não é específica do Rust e permite inspecionar itens como gráfico de chamadas de um 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. O último pode ser feito com o comando top do Twiggy:

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

Neste caso, a maior parte do tamanho do arquivo vem do alocador. Isso foi surpreendente, já que nosso código não usa alocações dinâmicas. Outro fator importante é a 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 legível por humanos. Wabt também contém wat2wasm, que permite que você ative em um módulo Wasm binário. Embora tenhamos usado essas duas ferramentas complementares para inspecionar nossos arquivos WebAssembly, encontramos wasm-strip sejam os mais úteis. O wasm-strip remove seções e metadados desnecessários de um módulo do 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 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 apenas com base no bytecode. Algumas ferramentas, como o Emscripten, essa ferramenta, outros não. É recomendável tentar salvar alguns bytes extras usando essas ferramentas.

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

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

$ 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. Como medimos a performance e quais foram os resultados?

Como fazer a comparação

Apesar de o WebAssembly ser um formato de bytecode de baixo nível, ele ainda precisa ser enviado por um compilador para gerar o código de máquina específico do host. Assim como JavaScript, o compilador funciona em vários estágios. Em termos simples, a primeira etapa é muito mais rápida na compilação, mas tende a gerar um código mais lento. Depois que o módulo for iniciado em execução, o navegador observa quais partes são usadas com frequência e envia essas com 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. Portanto, na grande maioria dos casos, nunca vamos receber os benefícios do compilador otimizador. É importante lembrar disso ao fazer a comparação. Executar nossos módulos WebAssembly 10.000 vezes em loop daria resultados irreais. Para obter números realistas, devemos 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. Escolhi uma escala logarítmica. Também é importante comparativos de mercado estavam usando a mesma imagem de teste de 16 megapixels e o mesmo host máquina, exceto um navegador, que não pode ser executado na mesma máquina.

Sem analisar muito esses gráficos, fica claro que resolvemos o problema problema de desempenho: 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 recursos previsíveis desempenho. Independentemente da linguagem escolhida, a variação entre os navegadores e linguagens é mínimo. Para ser exato: a variação padrão do JavaScript em todos os navegadores é de cerca de 400 ms, enquanto a variação padrão de todos os módulos do WebAssembly em todos os navegadores é de cerca de 80 ms.

Esforço

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

O AssemblyScript foi simples. Além de permitir o uso do TypeScript para programar em WebAssembly, tornando a revisão de código muito fácil para meus colegas, mas também produz módulos WebAssembly sem cola que são muito pequenos desempenho. As ferramentas do ecossistema TypeScript, como as mais bonitas e as tslint, provavelmente vai funcionar.

O 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 desviar um pouco do caminho ideal para alcançar 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 para sem necessidade, o tamanho total (módulo WebAssembly + código agrupador) acaba. por ser muito grande.

Conclusão

Então, qual linguagem você deve usar se tiver um caminho de acesso rápido do JS e quiser torná-lo mais rápido ou mais consistente com o WebAssembly. Como sempre, com desempenho a resposta é: depende. O que enviamos?

Gráfico de comparação

Comparando a troca de tamanho / desempenho do módulo 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 no Squoosh até agora são compilados usando o Emscripten. Queríamos ampliar nosso conhecimento ecossistema WebAssembly e use uma linguagem diferente na produção. O AssemblyScript é uma boa alternativa, mas o projeto é relativamente novo 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 parecer muito drástico no gráfico de dispersão, não é algo tão grande na realidade: O carregamento de 500 B ou 1,6 KB mesmo com 2G leva menos de 1/10 de segundo. E Esperamos que o Rust preencha a lacuna em termos de tamanho de módulo 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 AssemblyScript, Especialmente em projetos maiores, o Rust tem mais probabilidade de a produzir códigos mais rápidos sem precisar de otimizações manuais. Mas isso não deve impedir você de usar o que for mais confortável.

Dito isso: o AssemblyScript foi uma grande descoberta. Ele permite que a Web os desenvolvedores a produzir módulos WebAssembly sem precisar aprender idioma de destino. A equipe da AssemblyScript foi muito responsiva e está ativamente trabalhando para melhorar o conjunto de ferramentas. Vamos ficar de olho AssemblyScript no futuro.

Atualização: Rust

Após publicar este artigo, Nick Fitzgerald da equipe do Rust nos indicaram o excelente livro do Rust Wasm, que contém a seção sobre otimização do tamanho do arquivo. Seguir as instruções (principalmente, ativar otimizações de tempo de link e tratamento manual de pânico) nos 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 B após o gzip. Para mais detalhes, dê uma olhada no RP que abri no Squoosh.

Agradeço a Ashley Williams, Steve Klabnik, Nick Fitzgerald e Max Graey por toda a ajuda nesta jornada.