Benchmark : est-ce que ça vaut le coup d'utiliser WebAssembly plutôt que JavaScript ?

Dans mon précédent article, je vous avais présenté WebAssembly et je vous avais expliqué comment l'utiliser. Je vous avais également dit qu'il était « censé » être plus rapide que JavaScript et je vous avais promis de revenir plus en détail sur le sujet... Eh bien c'est parti !

Cet article a mis beaucoup plus de temps à sortir que prévu : plus j'effectuais de tests, plus je me posais de nouvelles questions sur le coût de tel ou tel élément et plus j'avais de nouvelles idées d'optimisation du code... Certaines de mes « découvertes » m'ont fait remettre en question ce que je tenais pour acquis sur WebAssembly, sur JavaScript et sur les deux navigateurs cobaye. Face à ces nouveaux éléments, j'ai dû réécrire une partie de l'article, d'où le délai. 😅️

Je ne vous en dis pas plus et on va se lancer sur le benchmark afin de déterminer si WebAssembly tient vraiment ses promesses et s'il est vraiment si rentable que ça de l'utiliser... #Suspense ! 😁️

Le benchmark

Étant donné que les besoins qui m'ont poussé à me pencher sur WebAssembly sont liés à du traitement d'image, on va rester dans ce thème. J'ai choisi d'implémenter un décalage de teinte (hue shift) pour le benchmark : ça reste un exemple simple, mais avec quand même une certaine quantité de calculs... Et puis j'avais déjà fait une partie de l'implémentation en JavaScript dans PhotonUI donc c'est toujours ça de moins à coder. 😋️

Animation: exemple de décalage de teinte

Aperçu du décalage de teinte

Concrètement, voici les étapes nécessaires à la réalisation du décalage de teinte (à répéter sur chaque pixel de l'image) :

  1. Premièrement, on convertit la couleur du pixel vers l'espace colorimétrique HSV. La couleur est originellement stockée en RGB, ce qui n'est pas très pratique pour la manipulation que l'on souhaite effectuer.
  2. Une fois converti, on peut décaler la teinte en toute simplicité.
  3. Et enfin, il faut reconvertir dans l'autre sens (HSV → RGB) pour pouvoir remettre le pixel dans l'image.

Note

Petite parenthèse sur les espaces colorimétriques

Les espaces colorimétriques sont un moyen d'exprimer les couleurs dans un espace, généralement à 3 dimensions. Il en existe plein de différents, utilisés suivant des besoins spécifiques comme par exemple RGB pour l'affichage sur écran, CMYK pour l'imprimerie, HSV pour la facilité de manipulation, ou encore L*a*b* lorsque l'on veut être au plus près de la perception humaine...

Dans notre cas on en utilise 2 :

  • RGB, qui définit une couleur par la synthèse additive de la quantité de rouge, de vert et de bleu qu'elle contient. C'est généralement de cette façon que sont stockés les pixels d'une image car c'est la manière dont fonctionnent physiquement les écrans de nos ordinateurs.
Illustration: Mélange additif de couleurs RGB
  • HSV, qui définit quant à lui la teinte (un nombre entre 0 et 360 représentant la couleur sur un cercle chromatique), la saturation (à quel point la couleur est vive ou terne) et la valeur (la luminosité de la couleur, donc à quel point elle est sombre ou brillante). HSV est en fait l'espace colorimétrique utilisé la plupart du temps lorsque l'on veut permettre à un utilisateur de sélectionner une couleur. Vous l'avez donc déjà utilisé si vous avez eu à faire à des sélecteurs de ce type :
Illustration : sélecteurs de couleurs HSV

) ← Fin de la parenthèse sur les espaces colorimétriques 😋️

Pour réaliser le benchmark, j'ai implémenté les différents algos à la fois en JavaScript et en C, de la manière la plus identique possible afin de ne pas biaiser le test.

