Guide WebAssembly (WASM) pour les applications web modernes
Comment des binaires compilés depuis C/C++ et Rust atteignent des performances quasi natives dans le navigateur.
Ce qu’est WebAssembly, précisément
WebAssembly est un format d’instructions binaires et une cible de compilation. On l’écrit rarement à la main. Un compilateur prend du C, du C++, du Rust, du Zig ou du Go et produit un module ; le navigateur valide ce module et le compile en code machine. Le format est volontairement petit et volontairement ennuyeux : une machine à pile, un flux de contrôle structuré au lieu de sauts arbitraires, et un système de types assez strict pour que la validation tienne en une seule passe linéaire sur les octets. Ce sont ces contraintes qui permettent à un navigateur de commencer à compiler un module alors qu’il est encore en train de le télécharger.
En revanche, ce n’est pas un remplaçant de JavaScript. Un module n’a accès ni au DOM, ni au réseau, ni aux appels système. Il sait faire de l’arithmétique, lire et écrire dans sa propre mémoire, et appeler les fonctions que l’hôte lui importe explicitement. Tout le reste demeure l’affaire de JavaScript. En pratique, vous livrez les deux : un module qui fait le calcul, et une fine couche de JavaScript qui lui fournit les octets et en ressort les résultats.
Pourquoi les codecs ont leur place dans WebAssembly
L’argument habituel, c’est la vitesse, et présenté ainsi il en cache un meilleur. Les moteurs JavaScript modernes sont rapides, parfois étonnamment, mais ils y arrivent en spéculant. Le JIT observe quels types traversent une fonction, compile une version spécialisée et la protège par des gardes. Donnez-lui une valeur qu’il n’attendait pas : la garde échoue, le code est désoptimisé et l’exécution repasse en mode lent jusqu’à ce qu’il se rétablisse. Dans du code applicatif, c’est invisible. Dans la boucle interne d’un décodeur, c’est un à-coup en plein milieu d’une image.
Un module WebAssembly transporte ses types dans le binaire. Le compilateur connaît la forme de chaque valeur avant que quoi que ce soit ne s’exécute : il n’y a rien à deviner et rien à invalider plus tard. La mémoire linéaire n’est pas non plus gérée par un ramasse-miettes, si bien que le chemin critique n’a aucune pause de collecte à contourner. En débit maximal, les deux sont souvent plus proches qu’on ne le croit. L’écart se creuse sur la régularité, et c’est la régularité que l’utilisateur ressent vraiment, quand une barre de progression avance d’un pas égal au lieu de progresser par saccades. Les instructions SIMD à largeur fixe de 128 bits aident aussi, car les boucles sur des pixels ou sur des échantillons audio ont exactement la forme qui se vectorise bien.
Et puis il y a la raison qui n’a rien à voir avec les performances. Les codecs image et vidéo, les moteurs de rendu PDF, les moteurs OCR et les modèles vocaux existent déjà sous forme de bibliothèques C et C++ matures, qui ont absorbé des années de rapports de bugs et de cas limites liés à des entrées malformées. Réécrire libjpeg ou Tesseract en JavaScript n’est pas un projet de week-end, et le résultat serait longtemps moins bon que l’original. WebAssembly vous permet de compiler l’existant et de le livrer. C’est pour cela que l’essentiel du WebAssembly en production est là, et c’est une raison plus solide que n’importe quel benchmark.
La mémoire linéaire, et pourquoi un onglet a un plafond
La mémoire d’un module est un seul tampon contigu. Les pointeurs du module sont des décalages dans ce tampon, qui grandit par pages de 64 KiB, et en wasm32 ces pointeurs font 32 bits, ce qui plafonne la mémoire adressable à 4 GiB. Les navigateurs s’arrêtent bien avant, et les téléphones bien plus tôt encore. Il en découle trois conséquences, et ce sont elles qui font mal en production.
La croissance est, en pratique, à sens unique. memory.grow agrandit le tampon, mais quand l’allocateur du module libère un bloc, ce bloc retourne dans le tas du module, pas au navigateur. Un seul gros traitement relève donc le pic mémoire de l’onglet aussi longtemps que vit cette instance. Si vous traitez de gros fichiers les uns après les autres, détruire l’instance et en créer une nouvelle entre deux traitements est plus fiable que de compter sur free pour rendre quoi que ce soit.
La croissance exige aussi une réservation contiguë. Un grow peut échouer alors que la machine a encore beaucoup de RAM libre, simplement parce que l’espace d’adressage est fragmenté. On le voit surtout sur les builds 32 bits et sur les téléphones à mémoire limitée, et cela arrive sous la forme d’une exception au milieu d’une opération plutôt que d’un refus poli au départ.
Enfin, les données doivent être copiées d’un côté à l’autre de la frontière. JavaScript ne peut pas transmettre un objet File à un module. Vous lisez le fichier dans un ArrayBuffer, copiez ces octets dans la mémoire linéaire, lancez l’opération, puis recopiez le résultat vers l’extérieur pour construire un Blob. Pendant un instant, vous avez l’entrée en double, plus la mémoire de travail dont le codec a lui-même besoin. Prévoyez plusieurs fois la taille du fichier plutôt qu’une seule, fixez une limite de taille explicite dans l’interface et traitez en flux quand le format le permet. Mieux vaut un refus que vous avez écrit qu’un plantage que vous n’avez pas écrit.
Le binaire qu’il faut livrer
Un codec sérieux compilé en WebAssembly se mesure en mégaoctets, et il ne se compresse pas comme du JavaScript. Le JavaScript minifié est du texte répétitif, et gzip adore ça. Un module compilé est déjà du binaire compact, donc le taux de compression est moins bon, même si Brotli aide. Les modèles d’apprentissage automatique sont pires encore : à côté des poids, le runtime n’est qu’une erreur d’arrondi. La règle qui en découle est simple, et pourtant on continue de l’enfreindre. Ne chargez pas le module au chargement de la page. Chargez-le quand l’utilisateur s’engage dans l’opération.
Quelques mécanismes rendent ce chargement différé moins pénible. Servez le fichier en application/wasm pour que WebAssembly.instantiateStreaming puisse compiler au fil de l’arrivée des octets au lieu de mettre d’abord tout le téléchargement en tampon, car un mauvais type MIME vous en prive sans rien dire. Donnez au fichier une URL contenant un hash de son contenu et un en-tête de cache immutable, pour qu’une nouvelle visite ne coûte rien. Les navigateurs mettent aussi en cache le code compilé des modules les plus gros, ce qui permet à un visiteur qui revient d’éviter la compilation en plus du téléchargement.
Ce site repose sur ce principe. Rien qui ressemble à du WebAssembly ne fait partie du chargement initial de la page. L’outil OCR n’injecte son moteur qu’au moment où vous lancez vraiment l’OCR, et le worker de transcription récupère son modèle vocal la première fois que vous transcrivez, puis s’appuie sur le cache du navigateur. Quelqu’un qui convertit un JPG en PNG et s’en va n’en télécharge rien, car ce chemin ne touche jamais à WebAssembly. C’est un dessin sur canvas et un appel d’encodage, tous deux pris en charge par le code natif du navigateur lui-même.
Quand WebAssembly n’est pas la bonne réponse
Il n’a pas accès au DOM, donc chaque opération sur le DOM repasse par JavaScript via du code de liaison. Un framework d’interface compilé en WebAssembly paie ce péage à chaque interaction. Le passage coûte peu, mais il n’est pas gratuit, et il se produit sans arrêt.
Les chaînes de caractères sont malcommodes pour la même raison. Il n’existe pas de type chaîne partagé, donc le texte est encodé, copié et décodé à la frontière. Une charge de travail faite surtout de manipulation de chaînes peut passer plus de temps à faire transiter les données qu’à calculer.
Les traitements courts amortissent rarement l’investissement. Le téléchargement, la compilation et l’instanciation prennent un temps bien réel. Si le calcul lui-même dure quelques millisecondes, le module ne rattrape jamais ce temps.
Et quand la plateforme fait déjà le travail en code natif, livrer votre propre copie est un retour en arrière. WebCodecs encode et décode la vidéo. Web Audio gère les graphes audio. Canvas décode, redimensionne et réencode les images. Emballer un rééchantillonneur dans un mégaoctet de WebAssembly pour faire ce que drawImage fait déjà est la forme la plus nette de cette erreur.
Un dernier point relève moins de la limite que du piège. Les appels WebAssembly sont synchrones. Un appel long bloque le thread sur lequel il tourne, et sur le thread principal cela veut dire une page figée, exactement comme avec une longue boucle JavaScript. Les modules lourds ont leur place dans un Worker. Ce n’est pas un conseil facultatif.
Les questions à se poser d’abord
- Une bibliothèque native mature résout-elle déjà correctement ce problème ? C’est la raison la plus solide de la compiler, et la plus faible de la réécrire en JavaScript.
- Le travail est-il numérique, autonome et assez long pour amortir le téléchargement et la compilation ? Les boucles serrées sur des tampons typés sont la forme qui l’emporte.
- Une API du navigateur le couvre-t-elle déjà ? Si WebCodecs, Web Audio ou canvas gèrent le cas, utilisez-les et ne livrez rien.
- Le binaire peut-il attendre que l’utilisateur demande l’opération, et pouvez-vous vivre avec la mémoire que l’instance gardera pendant tout le reste de la vie de l’onglet ?
- Peut-il tourner dans un Worker ? S’il doit s’exécuter sur le thread principal et dure plus d’une frame, la solution est de le déplacer, pas de l’optimiser.
Cette technologie perd tout son côté spectaculaire dès qu’on cesse d’attendre qu’elle remplace JavaScript. C’est une façon d’exécuter du code natif existant dans un bac à sable qui n’a jamais été conçu pour lui, avec un modèle mémoire qu’il faut respecter et un téléchargement qu’il faut payer. Traitez ces deux coûts comme des contraintes de conception dès le premier jour, et le reste relève de l’ingénierie ordinaire.