Emscripten y npm

¿Cómo integras WebAssembly en esta configuración? En este artículo, lo resolveremos con C/C++ y Emscripten como ejemplo.

WebAssembly (wasm) a menudo se presenta como una primitiva de rendimiento o una forma de ejecutar tu base de código C++ existente en la Web. Con squoosh.app, queríamos mostrar que hay, al menos, una tercera perspectiva para el wasm: aprovechar los enormes ecosistemas de otros lenguajes de programación. Con Emscripten, puedes usar código C/C++, Rust tiene compatibilidad con wasm integrada y el equipo de Go también está trabajando en ello. Estoy seguro de que vendrán muchos otros lenguajes.

En estas situaciones, wasm no es el elemento central de tu app, sino más bien una pieza de rompecabezas: otro módulo. Tu app ya tiene JavaScript, CSS, recursos de imagen, un sistema de compilación centrado en la Web y tal vez incluso un framework como React. ¿Cómo se integra WebAssembly en esta configuración? En este artículo, lo resolveremos con C/C++ y Emscripten como ejemplo.

Docker

Descubrí que Docker es muy valioso cuando trabaja con Emscripten. Por lo general, las bibliotecas de C/C++ están escritas para funcionar con el sistema operativo en el que se compilan. Es increíblemente útil tener un entorno constante. Con Docker, obtienes un sistema Linux virtualizado que ya está configurado para funcionar con Emscripten y tiene todas las herramientas y dependencias instaladas. Si falta algo, puedes instalarlo sin preocuparte por cómo afecta a tu propia máquina o a tus otros proyectos. Si algo sale mal, desecha el contenedor y comienza de nuevo. Si funciona una vez, puedes tener la seguridad de que seguirá funcionando y producirá resultados idénticos.

El registro de Docker tiene una imagen de Emscripten de trzeci que uso mucho.

Integración en npm

En la mayoría de los casos, el punto de entrada a un proyecto web es el package.json de npm. Por convención, la mayoría de los proyectos se pueden compilar con npm install && npm run build.

En general, los artefactos de compilación que produce Emscripten (un archivo .js y un archivo .wasm) deben tratarse como otro módulo de JavaScript y como otro elemento. Un agrupador, como webpack o de fusión, puede controlar el archivo JavaScript, y el archivo wasm debe tratarse como cualquier otro recurso binario más grande, como imágenes.

Por lo tanto, los artefactos de compilación de Emscripten deben compilarse antes de que se inicie el proceso de compilación “normal”:

{
    "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",
    // ...
    },
    // ...
}

La nueva tarea build:emscripten podría invocar a Emscripten directamente, pero, como se mencionó antes, recomendamos usar Docker para asegurarte de que el entorno de compilación sea coherente.

docker run ... trzeci/emscripten ./build.sh le indica a Docker que inicie un contenedor nuevo con la imagen trzeci/emscripten y ejecute el comando ./build.sh. build.sh es una secuencia de comandos de shell que escribirás a continuación. --rm le indica a Docker que borre el contenedor una vez que termine de ejecutarse. De esta manera, no compilas una colección de imágenes de máquina inactivas a lo largo del tiempo. -v $(pwd):/src significa que quieres que Docker “duplique” el directorio actual ($(pwd)) en /src dentro del contenedor. Cualquier cambio que realices en los archivos del directorio /src dentro del contenedor se replicará en tu proyecto real. Estos directorios duplicados se denominan “activaciones de vinculación”.

Veamos 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 "============================================="

Hay mucho que analizar aquí.

set -e pone el shell en el modo “fail fast”. Si algún comando de la secuencia de comandos muestra un error, toda la secuencia de comandos se aborta de inmediato. Esto puede ser increíblemente útil, ya que el último resultado de la secuencia de comandos siempre será un mensaje de éxito o el error que causó que la compilación fallara.

Con las sentencias export, defines los valores de un par de variables de entorno. Te permiten pasar parámetros adicionales de la línea de comandos al compilador C (CFLAGS), al compilador de C++ (CXXFLAGS) y al vinculador (LDFLAGS). Todos reciben la configuración del optimizador a través de OPTIMIZE para garantizar que todo se optimice de la misma manera. Hay algunos valores posibles para la variable OPTIMIZE:

  • -O0: No realices ninguna optimización. No se elimina el código muerto y Emscripten tampoco reduce el código JavaScript que emite. Es útil para la depuración.
  • -O3: Realiza optimizaciones agresivas para mejorar el rendimiento.
  • -Os: Realiza optimizaciones agresivas para el rendimiento y el tamaño como criterio secundario.
  • -Oz: Realiza optimizaciones de forma agresiva para mejorar el tamaño y sacrifica el rendimiento si es necesario.

