Бенчмарк — это инструмент, а не витрина

LLM решает, что делать; инструменты выполняют работу. Между ними находится ToolExecutor — классификация параллельно-безопасное/последовательное, детекция конфликтов путей, каскадная отмена и serde-механика вокруг каждого ToolCall. Этот слой работает на каждом шаге каждого агента, поэтому его характеристики напрямую определяют, сколько длится исследовательский запуск.

Именно поэтому стенд встроен в продукт. Фикстуры автоматически создаются во временном каталоге и затем удаляются; каждый сценарий повторяется 5–10 раз с прогревочным вызовом, а цифры ниже — это один запуск на macOS, 10 ядрах, в release-сборке. Ни сети, ни LLM — сценарий памяти использует офлайн TF-IDF эмбеддер, поэтому весь набор можно запускать в CI.

31× из-за кэша regex

Первая версия extract_symbols компилировала два регулярных выражения (regex::Regex::new) на каждый файл. Для code_symbols с его лимитом это было терпимо — цикл останавливается после первых ~25 файлов. Для repo_map, который честно обходит всё дерево, это было катастрофой: компиляция DFA доминировала в рантайме.

Исправление перенесло regex в статики OnceLock (компилируются один раз на процесс) и распараллелило чтение файлов в repo_map через tokio::spawn + join_all, сохранив порядок. A/B-прогон по 240 синтетическим Rust-файлам:

ВерсияРеальное время
До (regex на файл + последовательные чтения)902.0 ms
После (кэш OnceLock + распараллеленные чтения)29.5 ms

Это улучшение в 31× на debug-сборке; в release то же дерево обрабатывается за 6.6 ms. Живая контрольная точка: до исправления repo_map по репозиторию этого самого проекта (103 файла) занимал 106 ms в release. После исправления — единицы миллисекунд.

Тихая сериализация

Вторая находка была тоньше. ToolExecutor делит инструменты на параллельно-безопасные и последовательные; инструмент, которого нет в классификации, «на всякий случай» уходит в последовательные. Когда появились web_crawl, web_feed, code_symbols и repo_map, никто не добавил их в классификацию — и любой батч из нескольких таких вызовов незаметно выполнялся по одному, без ошибок и предупреждений.

Стенд показал это в цифрах: spawn-батч из 8 × web_feed дал 1.00× — ровно то, что даёт последовательное выполнение, — тогда как parse_html в тех же условиях дал ~3×. После добавления инструментов в parallel_safe (и теста классификации, чтобы они там остались):

БатчSpawn, доSpawn, после
8 × web_feed~1.00×1.25×
8 × code_symbols~1.00×3.04×
Каждый новый read-only инструмент должен поставляться с тестом классификации — иначе параллелизм бесследно исчезает.— Fathom, заметки о бенчмарках

Остальная часть табло

Тот же прогон измеряет механику вокруг инструментов. Сериализация аргументов ToolCall стоит ~750 ns; накладные расходы экзекутора поверх обычной диспетчеризации реестра — ~2.5 ms для батча из одного вызова и амортизируются до 753 µs на вызов в батче из 8 вызовов. Слой выполнения исчезает на фоне работы инструментов.

Для CPU-нагруженных батчей разница между режимами выполнения драматична: 8 × parse_html таблицы в ~1 MB работает в 3.78× быстрее под execute_batch_spawn (задачи tokio распределяются по ядрам), чем последовательно, тогда как join_all — который опрашивает фьючерсы на одном потоке — помогает только при ожидании I/O. Пропускная способность HTML-селекторов пиковая ~531k строк/с на малых документах (~350k строк/с на больших), а quick-xml RSS-парсер держит ~1.1M элементов/с.

Реалистичный смешанный ход — четыре чтения, три записи и один grep в одном батче — разбивается автоматически: пять вызовов выполняются конкурентно, три сериализуются, а результирующий вектор по-прежнему совпадает с исходным порядком вызовов. Общее реальное время: 70.9 ms.

Запускайте в CI

Всё вышеперечисленное воспроизводится одной командой — fathom bench --scenario all — с фикстурами, которые создаются и очищаются сами. Сценарии: dispatch, parallel-io, parallel-cpu, mixed, parse-scale, extract-json, feed-parse, code-map и memory. Поскольку ничто не касается сети и ничто не вызывает LLM, набор выполняется в CI на каждой сборке — именно так ловится следующая тихая регрессия.

А поскольку синтетические цифры — лишь половина истории, fathom stats читает SQLite-трассировку реальной сессии и сообщает длительности p50/p95 по каждому инструменту и коэффициент батчинга из продакшн-запусков.