Remplacer un chemin d'accès réactif dans le code JavaScript de votre application par WebAssembly

Il est toujours rapide,

Dans mes articles précédents, j'ai expliqué comment WebAssembly vous permet de déployer l'écosystème de bibliothèque C/C++ sur le Web. Une application qui fait un usage intensif des bibliothèques C/C++ est squoosh, notre application Web qui vous permet de compresser des images avec divers codecs compilés de C++ vers WebAssembly.

WebAssembly est une machine virtuelle de bas niveau qui exécute le bytecode stocké dans des fichiers .wasm. Ce bytecode est fortement typé et structuré de manière à pouvoir être compilé et optimisé pour le système hôte bien plus rapidement que JavaScript. WebAssembly fournit un environnement pour exécuter du code en pensant à la mise en bac à sable et à l'intégration dès le départ.

D'après mon expérience, la plupart des problèmes de performances sur le Web sont causés par une mise en page forcée et une peinture excessive. Cependant, une application doit de temps en temps effectuer une tâche coûteuse en calcul qui prend beaucoup de temps. WebAssembly peut vous aider.

Chemin réactif

Dans squoosh, nous avons écrit une fonction JavaScript qui fait pivoter un tampon d'image par multiples de 90 degrés. Bien que OffscreenCanvas soit idéal pour cela, il n'est pas compatible avec les navigateurs que nous ciblions et présente un peu de bugs dans Chrome.

Cette fonction effectue une itération sur chaque pixel d'une image d'entrée et la copie dans une position différente dans l'image de sortie pour obtenir une rotation. Pour une image de 4 094 x 4 096 pixels (16 mégapixels), il faudrait plus de 16 millions d'itérations du bloc de code interne, ce que nous appelons un "hot path". Malgré ce nombre plutôt important d'itérations, deux navigateurs sur trois que nous avons testés terminent la tâche en deux secondes maximum. Durée acceptable pour ce type d'interaction.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Toutefois, un seul navigateur prend plus de 8 secondes. La façon dont les navigateurs optimisent JavaScript est très compliquée, et selon les différents moteurs, l'optimisation varie selon les besoins. Certaines sont optimisées pour une exécution brute, d'autres pour une interaction avec le DOM. Dans le cas présent, nous avons rencontré un chemin non optimisé dans un navigateur.

WebAssembly, quant à lui, repose entièrement sur la vitesse d'exécution brute. Ainsi, si nous voulons des performances rapides et prévisibles dans tous les navigateurs pour ce type de code, WebAssembly peut nous aider.

WebAssembly pour des performances prévisibles

En général, JavaScript et WebAssembly peuvent atteindre les mêmes performances optimales. Toutefois, pour JavaScript, ces performances ne sont accessibles que via le "chemin rapide", et il est souvent difficile de rester sur ce "chemin rapide". L'un des principaux avantages de WebAssembly est des performances prévisibles, même sur différents navigateurs. La saisie stricte et l'architecture de bas niveau permettent au compilateur d'offrir des garanties plus solides. Ainsi, le code WebAssembly ne doit être optimisé qu'une seule fois et doit toujours utiliser le "chemin rapide".

Écrire pour WebAssembly

Auparavant, nous avons pris des bibliothèques C/C++ et les avons compilées dans WebAssembly afin d'utiliser leurs fonctionnalités sur le Web. Nous n'avons pas vraiment touché au code des bibliothèques, nous avons juste écrit de petites quantités de code C/C++ pour créer le pont entre le navigateur et la bibliothèque. Cette fois, notre motivation est différente: nous voulons écrire quelque chose à partir de zéro en gardant WebAssembly à l'esprit afin de pouvoir profiter de ses avantages.

Architecture WebAssembly

Lorsque vous écrivez pour WebAssembly, il est utile d'en savoir un peu plus sur ce qu'est WebAssembly.

Pour citer WebAssembly.org:

Lorsque vous compilez un extrait de code C ou Rust dans WebAssembly, vous obtenez un fichier .wasm contenant une déclaration de module. Cette déclaration se compose d'une liste d'"importations" que le module attend de son environnement, d'une liste d'exportations que ce module met à la disposition de l'hôte (fonctions, constantes, fragments de mémoire) et, bien sûr, des instructions binaires réelles des fonctions contenues dans ce module.

