Перейти к содержимому
6 мин чтения

CPU и NVMe offload для инференса без провала p99

CPU и NVMe offload для инференса: как сравнить перенос весов и KV-cache в RAM и SSD, измерить p99 и не сломать latency SLO.

CPU и NVMe offload для инференса без провала p99

Дефицит VRAM можно закрыть RAM и NVMe, но цена редко выглядит так, как ее рисуют в диаграммах памяти. Модель перестает падать с OOM, зато пользователь получает длинную паузу перед первым токеном или рывки внутри уже начавшегося ответа. Для чатового сервиса это хуже честного ограничения контекста: интерфейс выглядит исправным, а хвост задержки разваливает впечатление у самых дорогих запросов.

CPU и NVMe offload для инференса нужно рассматривать как обмен емкости на предсказуемость. RAM может быть рабочим уровнем при хорошем соединении GPU с CPU и строгом контроле конкуренции. NVMe годится как холодный уровень, когда состояние можно загрузить заранее или использовать повторно, но не как незаметное расширение HBM в цикле генерации.

Вес и KV-cache создают разные виды задержки

Перенос весов и перенос KV-cache решают разные OOM, поэтому один флаг не заменяет другой. Веса нужны на каждом проходе модели. KV-cache растет с длиной истории, числом активных последовательностей, количеством attention-слоев, числом KV-heads и точностью хранения.

Когда часть весов остается в RAM, GPU обращается к ним во время каждого forward pass. В актуальной конфигурации vLLM параметр --cpu-offload-gb использует UVA и pinned memory хоста. Документация прямо предупреждает, что этот режим требует быстрого межсоединения CPU-GPU, поскольку часть параметров используется на лету при каждом forward pass. Это прежде всего риск для TPOT и ITL: декодер вынужден ждать данные снова и снова.

KV-cache работает иначе. После prefill он содержит K и V для уже обработанных токенов. Если активная сессия потеряла нужные блоки из VRAM, их требуется вернуть, прежде чем продолжить внимание к истории. Пауза может проявиться как плохой TTFT после cache miss или как выброс ITL во время длинного ответа. В vLLM размер CPU-буфера KV задается отдельно через --kv-offloading-size; при tensor parallelism это суммарный объем по TP-рангам, а не объем на каждую карту. Ошибка в этом различии часто приводит к расчету, который обещал 64 GiB на четыре GPU, а выделил их на весь процесс.

Есть и третья сущность, которую регулярно смешивают с offload: сохраненный префикс. Prefix caching или host-memory prompt cache может убрать повторный prefill одинаковой системной инструкции. Это полезно, но не означает, что произвольная активная сессия безболезненно декодируется из RAM или SSD. У нее другие требования к своевременности данных.

NVMe не должен стоять в decode-контуре

NVMe можно использовать для хранения холодных KV-блоков и повторного использования префиксов, но синхронное чтение с SSD для каждого шага decode почти всегда превращает хвост задержки в лотерею. Даже быстрый локальный накопитель делит очереди с файловой системой, журналом, загрузкой моделей и соседними контейнерами. Виртуальный диск в облаке добавляет еще один слой, который не виден в характеристиках железа.

Проблема не в том, что SSD медленнее HBM. Это очевидно. Проблема в гранулярности: decode делает много коротких зависимых шагов. Один промах по холодному KV-блоку может задержать последовательность, а планировщик в этот момент продолжит обслуживать другие. Пользователь видит не медленный средний ответ, а длинную случайную паузу после уже выданного текста.

Для NVMe есть два разумных режима.

  • Хранить подготовленные KV-состояния длинных повторяющихся префиксов, когда их можно подгрузить до начала пользовательского ответа.
  • Держать холодный слой для сессий, которым допустимо восстановление с задержкой: пакетная обработка, асинхронный анализ документов, черновые отчеты.
  • Сохранять состояние между рестартами или между ролями prefill и decode, если транспорт и политика хранения это допускают.

