Como escrever uma biblioteca C para o Wasm

Às vezes, você quer usar uma biblioteca disponível apenas como código C ou C++. Tradicionalmente, é aqui que você desiste. Não mais, porque agora temos o Emscripten e o WebAssembly (ou o Wasm).

O conjunto de ferramentas

Eu me defini o objetivo de descobrir como compilar códigos C existentes para o Wasm. Houve algum ruído ao redor do back-end Wasm do LLVM, então comecei a analisar isso. Embora seja possível compilar programas simples dessa forma, quando você quiser usar a biblioteca padrão do C ou até mesmo compilar vários arquivos, provavelmente terá problemas. Isso me levou à principal lição que aprendi:

Embora o Emscripten tenha usado para ser um compilador C-to-asm.js, desde então, ele amadureceu para direcionar ao Wasm e está em processo de mudança para o back-end oficial do LLVM internamente. O Emscripten também oferece uma implementação da biblioteca padrão de C compatível com Wasm. Use Emscripten. Ele carrega muito trabalho oculto, emula um sistema de arquivos, fornece gerenciamento de memória e une o OpenGL com o WebGL, um monte de coisas que você realmente não precisa ter no desenvolvimento.

Embora pareça que você precisa se preocupar com a ocupação excessiva (eu certamente me preocupo), o compilador Emscripten remove tudo o que não é necessário. Nos meus experimentos, os módulos Wasm resultantes estão dimensionados corretamente para a lógica que eles contêm, e as equipes do Emscripten e do WebAssembly estão trabalhando para torná-los ainda menores no futuro.

Você pode comprar o Emscripten seguindo as instruções no site (link em inglês) ou usando o Homebrew. Se você é fã de comandos do Docker como eu e não quer instalar coisas no seu sistema apenas para testar o WebAssembly, use uma imagem Docker bem mantida:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Compilar algo simples

Vamos pegar o exemplo quase canônico de escrever uma função em C que calcule o no número de fibonacci:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

Se você conhece C, a função em si não deve ser muito surpreendente. Mesmo que você não conheça C, mas conheça JavaScript, esperamos que consiga entender o que está acontecendo aqui.

emscripten.h é um arquivo principal fornecido pela Emscripten. Ela é necessária apenas para que tenhamos acesso à macro EMSCRIPTEN_KEEPALIVE, mas ela oferece muito mais funcionalidades. Essa macro instrui o compilador a não remover uma função, mesmo que ela pareça não ser usada. Se omitíssemos essa macro, o compilador otimizaria a função, e ninguém a estaria usando afinal.

Vamos salvar tudo isso em um arquivo chamado fib.c. Para transformá-lo em um arquivo .wasm, precisamos recorrer ao comando emcc do compilador do Emscripten:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Vamos analisar esse comando. emcc é o compilador do Emscripten. fib.c é o nosso arquivo C. Até aqui, tudo bem. -s WASM=1 instrui o Emscripten a fornecer um arquivo Wasm em vez de um arquivo asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' instrui o compilador a deixar a função cwrap() disponível no arquivo JavaScript. Falaremos mais sobre essa função posteriormente. -O3 instrui o compilador a otimizar de forma agressiva. É possível escolher números menores para diminuir o tempo de build, mas isso também vai aumentar os pacotes resultantes, já que o compilador pode não remover o código não utilizado.

Depois de executar o comando, você terá um arquivo JavaScript chamado a.out.js e um arquivo WebAssembly chamado a.out.wasm. O arquivo Wasm (ou "módulo") contém nosso código C compilado e é bem pequeno. O arquivo JavaScript cuida do carregamento e da inicialização do módulo Wasm e oferece uma API mais agradável. Se necessário, ele também vai configurar a pilha, o heap e outras funcionalidades que geralmente são fornecidas pelo sistema operacional ao escrever o código C. Dessa forma, o arquivo JavaScript é um pouco maior, pesando 19 KB (aproximadamente 5 KB com gzip).

Executar algo simples

A maneira mais fácil de carregar e executar seu módulo é usar o arquivo JavaScript gerado. Depois de carregar esse arquivo, você terá um Module global à sua disposição. Use cwrap para criar uma função nativa de JavaScript que se encarrega da conversão de parâmetros em algo compatível com C e de invocar a função encapsulada. cwrap usa o nome da função, o tipo de retorno e os tipos de argumento como argumentos, nesta ordem:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Se você executar esse código, verá "144" no console, que é o 12o número de Fibonacci.

O segredo: compilar uma biblioteca C

Até agora, o código C que escrevemos foi criado tendo o Wasm em mente. No entanto, um caso de uso principal do WebAssembly é pegar o ecossistema existente de bibliotecas C e permitir que os desenvolvedores o usem na Web. Essas bibliotecas geralmente dependem da biblioteca padrão do C, de um sistema operacional, de um sistema de arquivos e outros. A Emscripten oferece a maioria desses recursos, mas existem limitações.

Vamos voltar ao meu objetivo original: compilar um codificador para WebP para Wasm. A fonte do codec WebP é escrita em C e está disponível no GitHub, além de uma extensa documentação da API. Esse é um ótimo ponto de partida.

    $ git clone https://github.com/webmproject/libwebp

Para começar de forma simples, vamos tentar expor WebPGetEncoderVersion() de encode.h para JavaScript escrevendo um arquivo C chamado webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Esse é um bom programa simples para testar se podemos conseguir o código-fonte do libwebp para compilar, já que não exigimos parâmetros ou estruturas de dados complexas para invocar essa função.

