Zurück zur Startseite

Parser-Beschleunigung um das 3-Fache: Übergang von Rust/WASM zu TypeScript

Der Artikel beschreibt den Fall der Umschreibung des DSL-Parsers von Rust/WASM zu TypeScript, was zu 2,6–3,3x Beschleunigung bei der Verarbeitung führte. Er analysiert die Gründe für den Overhead an der WASM-JS-Grenze und die Vorteile des inkrementellen Algorithmus mit Instruction-Caching.

Wie wir den Parser durch Verzicht auf WASM um das 3-Fache beschleunigt haben
Advertisement 728x90

# WASM über Bord: Rust-Parser in TypeScript umgeschrieben – 3x schneller

Der Wechsel von Rust/WASM zu TypeScript beim Parsen einer benutzerdefinierten DSL direkt im Browser hat den Overhead der Laufzeitgrenzen eliminiert und einen inkrementellen Algorithmus ermöglicht. Die Gesamtzeit für die Stream-Verarbeitung sank um den Faktor 2,6–3,3.

Architektur des openui-lang Parsers

Der Parser wandelt eine benutzerdefinierte DSL, die von einem Sprachmodell (LLM) generiert wird, in einen React-Komponentenbaum um. Der Pipeline-Prozess umfasst sechs aufeinanderfolgende Stufen, die jeweils eine spezifische Aufgabe erledigen:

  • Autocloser — stellt die syntaktische Korrektheit von Teilen des Texts sicher, indem er die minimale Anzahl schließender Klammern und Anführungszeichen ergänzt.
  • Lexer — scannt den Eingabestring in einem Durchgang und erzeugt typisierte Tokens.
  • Splitter — zerlegt den Token-Stream in einzelne id = expression-Anweisungen.
  • Parser — baut mit rekursivem Abstieg aus Ausdrücken einen abstrakten Syntaxbaum (AST).
  • Resolver — löst Variablenreferenzen auf, unterstützt Hoisting von Deklarationen und erkennt zyklische Abhängigkeiten.
  • Mapper — wandelt den internen AST in das öffentliche OutputNode-Format um, das für das React-Rendering bereit ist.

Jede Stufe ist für die Gesamtleistung entscheidend, da der Parser bei jedem Chunk des LLM-Output-Streams läuft.

Google AdInline article slot

Das Problem: Overhead an der WASM–JS-Grenze dominiert

Die ursprüngliche Rust-Implementierung, kompiliert zu WebAssembly, war beim Parsen selbst nicht langsam – sie litt unter den Datenübertragungskosten zwischen den Laufzeiten. Jeder Aufruf umfasste:

  • Kopieren des Strings vom JS-Heap in den WASM-Linear-Memory.
  • Serialisierung des Parse-Ergebnisses zu JSON innerhalb von Rust.
  • Kopieren des JSON-Strings zurück in den JS-Heap.
  • Deserialisierung des Strings in ein JS-Objekt via JSON.parse().

Der Versuch mit serde-wasm-bindgen für direkte Objektübertragung ohne JSON-Serialisierung verschlechterte die Leistung sogar um 9–29 %. Der Grund: rekursive Materialisierung von Rust-Datenstrukturen in native JS-Objekte, was unzählige feingranulare Laufzeitübergänge auslöste.

Die Lösung: Komplette TypeScript-Portierung + inkrementeller Algorithmus

Der komplette Verzicht auf WASM zugunsten von TypeScript hat den Datenübertragungs-Overhead getötet. Der echte Game-Changer war jedoch die Optimierung für Streaming mit inkrementellem Ansatz.

Google AdInline article slot

Die naive Variante parste bei jedem neuen Chunk den gesamten akkumulierten String von vorne – mit quadratischer O(N²)-Komplexität bezogen auf die Chunk-Anzahl.

Wir führten eine inkrementelle Strategie mit Anweisungs-Caching ein:

  • Anweisungen, die mit Zeilenumbruch bei Nulllage der Einrückung enden, gelten als unveränderlich.
  • Ihre ASTs werden nach dem ersten Parse gecacht.
  • Bei neuen Chunks wird nur der Schwanz (unfertige Anweisung) neu geparst.
  • Das Endergebnis fusioniert gecachte ASTs mit dem frischen Schwanz-Parse.

Die Komplexität sank damit auf lineare O(L), wobei L die Gesamtlänge des Dokuments ist.

Google AdInline article slot

Benchmarks und Leistungsergebnisse

Wir haben drei realistische LLM-generierte Testfälle geprüft.

Einzelner Parser-Aufruf (Medianzeit, Mikrosekunden):

| Testfall | TypeScript | WASM (via JSON) | Beschleunigung |

|---------------|------------|-----------------|---------------|

| simple-table | 9,3 | 20,5 | 2,2x |

| contact-form | 13,4 | 61,4 | 4,6x |

| dashboard | 19,4 | 57,9 | 3,0x |

Gesamtkosten Stream-Verarbeitung (Medianzeit, Mikrosekunden):

| Testfall | Naiv TS (O(N²)) | Inkrementell TS (O(N)) | Beschleunigung |

|---------------|------------------|------------------------|---------------|

| simple-table | 69 | 77 | — |

| contact-form | 316 | 122 | 2,6x |

| dashboard | 840 | 255 | 3,3x |

Kein Gewinn bei simple-table, da es sich um eine einzelne Anweisung ohne Cache-Nutzen handelt. Die Vorteile skalieren mit der Anzahl der Anweisungen.

Wichtige Erkenntnisse

  • In Rust/WASM-Setups mit häufigen Aufrufen ist nicht die Rechenleistung der Killer – sondern Datenkopieren und Serialisierung an Grenzen.
  • Direkte WASM-zu-JS-Objektübergabe via serde-wasm-bindgen kann langsamer sein als optimiertes JSON durch endlose interne Konvertierungen.
  • Algorithmische Verbesserungen (O(N²) zu O(N)) schlagen oft Sprach- oder Laufzeit-Mikro-Optimierungen.
  • WASM glänzt bei low-interaction-Aufgaben (Bildverarbeitung), scheitert aber bei häufigem Parsing in komplexe JS-Objekte.

Wann WASM einsetzen (und wann nicht)

WASM siegt:

  • Rechenintensive Tasks mit Bulk-Input und skalaren/in-place Outputs (Media-Verarbeitung, Krypto, Physik-Sims).
  • Portierung bestehender C/C++-Libs in den Browser ohne komplette Neuschreibung.

WASM scheitert:

  • Häufiges Parsing strukturierter Texte mit komplexen JS-Objekt-Rückgaben.
  • Hot Paths mit winzigen Inputs, wo Grenz-Overhead nicht amortisiert wird.

— Editorial Team

Advertisement 728x90

Weiterlesen