Emscripten et npm

Comment intégrer WebAssembly à cette configuration ? Dans cet article, nous allons utiliser C/C++ et Emscripten à titre d'exemple.

WebAssembly (wasm) est souvent présenté comme une primitive de performances ou un moyen d'exécuter votre codebase C++ existant sur le Web. Avec squoosh.app, nous voulions montrer qu'il existe au moins une troisième perspective pour Wasm: exploiter les vastes é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 la pièce maîtresse de votre application, mais plutôt une pièce de puzzle: c'est un autre module. Votre application dispose déjà de JavaScript, de CSS, de composants Image, d'un système de compilation Web et peut-être même d'un framework comme React. Comment intégrer WebAssembly dans cette configuration ? Dans cet article, nous allons utiliser C/C++ et Emscripten à titre d'exemple.

Docker

J'ai trouvé Docker très utile lorsque je travaille avec Emscripten. Les bibliothèques C/C++ sont souvent écrites pour fonctionner avec le système d'exploitation sur lequel elles reposent. Il est très utile d'avoir un environnement cohérent. Avec Docker, vous obtenez un système Linux virtualisé qui est 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 avoir à vous soucier de son impact sur votre propre machine ou sur vos autres projets. En cas de problème, supprimez le conteneur et recommencez. Si elle fonctionne une seule 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'utilise beaucoup.

Intégration à npm

Dans la plupart des cas, le point d'entrée d'un projet Web est le 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 simple module JavaScript et un autre élément. Le fichier JavaScript peut être géré par un bundler comme webpack ou rollup, et le fichier Wasm doit être traité comme tout autre élément binaire plus volumineux, comme des images.

Par conséquent, les artefacts de compilation Emscripten doivent être compilé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 directement Emscripten. Toutefois, comme indiqué précédemment, nous vous recommandons 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 son exécution terminée. Ainsi, 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 "duplique" le répertoire actuel ($(pwd)) sur /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 réel. Ces répertoires 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 à décortiquer ici !

set -e place l'interface système en mode "fail fast" (échec rapide). Si l'une des commandes du script renvoie une erreur, l'intégralité du script est immédiatement annulée. Cela peut s'avérer incroyablement 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 d'optimisation via OPTIMIZE pour s'assurer que tout est optimisé de la même manière. Deux valeurs sont possibles pour la variable OPTIMIZE:

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

Pour le Web, je recommande surtout -Os.

La commande emcc possède une myriade d'options. Notez qu'emcc est supposé être un "remplacement instantané pour des compilateurs tels que GCC ou clang". Ainsi, tous les indicateurs de GCC que vous pourriez connaître seront probablement également implémentés par emcc. L'indicateur -s est spécial, dans la mesure où il nous permet de configurer Emscripten de manière spécifique. Toutes les options disponibles se trouvent dans le fichier settings.js d'Embscripten, mais ce fichier peut être assez complexe. Voici la liste des indicateurs Emscripten qui, selon moi, sont les plus importants pour les développeurs Web:

  • --bind active embind.
  • -s STRICT=1 n'accepte plus 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 ce document, Emscripten alloue initialement 16 Mo de mémoire. Lorsque votre code alloue des fragments de mémoire, cette option décide si ces opérations font échouer l'intégralité du module Wasm lorsque la mémoire est épuisée, ou si le code Glue est autorisé à augmenter la mémoire totale pour prendre en charge l'allocation.
  • -s MALLOC=... choisit l'implémentation malloc() à utiliser. emmalloc est une implémentation malloc() petite et rapide spécialement 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 des threads.
  • -s EXPORT_ES6=1 transforme le code JavaScript en module ES6 avec une exportation par défaut compatible avec n'importe quel bundler. Nécessite également la définition de -s MODULARIZE=1.

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

  • -s FILESYSTEM=0 est un indicateur lié à Emscripten. Il permet d'émuler un système de fichiers lorsque votre code C/C++ utilise des opérations de système de fichiers. Il analyse le code qu'il compile pour décider d'inclure ou non l'émulation du système de fichiers dans le code Glue. Cependant, parfois, cette analyse peut se tromper et vous payez 70 Ko de code glue 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 consacrée au débogage.

