Vai al contenuto
rust webassembly performance engineering architecture

Perché abbiamo sviluppato ChainSolve con Rust e WebAssembly

Un'analisi tecnica approfondita del motivo per cui abbiamo scelto Rust compilato a WebAssembly per il motore di calcolo di ChainSolve e i miglioramenti di prestazioni ottenuti.

By godfrey-engineering
Visualizzazione astratta di codice Rust compilato a WebAssembly con grafici di prestazione

Quando abbiamo iniziato a sviluppare ChainSolve, avevamo un requisito non negoziabile: ogni calcolo deve essere eseguito nel browser con prestazioni quasi native. Niente round-trip server. Niente dipendenze cloud. Nessun dato lascia il computer dell’utente. Questo requisito ci ha portato a Rust e WebAssembly.

Il requisito di prestazione

ChainSolve consente agli utenti di costruire grafici di calcolo con potenzialmente migliaia di blocchi, ciascuno che esegue un’operazione matematica sui suoi input e passa i risultati ai blocchi a valle. Quando un utente cambia un valore di input, l’intero sottografo a valle deve ricalcolare e aggiornarsi in tempo reale — idealmente entro un singolo fotogramma di animazione (16ms a 60fps).

Un’implementazione JavaScript pura del motore di calcolo ha raggiunto un limite intorno a 200 blocchi prima che i cali di fotogrammi diventassero evidenti. Avevamo bisogno di almeno un miglioramento di 10 volte per supportare il nostro obiettivo di 5.000+ blocchi per grafico.

Perché Rust?

Abbiamo valutato diversi linguaggi che compilano a WebAssembly: C, C++, Go e Rust. Abbiamo scelto Rust per tre motivi.

Sicurezza della memoria senza garbage collection

Il sistema di ownership di Rust garantisce la sicurezza della memoria in fase di compilazione senza un garbage collector. Questo è critico per WebAssembly perché il modello di memoria lineare di Wasm significa che un use-after-free o un buffer overflow non solo blocca la tua scheda — corrompe silenziosamente l’intero spazio di memoria. Rust elimina questi bug per costruzione.

Astrazioni a costo zero

Le astrazioni di Rust (generici, iteratori, pattern matching) compilano nello stesso codice macchina che scriveresti manualmente in C. Questo significa che possiamo scrivere codice pulito ed espressivo senza pagare una penalità di prestazioni.

Eccellente toolchain WebAssembly

Lo strumento wasm-pack, il crate wasm-bindgen e l’ecosistema Rust-to-Wasm più ampio sono maturi e ben documentati. Possiamo esporre funzioni Rust a JavaScript con boilerplate minimo e nessun codice FFI manuale.

Panoramica dell’architettura

Il motore di calcolo ChainSolve ha tre livelli:

  1. Step [counter(steps)] Rappresentazione del grafico

    Il grafico di calcolo è memorizzato come una lista di adiacenza con ordinamento topologico. Quando la struttura del grafico cambia (blocchi aggiunti, rimossi o ricablati), ricalcoliamo l’ordine topologico utilizzando l’algoritmo di Kahn. Questo determina la sequenza di valutazione.

  2. Step [counter(steps)] Motore di valutazione

    Il motore percorre l’ordine topologico e valuta ogni blocco. I blocchi sono implementati come oggetti trait Rust con un’interfaccia evaluate(&self, inputs: &[Value]) -> Result<Value, EvalError> comune. Questo ci consente di aggiungere nuovi tipi di blocchi senza modificare il motore.

  3. Step [counter(steps)] Bridge JavaScript

    Il livello bridge utilizza wasm-bindgen per esporre un’API minima a JavaScript: create_graph(), add_block(), connect(), set_input(), evaluate() e get_results(). I dati vengono serializzati come JSON per i tipi complessi e passati come array f64 grezzi per i dati numerici per ridurre al minimo il sovraccarico di serializzazione.

Benchmark

Abbiamo sottoposto a benchmark il motore Wasm rispetto a un’implementazione JavaScript pura equivalente su tre carichi di lavoro:

Carico di lavoroJS (ms)Wasm (ms)Accelerazione
100 blocchi, catena lineare2,10,1811,7x
1.000 blocchi, grafico ramificato34,22,414,3x
5.000 blocchi, DAG complesso412,022,118,6x

L’accelerazione aumenta con la complessità del grafico perché il motore Wasm beneficia di una migliore località cache e dell’assenza dei controlli di tipo dinamici di JavaScript.

Integrazione Web Worker

Il motore di calcolo viene eseguito all’interno di un Web Worker per evitare di bloccare il thread principale. Il modello di comunicazione è diretto:

  1. Il thread principale invia mutazioni del grafico (blocco aggiunto, connessione modificata, input aggiornato) come messaggi strutturati al Worker.
  2. Il Worker applica mutazioni al motore Wasm e attiva la rivalutazione.
  3. I risultati vengono rimandati al thread principale tramite postMessage.
  4. Per set di risultati di grandi dimensioni, utilizziamo SharedArrayBuffer con Atomics per evitare il costo di serializzazione di postMessage.

Questa architettura significa che il thread dell’interfaccia utente non viene mai bloccato dal calcolo. Anche se un grafico impiega 500ms per valutare, l’utente può continuare a trascinare blocchi, scorrere il canvas e interagire con l’interfaccia senza alcun jank.

Lezioni apprese

Costruire un’applicazione Wasm di produzione ci ha insegnato diverse lezioni:

  1. La dimensione del binario Wasm conta. La nostra build iniziale era 2,4MB. Dopo l’abilitazione di LTO, wasm-opt e la rimozione dei simboli di debug, l’abbiamo ridotta a 340KB compresso. Ogni kilobyte conta per il tempo di caricamento iniziale.

  2. Evita allocazioni piccole frequenti. L’allocatore di Wasm è più semplice di un allocatore nativo. Utilizziamo l’allocazione arena per i dati temporanei per valutazione, il che ha ridotto il sovraccarico di allocazione del 60%.

  3. Profila nel browser, non sulla riga di comando. Le caratteristiche di prestazione differiscono tra Rust nativo e Wasm. Il pannello Prestazioni di Chrome DevTools con supporto di profilazione Wasm era il nostro strumento di ottimizzazione principale.

  4. Prova entrambi i lati del bridge. I bug nella serializzazione wasm-bindgen sono sottili. Manteniamo una suite di test completa che viene eseguita sia in Rust nativo (tramite cargo test) che nel browser (tramite wasm-pack test).

Conclusione

Rust e WebAssembly sono stati la scelta giusta per il motore di calcolo di ChainSolve. La combinazione di sicurezza della memoria, astrazioni a costo zero e prestazioni browser quasi native ci consente di fornire un’esperienza di calcolo visivo in tempo reale che non sarebbe possibile con JavaScript da solo.

Se stai considerando Wasm per la tua prossima applicazione basata su browser, speriamo che questo articolo fornisca contesto utile per la tua decisione. E se vuoi vedere i risultati in azione, prova a costruire un grafico di calcolo su app.chainsolve.co.uk.