Emscripten e npm

Como integrar o WebAssembly a essa configuração? Neste artigo, vamos trabalhar com C/C++ e Emscripten como exemplo.

O WebAssembly (wasm) é frequentemente enquadrado como uma primitiva de performance ou uma maneira de executar a base de código C++ 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, é possível usar código C/C++, o Rust tem suporte a wasm integrado e a equipe Go também está trabalhando nisso. Tenho certeza de que muitos outros idiomas serão adicionados.

Nesses cenários, o WASM não é o foco do app, mas sim uma peça do 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 integrar o WebAssembly a essa configuração? Neste artigo, vamos trabalhar com C/C++ e Emscripten como exemplo.

Docker

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

O Docker Registry tem uma imagem Emscripten de trzeci 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 um .wasm) precisam ser tratados como apenas outro módulo JavaScript e apenas mais um recurso. O arquivo JavaScript pode ser processado por um bundler como webpack ou rollup, e o arquivo wasm precisa ser tratado como qualquer outro ativo 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 anteriormente, recomendo usar o Docker para garantir que o ambiente de build seja consistente.

docker run ... trzeci/emscripten ./build.sh informa ao Docker para iniciar um novo contêiner usando a imagem trzeci/emscripten e executar o comando ./build.sh. build.sh é um script de shell que você vai escrever em seguida. --rm diz ao Docker para excluir o contêiner depois de concluir a execução. Dessa forma, você não cria uma coleção de imagens de máquina desatualizadas ao longo do 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 em 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 "============================================="

Há muito o que analisar aqui!

set -e coloca o shell no modo "fail fast". Se algum comando no script retornar um erro, todo o script será abortado imediatamente. Isso pode ser incrivelmente útil, porque a última saída do script será sempre 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 transmitir parâmetros de linha de comando adicionais para o compilador C (CFLAGS), o compilador C++ (CXXFLAGS) e o vinculador (LDFLAGS). Todos eles recebem as configurações do otimizador por 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 inativo é eliminado, e o Emscripten também não reduz o código JavaScript emitido. Bom para depuração.
  • -O3: otimize de forma agressiva para melhorar o desempenho.
  • -Os: otimiza de forma intensa o desempenho e o tamanho como um critério secundário.
  • -Oz: otimize o tamanho de forma dinâmica, sacrificando o desempenho, se necessário.

Para a Web, recomendo -Os.

O comando emcc tem uma infinidade de opções próprias. O emcc deve ser uma "substituição direta de compiladores como GCC ou clang". Portanto, todas as sinalizações do GCC provavelmente também serão implementadas por 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. Aqui está uma lista das flags Emscripten que considero mais importantes para os desenvolvedores Web:

  • --bind ativa embind (em inglês).
  • -s STRICT=1 descarta o suporte a todas as opções de build descontinuadas. Isso garante que o código seja criado de maneira compatível com versões futuras.
  • -s ALLOW_MEMORY_GROWTH=1 permite que a memória seja aumentada automaticamente, se necessário. No momento da gravação, o Emscripten vai alocar 16 MB de memória inicialmente. À medida que o código aloca pedaços 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 for esgotada ou se o código de união poderá expandir a memória total para acomodar a alocação.
  • O -s MALLOC=... escolhe qual implementação de malloc() usar. emmalloc é uma implementação pequena e rápida de malloc() especificamente para o Emscripten. A alternativa é dlmalloc, uma implementação completa de malloc(). Você só precisa mudar para dlmalloc se estiver alocando muitos objetos pequenos com frequência ou se quiser usar a linha de execução.
  • -s EXPORT_ES6=1 vai transformar o código JavaScript em um módulo ES6 com uma exportação padrão que funciona com qualquer bundler. Também requer que -s MODULARIZE=1 seja definido.

As flags a seguir não são sempre necessárias ou são úteis apenas para fins de depuração:

  • -s FILESYSTEM=0 é uma flag relacionada ao Emscripten e à capacidade de emular um sistema de arquivos para você quando o código C/C++ usa operações do sistema de arquivos. Ele faz algumas análises no código compilado para decidir se vai incluir ou não a emulação do sistema de arquivos no código glue. No entanto, às vezes, essa análise pode estar errada e você paga 70 kB em código de união adicional para uma emulação de sistema de arquivos que talvez não precise. Com -s FILESYSTEM=0, é possível forçar o Emscripten a não incluir esse código.
  • O -g4 vai fazer 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. Saiba mais sobre como depurar com o Emscripten na seção de depuração.

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

    #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 uma 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>

(Aqui está um gist contendo todos os arquivos.)

Para criar tudo, execute

$ npm install
$ npm run build
$ npm run serve

