Сгенерировано ИИ Qwen3.5-Plus
Сгенерировано ИИ Qwen3.5-Plus

Мы создали парсер openui‑lang на Rust и скомпилировали его в WASM. Логика была здравой: Rust быстрый, WASM в браузере даёт скорость, близкую к нативной, а наш парсер — разумно сложный, многоэтапный конвейер. Почему бы не захотеть его на Rust?

Но оказалось, мы оптимизировали не то, что нужно.


Конвейер

Парсер openui‑lang преобразует кастомный DSL, генерируемый большой языковой моделью (LLM), в дерево компонентов React. Запускается он на каждом фрагменте потока, поэтому задержки критичны.

Конвейер состоит из шести этапов:

autocloser → lexer → splitter → parser → resolver → mapper → ParseResult
  • Autocloser — делает частичный (полученный в середине потока) текст корректным синтаксически, добавляя минимум закрывающих скобок и кавычек.

  • Lexer — выполняет однопроходное сканирование, выдаёт типизированные токены.

  • Splitter — нарезает поток токенов на инструкции вида id = expression.

  • Parser — парсит выражения в стиле рекурсивного спуска, строит абстрактное синтаксическое дерево (AST).

  • Resolver — встраивает все ссылки на переменные, поддерживает поднятие объявлений, находит циклические зависимости.

  • Mapper — преобразует внутреннее AST в публичный формат OutputNode для React-рендерера.

«Налог» на переход через границу WASM

Каждый вызов WASM‑парсера несёт неизбежный оверхед независимо от скорости работы кода на Rust:

JS                              WASM
────────────────────────────────────────────────────────
wasmParse(вход)
  │
  ├─ копирование строки: куча JS → линейная память WASM   (выделение + memcpy)
  │
  │                                 Rust парсит              ✓ быстро
  │                                 serde_json::to_string()  ← сериализация результата
  │
  ├─ копирование JSON-строки: WASM → куча JS              (выделение + memcpy)
  │
  JSON.parse(jsonString)                                  ← десериализация результата
  │
  return ParseResult

Сам парсинг на Rust никогда не был медленным. Все задержки возникали на границе сред: копирование строки внутрь, сериализация результата в JSON, копирование строки обратно, затем десериализация V8 в JS‑объект.

Попытка исправления: обходим сериализацию в JSON

Естественный вопрос: а что если WASM будет возвращать JS‑объект напрямую, без сериализации в JSON? Мы подключили serde-wasm-bindgen, который делает именно это — преобразует Rust‑структуру в JsValue и возвращает её напрямую.

На 30% медленнее.

И вот почему. JS не может прочитать байты Rust‑структуры из линейной памяти WASM как нативный объект — схемы размещения данных в памяти в этих средах совершенно разные. Чтобы сконструировать JS‑объект из данных Rust, serde-wasm-bindgen должен рекурсивно материализовывать данные Rust в настоящие массивы и объекты JS. Это требует множества мелкозернистых преобразований через границу сред при каждом вызове parse().

Сравним с подходом через JSON: serde_json::to_string() полностью, без переходов границы сред выполняется в Rust и формирует одну строку. Один вызов memcpy копирует её в кучу JS, а затем нативный C++‑код V8 обрабатывает её через JSON.parse за один оптимизированный проход. Меньшее число крупных, лучше оптимизированных операций, выигрывает у множества мелких.

Бенчмарк — строка JSON против JsValue напрямую (1000 запусков, мкс на вызов)

Фикстура

Через JSON

serde‑wasm‑bindgen

Изменение

simple‑table

20,5

22,5

на 9% медленнее

contact‑form

61,4

79,4

на 29% медленнее

dashboard

57,9

74,0

на 28% медленнее

Мы немедленно откатили изменение.

Настоящее решение — полностью устраняем границу

Мы портировали весь конвейер парсера на TypeScript. Та же шестиступенчатая архитектура, тот же формат результата ParseResult — ни WASM, ни границы сред, всё полностью работает в куче V8.

Метод бенчмарка — одиночный парсинг

Что измеряется? Один вызов parse(completeString) на готовой итоговой строке. Это изолирует стоимость одного вызова парсера.

Как выполняется? 30 прогревочных итераций для стабилизации JIT, затем 1000 замеренных итераций с performance.now() (точность в микросекундах). Указывается медианное значение. Фикстуры — это реальные, сгенерированные LLM деревья компонентов. Они сериализованы с синтаксисом, соответствующим реальному формату потока.

Фикстуры:

  • simple-table — корень и одна таблица с тремя колонками и пятью строками (~180 символов);

  • contact-form — корень и форма с шестью полями ввода и кнопкой отправки (~400 символов);

  • dashboard — корень, боковая навигация, три карточки метрик, график и таблица данных (~950 символов).

Одиночный парсинг (1000 запусков, медиана, мкс)

Фикстура

TypeScript

WASM

Коэффициент ускорения

simple‑table

9,3

20,5

2,2

contact‑form

13,4

61,4

4,6

dashboard

19,4

57,9

3,0

Проблема алгоритма — O(N²) при обработке потока

Устранение WASM решило проблему стоимости одного вызова, но в архитектуре потоковой обработки оставался более глубокий недостаток.

Парсер вызывается на каждом фрагменте вывода LLM. Наивная реализация накапливает фрагменты и каждый раз парсит всю строку с нуля:

Фрагмент 1:  parse("root = Root([t")              → 14 символов
Фрагмент 2:  parse("root = Root([tbl])\ntbl = T") → 27 символов
Фрагмент 3:  parse(full_accumulated_string)      → ...

