Когда токен за токеном превращается в финансовую катастрофу
Представьте себе сервис типа ChatGPT, который обрабатывает 10 000 запросов в секунду. Каждый запрос генерирует ответ по 100 токенов. Стандартный подход — обрабатывать запросы батчами. Вы собираете N запросов, прогоняете их через модель одновременно, ждете, пока все закончат генерацию, и только потом берете следующую партию.
Звучит логично? А теперь посчитайте. Пользователь А запросил перевод "Привет" на английский — модель выдаст "Hello" за один шаг. Пользователь Б попросил составить бизнес-план на 10 страниц — это 2000 токенов генерации. Пользователь А давно получил ответ и ушел пить кофе, а вы продолжаете ждать, пока модель закончит генерировать бизнес-план для пользователя Б, прежде чем обработать новые запросы.
Вот она — главная проблема статического батчинга: GPU простаивает 95% времени, пока ждет самого длинного запроса в батче. Вы платите за дорогой A100, который большую часть времени просто греет воздух в дата-центре.
Ключ к пониманию — как LLM на самом деле генерируют текст
Чтобы понять, почему Continuous Batching работает, нужно разобраться с фундаментальным ограничением трансформеров. LLM не могут "заглянуть вперед" или сгенерировать ответ сразу целиком. Они работают итеративно: принимают последовательность токенов (промпт), вычисляют распределение вероятностей для следующего токена, выбирают наиболее вероятный (или не совсем), добавляют его к последовательности и повторяют.
Каждая итерация требует полного прохода через все слои модели. И вот здесь начинается магия оптимизации.
KV Cache: почему не нужно пересчитывать всё каждый раз
Самое дорогое в attention — умножение матриц Query на Key для получения весов. Но ключевое наблюдение: для уже обработанных токенов значения Key и Value (KV) не меняются от итерации к итерации. Токен "При" в промпте "Привет мир" имеет одни и те же KV на первом, втором и сотом шаге генерации.
Инженеры догадались кэшировать эти KV пары. Вместо пересчета attention с нуля для всей последовательности, модель просто добавляет KV для нового токена в кэш и вычисляет attention только между Query нового токена и всеми Key в кэше.
| Подход | Вычисления на токен | Память | Когда использовать |
|---|---|---|---|
| Без KV Cache | O(n²) для n токенов | Минимум | Никогда в продакшене |
| С KV Cache | O(n) для n токенов | Растет линейно | Всегда для инференса |
Continuous Batching: как заставить GPU работать на 100%
Теперь возвращаемся к нашей проблеме простаивающего GPU. Continuous Batching (он же iteration-level scheduling или dynamic batching) ломает парадигму "обработал батч — освободился".
Вместо того чтобы ждать, пока все запросы в батче закончат генерацию, система обрабатывает один шаг (одну итерацию) для всех активных запросов, а затем сразу переходит к следующему шагу. Запросы, которые завершили генерацию (выдали end-of-sequence токен), удаляются из батча. На их место добавляются новые запросы из очереди.
1Как это выглядит на практике
Допустим, у вас есть 4 запроса в системе:
- Запрос А: "Переведи 'кот' на английский" (ожидаемая длина: 1 токен)
- Запрос Б: "Напиши стихотворение про весну" (ожидаемая длина: 50 токенов)
- Запрос В: "Объясни квантовую механику" (ожидаемая длина: 200 токенов)
- Запрос Г: "Что такое API?" (ожидаемая длина: 10 токенов)
В статическом батчинге вы бы обработали все 4 запроса одновременно. Запрос А завершится на первом шаге, но GPU будет ждать, пока запрос В не сгенерирует все 200 токенов.
В Continuous Batching после первого шага запрос А завершается и освобождает слот в батче. Система немедленно берет из очереди новый запрос Д и начинает его обрабатывать на втором шаге вместе с Б, В и Г.
2Технические детали реализации
Самое сложное в Continuous Batching — управление памятью и вычислениями для запросов разной длины. Каждый запрос имеет свой KV Cache разного размера. Нужно как-то упаковать все это в тензоры, которые можно эффективно обрабатывать на GPU.
Современные системы (vLLM, TGI от Hugging Face) используют paged attention — аналог виртуальной памяти для KV Cache. KV Cache разбивается на блоки фиксированного размера (например, 128 токенов). Каждый запрос получает столько блоков, сколько нужно. Блоки могут быть несмежными в памяти, как страницы в оперативной памяти компьютера.
На каждом шаге система строит "таблицу страниц", которая указывает, какие блоки KV Cache соответствуют каким позициям в каких запросах. Attention механизм использует эту таблицу для правильного доступа к данным.
Что ломается при переходе на Continuous Batching
В теории все прекрасно. На практике возникает дюжина подводных камней.
Самая болезненная проблема — contention на памяти. Когда десятки запросов одновременно пытаются аллоцировать и освобождать блоки KV Cache, возникает классическая ситуация race condition. Нужна сложная система блокировок или lock-free аллокатор.
Проблема 1: Разная длина промптов
Запросы приходят с промптами разной длины. Некоторые системы предварительно вычисляют KV Cache для всего промпта перед началом генерации. Это создает пиковую нагрузку на память в момент добавления нового запроса в батч.
Решение — incremental encoding: вычислять KV Cache для промпта по мере продвижения по шагам генерации. Но это усложняет логику, потому что нужно отслеживать, для каких токенов промпта KV уже вычислены, а для каких нет.
Проблема 2: Прерывание генерации
Пользователь нажал "Стоп" посреди генерации длинного ответа. Нужно немедленно освободить все блоки KV Cache, которые занимал этот запрос, и убрать его из батча. Если делать это синхронно, можно заблокировать весь конвейер.
Правильное решение — асинхронное освобождение памяти с фоновым garbage collector. Запрос помечается как отмененный, но физическое освобождение блоков происходит позже, когда система не занята критическими вычислениями.
Проблема 3: Приоритеты и QoS
Не все запросы равны. Премиум-пользователи ожидают низкую задержку. Research-запросы могут подождать. В статическом батчинге вы просто создаете отдельные очереди. В Continuous Batching приоритеты влияют на scheduling: какой запрос взять следующим, когда освободится слот?
Некоторые системы реализуют weighted fair queueing: каждый запрос получает виртуальное время старта с поправкой на приоритет. Запросы с высоким приоритетом "опережают" время.
Реальные цифры: во сколько раз ускоряется обработка
Без Continuous Batching utilization GPU редко превышает 20-30% при смешанной нагрузке (короткие и длинные запросы). С Continuous Batching можно достичь 70-80% utilization.
| Метрика | Статический батчинг | Continuous Batching | Улучшение |
|---|---|---|---|
| Токенов в секунду | 1200 | 5800 | 4.8x |
| Запросов в секунду | 45 | 210 | 4.7x |
| P99 latency | 850 мс | 180 мс | 4.7x лучше |
Цифры из тестов vLLM на LLaMA-13B с A100. Важно: улучшение зависит от распределения длин запросов. Если все запросы одинаковой длины, Continuous Batching не дает преимущества. В реальном мире такое почти никогда не случается.
Как внедрить Continuous Batching в свой проект
Писать свою реализацию с нуля — безумие. Есть три практических пути:
- Использовать vLLM — сегодняшний золотой стандарт. Поддерживает большинство популярных моделей, имеет встроенную систему serving с API OpenAI-совместимого формата. Из коробки дает Continuous Batching с paged attention.
- Text Generation Inference (TGI) от Hugging Face — больше ориентирован на их экосистему. Хорошо интегрируется с трансформерами, поддерживает quantization.
- Использовать специализированные фреймворки вроде TensorRT-LLM от NVIDIA или MLC для edge-деплоя.
Если вы разворачиваете модель на своем железе и ожидаете высокую нагрузку, vLLM — самый безопасный выбор. Он абстрагирует всю сложность Continuous Batching за простым API.
Важный нюанс: Continuous Batching требует больше памяти, чем статический батчинг. Нужно резервировать память под рост KV Cache для новых запросов, которые могут прийти в любой момент. Настройте memory limits правильно.
Интеграция с существующей инфраструктурой
Допустим, у вас уже есть очередь запросов и система балансировки нагрузки. Добавление Continuous Batching поверх — это не просто замена библиотеки.
Нужно пересмотреть:
- Мониторинг: традиционные метрики вроде "запросов в секунду" становятся менее информативными. Важнее "токенов в секунду" и "utilization GPU"
- Autoscaling: как определять, когда добавлять новые инстансы? По средней длине очереди? По времени ожидания P95?
- Rate limiting: ограничивать запросы в секунду или токены в секунду? Второе честнее, но сложнее реализовать.
Что будет дальше: эволюция батчинга
Continuous Batching — не конечная точка оптимизации. Уже появляются более агрессивные техники:
Predictive batching: система пытается предсказать длину ответа на основе промпта и распределяет запросы по батчам так, чтобы они завершались примерно одновременно. Машинное обучение для scheduling.
Selective batching: не все слои модели одинаково важны для разных типов запросов. Можно пропускать некоторые attention heads или даже целые слои для "простых" запросов. Особенно актуально для mixture-of-experts моделей.
Cross-request optimization: если в батче несколько запросов с похожими промптами, можно частично переиспользовать вычисления между ними. Как кластеризация промптов, но на уровне отдельных матричных умножений.
Самый радикальный подход — полностью асинхронная генерация, где каждый запрос живет в своем собственном временном потоке, а GPU обрабатывает микробатчики из одного токена от сотен разных запросов. Технически реализовать почти невозможно, но исследования в этом направлении уже ведутся.
Начните с vLLM. Протестируйте на своей нагрузке. Посмотрите на метрики. И приготовьтесь объяснять CFO, почему теперь можно обслуживать в 5 раз больше пользователей на тех же самых GPU.