- Дата публикации
ИИ‑агент, который сначала читает, а потом пишет код: как +15% скорости для llama.cpp изменили правила игры
Что открыли
Исследователи проверили простую идею: если дать код‑агенту не только репозиторий проекта, но и доступ к статьям, форкам и другим бэкендам, он находит оптимизации, которые не видит, работая только по коду.
Они добавили фазу «исследования литературы» в цикл автономного код‑агента и направили его на CPU‑инференс в llama.cpp для модели TinyLlama 1.1B с квантованием Q4_0.
Результат за один прогон:
- агент провёл 30+ экспериментов;
- из них 5 оптимизаций попали в итоговый код;
- 4 из 5 — это слияние (fusion) ядер и проходов по памяти;
- итоговый выигрыш для flash attention:
- на x86 (AWS c6i.2xlarge, AVX‑512): +15,1% к скорости генерации текста (tg), +1,2% к скорости обработки промпта (pp);
- на ARM (AWS c7g.2xlarge, NEON): +5% к tg, +1,9% к pp.
Для базового пути без flash attention три более «мелкие» оптимизации дали:
- +2,5% к скорости обработки промпта (pp);
- +0,9% к скорости генерации текста (tg).
Все улучшения проверили на TinyLlama 1.1B Q4_0 с помощью llama-bench:
- параметры:
-p 512 -n 128 -t 8 -r 5; - метрики: токены в секунду для prompt processing (pp) и text generation (tg).
Общий бюджет эксперимента:
- ~3 часа работы;
- 4 облачных VM;
- ~$20 на CPU‑инстансы;
- ~$9 на API‑вызовы;
- всего ~$29.
Главный вывод: код‑агент, который сначала читает статьи, форки и другие бэкенды, а уже потом пишет патчи, генерирует более глубокие гипотезы и находит реальные ускорения там, где «агент по коду» застревает в микровыигрышах на уровне шума.
Как исследовали
От Karpathy к pi‑autoresearch
Исходная идея цикла «автоисследования» пришла из работы Андрея Карпати:
- агент получает train.py и метрику качества (например,
val_bpb); - сам предлагает изменения гиперпараметров и кода;
- запускает эксперименты;
- отбрасывает проигравшие варианты.
В одном из предыдущих запусков на 16 GPU агент провёл ~910 экспериментов за 8 часов и снизил val_bpb на 2,87%.
Дальше появился pi-autoresearch — переиспользуемый цикл для любого проекта, где есть бенчмарк и тесты. Shopify CEO Тоби Лютке запустил его на Liquid — Ruby‑шаблонизаторе, через который проходит $292 млрд годового товарооборота Shopify.
Результат Liquid‑прогона:
- ~120 экспериментов;
- 93 коммита;
- время parse+render сократилось на 53%;
- количество аллокаций — на 61%;
- 974 юнит‑теста прошли без регрессий.
Там агенту хватило контекста исходников: он увидел, что узкое место — StringScanner, и перебрал варианты внутри кода.
Где «агент по коду» ломается
С llama.cpp ситуация другая. Здесь узкое место не в одном классе, а в архитектуре вычислений и работе с памятью. Вопросы, которые нужно задать:
- модель ограничена вычислениями или памятью?
- нужно ли сливать несколько проходов по памяти в один?
- что уже сделали в ik_llama.cpp, llamafile, CUDA/Metal‑бэкендах?
Когда агент смотрел только на код, он пошёл по очевидному пути — микродооптимизация SIMD в горячих циклах GGML‑матриц. Примеры первых попыток:
- предвыборка (prefetch) в внутреннем цикле dot‑product для Q4_0: +0,8%;
- разворачивание цикла в 2 раза с двумя аккумуляторами: +0,9%;
- удаление временного буфера в
mul_mat: −2,8% (регрессия); - вынос расчёта границ блоков: +0,6%.
Все результаты — на уровне шума. В постмортеме агент сам сформулировал причину:
«Микрооптимизации в вычислительном пути почти не помогают, потому что генерация текста упирается в пропускную способность памяти, а не в вычисления».
Модель 606 MiB при ~49 токенах/с выкачивает ~30 ГБ/с из памяти — почти потолок DRAM для c6i.2xlarge. Когда CPU ждёт веса из DRAM, дополнительные SIMD‑трюки не спасают. Но из кода это неочевидно — нужны знания про roofline‑модель, типичные режимы batch‑size‑1 и характеристики железа.
Добавляем фазу ресёрча
Чтобы поднять качество гипотез, авторы добавили фазу исследования до первых правок кода:
- поиск по arXiv и другим источникам;
- анализ форков (ik_llama.cpp, llamafile и др.);
- сравнение CPU‑пути с CUDA/Metal/Vulkan/OpenCL‑бэкендами.
Базовый цикл autoresearch выглядит так:
- изменить код;
- запустить эксперимент;
- измерить метрику;
- принять или откатить.
pi-autoresearch обобщил это на любой проект с бенчмарком и тестами. В новой версии поверх этого добавили:
- фазу ресёрча между «волнами» экспериментов;
- параллельный запуск экспериментов через SkyPilot на нескольких VM.
Схема работы:
- агент сам пишет
autoresearch.sh(бенчмарк) иautoresearch.checks.sh(проверка корректности); - SkyPilot разворачивает по VM на эксперимент;
- на каждой VM:
- собирается проект;
- запускается бенчмарк;
- прогоняются проверки;
- лог с метриками возвращается агенту;
- агент читает
sky logs, коммитит победителей, планирует новую волну.
Пример шаблона задачи SkyPilot (из experiment.yaml):
experiment.yaml : SkyPilot task template for one experiment
resources :
cpus : 4
memory : 8
workdir : .
envs :
EXPERIMENT_ID : baseline
EXPERIMENT_DESC : "baseline measurement"
BUILD_CMD : "make -j$(nproc)"
BENCH_TIMEOUT : 300
CHECK_TIMEOUT : 300
setup : |
cd ~/sky_workdir
if [ -f setup_deps.sh ]; then
bash setup_deps.sh
else
eval "${BUILD_CMD}"
fi
run : |
cd ~/sky_workdir
# Build, benchmark, run checks, report METRIC lines
eval "${BUILD_CMD}" 2>&1 | tail -30
BENCH_OUTPUT=$(timeout "${BENCH_TIMEOUT}" bash autoresearch.sh 2>&1)
echo "$BENCH_OUTPUT"
# ... extract METRIC lines, run autoresearch.checks.sh ...
echo "EXPERIMENT_STATUS: done"
Для оптимизации CPU‑кода GPU не нужны — при желании их можно явно запросить через --gpus.
Конкретный прогон на llama.cpp
Что именно сделали:
- агент Claude Code получил репозиторий llama.cpp;
- через SkyPilot ему выдали 4 VM на AWS;
- цель: ускорить CPU‑инференс TinyLlama 1.1B Q4_0.
Аппаратные конфигурации:
- x86:
c6i.2xlarge(Intel Xeon Ice Lake, 8 vCPU, AVX‑512); - ARM:
c7g.2xlarge(Graviton3, 8 vCPU, NEON).
Метрика:
llama-bench -p 512 -n 128 -t 8 -r 5;- измеряют pp (prompt processing) и tg (text generation) в токенах/с.
Сначала агент использовал 4 x86‑VM, чтобы снять базу и провести первые эксперименты. Потом добавил ARM‑VM, чтобы убедиться, что фьюжены работают и на NEON, и на AVX2/FMA, с безопасными скалярными запасными путями.
Что нашёл ресёрч
Агент запустил две параллельные ветки исследования:
- статьи и материалы про операторные фьюжены и flash attention;
- анализ форков и других бэкендов llama.cpp.
Ключевые находки:
- ik_llama.cpp использует row‑interleaved репэкинг квантованных весов — это даёт 2,9× ускорение prompt processing. В llama.cpp это уже есть в виде формата Q4_0_8x8, и агент подтвердил, что он включён в бенчмарке.
- статья Blockbuster предлагает сливать весь FFN‑блок (RMSNorm + gate matmul + up matmul + SwiGLU + down matmul) в один кэш‑резидентный проход. Агент попытался реализовать идею, но упёрся в ограничение: веса в формате Q4_0_8x8, а
ggml_concatне умеет работать с репакнутыми квантованными тензорами. Для корректной реализации нужны изменения на уровне загрузчика модели, а не только графа. - агент проверил, используется ли AVX‑512 на c6i.2xlarge. Оказалось, да: флаг
-march=nativeвключает нужные макросы компилятора, даже еслиGGML_AVX512=OFFв CMake (эта переменная влияет только на сборку под MSVC). - PR #19139 в llama.cpp сливает веса gate+up для MoE‑моделей и даёт +12% к prompt processing, но для плотных моделей такой же трюк ещё не сделан.
Самой полезной оказалась не arXiv‑поисковая часть, а разбор форков и других бэкендов. Например, именно сравнение CPU‑пути с CUDA/Metal привело к идее фьюжена RMS_NORM + MUL на CPU.
После провала первой волны агент явно сменил стратегию:
«Нужно переключиться на оптимизации, которые уменьшают трафик памяти или улучшают паттерны доступа».
Матмулы занимают ~95% времени инференса. Остаётся ~5% на «мелочь» — softmax, RMSNorm, квантование. Но именно там операции достаточно маленькие, чтобы быть вычислительно ограниченными, а не памятью. Слияние проходов по памяти в этих местах даёт реальный выигрыш.
Что это меняет на практике
Пять оптимизаций, которые реально поехали
Из 30+ экспериментов в основной ветке осталось 5. Каждый отвечает за свой кусок «нематмульного» оверхеда.
1. Фьюжен softmax
Исходный код делал три прохода по данным:
- копировал буфер;
- масштабировал;
- добавлял маску.
До:
// Before: 3 passes
memcpy ( wp , sp , nc * sizeof ( float )); // pass 1: copy
ggml_vec_scale_f32 ( nc , wp , scale ); // pass 2: scale
ggml_vec_add_f32 ( nc , wp , wp , mp_f32 ); // pass 3: add mask
После фьюжена — один проход:
// After: 1 pass
for ( int i = 0 ; i < nc ; i ++ ) {
wp [ i ] = sp [ i ] * scale + mp_f32 [ i ];
}
2. Фьюжен RMSNorm
Похожий приём для RMS‑нормализации. Было два шага:
memcpy(y, x);ggml_vec_scale_f32(y, scale).
Теперь это один проход по массиву: y[i] = x[i] * scale.
3. Адаптивная параллелизация from_float
Функция from_float квантует активации в формат, который дальше идёт в dot‑product. Раньше стратегия распараллеливания была одинаковой для всех случаев.
Теперь агент разделил режимы:
- при большом числе строк (prompt processing) — параллелизация по строкам;
- при малом числе строк (text generation) — по элементам.
Чистое A/B‑сравнение на одной VM (без flash attention, чтобы изолировать эффект трёх первых изменений):
| | pp (токенов/с) | tg (токенов/с) | |-------------|---------------------|---------------------| | Базовый | 210.65 ± 0.64 | 48.90 ± 0.50 | | Оптимизированный | 215.97 ± 1.52 | 49.33 ± 0.37 | | Изменение | +2,5% | +0,9% |
Как и ожидалось, генерация почти не изменилась — она упирается в память и не затрагивает матмулы. Prompt processing, который ближе к compute‑bound, получил +2,5% за счёт меньшего числа проходов по данным.
4. Графовый фьюжен RMS_NORM + MUL на CPU
Эта оптимизация появилась напрямую из ресёрча по другим бэкендам. Агент заметил:
«Фьюжен RMS norm + MUL есть в CUDA, но его нет в CPU‑бэкенде».
Стандартный CPU‑путь делал:
- RMSNorm: читаем
x, считаем норму, пишемy = x * scale; - MUL: читаем
y, читаемweights, пишемy = y * weights.
CUDA и Metal уже давно делают это в одном проходе: y = x * scale * weights.
Агент добавил в CPU‑исполнитель графа распознавание паттерна «RMS_NORM, за которым сразу идёт MUL от его вывода» и вызов нового фьюжен‑ядра. В AVX2‑ветке оно выглядит так:
// Fused RMS norm + multiply (AVX2 path)
__m256 vscale = _mm256_set1_ps ( scale );
for (; i + 7 < ne ; i += 8 ) {
__m256 vx = _mm256_loadu_ps ( x + i );
__m256 vw = _mm256_loadu_ps ( w + i );
_mm256_storeu_ps ( y + i ,
_mm256_mul_ps ( _mm256_mul_ps ( vx , vw ), vscale ));
}
Первая версия была скалярной и не дала выигрыша: компилятор уже хорошо векторизовал цепочку memcpy + ggml_vec_scale_f32 + binary_op<op_mul>. После переписывания на явные AVX2/NEON‑интринсики эффект стал заметен в сочетании с другими фьюженами и снизил разброс времени за счёт более предсказуемых паттернов доступа к памяти.
Важно: изначальная реализация графового фьюжена имела баг — не проверяла, что вывод RMSNorm не используется где‑то ещё в графе. Агент сам же поймал эту ошибку на «код‑ревью» и переключился на готовую инфраструктуру ggml_can_fuse(), которая уже есть во всех GPU‑бэкендах.
5. Фьюжен KQ в flash attention
В пути tiled flash attention над плиткой QK выполнялись три независимых прохода:
- масштабирование (
scale); - добавление маски;
- поиск максимума.
До фьюжена:
// Before: 3 passes over KQ tile
ggml_vec_scale_f32 ( M , kq , scale ); // pass 1
ggml_vec_add_f32 ( M , kq , kq , mask_row ); // pass 2
ggml_vec_max_f32 ( M , & max , kq ); // pass 3
После — один проход с AVX2 FMA:
// After: 1 AVX2 FMA pass
__m256 vscale = _mm256_set1_ps ( scale );
__m256 vmax = _mm256_set1_ps ( - INFINITY );
for ( int i = 0 ; i < M ; i += 8 ) {
__m256 v = _mm256_fmadd_ps (
_mm256_loadu_ps ( & kq [ i ]),
vscale ,
_mm256_loadu_ps ( & mask_row [ i ]));
_mm256_storeu_ps ( & kq [ i ], v );
vmax = _mm256_max_ps ( vmax , v );
}
Критично: эти фьюжены работают внутри уже существующего пути flash attention (-fa 1) в llama.cpp. Агент не «придумал» flash attention, он оптимизировал его реализацию. Поэтому для честного сравнения нужно включать -fa 1 и в базе, и в оптимизированной сборке.
Итоговые цифры по flash attention
Чистое A/B‑сравнение (5 повторов, обе сборки с flash attention):
x86, Intel Xeon (c6i.2xlarge, AVX‑512)
| Конфигурация | pp512 (т/с) | tg128 (т/с) | |--------------------|----------------------|----------------------| | База + FA | 241.24 ± 2.24 | 41.37 ± 19.24 | | Оптимизировано + FA| 244.22 ± 1.78 | 47.62 ± 0.59 | | Изменение | +1,2% | +15,1% |
ARM, Graviton3 (c7g.2xlarge, NEON)
| Конфигурация | pp512 (т/с) | tg128 (т/с) | |--------------------|----------------------|----------------------| | База + FA | 292.99 ± 2.47 | 94.07 ± 19.87 | | Оптимизировано + FA| 298.56 ± 4.28 | 98.77 ± 2.59 | | Изменение | +1,9% | +5% |
Ускорение генерации заметно больше, чем prompt processing: во время генерации внимание занимает большую долю общего времени. Ещё один эффект — резкое падение разброса значений tg на x86: с ±19 т/с до ±0,59 т/с. Фьюжены убирают лишние записи в память и делают поведение кэша более стабильным.
Исследователи подчёркивают: тесты шли на shared‑тенанси EC2, где «шумные соседи» могут давать до 30% разброса между прогонами. Они многократно перемеряли базу и ориентируются на направление эффекта, а не на точность до десятых долей процента.
Что не взлетело
25+ экспериментов не попали в итоговый diff. Несколько показательных примеров:
- SIMD‑softmax с отложенным горизонтальным суммированием: идея — копить суммы в
__m256и один раз схлопывать в конце. Выигрыш 0%: компилятор и так векторизует скалярный цикл достаточно хорошо. - Тюнинг размеров тайлов в flash attention: варианты
Q=32/KV=128,Q=128/KV=32,Q=32/KV=32. Базовый64×64оказался уже оптимальным. - Фьюжен gate+up через
ggml_concat: попытка склеить матрицы gate и up на этапе построения графа, чтобы уменьшить число загрузок активаций. Крэш:ggml_concatне поддерживает репакнутые квантованные тензорыQ4_0_8x8. Нужно менять загрузчик модели, а не граф. - Prefetch для V во время вычисления softmax по QK: 0% — железный префетчер и так хорошо работает с последовательным доступом.
- Удаление «лишних» загрузок в
sgemmllamafile: функция загрузки Q4_0 делалаdenibble + subtract 8, и внутренний цикл вызывал её три раза для одного блока. Агент закешировал результат. Эффект 0% — компилятор уже провёл common subexpression elimination.
Повторяющаяся тема: без опыта работы с компиляторами и железом агенту трудно предсказать, какие «ручные оптимизации» уже сделал компилятор.
Ошибки и шум
Были и чисто инженерные проблемы:
- В
autoresearch.shнашли баг в парсинге JSON‑выводаllama-bench: скрипт фильтровал по несуществующему полю и репортил 14 т/с вместо 52 т/с для генерации. Несколько экспериментов успели «опираться» на неправильную базу. - На shared‑VM разброс доходил до 30% между запусками. Одна из VM постоянно показывала высокий шум. Пришлось:
- перезапускать особенно шумные инстансы;
- использовать стандартное отклонение как сигнал качества;
- доверять только результатам со
stddev < 2%от среднего.
Что это значит для вас
Если вы запускаете LLM на CPU
- llama.cpp — один из главных инструментов для локального запуска LLM на CPU. Ускорение на +15% tg для flash attention на x86 и +5% на ARM — это либо больше токенов в секунду на тех же машинах, либо меньше машин при той же нагрузке.
- Фьюжены, которые сделал агент, не завязаны на TinyLlama как таковую. Они оптимизируют общие участки пути инференса: softmax, RMSNorm, quantization, flash attention. Любая модель в llama.cpp, использующая эти же коды путей и flash attention, потенциально выигрывает.
- ARM‑результаты показывают, что оптимизации не заточены только под AVX‑512. На Graviton3 с NEON прирост тоже есть.
Пока авторы не отправили pull request в основной репозиторий, но diff уже готов. Если вы собираете llama.cpp из исходников, можно интегрировать эти патчи и протестировать на своём железе.
Если вы разрабатываете под open‑source‑стек
Главная практическая идея — менять входной контекст для код‑агента.
Когда агент видит только кодовую базу, он склонен к гипотезам вида «сделать этот цикл быстрее». Это работает там, где всё узкое место — внутри одного файла или модуля (пример Liquid с StringScanner).
Когда производительность зависит от архитектуры вычислений, памяти и решений из других проектов, нужно:
- явно давать агенту задачи «прочитать статьи и форки»;
- указывать, какие бэкенды или проекты считать «эталонными конкурентами»;
- заставлять его сравнивать паттерны: «есть ли этот фьюжен в CUDA/Metal, но нет в CPU?».
В этом запуске именно такой подход привёл к оптимизациям #4 и #5, которые не лежали на поверхности исходников CPU‑пути.
Если вы строите свои код‑агенты
Сравнение двух режимов:
| | GPU Autoresearch (Karpathy) | Literature‑Guided Autoresearch | |----------------------|-----------------------------|---------------------------------| | Цель | Обучение нейросети | Любой OSS‑проект | | Железо | GPU‑кластеры (H100/H200) | CPU‑VM (дешевле) | | Стратегия поиска | Агент думает по коду | Агент читает статьи + профилирует | | Экспериментов | ~910 за 8 часов | 30+ за ~3 часа | | Время на эксперимент | ~5 минут (обучение) | ~5 минут (сборка + бенчмарк) | | Стоимость | ~$300 (GPU) | ~$20 (CPU) + ~$9 (API) |
CPU‑оптимизации дороже по времени на один эксперимент (CMake‑сборка + бенчмарк), но дешевле в долларах. Агент проводит меньше экспериментов, зато тратит часть времени между волнами на чтение статей и кодов конкурентов.
Если вы хотите повторить этот подход в своём проекте, вам нужны:
- репозиторий с чётким бенчмарком и тестами;
- код‑агент уровня Claude Code или GPT‑4/5 с доступом к интернету (или хотя бы к локальной базе статей и форков);
- оркестратор вроде SkyPilot, чтобы параллелить эксперименты по VM;
- явное требование к агенту: сначала ресёрч, потом патчи.
Как запустить свой цикл автоисследований
Ниже — упрощённая схема, основанная на описанном подходе. Конкретные скрипты у авторов завязаны на их инфраструктуру, но логика та же.
1. Напишите бенчмарк и проверки
Файл autoresearch.sh должен:
- собирать проект (если нужно);
- запускать бенчмарк (например,
llama-benchили ваш тест); - печатать метрики в удобном для парсинга формате (JSON или строки с
METRIC:).
Файл autoresearch.checks.sh:
- запускает юнит‑тесты и sanity‑чек;
- возвращает ненулевой код выхода при регрессиях.
2. Настройте шаблон задачи для SkyPilot
Пример уже был выше, но ещё раз ключевые поля:
resources :
cpus : 4
memory : 8
workdir : .
envs :
EXPERIMENT_ID : baseline
EXPERIMENT_DESC : "baseline measurement"
BUILD_CMD : "make -j$(nproc)"
BENCH_TIMEOUT : 300
CHECK_TIMEOUT : 300
setup : |
cd ~/sky_workdir
if [ -f setup_deps.sh ]; then
bash setup_deps.sh
else
eval "${BUILD_CMD}"
fi
run : |
cd ~/sky_workdir
eval "${BUILD_CMD}" 2>&1 | tail -30
BENCH_OUTPUT=$(timeout "${BENCH_TIMEOUT}" bash autoresearch.sh 2>&1)
echo "$BENCH_OUTPUT"
# ... extract METRIC lines, run autoresearch.checks.sh ...
echo "EXPERIMENT_STATUS: done"
3. Дайте агенту права на ресёрч
В промпте к код‑агенту явно сформулируйте задачи:
- прочитать статьи по вашей теме (операторные фьюжены, квантование, attention и т.д.);
- изучить популярные форки вашего проекта;
- сравнить разные бэкенды (CPU vs CUDA vs Metal и т.п.);
- выписать список гипотез до первых изменений кода.
После каждой волны экспериментов просите его:
- анализировать профилирование и результаты;
- корректировать гипотезы;
- отбрасывать направления, которые упёрлись в шум.
4. Следите за шумом и багами
- измеряйте стандартное отклонение метрик;
- перезапускайте особенно шумные VM;
- регулярно проверяйте корректность скриптов парсинга (как показал пример с 14 т/с вместо 52 т/с).
Исследование показывает простой, но важный сдвиг: для сложных задач производительности недостаточно дать ИИ‑агенту один только код. Нужны статьи, форки, чужие бэкенды и доменные знания. Тогда он начинает задавать те же вопросы, что и сильный системный инженер — и приносит ускорения, которые реально чувствуются на продакшн‑нагрузке.