- Le parseur WASM écrit en Rust est structurellement rapide, mais la copie des données et le surcoût de sérialisation à la frontière JS-WASM se sont révélés être le goulot d’étranglement des performances
- Le retour direct d’objets via
serde-wasm-bindgens’est avéré 9 à 29 % plus lent que la sérialisation JSON, en raison du coût des conversions fines entre runtimes - En portant tout le pipeline en TypeScript, les mêmes choix d’architecture ont permis d’obtenir des performances par appel 2,2 à 4,6 fois supérieures
- En traitement de flux, le passage de O(N²) à O(N) grâce à un cache incrémental par phrase a permis d’obtenir une vitesse de traitement globale 2,6 à 3,3 fois supérieure
- En conséquence, il apparaît que WASM convient aux traitements intensifs en calcul et peu fréquents, mais pas au parsing d’objets JS ni aux fonctions appelées très souvent
Structure et limites du parseur Rust WASM
- Le parseur
openui-langest constitué d’un pipeline en 6 étapes qui convertit un DSL généré par un LLM en arbre de composants React- Étapes :
autocloser → lexer → splitter → parser → resolver → mapper → ParseResult - Chaque étape effectue la tokenisation, l’analyse syntaxique, la résolution des variables, la transformation d’AST, etc.
- Étapes :
- Le code Rust lui-même est rapide, mais les opérations de copie de chaînes, sérialisation JSON et désérialisation entre JS↔WASM se produisent à chaque appel
- Copie de la chaîne d’entrée (JS→WASM), parsing côté Rust, sérialisation du résultat en JSON, copie du JSON (WASM→JS), puis désérialisation côté JS
- Ce surcoût à la frontière dominait les performances globales, la vitesse de calcul de Rust n’étant pas le goulot d’étranglement
Tentative avec serde-wasm-bindgen et échec
- Pour éviter la sérialisation JSON,
serde-wasm-bindgen, qui retourne directement des structures Rust sous forme d’objets JS, a été appliqué - Mais on a observé une baisse de performance de 30 %
- JS ne peut pas lire directement la mémoire des structures Rust, et comme la disposition mémoire diffère entre runtimes, une conversion champ par champ est nécessaire
- À l’inverse, la sérialisation JSON consiste à générer une chaîne une seule fois côté Rust, puis à la traiter côté JS via un
JSON.parseoptimisé
- Résultats du benchmark
Fixture Aller-retour JSON serde-wasm-bindgen Variation simple-table 20.5µs 22.5µs -9% contact-form 61.4µs 79.4µs -29% dashboard 57.9µs 74.0µs -28%
Passage à TypeScript et amélioration des performances
- Le même pipeline en 6 étapes a été entièrement porté en TypeScript, supprimant la frontière WASM pour s’exécuter directement dans le heap de V8
- Résultats du benchmark par appel
Fixture TypeScript WASM Gain de vitesse simple-table 9.3µs 20.5µs 2.2x contact-form 13.4µs 61.4µs 4.6x dashboard 19.4µs 57.9µs 3.0x - La simple suppression de WASM a fortement réduit le coût par appel, mais l’inefficacité de la structure de streaming restait présente
Le problème en O(N²) du parsing en flux et son amélioration
- Lorsque la sortie du LLM est livrée en plusieurs chunks, le fait de reparser à chaque fois toute la chaîne accumulée introduit une inefficacité en O(N²)
- Exemple : parser un document de 1000 caractères 50 fois par blocs de 20 caractères revient à traiter 25 000 caractères au total
- La solution a consisté à introduire un cache incrémental par phrase
- Les phrases complètes sont mises en cache, et seule la phrase en cours est reparsée
- L’AST en cache est fusionné avec le nouvel AST avant de retourner le résultat
- Benchmarks sur l’ensemble du flux
Fixture TS naïf TS incrémental Gain de vitesse simple-table 69µs 77µs Aucun contact-form 316µs 122µs 2.6x dashboard 840µs 255µs 3.3x - Plus il y a de phrases, plus l’effet du cache augmente, et le débit global s’améliore linéairement
Enseignements sur l’usage de WASM
- Cas adaptés
- Travaux intensifs en calcul avec peu d’interactions : traitement d’image et de vidéo, chiffrement, simulation physique, codecs audio, etc.
- Portage de bibliothèques natives existantes : SQLite, OpenCV, libpng, etc.
- Cas inadaptés
- Parsing de texte structuré en objets JS : le coût de sérialisation devient dominant
- Fonctions appelées fréquemment sur des entrées courtes : le coût de frontière dépasse celui du calcul
- Principaux enseignements
- Il faut profiler les goulots d’étranglement avant de choisir le langage
- Le passage direct d’objets avec
serde-wasm-bindgencoûte plus cher - Améliorer la complexité algorithmique est plus efficace qu’un changement de langage
- WASM et JS ne partagent pas le même heap, et le coût de conversion existe toujours
Résultat final : le passage à TypeScript et le cache incrémental ont permis d’obtenir des performances 2,2 à 4,6 fois supérieures par appel, et 2,6 à 3,3 fois supérieures sur l’ensemble du flux
2 commentaires
N’était-ce pas, au fond, une façon détournée de se moquer d’un article très pointu sur l’optimisation des performances en Rust..
Commentaires sur Hacker News
Le vrai point clé, ce n’est pas TypeScript plutôt que Rust, mais la correction de l’algorithme de streaming, passé de O(N²) à O(N)
Rien que ce changement, avec une mise en cache au niveau des instructions (
statement), a apporté un gain de 3,3×Indépendamment du choix du langage, c’est surtout cela qui explique l’amélioration de la latence perçue par les utilisateurs
On a l’impression que le titre sous-estime ce point d’ingénierie pourtant intéressant
L’article en lui-même est intéressant, mais je suis fatigué ces temps-ci des titres trop racoleurs
La méthode consiste à mesurer le temps de chaque appel puis à utiliser la médiane (
median), mais dans un navigateur, avec les protections contre les attaques par temporisation intégrées aux moteurs JS, j’ai des doutes sur la précisionDire « on a réécrit du code du langage L vers M et c’est devenu plus rapide » est assez banal
Parce que cela donne l’occasion de corriger des choix enchevêtrés et erronés, et d’appliquer de meilleures approches apparues entre-temps
En fait, même si L=M, c’est pareil : les gains de performance viennent moins du langage que du processus de réécriture et de reconception
J’ai creusé davantage pour améliorer les performances de sérialisation d’objets à la frontière entre Rust et JS
L’approche de serde ne me semblait pas bonne en matière de performances, et j’ai résumé une tentative d’amélioration dans mon billet de blog
Je me demandais pourquoi Open UI ne faisait rien autour de WASM
Mais cette nouvelle entreprise utilise justement le nom Open UI, ce qui m’a embrouillé
À l’origine, l’Open UI W3C Community Group est un groupe qui travaille depuis plus de 5 ans sur des standards HTML comme popover, les
selectpersonnalisables, invoker command ou encore accordionIls font vraiment un excellent travail
Ils disent avoir intégré serde-wasm-bindgen dans l’idée d’« éviter l’aller-retour JSON », mais au final cela ressemble à une réinvention de JSON en binaire
Le JSON de V8 est aujourd’hui déjà très optimisé, et des implémentations comme simdjson peuvent traiter des gigaoctets par seconde
Il me semble peu probable que JSON soit le goulot d’étranglement
J’ai vraiment aimé le design de ce blog
J’ai particulièrement apprécié la barre latérale de type « scrollspy » qui met en évidence les titres selon la position de défilement
D’après Claude, cela semble avoir été fait avec fumadocs.dev
Je n’ai pas bien compris l’objectif du parseur Rust WASM
Ce point n’était pas clair dans l’article et mériterait plus d’explications
Cela semble viser à empêcher les fuites d’informations dues au prompt injection
Le parseur compile les morceaux diffusés en streaming par le LLM afin de construire l’UI en temps réel
Avant, le parseur redémarrait depuis zéro à chaque chunk, puis ils sont passés à une approche incrémentale, ce qui a fortement amélioré les performances (pendant le portage de Rust vers TypeScript)
Je me demandais si TypeScript ne tournait pas désormais sur une base Golang
C’est une blague, mais si on le réécrivait encore une fois en Rust, on obtiendrait peut-être encore 3× plus de performances /s