Emscripten et npm

Comment intégrer WebAssembly à cette configuration ? Dans cet article, nous allons résoudre ce problème avec C/C++ et Emscripten comme exemple.

WebAssembly (wasm) est souvent présenté comme une primitive de performances ou un moyen d'exécuter votre base de code C++ existante sur le Web. Avec squoosh.app, nous voulions montrer qu'il existe au moins une troisième perspective pour le wasm: utiliser les immenses écosystèmes d'autres langages de programmation. Avec Emscripten, vous pouvez utiliser du code C/C++, Rust est compatible avec wasm et l'équipe Go y travaille également. D'autres langues suivront certainement.

Dans ces scénarios, wasm n'est pas le centre de votre application, mais plutôt une pièce du puzzle: un autre module. Votre application contient déjà du code JavaScript, CSS, des composants Image, un système de compilation axé sur le Web et peut-être même un framework comme React. Comment intégrer WebAssembly à cette configuration ? Dans cet article, nous allons travailler avec C/C++ et Emscripten comme exemple.

Docker

Docker s'est avéré inestimable lorsque j'ai travaillé avec Emscripten. Les bibliothèques C/C++ sont souvent écrites pour fonctionner avec le système d'exploitation sur lequel elles sont compilées. Un environnement cohérent est extrêmement utile. Avec Docker, vous bénéficiez d'un système Linux virtualisé déjà configuré pour fonctionner avec Emscripten et sur lequel tous les outils et dépendances sont installés. Si quelque chose est manquant, vous pouvez simplement l'installer sans vous soucier de son impact sur votre propre machine ou vos autres projets. En cas de problème, jetez le conteneur et recommencez. S'il fonctionne une fois, vous pouvez être sûr qu'il continuera de fonctionner et de produire des résultats identiques.

Le registre Docker contient une image Emscripten de trzeci que j'ai beaucoup utilisée.

Intégration avec npm

Dans la plupart des cas, le point d'entrée d'un projet Web est package.json de npm. Par convention, la plupart des projets peuvent être compilés avec npm install && npm run build.

En règle générale, les artefacts de compilation produits par Emscripten (un fichier .js et un fichier .wasm) doivent être traités comme un simple module JavaScript et un simple composant. Le fichier JavaScript peut être géré par un outil de compilation comme webpack ou rollup, et le fichier wasm doit être traité comme n'importe quel autre élément binaire plus volumineux, comme les images.

Par conséquent, les artefacts de compilation Emscripten doivent être créés avant le début de votre processus de compilation "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 nouvelle tâche build:emscripten pourrait appeler Emscripten directement, mais comme indiqué précédemment, je vous recommande d'utiliser Docker pour vous assurer que l'environnement de compilation est cohérent.

docker run ... trzeci/emscripten ./build.sh indique à Docker de créer un nouveau conteneur à l'aide de l'image trzeci/emscripten et d'exécuter la commande ./build.sh. build.sh est un script shell que vous allez écrire ensuite. --rm indique à Docker de supprimer le conteneur une fois l'exécution terminée. Vous n'accumulez ainsi pas de collection d'images système obsolètes au fil du temps. -v $(pwd):/src signifie que vous souhaitez que Docker "mette en miroir" le répertoire actuel ($(pwd)) sur /src dans le conteneur. Toutes les modifications apportées aux fichiers du répertoire /src dans le conteneur seront reflétées dans votre projet réel. Ces répertoires miroirs sont appelés "montages de liaison".

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

Il y a beaucoup de choses à analyser ici !

set -e met le shell en mode "fail fast" (échec rapide). Si l'une des commandes du script renvoie une erreur, l'intégralité du script est immédiatement interrompue. Cela peut être extrêmement utile, car la dernière sortie du script sera toujours un message de réussite ou l'erreur à l'origine de l'échec de la compilation.

Avec les instructions export, vous définissez les valeurs de quelques variables d'environnement. Ils vous permettent de transmettre des paramètres de ligne de commande supplémentaires au compilateur C (CFLAGS), au compilateur C++ (CXXFLAGS) et à l'éditeur de liens (LDFLAGS). Ils reçoivent tous les paramètres de l'optimiseur via OPTIMIZE pour s'assurer que tout est optimisé de la même manière. La variable OPTIMIZE peut avoir plusieurs valeurs:

  • -O0: aucune optimisation n'est effectuée. Aucun code mort n'est éliminé, et Emscripten ne minifie pas non plus le code JavaScript qu'il émet. Utile pour le débogage.
  • -O3: optimisez de manière agressive pour améliorer les performances.
  • -Os: optimisation agressive des performances et de la taille en tant que critère secondaire.
  • -Oz: optimisez de manière agressive la taille, en sacrifiant les performances si nécessaire.

Pour le Web, je recommande principalement -Os.

