Emscripten et npm

Comment intégrer WebAssembly dans cette configuration ? Dans cet article, nous allons utiliser C/C++ et Emscripten comme exemple.

WebAssembly (wasm) est souvent présenté comme une primitive de performances ou un moyen d'exécuter votre codebase C++ existante sur le Web. Avec squoosh.app, nous voulions montrer qu'il existe au moins une troisième perspective pour wasm : utiliser les énormes é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. Je suis sûr que de nombreuses autres langues suivront.

Dans ces scénarios, wasm n'est pas l'élément central de votre application, mais plutôt une pièce du puzzle : un module de plus. Votre application possède déjà des éléments JavaScript, CSS et d'image, un système de compilation axé sur le Web et peut-être même un framework comme React. Comment intégrer WebAssembly dans cette configuration ? Dans cet article, nous allons utiliser C/C++ et Emscripten comme exemple.

Docker

J'ai trouvé Docker inestimable lorsque je travaillais avec Emscripten. Les bibliothèques C/C++ sont souvent écrites pour fonctionner avec le système d'exploitation sur lequel elles sont conçues. Il est extrêmement utile de disposer d'un environnement cohérent. Avec Docker, vous obtenez un système Linux virtualisé déjà configuré pour fonctionner avec Emscripten et sur lequel tous les outils et dépendances sont installés. S'il manque quelque chose, vous pouvez simplement l'installer sans vous soucier de l'impact sur votre propre machine ou sur vos autres projets. En cas de problème, jetez le récipient et recommencez. Si elle fonctionne une fois, vous pouvez être sûr qu'elle continuera à fonctionner et à produire des résultats identiques.

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

Intégration à npm

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

En général, les artefacts de compilation produits par Emscripten (un fichier .js et un fichier .wasm) doivent être traités comme un module JavaScript et un élément multimédia comme les autres. Le fichier JavaScript peut être géré par un bundler tel que 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 invoquer Emscripten directement, mais comme mentionné 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 lancer 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 qu'il a terminé de s'exécuter. De cette façon, vous n'accumulez pas une 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)) dans /src à l'intérieur du conteneur. Toutes les modifications que vous apportez aux fichiers du répertoire /src à l'intérieur du conteneur sont répercutées dans votre projet. Ces répertoires mis en miroir sont appelés "montages de liaison".

Examinons 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 à décortiquer ici !

set -e place le shell en mode "fail fast" (échec rapide). Si une commande du script renvoie une erreur, l'intégralité du script est immédiatement abandonnée. Cela peut être extrêmement utile, car la dernière sortie du script sera toujours un message de réussite ou l'erreur qui a entraîné l'échec de la compilation.

Les instructions export vous permettent de définir les valeurs de certaines 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 deux valeurs :

  • -O0 : n'effectue aucune optimisation. Aucun code mort n'est éliminé, et Emscripten ne réduit pas non plus le code JavaScript qu'il émet. Utile pour le débogage.
  • -O3 : optimiser de manière agressive les performances.
  • -Os : optimisation agressive des performances et de la taille comme critère secondaire.
  • -Oz : optimiser de manière agressive la taille, en sacrifiant les performances si nécessaire.

Pour le Web, je recommande principalement -Os.

La commande emcc comporte de nombreuses options. Notez qu'emcc est censé être un "remplacement direct pour les compilateurs tels que GCC ou clang". Ainsi, tous les indicateurs que vous connaissez peut-être dans GCC seront très probablement implémentés par emcc. L'indicateur -s est spécial, car 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 déroutant. Voici une liste des indicateurs Emscripten qui me semblent les plus importants pour les développeurs Web :

  • --bind permet d'activer 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 compatible avec les versions ultérieures.
  • -s ALLOW_MEMORY_GROWTH=1 permet d'augmenter automatiquement la mémoire si nécessaire. Au moment de la rédaction, Emscripten alloue initialement 16 Mo de mémoire. Cette option détermine si ces opérations feront échouer l'ensemble du module wasm lorsque la mémoire sera épuisée, ou si le code de colle est autorisé à étendre la mémoire totale pour s'adapter à l'allocation.
  • -s MALLOC=... choisit l'implémentation malloc() à utiliser. emmalloc est une implémentation malloc() petite et rapide, spécialement conçue pour Emscripten. L'alternative est dlmalloc, une implémentation malloc() à part entière. Vous n'avez besoin de passer à dlmalloc que si vous allouez fréquemment de nombreux petits objets ou si vous souhaitez utiliser le threading.
  • -s EXPORT_ES6=1 transformera 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 du système de fichiers dans le code de liaison. Toutefois, cette analyse peut parfois se tromper et vous payez un code de colle supplémentaire assez lourd de 70 ko 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 .wasm et d'émettre également un fichier de cartes sources pour le module wasm. Pour en savoir plus sur le débogage avec Emscripten, consultez la section Débogage.

