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

La rapidité est constante,

Lors de ma précédente articles J'ai parlé de l'utilisation de WebAssembly vous permet d'intégrer l'écosystème de bibliothèques en C/C++ sur le 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 les 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 peut le faire. WebAssembly fournit un environnement permettant d'exécuter du code ayant le système de bac à sable et l'intégration de données dès le départ.

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. bugs dans Chrome.

Cette fonction effectue une itération sur chaque pixel d'une image d'entrée et la copie une autre position dans l'image de sortie pour obtenir une 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 dans ce cas, nous avons trouvé 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 via la méthode "Fast path", et il est souvent difficile de rester sur cette voie rapide. L'un des principaux avantages WebAssembly offre des performances prévisibles, même sur tous les navigateurs. Les règles strictes la saisie et l'architecture de bas niveau permettent au compilateur de renforcer garantit que le code WebAssembly ne doit être optimisé qu'une seule fois utilisez 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 en pensant à WebAssembly. Nous pouvons ainsi utiliser et les avantages de WebAssembly.

Architecture WebAssembly

Lorsque vous écrivez pour WebAssembly, il est utile d'en savoir un peu plus 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 .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 de 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 pour écrire des modules WebAssembly qui n'ont pas besoin de mémoire supplémentaire n'utilisez 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. Les fonctions de ce type 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 devrions 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. Par conséquent, il doit être assez droit avant de transférer ce code vers n'importe quel langage. Nous avons évalué trois langues différentes qui s'compilent sur WebAssembly: C/C++, Rust et AssemblyScript. La seule question Pour chacune des langues, nous devons répondre à la question 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'Emmscripten est de qui remplace directement les compilateurs C connus tels que GCC ou clang. et est pour la plupart compatible avec les indicateurs. C'est un élément essentiel de la mission d'Emmscripten. car il souhaite rendre la compilation de code C et C++ existant dans 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 un format 8 bits non signé. entiers (ou octets). 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 Nous consultons le tampon RVBA d'une image que nous souhaitons réorganiser la rotation des clés. Pour déplacer un pixel, nous devons déplacer quatre octets consécutifs en même temps. (un octet pour chaque canal: R, V, B et A). Pour vous faciliter la tâche, nous pouvons tableau d'entiers 32 bits non signés. Par convention, l'image d'entrée commence à l'adresse 4. L'image de sortie commencera immédiatement après l'image d'entrée. date de fin:

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ée 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 enrichi, sans environnement d'exécution et un modèle de propriété qui garantit la sécurité de la mémoire et des threads. Rouille prend également en charge WebAssembly en tant que fonctionnalité essentielle, et l'équipe Rust a a contribué à améliorer l'écosystème WebAssembly grâce à ses excellents outils.

L'un de ces outils est wasm-pack, par le groupe de travail rustwasm. wasm-pack qui transforme votre code en un module Web et prêts à l'emploi avec des bundles tels que webpack. wasm-pack est un mais ne fonctionne actuellement que pour Rust. Le groupe est envisagez 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 tranches qui utilisent nos adresses de départ. Cela va à l'encontre du modèle de sécurité de la mémoire. que Rust applique. Pour comprendre comment utiliser le mot clé unsafe, ce qui nous permet d'écrire du code qui n'est pas 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 script jeune projet 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 version standard pour leur propre bibliothèque. Sa bibliothèque standard modélise les capacités WebAssembly. Cela signifie que vous ne pouvez pas simplement compiler n'importe quel TypeScript à WebAssembly, mais cela signifie que vous n'avez pas besoin d'apprendre une nouvelle 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 s'agit 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 pour accéder à la mémoire brute. Pour compiler notre fichier AssemblyScript, procédez comme suit : 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 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. 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.

Twiggy

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. C'é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 deux 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 en module Wasm binaire. Bien que nous ayons utilisé ces deux outils complémentaires pour inspecter nos fichiers WebAssembly, wasm-strip pour qu'il soit le plus utile possible. wasm-strip supprime les sections inutiles et les métadonnées 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 fonctionnent 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 bibliothèque standard de Rust, en utilisant le #![no_std] . Cette opération désactive aussi complètement les allocations de mémoire dynamique, ce qui supprime du code d'allocation de notre module. Compiler ce fichier Rust par

$ 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 plus volumineux que les modules générés par C et AssemblyScript, pour être considéré comme un poids 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 que le module a commencé le navigateur détecte les composants fréquemment utilisés 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 une fois, peut-être deux fois. Dans la grande majorité des cas, nous n'obtiendrons jamais les avantages du compilateur d'optimisation. Ceci est important à garder à l’esprit lorsque 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 vitesse par navigateur

Ces deux graphiques représentent des vues différentes des mêmes données. Dans le premier graphique, nous par navigateur ; dans le second, nous comparons par langue. Veuillez notez que j'ai choisi une échelle de temps 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 évident que nous avons résolu le problème problème de performances: tous les modules WebAssembly s'exécutent en 500 ms maximum. Ce confirme ce que nous avons vu au départ: WebAssembly offre des fonctions prévisibles des performances. Quelle que soit la langue choisie, les différences entre les navigateurs et les langages de programmation est minime. Pour être exact: l'écart type de JavaScript dans tous les navigateurs est d'environ 400 ms, alors que l'écart-type de tous nos L'utilisation des modules WebAssembly est d'environ 80 ms pour tous les navigateurs.

Efforts à fournir

Autre métrique : 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 à je ne vais pas créer de graphiques, mais j'aimerais avoir quelques éléments souligner:

AssemblyScript était fluide. Il vous permet non seulement d'utiliser TypeScript pour d'écrire WebAssembly, ce qui facilite grandement la révision du code pour mes collègues. produit des modules WebAssembly sans colle, de très petits éléments, des performances. Les outils de l'écosystème TypeScript, comme Prettier et Tslint, fonctionnera probablement.

L'utilisation de Rust avec wasm-pack est également extrêmement pratique, mais elle offre d'excellents résultats. Dans les projets WebAssembly de grande envergure, les liaisons et la gestion de la mémoire 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. prêt à l'emploi, mais sans avoir le courage de vous lancer dans le code de la colle et de le réduire à mais la taille totale (module WebAssembly + code glue) se termine. étant assez grandes.

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 pour les performances questions, la réponse est: cela dépend. Alors, qu'avons-nous livré ?

<ph type="x-smartling-placeholder">
</ph> Graphique de comparaison

Comparaison au niveau de la taille du module et des performances des différents langages que nous avons utilisé, le meilleur choix semble être C ou AssemblyScript. Nous avons décidé d'expédier 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 WebAssembly et utiliser un autre langage 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 Nous espérons que Rust comblera bientôt 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. Surtout pour les projets plus importants, Rust sera plus susceptible générer du code plus rapidement 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 garderons un œil sur AssemblyScript.

Mise à jour: Rust

Après la publication de cet article, Nick Fitzgerald de l'équipe Rust nous a indiqué son excellent livre Rust Wasm, qui contient une section sur l'optimisation de la taille des fichiers. Suivez le des instructions spécifiques (surtout l'optimisation de la durée de l'association gestion de la panique) nous a permis d'écrire du code Rust « normal » et de revenir à l'utilisation Cargo (npm de Rust) sans augmenter la taille du fichier. Le module Rust se termine avec 370 octets 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.