Для интерактивного ассистента не надо обещать пользователю 128k контекста только потому, что NVMe вместит соответствующий объем. Обещание должно опираться на p99 TTFT и p99 ITL при реальной смеси запросов. Если продукт требует потокового текста без заметных пауз, SSD остается уровнем подготовки и восстановления, а не частью горячего цикла.

Сначала посчитайте, что именно не помещается

Нельзя настроить offload по одной цифре VRAM. Нужно разложить память на веса, постоянные рабочие буферы, графы и временные тензоры, KV-cache, запас от фрагментации и место для пиков batch. Вес модели дает лишь нижнюю границу.

Для грубой оценки KV-cache используйте выражение:

KV bytes ≈ tokens × layers × kv_heads × head_dim × 2 × bytes_per_element

Множитель 2 означает K и V. Для GQA число kv_heads меньше числа attention-heads, поэтому нельзя подставлять в формулу первое число из карточки модели. Для квантованного KV-cache вместо двух байт для FP16 берите фактический размер элемента с учетом scale и block metadata. Точная цифра зависит от движка и схемы квантования, но порядок ошибки важнее третьего знака после запятой.

Пример расчета, который полезно сделать до запуска: при 32 слоях, 8 KV-heads, head_dim=128 и FP16 один токен занимает примерно 128 KiB KV-cache. Контекст на 32 768 токенов потребует около 4 GiB на одну последовательность. Восемь таких одновременных сессий уже требуют примерно 32 GiB, не считая внутреннего выравнивания блоков и запаса планировщика. Если модель помещается, а сервис падает только при длинных чатах, перенос весов в CPU лечит не ту болезнь.

Проверяйте также, какой контекст действительно приходит в API. Команда часто тестирует 32k синтетических токенов, а в продакшене добавляет к ним большую системную инструкцию, историю вызовов инструментов, документы RAG и повторенную историю после неудачного retry. Счетчик токенов на входе нужно писать в логи вместе с длиной ответа и идентификатором маршрута модели.

p99 ломает не средняя скорость, а очередь копирований

Средний throughput не отвечает на вопрос, пригоден ли offload. Он может остаться приемлемым, пока несколько запросов ждут памяти хоста, DMA или освобождения блока KV-cache. При низкой нагрузке эти ожидания редки. При росте конкуренции они складываются.

Представьте сервис с четырьмя обычными чатами по 2k токенов и одним запросом на 48k токенов с генерацией на тысячу токенов. Пока длинная сессия помещается в GPU, она дорого стоит по памяти, но ведет себя предсказуемо. После вытеснения ее KV в RAM каждый возврат блока конкурирует за PCIe с обращениями к offloaded весам и с копиями для prefill новых запросов. Если тот же хост еще выгружает контейнерные логи на NVMe, появляется третья очередь. Средний TPOT может выглядеть прилично, а p99 ITL вырастет на порядок.

Этот сценарий нельзя поймать тестом из одного запроса. Нужны минимум четыре среза:

  • один запрос, чтобы увидеть чистую цену offload без очереди;
  • фиксированная конкуренция 2, 4, 8 и дальше до продуктового лимита;
  • короткий и длинный контекст при одинаковой длине генерации;
  • смешанный профиль, где длинные запросы составляют небольшую, но постоянную долю.

Отдельно ограничьте очередь перед движком. Бесконечная очередь маскирует перегрузку: вы получаете высокий throughput и p99, который включает ожидание неопределенной длины. Контролируемый отказ или понижение максимального контекста честнее, чем поток ответов, зависающий на десятки секунд.

Документация vLLM для bench serve разделяет --request-rate и --max-concurrency: первый задает поступление запросов, второй ограничивает число реально исполняемых запросов. Это именно то различие, которое нужно для теста перегрузки, а не только для красивого результата на --request-rate inf.

Тестируйте три уровня памяти одним и тем же профилем

Не смешивайте RAM и KV
Единый OpenAI-совместимый шлюз помогает вынести часть LLM-вызовов за пределы вашего контура offload.

Сравнение имеет смысл только тогда, когда все варианты получают одинаковые входы, длину вывода, настройки sampling, лимит конкуренции и прогрев. Не меняйте одновременно квантование весов, размер batch и offload. Иначе вы не узнаете, какой компромисс купил память, а какой испортил задержку.

