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

La rapidité est constante,

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

WebAssembly est une machine virtuelle de bas niveau qui exécute le bytecode stocké dans des fichiers .wasm. Ce byte code est fortement typé et structuré de telle sorte qu'il peut être compilé et optimisé pour le système hôte bien plus rapidement que JavaScript. WebAssembly fournit un environnement permettant d'exécuter du code conçu dès le départ pour le bac à sable et l'intégration.

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

Le chemin du chaud

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 la solution idéale pour cela, il n'est pas compatible avec tous les navigateurs que nous ciblons et il génère quelques 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 afin d'effectuer 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 "chemin réactif". Malgré ce nombre assez important d'itérations, deux navigateurs sur trois que nous avons testés terminent la tâche en 2 secondes ou moins. 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;
    }
}

Mais avec un navigateur, le processus prend plus de 8 secondes. La façon dont les navigateurs optimisent JavaScript est vraiment compliquée, et les différents moteurs n'optimisent pas tous les paramètres. Certaines optimisent l'exécution brute, d'autres l'interaction avec le DOM. Dans le cas présent, nous avons trouvé un chemin non optimisé dans un navigateur.

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

WebAssembly pour des performances prévisibles

En général, JavaScript et WebAssembly peuvent obtenir les mêmes performances maximales. Toutefois, pour JavaScript, vous ne pouvez atteindre ces performances que via le "chemin rapide", et il est souvent difficile de rester sur ce "chemin rapide". L'un des principaux avantages de WebAssembly est la prévisibilité des performances, même sur tous les navigateurs. La saisie stricte et l'architecture de bas niveau permettent au compilateur de renforcer les garanties. Ainsi, le code WebAssembly n'a besoin d'être optimisé qu'une seule fois et utilise toujours le "chemin rapide".

Écrire pour WebAssembly

Auparavant, nous avons pris les bibliothèques C/C++ et les avons compilées sur 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 faire le lien entre le navigateur et la bibliothèque. Cette fois, notre motivation est différente: nous voulons écrire quelque chose de zéro en gardant WebAssembly à l'esprit afin de pouvoir profiter des avantages de WebAssembly.

Architecture WebAssembly

Lorsque vous écrivez pour WebAssembly, il est préférable de mieux comprendre ce qu'est réellement 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 des exportations que ce module met à la disposition de l'hôte (fonctions, constantes, morceaux de mémoire) et, bien sûr, des instructions binaires réelles pour les fonctions qu'il contient.

Quelque chose que je n'avais pas réalisé avant d'avoir examiné cela: la pile qui fait de WebAssembly une "machine virtuelle basée sur des piles" n'est pas stockée dans le fragment de mémoire utilisé par les modules WebAssembly. La pile est entièrement interne aux VM et inaccessible aux développeurs Web (sauf via les outils de développement). Il est donc possible d'écrire des modules WebAssembly qui n'ont pas besoin de mémoire supplémentaire et qui n'utilisent que la pile interne de la VM.

Dans le cas présent, nous devons utiliser de la mémoire supplémentaire pour permettre 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 règle générale, une fois que vous utilisez de la mémoire supplémentaire, vous devez gérer cette mémoire d'une manière ou d'une autre. 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 "allocateurs". Bien entendu, l'implémentation de l'outil d'allocation utilisé doit être incluse dans votre module WebAssembly, ce qui augmentera la taille de votre fichier. La taille et les performances de ces fonctions de gestion de la mémoire peuvent varier considérablement selon l'algorithme utilisé. C'est pourquoi de nombreux langages proposent plusieurs implémentations parmi lesquelles choisir ("dmalloc", "emmalloc", "wee_alloc", etc.).

