Como escrever uma biblioteca C para o Wasm

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

O conjunto de ferramentas

Minha meta era descobrir como compilar um código C para Wasm. Há alguns rumores sobre o back-end Wasm do LLVM, então comecei a investigar. Embora seja possível compilar programas simples dessa maneira, se 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 fosse um compilador C-to-asm.js, ele amadureceu para direcionar o Wasm e está em processo de mudar para o back-end oficial do LLVM internamente. O Emscripten também fornece uma implementação compatível com Wasm da biblioteca padrão C. Usar o Emscripten. Ele executa muitas tarefas ocultas, emula um sistema de arquivos, oferece gerenciamento de memória, envolve o OpenGL com o WebGL, ou seja, muitas coisas que você não precisa desenvolver por conta própria.

Embora isso possa parecer que você precisa se preocupar com o inchaço, eu certamente me preocupei — o compilador Emscripten remove tudo o que não é necessário. Nos meus experimentos, os módulos Wasm resultantes têm o tamanho adequado para a lógica que contêm, e as equipes do Emscripten e do WebAssembly estão trabalhando para torná-los ainda menores no futuro.

Você pode instalar o Emscripten seguindo as instruções no site ou usando o Homebrew. Se você gosta de comandos dockerizados como eu e não quer instalar coisas no seu sistema apenas para brincar com o WebAssembly, há uma imagem do Docker bem mantida que pode ser usada em vez disso:

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

Compilar algo simples

Vamos usar o exemplo quase canônico de como escrever uma função em C que calcula o número de Fibonacci th:

    #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 o C, a função não será muito surpreendente. Mesmo que você não saiba C, mas saiba JavaScript, esperamos que você consiga entender o que está acontecendo aqui.

emscripten.h é um arquivo de cabeçalho fornecido pelo Emscripten. Só precisamos dele para ter acesso à macro EMSCRIPTEN_KEEPALIVE, mas ele oferece muito mais funcionalidade. Essa macro informa ao compilador para não remover uma função, mesmo que ela não seja usada. Se omitíssemos essa macro, o compilador otimizaria a função, já que ninguém a está usando.

Vamos salvar tudo isso em um arquivo chamado fib.c. Para transformá-lo em um arquivo .wasm, precisamos usar o comando do compilador emcc 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 arquivo C. Até aqui, tudo bem. -s WASM=1 informa ao Emscripten que precisa de um arquivo Wasm em vez de um arquivo asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' informa ao compilador para deixar a função cwrap() disponível no arquivo JavaScript. Mais informações sobre essa função mais adiante. -O3 informa ao compilador que ele precisa otimizar de forma agressiva. Você pode 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 precisa ser bastante pequeno. O arquivo JavaScript cuida de carregar e inicializar nosso módulo Wasm e oferecer uma API melhor. Se necessário, ele também cuidará da configuração da pilha, da pilha de heap e de outras funcionalidades que normalmente são fornecidas pelo sistema operacional ao escrever código C. Por isso, o arquivo JavaScript é um pouco maior, pesando 19 KB (~5 KB compactado 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 do JavaScript que cuida da conversão de parâmetros para algo compatível com C e invoque a função combinada. 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, o "144" vai aparecer no console, que é o 12º número de Fibonacci.

O Santo Graal: compilar uma biblioteca C

Até agora, o código C que escrevemos foi criado pensando no Wasm. No entanto, um caso de uso principal do WebAssembly é usar o ecossistema atual de bibliotecas C e permitir que os desenvolvedores as usem na Web. Essas bibliotecas geralmente dependem da biblioteca padrão do C, de um sistema operacional, de um sistema de arquivos e de outras coisas. O Emscripten oferece a maioria desses recursos, embora haja algumas limitações.

Vamos voltar à minha meta original: compilar um codificador para WebP para Wasm. O código-fonte do codec WebP é escrito em C e está disponível no GitHub, assim como uma extensa documentação da API. Esse é um bom 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 fazer o código-fonte do libwebp ser compilado, já que não precisamos de 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 de cabeçalho do libwebp usando a flag -I e também transmitir todos os arquivos C do libwebp necessários. Vou ser sincero: eu coloquei todos os arquivos C que consegui encontrar e confiei no compilador para remover tudo o que era desnecessário. Parece que 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 um pouco 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>