Para compilar esse programa, precisamos informar ao compilador onde ele pode encontrar os arquivos principais do libwebp usando a flag -I e também transmitir todos os arquivos C do libwebp necessários. Vou ser honesto: acabei de fornecer todos os arquivos C que consegui encontrar e usei o compilador para remover tudo o que era desnecessário. Parece que ele funcionou muito bem!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Agora, só precisamos de HTML e JavaScript para carregar nosso novo módulo:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

Também veremos o número da versão da correção na saída:

Captura de tela do console do DevTools mostrando o número de versão
correto.

Acessar uma imagem do JavaScript no Wasm

Conseguir o número da versão do codificador é ótimo, mas codificar uma imagem real seria mais impressionante, certo? Então vamos fazer isso.

A primeira pergunta que precisamos responder é: como colocamos a imagem na Terra Wasm? Analisando a API de codificação de libwebp, ela espera uma matriz de bytes em RGB, RGBA, BGR ou BGRA. Felizmente, a API Canvas tem o getImageData(), que nos dá um Uint8ClampedArray que contém os dados da imagem em RGBA:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

Agora é "apenas" uma questão de copiar os dados da Terra do JavaScript para o Wasmland. Para isso, precisamos expor duas funções adicionais. um que aloca memória para a imagem dentro do Wasmland e outro que a libera novamente:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer aloca um buffer para a imagem RGBA. Portanto, 4 bytes por pixel. O ponteiro retornado por malloc() é o endereço da primeira célula de memória desse buffer. Quando o ponteiro for retornado para o estado final do JavaScript, ele será tratado apenas como um número. Depois de expor a função ao JavaScript usando cwrap, podemos usar esse número para encontrar o início do buffer e copiar os dados da imagem.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Grand Finale: codificar a imagem

A imagem já está disponível em Wasmland. É hora de chamar o codificador WebP para fazer o trabalho dele. Analisando a documentação do WebP, WebPEncodeRGBA parece perfeito. A função leva um ponteiro para a imagem de entrada e suas dimensões, bem como uma opção de qualidade entre 0 e 100. Ele também aloca um buffer de saída, que precisamos liberar usando WebPFree() quando terminarmos com a imagem WebP.

O resultado da operação de codificação é um buffer de saída e o comprimento dele. Como as funções em C não podem ter matrizes como tipos de retorno (a menos que aloquemos a memória dinamicamente), usei uma matriz global estática. Eu sei, não clean C (na verdade, ele depende do fato de que os ponteiros Wasm têm 32 bits de largura), mas para manter as coisas simples, acho que este é um atalho justo.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

Agora, com tudo isso no lugar, podemos chamar a função de codificação, pegar o ponteiro e o tamanho da imagem, colocá-los em um buffer de terra do JavaScript e liberar todos os buffers de Wasmland alocados no processo.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

Dependendo do tamanho da imagem, é possível encontrar um erro em que o Wasm não consegue aumentar a memória o suficiente para acomodar a imagem de entrada e de saída:

Captura de tela do console do DevTools mostrando um erro.

Felizmente, a solução para esse problema está na mensagem de erro! Só precisamos adicionar -s ALLOW_MEMORY_GROWTH=1 ao comando de compilação.

Pronto! Compilamos um codificador WebP e transcodificamos uma imagem JPEG para WebP. Para provar que ele funcionou, podemos transformar nosso buffer de resultado em um blob e usá-lo em um elemento <img>:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Conferir, a glória de uma nova imagem do WebP!

O painel de rede do DevTools e a imagem gerada.

Conclusão

Não é muito simples fazer com que uma biblioteca C funcione no navegador, mas, depois que você entende o processo geral e como o fluxo de dados funciona, fica mais fácil e os resultados podem ser surpreendentes.

O WebAssembly abre muitas novas possibilidades na Web para processamento, análise de números e jogos. Lembre-se de que o Wasm não é uma solução perfeita que precisa ser aplicada a tudo, mas quando você atinge um desses gargalos, o Wasm pode ser uma ferramenta incrivelmente útil.

Conteúdo bônus: como executar algo simples da maneira mais difícil

Se você quiser tentar evitar o arquivo JavaScript gerado, talvez seja possível. Vamos voltar ao exemplo de Fibonacci. Para carregar e executá-lo, podemos fazer o seguinte:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Os módulos WebAssembly criados pelo Emscripten não têm memória para trabalhar, a menos que você os forneça. Para fornecer um módulo Wasm com qualquer coisa, é preciso usar o objeto imports, o segundo parâmetro da função instantiateStreaming. O módulo Wasm pode acessar tudo que está dentro do objeto de importações, mas nada mais fora dele. Por convenção, os módulos compilados pela Emscripting esperam algumas coisas do ambiente de carregamento JavaScript:

  • Primeiro, temos o env.memory. O módulo Wasm não reconhece o mundo externo, então ele precisa de memória. Digite WebAssembly.Memory. Ele representa uma parte da memória linear (opcionalmente expansível). Os parâmetros de dimensionamento estão em "em unidades de páginas WebAssembly", o que significa que o código acima aloca uma página de memória, com cada página tendo um tamanho de 64 KiB. Sem uma opção maximum, o crescimento da memória é ilimitado (o Chrome tem atualmente um limite rígido de 2 GB). A maioria dos módulos WebAssembly não precisa definir um máximo.
  • env.STACKTOP define onde a pilha deve começar a crescer. Ela é necessária para fazer chamadas de função e alocar memória para variáveis locais. Como não fazemos nenhum manuseio dinâmico de gerenciamento de memória no nosso pequeno programa Fibonacci, podemos usar a memória inteira como uma pilha. Portanto, STACKTOP = 0.