Et voilà ! Pour tester cette configuration, créons 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 tout compiler, 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, vous devez inclure son code dans votre projet. Vous pouvez ajouter le code au dépôt de votre projet manuellement ou utiliser npm pour gérer également ce type de dépendances. Imaginons que je veuille utiliser libvpx dans mon application Web. 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 n'a pas de 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 clone le dépôt GitHub libvpx dans votre node_modules sous le nom libvpx.

Vous pouvez maintenant étendre votre script de compilation pour compiler libvpx. libvpx utilise configure et make pour la compilation. Heureusement, Emscripten peut vous aider à vous assurer que configure et make utilisent le compilateur Emscripten. Pour ce faire, vous pouvez utiliser les commandes 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 (fichiers .h ou .hpp traditionnellement) qui définissent les structures de données, les classes, les constantes, etc. qu'une bibliothèque expose, et la bibliothèque proprement dite (fichiers .so ou .a traditionnellement). 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 dans lesquels rechercher les fichiers d'en-tête. De plus, 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 fichier .js et un fichier .wasm, et que la page de démonstration affichera bien la constante :

Outils de développement 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 temps de compilation peuvent varier. Dans le cas de libvpx, cela prend beaucoup de temps, car un encodeur et un décodeur pour VP8 et VP9 sont compilés chaque fois que vous exécutez votre commande de compilation, même si les fichiers sources n'ont pas changé. Même une petite modification de votre my-module.cpp prendra beaucoup de temps à compiler. Il serait très utile 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 ignorera la compilation de libvpx si $SKIP_LIBVPX est défini (sur n'importe quelle valeur).

Vous pouvez maintenant compiler votre module, mais en évitant de reconstruire 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 voudriez compiler votre bibliothèque. Non seulement cela serait du gaspillage, mais cela vous empêcherait également de travailler sur votre projet hors connexion.

Dans ce cas, il est judicieux de créer votre propre image Docker. Les images Docker sont créées en écrivant un fichier Dockerfile qui décrit les étapes de compilation. Les Dockerfiles sont très 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, c'est-à-dire l'image que vous avez utilisée depuis le début. Avec RUN, vous demandez à Docker d'exécuter des commandes shell dans le conteneur. Toutes les modifications apportées au conteneur par ces commandes 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 légèrement 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éera votre image Docker, mais uniquement si elle n'a pas encore été créée. Tout se déroule ensuite comme avant, mais l'environnement de compilation dispose désormais de la commande doxygen, 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 naturellement compatibles, mais vous pouvez les faire fonctionner assez facilement avec des outils supplémentaires et l'isolation fournie par Docker. Cette configuration ne fonctionnera pas pour tous les projets, mais elle constitue un bon point de départ que vous pouvez adapter à vos besoins. Si vous avez des suggestions d'amélioration, 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 de la mise en cache. Docker exécute les fichiers Dockerfile étape par étape et attribue une image propre au résultat de chaque étape. Ces images intermédiaires sont souvent appelées "calques". Si une commande dans un fichier Dockerfile n'a pas changé, Docker ne réexécutera pas cette étape lorsque vous recompilerez le fichier Dockerfile. Au lieu de cela, il réutilise le calque de la dernière compilation de l'image.

Auparavant, vous deviez faire des efforts pour ne pas reconstruire libvpx à chaque fois que vous compiliez votre application. Vous pouvez plutôt 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 ne disposez pas de montages de liaison lorsque vous exécutez docker build. Par conséquent, napa n'est plus nécessaire.