Ниже минимальная матрица для одной модели и одной GPU-конфигурации. Она не дает универсальных цифр, но показывает характер деградации именно на вашем железе.

ВариантВесаKV-cacheЧто проверяет
AVRAMVRAMБазовая линия
BCPU RAMVRAMЦена offload весов в decode
CVRAMCPU RAMЦена вытеснения активного контекста
DVRAMRAM с холодным NVMe уровнемЦена miss и восстановления
ECPU RAMRAM с холодным NVMe уровнемХудший совместный режим

Для каждого варианта проведите прогоны на 2k, 8k, 32k и вашей верхней продуктовой длине контекста. Длина генерации должна быть фиксированной, например 256 или 512 токенов, а ignore_eos нужен только для синтетического теста, чтобы ранний EOS не испортил сопоставимость.

Пример формы запуска онлайн-теста:

vllm bench serve \
  --backend openai-chat \
  --base-url http://127.0.0.1:8000 \
  --endpoint /v1/chat/completions \
  --model local-model \
  --dataset-name random \
  --random-input-len 8192 \
  --random-output-len 256 \
  --num-prompts 160 \
  --request-rate 2.0 \
  --max-concurrency 8 \
  --ignore-eos \
  --percentile-metrics ttft,tpot,itl,e2el

Ожидаемый вывод должен содержать не только throughput, но и блоки P99 TTFT, P99 TPOT и P99 ITL. Такой формат выводит сам vllm bench serve. Сохраняйте сырые результаты JSON и версии драйвера, CUDA, движка, модели и tokenizer. После обновления vLLM сравнивать CSV без этой информации бесполезно.

Синтетика не заменяет трассы продакшена. После нее повторите тест на обезличенных распределениях длины запроса, реальных шаблонах tool calling и реальной доле повторяющихся префиксов. Но сначала нужна синтетическая матрица. Она быстро показывает, где находится излом.

RAM offload требует проверки PCIe и NUMA

CPU RAM не является единым ресурсом с одинаковой задержкой. На двухсокетном сервере GPU может быть привязана к одному NUMA-узлу, а процесс, pinned memory или NVMe прерывания окажутся на другом. Тогда путь данных пересекает межпроцессорную шину, и недостающие миллисекунды появляются только под нагрузкой.

Перед тестом зафиксируйте топологию:

nvidia-smi topo -m
numactl --hardware
lspci -tv

В выводе nvidia-smi topo -m вас интересует путь каждой GPU к CPU и соседним GPU. В выводе numactl --hardware проверьте, какой NUMA-узел владеет памятью, на которой будет жить процесс. Не делайте вывод о скорости по названию PCIe Gen5 в спецификации сервера: карта может стоять в слоте с меньшим числом линий или делить root complex с сетевой картой и накопителем.

Привяжите CPU affinity и memory policy процесса к узлу, близкому к GPU. Пример для проверки гипотезы, а не как вечная настройка:

numactl --cpunodebind=0 --membind=0 \
  vllm serve local-model --cpu-offload-gb 12

Сравните это с запуском на другом NUMA-узле при той же нагрузке. Если p99 заметно меняется, вы нашли физическую причину, а не загадку планировщика.

Pinned memory обычно нужна для быстрого обмена с GPU. NVIDIA пишет, что page-locked memory дает наивысшую пропускную способность передачи между хостом и устройством, но советует не закреплять память без необходимости. В инференсе это важно вдвойне: чрезмерный pinning отнимает обычную RAM у файлового кэша и у холодного NVMe уровня. Когда ОС начинает давить на память, выигрыш от offload быстро исчезает.

Не складывайте offload весов и KV без отдельного лимита

Сохраняйте следы каждого вызова
AI Router ведет аудит-логи и применяет rate-limits на уровне ключа для LLM API.

Совместить CPU offload весов и KV-cache можно, но это последний вариант, а не базовый режим. Оба потока используют RAM, пропускную способность CPU-GPU и внимание операционной системы к памяти. Когда один лимит задают «по остаточному принципу», система выглядит устойчивой до первой пачки длинных запросов.

