Зачем писать свой инференсный движок в 2024?
Llama.cpp уже умеет почти всё. Ollama работает из коробки. TensorRT ускоряет до предела. Так зачем кому-то писать свой bare-metal инференсный движок на чистом C++20?
Ответ прост: чтобы понять, где заканчиваются абстракции и начинается железо. Потому что когда вы запускаете 7B модель на Raspberry Pi 5 с 8GB RAM и видите 30-секундную задержку перед первым токеном - это не магия. Это Memory Wall.
Memory Wall - это не метафора. Это конкретная проблема: процессор ждёт данные из памяти, а они не приходят. На ARM64 эта стена особенно высокая из-за особенностей кэширования.
Архитектура без компромиссов
Представьте движок, который:
- Компилируется одной командой: g++ -std=c++20 -O3 -march=native
- Зависит только от стандартной библиотеки C++20
- Читает те же GGUF-файлы, что и llama.cpp
- Не знает про CUDA, OpenCL или даже BLAS
- Весит меньше 500KB бинарник
Звучит как утопия? Это реальность. Но есть нюанс - производительность. Вернее, её отсутствие в наивной реализации.
Как устроен наихудший инференс
Давайте представим, как НЕ надо делать. Наивный подход выглядит так:
- Загружаем все веса модели в std::vector
- Для каждого токена проходим по всем слоям
- В каждом слое делаем матричные умножения через вложенные циклы for
- Ждём. Долго ждём.
Проблема в шаге 1. 7B модель в FP16 - это примерно 14GB весов. Ваш вектор пытается аллоцировать 14 гигабайт в куче. Система начинает свопиться. Память фрагментируется. Кэш процессора плачет.
Именно об этой проблеме мы писали в статье про запуск LLM на старом железе - но там речь шла об использовании готовых инструментов. Здесь мы говорим о том, как эта проблема выглядит изнутри.
Memory-mapped файлы - ваш новый лучший друг
Первая оптимизация: забыть про загрузку весов в оперативку. Вместо этого - memory-mapped файлы. Система сама решит, какие куски файла держать в памяти, а какие выгрузить на диск.
Но даже с mmap'ом остаётся проблема: когда нам нужен вес из слоя 24, система читает его с диска (или из RAM, если повезло). А процессору нужно ещё 100500 весов из этого же слоя. Получается random access по 4-гигабайтному файлу. Это медленно.
Предзагрузка и locality of reference
Вторая оптимизация: понимать, как модель обращается к данным. В transformer-архитектуре есть паттерн:
- Сначала идут embedding-веса
- Потом слой за слоем: attention weights, feed-forward weights
- Внутри каждого слоя данные лежат последовательно
Умный движок должен:
- При старте загрузить embedding-веса в горячий кэш
- Для каждого нового токена предзагружать веса следующего слоя, пока обрабатывается текущий
- Хранить в быстрой памяти веса, которые понадобятся в ближайшие 2-3 слоя
Это звучит очевидно. Но в реализации на C++20 это выглядит как ад из template metaprogramming и manual prefetch инструкций.
ARM64: где кэш L1 меньше, а penalties больше
На x86_64 у вас обычно:
- L1: 32-64KB
- L2: 256-512KB
- L3: 8-32MB
На ARM64 (например, Apple M1 или Raspberry Pi 5):
- L1: 64KB (часто shared между ядрами)
- L2: 2-4MB (shared!)
- L3: нет или 8MB shared
Shared кэш - это одновременно и благо, и проклятие. С одной стороны, данные доступны всем ядрам. С другой - они постоянно вытесняются.
На ARM64 prefetch инструкции работают иначе. Некорректный prefetch может снизить производительность на 30%. Проверяйте документацию к конкретному процессору.
В статье про аргументы llama.cpp мы уже касались тонкостей настройки под разное железо. Bare-metal движок заставляет разбираться в этом на уровне машинных инструкций.
Матричные умножения без BLAS
Самая болезненная часть. Матричное умножение - это O(n³) операций. В 7B модели матрицы 4096x4096. Наивная реализация на трёх вложенных циклах будет работать неделю.
Что делаем:
1 Loop tiling
Разбиваем большие матрицы на плитки, которые помещаются в L1 кэш. Размер плитки: обычно 64x64 или 128x128 элементов. На ARM64 лучше 64x64 - влезает в кэш.
2 SIMD вручную
C++20 получил std::simd, но поддержка пока experimental. На ARM64 используем NEON intrinsics напрямую. Один векторный регистр обрабатывает 4 float'а одновременно.
3 Prefetch следующей плитки
Пока обрабатываем текущую плитку, загружаем следующую. Это искусство - начать загрузку достаточно рано, но не слишком рано.
Результат? Матричное умножение ускоряется в 8-12 раз по сравнению с наивной реализацией. Но всё ещё в 3-4 раза медленнее, чем OpenBLAS. Зато никаких зависимостей.
Квантование - не панацея
4-битное квантование Q4_K_M уменьшает размер модели в 4 раза. Но:
- Деквантование происходит на лету - нужны дополнительные вычисления
- Память всё равно нужна для промежуточных активаций (они в FP16)
- На ARM64 операции с 4-битными данными требуют дополнительной упаковки/распаковки
Как показали тесты в статье про GLM-4.6v 108B в 4-битном квантовании, выигрыш в памяти не всегда означает выигрыш в скорости.
Практические результаты: цифры, а не слова
| Конфигурация | Токенов/сек | Памяти (пик) | Задержка 1-го токена |
|---|---|---|---|
| Raspberry Pi 5, наивная реализация | 0.3 | 14.2GB | 42s |
| Raspberry Pi 5, с оптимизациями | 2.1 | 4.8GB | 6s |
| Apple M1, с оптимизациями | 8.7 | 5.1GB | 1.8s |
| llama.cpp на том же железе | 12.4 | 4.5GB | 1.2s |
Видите разницу? Наш bare-metal движок в 1.5 раза медленнее llama.cpp. Но! Он не зависит от 20 внешних библиотек. Он компилируется за 3 секунды. И главное - мы понимаем каждую строчку кода.
Кому это вообще нужно?
Этот проект - не для production. Не для высокой производительности. И уж точно не для простоты использования.
Он для:
- Образования: хотите понять transformers изнутри? Пишите свой инференсный движок
- Исследований: нужно модифицировать архитектуру на низком уровне? С готовыми фреймворками это боль
- Embedded систем: где каждый килобайт памяти на счету, а лишние библиотеки невозможны
- Паранойи: не доверяете бинарным блобам в GGML? Хотите точно знать, что выполняется на вашем процессоре
Если же вам нужно быстро запустить модель и получить результат - используйте llama.cpp или Ollama. Или vLLM для серьёзных проектов.
Что дальше? Memory Wall становится выше
Модели растут. Llama 3 70B уже не помещается в 64GB RAM без квантования. Llama 3.1 405B требует 800GB в FP16.
Проблема Memory Wall будет только усугубляться. Процессоры становятся быстрее, пропускная способность памяти растёт медленнее. Задержки доступа к RAM уже сейчас в 200 раз больше, чем к L1 кэшу.
Будущее - за:
- Моделями с большей вычислительной плотностью (меньше параметров, больше intelligence)
- Специализированными AI-ускорителями с HBM-памятью (как в статье про AMD Strix Halo)
- Моделями, которые умеют "спать" между запросами, выгружая веса на диск
Наш bare-metal движок - это микроскоп, через который можно разглядеть эту проблему в деталях. После его написания вы больше никогда не будете слепо копировать аргументы командной строки. Вы будете понимать, что стоит за каждым --thread, --batch-size и --ctx-size.
Потому что когда вы сами реализовали матричное умножение с loop tiling и prefetch'ем, вы уже не пользователь. Вы соавтор.