При выводе в 1000 символов фрагментами по 20 получится 50 вызовов парсера, обрабатывающих в сумме ~25 000 символов. И сложность относительно количества фрагментов — O(N²).

Решение — инкрементально кэшируем на уровне инструкций

Инструкции, завершённые переводом строки на нулевом уровне вложенности, неизменяемы — LLM никогда не вернётся и не модифицирует их. Мы добавили парсер потока, который кэширует AST завершённых инструкций:

Состояние: { buf, completedEnd, completedSyms, firstId }

При каждом push(chunk):
  1. Сканируем buf от completedEnd в поисках переводов строки на глубине 0
  2. Для каждой найденной завершённой инструкции: парсим + кэшируем AST → продвигаем completedEnd
  3. Оставшаяся (последняя, незавершённая) инструкция: autocloser + свежий парсинг
  4. Объединяем кэшированные + оставшаяся → разрешаем ссылки + преобразуем → возвращаем ParseResult

Завершённые инструкции никогда не парсятся повторно. На каждом новом фрагменте повторно разбирается только хвостовая, незавершённая часть. Вместо O(N²) асимптота O(L), где L — общая длина.

Метод бенчмарка — полная стоимость парсинга потока

Что измеряется? Суммарный оверхед парсинга, накопленный при обработке одного полного документа за все вызовы. В отличие от бенчмарка одиночного вызова, здесь измеряется сумма времени всех вызовов парсера в ходе реальной передачи потока — именно этот показатель влияет на отзывчивость, которая воспринимается пользователем.

Как выполняется? Документы воспроизводятся фрагментами по 20 символов. Каждый фрагмент вызывает parse() (наивный подход) или push() (инкрементальный). Фиксируется общее время всех вызовов. Берётся медиана 100 полных проходов потока.

Результаты: полная стоимость парсинга потока (медиана, мкс, все фрагменты)

Фикстура

Наивный TS (парсинг каждого фрагмента с нуля)

Инкрементальный TS (кэширование завершённых инструкций)

Коэффициент ускорения

simple‑table

69

77

нет (одна инструкция, кэш невыгоден)

contact‑form

316

122

2,6

dashboard

840

255

3,3

Фикстура simple-table содержит одну инструкцию — кэшировать нечего, поэтому подходы эквивалентны. Выгода растёт с увеличением числа инструкций, поскольку большая часть документа кэшируется и при обработке новых фрагментов пропускается.

Почему цифры по TypeScript выглядят по‑разному

В таблице одиночных вызовов для contact-form указано 13,4 мкс; в таблице обработки потока — 316 мкс (наивный подход). Это не противоречие — измеряются разные величины:

  • 13,4 мкс — стоимость одного вызова parse() для полной строки в 400 символов;

  • 316 мкс — суммарная стоимость ~20 вызовов parse() в ходе потока (фрагмент 1 парсит 20 символов, фрагмент 2 — 40 символов, …, фрагмент 20 — 400 символов). Все эти возрастающие вызовы суммируются.

Итоги

Подход

Стоимость одного вызова

Полная стоимость потока

Примечание

WASM + цикл через JSON

20–61 мкс

Базовый уровень

Оверхед на копирование каждый раз

WASM + serde‑wasm‑bindgen

22–79 мкс

+9–29%, медленнее

Сотни внутренних переходов через границу

TypeScript (наивный парсинг с нуля)

9–19 мкс

69–840 мкс

Нет границы, но обработка потока O(N²)

TypeScript (инкрементальный)

9–19 мкс

69–255 мкс

Нет границы + обработка потока O(N)

Ускорение в 2,2–4,6 раза на один вызов и снижение суммарной стоимости потоковой обработки в 2,6–3,3 раза.

Когда WASM действительно помогает

Этот опыт помог нам чётче сформулировать сценарии применения WASM:

Вычисления с минимальным взаимодействием — обработка изображений, видео, криптография, симуляции физики, аудиокодеки. Большой вход → скалярный выход или изменение на месте. Граница сред пересекается редко.

Портирование нативных библиотек — доставка в браузер C/C++‑библиотек (SQLite, OpenCV, libpng) без полного переписывания на JS.

Парсинг структурированного текста в JS‑объекты — платить за сериализацию приходится в любом случае. Сам парсинг выполняется достаточно быстро, и JIT‑компиляция V8 нивелирует любое преимущество Rust. Доминирует оверхед на переход через границу.

Часто вызываемые функции с малыми входными данными — если функция вызывается 50 раз за поток, а вычисления занимают 5 мкс, стоимость перехода через границу не удаётся амортизировать.

Ключевые выводы

  1. Прежде чем выбирать язык реализации, профилируйте, где на самом деле тратится время. В нашем случае затраты возникали не в вычислениях — они всегда были связаны с передачей данных WASM — JS.

  2. «Прямая передача объектов» через serde-wasm-bindgen не дешевле. Конструирование JS‑объекта поле за полем из Rust‑данных требует больше переходов через границу, чем передача одной JSON‑строки, а не меньше. Эти переходы происходят внутри одного FFI‑вызова, незаметно для разработчика.

  3. Снижение алгоритмической сложности перевешивает оптимизации на уровне языка. Переход от O(N²) к O(N) в потоковом сценарии на практике повлиял сильнее, чем смена WASM на TypeScript.

  4. WASM и JS не делят одну кучу. WASM располагает плоской линейной памятью WebAssembly.Memory, которую JS может читать как сырые байты, но эти байты отражают внутреннее представление Rust — указатели, дискриминанты перечислений, выравнивание данных — всё это для среды выполнения JS полностью непрозрачно. Преобразование требуется всегда и всегда во что‑то обходится.