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

Ele é sempre rápido, e você

Na minha experiência anterior artigos sobre como o WebAssembly permite que você leve 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 tal forma que ele pode ser compilado e otimizado para o sistema host muito mais rápido do que o JavaScript pode. O WebAssembly oferece um ambiente para executar códigos sandbox e embeddings em mente desde o início.

Pela minha experiência, a maioria dos problemas de desempenho na Web é causada e excesso de pintura, mas de vez em quando um aplicativo precisa fazer uma uma tarefa computacionalmente cara e que leva muito tempo. O WebAssembly pode ajudar aqui.

O caminho quente

No squoosh, criamos uma função JavaScript que gira um buffer de imagem em múltiplos de 90 graus. OffscreenCanvas seria ideal para isso não é compatível com os navegadores que visamos e um pouco bugs no Chrome.

Essa função itera em cada pixel de uma imagem de entrada e o copia 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 dos grandes número de iterações, dois em cada três navegadores que testamos terminam 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 otimizam para interação com o DOM. Em neste 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, 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", muitas vezes é complicado permanecer nesse "caminho rápido". Um benefício importante O WebAssembly oferece desempenho previsível, mesmo entre navegadores. O rigor 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 escrever para WebAssembly

Antes, pegamos bibliotecas C/C++ e as compilamos no WebAssembly para usar 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 das exportações que este disponibiliza para o host (funções, constantes, blocos de memória) e obviamente, as instruções binárias para as funções contidas nelas.

Algo que eu não percebi até ver isso: a pilha que faz WebAssembly, uma "máquina virtual baseada em pilha" não é armazenado em uma parte usada pelos módulos WebAssembly. A pilha é totalmente interna à VM e inacessível para desenvolvedores Web (exceto pelo DevTools). Por isso, é possível escrever módulos WebAssembly que não precisem de memória adicional use apenas a pilha interna da VM.

Em nosso caso, precisaremos de memória adicional para permitir acesso arbitrário aos pixels da nossa imagem e gerar uma versão rotacionada dessa imagem. 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. 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 seu módulo WebAssembly e aumentará o tamanho do arquivo. Esse tamanho e desempenho dessas funções de gerenciamento de memória pode variar bastante, dependendo o algoritmo usado. Por isso, muitas linguagens oferecem diversas implementações ("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 o resultado de volta. Podemos obter sem usar nenhum gerenciamento de memória.

Perfeito para escolher

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, ela deve ser bastante reta para transferir esse código para qualquer linguagem. Avaliamos três idiomas diferentes que compilam para 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 da Emscripten é funcionar como um substituto simples para compiladores C conhecidos, como GCC ou clang e é, na maioria das vezes, compatível com sinalizações. Essa é uma parte essencial da missão da Emscripten já que quer tornar a compilação de códigos C e C++ existentes para o WebAssembly tão fácil quanto sempre que possível.

O acesso à memória bruta é uma natureza do C, e ponteiros existem para essa mesma 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 4 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 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 o código agrupador tem cerca de 3,5 KB depois do 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. Ferrugem também oferece suporte ao WebAssembly como recurso principal, e a equipe do Rust contribuíram com muitas ferramentas excelentes para o ecossistema do WebAssembly.

Uma dessas ferramentas é o wasm-pack, que grupo de trabalho rustwasm. 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. wasm-pack é extremamente experiência conveniente, mas atualmente só funciona para Rust. O grupo é 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 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 um código que não esteja 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á mas é importante observar que ele não vai consumir apenas TypeScript. O AssemblyScript usa a mesma sintaxe do TypeScript, mas altera o padrão por conta própria. Sua biblioteca padrão modela os recursos de o WebAssembly. Isso significa que não é possível compilar um TypeScript para o WebAssembly, mas isso significa que você não precisa aprender um novo de programação para criar 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, ela 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 a outros dois idiomas. algumas ferramentas do ecossistema WebAssembly que podem ajudar a analisar seus arquivos WebAssembly (independentemente da linguagem com que foram criados) e dizer o que está acontecendo e também ajudá-lo a melhorar sua situação.

Twiggy

O Twiggy é outra ferramenta da biblioteca Rust Equipe do WebAssembly que extrai vários dados informativos de um WebAssembly mais tarde neste módulo. 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. A A segunda opção pode ser feita 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 é surpreendente, já que nosso código não usa alocações dinâmicas. Outro fator que contribui muito é um "nome de função" subseçã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 um algumas ferramentas que permitem inspecionar e manipular módulos 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. wasm-strip remove seções desnecessárias e metadados 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 apenas com base no bytecode. Algumas ferramentas, como o Emscripten, essa ferramenta, outros 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 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 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 do 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 seja ainda maior do que os módulos gerados por C e AssemblyScript, é 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 quais foram os resultados?

Como fazer uma comparação

Embora o WebAssembly seja um formato de bytecode de baixo nível, ele ainda precisa ser enviado com um compilador para gerar código de máquina específico do host. Assim como JavaScript, o compilador funciona em vários estágios. Para simplificar, a primeira etapa é muito mais rápido na compilação, mas tende a gerar códigos mais lentos. 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 ter isso em mente comparativos de mercado. Executar nossos módulos WebAssembly 10.000 vezes em loop daria resultados irrealistas. 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, comparar por navegador. No segundo gráfico, comparamos por idioma usado. Não se esqueça observe que escolhi uma escala de tempo 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: o desvio padrão do JavaScript em todos os navegadores é de aproximadamente 400 ms, enquanto o desvio padrão de todas Os módulos WebAssembly em todos os navegadores têm 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 portanto, não vou criar gráficos, mas gostaria de fazer algumas coisas destacar:

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.

Rust em combinação com wasm-pack também é muito conveniente, mas é excelente em projetos maiores do WebAssembly em que vinculações e gerenciamento de memória necessários. Tivemos que divergir um pouco do caminho da felicidade para alcançar uma tamanho do arquivo.

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 a sem necessidade, o tamanho total (módulo WebAssembly + código agrupador) acaba. por ser muito grande.

Conclusão

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

Gráfico de comparação

Comparação entre o tamanho do módulo e as vantagens e desvantagens de cada idioma que usamos, a melhor escolha parece ser C ou AssemblyScript. Decidimos enviar o Rust. vários motivos para essa decisão: todos os codecs enviados ao Squoosh até o momento 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 jovem e o compilador não é tão maduro quanto o Rust.

Embora a diferença no tamanho do arquivo entre o Rust e o tamanho de outros idiomas parece muito drástico no gráfico de dispersão, não é algo tão grande na realidade: Carregar 500 B ou 1,6 KB mesmo em 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 isso não vai impedir você de usar aquilo com que se sente 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 indica o excelente livro do Rust Wasm, que contém a seção sobre otimização do tamanho do arquivo. Seguir instruções (principalmente a ativação de otimizações de tempo de link e lidar com pânico) nos permitiram escrever código Rust “normal” e voltar a usar Cargo (a 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.

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