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) suele ser enmarcado como una primitiva de rendimiento o una forma de ejecutar tu código C++ existente de base de código en la Web. Con squoosh.app, queríamos mostrar que hay al menos una tercera perspectiva para el wasm: hacer uso de la enorme ecosistemas de otros lenguajes de programación. Con Emscripten, puedes usar código C/C++, Cuenta con compatibilidad Wasm integrada, y la interfaz Go también está trabajando en ello. Soy seguro que seguirán muchos otros lenguajes.

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

Docker

Descubrí que Docker es muy valioso cuando trabaja con Emscripten. C y C++ suelen escribirse para funcionar con el sistema operativo en el que se compilan. Es increíblemente útil tener un entorno constante. Con Docker, obtienes un virtualizado que ya está configurado para funcionar con Emscripten y tiene todas las herramientas y dependencias instaladas. Si falta algo, puedes simplemente instálala sin preocuparte por cómo afecta a tu propia máquina o otros proyectos. Si algo sale mal, desecha el contenedor y comienza de nuevo. Si funciona una vez, puedes estar seguro de que seguirá funcionando y producen resultados idénticos.

El registro de Docker tiene un texto escrito imagen de trzeci que he usado ampliamente.

Integración en npm

En la mayoría de los casos, el punto de entrada a un proyecto web es el package.json 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 producidos por Emscripten (un .js y un .wasm ) se deben tratar como a otro módulo de JavaScript y solo a otro activo. Un agrupador como Webpack o rollup puede controlar el archivo JavaScript. y el archivo Wasm debe tratarse como cualquier otro recurso binario más grande, imágenes de contenedores.

Por lo tanto, los artefactos de compilación de Emscripten deben compilarse antes que tu modelo "normal" proceso de compilación:

{
    "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, recomiendo usar Docker para asegurarse de que el entorno de compilación esté coherentes.

docker run ... trzeci/emscripten ./build.sh le indica a Docker que inicie una nueva con la imagen trzeci/emscripten y ejecuta el comando ./build.sh. build.sh es una secuencia de comandos de shell que escribirás a continuación. --rm lo dice Docker para borrar el contenedor cuando haya terminado de ejecutarse. De esta manera, no compilas y crear una colección de imágenes de máquinas inactivas a lo largo del tiempo. -v $(pwd):/src significa que deseas que Docker “duplica” el directorio actual ($(pwd)) a /src dentro de el contenedor. Cualquier cambio que realices en los archivos del directorio /src dentro del contenedor se duplicará en tu proyecto real. Estos directorios duplicados se llaman “activaciones de vinculación”.

Analicemos 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 coloca la shell en una falla rápida. . Si algún comando de la secuencia de comandos se muestra un error, toda la secuencia de comandos se anula de inmediato. Puede ser increíblemente útil, ya que el resultado final del guion siempre será un éxito. o el error que causó la falla de la compilación.

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

  • -O0: No realices ninguna optimización. No se elimina código muerto y se escribe tampoco reduce el código JavaScript que emite. Ideal para la depuración.
  • -O3: Realiza optimizaciones de forma agresiva para mejorar el rendimiento.
  • -Os: Realiza optimizaciones intensas para mejorar el rendimiento y el tamaño como una estrategia secundaria. criterio.
  • -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 la ECM que debería ser un "reemplazo directo de compiladores como GCC o clang". Todo las funciones experimentales de GCC probablemente serán implementadas por emcc como en la nube. La marca -s es especial porque nos permite configurar Emscripten específicamente. Todas las opciones disponibles están en la carpeta settings.js, pero ese archivo puede ser abrumador. Esta es una lista de las marcas de Emscripten que considero 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 compile de manera compatible con versiones futuras.
  • -s ALLOW_MEMORY_GROWTH=1 permite que la memoria aumente automáticamente si necesario. Al momento de la redacción, Emscripten asignará 16 MB de memoria al principio. A medida que tu código asigna fragmentos de memoria, esta opción decide si estas operaciones harán que todo el módulo wasm falle cuando la memoria se agote o si se permite que el código de adhesión expanda la memoria total a ajustar la asignación.
  • -s MALLOC=... elige qué implementación de malloc() usar. emmalloc es una implementación de malloc() pequeña y rápida, específicamente para Emscripten. El alternativa es dlmalloc, una implementación completa de malloc(). Solo debes Debes cambiar a dlmalloc si asignas muchos objetos pequeños con frecuencia o si quieres usar subprocesos.
  • -s EXPORT_ES6=1 convertirá el código de JavaScript en un módulo de ES6 con un la exportación predeterminada que funciona con cualquier agrupador. También requiere que -s MODULARIZE=1 para de configuración.

Las siguientes marcas no siempre son necesarias o solo son útiles para la depuración propósitos:

  • -s FILESYSTEM=0 es una marca relacionada con Emscripten y su capacidad para emular un sistema de archivos por ti cuando tu código C/C++ usa operaciones de sistema de archivos. Hace algunos análisis del código que compila para decidir si incluir del sistema de archivos en el código glue. Sin embargo, a veces, puede equivocarse, y pagar 70 KB de pegamento adicional código 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 emiten un archivo de mapas de orígenes para el módulo wasm. Puedes leer más en con Emscripten en su etapa 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 lo siguiente:

$ npm install
$ npm run build
$ npm run serve

Si navegas a localhost:8080, deberías ver el siguiente resultado en el 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 quieres compilar una biblioteca C/C++ para tu app web, necesitas que su código parte del proyecto. Puedes agregar el código al repositorio de tu proyecto de forma manual También puedes usar npm para administrar este tipo de dependencias. Digamos 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 instálalo directamente con npm.

Para salir de este enigma, hay napa. napa permite instalar cualquier archivo URL del repositorio 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 la biblioteca libvpx de GitHub. repositorio 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 que se compilarán. Por suerte, Emscripten puede ayudar a garantizar que configure y make usa el compilador de Emscripten. Para ello, se encuentran los contenedores comandos 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 .h o .hpp) que definen las estructuras de datos, clases, constantes, etc. que un la biblioteca actual y la real (tradicionalmente archivos .so o .a). Para Usarás la constante VPX_CODEC_ABI_VERSION de la biblioteca en tu código. para incluir los archivos de encabezado de la biblioteca con 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 buscar archivos de encabezado. Además, debes otorgarle al compilador el elemento archivo 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 ...

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