Quelque chose dont je n'ai pas réalisé que j'y ai étudié: la pile qui fait de WebAssembly une "machine virtuelle basée sur une pile" n'est pas stockée dans le fragment de mémoire utilisé par les modules WebAssembly. La pile est entièrement interne à la VM et inaccessible aux développeurs Web (sauf via les outils de développement). Ainsi, il est possible d'écrire des modules WebAssembly qui ne nécessitent aucune mémoire supplémentaire et n'utilisent que la pile interne de la VM.

Dans le cas présent, nous devrons utiliser de la mémoire supplémentaire pour autoriser un accès arbitraire aux pixels de notre image et générer une version pivotée de cette image. C'est à cela que sert WebAssembly.Memory.

Gestion de la mémoire

En général, une fois que vous avez utilisé de la mémoire supplémentaire, vous devez gérer cette mémoire. Quelles parties de la mémoire sont utilisées ? Lesquelles sont sans frais ? En C, par exemple, la fonction malloc(n) trouve un espace mémoire de n octets consécutifs. Les fonctions de ce type sont également appelées "allocations". Bien entendu, l'implémentation de l'outil d'allocation utilisé doit être incluse dans votre module WebAssembly et augmentera la taille de votre fichier. La taille et les performances de ces fonctions de gestion de mémoire peuvent varier considérablement selon l'algorithme utilisé. C'est pourquoi de nombreux langages proposent plusieurs implémentations ("dmalloc", "emmalloc", "wee_alloc", etc.).

