Émission d'une bibliothèque C vers Wasm

Parfois, vous souhaitez utiliser une bibliothèque qui n'est disponible qu'en tant que code C ou C++. En général, c'est là que l'on abandonne. Eh bien, ce n'est plus le cas, car nous avons désormais Emscripten et WebAssembly (ou Wasm).

Chaîne d'outils

Je me suis fixé pour objectif de découvrir comment compiler du code C existant en Wasm. Il y a eu des rumeurs concernant le backend Wasm de LLVM, alors j'ai commencé à me pencher sur ce sujet. Bien que vous puissiez obtenir des programmes simples à compiler de cette manière, vous rencontrerez probablement des problèmes lorsque vous souhaiterez utiliser la bibliothèque standard de C ou même compiler plusieurs fichiers. Cela m'a conduit à la leçon principale que j'ai apprise :

Bien qu'Emscripten ait été un compilateur C vers asm.js, il a depuis évolué pour cibler Wasm et est en cours de transition vers le backend LLVM officiel en interne. Emscripten fournit également une implémentation compatible avec Wasm de la bibliothèque standard C. Utilisez Emscripten. Il effectue de nombreuses tâches cachées, émule un système de fichiers, fournit une gestion de la mémoire, encapsule OpenGL avec WebGL, etc., de nombreuses choses que vous n'avez pas besoin de développer vous-même.

Vous avez peut-être l'impression que vous n'avez pas à vous soucier des surcharges, mais le compilateur Emscripten supprime tout ce qui n'est pas nécessaire. Lors de mes expériences, les modules Wasm obtenus sont adaptés à la logique qu'ils contiennent, et les équipes Emscripten et WebAssembly s'efforcent de les réduire encore plus à l'avenir.

Vous pouvez obtenir Emscripten en suivant les instructions sur son site Web ou en utilisant Homebrew. Si vous êtes fan des commandes Dockerisées comme moi et que vous ne souhaitez pas installer des éléments sur votre système pour simplement jouer avec WebAssembly, vous pouvez utiliser une image Docker bien gérée à la place:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Compiler un programme simple

Prenons l'exemple presque canonique d'écriture d'une fonction en C qui calcule le nth nombre de fibonacci:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

Si vous connaissez C, la fonction elle-même ne devrait pas être trop surprenante. Même si vous ne connaissez pas le langage C, mais que vous connaissez JavaScript, vous devriez être en mesure de comprendre ce qui se passe ici.

emscripten.h est un fichier d'en-tête fourni par Emscripten. Nous n'en avons besoin que pour accéder à la macro EMSCRIPTEN_KEEPALIVE, mais elle fournit beaucoup plus de fonctionnalités. Cette macro indique au compilateur de ne pas supprimer une fonction, même si elle semble inutilisée. Si nous omettez cette macro, le compilateur optimisera la fonction, car personne ne l'utilisera après tout.

Enregistrons tout cela dans un fichier nommé fib.c. Pour le convertir en fichier .wasm, nous devons utiliser la commande de compilation emcc d'Emscripten :

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Examinons cette commande. emcc est le compilateur d'Emscripten. fib.c est notre fichier C. Jusque-là, tout va bien. -s WASM=1 indique à Emscripten de nous fournir un fichier Wasm au lieu d'un fichier asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' indique au compilateur de laisser la fonction cwrap() disponible dans le fichier JavaScript. Nous reviendrons sur cette fonction plus tard. -O3 indique au compilateur d'effectuer une optimisation agressive. Vous pouvez choisir des valeurs plus faibles pour réduire le temps de compilation, mais cela augmentera également la taille des bundles générés, car le compilateur ne supprimera peut-être pas le code inutilisé.

Après avoir exécuté la commande, vous devriez obtenir un fichier JavaScript appelé a.out.js et un fichier WebAssembly appelé a.out.wasm. Le fichier Wasm (ou "module") contient notre code C compilé et doit être assez petit. Le fichier JavaScript se charge de charger et d'initialiser notre module Wasm, et de fournir une API plus conviviale. Si nécessaire, il se charge également de configurer la pile, le tas de mémoire et d'autres fonctionnalités généralement fournies par le système d'exploitation lors de l'écriture du code C. Par conséquent, le fichier JavaScript est un peu plus volumineux, pesant 19 Ko (environ 5 Ko compressés avec gzip).