Navegar para localhost:8080 vai mostrar a seguinte saída no console do DevTools:

O DevTools mostrando uma mensagem impressa em C++ e Emscripten.

Como adicionar código C/C++ como uma dependência

Se você quiser criar uma biblioteca C/C++ para seu app da Web, o código dela precisa fazer parte do projeto. Você pode adicionar o código ao repositório do projeto manualmente ou usar o npm para gerenciar esse tipo de dependência também. 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, a libvpx não está no npm e não tem um package.json. Por isso, não é possível instalá-la usando diretamente o npm.

Para sair desse enigma, existe o napa, que permite instalar qualquer URL de repositório Git como uma dependência na pasta node_modules.

Instale o napa como uma dependência:

$ npm install --save napa

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 cuida da clonagem do repositório libvpx do GitHub no seu node_modules com o nome libvpx.

Agora você pode estender seu script de build para criar o 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, há 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 arquivos .h ou .hpp) que definem as estruturas de dados, classes, constantes etc. que uma biblioteca expõe e a biblioteca real (tradicionalmente arquivos .so ou .a). Para usar a constante VPX_CODEC_ABI_VERSION da biblioteca no código, é necessário incluir os arquivos de cabeçalho 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 vpxenc.h. É para isso que serve a flag -I. Ele informa ao compilador quais diretórios precisam verificar os arquivos principais. Além disso, você também precisa 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 ver que o processo cria um novo .js e um novo arquivo .wasm e que a página de demonstração vai gerar a constante:

As Ferramentas do desenvolvedor
mostrando a versão ABI da libvpx mostrada por emscripten.

O processo de build leva muito tempo. O motivo dos tempos de build longos pode variar. No caso do libvpx, leva muito tempo porque ele compila um codificador e um decodificador para 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 vai levar muito tempo para ser criada. Seria muito benéfico manter os artefatos de build do libvpx assim que 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 ...

Veja um gist que contém todos os arquivos.

Com o comando eval, podemos definir variáveis de ambiente transmitindo parâmetros para o script de build. O comando test vai pular a criação do libvpx se $SKIP_LIBVPX for definido (para qualquer valor).

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

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personalizar o ambiente de build

Às vezes, as bibliotecas dependem de outras ferramentas para serem criadas. Se essas dependências não estiverem no ambiente de build fornecido pela imagem do Docker, você precisará adicionar. Por exemplo, digamos que você também queira criar a documentação do libvpx usando o doxygen. O Doxygen não está disponível no contêiner do Docker, mas pode ser instalado usando apt.

Se você fizer isso no build.sh, será necessário fazer o download e reinstalar o doxigênio sempre que quiser criar a biblioteca. Isso não seria apenas um desperdício, mas também impediria que você trabalhasse no projeto off-line.

Nesse caso, faz sentido criar sua própria imagem do Docker. As imagens do Docker são criadas escrevendo um Dockerfile que descreve as etapas de build. Os Dockerfiles são bastante poderosos e têm muitos comandos, mas, na maioria das vezes, você pode usar apenas FROM, RUN e ADD. Nesse 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ê está usando o tempo todo. Com RUN, você instrui o Docker a executar comandos do shell dentro do contêiner. As alterações que esses comandos fazem no contêiner agora fazem parte da imagem Docker. Para garantir que a imagem Docker tenha sido criada e esteja disponível antes de executar build.sh, é necessário ajustar um bit de package.json:

{
    // ...
    "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 que contém todos os arquivos.

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

Conclusão

Não é de surpreender que o código C/C++ e o npm não sejam uma combinação natural, mas você pode fazer com que ele funcione de forma bastante confortável com algumas ferramentas adicionais e o isolamento que o Docker oferece. Essa configuração não vai funcionar para todos os projetos, mas é um bom ponto de partida que pode ser ajustado de acordo com suas necessidades. Se você tem alguma melhoria, por favor, compartilhe.

Apêndice: como usar as 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. O Docker executa os Dockerfiles passo a passo e atribui ao resultado de cada etapa uma imagem própria. Essas imagens intermediárias geralmente são chamadas de "camadas". Se um comando em um Dockerfile não for alterado, o Docker não vai executar essa etapa novamente quando você recriar o Dockerfile. Em vez disso, ele reutiliza a camada da última vez que a imagem foi criada.

Antes, era preciso fazer um esforço para não recriar o libvpx sempre que você criava o app. Em vez disso, é possível 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

Confira um registro com todos os arquivos.

Você precisa instalar manualmente o git e clonar o libvpx, porque não há montagens de vinculação ao executar docker build. Como efeito colateral, não é mais necessário usar Napa.