Petite introduction à WebAssembly

Pour les besoins d'un projet nécessitant du traitement d'image, je me suis récemment penché sur WebAssembly, une technologie permettant d'apporter des performances supérieures à celle de JavaScript dans les navigateurs.

Dans cet article, je vais vous présenter ce qu'est exactement WebAssembly et comment ça s'utilise au travers de différents exemples couvrant les principaux points qui m'ont été utiles lors de la réalisation de mon projet.

Ces exemples mêleront du code JavaScript et du code C, mais pas de panique, on ne va pas faire de choses trop compliquées et je vais tout vous expliquer en détail ! 😁️

WebAssembly ? Qu'est-ce que c'est exactement ?

WebAssembly, abrégé WASM, est un standard du W3C définissant un format de bytecode bas niveau, compact, sûr (il tourne dans une sandbox), portable (pas dépendant d'une architecture ou d'un microprocesseur), et pouvant s'exécuter dans les navigateurs Web (entre autres).

WebAssembly n'est donc pas un langage de programmation, mais une target de compilation. Il est possible de compiler un programme écrit dans un langage de plus ou moins haut niveau comme C, C++, Rust ou encore Go vers WebAssembly, de la même manière qu'on l'aurait compilé pour Linux / amd64 ou pour Windows / x86.

Illustration de WebAssembly comme target de compilation

WebAssembly nous fournit également des API pour faire le lien entre le code ainsi compilé et le code JavaScript d'une page Web.