Exécuter un programme simple

Le moyen le plus simple de charger et d'exécuter votre module consiste à utiliser le fichier JavaScript généré. Une fois ce fichier chargé, vous disposerez d'un élément global Module. Utilisez cwrap pour créer une fonction native JavaScript qui se charge de convertir les paramètres en éléments compatibles avec C et d'appeler la fonction encapsulée. cwrap utilise le nom de la fonction, le type renvoyé et les types d'argument en tant qu'arguments, dans cet ordre:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Si vous exécutez ce code, vous devriez voir le "144" dans la console, qui correspond au 12e nombre de Fibonacci.

Le Saint Graal: compiler une bibliothèque C

Jusqu'à présent, le code C que nous avons écrit était conçu pour Wasm. Toutefois, un cas d'utilisation essentiel de WebAssembly consiste à utiliser l'écosystème existant de bibliothèques C et à permettre aux développeurs de les utiliser sur le Web. Ces bibliothèques s'appuient souvent sur la bibliothèque standard de C, un système d'exploitation, un système de fichiers et d'autres éléments. Emscripten fournit la plupart de ces fonctionnalités, mais il existe certaines limites.

Revenons à mon objectif initial: compiler un encodeur pour WebP vers Wasm. La source du codec WebP est écrite en C et disponible sur GitHub, ainsi qu'une documentation complète sur les API. C'est un bon point de départ.

    $ git clone https://github.com/webmproject/libwebp

Pour commencer, essayons d'exposer WebPGetEncoderVersion() à partir de encode.h en JavaScript en écrivant un fichier C appelé webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Il s'agit d'un bon programme simple pour tester si nous pouvons obtenir le code source de libwebp à compiler, car nous n'avons pas besoin de paramètres ni de structures de données complexes pour appeler cette fonction.

Pour compiler ce programme, nous devons indiquer au compilateur où il peut trouver les fichiers d'en-tête de libwebp à l'aide de l'indicateur -I et lui transmettre tous les fichiers C de libwebp dont il a besoin. Pour être honnête, je viens de donner tous les fichiers C que je pouvais trouver et j'ai utilisé le compilateur pour supprimer tout ce qui n'était pas nécessaire. Cela semble fonctionner parfaitement !

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Il ne nous reste plus qu'à ajouter du code HTML et JavaScript pour charger notre nouveau module :

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

Le numéro de version de la correction s'affiche dans la sortie :

Capture d&#39;écran de la console DevTools indiquant le numéro de version correct.

Extraire une image de JavaScript dans Wasm

L'obtention du numéro de version de l'encodeur est une bonne chose, mais l'encodage d'une image réelle serait plus impressionnant, n'est-ce pas ? Allons-y.

La première question à laquelle nous devons répondre est la suivante : comment importer l'image dans le monde Wasm ? En examinant l'API d'encodage de libwebp, il s'attend à un tableau d'octets au format RVB, RVBA, BGR ou BGRA. Heureusement, l'API Canvas dispose de getImageData(), ce qui nous donne un tableau Uint8ClampedArray contenant les données d'image en RVBA:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

Désormais, il s'agit "uniquement" de copier les données du champ JavaScript dans le pays de Wasm. Pour ce faire, nous devons exposer deux fonctions supplémentaires. L'une qui alloue de la mémoire pour l'image dans l'espace Wasm et l'autre qui la libère à nouveau :

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer alloue un tampon pour l'image RGBA, soit 4 octets par pixel. Le pointeur renvoyé par malloc() est l'adresse de la première cellule de mémoire de ce tampon. Lorsque le pointeur est renvoyé vers JavaScript, il est traité comme un simple nombre. Après avoir exposé la fonction à JavaScript à l'aide de cwrap, nous pouvons utiliser ce nombre pour trouver le début de notre tampon et copier les données d'image.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Grand final : Encoder l'image