Разделите бюджеты на бумаге и в конфигурации. Оставьте RAM для ОС, page cache, логов, tokenizer workers, сетевого стека и аварийного всплеска. Не отдавайте весь оставшийся объем pinned memory. CPU offload весов задавайте как небольшой дефицит до запуска модели, а не как возможность виртуально удвоить GPU. Для KV задавайте отдельный размер и отдельный лимит одновременных последовательностей.

В vLLM эти механизмы действительно разведены: --cpu-offload-gb относится к offload весов, а --kv-offloading-size включает offload KV-cache в CPU через выбранный backend. Это не гарантия независимой производительности. Это возможность не смешать две разные причины расхода памяти еще в конфигурации.

Есть популярная рекомендация: «раз уж RAM дешевая, задайте большой offload, а движок сам разберется». Она плоха для SLO. Планировщик может эффективно заполнить доступную память, но он не знает, какой пользовательский сценарий терпит редкие паузы в 500 мс, а какой нет. Лимит должен исходить из разрешенного p99, не из максимального объема DIMM.

Понижение точности KV часто лучше переноса KV

Когда узкое место именно в KV-cache, первым кандидатом должна быть меньшая точность кеша, если модель и движок дают приемлемое качество. Это уменьшает объем горячих данных и снижает вероятность вытеснения. В отличие от RAM offload, квантованный KV остается рядом с вычислением и не вводит дополнительную очередь DMA на каждом miss.

Проверять нужно не только общий benchmark качества. Возьмите задания, чувствительные к длинному контексту: поиск условия в договоре, сопоставление строк таблицы, многошаговый tool calling, ответы на вопрос по документу с отвлекающими фрагментами. Сравните правильность на FP16 KV и выбранной схеме. Если модель начала уверенно терять детали в дальнем контексте, экономия памяти оказалась ложной.

llama.cpp отдельно указывает поддержку квантования K-cache на CUDA и других backend, а его документация по производительности напоминает, что параметры CPU-потоков способны ограничить скорость даже при использовании GPU. Это полезное напоминание: уменьшение KV не освобождает от проверки CPU. Сжатый кеш может сэкономить VRAM, но prefill, копии и обслуживание запросов все равно используют хост.

Для многих сервисов разумный порядок такой: сократить ненужную историю, включить prefix caching для повторяемых префиксов, оценить KV quantization, ограничить конкуренцию длинных сессий, а затем уже переносить KV в RAM. Это не самый эффектный набор флагов, зато он обычно оставляет p99 контролируемым.

У NVMe должен быть холодный контракт

Дайте задачам отдельный путь
Через AI Router доступны модели OpenAI, Anthropic, Google, DeepSeek, xAI и других провайдеров.

Если вы все же добавляете NVMe, опишите его роль в системе как контракт. Какие данные попадают на диск? Когда они считаются холодными? Может ли запрос продолжить генерацию до их возврата? Кто чистит файлы? Что произойдет при заполнении диска или перезапуске процесса? Без ответов это не архитектура, а надежда на файловую систему.

Практичный контракт выглядит так: горячий KV активных интерактивных последовательностей живет только в VRAM; RAM хранит ограниченный резерв для недавно вытесненных блоков; NVMe держит только префиксы и состояния, которые можно вернуть до начала нового этапа работы; при miss интерактивный путь пересчитывает префикс или переводит задачу в асинхронный режим. Это может увеличить TTFT в редком случае, но не рвет текст в середине ответа.

Следите за тремя отдельными сигналами: латентностью устройства, глубиной очереди и процентом cache hit. Быстрый средний read не спасет систему, если редкие хвостовые чтения совпадают с peak prefill. Не кладите на тот же NVMe модельные файлы, журналы базы данных и cold KV без измерения смешанной нагрузки. Очередь у накопителя общая, даже если каталоги разные.

