Pourquoi nous avons construit ChainSolve avec Rust et WebAssembly
Une analyse technique approfondie des raisons pour lesquelles nous avons choisi Rust compilé en WebAssembly pour le moteur de calcul de ChainSolve, et des gains de performance que nous avons obtenus.
Lorsque nous avons commencé à construire ChainSolve, nous avions une exigence non négociable : chaque calcul doit s’exécuter dans le navigateur avec des performances proches du natif. Pas d’allers-retours serveur. Pas de dépendances cloud. Aucune donnée ne quitte la machine de l’utilisateur. Cette exigence nous a menés à Rust et WebAssembly.
L’exigence de performance
ChainSolve permet aux utilisateurs de construire des graphiques de calcul potentiellement composés de milliers de blocs, chacun effectuant une opération mathématique sur ses entrées et transmettant les résultats aux blocs en aval. Lorsqu’un utilisateur modifie une valeur d’entrée, l’ensemble du sous-graphe en aval doit se recalculer et se mettre à jour en temps réel — idéalement dans une seule image d’animation (16ms à 60fps).
Une implémentation en pur JavaScript du moteur de calcul a atteint ses limites autour de 200 blocs avant que les ralentissements d’image ne deviennent notables. Nous avions besoin d’au moins une amélioration de 10x pour supporter notre cible de 5 000+ blocs par graphique.
Pourquoi Rust ?
Nous avons évalué plusieurs langages qui se compilent en WebAssembly : C, C++, Go et Rust. Nous avons choisi Rust pour trois raisons.
Sécurité mémoire sans ramasse-miettes
Le système de propriété de Rust garantit la sécurité mémoire au moment de la compilation sans ramasse-miettes. C’est critique pour WebAssembly car le modèle de mémoire linéaire de Wasm signifie qu’une utilisation après libération ou un débordement de tampon ne fait pas seulement planter votre onglet — il corrompt silencieusement l’intégralité de l’espace mémoire. Rust élimine ces bugs par construction.
Abstractions sans coût
Les abstractions de Rust (génériques, itérateurs, filtrage par motif) se compilent jusqu’au même code machine que vous écririez à la main en C. Cela signifie que nous pouvons écrire du code propre et expressif sans pénalité de performance.
Chaîne d’outils WebAssembly excellente
L’outil wasm-pack, la crate wasm-bindgen, et l’écosystème plus large Rust-to-Wasm sont matures et bien documentés. Nous pouvons exposer des fonctions Rust à JavaScript avec un boilerplate minimal et aucun code glue FFI manuel.
Aperçu de l’architecture
Le moteur de calcul ChainSolve a trois couches :
-
Step [counter(steps)] Représentation du graphique
Le graphique de calcul est stocké sous forme de liste d’adjacence avec ordre topologique. Lorsque la structure du graphique change (blocs ajoutés, supprimés ou reconnectés), nous recalculons l’ordre topologique à l’aide de l’algorithme de Kahn. Cela détermine la séquence d’évaluation.
-
Step [counter(steps)] Moteur d'évaluation
Le moteur parcourt l’ordre topologique et évalue chaque bloc. Les blocs sont implémentés comme des objets trait Rust avec une interface
evaluate(&self, inputs: &[Value]) -> Result<Value, EvalError>commune. Cela nous permet d’ajouter de nouveaux types de blocs sans modifier le moteur. -
Step [counter(steps)] Passerelle JavaScript
La couche de passerelle utilise
wasm-bindgenpour exposer une API minimale à JavaScript :create_graph(),add_block(),connect(),set_input(),evaluate()etget_results(). Les données sont sérialisées en JSON pour les types complexes et transmises sous forme de tableaux f64 bruts pour les données numériques afin de minimiser les frais généraux de sérialisation.
Benchmarks
Nous avons mis en place des benchmarks du moteur Wasm par rapport à une implémentation en pur JavaScript équivalente sur trois charges de travail :
| Charge de travail | JS (ms) | Wasm (ms) | Accélération |
|---|---|---|---|
| 100 blocs, chaîne linéaire | 2.1 | 0.18 | 11.7x |
| 1 000 blocs, graphique ramifié | 34.2 | 2.4 | 14.3x |
| 5 000 blocs, DAG complexe | 412.0 | 22.1 | 18.6x |
L’accélération augmente avec la complexité du graphique car le moteur Wasm bénéficie d’une meilleure localité de cache et de l’absence des vérifications de type dynamique de JavaScript.
Intégration Web Worker
Le moteur de calcul s’exécute à l’intérieur d’un Web Worker pour éviter de bloquer le thread principal. Le schéma de communication est simple :
- Le thread principal envoie les mutations du graphique (bloc ajouté, connexion modifiée, entrée mise à jour) sous forme de messages structurés au Worker.
- Le Worker applique les mutations au moteur Wasm et déclenche la réévaluation.
- Les résultats sont renvoyés au thread principal via
postMessage. - Pour les grands ensembles de résultats, nous utilisons
SharedArrayBufferavec Atomics pour éviter le coût de sérialisation depostMessage.
Cette architecture signifie que le thread UI n’est jamais bloqué par le calcul. Même si un graphique prend 500ms à évaluer, l’utilisateur peut continuer à déplacer des blocs, à faire défiler la toile et à interagir avec l’interface sans aucun saccade.
Leçons apprises
Construire une application Wasm en production nous a enseigné plusieurs leçons :
-
La taille du binaire Wasm compte. Notre build initial était de 2.4MB. Après activation de LTO,
wasm-optet suppression des symboles de débogage, nous l’avons réduit à 340KB compressé. Chaque kilooctet compte pour le temps de chargement initial. -
Évitez les allocations petites et fréquentes. L’allocateur Wasm est plus simple qu’un allocateur natif. Nous utilisons l’allocation d’arène pour les données temporaires par évaluation, ce qui a réduit les frais généraux d’allocation de 60%.
-
Profilez dans le navigateur, pas en ligne de commande. Les caractéristiques de performance diffèrent entre Rust natif et Wasm. Le panneau Performance de Chrome DevTools avec support du profilage Wasm a été notre principal outil d’optimisation.
-
Testez les deux côtés de la passerelle. Les bugs dans la sérialisation
wasm-bindgensont subtils. Nous maintenons une suite de tests complète qui s’exécute à la fois en Rust natif (viacargo test) et dans le navigateur (viawasm-pack test).
Conclusion
Rust et WebAssembly ont été le bon choix pour le moteur de calcul de ChainSolve. La combinaison de la sécurité mémoire, des abstractions sans coût et des performances proches du natif dans le navigateur nous permet de proposer une expérience de calcul visuel en temps réel qui ne serait pas possible avec JavaScript seul.
Si vous envisagez Wasm pour votre prochaine application basée sur le navigateur, nous espérons que cet article fournit un contexte utile pour votre décision. Et si vous souhaitez voir les résultats en action, essayez de construire un graphique de calcul sur app.chainsolve.co.uk.