L'image est désormais disponible dans Wasm. Il est temps d'appeler l'encodeur WebP pour qu'il fasse son travail. D'après la documentation WebP, WebPEncodeRGBA semble parfaitement convenir. La fonction prend un pointeur vers l'image d'entrée et ses dimensions, ainsi qu'une option de qualité comprise entre 0 et 100. Il nous alloue également un tampon de sortie que nous devrons libérer à l'aide de WebPFree() une fois que nous aurons terminé avec l'image WebP.

Le résultat de l'opération d'encodage est un tampon de sortie et sa longueur. Étant donné que les fonctions en C ne peuvent pas avoir de tableaux comme types de retour (sauf si nous allouons de la mémoire de manière dynamique), j'ai eu recours à un tableau global statique. Je sais, pas de C propre (en fait, il repose sur le fait que les pointeurs Wasm ont une largeur de 32 bits), mais pour simplifier les choses, je pense que c'est un raccourci acceptable.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

Maintenant que tout est en place, nous pouvons appeler la fonction d'encodage, récupérer le pointeur et la taille de l'image, les placer dans notre propre tampon JavaScript-Land et libérer tous les tampons Wasm-land que nous avons alloués dans le processus.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

Selon la taille de votre image, il est possible que Wasm ne puisse pas augmenter suffisamment la mémoire pour accueillir à la fois l'image d'entrée et l'image de sortie:

Capture d&#39;écran de la console DevTools affichant une erreur.

Heureusement, la solution à ce problème se trouve dans le message d'erreur. Il nous suffit d'ajouter -s ALLOW_MEMORY_GROWTH=1 à notre commande de compilation.

Et voilà ! Nous avons compilé un encodeur WebP et transcodé une image JPEG au format WebP. Pour prouver que cela a fonctionné, nous pouvons transformer notre tampon de résultat en blob et l'utiliser sur un élément <img>:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Voici la gloire d'une nouvelle image WebP !

le panneau réseau des outils de développement et l&#39;image générée.

Conclusion

L'utilisation d'une bibliothèque C dans le navigateur n'est pas une mince affaire. Cependant, une fois que vous avez compris le processus global et le fonctionnement du flux de données, il devient plus facile et les résultats peuvent être époustouflants.

WebAssembly ouvre de nombreuses nouvelles possibilités sur le Web pour le traitement, le calcul et les jeux. Gardez à l'esprit que Wasm n'est pas une solution miracle à appliquer à tout, mais lorsque vous rencontrez l'un de ces goulots d'étranglement, Wasm peut être un outil incroyablement utile.

Contenu bonus: courir le plus difficilement

Si vous souhaitez essayer d'éviter le fichier JavaScript généré, vous pouvez peut-être y parvenir. Revenons à l'exemple de Fibonacci. Pour le charger et l'exécuter nous-mêmes, nous pouvons procéder comme suit:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Les modules WebAssembly créés par Emscripten n'ont pas de mémoire à utiliser, sauf si vous leur en fournissez. Pour fournir un module Wasm avec n'importe quoi, utilisez l'objet imports, qui est le deuxième paramètre de la fonction instantiateStreaming. Le module Wasm peut accéder à tout ce qui se trouve dans l'objet "imports", mais rien d'autre en dehors. Par convention, les modules compilés par Emscripting attendent deux éléments de l'environnement JavaScript de chargement:

  • Tout d'abord, il y a env.memory. Le module Wasm n'est pas conscient du monde extérieur, il doit donc obtenir de la mémoire pour travailler. Saisissez WebAssembly.Memory. Il représente un espace de mémoire linéaire (éventuellement extensible). Les paramètres de dimensionnement sont exprimés en "unités de pages WebAssembly", ce qui signifie que le code ci-dessus alloue une page de mémoire, chaque page ayant une taille de 64 KiB. Si vous ne fournissez pas l'option maximum, la croissance de la mémoire est théoriquement illimitée (Chrome est actuellement limité à 2 Go). La plupart des modules WebAssembly ne devraient pas avoir besoin de définir une valeur maximale.
  • env.STACKTOP définit où la pile est censée commencer à se développer. La pile est nécessaire pour effectuer des appels de fonction et allouer de la mémoire pour les variables locales. Comme nous ne nous servons pas de la gestion dynamique de la mémoire dans notre petit programme Fibonacci, nous pouvons simplement utiliser la mémoire entière comme pile, d'où STACKTOP = 0.