Emscripten e npm

Como você integra o WebAssembly a essa configuração? Neste artigo, vamos usar C/C++ e Emscripten como exemplo para resolver isso.

O WebAssembly (Wasm) geralmente é definido como um primitivo de desempenho ou uma maneira de executar sua base de código C++ atual na Web. Com o squoosh.app, queremos mostrar que há pelo menos uma terceira perspectiva para o Wasm: usar os enormes ecossistemas de outras linguagens de programação. Com o Emscripten, você pode usar o código C/C++, o Rust tem suporte para Wasm integrado, e a equipe do Go está trabalhando nisso. Tenho certeza de que muitos outros idiomas vão acompanhar.

Nesses cenários, o Wasm não é o elemento central do app, mas sim uma peça de quebra-cabeça: mais um módulo. Seu app já tem JavaScript, CSS, recursos de imagem, um sistema de build focado na Web e talvez até um framework como o React. Como você integra o WebAssembly a essa configuração? Neste artigo, vamos trabalhar com C/C++ e Emscripten como exemplo.

Docker

Descobri que o Docker é um ferramenta inestimável para trabalhar com o Emscripten. As bibliotecas C/C++ costumam ser programadas para funcionar com o sistema operacional em que são criadas. É extremamente útil ter um ambiente consistente. Com o Docker, você tem um sistema Linux virtualizado que já está configurado para trabalhar com o Emscripten e tem todas as ferramentas e dependências instaladas. Se algo estiver faltando, você poderá instalá-lo sem se preocupar com como isso afeta sua máquina ou outros projetos. Se algo der errado, descarte o contêiner e comece de novo. Se funcionar uma vez, você terá certeza de que ele continuará funcionando e produzindo resultados idênticos.

O Docker Registry tem uma imagem Emscripten do trzeci (links em inglês) que tenho usado bastante.

Integração com npm

Na maioria dos casos, o ponto de entrada para um projeto da Web é o package.json do npm. Por convenção, a maioria dos projetos pode ser criada com npm install && npm run build.

Em geral, os artefatos de build produzidos pelo Emscripten (um arquivo .js e .wasm) precisam ser tratados como apenas outro módulo JavaScript e apenas outro recurso. O arquivo JavaScript pode ser processado por um bundler, como webpack ou rollup, e o arquivo Wasm precisa ser tratado como qualquer outro recurso binário maior, como imagens.

Dessa forma, os artefatos de build do Emscripten precisam ser criados antes que o processo de build "normal" seja iniciado:

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

A nova tarefa build:emscripten pode invocar o Emscripten diretamente, mas, como mencionado antes, recomendo usar o Docker para garantir que o ambiente de build seja consistente.

docker run ... trzeci/emscripten ./build.sh instrui o Docker a ativar um novo contêiner usando a imagem trzeci/emscripten e executar o comando ./build.sh. build.sh é um script de shell que você vai criar em seguida. --rm instrui o Docker a excluir o contêiner após a conclusão da execução. Dessa forma, você não cria uma coleção de imagens de máquina desatualizadas com o tempo. -v $(pwd):/src significa que você quer que o Docker "espelhe" o diretório atual ($(pwd)) em /src dentro do contêiner. Todas as alterações feitas nos arquivos no diretório /src dentro do contêiner serão espelhadas no projeto real. Esses diretórios espelhados são chamados de "montagens de vinculação".

Vamos dar uma olhada em build.sh:

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

Tem muita coisa para analisar aqui!

set -e coloca o shell no modo "fail fast". Quando algum comando retorna um erro, o script inteiro é cancelado imediatamente. Isso pode ser incrivelmente útil, já que a última saída do script sempre será uma mensagem de sucesso ou o erro que causou a falha do build.

Com as instruções export, você define os valores de algumas variáveis de ambiente. Eles permitem que você transmita outros parâmetros de linha de comando para o compilador C (CFLAGS), o compilador C++ (CXXFLAGS) e o vinculador (LDFLAGS). Todos eles recebem as configurações do otimizador via OPTIMIZE para garantir que tudo seja otimizado da mesma maneira. Há alguns valores possíveis para a variável OPTIMIZE:

  • -O0: não faz otimização. Nenhum código morto é eliminado, e o Emscripten também não reduz o código JavaScript que emite. Bom para depuração.
  • -O3: otimize de forma agressiva para melhorar o desempenho.
  • -Os: otimiza significativamente o desempenho e o tamanho como um critério secundário.
  • -Oz: otimiza bastante o tamanho, sacrificando o desempenho, se necessário.

