Continuous Batching (Infinite Batching): увеличение пропускной способности LLM-сервера в 10-100 раз

opensourceaillmcontinuous-batchinginfinite-batchingperformanceinferenceit
← Back to Blog

Введение: почему один клиент = одна генерация?

Вы запустили 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) работает так:

  1. Каждый токен генерируется независимо — завершённые запросы удаляются из batch
  2. Новые запросы добавляются на освободившиеся слоты
  3. 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 для поздних клиентов становится неприемлемым

Ключевые выводы:

  1. Continuous Batching — генерация токенов независимо для каждого клиента
  2. Prefix Caching — экономия 99% времени обработки повторяющихся промптов
  3. PagedAttention — эффективное управление памятью KV-cache
  4. 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.