Dans le cas présent, nous connaissons les dimensions de l'image d'entrée (et donc celles de l'image de sortie) avant d'exécuter le module WebAssembly. Ici, nous avons vu une opportunité: traditionnellement, nous transmettions le tampon RVBA de l'image d'entrée en tant que paramètre à une fonction WebAssembly et renvoyions l'image pivotée en tant que valeur de retour. Pour générer cette valeur renvoyée, nous devrions utiliser l'outil d'allocation. Toutefois, comme nous connaissons la quantité totale de mémoire nécessaire (deux fois la taille de l'image d'entrée, une fois en entrée et une fois en sortie), nous pouvons placer l'image d'entrée dans la mémoire WebAssembly à l'aide de JavaScript, exécuter le module WebAssembly pour générer une deuxième image ayant fait l'objet d'une rotation, puis utiliser JavaScript pour relire le résultat. Nous pouvons partir sans utiliser aucune gestion de la mémoire.

J'ai l'embarras du choix

Si vous avez examiné la fonction JavaScript d'origine que nous voulons tester avec WebAssembly, vous pouvez constater qu'il s'agit d'un code purement de calcul sans API spécifiques à JavaScript. Par conséquent, il devrait être assez simple de porter ce code dans n'importe quel langage. Nous avons évalué trois langages différents qui peuvent être compilés dans WebAssembly: C/C++, Rust et AssemblyScript. Pour chaque langage, la seule question à laquelle nous devons répondre est la suivante: comment accéder à la mémoire brute sans utiliser les fonctions de gestion de la mémoire ?

C et Emscripten

Emscripten est un compilateur C pour la cible WebAssembly. L'objectif d'Emscripten est de remplacer directement les compilateurs C bien connus, tels que GCC ou clang, et il est principalement compatible avec les indicateurs. Il s'agit d'un élément essentiel de la mission d'Emscripten, qui souhaite simplifier au maximum la compilation du code C et C++ existant vers WebAssembly.

L'accès à la mémoire brute est dans la nature même de C, et les pointeurs existent pour cette raison:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Ici, nous transformons le nombre 0x124 en un pointeur en entiers (ou octets) non signés de 8 bits. Cela transforme efficacement la variable ptr en un tableau commençant par l'adresse mémoire 0x124, que nous pouvons utiliser comme n'importe quel autre tableau, ce qui nous permet d'accéder à des octets individuels pour la lecture et l'écriture. Dans le cas présent, nous examinons le tampon RVBA d'une image que nous souhaitons réorganiser pour obtenir la rotation. Pour déplacer un pixel, nous devons en fait déplacer quatre octets consécutifs à la fois (un octet pour chaque canal: R, V, B et A). Pour faciliter cela, nous pouvons créer un tableau d'entiers 32 bits non signés. Par convention, l'image d'entrée commence à l'adresse 4 et l'image de sortie commence directement après la fin de l'image d'entrée:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Après le portage de l'ensemble de la fonction JavaScript vers C, nous pouvons compiler le fichier C avec emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Comme toujours, emscripten génère un fichier de code Glue appelé c.js et un module Wasm nommé c.wasm. Notez que le module Wasm se limite à gzip, à raison de 260 octets seulement, tandis que le code Glue se situe à environ 3,5 Ko après gzip. Après quelques manipulations, nous avons pu supprimer le glue code et instancier les modules WebAssembly avec les API vanilla. Cela est souvent possible avec Emscripten, à condition que vous n'utilisiez aucun élément de la bibliothèque standard C.

Rust

Rust est un nouveau langage de programmation moderne doté d'un système de types enrichies, d'aucun environnement d'exécution et d'un modèle de propriété qui garantit la sécurité de la mémoire et des threads. Rust est également une fonctionnalité essentielle de WebAssembly, et l'équipe Rust a fourni de nombreux excellents outils à l'écosystème WebAssembly.

L'un de ces outils est wasm-pack, du groupe de travail rustwasm. wasm-pack récupère votre code et le transforme en un module convivial pour le Web qui fonctionne directement avec des bundlers tels que webpack. wasm-pack est une expérience extrêmement pratique, mais ne fonctionne actuellement que pour Rust. Le groupe envisage d'ajouter la prise en charge d'autres langues ciblées par WebAssembly.

En Rust, les tranches sont ce que sont les tableaux en C. Et tout comme en C, nous devons créer des tranches qui utilisent nos adresses de départ. Cette approche va à l'encontre du modèle de sécurité de la mémoire appliqué par Rust. Pour ce faire, nous devons utiliser le mot clé unsafe, ce qui nous permet d'écrire du code non conforme à ce modèle.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Compiler les fichiers Rust à l'aide

$ wasm-pack build

permet d'obtenir un module Wasm de 7,6 Ko avec environ 100 octets de code Glue (les deux après gzip).

AssemblyScript

AssemblyScript est un projet relativement jeune qui vise à être un compilateur TypeScript-to-WebAssembly. Toutefois, il est important de noter qu'il ne consommera pas n'importe quel TypeScript. AssemblyScript utilise la même syntaxe que TypeScript, mais remplace la bibliothèque standard par sa propre bibliothèque. Leur bibliothèque standard modélise les capacités de WebAssembly. Cela signifie que vous ne pouvez pas simplement compiler n'importe quel TypeScript que vous utilisez sur WebAssembly, mais que vous n'avez pas besoin d'apprendre un nouveau langage de programmation pour écrire WebAssembly.

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Compte tenu de la petite surface de type de notre fonction rotate(), il était relativement facile de transférer ce code vers AssemblyScript. Les fonctions load<T>(ptr: usize) et store<T>(ptr: usize, value: T) sont fournies par AssemblyScript pour accéder à la mémoire brute. Pour compiler notre fichier AssemblyScript, il suffit d'installer le package npm AssemblyScript/assemblyscript et d'exécuter

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript nous fournira un module Wasm d'environ 300 octets et aucun glue code. Le module fonctionne uniquement avec les API vanilla WebAssembly.

WebAssembly Forensics

Rust est de 7,6 Ko, ce qui est étonnamment élevé par rapport aux deux autres langues. L'écosystème WebAssembly contient plusieurs outils qui peuvent vous aider à analyser vos fichiers WebAssembly (quel que soit le langage utilisé pour leur création), à vous informer de ce qui se passe et à améliorer votre situation.

Twiggy

Twiggy est un autre outil de l'équipe WebAssembly de Rust qui extrait un ensemble de données pertinentes d'un module WebAssembly. Cet outil n'est pas spécifique à Rust et vous permet d'inspecter des éléments tels que le graphique d'appel du module, d'identifier les sections inutilisées ou superflues, et d'identifier les sections qui contribuent à la taille de fichier totale de votre module. Vous pouvez effectuer cette opération à l'aide de la commande top de Twiggy:

$ twiggy top rotate_bg.wasm
Capture d&#39;écran de l&#39;installation par Twiggy

Dans le cas présent, nous constatons qu'une grande partie de la taille de notre fichier provient de l'outil d'allocation. C'est surprenant, car notre code n'utilise pas les allocations dynamiques. La sous-section "Noms des fonctions" est un autre facteur contribuant grandement.

Wasm-Strip

wasm-strip est un outil du kit binaire WebAssembly, ou wabt. Il contient quelques outils qui vous permettent d'inspecter et de manipuler les modules WebAssembly. wasm2wat est un désassembleur qui transforme un module Wasm binaire en un format lisible par l'humain. Wabt contient également wat2wasm, qui vous permet de reconvertir ce format lisible en un module Wabt binaire. Bien que nous ayons utilisé ces deux outils complémentaires pour inspecter nos fichiers WebAssembly, nous avons constaté que wasm-strip était le plus utile. wasm-strip supprime les sections et les métadonnées inutiles d'un module WebAssembly:

$ wasm-strip rotate_bg.wasm

Cela permet de réduire la taille du fichier du module Rust de 7,5 Ko à 6,6 Ko (après gzip).

wasm-opt

wasm-opt est un outil de Binaryen. Il utilise un module WebAssembly et tente de l'optimiser à la fois en termes de taille et de performances uniquement en fonction du bytecode. Certains outils comme Emscripten exécutent déjà cet outil, d'autres non. Il est généralement judicieux d'essayer d'économiser quelques octets supplémentaires à l'aide de ces outils.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Avec wasm-opt, nous pouvons réduire encore quelques octets pour laisser un total de 6,2 Ko après gzip.

#![no_std]

Après consultation et recherche, nous avons réécrit notre code Rust sans utiliser la bibliothèque standard de Rust, à l'aide de la fonctionnalité #![no_std]. Cela désactive également complètement les allocations de mémoire dynamiques, ce qui supprime le code de l'outil d'allocation de notre module. Compilation de ce fichier Rust avec

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

a généré un module Wasm de 1,6 Ko après wasm-opt, wasm-strip et gzip. Bien qu'il soit encore plus volumineux que les modules générés par C et AssemblyScript, il est suffisamment petit pour être considéré comme léger.

Performances

Avant de tirer des conclusions s'appuyant uniquement sur la taille du fichier, nous nous sommes intéressés à l'optimisation des performances, et non à la taille du fichier. Alors, comment avons-nous mesuré les performances et quels ont été les résultats ?

Effectuer une analyse comparative

Bien que WebAssembly soit un format bytecode de bas niveau, il doit toujours être envoyé via un compilateur pour générer un code machine spécifique à l'hôte. Tout comme JavaScript, le compilateur fonctionne à plusieurs étapes. La première étape est beaucoup plus rapide lors de la compilation, mais elle a tendance à générer un code plus lent. Une fois que le module commence à s'exécuter, le navigateur observe les parties fréquemment utilisées et les envoie via un compilateur plus optimisé, mais plus lent.

Notre cas d'utilisation est intéressant, car le code permettant de faire pivoter une image n'est utilisé qu'une seule fois, voire deux fois. Ainsi, dans la grande majorité des cas, nous n'obtiendrons jamais les avantages du compilateur d'optimisation. Il est important de le garder à l'esprit lors de l'analyse comparative. Exécuter nos modules WebAssembly 10 000 fois dans une boucle donnerait des résultats irréalistes. Pour obtenir des chiffres réalistes, nous devons exécuter le module une fois et prendre des décisions en fonction des résultats de cette exécution unique.

Comparaison des performances

Comparaison de la vitesse par langue
Comparaison de la vitesse par navigateur

Ces deux graphiques représentent des vues différentes des mêmes données. Dans le premier graphique, nous comparons par navigateur, dans le second par langue utilisée. Veuillez noter que j'ai choisi une échelle de temps logarithmique. Il est également important que toutes les analyses comparatives aient utilisé la même image de test de 16 mégapixels et la même machine hôte, à l'exception d'un navigateur qui ne pouvait pas être exécuté sur la même machine.

Sans trop analyser ces graphiques, il est clair que nous avons résolu notre problème de performances initial: tous les modules WebAssembly s'exécutent en environ 500 ms ou moins. Cela confirme ce que nous avons vu au début: WebAssembly offre des performances prévisibles. Quelle que soit la langue choisie, les différences entre les navigateurs et les langues sont minimes. Pour être précis, l'écart-type de JavaScript sur tous les navigateurs est d'environ 400 ms, tandis que l'écart-type de tous nos modules WebAssembly sur tous les navigateurs est d'environ 80 ms.

Effort à fournir

Une autre métrique concerne la quantité d'efforts que nous avons dû fournir pour créer et intégrer notre module WebAssembly dans squoosh. Il est difficile d'attribuer une valeur numérique à l'effort. Je ne vais donc pas créer de graphiques, mais il y a quelques points que j'aimerais souligner:

AssemblyScript était fluide. Il vous permet non seulement d'utiliser TypeScript pour écrire WebAssembly, ce qui facilite grandement la révision du code pour mes collègues, mais il produit également des modules WebAssembly sans colle, très petits et offrant des performances correctes. Les outils de l'écosystème TypeScript, tels que Prettier et tslint, fonctionneront probablement.

Rust combiné à wasm-pack est également extrêmement pratique, mais excelle davantage dans les projets WebAssembly plus importants : des liaisons et la gestion de la mémoire sont nécessaires. Nous avons dû nous écarter un peu de la voie du succès pour obtenir une taille de fichier compétitive.

C et Emscripten ont créé un module WebAssembly très petit et très performant dès la première utilisation, mais sans avoir le courage de se lancer dans du glue code et de le réduire aux simples nécessités, la taille totale (module WebAssembly + glue code) finit par être assez volumineuse.

Conclusion

Quel langage devez-vous utiliser si vous disposez d'un chemin d'accès à chaud JS et que vous souhaitez le rendre plus rapide ou plus cohérent avec WebAssembly ? Comme toujours pour les questions sur les performances, la réponse est: cela dépend. Alors, qu'avons-nous expédié ?

Graphique de comparaison

Si l'on compare le compromis taille / performances du module des différents langages que nous avons utilisés, le meilleur choix semble être C ou AssemblyScript. Nous avons décidé d'expédier Rust. Cette décision a plusieurs raisons: tous les codecs envoyés dans Squoosh jusqu'à présent ont été compilés à l'aide d'Emmscripten. Nous voulions élargir nos connaissances sur l'écosystème WebAssembly et utiliser un autre langage en production. AssemblyScript est une alternative forte, mais le projet est relativement jeune et le compilateur n'est pas aussi mature que le compilateur Rust.

Bien que la différence de taille de fichier entre Rust et celle des autres langues soit assez importante sur le graphique à nuage de points, ce n'est pas si grave en réalité : le chargement de 500 octets ou de 1,6 Ko, même en 2G, prend moins d'un dixième de seconde. Rust devrait bientôt combler l'écart en termes de taille de module.

En termes de performances d'exécution, Rust offre une moyenne plus rapide sur tous les navigateurs qu'AssemblyScript. En particulier sur les projets de grande envergure, Rust sera plus susceptible de produire un code plus rapide sans nécessiter d'optimisations manuelles. Mais cela ne devrait pas vous empêcher d'utiliser ce avec quoi vous êtes le plus à l'aise.

Cela étant dit, AssemblyScript a été une excellente découverte. Il permet aux développeurs Web de produire des modules WebAssembly sans avoir à apprendre un nouveau langage. L'équipe AssemblyScript a été très réactive et travaille activement à l'amélioration de sa chaîne d'outils. Nous garderons un œil sur AssemblyScript à l'avenir.

Info: Rust

Après avoir publié cet article, Nick Fitzgerald, de l'équipe Rust, nous a renvoyé l'excellent livre Rust Wasm, qui contient une section sur l'optimisation de la taille des fichiers. En suivant ces instructions (notamment pour activer l'optimisation du temps de liaison et la gestion d'assistance manuelle), nous avons pu écrire du code Rust "normal" et revenir à l'utilisation de Cargo (npm de Rust) sans augmenter la taille du fichier. Le module Rust se termine par 370 B après gzip. Pour en savoir plus, consultez la demande d'informations que j'ai ouverte sur Squoosh.

Nous remercions Ashley Williams, Steve Klabnik, Nick Fitzgerald et Max Graey pour leur aide tout au long de leur parcours.