Zum Inhalt springen
rust webassembly performance engineering architecture

Warum wir ChainSolve mit Rust und WebAssembly gebaut haben

Ein technischer Deep Dive in die Gründe für unsere Wahl von Rust kompiliert zu WebAssembly für ChainSolves Berechnungs-Engine und die Performance-Gewinne, die wir erreicht haben.

By godfrey-engineering
Abstrakte Visualisierung von Rust-Code, der zu WebAssembly kompiliert wird, mit Performance-Graphen

Als wir uns daran machten, ChainSolve zu bauen, hatten wir eine nicht verhandelbare Anforderung: Jede Berechnung muss im Browser mit nahezu nativer Performance ausgeführt werden. Keine Server-Roundtrips. Keine Cloud-Abhängigkeiten. Keine Daten, die die Maschine des Benutzers verlassen. Diese Anforderung führte uns zu Rust und WebAssembly.

Die Performance-Anforderung

ChainSolve ermöglicht Benutzern, Berechnungsgraphen mit potenziell Tausenden von Blöcken zu erstellen, von denen jeder eine mathematische Operation auf seine Eingaben ausführt und Ergebnisse an nachgelagerte Blöcke weitergibt. Wenn ein Benutzer einen Eingabewert ändert, muss der gesamte nachgelagerte Subgraph in Echtzeit neu berechnet und aktualisiert werden — idealerweise innerhalb eines einzigen Animationsrahmens (16ms bei 60fps).

Eine reine JavaScript-Implementierung der Berechnungs-Engine erreichte eine Grenze bei etwa 200 Blöcken, bevor Frame-Drops erkennbar wurden. Wir benötigten mindestens eine 10x Verbesserung, um unser Ziel von 5.000+ Blöcken pro Graph zu unterstützen.

Warum Rust?

Wir haben mehrere Sprachen evaluiert, die zu WebAssembly kompilieren: C, C++, Go und Rust. Wir haben uns für Rust aus drei Gründen entschieden.

Speichersicherheit ohne Garbage Collection

Rusts Ownership-System garantiert Speichersicherheit zur Compile-Zeit ohne einen Garbage Collector. Dies ist kritisch für WebAssembly, da Wasms lineares Speichermodell bedeutet, dass ein Use-after-Free oder Buffer-Overflow nicht nur Ihren Tab zum Absturz bringt — es beschädigt den gesamten Speicherplatz stillschweigend. Rust eliminiert diese Bugs durch Konstruktion.

Zero-Cost Abstractions

Rusts Abstraktionen (Generics, Iterators, Pattern Matching) kompilieren zu denselben Maschinencodes, den Sie von Hand in C schreiben würden. Das bedeutet, wir können sauberen, ausdrucksstarken Code schreiben, ohne eine Performance-Steuer zu zahlen.

Hervorragende WebAssembly-Toolchain

Das Tool wasm-pack, die wasm-bindgen Crate und das breitere Rust-to-Wasm Ökosystem sind ausgereift und gut dokumentiert. Wir können Rust-Funktionen für JavaScript mit minimalem Boilerplate und ohne manuelle FFI-Glue-Code verfügbar machen.

Architektur-Übersicht

Die ChainSolve-Berechnungs-Engine hat drei Ebenen:

  1. Step [counter(steps)] Graphdarstellung

    Der Berechnungsgraph wird als Adjacency List mit topologischer Ordnung gespeichert. Wenn sich die Graphstruktur ändert (Blöcke hinzugefügt, entfernt oder neu verdrahtet), berechnen wir die topologische Ordnung mit Kahns Algorithmus neu. Dies bestimmt die Evaluierungssequenz.

  2. Step [counter(steps)] Evaluierungs-Engine

    Die Engine durchläuft die topologische Ordnung und evaluiert jeden Block. Blöcke werden als Rust-Trait-Objekte mit einer gemeinsamen evaluate(&self, inputs: &[Value]) -> Result<Value, EvalError> Schnittstelle implementiert. Dies ermöglicht es uns, neue Block-Typen hinzuzufügen, ohne die Engine zu modifizieren.

  3. Step [counter(steps)] JavaScript-Brücke

    Die Brückenschicht verwendet wasm-bindgen, um eine minimale API für JavaScript verfügbar zu machen: create_graph(), add_block(), connect(), set_input(), evaluate() und get_results(). Daten werden als JSON für komplexe Typen serialisiert und als rohe f64-Arrays für numerische Daten weitergegeben, um den Serialisierungs-Overhead zu minimieren.

