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

C'est toujours rapide, yo

Dans mes articles et précédents, j'ai expliqué comment WebAssembly vous permet d'intégrer l'écosystème de bibliothèques C/C++ au Web. Squoosh, notre application Web qui vous permet de compresser des images avec divers codecs compilés de C++ vers WebAssembly, est une application qui utilise largement les bibliothèques C/C++.

WebAssembly est une machine virtuelle de bas niveau qui exécute le bytecode stocké dans des fichiers .wasm. Ce code d'octet est fortement typé et structuré de manière à pouvoir être compilé et optimisé pour le système hôte beaucoup plus rapidement que JavaScript. WebAssembly fournit un environnement pour exécuter du code qui a été 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 causés par une mise en page forcée et une peinture excessive, 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 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 tous les navigateurs que nous ciblions et présente quelques bugs dans Chrome.

Cette fonction itère sur chaque pixel d'une image d'entrée et le copie à une autre position dans l'image de sortie pour effectuer la 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 chaud". Malgré ce nombre d'itérations assez élevé, deux navigateurs sur trois que nous avons testés terminent la tâche en deux 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;
    }
}

Cependant, un navigateur met plus de huit secondes. La façon dont les navigateurs optimisent JavaScript est très complexe, et les différents moteurs optimisent pour différentes choses. Certains sont optimisés pour l'exécution brute, d'autres pour l'interaction avec le DOM. Dans ce cas, nous avons rencontré un chemin non optimisé dans un navigateur.

WebAssembly, en revanche, est entièrement basé sur la vitesse d'exécution brute. Par conséquent, si nous souhaitons des performances rapides et prévisibles dans tous les navigateurs pour un code comme celui-ci, WebAssembly peut nous aider.

WebAssembly pour des performances prévisibles

En général, JavaScript et WebAssembly peuvent atteindre les mêmes performances maximales. Toutefois, pour JavaScript, ces performances ne peuvent être atteintes que sur 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 entre les navigateurs. La typographie stricte et l'architecture de bas niveau permettent au compilateur de fournir des garanties plus solides, de sorte que le code WebAssembly ne doit être optimisé qu'une seule fois et qu'il utilise toujours le "chemin rapide".

Écrire pour WebAssembly

Auparavant, nous utilisions des bibliothèques C/C++ et les compilaions en WebAssembly pour utiliser leurs fonctionnalités sur le Web. Nous n'avons pas vraiment touché au code des bibliothèques. Nous avons simplement écrit de petites quantités de code C/C++ pour établir un 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 de comprendre un peu mieux ce qu'est réellement WebAssembly.

D'après WebAssembly.org:

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

Je ne l'ai réalisé que lorsque j'ai examiné ce point: la pile qui fait de WebAssembly une "machine virtuelle basée sur une pile" n'est pas stockée dans le bloc de mémoire utilisé par les modules WebAssembly. La pile est complètement interne à la 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 besoin d'aucune mémoire supplémentaire et n'utilisent que la pile interne de la VM.

Dans notre cas, 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 utilisez de la mémoire supplémentaire, vous devrez la gérer 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. Ces fonctions sont également appelées "allocateurs". Bien entendu, l'implémentation de l'allocateur 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 la mémoire peuvent varier considérablement en fonction de l'algorithme utilisé. C'est pourquoi de nombreux langages proposent plusieurs implémentations au choix ("dmalloc", "emmalloc", "wee_alloc", etc.).