Dans le cas présent, nous connaissons les dimensions de l'image d'entrée (et donc des dimensions de l'image de sortie) avant d'exécuter le module WebAssembly. Ici, nous avons eu l'occasion de transmettre le tampon RVBA de l'image d'entrée en tant que paramètre à une fonction WebAssembly et de renvoyer l'image ayant fait l'objet d'une rotation 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 pour l'entrée et une fois pour la 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 pivotée, puis utiliser JavaScript pour relire le résultat. Nous pouvons nous en sortir sans utiliser de gestion de mémoire du tout !

C'est l'embarras du choix

Si vous avez examiné la fonction JavaScript d'origine que nous voulons utiliser avec WebAssembly-fy, vous pouvez constater qu'il s'agit d'un code purement informatique sans API spécifique à JavaScript. Il devrait donc être assez simple de transférer ce code dans n'importe quel langage. Nous avons évalué trois langages différents qui se compilent sur WebAssembly: C/C++, Rust et AssemblyScript. Pour chacun des langages, 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 mémoire ?

C et Emscripten

Emscripten est un compilateur C pour la cible WebAssembly. L'objectif d'Emscripten est de servir de solution de remplacement prête à l'emploi pour les compilateurs C bien connus, tels que GCC ou clang, et est principalement compatible avec les indicateurs. C'est un aspect essentiel de la mission d'Emmscripten, car il souhaite faciliter autant que possible la compilation du code C et C++ existant avec WebAssembly.

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

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

Ici, nous transformons le nombre 0x124 en pointeur vers des entiers (ou octets) 8 bits non signés. La variable ptr est ainsi transformée en un tableau commençant à 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 une rotation. Pour déplacer un pixel, nous devons 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 avoir porté l'intégralité 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 nommé c.js et un module Wasm nommé c.wasm. Notez que le module Wasm est compressé au format gzip à seulement 260 octets, tandis que le code glue est d'environ 3,5 Ko après gzip. Après quelques manipulations, nous avons pu abandonner le code glue et instancier les modules WebAssembly avec les API vanilla. C'est souvent possible avec Emscripten, tant que vous n'utilisez 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 enrichi, 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 prend également en charge WebAssembly en tant que fonctionnalité principale, et son équipe a fourni de nombreux outils d'excellente qualité à l'écosystème WebAssembly.

L'un de ces outils est wasm-pack, du groupe de travail rustwasm. wasm-pack utilise votre code et le transforme en un module adapté au Web qui fonctionne directement avec les bundlers comme 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 de ciblage WebAssembly.

Dans Rust, les tranches correspondent à ce que contiennent les tableaux en C. Et tout comme en C, nous devons créer des tranches qui utilisent nos adresses de départ. Cela va à l'encontre du modèle de sécurité de la mémoire appliqué par Rust. Pour cela, 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;
    }
}

La compilation des fichiers Rust à l'aide de

$ wasm-pack build

on obtient un module Wasm de 7,6 Ko contenant environ 100 octets de code glue (les deux après gzip).

AssemblyScript

AssemblyScript est un projet assez jeune visant à être un compilateur TypeScript vers WebAssembly. Toutefois, il est important de noter qu'il ne se contentera pas de consommer un TypeScript. AssemblyScript utilise la même syntaxe que TypeScript, mais remplace la bibliothèque standard par sa propre bibliothèque. Sa bibliothèque standard modélise les capacités de WebAssembly. Cela signifie que vous ne pouvez pas simplement compiler un TypeScript dans WebAssembly, mais cela signifie 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 a été assez 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 nous 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 sans code glue. Le module ne fonctionne qu'avec les API WebAssembly vanilla.

Analyses forensiques WebAssembly

La taille de 7,6 Ko de Rust est étonnamment importante par rapport aux deux autres langues. L'écosystème WebAssembly propose quelques outils qui peuvent vous aider à analyser vos fichiers WebAssembly (quel que soit le langage dans lequel ils ont été créés), à 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 grand nombre de données utiles à partir 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, de déterminer les sections inutilisées ou superflues, ainsi que les sections qui contribuent à la taille totale du fichier de votre module. Cette dernière méthode peut être effectuée à l'aide de la commande top de Twiggy:

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

Dans ce cas, nous pouvons voir que la majorité de la taille de notre fichier provient de l'outil d'allocation. C'était surprenant, car notre code n'utilise pas d'allocations dynamiques. Un autre facteur important est la sous-section « Noms des fonctions ».

bande de Wasm

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 en fonction du bytecode uniquement. 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 gagner une poignée d'octets supplémentaires et obtenir 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]. Cette opération désactive également complètement les allocations de mémoire dynamique, ce qui supprime le code de l'outil d'allocation de notre module. La 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 hâtives basées uniquement sur la taille du fichier, nous avons entrepris d'optimiser les performances, et non la taille du fichier. Comment avons-nous mesuré les performances et quels ont été les résultats ?

