Émission d'une bibliothèque C vers Wasm

Parfois, vous souhaitez utiliser une bibliothèque disponible uniquement sous forme de code C ou C++. Habituellement, c'est là que l'on abandonne. En fait, ce n'est plus le cas, car nous avons maintenant Emscripten et WebAssembly (ou Wasm).

La chaîne d'outils

Je me suis fixé l'objectif de trouver comment compiler un code C existant vers Wasm. Il y a eu du bruit autour du backend Wasm de LLVM, alors j'ai commencé à m'y intéresser. Bien que vous puissiez obtenir des programmes simples à compiler de cette manière, vous rencontrerez probablement des problèmes au moment où vous souhaiterez utiliser la bibliothèque standard de C ou même compiler plusieurs fichiers. Cela m'a conduit à la leçon majeure que j'ai apprise:

Bien qu'Emscripten utilisait un compilateur C-to-asm.js, elle cible désormais Wasm et est en train de passer au backend LLVM officiel en interne. Emscripten fournit également une implémentation compatible Wasm de la bibliothèque standard de C. Utilisez Emscripten. Il comporte beaucoup de tâches cachées, émule un système de fichiers, assure la gestion de la mémoire et encapsule OpenGL avec WebGL. Beaucoup de choses que vous n'avez vraiment pas besoin de développer par vous-même.

Cela peut donner l'impression que vous devez vous soucier des surcharges (ce qui m'inquiète certainement), car le compilateur Emscripten supprime tout ce qui n'est pas nécessaire. Dans mes tests, les modules Wasm obtenus sont correctement dimensionnés en fonction de la logique qu'ils contiennent, et les équipes Emscripten et WebAssembly s'efforcent de les réduire encore davantage à l'avenir.

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

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

Compiler quelque chose de simple

Prenons l'exemple presque canonique consistant à écrire une fonction en C qui calcule le nième 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 C, mais que vous connaissez JavaScript, vous devriez pouvoir comprendre ce qui se passe ici.

emscripten.h est un fichier d'en-tête fourni par Emscripten. Nous en avons uniquement besoin pour avoir accès à la macro EMSCRIPTEN_KEEPALIVE, mais elle offre 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 omettons cette macro, le compilateur optimiserait la fonction, car personne ne l'utilise après tout.

Enregistrons tout cela dans un fichier appelé fib.c. Pour le transformer en fichier .wasm, nous devons utiliser la commande de compilateur emcc d'Embscripten:

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

Analysons 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 ultérieurement. -O3 indique au compilateur d'optimiser agressivement. Vous pouvez choisir des nombres inférieurs pour réduire la durée de compilation, mais cela augmentera également la taille des bundles obtenus, car le compilateur pourrait ne pas supprimer le code inutilisé.

Après avoir exécuté la commande, vous devriez obtenir un fichier JavaScript nommé a.out.js et un fichier WebAssembly nommé a.out.wasm. Le fichier Wasm (ou "module") contient notre code C compilé et devrait ê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, elle 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. Le fichier JavaScript est donc un peu plus volumineux, avec une taille de 19 Ko (environ 5 Ko gzip).

Lancer quelque chose de 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 Module global à votre disposition. Utilisez cwrap pour créer une fonction native JavaScript qui convertit les paramètres en éléments compatibles avec C et appelle la fonction encapsulée. cwrap prend le nom de la fonction, le type renvoyé et les types d'argument comme 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 "144" dans la console, qui est le 12e nombre de Fibonacci.

Le Saint Graal: compiler une bibliothèque C

Jusqu'à présent, le code C que nous avons écrit a été écrit en tenant compte de Wasm. Cependant, l'un des principaux cas d'utilisation de WebAssembly consiste à prendre l'écosystème existant de bibliothèques C et à permettre aux développeurs de les utiliser sur le Web. Ces bibliothèques reposent 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, bien qu'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'avec une documentation complète sur les API. C'est un très bon point de départ.

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

Pour commencer, essayons d'exposer WebPGetEncoderVersion() depuis encode.h à 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 programme simple et efficace pour tester si nous pouvons compiler le code source de libwebp, car nous n'avons besoin d'aucun paramètre ni de structure de données complexe pour appeler cette fonction.

