Перейти к содержимому
ПереводыQwen, InternLM и другие

modelscope-classroom, учебник, раздел 6.5: Continuous Batching

Полный перевод раздела 6.5 «Учебника ModelScope по большим моделям»: почему статический батчинг теряет почти половину времени GPU, как работает планирование на уровне итераций, смешивание prefill и decode, chunked prefill, алгоритмы планирования, вытеснение и выгрузка KV-кэша, метрики и реализации в vLLM, TensorRT-LLM и SGLang.

Continuous Batching (непрерывный батчингнепрерывный батчингEN continuous batching (iteration-level scheduling)Состав батча пересматривается после каждого шага decode — закончившиеся запросы уходят, новые встают на их место. Идея системы Orca (2022), стандарт всех современных серверов.) — ключевая техника планирования в системах инференса LLM. Классический статический батчинг вынужден ждать, пока закончатся все запросы в батче, и только потом начинать новый — это приводит к серьёзной растрате ресурсов. Continuous Batching позволяет запросам динамически входить в батч и выходить из него, что сильно повышает пропускную способность системы.

Представьте досмотр в аэропорту. Классический статический батчинг похож на «пропуск партиями»: следующие 10 человек заходят, только когда вся предыдущая группа из 10 прошла досмотр. Если у первого — небольшой рюкзак и он прошёл за 30 секунд, а у восьмого — три чемодана и ему нужно 5 минут, ждут все. Continuous Batching похож на настоящую линию досмотра: ушёл один — на его место сразу встаёт следующий, и линия всегда загружена полностью.

graph TD
subgraph СТ["Статический батчинг"]
  S1["Собрать B запросов"] --> S2["Сформировать батч"]
  S2 --> S3["Decode всех запросов"]
  S3 --> S4["Ждать самый медленный"]
  S4 --> S5["Вернуть результаты"]
  S5 --> S1
end
subgraph НБ["Continuous Batching"]
  C1["Запрос пришёл"] --> C2["Динамически вошёл в батч"]
  C2 --> C3["Сгенерировать токен"]
  C3 --> C4{"Какой-то запрос завершён?"}
  C4 -->|да| C5["Убрать завершённые"]
  C5 --> C6["Добавить новые"]
  C6 --> C3
  C4 -->|нет| C3
end

Процесс статического батчинга (Static Batching):

  1. Собрать BB запросов в батч.
  2. Сделать prefill всех запросов.
  3. Делать decode, пока не закончатся все запросы.
  4. Вернуть результаты, начать следующий батч.

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

Пример:

Запрос 1: ответ 50 токенов, 5 с
Запрос 2: ответ 500 токенов, 50 с
Запрос 3: ответ 100 токенов, 10 с
Статический батчинг: ждём 50 с, у запросов 1 и 3 загрузка GPU крайне низкая

Потери на паддинге. Выравнивание длин требует паддинга, а заполненные позиции создают бесполезные вычисления внимания.

Всплески задержки. Границы батчей создают ожидание: задержка нового запроса зависит от самого медленного запроса в текущем батче.

Пусть длины ответов в батче равномерно распределены на [Lmin⁡,Lmax⁡][L_{\min}, L_{\max}]. Полезная загрузка:

загрузка=средняя длинамаксимальная длина=(Lmin⁡+Lmax⁡)/2Lmax⁡\text{загрузка} = \frac{\text{средняя длина}}{\text{максимальная длина}} = \frac{(L_{\min} + L_{\max})/2}{L_{\max}}

где:

  • Lmin⁡L_{\min}, Lmax⁡L_{\max} — минимальная и максимальная длина ответа в батче;
  • длины ответов считаются равномерно распределёнными на [Lmin⁡,Lmax⁡][L_{\min}, L_{\max}];
  • «загрузка» — доля полезного времени вычислений GPU во всём времени; время, когда короткие запросы закончились и ждут, потрачено впустую.

Если Lmin⁡=10L_{\min} = 10, Lmax⁡=500L_{\max} = 500, загрузка всего (10+500)/(2×500)=51%(10+500)/(2 \times 500) = 51\% — почти половина времени GPU уходит на ожидание.

Continuous Batching (ещё называют Iteration-Level Scheduling — планирование на уровне итераций) пересматривает состав батча после каждой итерации decode:

  • завершённые запросы сразу удаляются;
  • новые запросы сразу добавляются;
  • размер батча меняется динамически.