Les principaux intérêts de WebAssembly sont :

  • La portabilité : il est possible de réutiliser du code écrit dans n'importe quel langage au sein d'une page Web (pour peu qu'un compilateur WebAssembly existe pour ledit langage).
  • La rapidité : WebAssembly est censé s'exécuter plus rapidement que du code JavaScript (on en reparlera prochainement 😉️).

Un point important à considérer avant de se lancer, c'est la compatibilité. Il serait en effet dommage d'écrire une super application en WebAssembly si elle ne peut tourner nulle part... Eh bien soyez rassurés, on est pas mal de ce côté-là : les principaux navigateurs de bureau (Firefox, Chromium, Chrome, Safari, Opera et Edge) le supportent depuis 2017, et les navigateurs mobiles ne sont pas en reste.

Vous pourrez retrouver toutes les informations de compatibilité sur caniuse.com/wasm.

Compatibilité de WebAssembly sur les navigateurs de bureau

Compatibilité de WebAssembly sur les navigateurs de bureau

Maintenant que vous en savez un peu plus à son sujet, passons un peu à la pratique pour voir comment ça s'utilise.

Premier programme WebAssembly : Hello World

Commençons donc avec un traditionnel « Hello world » afin de se faire la main.

Installation du compilateur

Étant donné que l'on va devoir compiler un programme C vers WebAssembly, on va devoir installer un compilateur qui, dans notre cas, sera Emscripten.

Pour Linux, un paquet est disponible sur la plupart des distributions. Par exemple, il est possible de l'installer sous Ubuntu (à partir de la 22.04) à l'aide de la commande suivante :

sudo apt install emscripten

Si vous utilisez macOS, il y a un paquet Homebrew. Pour Windows, le plus simple est soit de passer par WSL afin d'utiliser le paquet pour Linux, soit de passer par Chocolatey pour une installation native. Pour les autres systèmes ou si vous souhaitez utiliser d'autres méthodes d'installation, vous pouvez consulter la documentation officielle d'Emscripten.

Écriture de notre programme

Passons maintenant à l'écriture de notre programme. Comme je l'ai dit en introduction, nous allons coder les parties WebAssembly en C ; on va donc commencer par créer un fichier nommé "hello.c" et y placer le code suivant :

#include <stdio.h>

int main() {
    puts("Hello World");
    return 0;
}

Compilation du programme

Si on voulait compiler ce programme de manière classique pour notre système, on pourrait le faire avec la commande suivante :

gcc hello.c -o hello

Eh bien pour le compiler pour WebAssembly, la commande n'est pas très différente :

emcc hello.c -o hello.html

Une fois compilé, on se retrouve avec les trois fichiers suivants :

  • hello.html : une page HTML générée par Emscripten et qui fera tourner notre application,
  • hello.js : du code JavaScript faisant la liaison entre notre application WebAssembly et la page Web,
  • hello.wasm : le fichier binaire contenant le code de notre application WebAssembly.

Lancer l'application dans le navigateur

Si vous ouvrez le fichier "hello.html" directement avec votre navigateur, vous vous apercevrez rapidement que rien ne fonctionne. Si vous avez la curiosité d'ouvrir la console JavaScript, vous pourrez y voir une erreur similaire à celle-ci :

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at file:///.../hello.wasm. (Reason: CORS request not http).

failed to asynchronously prepare wasm: [object ProgressEvent]

warning: Loading from a file URI (file:///.../hello.wasm) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing

Capture d'écran de l'erreur dans le navigateur

Le script de "hello.js" essaye en effet de télécharger "hello.wasm" mais il ne peut bien évidemment pas accéder aux fichiers sur le disque dur pour des raisons de sécurité.

Pour résoudre ce problème, il va falloir servir les fichiers à l'aide d'un serveur HTTP. Le plus simple pour le moment c'est d'ouvrir une console dans le dossier contenant nos fichiers puis de lancer le serveur HTTP intégré à Python :

cd dossier/de/mon/projet/
python3 -m http.server

On peut ensuite se rendre à l'adresse "http://localhost:8000/hello.html" à l'aide de notre navigateur :

Capture d'écran de l'application WebAssembly tournant dans le navigateur.

Et cette fois-ci ça fonctionne ! On peut constater que notre « Hello World » s'affiche bien, à la fois dans la « console » Emscripten présente dans la page ainsi que dans la console JavaScript.

Plus d'options de compilation

Il existe une option de compilation qui peut nous éviter d'avoir à lancer un serveur HTTP : -sSINGLE_FILE. Cette option fait en sorte que tout se retrouve dans un unique fichier et résout donc nos problèmes de téléchargement lorsque l'on ouvre la page HTML directement avec le navigateur.

La commande de compilation devient donc :

emcc -sSINGLE_FILE hello.c -o hello.singlefile.html

C'est tout de suite beaucoup plus pratique ! 😁️

Note

Vous pouvez retrouver le code source de cet exemple ainsi qu'une version de démo en ligne sur Github :

Second programme WebAssembly : appeler les fonctions WASM depuis JavaScript

Pour ce second programme, on va voir comment on appelle une fonction WebAssembly depuis du code JavaScript, comment lui passer des paramètres et comment récupérer sa valeur de retour. On va également voir comment intégrer tout ça dans notre propre page Web plutôt que d'utiliser celle générée par Emscripten.

Pour ce programme, on aura les fichiers suivants :

  • example.c : contient notre code C qui sera compilé en WebAssembly,
  • example.wasm.js : la version compilée de example.c,
  • script.js : contient le code JavaScript qui fera appel à une fonction C,
  • index.html : la page Web qui servira d'hôte à tout ce petit monde.

example.c

On va donc écrire un fichier "example.c" contenant une fonction permettant d'additionner deux nombres (oui je sais, c'est pas foufou comme exemple, mais ça a le mérite d'être simple). Voici le contenu de ce fichier :

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE int sum(int a, int b) {
    return a + b;
}

On peut remarquer que la fonction sum() dans le code ci-dessus est précédée par un mystérieux EMSCRIPTEN_KEEPALIVE. Cette macro permet d'indiquer deux choses au compilateur :

  1. De conserver cette fonction quoi qu'il arrive (sinon elle pourrait se retrouver supprimée du code généré si elle n'est jamais appelée ailleurs dans le code C).
  2. De rajouter cette fonction a la liste des fonctions exportées (et donc accessibles depuis le JavaScript).

On notera également l'inclusion de <emscripten.h> : on en a besoin tout simplement par ce que c'est là qu'est définie la macro EMSCRIPTEN_KEEPALIVE.

On peut à présent compiler notre exemple à l'aide de la commande suivante:

emcc -sSINGLE_FILE -s"EXPORTED_RUNTIME_METHODS=['ccall']" example.c -o example.wasm.js

Il y a deux différences par rapport à la commande que l'on avait utilisée dans l'exemple précédent :

  • -s"EXPORTED_RUNTIME_METHODS=['ccall']" : cette option demande à Emscripten d'exporter la fonction ccall() du runtime. Il s'agit de la fonction qui nous servira de passerelle entre le code JavaScript et le code C.
  • example.wasm.js : ici mon fichier de sortie a pour extension ".js", ce qui fait que Emscripten ne génèrera plus la page HTML, il faudra donc écrire la nôtre.

Note

NOTE : Il est également possible d'utiliser l'extension .mjs pour générer des modules ES2015 importables (si vous utilisez Browserify, Webpack, etc.), ainsi que l'extension .wasm pour ne générer que les modules WebAssembly.

script.js

Passons à présent au code JavaScript qui appellera notre fonction sum(). Créons donc un fichier "script.js" avec le code suivant :

Module.onRuntimeInitialized = function() {
    const numA = 32;
    const numB = 10;

    const result = Module.ccall(
        "sum",                    // Nom de la fonction WASM à appeler
        "number",                 // Type de la valeur de retour
        ["number", "number"],     // Types des paramètres de la fonction
        [numA, numB],             // Les paramètres de la fonction
    );

    console.log(`${numA} + ${numB} = ${result}`);
};

Rien de bien compliqué ici : lorsque le module WASM est prêt, (Module.onRuntimeInitialized), on appelle la fonction sum() via Module.ccall(). La syntaxe est un peu rébarbative puisqu'il faut lui indiquer explicitement tous les types des paramètres et de la valeur de retour de la fonction C, mais on verra un peu plus tard comment simplifier tout ça. 😉️

Avis

Attention : il est important de noter que le module WASM n'est pas encore prêt à fonctionner lorsque la page Web a fini de charger (window.onload). Si du code doit être immédiatement exécuté au chargement de la page il vous faudra definir le callback Module.onRuntimeInitialized à la place.

index.html

Maintenant qu'on a notre code C et JavaScript de prêts, il ne nous reste plus qu'à écrire notre page HTML :

<!DOCTYPE html>

<html>

    <head>
        <meta charset="UTF-8" />
        <title>WebAssembly Example 02</title>
    </head>

    <body>
        <script src="./example.wasm.js"></script>
        <script src="./script.js"></script>
    </body>

</html>

Lancement du programme

On peut maintenant ouvrir notre page "index.html" dans un navigateur et ouvrir la console JavaScript pour admirer le résultat.

Apperçu du résultat dans la console JavaScript

Note

Vous pouvez une nouvelle fois retrouver le code source de cet exemple ainsi qu'une version de démo en ligne (dans une version un peu améliorée) sur Github :

Capture d'écran de la version améliorée du second exemple dans un navigateur

Troisième programme WebAssembly : pointeurs et tableaux

Pour ce troisième programme, on va voir comment utiliser des pointeurs et comment passer des tableaux entre le code JavaScript et le module WebAssembly. On va donc écrire un petit programme de traitement d'image qui travaillera sur les pixels d'un canvas.

Le traitement en question ne sera pas très compliqué : on va implémenter un seuil. Tous les pixels en dessous du seuil seront affichés en noir et tout ceux au-dessus en blanc, ce qui nous donnera un résultat similaire à celui-ci :

Exemple d'un seuil sur une image

Pour cet exemple, on va avoir besoin des fichiers suivants :

  • example.c : notre code C qui sera compilé en WebAssembly,
  • example.wasm.js : la version compilée de example.c,
  • script.js : code JavaScript qui fera appel à nos fonctions WASM,
  • image.jpg : l'image sur laquelle on va effectuer des traitements,
  • index.html : la page Web qui servira d'hôte à tout ce petit monde.

index.html

Commençons cette fois-ci par la page Web :

<!DOCTYPE html>

<html>

    <head>
        <meta charset="UTF-8" />
        <title>WebAssembly Example 03</title>
    </head>

    <body>
        <img src="./image.jpg" id="image" />
        <canvas id="canvas"></canvas>
        <script src="./example.wasm.js"></script>
        <script src="./script.js"></script>
    </body>

</html>

Rien de bien différent par rapport à l'exemple précédent, si ce n'est la présence d'une image, qui sera celle que l'on va traiter, et d'un canvas qui permettra d'afficher le résultat.

example.c

Pour la partie en C, on va créer le fichier "example.c" avec le contenu suivant :

#include <stdlib.h>
#include <stdint.h>
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE uint8_t* allocBuffer(int size) {
    return malloc(size * sizeof(uint8_t));
}

EMSCRIPTEN_KEEPALIVE void freeBuffer(uint8_t* buffer) {
    free(buffer);
}

Comme vous pouvez le constater, ici il n'est pas encore question de jouer avec les pixels d'une image. Avant de pouvoir envoyer un tableau de pixels depuis le JavaScript, encore faut-il être en mesure d'allouer la mémoire nécessaire pour l'accueillir.

On a donc les deux fonctions suivantes qui nous serviront à gérer la mémoire :

  • allocBuffer() qui va allouer la mémoire en quantité suffisante pour contenir les pixels de l'image à traiter. Cette fonction nous retournera un pointeur vers la zone mémoire où l'on pourra stocker le tableau de pixel.
  • et freeBuffer() qui permettra de libérer la mémoire quand on en aura plus besoin.

Ces fonctions seront à appeler depuis le code JavaScript lorsque l'on voudra passer un tableau à la fonction WebAssembly qui fera le traitement d'image.

Note

NOTE : on peut également remarquer que j'ai utilié le type uint8_t, définit dans <stdint.h>, afin que le type du buffer soit bien explicite. J'aurais également pu le déclarer en tant que unsigned char, ça aurait donné le même résultat.

Passons maintenant à la fonction threshold() qui fera effectivement le traitement d'image :

EMSCRIPTEN_KEEPALIVE void threshold(uint8_t* pixels, int width, int height, int threshold) {
    // Comme il y a 4 canaux dans notre image (Rouge, Vert, Bleu, Alpha),
    // la taille du tableau fait : largeur × hauteur × 4
    int array_length = width * height * 4;

    // Ici, i = i + 4 car on navigue de pixel en pixel, et qu'un pixel est
    // composé de 4 composantes (RGBA).
    for (int i = 0 ; i < array_length ; i += 4) {
        int red = pixels[i+0];
        int green = pixels[i+1];
        int blue = pixels[i+2];
        // On ignore la case [i+3] puisqu'on a pas besoin de toucher au
        // canal alpha qui code la transparence du pixel.

        // Calcul de la luminosité du pixel à partir de ses couleurs.
        int brightness = 0.299 * red + 0.587 * green + 0.114 * blue;

        // Si la luminosité du pixel est en dessous du seuil donné, on le
        // change en noir, sinon on le change en blanc.
        if (brightness < threshold) {
            pixels[i+0] = 0;
            pixels[i+1] = 0;
            pixels[i+2] = 0;
        } else {
            pixels[i+0] = 255;
            pixels[i+1] = 255;
            pixels[i+2] = 255;
        }
    }
}

La fonction ci-dessus est relativement simple. Elle prend en paramètre :

  • le pointeur vers le tableau de pixel à traiter,
  • les dimensions de l'image afin de pouvoir calculer la taille du tableau,
  • et le seuil souhaité.

Ensuite on boucle sur tous les pixels de l'image. On calcule la luminosité du pixel puis on remplace sa couleur par du noir si la luminosité est en dessous du seuil, ou par du blanc si elle est au-dessus.

Une fois le code écrit, il ne nous reste plus qu'à compiler le programme à l'aide de la commande suivante :

emcc -sSINGLE_FILE -s"EXPORTED_RUNTIME_METHODS=['cwrap']" example.c -o example.wasm.js

Les plus attentifs d'entre vous auront remarqué qu'il y a une légère différence par rapport à la commande de l'exemple précédent : ici j'exporte la fonction du runtime cwrap() au lieu de ccall().

On a vu qu'il était en effet assez pénible d'appeler les fonctions WebAssembly avec ccall() car il faut passer plein de paramètres liés aux types. cwrap() va nous permettre de rendre les choses plus simples.

script.js

Il ne nous reste plus qu'à écrire le code JavaScript qui fera appel à nos fonctions WebAssembly. Et on va commencer par binder les fonctions WASM en JavaScript afin de pouvoir les appeler de manière transparente, sans avoir à spécifier les types des paramètres à chaque fois :

const API = {
    allocBuffer: Module.cwrap("allocBuffer", "number", ["number"]),
    freeBuffer: Module.cwrap("freeBuffer", "", ["number"]),
    threshold: Module.cwrap("threshold", "", ["number", "number", "number", "number"]),
};

La fonction Module.cwrap() prend des paramètres très similaires à ceux de Module.ccall(), et elle nous retourne des fonctions JavaScript que l'on va pouvoir appeler par la suite.

Écrivons à présent la fonction qui fera les manipulations sur l'image, le canvas et les appels au code WebAssembly :

function thresholdImageOnCanvas(image, canvas, threshold=127) {
    // On commence par redimensionner le canvas à la même taille que l'image.
    canvas.width = image.width;
    canvas.height = image.height;

    // On récupère le contexte 2D du canvas afin de pouvoir dessiner dedans.
    const ctx = canvas.getContext("2d");

    // On dessine l'image dans le canvas.
    ctx.drawImage(image, 0, 0);

    // On récupère les pixels qui composent l'image sous forme de tableau.
    //
    // L'objet « imageData » retourné est composé de la manière suivante :
    //
    // {
    //     width: ...,
    //     height: ...,
    //     data: [red1, green1, blue1, alpha1, red2, green2, blue2, alpha2, …]
    //     //     ---------------------------  ---------------------------
    //     //      PIXEL 1 (en haut à gauche)             PIXEL 2
    // }
    //
    // NOTE: « imageData.data » est un Uint8ClampedArray.
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // Allocation du buffer qui contiendra nos pixels dans le module WASM.
    //
    // « pixel_p » est un pointeur vers le buffer (dans le monde
    // JavaScript, il ne s'agit que d'un simple nombre).
    const pixels_p = API.allocBuffer(imageData.data.length);

    // Copie des pixels dans le buffer préalablement alloué.
    //
    // « Module.HEAP8 » est un Uint8Array qui représente la « RAM » (le tas
    // pour être plus précis) du module WASM. Le pointeur « pixel_p » est en
    // fait simplement l'index de la case où commence notre buffer dans ce
    // tableau.
    Module.HEAP8.set(imageData.data, pixels_p);

    // Appel de la fonction WASM qui va opérer les modifications de
    // l'image.
    API.threshold(pixels_p, imageData.width, imageData.height, threshold);

    // On récupère les pixels modifiés dans la mémoire du programme WASM et
    // on les remet en place dans l'objet « imageData ».
    imageData.data.set(new Uint8Array(
        Module.HEAP8.buffer, pixels_p, imageData.data.length
    ));

    // On remet les pixels dans le canvas.
    ctx.putImageData(imageData, 0, 0);

    // Et enfin on oublie pas de libérer la mémoire.
    API.freeBuffer(pixels_p);
}

Je pense que le code est suffisamment commenté pour être compréhensible. J'avoue qu'au début ça fait un peu bizarre de se retrouver avec des pointeurs en JavaScript, mais on s'y fait. 😉️

Et pour terminer, on rajoute quelques lignes de code pour appeler notre fonction dès que le module WASM est initialisé et que l'image à traiter est chargée :

Module.onRuntimeInitialized = function() {
    const image = document.getElementById("image");
    const canvas = document.getElementById("canvas");

    // Si l'image est chargée, on appelle directement la fonction de traitement
    // d'image, sinon on attend qu'elle soit effectivement chargée pour appeler
    // la fonction.
    if (image.complete) {
        thresholdImageOnCanvas(image, canvas);
    } else {
        image.onload = function() {
            thresholdImageOnCanvas(image, canvas);
        };
    }
};

Lancement du programme

Pour lancer notre programme, on ne va pas pouvoir se contenter de simplement ouvrir notre page Web avec notre navigateur cette fois-ci. À cause des sécurités en place dans les navigateurs, si une image d'une origine différente de celle de la page Web est collée dans un canvas, il n'est plus possible de récupérer les pixels de ce dernier. Il nous faut donc servir notre page Web et notre image avec un serveur HTTP, et sur la même « adresse ».

Comme pour le premier exemple de cet article, il est possible là aussi d'utiliser le serveur HTTP intégré à Python :

cd /chemin/vers/lexemple3/
python3 -m http.server

Il suffit ensuite de se rendre à l'adresse suivante pour admirer le résultat :

Note

Le code source de cet exemple ainsi qu'une version de démo en ligne sont disponibles sur Github :

Conclusion

J'aurais voulu aborder de nombreux autres sujets dans cet article, comme l'appel de fonction JavaScript depuis le module WASM, ou l'accès à certaines API des navigateurs (Web Workers, Fetch API, Gamepad API,...), mais il ne s'agit que d'une introduction au sujet, on ne peut pas être exhaustifs. 😅️

À l'origine je voulais également ajouter un benchmark à l'article afin de comparer les performances de JavaScript et de WebAssembly, mais l'article commençait à devenir trop long à mon goût. Je vais donc rédiger un article dédié à ce benchmark.

J'espère que cet article vous aura permis d'en apprendre un peu plus sur le fonctionnement de WebAssembly, et je vous retrouve d'ici deux semaines environ pour un benchmark WebAssembly vs JavaScript. 😋️

Pour aller plus loin :


La photo de papillon utilisée pour le 3ème exemple a été prise par Atanu Bose et est placée sous la licence Creative Commons Attribution-Share Alike 4.0.