Бенчмарк — это инструмент, а не витрина
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 по каждому инструменту и коэффициент батчинга из продакшн-запусков.