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. Une application qui fait un usage intensif des bibliothèques C/C++ est squoosh, notre qui vous permet de compresser des images à l'aide de divers codecs compilé de C++ à WebAssembly.

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 permettant d'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 dus à des contraintes de mise en page et de peinture excessive, mais de temps en temps, une application doit une tâche coûteuse en calcul qui prend beaucoup de temps. WebAssembly peut vous aider ici.

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. Alors que OffscreenCanvas convient parfaitement elle n'est pas prise en charge par les navigateurs que nous ciblions. de 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 taille de 4 094 pixels par une image de 4 096 pixels (16 mégapixels). Il faudrait plus de 16 millions d'itérations ce que nous appelons un "chemin réactif". Malgré le fait que nombre d'itérations, 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;
    }
}

Mais avec un navigateur, le processus prend plus de 8 secondes. Comment les navigateurs optimisent JavaScript est très compliqué, et les différents moteurs n'optimisent pas les campagnes pour différentes choses. Certaines optimisent l'exécution brute, d'autres l'interaction avec le DOM. Dans ce cas, nous avons rencontré un chemin non optimisé dans un navigateur.

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

WebAssembly pour des performances prévisibles

En général, JavaScript et WebAssembly peuvent obtenir 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 WebAssembly offre des performances prévisibles, même sur tous 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 avons pris les bibliothèques C/C++ et les avons compilées sur WebAssembly pour utiliser leur sur le Web. Nous n'avons pas vraiment touché au code des bibliothèques, a écrit de petites quantités de code C/C++ pour former 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 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 dans WebAssembly, vous obtenez un .wasm contenant une déclaration de module. Cette déclaration consiste en une liste "importations" que le module attend de son environnement, une liste des exportations qu'il met à la disposition de l'hôte (fonctions, constantes, morceaux de mémoire) et bien sûr les instructions binaires réelles pour les fonctions qu'elle contient.

Quelque chose que je n’avais pas réalisé avant de m’y pencher: la pile qui fait WebAssembly une "machine virtuelle basée sur des piles" n'est pas stockée dans le fragment la mémoire utilisée par les modules WebAssembly. La pile est entièrement interne à la VM les développeurs Web ne peuvent pas y accéder (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 n'utilisent que la pile interne de la VM.

Dans notre cas, nous devrons 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 à quoi sert WebAssembly.Memory.

Gestion de la mémoire

Généralement, une fois que vous utilisez de la mémoire supplémentaire, 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 sur n octets consécutifs. Ces fonctions sont également appelées "allocateurs". Bien entendu, l'implémentation de l'outil d'attribution utilisé doit être incluse dans votre WebAssembly et augmentera la taille de votre fichier. Cette taille et ces performances fonctions de gestion de la mémoire peuvent varier considérablement selon l'algorithme utilisé. C'est pourquoi de nombreux langages offrent plusieurs implémentations à choisir ("dmalloc", "emmalloc", "wee_alloc", etc.).

Dans notre cas, nous connaissons les dimensions de l'image d'entrée (et donc de l'image de sortie) avant d'exécuter le module WebAssembly. Ici, nous Habituellement, nous transmettons le tampon RVBA de l'image d'entrée à une fonction WebAssembly et renvoyer l'image ayant fait l'objet d'une rotation en retour . Pour générer cette valeur renvoyée, nous devons utiliser l'outil d'allocation. Mais comme nous connaissons la quantité totale de mémoire nécessaire (deux fois la taille de l'entrée une fois pour l'entrée et une fois pour la sortie), nous pouvons placer l'image d'entrée dans de la mémoire WebAssembly à l'aide de JavaScript, exécutez le module WebAssembly pour générer une Deuxièmement, l'image a fait l'objet d'une rotation, puis relit le résultat à l'aide de JavaScript. Nous pouvons obtenir sans utiliser de gestion de la mémoire.

C'est l'embarras du choix

Si vous avez observé la fonction JavaScript d'origine, que nous voulons que WebAssembly-fy soit, vous voyez qu'il s'agit sans API spécifique à JavaScript. 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 Pour chacune des langues, nous devons répondre à cette question: 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 est la nature même du C et des pointeurs existent pour cette motif:

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 à partir de 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 vous faciliter la tâche, nous pouvons 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 glue nommé c.js et un module Wasm appelé c.wasm. Notez que le module Wasm est compressé avec seulement 260 octets environ, alors que le le code Glue représente environ 3,5 Ko après gzip. Après quelques manipulations, nous avons pu abandonner le code Glue et instanciez les modules WebAssembly avec les API vanilla. C'est souvent possible avec Emscripten, tant que vous n'utilisez 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, par le 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 est 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;
    }
}

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 relativement récent qui vise à être un compilateur TypeScript vers WebAssembly. Il est il est important de noter, cependant, qu'il ne consommera pas seulement 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 capacités 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 sans code glue. Le module ne fonctionne qu'avec les API WebAssembly vanilla.