La commande emcc propose de nombreuses options. Notez qu'emcc est censé être un "remplacement direct des compilateurs tels que GCC ou clang". Par conséquent, tous les indicateurs que vous connaissez de GCC seront probablement implémentés par emcc. L'indicateur -s est spécial en ce sens qu'il nous permet de configurer Emscripten spécifiquement. Toutes les options disponibles se trouvent dans settings.js d'Emscripten, mais ce fichier peut être assez intimidant. Voici la liste des options Emscripten qui me semblent les plus importantes pour les développeurs Web:

  • --bind active l'embind.
  • -s STRICT=1 ne prend plus en charge toutes les options de compilation obsolètes. Cela garantit que votre code est compilé de manière rétrocompatible.
  • -s ALLOW_MEMORY_GROWTH=1 permet d'augmenter automatiquement la mémoire si nécessaire. Au moment de la rédaction de cet article, Emscripten alloue initialement 16 Mo de mémoire. Lorsque votre code alloue des blocs de mémoire, cette option détermine si ces opérations entraînent l'échec de l'ensemble du module wasm lorsque la mémoire est épuisée, ou si le code de liaison est autorisé à augmenter la mémoire totale pour accueillir l'allocation.
  • -s MALLOC=... choisit l'implémentation malloc() à utiliser. emmalloc est une implémentation malloc() petite et rapide, spécifiquement conçue pour Emscripten. L'alternative est dlmalloc, une implémentation malloc() complète. Vous ne devez passer à dlmalloc que si vous allouez fréquemment de nombreux petits objets ou si vous souhaitez utiliser le thread.
  • -s EXPORT_ES6=1 transforme le code JavaScript en module ES6 avec une exportation par défaut qui fonctionne avec n'importe quel bundler. Nécessite également que -s MODULARIZE=1 soit défini.

Les options suivantes ne sont pas toujours nécessaires ou ne sont utiles qu'à des fins de débogage:

  • -s FILESYSTEM=0 est un indicateur lié à Emscripten et à sa capacité à émuler un système de fichiers pour vous lorsque votre code C/C++ utilise des opérations de système de fichiers. Il effectue une analyse du code qu'il compile pour décider d'inclure ou non l'émulation de système de fichiers dans le code de liaison. Cependant, cette analyse peut parfois se tromper et vous payez 70 ko de code de liaison supplémentaire pour une émulation de système de fichiers dont vous n'avez peut-être pas besoin. Avec -s FILESYSTEM=0, vous pouvez forcer Emscripten à ne pas inclure ce code.
  • -g4 permet à Emscripten d'inclure des informations de débogage dans le .wasm et d'émettre également un fichier de mappage source pour le module wasm. Pour en savoir plus sur le débogage avec Emscripten, consultez la section sur le débogage.

Et voilà ! Pour tester cette configuration, préparons un petit 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);
    }

Et 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>

(Voici un gist contenant tous les fichiers.)

Pour compiler tout, exécutez

$ npm install
$ npm run build
$ npm run serve

Si vous accédez à localhost:8080, le résultat suivant devrait s'afficher dans la console DevTools:

Outils de développement affichant un message imprimé via C++ et Emscripten.

Ajouter du code C/C++ en tant que dépendance

Si vous souhaitez créer une bibliothèque C/C++ pour votre application Web, son code doit faire partie de votre projet. Vous pouvez ajouter le code manuellement au dépôt de votre projet ou utiliser npm pour gérer ce type de dépendances. Supposons que je souhaite utiliser libvpx dans ma webapp. libvpx est une bibliothèque C++ permettant d'encoder des images avec VP8, le codec utilisé dans les fichiers .webm. Toutefois, libvpx n'est pas sur npm et ne dispose pas d'un package.json. Je ne peux donc pas l'installer directement avec npm.

Pour résoudre ce problème, il existe napa. napa vous permet d'installer n'importe quelle URL de dépôt Git en tant que dépendance dans votre dossier node_modules.

Installez Napa en tant que dépendance:

$ npm install --save napa

et assurez-vous d'exécuter napa en tant que script d'installation:

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

Lorsque vous exécutez npm install, Napa se charge de cloner le dépôt GitHub libvpx dans votre node_modules sous le nom libvpx.

Vous pouvez désormais étendre votre script de compilation pour compiler libvpx. libvpx utilise configure et make pour être compilé. Heureusement, Emscripten peut vous aider à vous assurer que configure et make utilisent le compilateur d'Emscripten. Pour ce faire, utilisez les commandes de wrapper emconfigure et 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 ...

Une bibliothèque C/C++ est divisée en deux parties: les en-têtes (généralement des fichiers .h ou .hpp) qui définissent les structures de données, les classes, les constantes, etc. qu'une bibliothèque expose et la bibliothèque elle-même (généralement des fichiers .so ou .a). Pour utiliser la constante VPX_CODEC_ABI_VERSION de la bibliothèque dans votre code, vous devez inclure les fichiers d'en-tête de la bibliothèque à l'aide d'une instruction #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;
}