Итерация 1: [Req1, Req2, Req3] → генерируем токен → Req1 завершён
Итерация 2: [Req2, Req3, Req4] → добавили новый Req4, генерируем токен
Итерация 3: [Req2, Req3, Req4] → генерируем токен → Req3 завершён
Итерация 4: [Req2, Req4, Req5] → добавили Req5, генерируем токен
...

Явных «границ батча» нет, запросы обрабатываются конвейером — как на линии досмотра в аэропорту, где нет деления на «первую партию» и «вторую партию», а поток пассажиров непрерывен.

  1. Нет ожидания: запрос возвращается, как только завершён, не дожидаясь остальных в батче.
  2. Высокая загрузка: GPU всё время работает с полной нагрузкой.
  3. Низкая задержка: новый запрос сразу входит в батч, не дожидаясь конца текущего.
  4. Гибкое планирование: можно в первую очередь обслуживать срочные запросы.

Continuous Batching должен обрабатывать два вида запросов:

  • запросы на prefill: только что пришли, нужно обработать промпт;
  • запросы на decode: уже генерируют ответ.

Их вычислительные свойства различны: prefill упирается в вычисления (compute-boundограничено вычислениями (compute-bound)EN compute-boundОперация, в которой вычислитель загружен полностью, а память успевает подвозить данные. Ускоряется только более быстрым железом или меньшим числом операций.), decode — в память (memory-boundограничено памятью (memory-bound)EN memory-boundОперация, которая дольше ждёт данные из памяти, чем считает. Ускоряется, если читать меньше байт (квантование, GQA, MLA) или делать больше работы на каждый прочитанный байт (батчинг).).

Последовательная: сначала закончить все prefill, потом decode.

  • Просто, но может «заморить голодом» запросы на decode.

Чередующаяся: делать prefill раз в несколько итераций.

  • Баланс между задержкой и пропускной способностью.

Разделённая: prefill и decode на разных GPU.

  • Лучшая загрузка, но выше сложность.

Вместе с Chunked Prefillprefill по частям (chunked prefill)EN chunked prefillДлинный промпт обрабатывается кусками, которые перемешиваются с шагами decode других запросов, чтобы те не замирали на время большого prefill. длинный prefill можно резать на куски и смешивать с запросами на decode:

Итерация 1: [Decode: Req1, Req2] + [Кусок prefill: Req3_part1]
Итерация 2: [Decode: Req1, Req2] + [Кусок prefill: Req3_part2]
Итерация 3: [Decode: Req1, Req2, Req3] → prefill Req3 завершён, он переходит в decode

Так длинный prefill не блокирует decode.

С Continuous Batching управлять KV-кэшемKV-кэшEN KV cache сложнее:

  • запросы динамически входят и выходят, и KV-кэш нужно динамически выделять и освобождать;
  • длины KV-кэша у разных запросов разные.

Paged AttentionPagedAttention (страничный KV-кэш)EN PagedAttentionKV-кэш хранится блоками фиксированного размера (например, по 16 токенов), а таблица блоков связывает логические позиции с физическими — как страницы виртуальной памяти в ОС. Основа vLLM. — идеальное решение: страничное управление естественно поддерживает динамическое выделение.

Запросы в батче разной длины — как считать эффективно?

Паддинг: дополнить до самой длинной — просто, но расточительно.

Packed Attention: склеить несколько коротких последовательностей в одну длинную и разделить их маской внимания.

Ragged Tensor: использовать «рваные» тензоры неодинаковой длины и обойтись без паддинга.

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

Частые входы и выходы запросов могут фрагментировать видеопамять. Страничный механизм Paged Attention эффективно смягчает эту проблему.

Самая простая стратегия: обрабатывать в порядке поступления.

  • Честно, но может задерживать длинные запросы.

В первую очередь — запросы, которые, по прогнозу, закончатся быстрее всего.

  • Минимизирует среднюю задержку.
  • Нужно предсказывать длину ответа (а прогноз неточен).
  • Может «заморить голодом» длинные запросы.

Планирование по приоритету запроса:

  • платные пользователи > бесплатные;
  • интерактивные > пакетные;
  • срочные > обычные.

Гарантирует каждому запросу справедливую долю ресурсов:

справедливая доля=все ресурсычисло активных запросов\text{справедливая доля} = \frac{\text{все ресурсы}}{\text{число активных запросов}}

где «все ресурсы» — видеопамять и время вычислений GPU, а «число активных запросов» — сколько запросов сейчас обрабатывается. Запросам, уже получившим много ресурсов, понижают приоритет, чтобы каждый получил примерно одинаковое время обслуживания.