Vous pourrez bien entendu retrouver le code source intégral, ainsi que les résultats bruts du benchmark, sur GitHub. J'ai également publié une version du benchmark en ligne pour que vous puissiez le tester facilement par vous-même.

Capture d'écran du code source

Aperçu du code source JavaScript (à gauche) et C (à droite) afin de comparer la similitude des implémentations

Conditions d'exécution du benchmark

Pour avoir les résultats les plus fiables possibles, j'ai essayé d'éliminer (ou en tout cas de réduire) tout ce qui pourrait avoir un impact sur les mesures effectuées.

Étant donné que le processeur de mon PC actuel comporte des cores asymétriques (4 cores P, plus performants, et 8 cores E, plus économes en énergie), et que je ne peux pas vraiment choisir sur quels cores mon système d'exploitation fera tourner le thread du navigateur qui exécutera le benchmark, j'ai décidé de ressortir mon ancien PC qui a tous ses cores parfaitement identiques.

J'ai donc fait tourner le benchmark sur mon ancien ThinkPad T490, équipé d'un processeur Intel i7-8565U (4 cores, avec hyper-threading) dont j'ai limité le changement de fréquence en forçant l'utilisation du mode performance du système.

J'ai également pris soin d'exécuter les benchmark après un cold boot, avec uniquement le navigateur en cours de test de lancé, avec ses paramètres par défaut, aucune extension, et un seul onglet d'ouverts.

Voici les specs du système de test et les versions des navigateurs :

Machine ThinkPad T490
Processeur Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
RAM 24 Go DDR4
Stockage SSD NVMe Samsung 970 EVO Plus 1 To
Système Ubuntu 22.04.1 LTS (Jammy Jellyfish)
Serveur d'affichage Wayland
Firefox 109.0.1 (build du PPA de Mozilla) (64-bit)
Chromium 109.0.5414.119 (Build officiel) snap (64 bits)

Sur chaque navigateur, le benchmark sera exécuté 360 fois, en alternance, pour chacune des deux implémentations. Autrement dit, je vais lancer le benchmark dans Firefox, qui exécutera d'abord la version JavaScript, puis la version WebAssembly, puis de nouveau la version JavaScript, puis WebAssembly, et ce 360 fois. Ensuite on recommence la même chose sur Chromium.

Dernier point important à noter : l'implémentation WebAssembly est compilée avec le niveau d'optimisation maximale :

emcc -O3 [...]

Résultats du benchmark

On ne va pas tourner autour du pot, voici les résultats obtenus avec ce premier benchmark:

Navigateur Implémentation Durée maximale (ms) Durée minimale (ms) Durée moyenne (ms)
Firefox JS 145.00 110.00 114.25
WASM 47.00 32.00 34.21
Chromium JS 118.80 56.80 58.93
WASM 72.00 41.00 42.52

Pour Firefox, on peut constater que l'implémentation WebAssembly a été 3,34 fois plus rapide que la version JavaScript... Plutôt impressionnant ! Pour Chromium, WebAssembly s'est montré 1,37 fois plus rapide que l'implémentation en JavaScript. C'est moins impressionnant mais ça reste une amélioration non négligeable !

Pour que ça soit plus parlant, voici un graphique représentant la durée de chaque runs :

Graphique du benchmark 01

Durée d'exécution (en millisecondes) des différentes implémentations (JS et WASM) du hue shift sur Firefox et Chromium

Ce graphique permet de se rendre compte que malgré quelques variations, les temps restent plutôt stables, surtout avec WebAssembly.

On peut également remarquer que les premiers runs sont beaucoup plus long que les suivants... Cela s'explique par l'optimisation des moteurs JavaScript : lors de la première exécution de la fonction, ils la compilent à la volée (on parle de Just In Time compilation, ou JIT pour les intimes). La seconde exécution de la fonction se fait donc sur un code déjà optimisé et compilé par l'interpréteur JavaScript, c'est pourquoi elle est beaucoup plus rapide.