Expertise WebAssembly

La taille de 7,6 Ko de Rust est étonnamment importante par rapport aux deux autres langues. Il y deux outils de l'écosystème WebAssembly peuvent vous aider à analyser vos fichiers WebAssembly (quelle que soit la langue dans laquelle ils ont été créés) et vous informer de ce qui se passe et vous aider à améliorer votre situation.

Brindillon

Twiggy est un autre outil du L'équipe WebAssembly extrait de nombreuses données utiles d'une instance WebAssembly de ce module. L'outil n'est pas spécifique à Rust et vous permet d'inspecter des éléments tels que le graphique des appels du module, déterminer les sections inutilisées ou superflues les sections qui contribuent à la taille totale du fichier de votre module. La la deuxième 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 du d'un outil d'allocation. Cela était surprenant, car notre code n'utilise pas d'allocations dynamiques. Un autre facteur important est les « noms des fonctions » dans cette sous-section.

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 des modules WebAssembly. wasm2wat est un désassembleur qui transforme un module Wasm binaire en dans un format lisible par l'humain. Wabt contient également wat2wasm, qui vous permet d'activer ou de désactiver ce format lisible par l'humain dans un 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 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 prend un module WebAssembly et tente de l'optimiser à la fois en termes de taille des performances basées uniquement sur le bytecode. Certains outils comme Emscripten exécutent déjà cet outil, d'autres non. C'est généralement une bonne idée d'essayer d'économiser d'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 autre poignée d'octets 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, en utilisant le #![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. Compiler 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 sommes partis de cette aventure. pour optimiser les performances, et non la taille du fichier. Comment avons-nous mesuré les performances 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 du code machine spécifique à l'hôte. Tout comme JavaScript, le compilateur fonctionne en plusieurs étapes. On dit simplement que la première étape la compilation plus rapide, mais 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 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 optimisant. Il est important de garder cela à l'esprit lors de l'analyse comparative. Exécuter nos modules WebAssembly 10 000 fois dans une boucle nous 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 chiffres de cette seule course.

Comparaison des performances

Comparaison de 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 par navigateur ; dans le second, nous comparons par langue. Notez que j'ai choisi une échelle temporelle logarithmique. Il est également important que tous utilisaient la même image de test de 16 mégapixels et le même 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 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 était fluide. 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, tels que 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 parcours du bonheur pour parvenir à une la taille du fichier.

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

Alors, quel langage devez-vous utiliser si vous avez un chemin chaud JS et que vous voulez plus rapides et plus cohérents avec WebAssembly. Comme toujours avec les questions de performances, la réponse est : "ça dépend". Alors, qu'avons-nous livré ?

<ph type="x-smartling-placeholder">
</ph> 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. Il y plusieurs raisons justifient cette décision: tous les codecs livrés sur Squoosh jusqu'à présent sont compilés avec Emscripten. Nous souhaitions élargir nos connaissances sur l'écosystème WebAssembly et utiliser une autre langue en production. AssemblyScript est une bonne alternative, 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 langues le graphique à nuage de points est plutôt radical, mais ce 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. Et Rust devrait 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 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. Mais cela 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. Elle permet aux applications aux développeurs de créer des modules WebAssembly sans avoir à apprendre langue. L'équipe AssemblyScript a été très réactive et active à améliorer leur chaîne d'outils. Nous allons certainement surveiller AssemblyScript à l'avenir.

Mise à jour: 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 grand merci à Ashley Williams, Steve Klabnik, Nick Fitzgerald et Max Graey pour leur aide dans cette aventure.