Dans notre cas, nous connaissons les dimensions de l'image d'entrée (et donc les dimensions de l'image de sortie) avant d'exécuter le module WebAssembly. Nous avons vu une opportunité ici: traditionnellement, nous transmettons le tampon RGBA de l'image d'entrée en tant que paramètre à une fonction WebAssembly et renvoyons l'image pivotée en tant que valeur renvoyée. Pour générer cette valeur renvoyée, nous devons 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 lire le résultat. Nous pouvons nous en passer sans utiliser aucune gestion de la mémoire.

Un choix infini

Si vous examinez la fonction JavaScript d'origine que nous souhaitons WebAssembly-fier, vous pouvez constater qu'il s'agit d'un code purement computationnel sans API JavaScript spécifiques. Il devrait donc être assez simple de porter ce code dans n'importe quelle langue. Nous avons évalué trois langages différents qui compilent vers WebAssembly: C/C++, Rust et AssemblyScript. La seule question à laquelle nous devons répondre pour chacun des langages est la suivante: comment accéder à la mémoire brute sans utiliser de 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 fonctionner comme un remplacement prêt à l'emploi pour les compilateurs C bien connus tels que GCC ou clang. Il est généralement compatible avec les indicateurs. Il s'agit d'une partie essentielle de la mission d'Emscripten, qui vise à rendre la compilation du code C et C++ existant en WebAssembly aussi simple que possible.

L'accès à la mémoire brute fait partie de la nature même du langage 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) de 8 bits non signés. Cela transforme efficacement la variable ptr 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 notre cas, nous examinons un tampon RGBA d'une image que nous souhaitons réorganiser pour effectuer une rotation. Pour déplacer un pixel, nous devons en fait déplacer quatre octets consécutifs à la fois (un octet pour chaque canal: R, G, B et A). Pour simplifier la tâche, nous pouvons créer un tableau d'entiers 32 bits non signés. Par convention, notre image d'entrée commencera à l'adresse 4 et notre image de sortie commencera immédiatement 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 de liaison appelé c.js et un module wasm appelé c.wasm. Notez que le module wasm ne gzippe qu'environ 260 octets, tandis que le code de liaison est d'environ 3,5 ko après gzip. Après quelques manipulations, nous avons pu abandonner le code de liaison et instancier les modules WebAssembly avec les API standards. Cela est souvent possible avec Emscripten tant que vous n'utilisez rien de la bibliothèque standard C.

Rust

Rust est un nouveau langage de programmation moderne doté d'un système de types riche, sans environnement d'exécution et d'un modèle de propriété qui garantit la sécurité de la mémoire et la sécurité des threads. Rust est également compatible avec WebAssembly en tant que fonctionnalité de base, et l'équipe Rust a contribué à de nombreux excellents outils à l'écosystème WebAssembly.

L'un de ces outils est wasm-pack, du groupe de travail rustwasm. wasm-pack transforme votre code en un module compatible avec le Web qui fonctionne immédiatement avec des outils de compilation 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 de ciblage WebAssembly.

En Rust, les tranches correspondent aux tableaux en C. Et tout comme en C, nous devons créer des tranches qui utilisent nos adresses de début. Cela va à l'encontre du modèle de sécurité de la mémoire que Rust applique. Pour y parvenir, nous devons utiliser le mot clé unsafe, qui nous permet d'écrire du code qui ne respecte pas 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 de

$ wasm-pack build

génère un module wasm de 7,6 Ko avec environ 100 octets de code de liaison (les deux après gzip).

AssemblyScript

AssemblyScript est un projet relativement récent qui vise à être un compilateur TypeScript vers WebAssembly. Il est toutefois 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 la sienne. Sa bibliothèque standard modélise les fonctionnalités de WebAssembly. Cela signifie que vous ne pouvez pas simplement compiler n'importe quel code TypeScript en WebAssembly, mais cela signifie que vous n'avez pas besoin d'apprendre un nouveau langage de programmation pour écrire du code 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 porter 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 et aucun code de liaison. Le module fonctionne uniquement avec les API WebAssembly standards.

Expertise WebAssembly

Les 7,6 Ko de Rust sont étonnamment importants par rapport aux deux autres langages. L'écosystème WebAssembly propose plusieurs outils qui peuvent vous aider à analyser vos fichiers WebAssembly (quel que soit le langage avec lequel ils ont été créés), vous indiquer ce qui se passe et vous aider à améliorer votre situation.

Twiggy

Twiggy est un autre outil de l'équipe WebAssembly de Rust qui extrait un ensemble de données intéressantes à partir d'un module WebAssembly. L'outil n'est pas spécifique à Rust et vous permet d'inspecter des éléments tels que le graphique des appels du module, de déterminer les sections inutilisées ou superflues, et de déterminer quelles sections contribuent à la taille totale du fichier de votre module. Vous pouvez effectuer cette dernière opération à 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 qu'une majorité de la taille de nos fichiers provient de l'alloueur. Cela était surprenant, car notre code n'utilise pas d'allocations dynamiques. Une sous-section "Noms de fonctions" est également un facteur important.

wasm-strip

wasm-strip est un outil du kit d'outils binaires WebAssembly, ou wabt. Il contient quelques outils qui vous permettent d'inspecter et de manipuler des modules WebAssembly. wasm2wat est un désassembleur qui convertit un module wasm binaire en un format lisible par l'homme. Wabt contient également wat2wasm, qui vous permet de convertir ce format lisible par l'homme en module wasm binaire. Bien que nous ayons utilisé ces deux outils complémentaires pour inspecter nos fichiers WebAssembly, nous avons trouvé 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 réduit 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 prend un module WebAssembly et tente de l'optimiser à la fois pour la taille et les performances en fonction uniquement du bytecode. Certains outils comme Emscripten exécutent déjà cet outil, d'autres non. Il est généralement recommandé d'essayer d'économiser des octets supplémentaires à l'aide de ces outils.

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

Avec wasm-opt, nous pouvons économiser encore quelques octets pour 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]. Cela désactive également complètement les allocations de mémoire dynamiques, en supprimant le code de l'outil d'allocation de mémoire 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 uniquement sur la taille des fichiers, rappelons-nous que nous avons entrepris ce voyage pour optimiser les performances, et non la taille des fichiers. Comment avons-nous mesuré les performances et quels ont été les résultats ?