Para a Web, recomendamos o -Os.

O comando emcc tem uma infinidade de opções. Observe que o emcc serve para ser uma "substituição simples para compiladores como GCC ou clang". Portanto, todas as flags que você conhecer do GCC também vão ser implementadas pelo emcc. A flag -s é especial porque permite configurar o Emscripten especificamente. Todas as opções disponíveis podem ser encontradas no settings.js do Emscripten, mas esse arquivo pode ser bastante pesado. Veja uma lista das sinalizações Emscripten que acredito serem as mais importantes para desenvolvedores da Web:

  • --bind ativa a vinculação.
  • O -s STRICT=1 descarta o suporte a todas as opções de build descontinuadas. Isso garante que seu código seja criado de maneira compatível com versões anteriores.
  • -s ALLOW_MEMORY_GROWTH=1 permite que a memória aumente automaticamente, se necessário. No momento em que este artigo foi escrito, o Emscripten alocará 16 MB de memória inicialmente. À medida que o código aloca blocos de memória, essa opção decide se essas operações vão fazer com que todo o módulo Wasm falhe quando a memória é esgotada ou se o código agrupador pode expandir a memória total para acomodar a alocação.
  • -s MALLOC=... escolhe qual implementação de malloc() usar. emmalloc é uma implementação pequena e rápida de malloc() especificamente para Emscripten. A alternativa é dlmalloc, uma implementação de malloc() completa. Você só vai precisar mudar para dlmalloc se estiver alocando muitos objetos pequenos com frequência ou se quiser usar linhas de execução.
  • -s EXPORT_ES6=1 transformará o código JavaScript em um módulo ES6 com uma exportação padrão que funciona com qualquer bundler. Também exige a definição de -s MODULARIZE=1.

As sinalizações a seguir nem sempre são necessárias ou são úteis apenas para fins de depuração:

  • -s FILESYSTEM=0 é uma sinalização relacionada ao Emscripten e permite emular um sistema de arquivos para você quando o código C/C++ usa operações de sistema de arquivos. Ele analisa o código que compila para decidir se vai incluir a emulação do sistema de arquivos no código agrupador. No entanto, às vezes, essa análise pode errar, e você paga 70 KB em um código agrupador extra para uma emulação de sistema de arquivos que talvez não seja necessária. Com -s FILESYSTEM=0, é possível forçar o Emscripten a não incluir esse código.
  • -g4 fará com que o Emscripten inclua informações de depuração no .wasm e também emita um arquivo de mapas de origem para o módulo Wasm. Leia mais sobre depuração com o Emscripten na seção de depuração.

Pronto! Para testar essa configuração, vamos criar um my-module.cpp pequeno:

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

E um index.html:

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

Este é um gist que contém todos os arquivos.

Para construir tudo, execute

$ npm install
$ npm run build
$ npm run serve

A navegação para localhost:8080 mostrará a seguinte saída no console do DevTools:

DevTools mostrando uma mensagem impressa via C++ e Emscripten.

Adicionar código C/C++ como dependência

Se você quiser criar uma biblioteca C/C++ para seu app da Web, o código dela precisará fazer parte do projeto. É possível adicionar o código ao repositório do projeto manualmente ou usar o npm para gerenciar esse tipo de dependência. Digamos que eu queira usar libvpx no meu webapp. libvpx é uma biblioteca C++ para codificar imagens com VP8, o codec usado em arquivos .webm. No entanto, o libvpx não está no npm e não tem um package.json. Por isso, não é possível instalá-lo usando o npm diretamente.

Para sair dessa confusão, existe o napa. O napa permite que você instale qualquer URL do repositório git como uma dependência na sua pasta node_modules.

Instale o comando napa como uma dependência:

$ npm install --save napa

e execute napa como um script de instalação:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

Quando você executa npm install, o napa clona o repositório libvpx do GitHub no seu node_modules com o nome libvpx.

Agora você pode estender seu script de build para criar libvpx. O libvpx usa configure e make para ser criado. Felizmente, o Emscripten pode ajudar a garantir que configure e make usem o compilador do Emscripten. Para essa finalidade, existem os comandos de wrapper emconfigure e emmake:

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

Uma biblioteca C/C++ é dividida em duas partes: os cabeçalhos (tradicionalmente .h ou arquivos .hpp) que definem as estruturas de dados, classes, constantes etc. que uma biblioteca expõe e a biblioteca em si (tradicionalmente, arquivos .so ou .a). Para usar a constante VPX_CODEC_ABI_VERSION da biblioteca no seu código, você precisa incluir os arquivos principais da biblioteca usando uma instrução #include:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

