В конце 2025 года я наткнулся на забавный бенчмарк: один стартап выкатил собственный inference engine для CPU, обещая чуть ли не 2x ускорение по сравнению с llama.cpp. Через месяц они опубликовали результаты — и там llama.cpp рвал их кастомное решение в 7.3 раза на MoE-модели DeepSeek-V2-Lite (16 experts, top-2). Скандал? Нет, закономерность.
В этой статье я на пальцах (и на циферках) разберу, почему так происходит, и как не наступить на те же грабли, если вдруг решите написать свой движок. Поехали.
Архитектура MoE: красивая идея, жестокий выхлоп для CPU
MoE (Mixture of Experts) — это когда у вас есть набор маленьких FFN-слоёв (экспертов) и роутер, который выбирает 2-4 самых подходящих для каждого токена. Каждый эксперт — плотный слой, но их суммарный объём параметров огромен. Для CPU это превращается в адский memory-bound workload: нужно загрузить матрицы весов из DRAM в кэш, и если они не помещаются в L3 — привет, bottleneck.
Возьмём DeepSeek-V2-Lite: 16 экспертов по 1.1B параметров каждый, total ~17.6B. В FP32 это ~70 ГБ — даже не помещается в оперативу 32 ГБ. Поэтому единственный разумный путь — квантизация. GGUF (в llama.cpp) использует смешанную прецизию: веса в Q4_K_M (4.5 бита на параметр), активации в FP16. В сумме ~10 ГБ — влезает в DDR5. Но и это не спасает от проблем пропускной способности.
Ключевая метрика для CPU inference: не FLOPS, а доля загрузки памяти. GPU делает 100 TFLOPS, CPU — 1-2 TFLOPS, но память DDR5 выдаёт ~50-60 ГБ/с. Если модель не помещается в L3 (~20-50 МБ), каждый токен требует полной перезагрузки весов из DRAM. В MoE с разреженным доступом — ситуация хуже: каждый запрос загружает разные эксперты, и кэш не помогает.
Бенчмарк: llama.cpp vs кастомный движок (назвать не буду)
Давайте на цифрах. Тестовый стенд: AMD Ryzen 9 9950X (16 ядер, DDR5-6000), модель DeepSeek-V2-Lite Q4_K_M (GGUF), промпт длиной 512 токенов. Измеряем t/s (токенов в секунду) на генерации (batch=1).
| Движок | t/s (генерация) | IPC | Memory throughput (ГБ/с) | Примечания |
|---|---|---|---|---|
| llama.cpp (b3963) | 11.2 | 1.6 | 51 | Почти утилизация 85% от теоретической пропускной способности DDR5 |
| Custom Engine (v0.3) | 1.54 | 0.35 | 7.2 | Барьер памяти: каждый эксперт загружается отдельным memcpy, без переиспользования |
Разрыв в 7.3 раза, как и обещалось. Но давайте копнём глубже. IPC (инструкций за такт) у llama.cpp = 1.6, что для таких задач круто (обычно на memory-bound workload IPC < 1). У кастомного движка — 0.35, то есть процессор простаивает 65% времени, ожидая данных из памяти. Почему?
Ответ — в layout весов и prefetching. llama.cpp хранит веса в GGUF в блочном формате: группа строк эксперта пакуется в один кэш-лайн (64 байта). При загрузке весов для вычисления одного элемента attention/FFN, CPU может заранее prefetch соседние данные, которые будут использованы следующим токеном. В кастомном движке веса идут простыми матрицами в row-major, и каждое чтение вызывает cache miss.
Ещё одна деталь — kernel fusion. llama.cpp объединяет умножение на веса, bias, SiLU/GeLU в один проход по памяти. Кастомный движок делает отдельные вызовы cuBLAS (или Eigen), что порождает лишние обходы DRAM.
Важный нюанс: если сравнивать на GPU, кастомные CUDA-ядра могут быть быстрее (смотрите статью про кастомные CUDA ядра для обучения LLM). Но на CPU выигрыш llama.cpp — результат десятилетнего опыта автора в оптимизации под x86 и ARM.
1 Ошибка №1: игнорирование quantization granularity
Кастомные движки часто делают Q4_0 (4-bit per row). llama.cpp использует Q4_K_M, где блок квантования — 32 строки, а не целый ряд. Это даёт меньшую погрешность, но главное — позволяет SIMD-векторизовать загрузку и дезактивацию. На MoE, где каждый эксперт мал, разница особенно заметна.
2 Ошибка №2: раздельный доступ к экспертам через роутер
В кастомных реализациях часто сначала вычисляют роутер, потом выбирают экспертов и загружают их веса заново. llama.cpp же делает гейтинг параллельно с вычислением общего слоя, переиспользуя уже загруженные в кэш веса предыдущего слоя. Это даёт прирост в ~20% за счёт устранения дополнительных промахов кэша.
3 Ошибка №3: многопоточность без учёта NUMA
Автор llama.cpp разбивает эксперты по CCX (комплексам ядер) AMD, чтобы каждый поток работал со своим набором L3-кэша. Кастомные движки часто используют OpenMP с дефолтным расписанием — это убивает производительность на двухсокетных системах (см. статью про сборку llama.cpp).
Теперь к интересному: как вообще можно догнать llama.cpp, если писать свой движок? Ответ: почти никак, если не потратить годы. Но есть архитектурные решения, которые дают хотя бы 50% от производительности llama.cpp. Например, использовать готовую библиотеку ggml (ядро llama.cpp) как backend, а поверх писать свою логику роутинга и кэша. Это честный путь — как, например, делают в нашем туториале по написанию движка на C.
Но есть и альтернативный подход: взять модель DeepSeek-V2-Lite и переписать её под PowerInfer с sparse activation — это для очень слабых CPU, где нужно выживать (обзор в статье про PowerInfer).
Так в чём же корень разрыва 7.3x? Я бы сказал, что это комбинация трёх факторов:
- LLaMA.cpp использует кэш-осведомлённый layout (format GGUF) и prefetching.
- Кастомные движки делают лишние обходы памяти из-за раздельного вычисления экспертов.
- LLaMA.cpp использует AVX-512 и VNNI (на Zen 4/5) для быстрых 4-битных умножений, в то время как многие кастомные решения пишутся на платформонезависимом коде.
Возможно, в 2026 году ситуация изменится: появились методы запуска 120B моделей на DDR5 с помощью offloading, да и процессоры с HBM (Sapphire Rapids?) подешевели. Но пока что, если вы пишете свой движок для MoE на CPU — имейте в виду: вы соревнуетесь не с ребятами из Meta, а с пакетом семилетнего опыта обратной связи от тысяч бенчмарк-раннеров. Совет: используйте llama.cpp как baseline, а все «улучшения» сначала проверяйте на профилировщиках (perf, VTune) — иначе рискуете получить 7.3x в обратную сторону.
Кстати, подобное сравнение качества уже было в контексте Nemotron 3 Super: разное качество в llama.cpp и vLLM — там расходятся ещё и результаты из-за precision. Но это уже совсем другая история.