E vamos ver 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.

Extrair uma imagem do JavaScript para o Wasm

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

A primeira pergunta que precisamos responder é: como colocar a imagem no Wasm? Analisando a API de codificação do libwebp, ela espera uma matriz de bytes em RGB, RGBA, BGR ou BGRA. Felizmente, a API Canvas tem getImageData(), que nos dá uma 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, basta copiar os dados do JavaScript para o Wasm. Para isso, precisamos expor duas funções adicionais. Uma que aloca memória para a imagem dentro do Wasm e outra 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, ou seja, 4 bytes por pixel. O ponteiro retornado por malloc() é o endereço da primeira célula de memória daquele buffer. Quando o ponteiro é retornado ao JavaScript, ele é tratado como apenas 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 no Wasm. É hora de chamar o codificador WebP para fazer o trabalho. Analisando a documentação do WebP, WebPEncodeRGBA parece ser a opção ideal. A função usa um ponteiro para a imagem de entrada e as dimensões dela, além de uma opção de qualidade entre 0 e 100. Ele também aloca um buffer de saída para nós, que precisamos liberar usando WebPFree() depois de 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 memória dinamicamente), recorri a uma matriz global estática. Eu sei, não é C limpo (na verdade, ele depende do fato de que os ponteiros Wasm têm 32 bits de largura), mas, para simplificar as coisas, acho que esse é 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 em vigor, podemos chamar a função de codificação, pegar o ponteiro e o tamanho da imagem, colocá-los em um buffer JavaScript e liberar todos os buffers Wasm que alocamos 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, você pode encontrar um erro em que o Wasm não consegue aumentar a memória o suficiente para acomodar a imagem de entrada e a 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 nosso comando de compilação.

Pronto! Compilamos um codificador WebP e transcodificamos uma imagem JPEG para WebP. Para provar que funcionou, podemos transformar o buffer de resultados 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);

Conheça a glória de uma nova imagem WebP.

Painel de rede do DevTools e a imagem gerada.

Conclusão

Não é fácil fazer uma biblioteca C funcionar no navegador, mas, depois que você entender o processo geral e como o fluxo de dados funciona, vai ficar mais fácil e os resultados podem ser incríveis.

O WebAssembly abre muitas novas possibilidades na Web para processamento, processamento de números e jogos. Lembre-se de que o Wasm não é uma bala de prata que precisa ser aplicada a tudo, mas, quando você encontra 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 fazer isso. Vamos voltar ao exemplo de Fibonacci. Para fazer o carregamento e a execução, 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ê forneça memória a eles. A maneira de fornecer um módulo Wasm com qualquer coisa é usando o objeto imports, o segundo parâmetro da função instantiateStreaming. O módulo Wasm pode acessar tudo dentro do objeto de importações, mas nada fora dele. Por convenção, os módulos compilados pelo Emscripting esperam algumas coisas do ambiente JavaScript de carregamento:

  • Primeiro, há env.memory. O módulo Wasm não tem conhecimento do mundo externo, por assim dizer, então ele precisa de memória para funcionar. Insira WebAssembly.Memory. Ele representa um pedaço de memória linear (opcionalmente expansível). Os parâmetros de dimensionamento estão em "unidades de páginas do WebAssembly", o que significa que o código acima alocou uma página de memória, com cada página tendo um tamanho de 64 KiB. Sem uma opção maximum, a memória é teoricamente ilimitada no crescimento (o Chrome atualmente tem um limite rígido de 2 GB). A maioria dos módulos do WebAssembly não precisa definir um máximo.
  • env.STACKTOP define onde a pilha deve começar a crescer. A pilha é necessária para fazer chamadas de função e alocar memória para variáveis locais. Como não fazemos nenhuma manobra de gerenciamento de memória dinâmica no nosso pequeno programa Fibonacci, podemos usar toda a memória como uma pilha, ou seja, STACKTOP = 0.