Powrót do strony głównej

Przyspieszenie parsera 3-krotnie: przejście z Rust/WASM na TypeScript

Artykuł opisuje przypadek przepisania parsera DSL z Rust/WASM na TypeScript, co doprowadziło do przyspieszenia przetwarzania 2,6–3,3-krotnie. Analizowane są przyczyny overheadu na granicy WASM-JS i zalety algorytmu inkrementalnego z cachowaniem instrukcji.

Jak przyspieszyliśmy parser 3-krotnie, rezygnując z WASM
Advertisement 728x90

Porzucenie WASM: jak przepisanie parsera z Rust na TypeScript przyspieszyło przetwarzanie 3-krotnie

Przejście z Rust/WASM na TypeScript do parsowania DSL w przeglądarce wyeliminowało narzut na granicy środowisk i umożliwiło wdrożenie algorytmu inkrementalnego, zmniejszając całkowity czas przetwarzania strumienia w 2,6–3,3 razy.

Architektura parsera openui-lang

Parser przekształca niestandardowy DSL, generowany przez model językowy (LLM), w drzewo komponentów React. Konwejer składa się z sześciu kolejnych etapów, każdy wykonujący specyficzne zadanie:

  • Autocloser — zapewnia syntaktyczną poprawność częściowego tekstu, dodając minimalną liczbę zamykających nawiasów i cudzysłowów.
  • Lexer — wykonuje jednoprzebiegowe skanowanie ciągu wejściowego i generuje typizowane tokeny.
  • Splitter — dzieli strumień tokenów na oddzielne instrukcje w formacie id = expression.
  • Parser — używa rekurencyjnego zejścia do budowania abstrakcyjnego drzewa syntaktycznego (AST) z wyrażeń.
  • Resolver — rozstrzyga odniesienia do zmiennych, wspiera hoisting deklaracji i wykrywa cykliczne zależności.
  • Mapper — transformuje wewnętrzne AST w publiczny format OutputNode, gotowy dla renderera React.

Każdy etap jest krytycznie ważny dla końcowej wydajności, ponieważ parser jest wywoływany na każdym fragmencie strumienia wyjściowego LLM.

Google AdInline article slot

Problem: dominujący narzut na granicy WASM–JS

Początkowa implementacja w Rust, skompilowana do WebAssembly, cierpiała nie z powodu wolnego parsowania, ale kosztów transferu danych między środowiskami. Każde wywołanie obejmowało:

  • Kopiowanie ciągu ze sterty JS do liniowej pamięci WASM.
  • Serializację wyniku parsowania w JSON wewnątrz Rust.
  • Kopiowanie ciągu JSON z powrotem do sterty JS.
  • Deserializację ciągu w obiekt JS za pomocą JSON.parse().

Próba użycia serde-wasm-bindgen do bezpośredniej transmisji obiektów bez serializacji w JSON pogorszyła wydajność o 9–29%. Stało się tak z powodu konieczności rekurencyjnego materializowania struktur danych Rust w natywne obiekty JS, co tworzyło wiele drobnoziarnistych przejść przez granicę środowisk.

Rozwiązanie: pełny port na TypeScript i algorytm inkrementalny

Całkowite porzucenie WASM i przejście na TypeScript wyeliminowało narzut na transfer danych. Jednak kluczową poprawą była optymalizacja algorytmu dla przetwarzania strumieniowego.

Google AdInline article slot

Naiwna implementacja wywoływała parser na każdym nowym fragmencie, za każdym razem przetwarzając cały zgromadzony ciąg od początku. To prowadziło do kwadratowej złożoności O(N²) względem liczby fragmentów.

Wdrożono strategię inkrementalną z buforowaniem na poziomie instrukcji:

  • Instrukcje, zakończone znakiem nowej linii na zerowym poziomie zagnieżdżenia, są uznawane za niezmienne.
  • AST takich zakończonych instrukcji jest buforowane po pierwszym parsowaniu.
  • Przy nadejściu nowego fragmentu, ponownie parsowany jest tylko ogonowa, niezakończona instrukcja.
  • Wynik jest formowany przez połączenie buforowanych AST i świeżego wyniku parsowania ogona.

To zmniejszyło złożoność do liniowej O(L), gdzie L — całkowita długość dokumentu.

Google AdInline article slot

Benchmarki i wyniki wydajności

Porównanie przeprowadzono na trzech realistycznych fixture'ach, wygenerowanych przez LLM.

Pojedyncze wywołanie parsera (mediana czasu, mikrosekundy):

| Fixture | TypeScript | WASM (przez JSON) | Przyspieszenie |

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

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

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

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

Pełny koszt przetwarzania strumienia (mediana czasu, mikrosekundy):

| Fixture | Naiwny TS (O(N²)) | Inkrementalny TS (O(N)) | Przyspieszenie |

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

| simple-table | 69 | 77 | — |

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

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

Dla simple-table nie ma przyspieszenia, ponieważ dokument składa się z jednej instrukcji i nie ma co buforować. Korzyść rośnie ze wzrostem liczby instrukcji w dokumencie.

Co jest ważne

  • Główny bottleneck w połączeniu Rust/WASM dla częstych wywołań — to nie obliczenia, ale narzut na kopiowaniu i serializacji danych przez granicę środowisk.
  • Bezpośrednia transmisja obiektów z WASM do JS przez serde-wasm-bindgen jest często wolniejsza niż optymalizowana serializacja w JSON, z powodu wielu wewnętrznych transformacji.
  • Optymalizacja złożoności algorytmicznej (przejście od O(N²) do O(N)) może dać większy praktyczny zysk niż mikrooptymalizacje na poziomie języka lub środowiska wykonawczego.
  • WASM jest efektywny dla zadań z minimalną interakcją (np. przetwarzanie obrazów), ale nie nadaje się do częstego parsowania strukturalnego tekstu w obiekty JS.

Kiedy używać WASM, a kiedy nie

Efektywne scenariusze dla WASM:

  • Złożone obliczeniowo zadania z dużym wolumenem danych wejściowych i skalarnym lub in-place wyjściem (przetwarzanie mediów, kryptografia, symulacje fizyczne).
  • Portowanie istniejących natywnych bibliotek (C/C++) do przeglądarki bez pełnego przepisywania.

Nieefektywne scenariusze dla WASM:

  • Częste parsowanie strukturalnego tekstu z koniecznością zwracania złożonych obiektów JS.
  • Funkcje, wywoływane często z małymi danymi wejściowymi, gdzie narzut na przejściu nie jest amortyzowany.

— Editorial Team

Advertisement 728x90

Czytaj dalej