Производительность: воспроизводимый замер
Код в этой главе записан в прежней поверхности языка — со словами
категория,объект,утилита. Сегодняшний компилятор её слова читает, но программой такой файл не считает: файл, где есть только утилиты,flang checkотклоняет. Разбор задачи в главе верен; синтаксис переносится по таблице из главы «Старые модели».
До этой ревизии курс приводил цифры с чужой машины (Apple M1 Max, Node 24.6),
которые читатель не мог проверить. Число без метода воспроизведения — не
факт, а утверждение на веру. Дальше — харнесс examples/spec/benchmark/,
которым вы снимаете свои миллисекунды на своей машине, а не переписываете
чужие в заметки.
Минимальный пример
Компилятор из static/js/vendor/fts/browser.js — то, что измеряет харнесс, и
то, что исполняется в песочнице курса. Небольшая модель ниже компилируется и
проходит собственные примеры:
категория «Тарификация»
объект Заявка
объём является числом
«премиум клиент» является признаком
утилита «Рассчитать стоимость»
принимает Заявка
возвращает деньги
начинает с 100
правило «Крупный объём»
если объём не меньше 50
то добавить 40
правило «Премиум скидка»
если «премиум клиент» равен да
то добавить -20
свойство «Стоимость не ниже минимального тарифа»
результат не меньше 80
пример «Стандартная заявка»
дано объём равен 10
дано «премиум клиент» равен нет
ожидается результат равен 100
пример «Крупная премиум заявка»
дано объём равен 80
дано «премиум клиент» равен да
ожидается результат равен 120
compile разбирает такой текст в документ, validate проверяет ссылки и
типы, executeUtility/testUtilities исполняют правила, generateTypeScript
печатает .ts-реализацию и тест. Именно эти четыре операции измеряет харнесс.
Что делает компилятор
Разбор — не парсинг в вакууме: естественный русский синтаксис (natural-parser.js)
разворачивается в тот же документ, что и явный JSON-документ. Validate проверяет,
что структуры, поля и функторы существуют и типы согласованы, но не исполняет
правила. Execute проходит по списку правил утилиты и вычисляет результат для
конкретного входа. Generate печатает готовый TypeScript и тест на node:test —
без обращения к диску и без tsc.
Все четыре операции — чистый JS-код над уже разобранной или ещё не разобранной строкой. Ни одна не трогает сеть, файловую систему форматов сборки или дочерние процессы.
Методика замера
measure.mjs — не однократный console.time. Три решения объясняют, почему:
- Прогрев. Перед замером выполняется 5 холостых вызовов — JIT ещё не разогрелся, и первые вызовы систематически медленнее тех, что увидит реальный процесс, проработавший хоть немного.
- Батчи с минимальным временем 2 мс. Одиночный вызов
compileна модели из 10 полей занимает десятые доли миллисекунды — на этом масштабе разрешение таймера и накладные расходы самого вызова функции становятся сравнимы с измеряемой величиной. Харнесс подбирает размер батча так, чтобы суммарное время одного замера было не меньше 2 мс, и делит его на размер батча. - Медиана, а не среднее. По серии батчей харнесс сортирует результаты и
берёт медиану. Среднее вытягивает на себя один выброс — GC-паузу,
деоптимизацию JIT, соседний процесс, укравший ядро CPU. Медиана устойчива к
такому шуму.
min/max/p95в отчёте показывают разброс: если p95 сильно оторвался от медианы, замер нестабилен и одному числу верить не стоит.
Модели генерируются программно, с 10, 100 и 1000 полей/правил — тем же
способом, что в апстримном scripts/benchmark.mjs: детерминированный текст,
без ручных .fts-файлов, значит, любой может сгенерировать точно такую же
модель у себя.
Транспиляция сгенерированного TypeScript в измерение не входит: в этом
репозитории пакета typescript нет — это учебный репозиторий, не production-сборка
FTS. Харнесс не подделывает цифру и не умалчивает о пропуске: при отсутствии
пакета печатает явную строку transpile: { measured: false, note: "..." }.
Если typescript доступен в вашем окружении, харнесс подключит его через
динамический import и измерит шаг отдельно.
Ваши цифры за одну команду
node examples/spec/benchmark/measure.mjs
Фактический вывод на машине, где готовился этот модуль (node v24.18.0,
linux/x64, AMD EPYC 9354 32-Core Processor, 8 ядер), медиана в миллисекундах:
| Операция | 10 | 100 | 1000 |
|---|---|---|---|
| Compile | 0.0705 | 0.5743 | 6.1858 |
| Validate | 0.0101 | 0.0783 | 0.8217 |
| Execute (все правила срабатывают) | 0.0015 | 0.0030 | 0.0180 |
| Generate TypeScript | 0.0079 | 0.0450 | 0.5771 |
При scale 1000 compile + validate вместе занимают около 7.01 мс. Полный
JSON-отчёт с mean/min/max/p95 по каждой операции — в
examples/spec/benchmark/baseline-linux-x64-node24.json, снятом той же командой
с флагом --out.
Рост модели со scale 10 до scale 1000 (в 100 раз) увеличивает compile примерно в 88 раз, validate — примерно в 81 раз: обе операции растут почти линейно с размером модели, чуть медленнее её. Execute растёт иначе: с 0.0015 до 0.018 мс, то есть в 12 раз при 100-кратном росте числа правил — потому что цикл по правилам утилиты дешёвый сам по себе, каждое дополнительное правило стоит доли микросекунды. Даже при 1000 совпавших правилах execute остаётся на два порядка дешевле compile того же масштаба. Разбор синтаксиса и построение структур документа стоит на порядки дороже прохода по уже готовому списку правил — и именно поэтому «модель выросла» почти всегда означает «compile подорожал», а не «выполнение подорожало».
Сравнение с baseline
node examples/spec/benchmark/compare.mjs my.json examples/spec/benchmark/baseline-linux-x64-node24.json
compare.mjs печатает отклонение медианы в процентах операция за операцией.
Baseline с другой машины сравнивать буквально нельзя — абсолютные миллисекунды
не переносятся между разным железом, инструмент печатает явное предупреждение
об этом. Ниже — фактический вывод сравнения замера с этой машины и чужого
baseline из апстрима (Apple M1 Max, Node 24.6.0, взят из
benchmarks/baseline-darwin-arm64-node24.json апстрима FTS, а не измерен
здесь):
ВНИМАНИЕ: baseline снят на другом железе или ОС. Абсолютные миллисекунды не сравнимы —
смотрите на то, как растёт median между scale 10/100/1000, а не на разницу процентов ниже.
operation scale current_ms baseline_ms diff_%
compile 10 0.0705 0.0485 +45.4%
compile 100 0.5743 0.3742 +53.5%
compile 1000 6.1858 3.8279 +61.6%
validate 1000 0.8217 0.9477 -13.3%
execute 1000 0.018 0.0146 +23.3%
generate_typescript 1000 0.5771 0.3442 +67.7%
Разное железо даёт разный процент по каждой операции — это ожидаемо и не повод паниковать. Полезный сигнал — на своей машине сравнить сегодняшний отчёт со вчерашним: тот же CPU, тот же Node, тот же метод. Если compile на scale 1000 внезапно вырос втрое на одинаковом железе — это регрессия, а не шум.
Что эти цифры не означают
Харнесс меряет чистый рантайм FTS в Node.js — четыре функции над уже загруженной в память строкой. Он не меряет:
- сборку Vite, webpack или esbuild вокруг сгенерированного кода;
- полный
tscс type graph всего проекта, инкрементальным кешем, плагинами и sourcemaps — вместо этого шаг транспиляции честно помечен пропущенным; - сеть, диск, старт процесса Node — само измерение стартует уже внутри прогретого процесса;
- поведение под конкурентной нагрузкой — харнесс однопоточный и последовательный, а прод-сервис может компилировать модели параллельно.
Здесь же уместно назвать порядок величин. Холодный rebuild фронтенд-проекта
на Vite или webpack обычно занимает от сотен миллисекунд до нескольких
секунд; проверка типов всего проекта через tsc — секунды. Compile + validate
- generate для модели из 1000 полей и правил на этой машине укладывается в 7.8 мс — на три порядка меньше. Для реалистичных моделей (в проектах курса — десятки полей, а не тысячи) производительность FTS перестаёт быть вопросом раньше, чем читатель успевает открыть профилировщик: она тонет в шуме остальной сборки. Явно измерять стоит, когда модель на порядки крупнее — 1000 полей и больше, — или когда compile вызывается на каждый запрос, а не один раз при старте процесса.
Замер в CI
Регрессия ловится не абсолютным порогом (шумные CI-раннеры на разделяемом CPU дают разброс, из-за которого фиксированный процент будет либо ложно срабатывать, либо ничего не ловить), а сравнением с прошлым отчётом на том же раннере:
- run: node examples/spec/benchmark/measure.mjs --out current.json
- run: node examples/spec/benchmark/compare.mjs current.json baseline.json
# baseline.json — артефакт предыдущего успешного прогона на master,
# сохранённый через actions/cache или actions/upload-artifact
- run: node examples/spec/benchmark/compare.mjs current.json baseline.json --json > diff.json
Дальше — небольшой скрипт-обвязка (не входит в этот харнесс), который читает
diff.json и падает, если diff_percent на нужной операции и масштабе
превысил выбранный порог с запасом на шум раннера — например, x2, а не x1.1.
Смысл не в точной цифре, а в том, чтобы ловить скачки на порядок, а не
дёргать команду на каждый процент.
Практика в песочнице
На вкладке «Проверка» песочница тоже показывает время: «Модель валидна ·
разбор N мс». Это не то же измерение, что делает measure.mjs, и вот почему:
в песочнице это один вызов compile+validate в браузере читателя, без
прогрева и без батчей — ровно то, что реально произошло, когда вы открыли
вкладку. Такое число полезно как ощущение «прямо сейчас, на этом устройстве,
это быстро», но не годится для сравнения между запусками: один холодный вызов
шумит сильнее, чем медиана из полусотни прогретых батчей. Харнесс же
предназначен для другого — стабильного тренда во времени, который можно
класть в CI и сравнивать день за днём.
Чек-лист
- Снимайте цифры
measure.mjsна своей машине — не переносите чужие как свои. - Сравнивайте
compare.mjsв первую очередь с прошлым отчётом на том же железе; чужой baseline — только по форме роста, не по абсолютным цифрам. - Не измеряйте то, чего харнесс не измеряет:
tsc, бандлер, сеть — для них нужен отдельный application-level замер. - Явно помечайте пропущенные шаги (как транспиляция без
typescript) вместо того, чтобы либо подделывать число, либо тихо его прятать. - Для CI сравнивайте с прошлым прогоном на том же раннере и берите порог с запасом на шум, а не жёсткую константу.