Effectuer une analyse comparative

Bien que WebAssembly soit un format de bytecode de bas niveau, il doit tout de même être envoyé via un compilateur pour générer un code machine spécifique à l'hôte. Tout comme JavaScript, le compilateur fonctionne en plusieurs étapes. La première étape est beaucoup plus rapide pour la compilation, mais elle a tendance à générer du code plus lent. Une fois le module lancé, 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, dans la mesure où le code de rotation d'une image est utilisé une fois, peut-être deux fois. Ainsi, dans la grande majorité des cas, nous n'obtiendrons jamais les avantages du compilateur d'optimisation. Il est important de garder cela à 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 nous basant sur les chiffres de cette exécution unique.

Comparaison des performances

Comparaison de vitesse par langue
Comparaison de 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, et dans le second graphique, par langue utilisée. Veuillez noter que j'ai choisi une échelle de temps logarithmique. Il est également important que toutes les analyses comparatives utilisent la même image de test de 16 mégapixels et la même machine hôte, à l'exception d'un navigateur, qui ne peut 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 que nous choisissons, l'écart entre les navigateurs et les langues est minime. Pour être exact, l'écart type de JavaScript sur tous les navigateurs est d'environ 400 ms, tandis que l'écart type de tous nos modules WebAssembly pour tous les navigateurs est d'environ 80 ms.

Efforts à fournir

Une autre métrique concerne les 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 graphique, mais j'aimerais souligner quelques points:

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 avec des performances décentes. Les outils de l'écosystème TypeScript, tels que Prettier et Tslint, fonctionneront probablement.

L'utilisation de Rust en combinaison avec wasm-pack est également extrêmement pratique, mais elle excelle davantage dans les projets WebAssembly plus importants où des liaisons et une gestion de la mémoire sont nécessaires. Nous avons dû nous écarter un peu du parcours du bonheur pour obtenir une taille de fichier compétitive.

C et Emscripten ont automatiquement créé un module WebAssembly très petit et très performant, mais sans avoir le courage de se lancer dans le code glue et de le réduire au strict nécessaire, la taille totale (module WebAssembly + code glue) finit par être assez importante.

Conclusion

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

Graphique de comparaison

Si l'on compare les différents langages utilisés en termes de taille de module et de performances, le meilleur choix semble être C ou AssemblyScript. Nous avons décidé d'expédier Rust. Plusieurs raisons justifient cette décision: tous les codecs livrés dans Squoosh jusqu'à présent sont compilés à l'aide d'Emscripten. Nous souhaitions élargir nos connaissances sur l'écosystème WebAssembly et utiliser un autre langage en production. AssemblyScript est une alternative efficace, mais le projet est relativement récent et le compilateur n'est pas aussi mature que le compilateur Rust.

Bien que la différence de taille de fichier entre Rust et les autres langages semble assez importante dans le graphique à nuage de points, elle n'est pas si grave en réalité : le chargement de 500 octets ou 1,6 Ko, même en 2G, prend moins d'un dixième de seconde. Nous espérons que Rust va bientôt combler l'écart en termes de taille de module.

En termes de performances d'exécution, Rust a une moyenne plus rapide sur tous les navigateurs qu'AssemblyScript. En particulier dans les projets de grande envergure, Rust est plus susceptible de produire du code plus rapide sans avoir besoin 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 grande 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.

Mise à jour: Rust

Après la publication de cet article, Nick Fitzgerald de l'équipe Rust nous a recommandé son excellent livre Rust Wasm, qui contient une section sur l'optimisation de la taille des fichiers. En suivant les instructions fournies (en particulier en activant l'optimisation du temps de liaison et la gestion manuelle des paniques), nous avons pu écrire du code Rust "normal" et revenir à l'utilisation de Cargo (le npm de Rust) sans gonfler la taille du fichier. Le module Rust se termine par 370B après gzip. Pour en savoir plus, consultez le rapport de presse que j'ai ouvert sur Squoosh.

Un grand merci à Ashley Williams, Steve Klabnik, Nick Fitzgerald et Max Graey pour leur aide dans cette aventure.