Continuous Batching (Infinite Batching): увеличение пропускной способности LLM-сервера в 10-100 раз
Введение: почему один клиент = одна генерация?
Вы запустили LLM-сервер. Один пользователь задаёт вопрос, модель генерирует ответ. Второй пользователь ждёт. Третий ждёт ещё дольше.
Почему? Потому что классический batching работает так:
Время →
Клиент 1: [Prompt Processing] [Generation: токен 1, 2, 3, ..., 128]
Клиент 2: [Ожидание...] [Prompt Processing] [Generation: токен 1, 2, ..., 64]
Клиент 3: [Ожидание...] [Ожидание...] [Prompt Processing] [Generation: токен 1, ..., 256]
Каждый клиент получает выделенный batch на всю свою генерацию. Пока один клиент генерирует 256 токенов, другие ждут. Это катастрофическая неэффективность.
Continuous Batching (Infinite Batching) решает эту проблему: токены генерируются независимо для каждого клиента, и batch динамически меняется на каждом шаге.
Клиент 1: [Prompt Processing] [токен 1] [токен 2] [токен 3] ... [токен 128] [DONE]
Клиент 2: [Ожидание...] [Prompt Processing] [токен 1] [токен 2] ... [DONE]
Клиент 3: [Ожидание...] [Ожидание...] [Prompt Processing] [токен 1] [токен 2] [токен 3] ...
Batch на каждом шаге = активные клиенты
Шаг 1: batch_size = 3 (все обрабатывают промпты)
Шаг 2: batch_size = 2 (Клиент 1 генерирует токен, Клиент 2 обрабатывает промпт)
Шаг 3: batch_size = 3 (все активны)
...
Шаг 129: batch_size = 1 (только Клиент 3)
Результат: пропускная способность растёт в 10-100 раз при множестве клиентов.
Проблема: почему классический batching не работает для генерации?
Классический (статический) batching
В обычном машинном обучении batching — это базовая техника. Мы берём N примеров и обрабатываем их параллельно:
# Классический forward pass с batching
inputs = tokenizer(["Что такое ИИ?", "Напиши poem о весне", "Код на Python для бинарного поиска"])
outputs = model.generate(inputs, max_new_tokens=256)
# Все три запроса генерируют 256 токенов синхронно
# Batch size = 3 на ВСЮ генерацию
Это работает для inference, когда все запросы имеют одинаковую длину вывода. Но в реальности:
Клиент 1: "Кратко: 2+2=?" → ответ: 2 токена
Клиент 2: "Напиши эссе" → ответ: 512 токенов
Клиент 3: "Объясни квантовую физику" → ответ: 1024 токена
Классический batching вынужден ждать, пока самый длинный ответ не будет завершён.
Проблема переменных длин
Статический batching:
Шаг 1-512: batch = [Клиент 1, Клиент 2, Клиент 3]
Клиент 1 закончил на шаге 2 → GPU тратит 99.6% времени на padding
Клиент 2 закончил на шаге 512 → OK
Клиент 3 ещё генерирует
Шаг 513-1024: batch = [Клиент 3]
GPU работает на 10-15% нагрузки
GPU ожидает завершения самого длинного запроса в batch. Если один ответ короче — ресурсы простаивают.
GPU utilization при статическом batching
Время простоя GPU:
- Клиент 1 (2 токена): 99.6% простоя
- Клиент 2 (512 токенов): 0% простоя
- Клиент 3 (1024 токена): 0% простоя
Средний utilization при 100 клиентах со средним ответом 256 токенов:
- Первые 2 токена: 100% (все обрабатывают промпты)
- Токены 3-256: ~50-70% (короткие ответы завершаются)
- Токены 257+: <10% (почти все завершили генерацию)
Решение: Continuous Batching
Основная идея
Continuous Batching (также известный как Infinite Batching, Iterative Batching, Request-level Batching) работает так:
- Каждый токен генерируется независимо — завершённые запросы удаляются из batch
- Новые запросы добавляются на освободившиеся слоты
- Padding минимизируется — только активные токены обрабатываются
Continuous Batching:
Шаг 1: batch = [К1, К2, К3, К4, К5] → все обрабатывают промпты
Шаг 2: batch = [К1, К2, К3, К4, К5] → генерация токенов
Шаг 3: batch = [К2, К3, К4, К5, Н1] → К1 завершил, добавлен новый Н1
Шаг 4: batch = [К2, К3, К4, К5, Н1] → генерация
Шаг 5: batch = [К3, К4, К5, Н1, Н2] → К2 завершил, добавлены Н1, Н2
...
Шаг 50: batch = [Н10, Н11, Н12, Н13, Н14] → все новые запросы
Ключевые компоненты
1. Dynamic Scheduling
class ContinuousBatchScheduler:
def __init__(self):
self.active_requests = [] # [Request, ...]
self.waiting_queue = [] # очередь новых запросов
self.completed = [] # завершённые запросы
def step(self, model, tokenizer):
# 1. Генерируем токен для каждого активного запроса
outputs = []
for req in self.active_requests:
input_ids = req.current_input_ids
output = model.generate(input_ids, max_new_tokens=1)
token = output[0][-1]
req.append_token(token)
outputs.append(req)
# 2. Удаляем завершённые (EOS или max_length)
finished = [r for r in outputs if r.is_complete()]
self.active_requests = [r for r in outputs if not r.is_complete()]
self.completed.extend(finished)
# 3. Добавляем новые из очереди
while len(self.active_requests) < self.max_batch_size and self.waiting_queue:
new_req = self.waiting_queue.pop(0)
self.active_requests.append(new_req)
return outputs
2. Prefix Caching (кэширование префиксов)
Ключевое дополнение к continuous batching — кэширование KV-ключей для повторяющихся префиксов (системных промптов, шаблонов).
class PrefixCache:
def __init__(self, max_size=1024):
self.cache = {} # hash(prefix) → KV-cache tensors
self.lru_order = {}
def get(self, prefix_tokens):
"""Получить кэшированные KV-ключи для префикса"""
hash_key = hash(tuple(prefix_tokens[:1000])) # кэшируем первые 1000 токенов
if hash_key in self.cache:
self._touch(hash_key)
return self.cache[hash_key]
return None
def put(self, prefix_tokens, kv_cache):
"""Сохранить KV-ключи в кэш"""
hash_key = hash(tuple(prefix_tokens[:1000]))
if len(self.cache) >= self.max_size:
self._evict_lru()
self.cache[hash_key] = kv_cache
self._touch(hash_key)
Почему это важно: в реальных LLM-серверах 60-90% времени inference тратится на обработку промпта (prompt processing), а не на генерацию. Prefix caching устраняет эту стоимость для повторяющихся префиксов.
Системный промпт (повторяется для каждого запроса):
"Ты — полезный ассистент. Отвечай подробно и по делу."
Без prefix caching:
Каждый запрос: 512 токенов промпта → forward pass → KV-cache
100 запросов = 100 × forward pass на промпт
С prefix caching:
Первый запрос: 512 токенов → forward pass → кэш
Остальные 99: кэш hit → 0 forward pass
Экономия: ~99% времени обработки промпта
3. Paged Attention (память как страницы)
vLLM ввёл PagedAttention — аналог виртуальной памяти в ОС, но для KV-cache.
Обычное выделение памяти KV-cache:
Batch size = 128, max_context = 4096
Выделено: 128 × 4096 × 2 (key + value) × 32 (layers) × 4 (bytes) ≈ 128 GB
Реально используется: ~20 GB
Потеряно: 108 GB (фрагментация + pre-allocation)
PagedAttention:
Logical KV blocks → Physical KV blocks
Выделение по demand (как page fault в ОС)
Utilization: 85-95%
Logical Block 0 → Physical Block 1024
Logical Block 1 → Physical Block 1025
Logical Block 2 → Physical Block 2048 (разные запросы используют разные блоки)
Logical Block 3 → Physical Block 1026
...
KV Cache Memory:
┌─────────────┬──────────────┐
│ Request A │ Block 1024-1025│ (64 tokens)
├─────────────┼──────────────┤
│ Request B │ Block 2048-... │ (256 tokens)
├─────────────┼──────────────┤
│ Request C │ Block 1026-1027│ (128 tokens)
└─────────────┴──────────────┘
Сравнение: Static vs Continuous Batching
100 клиентов, средний промпт 512 токенов, средний ответ 256 токенов
Static Batching:
- Batch size = 32 (максимум в GPU)
- 100 клиентов / 32 = 4 итерации batch'ей
- Каждая итерация: max(промпт + ответ) = 512 + 256 = 768 шагов
- Total: 4 × 768 = 3072 шага
- Throughput: ~130 токенов/сек (все клиенты)
- Avg latency: ~18 секунд на клиент
Continuous Batching:
- Batch size динамический: от 100 до 1
- Prompt processing: 1 итерация (все 100 параллельно)
- Generation: 256 шагов, batch уменьшается от 100 до 1
- Total: 1 + 256 = 257 шагов
- Throughput: ~1200 токенов/сек (все клиенты)
- Avg latency: ~2 секунды на клиент
Ускорение throughput: ~9.2x
Уменьшение latency: ~9x
Реализации Continuous Batching
vLLM — промышленный стандарт
vLLM — самый популярный LLM inference сервер с continuous batching с 2023 года.
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
tensor_parallel_size=1,
gpu_memory_utilization=0.95, # 95% VRAM для KV-cache
max_num_batched_tokens=8192, # макс. токенов в batch
max_num_seqs=256, # макс. количество запросов
)
sampling_params = SamplingParams(
temperature=0.7,
max_tokens=1024,
top_p=0.95,
)
# 100 запросов — все обрабатываются параллельно
prompts = [f"Ответь кратко: вопрос {i}?" for i in range(100)]
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
prompt = output.prompt
generated_text = output.outputs[0].text
print(f"{prompt} → {generated_text}")
Ключевые параметры vLLM:
# Сервер с continuous batching
vllm serve Qwen/Qwen2.5-7B-Instruct \
--tensor-parallel-size 1 \
--gpu-memory-utilization 0.95 \
--max-num-batched-tokens 8192 \
--max-num-seqs 256 \
--max-model-len 4096 \
--quantization auto \
--port 8000
| Параметр | Описание | Рекомендуемое значение |
|---|---|---|
gpu_memory_utilization |
Доля VRAM для KV-cache | 0.85-0.95 |
max_num_batched_tokens |
Макс. токенов в batch | 4096-16384 |
max_num_seqs |
Макс. активных запросов | 128-1024 |
max_model_len |
Макс. длина контекста | 2048-8192 |
block_size |
Размер KV-block (должен делить context) | 16 или 32 |
TGI (Text Generation Inference) — Hugging Face
TGI — альтернатива от Hugging Face с continuous batching.
# Запуск TGI
docker run --gpus all -p 8080:80 \
ghcr.io/huggingface/text-generation-inference:2.0 \
--model-id Qwen/Qwen2.5-7B-Instruct \
--max-batch-total-tokens 8192 \
--max-batch-prefill-tokens 4096 \
--max-input-length 4096 \
--max-total-tokens 8192
import requests
response = requests.post(
"http://localhost:8080/generate",
json={
"inputs": "Что такое continuous batching?",
"parameters": {
"max_new_tokens": 256,
"temperature": 0.7,
"top_p": 0.95,
}
}
)
print(response.json())
SGLang — от UC Berkeley
SGLang (Structured Generation Language) — новый фреймворк с оптимизированным continuous batching.
import sglang as sgl
from sglang import set_default_backend, RuntimeEndpoint
# Инициализация
backend = sgl.Runtime(model_path="Qwen/Qwen2.5-7B-Instruct", port=30000)
set_default_backend(backend)
@sgl.function
def qa(s, question):
s += sgl.user(question)
s += sgl.assistant(sgl.gen("answer", max_tokens=256))
# Параллельный запуск
programs = [qa(question=f"Вопрос {i}?") for i in range(100)]
programs = sgl.run_batch(
programs,
max_parallel=100,
schedule_policy="longest_prefix_weight" # оптимизация prefix caching
)
SGLang особенности:
- Frontend-Backend архитектура — frontend обрабатывает промпты, backend генерирует
- Schedule policy — умное планирование на основе длины общего префикса
- Radix attention — древовидный prefix cache для диалогов
llama.cpp — для локального запуска
# Сервер llama.cpp с continuous batching
./server -m models/qwen2.5-7b-instruct.Q4_K_M.gguf \
-c 4096 \
-ngl 80 \
-t 8 \
--host 0.0.0.0 \
--port 8080
# 100 запросов к одному серверу — все обрабатываются параллельно
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen2.5-7b",
"messages": [{"role": "user", "content": "Вопрос 1?"}],
"max_tokens": 256
}'
Ollama
Ollama из коробки поддерживает continuous batching через свой сервер.
# Ollama автоматически batch'ит запросы
ollama serve
# 100 параллельных запросов
for i in {1..100}; do
curl http://localhost:11434/api/generate \
-d '{
"model": "qwen2.5",
"prompt": "Вопрос $i?",
"stream": false
}' &
done
wait
Бенчмарки: continuous batching vs без
RTX 4090 (24 GB VRAM), Qwen 2.5 7B Q4
| Сценарий | Static Batch | Continuous Batch | Улучшение |
|---|---|---|---|
| 1 клиент, 256 токенов | 35 tok/s | 35 tok/s | — |
| 10 клиентов, 256 токенов | 35 tok/s | 280 tok/s | 8x |
| 50 клиентов, 256 токенов | 35 tok/s | 850 tok/s | 24x |
| 100 клиентов, 256 токенов | 35 tok/s | 1100 tok/s | 31x |
| 100 клиентов, avg 128 токенов | 35 tok/s | 1400 tok/s | 40x |
A100 80GB, Llama 3.1 70B Q4
| Сценарий | Static Batch | Continuous Batch | Улучшение |
|---|---|---|---|
| 1 клиент, 512 токенов | 28 tok/s | 28 tok/s | — |
| 10 клиентов, 512 токенов | 28 tok/s | 180 tok/s | 6.4x |
| 50 клиентов, 512 токенов | 28 tok/s | 650 tok/s | 23x |
| 100 клиентов, 512 токенов | 28 tok/s | 900 tok/s | 32x |
С mixed-length запросами (реальный сценарий)
Смешанные запросы:
- 30% коротких (16-64 токенов)
- 50% средних (64-256 токенов)
- 20% длинных (256-1024 токенов)
100 клиентов, A100 80GB:
Static Batching:
Throughput: 45 tok/s (ограничен longest request)
Avg latency: 12.5s
P99 latency: 34.2s
Continuous Batching (vLLM):
Throughput: 1800 tok/s
Avg latency: 0.8s
P99 latency: 3.2s
Prefix Caching в действии
Сценарий: API с повторяющимся системным промптом
# Каждый запрос к LLM-API содержит системный промпт:
SYSTEM_PROMPT = """Ты — полезный AI-ассистент для разработчиков.
Ты помогаешь с кодом, объясняешь концепции и решаешь задачи.
Отвечай подробно, с примерами кода."""
# 1000 запросов с одинаковым системным промптом
prompts = [
f"{SYSTEM_PROMPT}\n\nПользователь: {user_question}"
for user_question in user_questions
]
# Без prefix caching:
# 1000 × 256 токенов промпта = 256,000 forward pass операций
# Время: ~80 секунд
# С prefix caching (vLLM):
# Первый: 256 токенов → 80ms
# Остальные 999: cache hit → 0.5ms каждый
# Время: 80ms + 999 × 0.5ms = ~580ms
# Экономия: 99.3%
Radix Attention (SGLang)
Для диалогов с общими историями:
Диалог пользователя A:
User: Привет!
Assistant: Привет! Чем помочь?
User: Что такое Python?
Assistant: Python — это...
Диалог пользователя B:
User: Привет!
Assistant: Привет! Чем помочь?
User: Что такое Rust?
Assistant: Rust — это...
Общий префикс:
[Привет! → Привет! Чем помочь?] — кэширован на 99% запросов
Radix Tree:
[ROOT]
/ \
[Привет!] ...
/
[Привет! → Привет! Чем помочь?]
/ \
[Python] [Rust]
# SGLang автоматически строит radix tree
# Для повторяющихся историй диалога — максимальный cache hit rate
runtime = sgl.Runtime(
model_path="Qwen/Qwen2.5-7B-Instruct",
schedule_policy="longest_prefix_weight", # приоритет запросам с длинным общим префиксом
enable_prefix_caching=True,
)
Сравнение LLM inference фреймворков
| Фреймворк | Continuous Batching | Prefix Caching | Paged Attention | Multi-GPU | GGUF |
|---|---|---|---|---|---|
| vLLM | ✅ | ✅ | ✅ | ✅ | ❌ (FP16/INT8) |
| TGI | ✅ | ✅ | ❌ | ✅ | ❌ |
| SGLang | ✅ | ✅ (Radix) | ❌ | ✅ | ❌ |
| llama.cpp | ✅ | ✅ | ❌ | ❌ | ✅ |
| Ollama | ✅ | ✅ | ❌ | ❌ | ✅ |
| TensorRT-LLM | ✅ | ✅ | ❌ | ✅ | ❌ |
Когда что выбирать?
| Сценарий | Рекомендация |
|---|---|
| Локальный сервер, GGUF | llama.cpp или Ollama |
| Production API, максимальный throughput | vLLM |
| Hugging Face экосистема | TGI |
| Сложная structured generation | SGLang |
| Multi-GPU, максимальная скорость | TensorRT-LLM или vLLM |
| Мобильное устройство | llama.cpp (MPS) |
Настройка production-сервера
vLLM production configuration
from vllm import LLM, SamplingParams
from vllm.config import MemoryPoolSize
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
# Memory optimization
gpu_memory_utilization=0.95, # 95% VRAM для KV-cache
max_num_batched_tokens=16384, # токенов в batch
max_num_seqs=1024, # макс. запросов
block_size=16, # размер KV-block
# Performance
tensor_parallel_size=1, # для multi-GPU: количество GPU
pipeline_parallel_size=1, # pipeline parallelism
dtype="auto", # автоматический выбор точности
quantization="auto", # автоматическое квантование
# Caching
enable_chunked_prefill=True, # чанковый prefill для больших промптов
enable_prefix_caching=True, # prefix caching
# Scheduling
scheduler_type="streaming", # streaming scheduling
swap_space=8, # CPU swap space (GB)
)
Chunked Prefill
Для больших промптов chunked prefill разбивает обработку на части:
# Без chunked prefill:
# Промпт 8192 токенов → один большой batch, блокирует других
# С chunked prefill:
# Промпт 8192 токенов → 4 чанка по 2048
# Каждый чанк обрабатывается параллельно с другими запросами
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
enable_chunked_prefill=True, # включить
chunked_prefill_size=2048, # размер чанка
)
Chunked Prefill:
Промпт клиента 1 (8192 токена):
Чанк 1: токены 1-2048 → forward pass
Чанк 2: токены 2049-4096 → forward pass (параллельно с другими)
Чанк 3: токены 4097-6144 → forward pass (параллельно с другими)
Чанк 4: токены 6145-8192 → forward pass (параллельно с другими)
Пока клиент 1 обрабатывает чанки, другие клиенты:
Клиент 2: обрабатывает свой промпт
Клиент 3: генерирует токен
Клиент 4: обрабатывает свой промпт
HTTP API для production
# FastAPI + vLLM
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from vllm import LLM, SamplingParams
import time
app = FastAPI(title="LLM API")
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
gpu_memory_utilization=0.95,
max_num_seqs=512,
enable_prefix_caching=True,
)
class ChatRequest(BaseModel):
messages: list[dict]
max_tokens: int = 256
temperature: float = 0.7
@app.post("/chat/completions")
async def chat(request: ChatRequest):
start = time.time()
outputs = llm.generate(
[msg["content"] for msg in request.messages],
SamplingParams(
max_tokens=request.max_tokens,
temperature=request.temperature,
)
)
latency = time.time() - start
return {
"text": outputs[0].outputs[0].text,
"latency_ms": latency * 1000,
"prompt_tokens": outputs[0].prompt_token_ids,
}
Типичные проблемы и решения
Проблема 1: Out of Memory (OOM)
Причина: KV-cache переполняет VRAM
Решение:
1. Уменьшить gpu_memory_utilization (0.95 → 0.85)
2. Уменьшить max_num_seqs
3. Уменьшить max_model_len
4. Включить quantization
Проблема 2: Низкий throughput при малом количестве клиентов
Причина: overhead на scheduling и communication
Решение:
1. Увеличить batch size
2. Использовать tensor_parallel_size > 1 для больших моделей
3. Включить pipeline_parallel_size для multi-GPU
Проблема 3: Высокая latency для отдельных запросов
Причина: long-tail effect — запросы с длинными промптами блокируют batch
Решение:
1. enable_chunked_prefill=True
2. Уменьшить max_num_batched_tokens
3. Использовать schedule_policy="longest_prefix_weight" (SGLang)
Выводы
Continuous Batching — это не просто оптимизация, а необходимый стандарт для production LLM-серверов. Без него:
- Пропускная способность падает в 10-100 раз при множестве клиентов
- GPU простаивает 90%+ времени
- Latency для поздних клиентов становится неприемлемым
Ключевые выводы:
- Continuous Batching — генерация токенов независимо для каждого клиента
- Prefix Caching — экономия 99% времени обработки повторяющихся промптов
- PagedAttention — эффективное управление памятью KV-cache
- Chunked Prefill — обработка больших промптов без блокировки batch
Рекомендации по выбору фреймворка:
- vLLM — для production API с максимальным throughput
- llama.cpp / Ollama — для локального запуска с GGUF моделями
- SGLang — для сложной structured generation и диалогов
- TGI — для Hugging Face экосистемы
Continuous batching — это то, что отличает «просто вызов API» от production-grade inference сервера. Если вы запускаете LLM для более чем одного пользователя одновременно — вам нужен continuous batching.