Le problème est que le compilateur ne sait pas chercher vpxenc.h. C'est à cela que sert l'indicateur -I. Il indique au compilateur les répertoires à rechercher pour les fichiers d'en-tête. Vous devez également fournir au compilateur le fichier de bibliothèque réel:

# ... 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 vous exécutez npm run build maintenant, vous verrez que le processus crée un nouveau fichier .js et un nouveau fichier .wasm, et que la page de démonstration génère bien la constante:

DevTools affichant la version ABI de libvpx imprimée via emscripten.

Vous remarquerez également que le processus de compilation prend beaucoup de temps. Les raisons des longs délais de compilation peuvent varier. Dans le cas de libvpx, cela prend beaucoup de temps, car il compile un encodeur et un décodeur pour VP8 et VP9 chaque fois que vous exécutez votre commande de compilation, même si les fichiers sources n'ont pas changé. Même un petit changement apporté à votre my-module.cpp prendra beaucoup de temps à compiler. Il serait très bénéfique de conserver les artefacts de compilation de libvpx une fois qu'ils ont été compilés pour la première fois.

Pour ce faire, vous pouvez utiliser des variables d'environnement.

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

(Voici un gist contenant tous les fichiers.)

La commande eval nous permet de définir des variables d'environnement en transmettant des paramètres au script de compilation. La commande test ignore la compilation de libvpx si $SKIP_LIBVPX est défini (sur n'importe quelle valeur).

Vous pouvez maintenant compiler votre module, mais ignorer la recompilation de libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personnaliser l'environnement de compilation

Parfois, les bibliothèques dépendent d'outils supplémentaires pour la compilation. Si ces dépendances sont manquantes dans l'environnement de compilation fourni par l'image Docker, vous devez les ajouter vous-même. Par exemple, supposons que vous souhaitiez également créer la documentation de libvpx à l'aide de doxygen. Doxygen n'est pas disponible dans votre conteneur Docker, mais vous pouvez l'installer à l'aide de apt.

Si vous le faisiez dans votre build.sh, vous devriez télécharger et réinstaller doxygen chaque fois que vous souhaitez compiler votre bibliothèque. Cela serait non seulement un gaspillage, mais vous empêcherait également de travailler sur votre projet lorsque vous êtes hors connexion.

Il est donc logique de créer votre propre image Docker. Les images Docker sont créées en écrivant un Dockerfile qui décrit les étapes de compilation. Les fichiers Dockerfile sont assez puissants et comportent de nombreuses commandes, mais la plupart du temps, vous pouvez vous contenter d'utiliser FROM, RUN et ADD. Dans ce cas :

FROM trzeci/emscripten

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

Avec FROM, vous pouvez déclarer l'image Docker que vous souhaitez utiliser comme point de départ. J'ai choisi trzeci/emscripten comme base, l'image que vous utilisez depuis le début. Avec RUN, vous demandez à Docker d'exécuter des commandes shell dans le conteneur. Les modifications apportées par ces commandes au conteneur font désormais partie de l'image Docker. Pour vous assurer que votre image Docker a été créée et est disponible avant d'exécuter build.sh, vous devez ajuster un peu votre 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",
    // ...
    },
    // ...
}

(Voici un gist contenant tous les fichiers.)

Cela crée votre image Docker, mais uniquement si elle n'a pas encore été créée. Ensuite, tout s'exécute comme avant, mais l'environnement de compilation dispose désormais de la commande doxygen, ce qui permet également de compiler la documentation de libvpx.

Conclusion

Il n'est pas surprenant que le code C/C++ et npm ne soient pas compatibles, mais vous pouvez les faire fonctionner très facilement avec des outils supplémentaires et l'isolation fournie par Docker. Cette configuration ne convient pas à tous les projets, mais elle constitue un bon point de départ que vous pouvez ajuster en fonction de vos besoins. Si vous avez des suggestions d'améliorations, n'hésitez pas à les partager.

Annexe: Utiliser les couches d'image Docker

Une autre solution consiste à encapsuler davantage de ces problèmes avec Docker et son approche intelligente du cache. Docker exécute les fichiers Docker étape par étape et attribue à chaque étape une image qui lui est propre. Ces images intermédiaires sont souvent appelées "couches". Si une commande d'un Dockerfile n'a pas changé, Docker ne réexécutera pas cette étape lorsque vous recompilerez le Dockerfile. Au lieu de cela, il réutilise la couche de la dernière fois que l'image a été créée.

Auparavant, vous deviez vous efforcer de ne pas recompiler libvpx chaque fois que vous créiez votre application. Vous pouvez désormais déplacer les instructions de compilation de libvpx de votre build.sh vers le Dockerfile pour utiliser le mécanisme de mise en cache 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

(Voici un gist contenant tous les fichiers.)

Notez que vous devez installer manuellement git et cloner libvpx, car vous n'avez pas de montages de liaison lorsque vous exécutez docker build. En conséquence, vous n'avez plus besoin de Napa.