O problema é que o compilador não sabe onde procurar o vpxenc.h. É para isso que serve a flag -I. Ele informa ao compilador quais diretórios verificar se há arquivos principais. Além disso, também é necessário fornecer ao compilador o arquivo de biblioteca real:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

Se você executar npm run build agora, vai notar que o processo cria um novo arquivo .js e um novo arquivo .wasm, e que a página de demonstração realmente vai gerar a constante:

DevTools
mostrando uma versão ABI do libvpx impressa via emscripten.

Você também vai perceber que o processo de build leva muito tempo. O motivo para tempos de compilação longos pode variar. No caso do libvpx, isso leva muito tempo porque compila um codificador e um decodificador para o VP8 e VP9 sempre que você executa o comando de build, mesmo que os arquivos de origem não tenham mudado. Mesmo uma pequena mudança no my-module.cpp levará muito tempo para ser criada. Seria muito benéfico manter os artefatos de build do libvpx assim que eles forem criados pela primeira vez.

Uma maneira de fazer isso é usando variáveis de ambiente.

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

Este é um gist contendo todos os arquivos.

O comando eval permite definir variáveis de ambiente transmitindo parâmetros para o script de build. O comando test pulará a criação de libvpx se $SKIP_LIBVPX estiver definido (como qualquer valor).

Agora você pode compilar seu módulo, mas pular a recriação do libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Como personalizar o ambiente de build

Às vezes, as bibliotecas dependem de outras ferramentas para serem criadas. Se essas dependências estiverem ausentes no ambiente de build fornecido pela imagem Docker, será necessário adicioná-las por conta própria. Por exemplo, digamos que você também queira criar a documentação do libvpx usando doxygen. O Doxygen não está disponível no contêiner do Docker, mas é possível instalá-lo usando apt.

Se você fizer isso no build.sh, faça o download e reinstale o doxygen sempre que quiser criar sua biblioteca. Não só isso seria desperdiçado, mas também impediria você de trabalhar no projeto enquanto estiver off-line.

Aqui faz sentido criar sua própria imagem Docker. Para criar imagens do Docker, grave um Dockerfile que descreva as etapas de criação. Os Dockerfiles são bastante potentes e têm muitos comandos, mas, na maioria das vezes, é possível usar FROM, RUN e ADD. Neste caso:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

Com FROM, é possível declarar qual imagem do Docker você quer usar como ponto de partida. Escolhi trzeci/emscripten como base: a imagem que você vem usando o tempo todo. Com RUN, você instrui o Docker a executar comandos do shell dentro do contêiner. As alterações feitas por esses comandos no contêiner agora fazem parte da imagem do Docker. Para garantir que a imagem Docker tenha sido criada e esteja disponível antes da execução build.sh, é preciso ajustar seu package.json um bit:

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

Este é um gist contendo todos os arquivos.

Isso vai criar a imagem Docker, mas apenas se ela ainda não tiver sido criada. Em seguida, tudo é executado como antes, mas agora o ambiente de build tem o comando doxygen disponível, o que fará com que a documentação do libvpx também seja criada.

Conclusão

Não surpreende que o código C/C++ e o npm não sejam adequados, mas é possível fazer com que eles funcionem muito bem com algumas outras ferramentas e o isolamento que o Docker oferece. Essa configuração não funciona para todos os projetos, mas é um ponto de partida adequado que você pode ajustar de acordo com suas necessidades. Se você tiver melhorias, compartilhe.

Apêndice: como usar camadas de imagem do Docker

Uma solução alternativa é encapsular mais desses problemas com o Docker e a abordagem inteligente do Docker para armazenamento em cache. Ele executa os Dockerfiles detalhadamente e atribui ao resultado de cada etapa uma imagem própria. Essas imagens intermediárias costumam ser chamadas de "camadas". Se um comando em um Dockerfile não tiver sido alterado, o Docker não executará novamente essa etapa quando você estiver recriando o Dockerfile. Em vez disso, ele reutiliza a camada da última vez em que a imagem foi criada.

Anteriormente, era necessário algum esforço para não recriar o libvpx sempre que você criava seu app. Em vez disso, você pode mover as instruções de criação do libvpx do build.sh para o Dockerfile para usar o mecanismo de armazenamento em cache do Docker:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

Este é um gist contendo todos os arquivos.

Observe que você precisa instalar o git e clonar o libvpx manualmente, já que não há montagens de vinculação ao executar docker build. Como efeito colateral, não precisa mais de um cochilo.