Демо нагрузки и реальная нагрузка различаются. Демо всегда быстрое — если поставить модель и прогнать пару запросов, то latency вас обрадует, а TTFT, вероятнее всего, будет приятным. Но когда сервис неделю живет под реальной нагрузкой, начинаются проблемы: p99 растет, память утекает, а однажды прилетает OOM на запросе, который вчера проходил без проблем.

Я Стас Погоржельский, технологический евангелист VK Cloud, неоднократно наблюдал этот сценарий в демонстрационных средах. Инференс редко выходит из строя сразу и явно; как правило, его работа постепенно ухудшается, оставаясь незаметной до достижения критического состояния. 

Ниже я разберу четыре механизма, приводящие к деградации производственной среды на длительном интервале: фрагментацию KV-кэша, нехватку памяти (OOM) при работе с длинным контекстом, блокировку очереди (head-of-line blocking) внутри батча и расхождение метрик p50 и p99. Для каждого случая опишу внутреннее поведение системы, способы воспроизведения проблемы и метрики, сигнализирующие о надвигающемся инциденте. 

Скрипты воспроизведения (нагрузка, снятие метрик, пороги алертов) собраны в репозитории-харнесе. Чтобы получить актуальные числа на своем железе, запустите скрипты у себя, числа в статье сняты со стенда или взяты из публичных источников.

Мы уже публиковали в блоге статью о дезагрегированном инференсе в Kubernetes, но в ней мы обсуждали вопросы размещения и разделения этапов префилла и декодирования на уровне отдельных сервисов. В этом материале акцент смещен на то, какие проблемы возникают в процессе эксплуатации, а не на архитектурные подходы к развертыванию. 

Память как узкое место инференса

Самое главное, что стоит принять, — продовый инференс упирается в память сильнее, чем в вычисления:

  • Префилл, то есть обработка промпта, грузит вычислительные ядра. 

  • Декодирование, когда модель выдает токены по одному, все время читает и пишет память и упирается именно в ее пропускную способность. 

На длинной дистанции деградация возникает как раз из-за декодирования вместе с растущим KV-cache, поэтому дальше мы будем говорить именно о памяти. В свежей работе UC Berkeley так и сказано: по мере роста контекста основной источник трафика памяти смещается с весов модели на KV-cache. Интеграторы фиксируют это даже жестче: KV-cache в проде часто начинает превышать по объему сами веса модели.

Перейдем от абстракций к конкретным цифрам. Модель на 13B параметров на A100 40GB занимает примерно 26 Гб под веса. Остается около 14 Гб, и это все, что есть под KV-cache. При расходе порядка 1 Мб состояния на токен получается примерно 14 тысяч токенов на всю карту. То есть при длине последовательности 512 в батч влезает около 28 запросов, а при 2048 уже только 7 (Anyscale). 

Дальше все сложнее. У старых моделей с обычным multi-head attention KV-cache для 70B и контекста 8K доходит до ≈20 Гб на запрос, а батч на 32 запроса — это порядка 640 Гб (introl.com). Но у современных 70B (Llama-3) 8 KV-голов вместо 64, и та же память падает примерно в 8 раз: ≈2,6 Гб на запрос при 8K и около 10 Гб при 32K (Symphony). Считайте под свою архитектуру, разница на порядок.

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

Схема 1. Как делится память GPU: веса против растущего KV-cache
Схема 1. Как делится память GPU: веса против растущего KV-cache

Фрагментация KV-cache: откуда берутся 60–80% потерь

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

Представьте запрос, который генерирует 100 токенов, а max_len выставлен в 2048. Значит, около 1948 слотов из 2048 зарезервированы и простаивают, это примерно 95% впустую (разбор PagedAttention). Умножьте на все активные последовательности, и картина становится грустной. Авторы vLLM замерили это на реальных нагрузках: существующие на тот момент системы теряли 60-80% памяти KV-cache на фрагментацию и избыточное резервирование (blog.vllm.ai).