Les résultats de ce benchmark sont plutôt conformes avec l'idée que je me faisais des différentes technologies et navigateurs testés, même si j'ai été un peu surpris par l'écart de performance entre les moteurs JavaScript de Firefox et de Chromium (je m'attendais à un écart en défaveur de Firefox, mais pas à ce point).

Quoi qu'il en soit, ces résultats sont plutôt encourageants pour WebAssembly, et j'aurais pu m'arrêter là et en conclure que WebAssembly, c'est vraiment trop bien... Mais je suis curieux et j'avais envie de jouer avec certains paramètres pour voir à quel point ils influeraient sur les performances... 😛️

Et si...

« Et si ». Deux mots qui ont rythmé mes semaines depuis la parution de mon premier article sur WebAssembly... « Et si je change ce paramètre, quel effet ça a sur les performances ? Et si je change celui-là ? Et si j'essayais de faire comme ça ? »...

Au fil des semaines, j'ai multiplié les benchmarks afin de répondre à mes interrogations et me forger une meilleure idée de ce que pouvait apporter WebAssembly en termes de performance. Quelques-uns de ces benchmarks sont disponibles dans le dépôt Git, et vous pouvez aller y jeter un œil si vous êtes curieux, mais la plupart ne sont que des tests en vrac sur mon disque dur (seuls ceux que je pensais utiliser pour mon article ont été écrits de manière présentable et publiés).

Quel est le coût de l'allocation de mémoire ?

Parmi ces benchmark, j'ai par exemple mesuré le coût de l'allocation et de la libération de mémoire nécessaire au stockage du tableau de pixel dans le module WebAssembly. En effet, dans la première version du benchmark, j'alloue (malloc()) et je libère (free()) la mémoire pour chaque appel à la fonction testé. Ce temps étant bien évidemment comptabilisé lors des mesures de performance de WebAssembly.

J'ai donc modifié un peu le benchmark afin de mesurer le temps avec et sans ces allocations de mémoire. Pour la version sans allocation, j'ai tout simplement alloué le tableau à l'avance et l'ai réutilisé tout le long des tests au lieu de le faire à chaque fois.

Voici les résultats ainsi obtenus :

Navigateur Implémentation Durée moyenne (ms)
Firefox WASM 35.08
WASM (shared buffer) 34.03
Chromium WASM 46.01
WASM (shared buffer) 42.44

On peut se rendre compte que le coût ne semble pas si énorme que ça : de l'ordre de 1 ms dans Firefox et 3.6 ms dans Chromium. Mais ce n'est pourtant pas négligeable ; tout dépend de la fréquence à laquelle on fait des allocations.

La quantité de données traitée influe-t-elle sur les performances ?

Ayant beaucoup travaillé sur des bindings Python / C, je sais d'expérience que le passage « d'un monde à l'autre » peut avoir un coût mesurable et parfois non négligeable. Il faut en effet convertir des types d'un langage à l'autre, ou copier de grandes quantités de données, comme c'est le cas ici dans notre benchmark.

J'ai donc voulu mesurer s'il était plus rentable de traiter des grosses images plutôt que des petites. J'ai donc écrit un benchmark testant plein d'images de différentes tailles afin de voir si je pourrais mesurer quelque chose d'intéressant.

Apperçu des différentes tailles d'image

Réponse rapide : bof, pas vraiment :

Graphique du benchmark 02

Si le « coût au pixel » semble un peu plus élevé sur les très petites images, cela se produit aussi bien sur l'implémentation JavaScript que sur l'implémentation WebAssembly. L'écart entre les deux implémentations reste globalement du même ordre de grandeur.

Cela semble indiquer qu'il n'y a pas de surcoût notable à appeler une fonction située dans un module WebAssembly depuis JavaScript. C'est plutôt bon à savoir. 🙂️

Et puis...