Et voilà ! Pour tester cette configuration, créons un tout 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 la commande suivante :

$ npm install
$ npm run build
$ npm run serve

En accédant à 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 intégrer son code à votre projet. Vous pouvez ajouter manuellement le code au dépôt de votre projet ou utiliser npm pour gérer également ce type de dépendances. Supposons que je souhaite utiliser libvpx dans mon application Web. libvpx est une bibliothèque C++ permettant d'encoder les images avec VP8, le codec utilisé dans les fichiers .webm. Cependant, libvpx n'est pas sur npm et ne possède pas de package.json. Je ne peux donc pas l'installer directement à l'aide de npm.

Pour sortir de ce casse-tête, vous pouvez utiliser napa. napa vous permet d'installer n'importe quelle URL du 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

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 fichier node_modules sous le nom libvpx.

Vous pouvez maintenant étendre votre script de compilation pour compiler libvpx. libvpx utilise configure et make pour être compilés. Heureusement, Emscripten peut s'assurer que configure et make utilisent le compilateur d'Embscripten. À cet effet, il existe 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 .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 réelle (fichiers .so ou .a classiques). 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 rechercher vpxenc.h. C'est à cela que sert l'option -I. Il indique au compilateur quels répertoires vérifier 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 compile une nouvelle .js et un nouveau fichier .wasm, et 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. La raison des durées de compilation longues peut varier. Dans le cas de libvpx, cela prend beaucoup de temps, car il compile un encodeur et un décodeur pour les formats VP8 et VP9 chaque fois que vous exécutez votre commande de compilation, même si les fichiers sources n'ont pas changé. La compilation, même mineure, de votre my-module.cpp prendra beaucoup de temps. Il serait très utile de conserver les artefacts de compilation de libvpx une fois qu'ils ont été créé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 sans recompiler libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Personnaliser l'environnement de compilation

La compilation des bibliothèques dépend parfois d'outils supplémentaires. 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 faites dans votre build.sh, vous devrez télécharger et réinstaller Doxygen chaque fois que vous souhaitez compiler votre bibliothèque. Ce serait non seulement 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. Pour créer des images Docker, vous devez écrire un fichier 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 simplement 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 à l'intérieur du 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 qu'elle 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.)

Votre image Docker sera créée, mais seulement si elle n'a pas encore été compilée. Tout s'exécute comme auparavant, mais la commande doxygen est désormais disponible dans l'environnement de compilation, ce qui entraîne également la création de la documentation de libvpx.

Conclusion

Il n'est pas surprenant que le code C/C++ et npm ne soient pas parfaitement adaptés, mais vous pouvez les faire fonctionner assez facilement avec des outils supplémentaires et l'isolation fournie par Docker. Cette configuration ne fonctionne pas pour tous les projets, mais c'est un bon point de départ que vous pouvez adapter à vos besoins. Si vous avez des améliorations, veuillez les partager.

Annexe: Utiliser les couches d'image Docker

Une autre solution consiste à encapsuler davantage de ces problèmes à l'aide de l'approche intelligente de la mise en cache de Docker et Docker. Docker exécute des fichiers Dockerfile étape par étape et attribue le résultat de chaque étape à son propre image. Ces images intermédiaires sont souvent appelées "couches". Si une commande dans un Dockerfile n'a pas changé, Docker n'exécute pas cette étape lorsque vous recompilez le Dockerfile. À la place, il réutilise la couche de la dernière création de l'image.

Auparavant, vous deviez faire des efforts pour ne pas recompiler libvpx chaque fois que vous compiliez votre application. À la place, vous pouvez déplacer les instructions de compilation de libvpx de votre fichier build.sh vers 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 d'installation de liaison lorsque vous exécutez docker build. Comme effet secondaire, la sieste n'est plus nécessaire.