Para la Web, recomiendo principalmente -Os.

El comando emcc tiene un sinfín de opciones propias. Ten en cuenta que se supone que emcc es un "reemplazo directo para compiladores como GCC o clang". Por lo tanto, es probable que emcc también implemente todas las marcas que conozcas de GCC. La marca -s es especial porque nos permite configurar Emscripten de forma específica. Puedes encontrar todas las opciones disponibles en el archivo settings.js de Emscripten, pero ese archivo puede resultar abrumador. Esta es una lista de las marcas Emscripten que creo que son más importantes para los desarrolladores web:

  • --bind habilita embind.
  • -s STRICT=1 deja de ser compatible con todas las opciones de compilación obsoletas. Esto garantiza que tu código se compile de manera retrocompatible.
  • -s ALLOW_MEMORY_GROWTH=1 permite que la memoria aumente automáticamente si es necesario. En el momento de escribir este artículo, Emscripten asignará 16 MB de memoria inicialmente. A medida que tu código asigna fragmentos de memoria, esta opción decide si estas operaciones harán que falle todo el módulo wasm cuando se agote la memoria o si el código de unión puede expandir la memoria total para adaptarse a la asignación.
  • -s MALLOC=... elige qué implementación de malloc() usar. emmalloc es una implementación malloc() pequeña y rápida específicamente para Emscripten. La alternativa es dlmalloc, una implementación completa de malloc(). Solo debes cambiar a dlmalloc si asignas muchos objetos pequeños con frecuencia o si deseas usar subprocesos.
  • -s EXPORT_ES6=1 convertirá el código JavaScript en un módulo ES6 con una exportación predeterminada que funciona con cualquier empaquetador. También requiere que se configure -s MODULARIZE=1.

Las siguientes marcas no siempre son necesarias o solo son útiles para depurar:

  • -s FILESYSTEM=0 es una marca relacionada con Emscripten y su capacidad de emular un sistema de archivos por ti cuando tu código C/C++ usa operaciones de sistema de archivos. Realiza un análisis del código que compila para decidir si incluir o no la emulación del sistema de archivos en el código de unión. Sin embargo, a veces, en este análisis se puede equivocar y pagas 70 KB en código de adhesión adicional para una emulación de sistema de archivos que quizás no necesites. Con -s FILESYSTEM=0, puedes forzar la inclusión de Emscripten para que no incluya este código.
  • -g4 hará que Emscripten incluya información de depuración en .wasm y también emita un archivo de mapas de origen para el módulo wasm. Puedes obtener más información sobre la depuración con Emscripten en la sección de depuración.

Listo. Para probar esta configuración, crearemos un pequeño 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);
    }

Y un 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 es un gist que contiene todos los archivos).

Para compilar todo, ejecuta

$ npm install
$ npm run build
$ npm run serve

Si navegas a localhost:8080, se debería mostrar el siguiente resultado en la consola de Herramientas para desarrolladores:

Herramientas para desarrolladores que muestran un mensaje impreso a través de C++ y Emscripten.

Cómo agregar código C/C++ como dependencia

Si deseas compilar una biblioteca de C/C++ para tu app web, necesitas que su código forme parte del proyecto. Puedes agregar el código al repositorio de tu proyecto de forma manual o usar npm para administrar también este tipo de dependencias. Supongamos que quiero usar libvpx en mi aplicación web. libvpx es una biblioteca de C++ para codificar imágenes con VP8, el códec que se usa en los archivos .webm. Sin embargo, libvpx no está en npm y no tiene un package.json, por lo que no puedo instalarlo directamente con npm.

Para resolver este problema, tenemos napa, que te permite instalar cualquier URL de repositorio de Git como una dependencia en tu carpeta node_modules.

Instala napa como una dependencia:

$ npm install --save napa

Asegúrate de ejecutar napa como una secuencia de comandos de instalación:

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

Cuando ejecutas npm install, napa se encarga de clonar el repositorio de GitHub de libvpx en tu node_modules con el nombre libvpx.

Ahora puedes extender tu secuencia de comandos de compilación para compilar libvpx. libvpx usa configure y make para compilarse. Afortunadamente, Emscripten puede ayudar a garantizar que configure y make usen el compilador de Emscripten. Para ello, existen los comandos del wrapper emconfigure y 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 ...

Una biblioteca C/C++ se divide en dos partes: los encabezados (tradicionalmente, archivos .h o .hpp) que definen las estructuras de datos, las clases, las constantes, etc. que expone una biblioteca, y la biblioteca real (tradicionalmente, archivos .so o .a). Para usar la constante VPX_CODEC_ABI_VERSION de la biblioteca en tu código, debes incluir los archivos de encabezado de la biblioteca mediante una sentencia #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;
}