Для команд, которым важны локальное размещение данных и управляемые маршруты моделей, AI Router может быть внешним OpenAI-совместимым шлюзом, а сам hot path инференса стоит держать с явными лимитами контекста и очереди. Offload не отменяет требования к data residency, аудиту или маскированию PII: он лишь меняет, где физически лежат тензоры в конкретный момент.

Решение принимают по границе деградации

Удачный тест offload заканчивается не ответом «вариант D быстрее». Он заканчивается границей: при какой длине контекста и какой конкуренции p99 TTFT или p99 ITL перестает укладываться в SLO. Только после этого можно решить, что делать с продуктом.

Если CPU offload дает небольшой рост p99 при одном запросе, но резко ухудшается на конкуренции 8, его можно оставить для внутренних задач с очередью и отдельным пулом. Если RAM KV-offload держит p99 при 8k, но ломается на 32k, задайте отдельный лимит длинных сессий вместо общего max_model_len для всех. Если NVMe полезен только при повторном префиксе, оформите его как кэш префиксов и не называйте расширением VRAM.

Память не бывает бесплатной только потому, что она не на GPU. Дайте каждому уровню памяти свою работу, измерьте хвосты отдельно для prefill и decode, а затем откажитесь от конфигурации, которая помещает модель ценой непредсказуемого ответа.

Часто задаваемые вопросы

Можно ли использовать CPU offload весов в продакшене?

Да, если offload убирает только небольшой дефицит памяти и запросы не требуют жесткой задержки каждого следующего токена. Но CPU offload весов делает обращение к памяти хоста частью каждого forward pass, поэтому PCIe, NUMA и занятость CPU становятся частью критического пути.

Подходит ли NVMe для KV-cache во время генерации?

Обычно нет. NVMe хорош для холодного хранения KV-состояний, повторного использования префиксов и переживания рестарта процесса, но чтение с SSD в decode-цикле добавляет задержки, которые нельзя спрятать стабильной нагрузкой.

Чем отличаются TTFT, TPOT и ITL при offload?

TTFT измеряет время от приема запроса до первого токена и сильнее реагирует на prefill, очередь и холодные чтения. TPOT и ITL показывают паузы между последующими токенами, поэтому именно они первыми выдают проблемы от offload весов и вытеснения KV-cache.

Как проверить, что PCIe и NUMA не портят задержку?

Нужно измерить доступную пропускную способность и задержку именно между GPU и тем NUMA-узлом, где находятся CPU-память и NVMe. Паспортная скорость SSD и общий результат nvidia-smi этого не показывают.

Нужно ли включать offload сразу после OOM?

Не обязательно. Если хватает места, сначала проверьте KV-cache меньшей точности, ограничение максимального контекста, prefix caching и разделение prefill с decode. Offload имеет смысл, когда эти меры не дают нужной емкости или требуют недопустимо менять продуктовые ограничения.

При какой нагрузке offload сильнее всего бьет по p99?

Он опаснее всего при длинных запросах, высокой конкуренции и смеси коротких сессий с редкими очень длинными. В таком профиле одна тяжелая сессия может вытеснить горячий KV-cache, а затем создать серию копирований для многих других запросов.

Всегда ли pinned memory ускоряет CPU offload?

Нет. Pinning ускоряет DMA между GPU и RAM, но закрепленные страницы нельзя свободно вытеснять, и чрезмерный объем pinned memory оставляет меньше обычной RAM для ОС, файлового кэша и соседних процессов.

Что переносить первым, веса или KV-cache?

Начните с CPU offload весов, если вам не хватает памяти уже при загрузке модели. Начните с KV offload, если модель помещается, а OOM возникает при росте контекста или числа одновременных сессий.

Какие метрики важнее среднего throughput?

Смотрите на отдельные p50, p95 и p99 для TTFT и ITL, число ошибок, долю запросов с hit в кэш и фактическую конкуренцию. Средний throughput может расти, пока часть пользователей ждет неприемлемо долго.

Меняет ли offload требования к data residency?

Нет. Offload сам по себе не решает требования к размещению данных, журналированию и маскированию PII. Он меняет физическое место хранения тензоров, поэтому перед запуском нужно отдельно проверить путь данных, права на диск, шифрование и сроки хранения.