Что такое Continuous Batching
Традиционный static batching собирает запросы в batch фиксированного размера и ждёт, пока все запросы в batch завершатся, прежде чем принять новые. Проблема в том, что запросы завершаются в разное время: один генерует 50 токенов, другой — 500. Пока самый длинный запрос не закончится, GPU простаивает на хвосте: короткие запросы уже готовы, но их результат «ждёт» длинный.
Continuous batching (иначе iteration-level или in-flight batching) убирает эту блокировку. Scheduler добавляет новые запросы в batch на каждой итерации decode: как только запрос завершается и освобождает место в KV-cache, на его место сразу ставится следующий из очереди. GPU не простаивает на хвостах.
Почему это даёт 2–10x
Разница — в простоях. Напрямую посчитайте: batch из 8 запросов, где 7 завершаются на 200-м токене, а один тянется до 2000. Static batching держит GPU занятыми 2000 итераций, но полезную работу после 200-й итерации выполняет только 1 запрос из 8 — остальное время память и вычисления «на воздух». Effective throughput падает примерно в 8 раз против теоретического.
Continuous batching убирает этот хвост: 7 запросов уходят на 200-й токене, их место тут же занимает очередь, и GPU продолжает работать на полную. На смешанной нагрузке (разная длина промптов и генераций) выигрыш обычно 2–4x, на сильно гетерогенной — до 10x.
Как работает scheduler vLLM
vLLM держит два пула ресурсов, и scheduler на каждой итерации решает, что положить в следующий step:
- Завершённые: запросы, достигшие
max_tokensили EOS, освобождают свои KV-блоки (см. PagedAttention). - Бюджет: освобождённые токены и свободные блоки идут в доступный бюджет итерации.
- Новые: scheduler берёт из очереди запросы, чей prefill + текущий decode вписываются в бюджет.
- Повтор: шаг повторяется на каждом decode-токене.
Ключевое отличие от static: решение принимается на каждом токене, а не на каждом batch. Это и есть «continuous».
Prefill и decode — две разные фазы
Важно разделять две фазы, потому что они по-разному нагружают GPU:
- Prefill — обработка входного промпта целиком. Вычислительно тяжёлая фаза (compute-bound): весь промпт прогоняется через модель за один forward pass. Для 8K-промпта это заметная доля TTFT.
- Decode — генерация по одному токену. Фаза, ограниченная пропускной способностью памяти (memory-bound): на каждый токен модель перечитывает все веса и KV-cache.
Continuous batching смешивает фазы в одном batch: пока часть запросов декодирует, другие проходят prefill. Это хорошо для throughput, но может поднимать TTFT длинных промптов, если decode-запросы «забивают» итерацию.
Параметры vLLM
vllm serve model \
--max-num-seqs 256 \
--max-num-batched-tokens 8192
--max-num-seqs— потолок одновременных последовательностей в batch. Лимитирует параллельность. По умолчанию 256.--max-num-batched-tokens— максимальная сумма токенов (prefill + decode) в одной итерации. Лимитирует, как много «новой» работы scheduler может впихнуть за шаг. По умолчанию 2048–8192 в зависимости от версии.
--scheduler-delay-factor — задержка между итерациями для балансировки prefill/decode; настраивается редко и обычно не трогается.
Когда работает лучше
Continuous batching особенно эффективен при:
- Смешанной нагрузке — запросы разной длины и длительности генерации. Чем гетерогеннее, тем больший хвост static batching теряет.
- Высокой частоте новых запросов — очередь постоянно пополняется, освободившиеся места сразу заполняются.
- Переменном throughput — пиковые часы с разными типами запросов.
Меньше выигрыш, когда все запросы одинаковой длины и завершаются синхронно — но такой сценарий в production почти не встречается.
Что мы видели в референс-деплое
Здесь continuous batching сталкивается с жёстким компромиссом: контекст против параллельности.
В нашем референс-деплое (Qwen3.8-27B на 2×RTX 3090) KV-cache под 256K контекст съедает ~18GB из 48GB. При --max-num-seqs 8 (дефолт) 8×256K просто не влезает в память — vLLM либо крашится, либо отклоняет запросы. Поэтому мы сознательно ушли на --max-num-seqs 2: жертвуем параллельностью ради 256K контекста.
Это честный trade-off, а не «обход»: наш агент генерирует последовательно (одна сессия за раз), поэтому 2 параллельные последовательности ему хватает. Continuous batching при max-num-seqs 2 по-прежнему работает — он просто переключает между 2 активными запросами на каждом токене, а не держит 8.
Полный разбор этого компромисса — в части 3 серии «Наш опыт» и в бенчмарке Qwen на 2×3090.
Частые ошибки при тюнинге batching
- Поднятие
max-num-seqsбез проверки KV-cache. Больше параллельных запросов = больше KV-cache. Если памяти не хватает, vLLM будет отклонять запросы или крашиться. Всегда проверяйтеKV cache usageпосле изменения. - Игнорирование
max-num-batched-tokens. Если prefill-промпты длинные (8K+), по умолчанию budget может не вписать prefill в одну итерацию, и TTFT вырастет. Для длинных промптов поднимитеmax-num-batched-tokens. - Оптимизация под batch=1. Замеры на одиночном запросе показывают «чистую» скорость модели, но не production-поведение. Merяйте на realistic concurrency.
- Не смотреть queue length. Если очередь растёт, а KV-cache свободна — поднимайте
max-num-seqs. Если KV-cache забита, а очередь пустая — вы упираетесь в память, и batching здесь не поможет.
Вывод
Continuous batching — это не «опция», а то, как vLLM работает по умолчанию. Его эффект максимален на смешанной нагрузке и высокой частоте запросов. При тюнинге смотрите на два числа: заполненность KV-cache (сколько блоков занято) и длинину очереди (сколько запросов ждут). Если очередь растёт, а KV-cache свободна — поднимите max-num-seqs. Если KV-cache забита, а очередь пустая — вы упираетесь в память, и тут решает PagedAttention и размер контекста, а не batching.
Смежные материалы: PagedAttention, Tensor Parallelism, Observability.