Трендовые github проекты в нашем телеграм канале. Подпишись → Сколько запросов реально влезает в одну GPU
Когда поднимаешь локальную LLM на своей карте или арендуешь GPU на час, первый вопрос звучит просто: влезет ли модель и сколько параллельных запросов она вытянет. Онлайн-калькуляторы VRAM отвечают на него быстро и почти всегда неточно. Причина в двух местах, где арифметика «веса плюс KV-кэш меньше объёма карты» расходится с тем, как работают современные inference-движки.
Движок резервирует память заранее
Интуиция подсказывает, что движок положит в память веса модели, а остаток будет раздавать под KV-кэш по мере поступления запросов. Так не делает ни один из распространённых движков.
vLLM при старте резервирует крупный непрерывный кусок VRAM под пул и уже внутри него страницами размещает KV-кэш. Долей управляет флаг gpu_memory_utilization, по умолчанию 0.92. В SGLang тот же механизм называется mem_fraction_static (авто, около 0.9), в TensorRT-LLM — kv_cache_free_gpu_mem_fraction. Веса грузятся внутрь этого куска, KV-кэш занимает то, что осталось после весов и служебных расходов. Свободные проценты VRAM за пределами пула под кэш не пойдут никогда.
Отсюда ёмкость KV-пула считается так:
KV-пул = util · VRAM − веса − overhead
Механизм подробно описан в работе про PagedAttention (arXiv:2309.06180): пул выделяется одним куском, чтобы избежать фрагментации и раздавать страницы кэша под запросы дёшево.
Проверить расчёт можно без аренды карты. vLLM при старте печатает строку вида # GPU blocks: N — это и есть фактический размер KV-пула в блоках. Если заранее посчитать ёмкость по формуле выше и перевести в блоки, число должно совпасть с логом старта. На Llama-3-8B, 1×A100, контекст 8192 наивная оценка без учёта util обещает около 60 одновременных запросов, поправка на util даёт 54, живой замер — 55.
Геометрия KV-кэша: GQA, MLA, MoE
Второе место, где калькуляторы промахиваются, — размер KV-кэша на один токен. Он зависит от архитектуры внимания, и в уме его посчитать сложно.
GQA (grouped-query attention). Классический случай, байты на токен:
2 · n_kv · head_dim · L · p
где n_kv — число KV-голов, head_dim — размерность головы, L — число слоёв, p — байт на элемент (2 для fp16).
MLA (multi-head latent attention). У DeepSeek внимание сжато в латентное представление, и формула другая:
(d_c + d_rope) · L · p
где d_c — размерность сжатого латента, d_rope — RoPE-часть.
Разница огромная. Для DeepSeek-V2-Lite из его config.json:
- MLA, корректно:
(512 + 64) · 27 · 2 = 31 104байт на токен; - наивная GQA-формула с
head_dim = 128:221 184байт на токен — завышение в 7,1 раза; - та же формула с
head_dim = 192:331 776байт на токен — завышение в 10,7 раза.
Калькулятор, который трактует MLA как обычное внимание, завышает KV-кэш в 7–11 раз в зависимости от того, какой head_dim он подставит. Промах уводит в сторону: кажется, что модель требует почти на порядок больше карт, и от неё отказываются или берут стойку вместо одной GPU. Сжатие KV в MLA задокументировано в статье про DeepSeek-V2 (arXiv:2405.04434).
MoE. У DeepSeek-V2-Lite общий объём весов — 16B параметров, активных на токен — 2B. Память платится за все 16B, скорость декодинга считается по 2B. Эти две цифры легко перепутать при планировании.
Как считать перед арендой GPU
Порядок действий, который даёт оценку в пределах нескольких процентов от факта:
- Скачать с HuggingFace только
config.jsonи индексsafetensors— это пара килобайт, GPU не нужен. - По конфигу определить архитектуру внимания: GQA, MLA или гибрид, а также MoE.
- Посчитать байты на токен по формуле под конкретную архитектуру.
- Оценить overhead: активации плюс CUDA-графы. Это эмпирическая величина, коэффициент обычно в диапазоне 1,5–2,2 от базовой оценки активаций; её приходится калибровать по логам конкретного движка.
- Подставить всё в
KV-пул = util · VRAM − веса − overheadи поделить на размер KV-кэша одного запроса при нужной длине контекста.
Квантование весов и KV-кэша задаётся раздельно. Флаг --kv-cache-dtype fp8 вдвое увеличивает ёмкость пула по числу токенов. Комбинация fp4-весов с fp16-кэшем встречается чаще, чем осознанно выбирается, — стоит проверять оба dtype отдельно.
Чего такой расчёт не даёт
- Скорость. TTFT и утилизацию вычислителя (MFU) чисто из конфига получить нельзя, нужен замер на железе. Пропускная способность памяти при декоде (MBU) меряется стабильно, ей верить можно.
- Смешанная нагрузка. Запросы разной длины с ограничением
--max-num-seqsсчитаются хуже, чем однородный батч; здесь оценка ёмкости будет грубее. - Несколько GPU. При тензорном и конвейерном параллелизме активации шардятся между картами, а CUDA-графы остаются на каждой карте целиком.
- Спекулятивный декодинг. Он переводит decode из режима memory-bound в compute-bound и сдвигает точку баланса, из-за чего профиль потребления памяти меняется.
- Нелинейный контекст. Mamba/SSM, sliding-window и гибриды ломают предположение, что KV-кэш растёт линейно по длине контекста.
Практический чек-лист
- Не полагаться на «веса + KV < VRAM»: движок заберёт под пул фиксированную долю (
gpu_memory_utilizationи аналоги) при старте. - Считать ёмкость через
util · VRAM − веса − overhead, а не через полный объём карты. - Определять архитектуру внимания перед расчётом KV: MLA даёт кратно меньший кэш, чем подсказывает GQA-формула.
- Для MoE держать в голове две разные цифры — параметры общие и активные.
- Сверять расчёт со строкой
# GPU blocksиз лога старта vLLM: если числа расходятся, ошибка в оценке весов или overhead. - Ловили OOM там, где по расчёту всё влезало? Почти всегда это недоучтённый overhead или неверно определённая архитектура внимания.