Et puis j'ai fini par me poser une excellente question : « Quel est le coût de mes appels de fonctions JavaScript ? ». Et il s'est avéré qu'il était bien plus important que je ne l'avais imaginé.

Réécriture du benchmark et nouveaux résultats

Rappelons tout d'abord le fonctionnement actuel du benchmark... Pour chaque pixel :

  • on convertit la couleur RGB vers HSV,
  • puis on effectue la rotation de teinte,
  • et on reconvertit la couleur de HSV vers RGB.

Pour ces étapes de conversion, j'ai tout naturellement développé deux fonctions helper :

  • rgb2hsv(r, g, b)[h, s, v]
  • hsv2rgb(h, s, v)[r, g, b]

Ces fonctions sont toutes deux appelées pour chaque pixel de l'image ; soit plus de 1,2 million de fois chacune pour l'image testée dans le benchmark.

J'ai donc décidé de réécrire le benchmark (aussi bien la version JavaScript que la version WebAssembly) sous la forme d'une seule grosse fonction. Et là j'ai eu des surprises !

Voici les résultats obtenus :

Navigateur Implémentation Durée maximale (ms) Durée minimale (ms) Durée moyenne (ms)
Firefox JS 72.00 44.00 46.72
WASM 42.00 30.00 31.81
Chromium JS 80.30 43.10 44.88
WASM 63.00 42.10 44.30

Et voici le détail des différents runs sur le graphique qui va bien :

Graphique du benchmark 04

Durée d'exécution (en millisecondes) des différentes implémentations (JS et WASM) du hue shift sur Firefox et Chromium

J'avoue que je ne m'attendais pas du tout à ce résultat ! 😲️

Pour bien que vous puissiez vous rendre compte de la différence, je vous remets côte à côte les résultats du premier benchmark et de la nouvelle version :

Graphique des benchmark 01 et 04 côte à côte

À gauche le résultat du premier benchmark, et à droite le nouveau

Dans la nouvelle version du benchmark, non seulement les performances des moteurs JavaScript de Firefox et de Chromium sont quasiment les mêmes, mais en plus les performances des versions WebAssembly et JavaScript de Chromium sont identiques. Seule la version WebAssembly tournant dans Firefox tire son épingle du jeu ! 🤯️

Vous pourrez retrouver cette nouvelle version du benchmark sur GitHub :

Conclusion

Ces benchmarks ne sont représentatif que d'un cas d'utilisation parmi d'autres et il n'est pas possible d'en tirer une généralité sur les performances de WebAssembly et de JavaScript ; sans compter que les navigateurs évoluent rapidement et s'améliorent de version en version.

Cependant j'ai appris pas mal de choses durant mes tests. La première est que ça vaut clairement le coup de bien travailler l'optimisation de son code JavaScript avant de considérer l'utilisation de WebAssembly : les performances entre les deux peuvent s'avérer identiques. On notera toutefois que WebAssembly peut apporter un boost de performance non négligeable sous Firefox.

Alors est-ce que ça vaut le coup d'utiliser WebAssembly dans vos applications Web ?

Je n'ai pas la réponse à cette question : après avoir fait votre maximum pour optimiser votre code JavaScript, si les performances ne sont toujours pas suffisantes, ça vaut le coup d'essayer, mais il ne faut pas s'attendre à des miracles non plus. 😉️

Il y a également le cas où vous avez déjà une base de code en C, C++ ou autre (je pense par exemple à OpenCV dans le monde du traitement d'image). Dans ce cas WebAssembly peut vous permettre de réutiliser directement ce code sans avoir à tout réécrire en JavaScript. Un gain de temps appréciable ! 😃️

J'aurais aimé pouvoir vous fournir un avis plus tranché, mais les choses ne sont jamais aussi simples qu'on le souhaiterait. J'espère en tout cas que cet article vous aura plu et qu'il aura pu vous apprendre quelques trucs.

À bientôt, j'espère, pour un nouvel article sur un tout autre sujet ! 😁️