Что такое 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:

  1. Завершённые: запросы, достигшие max_tokens или EOS, освобождают свои KV-блоки (см. PagedAttention).
  2. Бюджет: освобождённые токены и свободные блоки идут в доступный бюджет итерации.
  3. Новые: scheduler берёт из очереди запросы, чей prefill + текущий decode вписываются в бюджет.
  4. Повтор: шаг повторяется на каждом 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.

← PagedAttention Tensor Parallelism →