Pour compiler ce programme, nous devons indiquer au compilateur où 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. Je vais être honnête: je viens de lui donner tous les fichiers C que je pouvais trouver et de m'appuyer sur le compilateur pour supprimer tout ce qui était inutile. Cela semblait excellent !

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

Désormais, nous n'avons besoin que de code HTML et JavaScript pour charger notre tout 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 affichant le numéro de version correct.

Récupérer une image JavaScript dans Wasm

Obtenir le numéro de version de l'encodeur est parfait, mais encoder une image réelle serait plus impressionnant, n'est-ce pas ? C'est parti.

La première question à se poser est la suivante: comment intégrer l'image au terrain Wasm ? Si l'on considère l'API d'encodage de libwebp, on s'attend à un tableau d'octets au format RVB, RVBA, BGR ou BGRA. Heureusement, l'API Canvas dispose de getImageData(), qui nous donne un Uint8ClampedArray contenant les données d'image au format 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);
}

Il ne s'agit alors "que" de copier les données de JavaScript land vers Wasm. Pour cela, nous devons présenter deux fonctions supplémentaires. Une qui alloue de la mémoire pour l'image dans Wasm land et une 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 RVBA, soit 4 octets par pixel. Le pointeur renvoyé par malloc() correspond à 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);

Finale: encoder l'image

L'image est désormais disponible au format Wasm land. Il est temps d'appeler l'encodeur WebP pour qu'il effectue son travail. En consultant la documentation WebP, WebPEncodeRGBA semble être la solution idéale. La fonction utilise un pointeur vers l'image d'entrée et ses dimensions, ainsi qu'une option de qualité comprise entre 0 et 100. Il alloue également un tampon de sortie que nous devons libérer à l'aide de WebPFree() une fois l'image WebP terminée.

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 en tant que types renvoyés (sauf si nous allouons de la mémoire de manière dynamique), j'ai eu recours à un tableau global statique. Je sais, pas le C propre (en fait, cela 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 juste.

    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 cela est en place, nous pouvons appeler la fonction d'encodage, récupérer le pointeur et la taille de l'image, le placer dans notre propre tampon JavaScript, puis libérer tous les tampons Wasm-land que nous avons alloués au cours du 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, vous pouvez rencontrer une erreur indiquant que Wasm ne peut 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 en WebP. Pour prouver que cela a fonctionné, nous pouvons transformer le tampon de résultat en un 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);

Découvrez 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

Faire fonctionner une bibliothèque C dans le navigateur n'est pas une promenade facile, mais 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 traitement de nombres et les jeux vidéo. 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: créer quelque chose de simple, durement

Si vous le souhaitez, vous pouvez éviter d'utiliser le fichier JavaScript généré. 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 disponible, sauf si vous leur en fournissez. Pour fournir tout élément à un module Wasm, vous devez utiliser l'objet imports, le deuxième paramètre de la fonction instantiateStreaming. Le module Wasm peut accéder à tout ce qui se trouve à l'intérieur de l'objet "imports", mais rien d'autre en dehors de celui-ci. Par convention, les modules compilés par Emscripting attendent deux choses de l'environnement de chargement JavaScript:

  • Premièrement, env.memory. Le module Wasm n'est pas conscient du monde extérieur, pour ainsi dire, il doit donc disposer de la mémoire pour fonctionner. Saisissez WebAssembly.Memory. Il s'agit d'une quantité de mémoire linéaire (éventuellement évolutive). 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. Sans option maximum, la croissance de la mémoire est théoriquement illimitée (Chrome a actuellement une limite stricte de 2 Go). Il n'est pas nécessaire de définir un nombre maximal pour la plupart des modules WebAssembly.
  • env.STACKTOP définit où la pile est censée commencer à croître. La pile est nécessaire pour effectuer des appels de fonction et allouer de la mémoire pour les variables locales. Comme nous ne faisons aucune manœuvre en ce qui concerne la gestion dynamique de la mémoire dans notre petit programme Fibonacci, nous pouvons simplement utiliser l'intégralité de la mémoire en tant que pile, d'où STACKTOP = 0.