vLLM использует стратегию FCFS + вытеснение:

  1. По умолчанию — FCFS.
  2. Когда не хватает видеопамяти, вытесняется последний вошедший запрос (приостанавливается и выгружается — swap out).
  3. Когда память освобождается, вытесненный запрос возобновляется.

Когда одновременных запросов слишком много и под KV-кэш не хватает видеопамяти, часть запросов нужно вытеснить (Preemption). Это как при внезапной толчее на досмотре: часть пассажиров приходится временно отправить обратно в зал ожидания, пока линия не освободится.

graph TD
A["Не хватает видеопамяти"] --> B{"Стратегия вытеснения"}
B --> C["Выбросить KV-кэш"]
B --> D["Выгрузить в память CPU"]
B --> E["Пересчитать"]
C --> F["Потом заново prefill"]
D --> G["Вернуть, когда память освободится"]
E --> H["Сохранить токены, потом заново prefill"]

Выбросить: удалить KV-кэш вытесненного запроса, потом заново сделать prefill.

  • Просто, но теряется уже сделанная работа.

Выгрузить (Swap): перенести KV-кэш в память CPU, потом вернуть обратно — как сдать багаж в камеру хранения в зале ожидания и забрать, когда освободится место.

  • Прогресс сохраняется, но растут расходы на ввод-вывод.

Пересчитать (Recompute): сохранить только промпт и уже сгенерированные токены, после вытеснения заново сделать prefill.

  • Промежуточный вариант; подходит, когда prefill стоит недорого.

vLLM поддерживает выгрузку KV-кэша между GPU и CPU:

  1. При нехватке видеопамяти выбирается запрос, вошедший последним.
  2. Его KV-кэш асинхронно копируется в память CPU.
  3. Блоки на GPU освобождаются.
  4. Когда память освободится, кэш асинхронно копируется обратно, и запрос возобновляется.

Paged Attention позволяет выгружать по блокам, что гибче.

При Continuous Batching фактический размер батча всё время меняется. Цели:

  • максимально загрузить GPU;
  • не выйти за пределы видеопамяти;
  • сохранить разумную задержку.

Обычно задают максимальный размер батча, а планировщик подстраивает фактический.

Prefill влияет на задержку запросов, которые уже в decode. Стратегии:

  • немедленный prefill: новый запрос обрабатывается сразу — может вырасти задержка decode;
  • отложенный prefill: накопить запросы и сделать prefill пачкой — меньше прерываний;
  • chunked prefill: обрабатывать по кускам — влияние сглаживается.

При развёртывании на нескольких GPU запросы нужно распределять равномерно:

  • по кругу (round robin): просто, но не учитывает нагрузку;
  • наименьшее число соединений: отдавать GPU, у которой сейчас меньше всего запросов;
  • взвешенно: учитывать разную мощность GPU.
  • Глубина очереди: число ожидающих запросов — отражает нагрузку системы.
  • Распределение задержки: P50, P95, P99.
  • Пропускная способность: обработанных токенов в секунду.
  • Доля вытеснений: доля вытесненных запросов — отражает нехватку ресурсов.

vLLMvLLMEN vLLMОткрытый движок инференса LLM (UC Berkeley): PagedAttention, continuous batching, тензорный параллелизм, OpenAI-совместимый сервер (vllm serve, порт 8000). Стандарт для сервисов и генерации в RL. — эталонная реализация Continuous Batching + Paged Attention:

  • Python API + ядра на C++/CUDA;
  • поддерживает много моделей (LLaMA, Mistral, Qwen и др.);
  • интеграция с Ray для распределённого инференса.

TensorRT-LLM от NVIDIA даёт похожие возможности:

  • сильно оптимизированные ядра CUDA;
  • интеграция с Triton Inference Server;
  • параллелизм на нескольких GPU.

SGLangSGLangEN SGLangДвижок инференса от LMSYS: RadixAttention (переиспользование общего префикса запросов), быстрый структурированный вывод, ранняя поддержка новых DeepSeek и Qwen; OpenAI-совместимый сервер на порту 30000. поверх Continuous Batching улучшает интерфейс программирования:

  • RadixAttentionRadixAttentionEN RadixAttentionПрефикс-кэш SGLang — KV-кэш всех запросов хранится в префиксном (radix) дереве, общие начала переиспользуются автоматически, редко нужные ветви вытесняются по LRU.: более эффективный префикс-кэш;
  • оптимизации структурированной генерации;
  • поддержка управляющих конструкций (control flow).