К счастью, для решения этой проблемы существует PagedAttention. Память под KV-cache выделяется не одним куском, а блоками по требованию, по аналогии с виртуальной памятью и таблицей страниц в ОС. Логические позиции токенов сопоставляются физическим блокам через block table. За счет этого потери на фрагментацию падают до менее чем 4%, а throughput растет в 2–4 раза при том же уровне латентности по сравнению с FasterTransformer и Orca (PagedAttention, SOSP 2023), а против HuggingFace Transformers прирост доходит до 24-кратного (blog.vllm.ai).

Может показаться, что на этом вопрос с памятью закрыт. Однако на практике paged attention скорее смещает проблему, чем полностью ее устраняет. Со временем проявляется внешняя фрагментация пулов блоков и начинает сказываться работа механизма вытеснения (eviction).

Например, в TensorRT-LLM KV-cache организован иерархически: блок выступает минимальной единицей аллокации, пул — это буфер памяти (primary на GPU и secondary с выгрузкой на CPU), а отдельный менеджер отслеживает состояния блоков (Created, Updated, Removed, Stored). При постоянных аллокациях, вытеснениях и повторном использовании блоков фактическая доступная емкость под новые запросы начинает заметно колебаться.

Именно в этой динамике занятости пула часто скрывается первый источник деградации: код остается неизменным, но поведение системы со временем ухудшается.

У vLLM есть метрика занятости кэша, ее и надо писать во времени под растущим числом уникальных префиксов. Скрипт kv_usage_probe.py из харнеса делает ровно это:

# kv_usage_probe.py - снятие занятости KV-cache во времени
from vllm import LLM
import time, csv

llm = LLM(model="meta-llama/Llama-2-13b-hf", gpu_memory_utilization=0.9)

with open("kv_usage.csv", "w", newline="") as f:
    w = csv.writer(f); w.writerow(["ts", "gpu_cache_usage", "free_blocks"])
    for  in range(3600):  # час наблюдения под нагрузкой
        m = llm.llmengine.stat_logger.stats  # gpu_cache_usage_sys, num_free_gpu_blocks
        w.writerow([time.time(), m.gpu_cache_usage_sys, m.num_free_gpu_blocks])
        time.sleep(1)

Если при стабильном RPS занятость кэша ползет вверх и не откатывается, это ранний сигнал. До OOM еще есть время

OOM на длинном контексте: как воспроизвести и поймать заранее

OOM в инференсе почти никогда не происходит мгновенно. Память, как правило, исчерпывается не из‑за одного крупного запроса, а в результате постепенного накопления. Это особенно заметно в multi-turn сценариях, где контекст увеличивается с каждой репликой.

В исследовании Symphony на реальном multi-turn датасете показано, что при использовании подхода с пересчетом (recompute) более 99% обрабатываемых токенов оказываются избыточными, а KV-cache для Llama-3.1-70B при контексте 32K требует порядка 10 Гб (Symphony, дек. 2024). Каждый дополнительный шаг диалога добавляет новые гигабайты, которые необходимо удерживать в памяти. Пока число активных диалогов невелико, система работает стабильно, но при росте нагрузки и накоплении длинных контекстов память может исчерпаться скачкообразно.

Ниже приведен порядок величин для потребления памяти KV-cache (это публичные оценки, точные значения стоит измерять отдельно, например с помощью harness-скрипта oom_probe.py):

Сценарий

Память под KV-cache

Допущение

Источник

13B, seq 512, батч ~28

≈14 Гб на A100 40GB

MHA

Anyscale

70B, контекст 8K, 1 запрос

≈20 Гб

MHA (64 головы)

introl.com, 2025

70B, контекст 8K, 1 запрос

≈2,6 Гб

GQA (8 KV-голов)

расчет по Symphony

70B, контекст 32K, 1 запрос

≈10 Гб

GQA

Symphony, 2024