DevTools
que muestra la versión de 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 largos pueden variar. En el caso de libvpx, lleva mucho tiempo porque compila un codificador y un decodificador para VP8 y VP9 cada vez que ejecutas tu comando de compilación, incluso si los archivos de origen no han cambiado. Incluso un pequeño El cambio a tu my-module.cpp tardará mucho tiempo en compilarse. Sería muy beneficiosos para mantener los artefactos de compilación de libvpx una vez que se hayan construyeron 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 ...

(Este es un gist que contiene todos los archivos).

El comando eval nos permite configurar variables de entorno pasando parámetros. a la secuencia de comandos de compilación. El comando test omitirá la compilación de libvpx si Se establece $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 estas dependencias en el entorno de compilación proporcionado por la imagen de Docker, debes agrégalos por tu cuenta. A modo de ejemplo, supongamos que también quieres compilar la documentación de libvpx con doxygen. El Doxígeno no es 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 crear tu biblioteca. No solo sería un desperdicio, pero también te impediría trabajar en tu proyecto mientras estás sin conexión.

Aquí tiene sentido compilar tu propia imagen de Docker. Las imágenes de Docker se compilan mediante escribiendo una Dockerfile que describa los pasos de compilación. Los Dockerfiles son bastante son potentes y tienen mucha , pero la mayoría de las puedes salir con solo usar 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 quieres usar como inicio punto. Elegí trzeci/emscripten como base, la imagen que estás usando desde el principio. 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, la imagen de Docker. Para asegurarte de que tu imagen de Docker se compiló y está disponible antes de que ejecutes build.sh, debes ajustar tu package.json a 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 es un gist que contiene todos los archivos).

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

Conclusión

No es de extrañar que el código C/C++ y npm no sean una opción natural, pero haz que funcione cómodamente con algunas herramientas adicionales y el aislamiento que proporciona Docker. Esta configuración no funcionará en todos los proyectos, pero es punto de partida decente que puedas adaptar a tus necesidades. Si tienes mejoras, compártelas.

Apéndice: Usa las capas de imágenes de Docker

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

Anteriormente, debías hacer un esfuerzo para no volver a compilar libvpx cada vez. en que compilas tu app. En su lugar, puedes mover las instrucciones de compilación para libvpx. de tu build.sh a Dockerfile para usar el almacenamiento en caché de Docker mecanismo:

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

Ten en cuenta que debes instalar git y clonar manualmente libvpx, ya que no tienes Vincula activaciones cuando se ejecuta docker build. Como efecto secundario, no se necesita Napa.