El problema es que el compilador no sabe dónde buscar vpxenc.h. Para esto se usa la marca -I. Le indica al compilador qué directorios debe buscar archivos de encabezado. Además, debes proporcionarle al compilador el archivo real de la biblioteca:

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

Si ejecutas npm run build ahora, verás que el proceso compila un nuevo archivo .js y un nuevo archivo .wasm, y que la página de demostración, de hecho, mostrará la constante:

DevTools muestra la versión ABI de libvpx impresa a través de emscripten.

También notarás que el proceso de compilación lleva mucho tiempo. El motivo de los tiempos de compilación prolongados puede variar. En el caso de libvpx, tarda mucho tiempo porque compila un codificador y un decodificador para VP8 y VP9 cada vez que ejecutas el comando de compilación, aunque los archivos de origen no hayan cambiado. Incluso un cambio pequeño en tu my-module.cpp tardará mucho tiempo en compilarse. Sería muy beneficioso mantener los artefactos de compilación de libvpx una vez que se hayan compilado por primera vez.

Una forma de lograrlo es usar variables de entorno.

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

(Aquí hay un gist que contiene todos los archivos).

El comando eval nos permite configurar variables de entorno mediante el paso de parámetros a la secuencia de comandos de compilación. El comando test omitirá la compilación de libvpx si se configura $SKIP_LIBVPX (en cualquier valor).

Ahora puedes compilar tu módulo, pero omitir la recompilación de libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personaliza el entorno de compilación

A veces, las bibliotecas dependen de herramientas adicionales para su compilación. Si faltan estas dependencias en el entorno de compilación que proporciona la imagen de Docker, debes agregarlas por tu cuenta. A modo de ejemplo, supongamos que también quieres compilar la documentación de libvpx con doxygen. Doxygen no está disponible dentro del contenedor de Docker, pero puedes instalarlo con apt.

Si lo hicieras en tu build.sh, volverías a descargar y a instalar Doxygen cada vez que quieras compilar tu biblioteca. No solo sería un desperdicio, sino que también te impediría trabajar en tu proyecto sin conexión.

Aquí tiene sentido compilar tu propia imagen de Docker. Para compilar imágenes de Docker, se debe escribir un Dockerfile que describa los pasos de compilación. Los Dockerfiles son bastante poderosos y tienen muchos comandos, pero la mayor parte del tiempo puedes comenzar solo con FROM, RUN y ADD. En este caso, ocurre lo siguiente:

FROM trzeci/emscripten

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

Con FROM, puedes declarar qué imagen de Docker deseas usar como punto de partida. Elegí trzeci/emscripten como base, la imagen que usaste todo el tiempo. Con RUN, le indicas a Docker que ejecute comandos de shell dentro del contenedor. Sin importar los cambios que realicen estos comandos en el contenedor, ahora forma parte de la imagen de Docker. Para asegurarte de que tu imagen de Docker se haya compilado y esté disponible antes de ejecutar build.sh, debes ajustar un poco tu 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",
    // ...
    },
    // ...
}

(Aquí hay un gist que contiene todos los archivos).

Esto compilará tu imagen de Docker, pero solo si aún no se compiló. Luego, todo se ejecuta como antes, pero ahora el entorno de compilación tiene el comando doxygen disponible, lo que hará que la documentación de libvpx también se compile.

Conclusión

No es de extrañar que el código C/C++ y npm no sean una combinación natural, pero puedes lograr que funcionen de forma bastante cómoda con algunas herramientas adicionales y el aislamiento que proporciona Docker. Esta configuración no funcionará para todos los proyectos, pero es un buen punto de partida que puedes ajustar según tus necesidades. Si tienes mejoras, compártelas.

Apéndice: Cómo usar las capas de imágenes de Docker

Una solución alternativa es encapsular más de estos problemas con Docker y su enfoque inteligente de almacenamiento en caché. Docker ejecuta los Dockerfiles paso a paso y asigna una imagen propia al resultado de cada paso. Estas imágenes intermedias a menudo se denominan "capas". Si un comando en un Dockerfile no ha cambiado, Docker no volverá a ejecutar ese paso cuando vuelvas a compilar el Dockerfile. En cambio, reutiliza la capa de la última vez que se compiló la imagen.

Antes, tenías que hacer un esfuerzo para no volver a compilar libvpx cada vez que compilabas tu app. En su lugar, puedes mover las instrucciones de compilación para libvpx de tu build.sh a Dockerfile para usar el mecanismo de almacenamiento en caché de 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

(Aquí hay un gist que contiene todos los archivos).

Ten en cuenta que debes instalar git y clonar libvpx de forma manual, ya que no tienes activaciones de vinculación cuando ejecutas docker build. Como efecto secundario, ya no se necesita Napa.