Строки с 8K специально даны в двух вариантах: одна и та же модель с MHA и с GQA различается по памяти примерно в 8 раз. Отсюда и разброс оценок в статьях, и ответ на вопрос, почему батч на 32 запроса это либо ~640 Гб (MHA), либо ~84 Гб (GQA). Прежде чем планировать железо, посмотрите, сколько KV-голов у вашей модели.

Квантизация KV-cache: FP8 дает примерно двукратную экономию памяти против FP16 при минимальной потере качества, экспериментальный INT4 до четырехкратной (introl.com). Плюс переиспользование общих префиксов: Automatic Prefix Caching в vLLM на хорошо структурированных промптах дает cache hit rate от 87% (introl.com), а это прямая экономия и памяти, и времени префилла.

Воспроизвести OOM полезно заранее, в контролируемых условиях. Скрипт oom_probe.py постепенно раздувает контекст и пишет момент срыва:

# oom_probe.py - довести стенд до OOM в контролируемых условиях
ctx = 2048
while True:
    prompt = build_prompt(tokens=ctx)
    try:
        client.generate(prompt, max_tokens=256)
    except RuntimeError:                       # CUDA OOM
        log(f"OOM at ctx={ctx}, peak_kv_gb={peak_kv()}, active_seqs={active()}")
        break
    log(ctx, peak_kv(), active())
    ctx += 1024

Порядок деградации на таком прогоне выглядит примерно так (13B на A100 40GB, батч 16):

Контекст

Пик KV, FP16

Пик KV, FP8

Итог

4K

≈11 Гб

≈6 Гб

стабильно

8K

≈13 Гб

≈7 Гб

стабильно, запас тает

12K

на грани

≈10 Гб

FP16 срывается в OOM

16K

OOM

≈13 Гб

FP8 еще держит

Числа усредненные и иллюстративные, это порядок величины, не точный замер конкретного стенда. Конфигурация, версия vLLM и профиль трафика заметно сдвинут границы. Но и так заметно, что FP8-квантизация KV-cache отодвигает срыв примерно вдвое по контексту.

Мониторить до инцидента надо не факт OOM, а тренд пиковой занятости KV в связке с числом активных последовательностей. Если пик ползет вверх при неизменном профиле трафика, значит скоро что-то пойдет не так.

Батчинг под конкуренцией: continuous batching и head-of-line blocking

Continuous batching сделал продовый инференс экономически оправданным. Вместо ожидания завершения всего батча планировщик на каждой итерации динамически добавляет и убирает последовательности. Получается эффект до 23-кратного по throughput с учетом оптимизаций памяти в vLLM, около 8-кратного за счет самого механизма continuous batching в Ray Serve и TGI и до 4-кратного благодаря оптимизациям модели во FasterTransformer (Anyscale).

Однако у этого подхода есть ограничение, которое проявляется при смешанной нагрузке. Пока запросы имеют сопоставимую длину, батч обрабатывается стабильно. Но появление одного тяжелого запроса с длинным префиллом может замедлить обработку остальных — возникает классический head-of-line blocking.

Схема 2. Head-of-line blocking: длинный префилл держит очередь коротких
Схема 2. Head-of-line blocking: длинный префилл держит очередь коротких

Показательный пример из практики (кейс анонимизирован и приведен как иллюстрация механики, а не конкретной компании): сервис код‑ревью при нагрузке около 200 запросов в секунду имел p50 на уровне 1.2 секунды и p99 — 47 секунд. Причина оказалась в запросах с диффами более 8000 строк, где префилл занимал свыше 45 секунд. Пока обрабатывался такой запрос, короткие — обычно укладывающиеся в ≈1,5 секунды — накапливались в очереди (разбор tail latency).

Частично проблему решает chunked prefill: длинный префилл разбивается на части и чередуется с декодированием в рамках одного батча, благодаря чему короткие запросы не блокируются. В vLLM V1 этот режим включен по умолчанию, и планировщик отдает приоритет декодированию, добавляя префилл порциями (vLLM docs). Это позволяет заметно снизить head-of-line blocking.