Comment effectuer une analyse comparative

Bien que WebAssembly soit un format de bytecode de bas niveau, il doit toujours être envoyé via un compilateur pour générer du code machine spécifique à l'hôte. Tout comme JavaScript, le compilateur fonctionne en plusieurs étapes. En termes simples, la première étape est beaucoup plus rapide en termes de compilation, mais elle tend à 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 sera utilisé une fois, peut-être deux fois. Dans la grande majorité des cas, nous ne bénéficierons donc jamais des 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 seule fois et prendre des décisions en fonction des chiffres de cette seule exécution.

Comparaison des performances

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

Ces deux graphiques sont des vues différentes des mêmes données. Dans le premier graphique, nous effectuons une comparaison par navigateur, et dans le second, par langue utilisée. Notez que j'ai choisi une échelle temporelle logarithmique. Il est également important que tous les benchmarks 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 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 indiqué au début: WebAssembly offre des performances prévisibles. Quelle que soit la langue choisie, la variance entre les navigateurs et les langues est minime. 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.

Efforts à fournir

Une autre métrique est l'effort 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 je voudrais souligner quelques points:

AssemblyScript a été facile à utiliser. Non seulement il vous permet d'utiliser TypeScript pour écrire du WebAssembly, ce qui facilite la révision du code pour mes collègues, mais il produit également des modules WebAssembly sans colle qui sont très petits et offrent des performances correctes. Les outils de l'écosystème TypeScript, comme prettier et tslint, fonctionneront probablement.

Rust associé à wasm-pack est également extrêmement pratique, mais il 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 chemin idéal pour obtenir une taille de fichier compétitive.

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

Conclusion

Quelle langue devez-vous utiliser si vous avez un chemin d'accès rapide en JS et que vous souhaitez l'accélérer ou le rendre plus cohérent avec WebAssembly ? Comme toujours avec les questions de performances, la réponse est: ça dépend. Qu'avons-nous donc envoyé ?

Graphique de comparaison

En comparant le compromis taille de module / performances des différents langages que nous avons utilisés, le meilleur choix semble être C ou AssemblyScript. Nous avons décidé de lancer Rust. Cette décision est motivée par plusieurs raisons: tous les codecs fournis dans Squoosh jusqu'à présent sont compilés à l'aide d'Emscripten. Nous souhaitions élargir nos connaissances sur l'écosystème WebAssembly et utiliser une autre langue en production. AssemblyScript est une alternative intéressante, 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 les autres langages semble assez importante dans le graphique en nuage de points, elle n'est pas si importante en réalité : charger 500 octets ou 1,6 ko, même plus de 2 Go, prend moins d'un dixième de seconde. Et Rust devrait bientôt combler l'écart en termes de taille de module.

En termes de performances d'exécution, Rust affiche une moyenne plus rapide sur l'ensemble des navigateurs que AssemblyScript. En particulier pour les projets plus importants, Rust est plus susceptible de produire du code plus rapide sans avoir besoin d'optimiser le code manuellement. Cela ne doit pas vous empêcher d'utiliser ce qui vous convient le mieux.

Cela dit, AssemblyScript a été une excellente découverte. Il permet aux développeurs Web de produire des modules WebAssembly sans avoir à apprendre une nouvelle langue. L'équipe AssemblyScript a été très réactive et travaille activement à l'amélioration de sa chaîne d'outils. Nous allons certainement surveiller AssemblyScript à l'avenir.

Nouveauté: Rust

Après la publication de cet article, Nick Fitzgerald de l'équipe Rust nous a orientés vers son excellent livre Rust Wasm, qui contient une section sur l'optimisation de la taille des fichiers. En suivant les instructions (en particulier en activant les optimisations au moment de l'association 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 370 Mo après gzip. Pour en savoir plus, veuillez consulter la PR que j'ai ouverte sur Squoosh.

Un merci tout particulier à Ashley Williams, Steve Klabnik, Nick Fitzgerald et Max Graey pour toute leur aide tout au long de ce parcours.