Benchmarks

Wir haben die Wasm-Engine gegen eine äquivalente reine JavaScript-Implementierung auf drei Workloads benchmarkt:

WorkloadJS (ms)Wasm (ms)Speedup
100 Blöcke, lineare Kette2.10.1811.7x
1.000 Blöcke, verzweigter Graph34.22.414.3x
5.000 Blöcke, komplexer DAG412.022.118.6x

Der Speedup nimmt mit der Graphkomplexität zu, weil die Wasm-Engine von besserer Cache-Lokalität und dem Fehlen von Javas dynamischen Typ-Checks profitiert.

Web Worker Integration

Die Berechnungs-Engine läuft innerhalb eines Web Worker, um zu verhindern, dass der Hauptthread blockiert wird. Das Kommunikationsmuster ist geradlinig:

  1. Der Hauptthread sendet Graphmutationen (Block hinzugefügt, Verbindung geändert, Eingabe aktualisiert) als strukturierte Nachrichten an den Worker.
  2. Der Worker wendet Mutationen auf die Wasm-Engine an und löst eine Neubewertung aus.
  3. Ergebnisse werden über postMessage an den Hauptthread zurückgesendet.
  4. Für große Ergebnismengen verwenden wir SharedArrayBuffer mit Atomics, um die Serialisierungskosten von postMessage zu vermeiden.

Diese Architektur bedeutet, dass der UI-Thread niemals durch Berechnungen blockiert wird. Selbst wenn ein Graph 500ms zur Evaluierung benötigt, kann der Benutzer weiterhin Blöcke ziehen, die Canvas scrollen und mit der Schnittstelle interagieren, ohne Ruckler.

Gelernte Lektionen

Das Bauen einer produktiven Wasm-Anwendung hat uns mehrere Lektionen gelehrt:

  1. Wasm-Größe ist wichtig. Unser anfänglicher Build war 2.4MB groß. Nach Aktivierung von LTO, wasm-opt und Entfernen von Debug-Symbolen reduzierten wir ihn auf 340KB komprimiert. Jedes Kilobyte zählt für die Anfangsladezeit.

  2. Vermeiden Sie häufige kleine Zuordnungen. Wasms Allocator ist einfacher als ein nativer Allocator. Wir verwenden Arena-Zuordnung für temporäre Daten pro Evaluierung, was den Zuordnungs-Overhead um 60% reduzierte.

  3. Profilieren Sie im Browser, nicht in der Kommandozeile. Performance-Charakteristiken unterscheiden sich zwischen nativen Rust und Wasm. Chromes DevTools Performance Panel mit Wasm-Profilierungsunterstützung war unser primäres Optimierungstool.

  4. Testen Sie beide Seiten der Brücke. Bugs in der wasm-bindgen Serialisierung sind subtil. Wir pflegen eine umfassende Test-Suite, die sowohl in nativen Rust (via cargo test) als auch im Browser (via wasm-pack test) läuft.

Fazit

Rust und WebAssembly sind die richtige Wahl für ChainSolves Berechnungs-Engine gewesen. Die Kombination aus Speichersicherheit, Zero-Cost Abstractions und nahezu nativer Browser-Performance ermöglicht es uns, ein Echtzeit-Visual-Computation-Erlebnis zu liefern, das mit JavaScript allein nicht möglich wäre.

Wenn Sie Wasm für Ihre nächste Browser-basierte Anwendung in Betracht ziehen, hoffen wir, dass dieser Beitrag nützlichen Kontext für Ihre Entscheidung bietet. Und wenn Sie die Ergebnisse in Aktion sehen möchten, versuchen Sie, einen Berechnungsgraph unter app.chainsolve.co.uk zu erstellen.