Важно учитывать, откуда возникает проблема. В прежнем режиме (с отключенным chunked prefill) планировщик, наоборот, приоритизировал префилл и не смешивал его с декодированием. Это улучшало TTFT, но ухудшало inter-token latency и утилизацию GPU. Размер окна батчинга задается параметром max_num_batched_tokens: для увеличения throughput его обычно повышают (в актуальных версиях ориентир — от 8192, ранее рекомендовали существенно меньшие значения). Значения по умолчанию — это всегда компромисс и не обязательно оптимальный для конкретной нагрузки.

# vllm_config.yaml - включаем chunked prefill, окно подбираем под свой профиль
enable_chunked_prefill: true
max_num_batched_tokens: 8192   # выше = throughput, ниже = отзывчивость; мерить харнесом

Отдельный аспект — так называемое «колено» батча. По наблюдениям практиков, насыщение вычислительных ресурсов обычно наступает на уровне 20–30 запросов в батче (tianpan.co). После этой точки добавление новых запросов почти не увеличивает throughput, но заметно ухудшает латентность каждого отдельного запроса. Поэтому важнее определить это колено на собственной инфраструктуре и реальном профиле трафика, чем ориентироваться на чужие бенчмарки. Например, скрипт batch_sweep.py из harness позволяет прогнать батчи от 1 до N и построить зависимость throughput от латентности.

Здесь же возникает типичная ошибка — выбор движка по одному агрегированному показателю. В ряде сравнений vLLM за счет PagedAttention показывает до 24-кратного более высокий throughput, чем HuggingFace TGI, при высокой конкурентности. Однако в интерактивных сценариях с умеренной нагрузкой TGI может демонстрировать более низкую хвостовую латентность (сравнение серверов, нояб. 2025). Понятие «быстрее» напрямую зависит от целевой метрики: максимальная пропускная способность под нагрузкой или минимальное время отклика для отдельного пользователя. Ориентация на бенчмарки, полученные в чужом режиме, часто приводит к ухудшению характеристик в реальной системе.

Отдельно стоит учитывать переиспользование префиксов. SGLang применяет RadixAttention для повторного использования KV-cache общих частей промптов между запросами (бенчмарк SGLang vs vLLM, июль 2026), тогда как в vLLM аналогичная задача решается через Automatic Prefix Caching. При наличии большого числа запросов с общей «шапкой» (системный промпт, инструкции, few-shot примеры) это дает экономию памяти KV-cache и ускоряет префилл. Если же запросы сильно различаются, эффект минимален, а накладные расходы на поддержку кэша сохраняются. Практический подход остается прежним: сначала измерения на собственном трафике, затем включение оптимизаций.

Разъезд p50/p99: почему хвост живет своей жизнью

Разрыв между p50 и p99 в LLM-инференсе структурный. В разборе tail latency приводится пример, где p50 равен 800 мс, а p99 равен 28 секундам, то есть разрыв в 35 раз (tianpan.co).

Его причина — в самой природе генерации. Длина ответа сильно варьируется, до 2-4-кратного по сравнению с медианой, а стоимость запроса при этом отличается на порядки: короткий промпт с коротким ответом дешевле длинного с длинной генерацией в сотню раз. Смешайте это с общей очередью и общей памятью, и хвост распределения начинает жить своей жизнью, независимо от медианы.

Публичные ориентиры по хвосту:

Метрика

Значение

Источник

p50 / p99 (пример разрыва)

800 мс / 28 с (×35)

tianpan.co, 2026

Разброс длины ответа

2–4 от медианы

tianpan.co, 2026

Эффект tail-aware scheduling

−35–50% p99 TTLT, −34–47% TTFT

ICML 2026

Хорошая новость в том, что с хвостом научились работать на уровне планировщика. Работа с ICML 2026 показывает, что tail-aware scheduling снижает p99 time-to-last-token на 35–50% и TTFT на 34–47% на реальных и открытых трейсах (ICML 2026). Это не бесплатно и не всегда применимо, но направление рабочее.

Что снимать и на что ставить алерты:

# latency_percentiles.py - считаем хвост, а не среднее
import numpy as np
for name, arr in [("TTFT", ttft), ("ITL", itl)]:
    p50, p95, p99 = np.percentile(arr, [50, 95, 99])
    print(f"{name}: p50={p50:.3f} p95={p95:.3f} p99={p99:.3f}")
# алерт не на p50, а на рост отношения p99/p50 и на глубину очереди

Как хвост уезжает по мере роста RPS (13B, chunked prefill on, смешанная длина запросов):

RPS

TTFT p50

TTFT p99

ITL p99

Комментарий

5

~0.3 с

~0.9 с

~40 мс

запас есть

15

~0.5 с

~2.5 с

~90 мс

подходим к колену

25

~0.8 с

~9 с

~0.4 с

колено батча, хвост рвется

35

~1.2 с

~28 с

~1.7 с

деградация, очередь копится

Суть в том, что p50 растет линейно и спокойно, а p99 после колена улетает нелинейно.

Вот набор метрик, который реально предсказывает деградацию до инцидента: p99 inter-token latency, глубина очереди, gpu_cache_usage, счетчик вытеснений и preemption. А средний latency промолчит ровно до того момента, когда станет поздно.

vGPU и мультитенантность: где деградация усиливается

Все описанное выше обостряется, когда GPU не ваш целиком, а разделяемый. При виртуализированном GPU и мультитенантной нагрузке несколько потребителей делят и память, и очередь планировщика. Head-of-line blocking из соседнего тенанта становится вашей проблемой, а конкуренция за KV-cache сдвигает «колено» батча раньше, чем вы ожидали по одиночным замерам.

Публичных цифр именно по деградации инференса на vGPU у меня нет, поэтому этот раздел концептуальный, без вымышленных замеров. Практический вывод: в мультитенантной среде нужны лимиты KV-cache и квоты на потребителя, иначе один сосед с длинными контекстами утащит за собой всех. В облаке VK Cloud с vGPU изоляция и квоты управляются на уровне платформы, но саму механику деградации стоит понимать независимо от того, где вы разворачиваете инференс.

Заключение и чек-лист метрик

Соблазн включить все модное сразу вредит. Тот же speculative decoding в демо выглядит отлично, до 2–3-кратного ускорения при малых батчах (SPIRe), а свежие адаптивные варианты добавляют еще 15–20% (DSD, нояб. 2025). Но в проде он ведет себя непредсказуемо. В диссертации UC Berkeley это сформулировано так:

«Спекулятивное декодирование демонстрирует нестабильную и крайне изменчивую производительность в реальных системах»
X. Liu, PhD thesis, UC Berkeley, 2025

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

Деградация, как правило, проявляется заранее через косвенные сигналы. При построении мониторинга инференса разумно ориентироваться именно на такие ранние индикаторы, а не на уже произошедшие сбои:

  • Рост gpu_cache_usage при стабильном RPS указывает на приближение OOM.

  • Ускоренный рост p99 inter-token latency относительно p50 сигнализирует о деградации хвоста и наличии тяжелых запросов.

  • Увеличение глубины очереди и счетчика preemption говорит о перегрузке планировщика.

  • Рост длины «хвоста» ответов означает накопление длинных генераций, которые впоследствии приводят к head-of-line blocking.

Paged attention и continuous batching действительно повысили эффективность инференса, но не устранили саму проблему деградации — она сместилась из резких отказов по памяти в постепенное ухудшение характеристик памяти и латентности во времени. На практике точечные оптимизации работы с памятью и корректный мониторинг хвостовых метрик оказываются более устойчивыми мерами, чем простое наращивание ресурсов.

Harness со скриптами (kv_usage_probe.py, oom_probe.py, batch_sweep.py, latency_percentiles.py) и конфигурациями доступен в репозитории к статье. Его можно запустить на собственной инфраструктуре — ведь различия в результатах представляют практический интерес для сравнения на разных типах оборудования.

Комментарии (0)