Привет! Этот пост — перевод очень хардовой статьи про внутренности vLLM и того, как устроен инференс LLM. Переводить было сложно из-за англицизмов и отсутствия устоявшегося перевода многих терминов, но это слишком классная статья, и она обязана быть на русском языке! А дальше — слово автору:
От paged attention, непрерывного батчинга, кэширования префиксов , specdec и т.д. — до мульти-GPU и мультинодового динамического сервинга LLM под нагрузкой.
В этом посте я постепенно представлю все основные системные компоненты и продвинутые функции, которые составляют современную систему инференса LLM с высокой пропускной способностью. И детально разберу, как внутри работает vLLM.
Этот пост структурирован на пять частей:
Движок LLM и ядро движка: основы движка vLLM (планирование, paged attention, непрерывный батчинг (continuous batching) и другие)
Продвинутые функции: префилл по чанкам (chunked prefill), кэширование префиксов (prefix caching), управляемое и спекулятивное декодирование (guided & speculative decoding), разделённые P/D
Масштабирование: от single-GPU до multi-GPU исполнения
Слой сервинга (Serving layer): распределённая / конкурентная веб-инфраструктура (distributed / concurrent web scaffolding)
Бенчмарки и автотюнинг: измерение задержки (latency) и пропускной способности (throughput)
Примечание
Анализ основан на коммите 42172ad (9 августа 2025 года).
Целевая аудитория: все, кому интересно, как работают современные движки LLM, а также те, кто хочет внести вклад в vLLM, SGLang и другие проекты.Я сфокусируюсь на движке V1. Я также исследовал V0 (теперь устаревшую), что помогло понять эволюцию проекта — многие концепции по-прежнему применимы.
Первая часть, посвящённая LLM Engine / Engine Core, может показаться немного перегруженной или сухой — но остальная часть блога содержит множество примеров и иллюстраций. 🙂
Движок LLM — это основополагающий строительный блок vLLM. Сам по себе он уже обеспечивает инференс с высокой пропускной способностью — но только в офлайн-режиме. Использовать его для обслуживания клиентов через веб пока невозможно.
Мы будем использовать следующий фрагмент кода для офлайн-инференса в качестве основного примера (адаптирован из basic.py).
from vllm import LLM, SamplingParams
# Список промптов, которые будут переданы модели
prompts = [
"Привет, меня зовут",
"Столица России — это",
]
# Параметры сэмплирования (sampling) для генерации текста
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
def main():
# Инициализация модели LLM (в данном случае — TinyLlama)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
# Генерация ответов для заданных промптов с использованием параметров
сэмплирования
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
Переменные окружения:
VLLM_USE_V1="1" # используем движок версии V1
VLLM_ENABLE_V1_MULTIPROCESSING="0" # запускаем в одном процессе
Эта конфигурация:
офлайн (без веб/распределенной инфраструктуры)
синхронная (все выполняется в одном блокирующем процессе)
один GPU (без параллелизма данных/модели/конвейера/экспертов; DP/TP/PP/EP = 1 — где DP = data parallelism, TP = tensor parallelism, PP = pipeline parallelism, EP = expert parallelism)
использует стандартный трансформер (поддержка гибридных моделей, таких как Jamba, требует более сложного гибридного аллокатора памяти KV-кэша)
Далее мы постепенно перейдём к онлайн-асинхронной, мульти-GPU, мультинодовой системе инференса — но по-прежнему для стандартного трансформера.
В этом примере мы делаем две вещи:
Создаем движок
Вызываем generate
для сэмплирования ответов по заданным промптам
Давайте начнем анализировать конструктор.
Основные компоненты движка:
vLLM конфиг (содержит все настройки для конфигурирования модели, кеша, параллелизма и прочее)
процессор (превращает сырые входные данные → EngineCoreRequests
через валидацию, токенизацию и обработку)
клиент ядра движка (в нашем рабочем примере мы используем InprocClient
, который по сути равен EngineCore
; далее мы постепенно перейдем к DPLBAsyncMPClient
, который позволяет обслуживать систему в масштабе)
процессор вывода (преобразует сырые EngineCoreOutputs
→ RequestOutput
, который видит пользователь)
С устареванием движка V0 имена классов и детали могут меняться. Я буду подчеркивать основные идеи, а не точные сигнатуры. Я абстрагирую часть деталей, но не все.
Само ядро движка состоит из нескольких подкомпонентов:
Исполнитель модели (Model Executor) (выполняет прямые проходы (forward passes) по модели, в настоящее время мы имеем дело с UniProcExecutor
, который имеет один процесс воркера
на одном GPU). Мы постепенно дойдем до MultiProcExecutor
, который поддерживает несколько GPU
Менеджер структурированного вывода (Structured Output Manager) (используется для управляемого декодирования (guided decoding) — мы рассмотрим это позже)
Планировщик (Scheduler) (решает, какие запросы попадут в следующий шаг движка) — он дополнительно содержит:
настройку политики (policy setting) — это может быть либо FCFS («первым пришёл, первым обслужен»), либо приоритет (priority) (запросы с более высоким приоритетом обслуживаются первыми)
очереди ожидания и выполнения (waiting и running queues);
менеджер KV-кэша — сердце paged attention
Менеджер KV-кэша поддерживает очередь свободных блоков — free_block_queue
, то есть пул доступных блоков KV-кэша (часто их количество достигает сотен тысяч, в зависимости от объёма видеопамяти (VRAM) и размера блока).
Во время paged attention эти блоки служат индексной структурой, которая сопоставляет токены с их соответствующими вычисленными блоками KV-кэша.
Размер блока (block size) для стандартного слоя трансформера (не MLA) вычисляется следующим образом:
2 (key/value)*
block_size
*num_kv_heads
*head_size
*dtype_num_bytes
(где
block_sized по умолчанию 16, type_num_bytes
- например, 2 для bf16)
Инициализация устройства:
Назначить CUDA-устройство (например, "cuda:0") воркеру и проверить, что dtype модели поддерживается (например, bf16)
Проверить, что доступно достаточно VRAM, учитывая запрошенную gpu_memory_utilization
(утилизацию памяти GPU) (например, 0.8 → 80% от общей VRAM)
Настроить распределенные настройки (DP / TP / PP / EP и прочие)
Создать объект model_runner
(раннер модели) (содержит сэмплер, KV-кэш и буферы прямого прохода (forward-pass), такие как input_ids
, positions
и т.д.)
Создать объект объект InputBatch
(батч входных данных) (содержит буферы прямого прохода на стороне CPU, таблицы блоков (block tables) для индексации KV-кэша, метаданные сэмплирования (sampling metadata) и т.д.)
Загрузка модели:
Создать архитектуру модели
Загрузить веса модели
Вызвать model.eval() (режим инференса PyTorch)
Опционально: вызвать torch.compile() на модели
Инициализация KV-кэша:
Получить спецификацию KV-кэша для каждого слоя. Исторически это всегда было FullAttentionSpec
(гомогенный трансформер), но с гибридными моделями (скользящее окно, Transformer/SSM типа Jamba) структура стала более сложной (см. Jenga)
Выполнить пробный/профилирующий прямой проход и сделать снимок памяти GPU для вычисления, сколько блоков KV-кэша помещается в доступную VRAM
Выделить, изменить форму и связать тензоры KV-кэша со слоями внимания
Подготовить метаданные внимания (например, установить бэкенд на FlashAttention), которые затем будут использованы ядрами во время прямого прохода
Если не предоставлен --enforce-eager
, для каждого из размеров батчей прогрева (warmup) выполнить фиктивный запуск и записать CUDA-графы. CUDA-графы записывают всю последовательность работы GPU в DAG (Directed Acyclic Graph). Позже во время прямого прохода мы запускаем/воспроизводим предварительно подготовленные графы и сокращаем накладные расходы на запуск ядер и таким образом улучшаем задержку (latency)
Я абстрагировался здесь от многих низкоуровневых деталей — но это основные части, которые я представлю сейчас, поскольку буду ссылаться на них неоднократно в следующих секциях.
Теперь, когда у нас инициализирован движок, давайте перейдем к функции generate
.
Первым шагом является валидация и подача запросов в движок. Для каждого промпта мы:
Создаем уникальный ID запроса и фиксируем его время поступления
Вызываем препроцессор входных данных (input preprocessor), который токенизирует промпт и возвращает словарь, содержащий prompt
, prompt_token_ids
и type
(текст, токены, эмбеддинги и т.д.)
Упаковываем эту информацию в EngineCoreRequest
, добавляя приоритет, параметры сэмплирования и другие метаданные
Передаем запрос в ядро движка, которое оборачивает его в объект Request
и устанавливает его статус в WAITING
. Этот запрос затем добавляется в очередь ожидания планировщика (для FCFS добавляется в конец и heap-push для приоритета)
На этом этапе движок загружен и исполнение может начаться. В примере синхронного движка эти начальные промпты — единственные, которые мы будем обрабатывать — нет механизма для внедрения новых запросов во время выполнения. В отличие от этого, асинхронный движок поддерживает такую возможность (так называемый continuous batching): после каждого шага учитываются как новые, так и старые запросы.
Поскольку прямой проход сглаживает (flattens) батч в одну последовательность, а пользовательские ядра (custom kernels) обрабатывают это эффективно, непрерывный батчинг фундаментально поддерживается даже в синхронном движке.
Далее, пока есть запросы для обработки, движок повторно вызывает свою функцию step()
. Каждый шаг имеет три стадии:
Планирование: выбрать, какие запросы выполнять на этом шаге (декодирование и/или префилл по чанкам (chunked prefill))
Прямой проход : запустить модель и сэмплировать токены
Постобработка (Postprocess): добавить сэмплированные ID токенов к каждому Request
, детокенизировать и проверить условия остановки. Если запрос завершен, очистить (например, вернуть его блоки KV-кэша в free_block_queue
) и вернуть вывод досрочно
Условия остановки:
Запрос превышает свой лимит длины (
max_model_length
(максимальная длина модели) или собственныйmax_tokens
(максимальное количество токенов))Сэмплированный токен является ID конца последовательности (EOS ID) (если только не включен
ignore_eos
(игнорировать EOS) -> полезно для бенчмаркинга, когда мы хотим принудительно сгенерировать определенное количество выходных токенов)Сэмплированный токен совпадает с любым из
stop_token_ids
(ID токенов остановки), указанных в параметрах сэмплированияСтроки остановки (stop strings) присутствуют в выводе - мы обрезаем вывод до первого появления строки остановки и прерываем запрос в движке (обратите внимание, что
stop_token_ids
будут присутствовать в выводе, но строки остановки не будут)
В потоковом режиме (streaming mode) мы бы отправляли промежуточные токены по мере их генерации, но пока что это проигнорируем.
Далее рассмотрим планирование более детально.
Существует два основных типа рабочих нагрузок, которые обрабатывает движок инференса:
Запросы префилла — прямой проход по всем токенам промпта. Они обычно ограничены вычислениями (compute-bound) и их порог зависит от аппаратного обеспечения и длины промпта. В конце мы сэмплируем один токен из распределения вероятностей позиции финального токена
Запросы декодирования — прямой проход только по самому последнему токену. Все предыдущие KV-векторы уже закешированы. Они ограничены пропускной способностью памяти (memory-bandwidth-bound), поскольку нам всё ещё нужно загружать все веса LLM (и KV-кэши) только для вычисления одного токена
В секции бенчмаркинга мы проанализируем так называемую roofline-модель (roofline model) производительности GPU. Там мы более детально рассмотрим профили производительности префилла/декодирования.
Планировщик V1 может смешивать оба типа запросов на одном шаге благодаря более умным проектным решениям. В отличие от него, движок V0 мог обрабатывать только либо префилл, либо декодирование за раз.
Планировщик приоритизирует запросы декодирования — т.е. те, что уже находятся в очереди выполнения. Для каждого такого запроса он:
Вычисляет количество новых токенов для генерации (не всегда 1, из-за спекулятивного декодирования (speculative decoding) и асинхронного планирования — подробнее об этом позже)
Вызывает функцию allocate_slots
(выделения слотов) менеджера KV-кэша (детали ниже)
Обновляет бюджет токенов, вычитая количество токенов из шага 1
После этого он обрабатывает запросы префилла из очереди ожидания:
Получает количество вычисленных блоков (возвращает 0, если кеширование префиксов отключено — мы рассмотрим это позже)
Вызывает функцию allocate_slots
менеджера KV-кэша
Извлекает запрос из очереди ожидания и перемещает его в очередь выполнения, устанавливая его статус в RUNNING
(выполняется)
Обновляет бюджет токенов
Теперь давайте посмотрим, что делает allocate_slots
:
Вычисляет количество блоков — определяет, сколько новых блоков KV-кэша (n
) должно быть выделено. Каждый блок хранит 16 токенов по умолчанию. Например, если запрос префилла имеет 17 новых токенов, нам нужно ceil(17/16) = 2
блока
Проверяет доступность — если в пуле менеджера недостаточно блоков, выходит досрочно. В зависимости от того, является ли это запросом декодирования или префилла, движок может попытаться выполнить вытеснение через перевычисление (recompute preemption) (вытеснение через своп (swap preemption) поддерживалось в V0) путём вытеснения запросов с низким приоритетом (вызывая kv_cache_
manager.free
, которая возвращает блоки KV в пул блоков), или он может пропустить планирование и продолжить исполнение
Выделяет блоки — через координатор менеджера KV-кэша извлекает первые n
блоков из пула блоков (двусвязный список free_block_queue
, упомянутый ранее). Сохраняет в req_to_blocks
— словарь, отображающий каждый request_id
(ID запроса) на его список блоков KV-кэша
Мы наконец готовы выполнить прямой проход!
Мы вызываем execute_model
исполнителя модели (model executor), который делегирует задачу Worker, а тот, в свою очередь, делегирует её model_runner.
Вот основные шаги:
Обновление состояний — удалить завершенные запросы из input_batch
; обновить различные метаданные, связанные с прямым проходом (например, блоки KV-кэша на запрос, которые будут использоваться для индексации в память paged KV-кэша)
Подготовка входных данных — копировать буферы из CPU→GPU; вычислить позиции; построить slot_mapping
(отображение слотов) (подробнее об этом в примере); сконструировать метаданные внимания (attention metadata)
Прямой проход — запустить модель с пользовательскими ядрами paged attention. Все последовательности сглаживаются и конкатенируются в одну длинную «суперпоследовательность». Индексы позиций и маски внимания гарантируют, что каждая последовательность обращает внимание только на свои собственные токены, что позволяет непрерывный батчинг без выравнивания справа (right-padding)
Сбор состояний последнего токена — извлечь скрытые состояния (hidden states) для финальной позиции каждой последовательности и вычислить логиты
Сэмплирование — сэмплировать токены из вычисленных логитов, как указано в конфигурации сэмплирования (жадное, temperature, top-p, top-k и т.д.)
Сам шаг прямого прохода имеет два режима исполнения:
Режим eager — запустить стандартный прямой проход PyTorch, когда включено немедленное исполнение
Режим «захвата» (Captured) — исполнить/воспроизвести предварительно захваченный CUDA-граф, когда eager не принудительно включен (помните, мы захватили их во время конструирования движка в процедуре инициализации KV-кэша)
Вот конкретный пример, который должен прояснить непрерывный батчинг и paged attention:
Имея базовый flow движка, мы можем рассмотреть продвинутые функции.
Мы уже обсудили вытеснение (preemption), paged attention и непрерывный батчинг.
Далее погрузимся вот во что:
Префилл по чанкам
Кеширование префиксов
Управляемое декодирование (через конечные автоматы, ограниченные грамматикой (grammar-constrained finite-state machines))
Спекулятивное декодирование (Speculative decoding)
Разделенные P/D (Disaggregated prefill/decoding)
Префилл по чанкам — это техника для обработки длинных промптов путем разделения их шага префилла на меньшие чанки. Без нее мы могли бы получить один очень длинный запрос, монополизирующий один шаг движка, блокируя выполнение других запросов префилла. Это бы отложило все другие запросы и увеличило бы их задержку.
Например, пусть каждый чанк содержит n
(=8) токенов, обозначенных строчными буквами, разделенными «-». Длинный промпт P
может выглядеть как x-y-z
, где z
— неполный чанк (например, 2 токена). Выполнение полного префилла для P
тогда займёт ≥ 3 шагов движка (больше может произойти, если он не запланирован для выполнения на одном из шагов), и только на последнем шаге префилла по чанкам мы сэмплируем один новый токен.
Вот тот же пример визуально:
Реализация проста: ограничить количество новых токенов на шаг. Если запрошенное количество превышает long_prefill_token_threshold
(порог длинного префилла), установить его точно на это значение. Базовая логика индексации (описанная ранее) позаботится об остальном.
В vLLM V1 вы включаете префилл по чанкам, устанавливая long_prefill_token_threshold
в положительное целое число. (технически это может произойти независимо от этого, если длина промпта превышает бюджет токенов, мы обрезаем его и запускаем префилл по чанкам)
Чтобы объяснить, как работает кеширование префиксов, давайте возьмём исходный пример кода и немного изменим его:
from vllm import LLM, SamplingParams
long_prefix = "<фрагмент текста, который кодируется в больше, чем block_size токенов>"
prompts = [
"Привет, меня зовут",
"Столица России — это",
]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(long_prefix + prompts[0], sampling_params)
outputs = llm.generate(long_prefix + prompts[1], sampling_params)
if __name__ == "__main__":
main()
Кеширование префиксов позволяет избежать перевычисления токенов, которые несколько промптов разделяют в начале - отсюда префикс.
Ключевой элемент — это long_prefix
(длинный префикс): он определяется как любой префикс длиннее блока KV-кэша (16 токенов по умолчанию). Чтобы упростить наш пример, скажем, что long_prefix
имеет ровно длину n x block_size
(размер блока) (где n ≥ 1
).
т.е. он идеально выравнивается с границей блока — иначе нам пришлось бы перевычислять
long_prefix_len % block_size
токенов, так как мы не можем кешировать неполные блоки
Без кеширования префиксов каждый раз, когда мы обрабатываем новый запрос с тем же long_prefix
, мы бы перевычисляли все n x block_size
токенов.
С кешированием префиксов эти токены вычисляются один раз (их KV сохраняются в страничной памяти KV-кэша) и затем переиспользуются, так что только новые токены промпта требуют обработки. Это ускоряет запросы префилла (хотя это не помогает с декодированием).
Как это работает в vLLM?
Во время первого вызова generate
, на стадии планирования, внутри kv_cache_manager.get_computed_blocks
, движок вызывает hash_request_tokens
:
Эта функция разделяет long_prefix + prompts[0]
на чанки из 16 токенов
Для каждого полного чанка она вычисляет хеш (используя либо встроенный хеш, либо SHA-256, который медленнее, но имеет меньше коллизий). Хеш объединяет хеш предыдущего блока, текущие токены и опциональные метаданные
опциональные метаданные включают: MM hash, LoRA ID, cache salt (внедряется в хеш первого блока, гарантирует, что только запросы с этой солью кеша могут переиспользовать блоки)
Каждый результат сохраняется как объект BlockHash, содержащий как хэш, так и соответствующие token IDs. Возвращается список блок-хэшей.
Список сохраняется в self.req_to_block_hashes[request_id]
.
Далее движок вызывает find_longest_cache_hit
, чтобы проверить, существуют ли уже эти хэши в cached_block_hash_to_block
. Для первого запроса совпадений не находится.
Затем мы вызываем allocate_slots
, который в свою очередь вызывает coordinator.cache_blocks
, связывая новые записи BlockHash с выделенными блоками KV и фиксируя их в cached_block_hash_to_block
.
После этого прямой проход заполнит KVs в памяти paged KV cache, соответствующей блокам KV, которые мы выделили выше.
После нескольких шагов работы движка будут выделены дополнительные блоки KV-кэша, но для нашего примера это не имеет значения, так как префикс расходится сразу после long_prefix
При втором вызове generate
с тем же префиксом шаги 1–3 повторяются, но теперь find_longest_cache_hit
находит совпадения для всех n блоков (через линейный поиск). Движок может напрямую повторно использовать эти блоки KV.
Если бы исходный запрос все еще был активен, счётчик ссылок для этих блоков увеличился бы (например, до 2). В этом примере первый запрос уже завершeн, поэтому блоки были возвращены в пул, а их счeтчики ссылок сброшены обратно в 0. Поскольку мы смогли получить их из cached_block_hash_to_block
, мы знаем, что они валидны (логика менеджера KV-кэша устроена именно так), и поэтому просто снова удаляем их из free_block_queue.
Блоки KV-кэша становятся недействительными только в тот момент, когда они собираются быть перераспределены из free_block_queue (которая извлекает элементы слева) и мы обнаруживаем, что блок всё ещё имеет связанный хэш и присутствует в
cached_block_hash_to_block
. В этот момент мы очищаем хэш блока и удаляем его запись изcached_block_hash_to_block
, гарантируя, что блок не сможет быть повторно использован через prefix caching (по крайней мере для старого префикса).
И вот суть prefix caching: не нужно повторно вычислять префиксы, которые вы уже видели — просто повторно используйте их KV-кэш!
Если вы поняли этот пример, вы также поняли, как работает paged attention.
Prefix caching включено по умолчанию. Чтобы отключить его: enable_prefix_caching = False
.
Управляемое декодирование — это техника, при которой на каждом шаге декодирования логиты ограничиваются конечным автоматом на основе грамматики. Это гарантирует, что могут быть сэмплированы только токены, разрешeнные грамматикой.
Это мощная настройка: вы можете применять что угодно — от регулярных грамматик (тип-3 по Хомскому, например, произвольные паттерны regex) вплоть до контекстно-свободных грамматик (тип-2, которые охватывают большинство языков программирования).
Чтобы сделать это менее абстрактным, давайте начнем с простейшего возможного примера, основываясь на нашем предыдущем коде:
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
prompts = [
"Полный отстой",
"Погодка сегодня прекрасная",
]
guided_decoding_params = GuidedDecodingParams(choice=["Positive", "Negative"])
sampling_params = SamplingParams(guided_decoding=guided_decoding_params)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
Представим игрушечный пример (предположим токенизацию на уровне символов): на префилле FSM маскирует логиты, так что только «P» или «N» возможны. Если сэмплируется «P», FSM переходит к ветке «Positive»; на следующем шаге разрешена только «o», и так далее.
Как это работает в vLLM:
При конструировании движка LLM создаётся StructuredOutputManager
(менеджер структурированного вывода); он имеет доступ к токенизатору и поддерживает тензор grammarbitmask
(битовой маски грамматики)
При добавлении запроса его статус устанавливается в WAITING_FOR_FSM
(ожидание FSM), и grammar_init
выбирает компилятор бэкенда (например, xgrammar
; обратите внимание, что бэкенды — это сторонний код)
Грамматика для этого запроса компилируется асинхронно
Во время планирования, если асинхронная компиляция завершена, статус переключается на WAITING
(ожидание), и request_id
добавляется в structured_output_request_ids
(ID запросов структурированного вывода); иначе он помещается в skipped_waiting_requests
(пропущенные ожидающие запросы) для повторной попытки на следующем шаге движка
После цикла планирования (всe ещe внутри планирования), если есть FSM-запросы, StructuredOutputManager
просит бэкенд подготовить/обновить grammarbitmask
После того как прямой проход производит логиты, функция xgr_torch_compile расширяет битовую маску до размера словаря (коэффициент расширения 32x, потому что мы используем 32-битные целые числа) и маскирует недопустимые логиты до –∞
После сэмплирования следующего токена FSM запроса продвигается через accept_tokens
(принять токены). Визуально мы переходим к следующему состоянию на диаграмме FSM
Шаг 6 заслуживает дальнейших пояснений.
Если vocab_size = 32
(размер словаря), grammarbitmask
— это одно целое число; его двоичное представление кодирует, какие токены разрешены («1») против недопустимых («0»). Например, «101…001» расширяется в массив длиной 32 [1, 0, 1, ..., 0, 0, 1]
; позиции с 0 получают логиты, установленные в –∞. Для больших словарей используются несколько 32-битных слов и соответственно расширяются/конкатенируются. Бэкенд (например, xgrammar
) отвечает за создание этих битовых паттернов, используя текущее состояние конечного автомата (FSM).
Большая часть сложности здесь скрыта в сторонних библиотеках, таких как xgrammar
Вот ещё более простой пример с vocab_size = 8 (размер словаря) и 8-битными целыми числами (для тех из вас, кто любит визуализации):
Вы можете включить это в vLLM, передав желаемый конфиг guided_decoding
.
При авторегрессивной генерации для получения каждого нового токена требуется прямой проход через большую языковую модель. Это дорогая операция — на каждом шаге приходится загружать и применять все веса модели только для вычисления одного токена! (при размере батча == 1, в общем случае это B
)
Спекулятивное декодирование решает эту проблему, используя дополнительную маленькую драфт-модель (draft model). Драфт-модель быстро предлагает k
токенов-кандидатов. Но окончательное решение всe равно принимает большая модель — маленькая только предсказывает возможные продолжения. Это гарантирует качество генерации большой модели при меньших затратах.
Алгоритм работает так:
Драфт: маленькая модель обрабатывает текущий контекст и предлагает k
токенов
Верификация: большая модель делает один проход по контексту вместе с k
драфт-токенами. Получаем вероятности для этих k
позиций плюс ещё одна дополнительная (итого k+1
кандидат)
Принятие/отклонение: проверяем k
драфт-токенов слева направо:
Если вероятность токена по большой модели ≥ вероятности по драфт-модели, принимаем его
Иначе принимаем с вероятностью p_large(token)/p_draft(token)
Останавливаемся при первом отклонении, либо принимаем все k
токенов
Если приняли все k
драфт-токенов, дополнительно «бесплатно» сэмплируем (k+1)
-й токен из большой модели (распределение уже вычислено)
При отклонении создаeм новое ребалансированное распределение в этой позиции (p_large - p_draft
, обрезаем отрицательные значения, нормализуем) и сэмплируем из него
Почему это работает: правило принятия/отклонения математически гарантирует, что результирующее распределение последовательности совпадает с тем, как если бы мы генерировали токены один за другим только большой моделью. Спекулятивное декодирование статистически эквивалентно обычному авторегрессивному декодированию, но потенциально намного быстрее — один проход большой модели может дать до k+1
токенов.
Рекомендую посмотреть на gpt-fast для простой реализации, и оригинальную статью для математических деталей и доказательства эквивалентности сэмплированию из полной модели.
vLLM V1 не поддерживает метод LLM драфт-модели, вместо этого он реализует более быстрые — но менее точные — схемы предложения токенов: n-грамма (n-gram), EAGLE и Medusa.
Краткое описание каждого:
n-грамма: взять последние prompt_lookup_max
(максимальное окно поиска в промпте) токенов; найти предыдущее совпадение в последовательности; если найдено, предложить k
токенов, которые следовали за этим совпадением; иначе уменьшить окно и повторить попытку до prompt_lookup_min
(минимальное окно поиска)
Текущая реализация возвращает
k
токенов после первого совпадения. Кажется более естественным ввести смещение в пользу недавних совпадений и развернуть направление поиска? (т.е. последнее совпадение)
Eagle: выполнить «хирургию модели» (model surgery) на большой LM — сохранить эмбеддинги и голову языковой модели (LM head), заменить стек трансформера на лeгкий MLP; дообучить это как дешёвый драфт
Medusa: обучить вспомогательные линейные головы (auxiliary linear heads) поверх (эмбеддинги перед головой LM) большой модели для параллельного предсказания следующих k
токенов; использовать эти головы для более эффективного предложения токенов, чем запуск отдельной маленькой LM
Вот как вызвать спекулятивное декодирование в vLLM, используя ngram
в качестве метода драфта:
from vllm import LLM, SamplingParams
prompts = [
"Привет, меня зовут",
"Столица России — это",
]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
speculative_config={
"method": "ngram",
"prompt_lookup_max": 5,
"prompt_lookup_min": 3,
"num_speculative_tokens": 3,
}
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", speculative_config=speculative_config)
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
Как это работает в vLLM?
Настройка (во время конструирования движка):
Инициализация устройства: создать drafter
(драфтер, драфт-модель, например, NgramProposer
) и rejection_sampler
(сэмплер отклонения) (части его написаны на Triton).
Загрузка модели: загрузить веса драфт-модели (пустая операция для n-граммы)
После этого в функции generate
(предположим, мы получаем совершенно новый запрос):
Выполнить обычный шаг префилла с большой моделью.
После прямого прохода и стандартного сэмплирования вызвать propose_draft_token_ids(k)
(предложить ID драфт-токенов) для сэмплирования k
драфт-токенов из драфт-модели
Сохранить их в request.spec_token_ids
(ID спекулятивных токенов запроса) (обновить метаданные запроса)
На следующем шаге движка, когда запрос находится в очереди выполнения, добавить len(request.spec_token_ids)
к счётчику «новых токенов», чтобы allocate_slots
зарезервировала достаточно блоков KV для прямого прохода
Скопировать spec_token_ids
в input_batch.token_ids_cpu
(ID токенов батча входных данных на CPU) для формирования токенов (контекст + драфт)
Вычислить метаданные через calcspec_decode_metadata
(это копирует токены из input_batch.token_ids_cpu
, подготавливает логиты и т.д.), затем запустить прямой проход большой модели по драфт-токенам
Вместо обычного сэмплирования из логитов использовать rejection_sampler
для принятия/отклонения слева направо и производства output_token_ids
(ID выходных токенов)
Повторить шаги 2-7 до тех пор, пока не будет выполнено условие остановки
Лучший способ усвоить это — запустить отладчик и пройтись по кодовой базе, но эта секция, надеюсь, дает представление об этом. Это тоже:
Я уже ранее намекал на мотивацию разделенных P/D (префилл/декодирование).
Префилл и декодирование имеют очень разные профили производительности (ограничены вычислениями против ограничены пропускной способностью памяти), поэтому разделение их исполнения — разумное проектное решение. Это даёт более жeсткий контроль над задержкой — как TFTT
(time-to-first-token, время до первого токена), так и ITL
(inter-token latency, задержка между токенами) — подробнее об этом в секции бенчмаркинга.
На практике мы запускаем N
инстансов vLLM для префилла и M
инстансов vLLM для декодирования, автоматически масштабируя их на основе актуального микса запросов. Воркеры префилла записывают KV в выделенный сервис KV-кэша; воркеры декодирования читают из него. Это изолирует длинный, пульсирующий префилл от стабильного, чувствительного к задержке декодирования.
Как это работает в vLLM?
Для ясности пример ниже опирается на SharedStorageConnector
, отладочную реализацию коннектора (connector) , используемую для иллюстрации механики.
Коннектор — это абстракция vLLM для обработки обмена KV между инстансами. Интерфейс коннектора ещe не стабилен, запланированы некоторые краткосрочные улучшения, которые повлекут изменения, некоторые потенциально ломающие (breaking).
Мы запускаем 2 инстанса vLLM (GPU 0 для префилла и GPU 1 для декодирования), а затем передаeм KV-кэш между ними:
import os
import time
from multiprocessing import Event, Process
import multiprocessing as mp
from vllm import LLM, SamplingParams
from vllm.config import KVTransferConfig
prompts = [
"Привет, меня зовут",
"Столица России — это",
]
def run_prefill(prefill_done):
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=1)
ktc=KVTransferConfig(
kv_connector="SharedStorageConnector",
kv_role="kv_both",
kv_connector_extra_config={"shared_storage_path": "local_storage"},
)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)
llm.generate(prompts, sampling_params)
prefill_done.set() # уведомить инстанс декодирования, что KV-кэш готов
# Чтобы поддерживать ноду префилла работающим в случае, если нода декодирования ещё не завершён;
# иначе скрипт может завершиться преждевременно, вызывая неполное декодирование
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Скрипт остановлен пользователем")
def run_decode(prefill_done):
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
sampling_params = SamplingParams(temperature=0, top_p=0.95)
ktc=KVTransferConfig(
kv_connector="SharedStorageConnector",
kv_role="kv_both",
kv_connector_extra_config={"shared_storage_path": "local_storage"},
)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)
prefill_done.wait() # блокировать, ожидая KV-кэш от инстанса префилла
# Внутренне он сначала получит KV-кэш перед запуском цикла декодирования
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
prefill_done = Event()
prefill_process = Process(target=run_prefill, args=(prefill_done,))
decode_process = Process(target=run_decode, args=(prefill_done,))
prefill_process.start()
decode_process.start()
decode_process.join()
prefill_process.terminate()
Я также экспериментировал с
LMCache
, самым быстрым коннектором, готовым к продакшену (использует NVIDIA NIXL в качестве бэкенда), но он всe ещe находится на самом переднем крае, и я столкнулся с некоторыми багами. Поскольку большая часть его сложности находится во внешнем репозитории,SharedStorageConnector
— лучший выбор для объяснения
Это шаги в vLLM:
Создание — во время конструирования движка коннекторы создаются в двух местах:
Внутри процедуры инициализации устройства воркера (в функции инициализации распределённого окружения воркера), с ролью «worker»
Внутри конструктора планировщика, с ролью «scheduler»
Поиск в кеше — когда планировщик обрабатывает запросы префилла из очереди waiting
(после локальных проверок кеша префиксов), он вызывает get_num_new_matched_tokens
коннектора. Это проверяет наличие внешне закешированных токенов на сервере KV-кэша. Префилл всегда видит здесь 0; декодирование может иметь попадание в кеш (cache hit). Результат добавляется к локальному счётчику перед вызовом allocate_slots
Обновление состояния — затем планировщик вызывает connector.update_state_after_alloc
, который записывает запросы, имевшие кеш (пустая операция для префилла)
Построение объекта метаданных — в конце планирования планировщик вызывает meta =
connector.build
_connector_meta
:
Префилл добавляет все запросы с is_store=True
(для загрузки KV).
Декодирование добавляет запросы с is_store=False
(для получения KV).
Контекстный менеджер — перед прямым проходом движок входит в контекстный менеджер KV-коннектора:
при входе: вызывается kv_connector.start_load_kv
. Для декодирования это загружает KV с внешнего сервера и внедряет его в страничную память. Для префилла это пустая операция
при выходе: вызывается kv_connector.wait_for_save
. Для префилла это блокирует до тех пор, пока KV не будет загружен на внешний сервер. Для декодирования это пустая операция
Вот визуальный пример:
Для SharedStorageConnector
«внешний сервер» — это просто локальная файловая система
В зависимости от конфигурации передачи KV также могут выполняться слой за слоем (до/после каждого слоя внимания)
Декодирование загружает внешний KV только один раз, на первом шаге своих запросов; после этого оно вычисляет/сохраняет локально
Разобравшись с основными техниками, мы можем перейти к масштабированию.
Предположим, веса вашей модели перестали помещаться в памяти одного GPU.
Первое решение — распределить модель по нескольким GPU на одном узле через параллелизм тензоров (tensor parallelism) (например, TP=8
). Если модель все еще не помещается, следующий шаг — конвейерный параллелизм (pipeline parallelism) между узлами.
Пропускная способность внутри узла (intranode bandwidth) значительно выше, чем между узлами (internode), поэтому параллелизм тензоров (TP) обычно предпочтительнее конвейерного параллелизма (PP) (также верно, что PP передаёт меньше данных, чем TP)
Я не рассматриваю параллелизм экспертов (expert parallelism, EP), поскольку мы фокусируемся на стандартных трансформерах, а не на MoE (смеси экспертов), и не рассматриваю параллелизм последовательностей (sequence parallelism), так как TP и PP — наиболее часто используемые на практике
На этом этапе нам нужны несколько процессов GPU (воркеров) и оркестрационный слой для их координации. Это именно то, что предоставляет MultiProcExecutor
.
Как это работает в vLLM:
MultiProcExecutor
инициализирует очередь сообщений rpc_broadcast_mq
(реализована через общую память)
Конструктор проходит по всем рангам от 0 до world_size
(общее количество воркеров, например при TP=8
имеем world_size=8
) и порождает демон-процесс для каждого ранга через WorkerProc.make_worker_process
Для каждого воркера родительский процесс создаёт пару каналов (pipe) для чтения и записи
Новый процесс запускает WorkerProc.worker_main
, который создает воркер (проходя те же этапы «инициализации устройства», «загрузки модели» и т.д., что и в UniprocExecutor
)
Каждый воркер определяет свою роль — драйвер (driver, ранг 0 в группе TP) или обычный воркер. Все воркеры настраивают две очереди:
rpc_broadcast_mq
(общая с родительским процессом) для получения рабочих заданий
worker_response_mq
для отправки результатов обратно
При инициализации каждый дочерний процесс отправляет дескриптор своей worker_response_mq
родителю через канал. Когда получены дескрипторы от всех воркеров, родитель разблокируется — координация завершена
Воркеры входят в цикл активного ожидания, блокируясь на rpc_broadcast_mq.dequeue
. При поступлении рабочего задания они его исполняют (аналогично UniprocExecutor
, но с работой, разделённой согласно TP/PP). Результаты отправляются через worker_response_mq.enqueue
При получении запроса MultiProcExecutor
помещает его в rpc_broadcast_mq
(неблокирующая операция) для всех дочерних воркеров. Затем ожидает результат от назначенного выходного ранга через worker_response_mq.dequeue
С точки зрения движка ничего не изменилось — вся эта сложность мультипроцессинга абстрагирована через вызов execute_model
исполнителя модели.
В случае UniProcExecutor
: execute_model напрямую приводит к вызову execute_model на воркере
В случае MultiProcExecutor
: execute_model косвенно приводит к вызову execute_model на каждом воркере через rpc_broadcast_mq
На этом этапе мы можем запускать модели настолько большие, насколько позволяют ресурсы, используя тот же интерфейс движка.
Следующий шаг — масштабирование вширь (scale out): включить параллелизм данных (data parallelism, DP > 1
), реплицируя модель по узлам, добавить лeгкий слой координации DP, ввести балансировку нагрузки между репликами и разместить один или несколько API-серверов перед ними для обработки входящего трафика.
Существует много способов настройки инфраструктуры сервинга, но чтобы быть конкретными, вот один пример: предположим, у нас есть два узла H100 и мы хотим запустить четыре движка vLLM на них.
Если модель требует TP=4
, мы можем настроить узлы следующим образом.
На первой ноде запускаем движок в режиме headless (без API-сервера) со следующими аргументами:
vllm serve <model-name>
--tensor-parallel-size 4
--data-parallel-size 4
--data-parallel-size-local 2
--data-parallel-start-rank 0
--data-parallel-address <master-ip>
--data-parallel-rpc-port 13345
--headless
и запускаем ту же команду на другой ноде с небольшими изменениями:
без --headless
с измененным стартовым рангом DP (DP start rank)
vllm serve <model-name>
--tensor-parallel-size 4
--data-parallel-size 4
--data-parallel-size-local 2
--data-parallel-start-rank 2
--data-parallel-address <master-ip>
--data-parallel-rpc-port 13345
Предполагается, что сеть настроена так, что все ноды имеют доступ к указанному IP и порту.
Как это работает в vLLM?
На headless-ноде CoreEngineProcManager
запускает 2 процесса (согласно --data-parallel-size-local
), каждый из которых выполняет EngineCoreProc.run_engine_core
. Каждая из этих функций создаeт DPEngineCoreProc
(ядро движка) и затем входит в свой цикл активного ожидания (busy loop).
DPEngineCoreProc
инициализирует свой родительский EngineCoreProc
(потомок EngineCore
), который:
Создаёт input_queue
(очередь входных данных) и output_queue
(очередь выходных данных) (queue.Queue
)
Выполняет начальное рукопожатие с фронтендом на другой ноде, используя сокет DEALER
ZMQ (библиотека асинхронного обмена сообщениями), и получает информацию об адресе координации
Инициализирует группу DP (например, используя бэкенд NCCL)
Инициализирует EngineCore
с MultiProcExecutor
(TP=4
на 4 GPU, как описано ранее)
Создаeт ready_event
(событие готовности) (threading.Event
)
Запускает демон-поток входных данных (threading.Thread
), выполняющий process_input_sockets(…, ready_event)
. Аналогично запускает поток выходных данных
Всё ещё в главном потоке ожидает ready_event
до тех пор, пока все потоки входных данных во всех 4 процессах (охватывающих 2 ноды) не завершат координационное рукопожатие, наконец выполняя ready_event.set()
После разблокировки отправляет сообщение «готов» (ready) фронтенду с метаданными (например, num_gpu_blocks
(количество GPU-блоков), доступных в памяти страничного KV-кэша)
Главный поток, потоки входных и выходных данных затем входят в свои соответствующие циклы активного ожидания
Кратко: В итоге получаем 4 дочерних процесса (по одному на реплику DP), каждый из которых запускает главный поток, поток входных и выходных данных. Они завершают координационное рукопожатие с координатором DP и фронтендом, затем все три потока на процесс работают в установившихся циклах активного ожидания.
Текущее установившееся состояние:
Поток входных данных — блокируется на входном сокете до тех пор, пока запрос не будет маршрутизирован от API-сервера; при получении декодирует payload (полезную нагрузку), ставит рабочий элемент в очередь через input_queue.put_nowait(...)
и возвращается к блокировке на сокете
Главный поток — пробуждается на input_queue.get(...)
, подаёт запрос движку; MultiProcExecutor
выполняет прямой проход и ставит результаты в очередь output_queue
Поток выходных данных — пробуждается на output_queue.get(...)
, отправляет результат обратно API-серверу, затем возобновляет блокировку
Дополнительные механики:
Счётчик волн DP — система отслеживает «волны»; когда все движки становятся неактивными, они переходят в состояние покоя, и счeтчик увеличивается при поступлении новой работы (полезно для координации/метрик)
Управляющие сообщения — API-сервер может отправлять не только запросы инференса (например, прерывания и утилитарные/управляющие RPC)
Фиктивные шаги для синхронного выполнения (lockstep) — если у любой реплики DP есть работа, все реплики выполняют шаг прямого прохода; реплики без запросов выполняют фиктивный шаг для участия в обязательных точках синхронизации (избегает блокировки активной реплики)
Уточнение о синхронном выполнении (lockstep): это фактически требуется только для моделей MoE, где слои экспертов формируют группу EP или TP, в то время как слои внимания остаются DP. В настоящее время это всегда делается с DP — просто потому что «встроенный» DP для не-MoE моделей имеет ограниченное применение, поскольку вы можете просто запустить несколько независимых инстансов vLLM и балансировать нагрузку между ними обычным способом.
Теперь вторая часть — что происходит на ноде с API-сервером?
Мы создаем объект AsyncLLM
(асинхронная обертка asyncio вокруг движка LLM). Внутренне это создает DPLBAsyncMPClient
(клиент с параллелизмом данных, балансировкой нагрузки, асинхронный и мультипроцессный).
Внутри родительского класса MPClient
выполняется функция launch_core_engines
:
Создает ZMQ-адреса, используемые для стартового рукопожатия (как видно на headless-ноде)
Порождает процесс DPCoordinator
(координатора DP)
Создает CoreEngineProcManager
(так же, как на headless-ноде)
Внутри AsyncMPClient
(потомок MPClient
) мы:
Создаем outputs_queue
(очередь выходных данных) (asyncio.Queue
)
Создаем asyncio-задачу process_outputs_socket
, которая коммуницирует (через выходной сокет) с потоками выходных данных всех 4 DPEngineCoreProc
и записывает в outputs_queue
Затем еще одна asyncio-задача output_handler
из AsyncLLM
читает из этой очереди и, наконец, отправляет информацию функции create_completion
Внутри DPAsyncMPClient
мы создаем asyncio-задачу run_engine_stats_update_task
, которая коммуницирует с координатором DP.
Координатор DP выступает посредником между фронтендом (API-сервером) и бэкендом (ядрами движков):
Периодически отправляет информацию о балансировке нагрузки (размеры очередей, количество запросов в состояниях ожидания и выполнения) в run_engine_stats_update_task
фронтенда
Обрабатывает команды SCALE_ELASTIC_EP
от фронтенда путем динамического изменения количества движков (работает только с бэкендом Ray)
Отправляет события START_DP_WAVE
бэкенду (при триггере от фронтенда) и сообщает об обновлениях состояния волны обратно
Подводя итог, фронтенд (AsyncLLM
) запускает несколько asyncio-задач (важно: конкурентные, не параллельные):
Класс задач обрабатывает входящие запросы через путь generate
(каждый новый клиентский запрос порождает новую asyncio-задачу)
Две задачи (process_outputs_socket
, output_handler
) обрабатывают выходные сообщения от базовых движков
Одна задача (run_engine_stats_update_task
) поддерживает связь с координатором DP: отправляет триггеры волн, опрашивает состояние балансировки нагрузки и обрабатывает запросы динамического масштабирования
Наконец, главный процесс сервера создает приложение FastAPI и монтирует endpoints (конечные точки), такие как OpenAIServingCompletion
и OpenAIServingChat
, которые предоставляют /completion
, /chat/completion
и другие. Затем стек обслуживается через Uvicorn.
Итак, собирая все вместе, вот полный жизненный цикл запроса!
Вы отправляете из терминала:
curl -X POST http://localhost:8000/v1/completions -H "Content-Type: application/json" -d '{
"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
"prompt": "The capital of France is",
"max_tokens": 50,
"temperature": 0.7
}'
Что происходит далее:
Запрос поступает в эндпоинт create_completion
класса OpenAIServingCompletion
на API-сервере
Функция асинхронно токенизирует промпт и подготавливает метаданные (ID запроса, параметры сэмплирования, временную метку и т.д.)
Затем вызывается AsyncLLM.generate
, который идет по тому же пути, что и синхронный движок, в итоге вызывая DPAsyncMPClient.add_request_async
Это вызывает get_core_engine_for_request
, который балансирует нагрузку между движками на основе состояния координатора DP (выбирает движок с минимальным показателем нагрузки: score = len(waiting) * 4 + len(running)
)
Запрос ADD
отправляется во входной сокет (input_socket
) выбранного движка
На этом движке:
Поток входных данных — пробуждается, декодирует данные из входного сокета и помещает задачу в input_queue
для главного потока
Главный поток — пробуждается на input_queue
, добавляет запрос в движок и циклически вызывает engine_core.step()
, помещая промежуточные результаты в output_queue
до выполнения условия остановки
Напоминание:
step()
вызывает планировщик, исполнитель модели (который в свою очередь может бытьMultiProcExecutor
!), и т.д. Мы уже видели это!
Поток выходных данных — разблокируется на output_queue
и отправляет результаты обратно через выходной сокет
Эти результаты активируют asyncio-задачи вывода AsyncLLM
(process_outputs_socket
и output_handler
), которые передают токены обратно в эндпоинт create_completion
FastAPI
FastAPI добавляет метаданные (причина завершения, логарифмы вероятностей (logprobs), информация об использовании и т.д.) и возвращает JSONResponse
через Uvicorn в ваш терминал!
И вот так ваше completion вернулось — вся распределенная механика скрыта за простой командой curl
! :) Ну классно же!
При добавлении большего количества API-серверов балансировка нагрузки обрабатывается на уровне ОС/сокетов. С точки зрения приложения ничего существенного не меняется — сложность скрыта
С Ray в качестве бэкенда DP вы можете предоставить URL-эндпоинт (
/scale_elastic_ep
), который позволяет автоматическое масштабирование количества реплик движка вверх или вниз
Бенчмарки и автонастройка - задержка vs пропускная способность
До сих пор мы анализировали «частицы газа» — внутреннее устройство того, как запросы проходят через движок/систему. Теперь пора отдалиться и посмотреть на систему в целом, и задать вопрос: как мы измеряем производительность системы инференса?
На самом высоком уровне существует две конкурирующие метрики:
Задержка (Latency) — время от момента отправки запроса до возвращения токенов
Пропускная способность (Throughput) — количество токенов/запросов в секунду, которое система может генерировать/обрабатывать
Задержка наиболее важна для интерактивных приложений, где пользователи ожидают ответов.
Пропускная способность важна в оффлайн-задачах, таких как генерация синтетических данных для запусков пре/пост-обучения, очистка/обработка данных, и в целом — любые типы оффлайн-задач пакетного инференса.
Прежде чем объяснить, почему задержка и пропускная способность конкурируют, давайте определим несколько распространенных метрик инференса:
Метрика | Определение |
---|---|
| Время от отправки запроса до получения первого выходного токена |
| Время между двумя последовательными токенами (например, от токена i-1 до токена i) |
| Средняя ITL по всем выходным токенам в запросе |
| Полное время обработки запроса, т.е. TTFT + сумма всех ITL, или эквивалентно время между отправкой запроса и получением последнего выходного токена |
| Общее количество токенов, обработанных в секунду (входных, выходных или обоих), или альтернативно запросов в секунду |
| Пропускная способность, соответствующая целям уровня обслуживания (SLO), таким как максимальная TTFT, TPOT или сквозная задержка. Например, учитываются только токены из запросов, соответствующих этим SLO |
Вот упрощенная модель, объясняющая конкурирующую природу этих двух метрик.
узким местом является I/O весов модели, а не KV-кэша; т.е. мы работаем с короткими последовательностями.
Компромисс становится очевидным, если посмотреть, как размер батча B
влияет на один шаг декодирования. При B ↓
к 1 задержка ITL падает: на шаг приходится меньше работы, и токен не "конкурирует" с другими. При B ↑
к бесконечности ITL растет, потому что мы выполняем больше операций с плавающей точкой (FLOP) на шаг — но пропускная способность улучшается (пока не достигнем пиковой производительности), потому что I/O весов амортизируется по большему количеству токенов.
Roofline-модель помогает это понять: ниже батча насыщения B_sat
время шага определяется пропускной способностью HBM (потоковая передача весов слой за слоем в память на чипе), поэтому задержка шага почти постоянна — вычисление 1 или 10 токенов может занять примерно одинаковое время. После B_sat
ядра становятся ограничены вычислениями, и время шага растет примерно пропорционально B
; каждый дополнительный токен добавляет к ITL.
Для более строгого рассмотрения нам нужно учесть автонастройку ядер: по мере роста B
среда выполнения может переключаться на более эффективные ядра для этой формы данных, изменяя достигнутую производительность P_kernel
. Задержка шага составляет t = FLOPs_step / P_kernel
, где FLOPs_step
— это объем работы на шаге. Видно, что когда P_kernel
достигает P_peak
(пиковой производительности), больше вычислений на шаг напрямую приведет к увеличению задержки.
vLLM предоставляет CLI vllm bench {serve,latency,throughput}
, который оборачивает vllm / benchmarks / {server,latency,throughput}.py.
Вот что делают скрипты:
latency — использует короткий ввод (по умолчанию 32 токена) и сэмплирует 128 выходных токенов с маленьким батчем (по умолчанию 8). Выполняет несколько итераций и сообщает сквозную задержку (e2e latency) для батча
throughput — отправляет фиксированный набор промптов (по умолчанию: 1000 примеров ShareGPT) все сразу (т.е. в режиме QPS=Inf
- бесконечное количество запросов в секунду), и сообщает количество входных/выходных/всего токенов и запросов в секунду за весь запуск
serve — Запускает сервер vLLM и симулирует реальную рабочую нагрузку, сэмплируя времена между прибытиями запросов из распределения Пуассона (или более общего гамма-распределения). Отправляет запросы в течение временного окна, измеряет все метрики, которые мы обсуждали, и может опционально применять максимальную конкурентность на стороне сервера (через семафор, например, ограничивая сервер до 64 конкурентных запросов)
Вот пример того, как вы можете запустить скрипт latency:
vllm bench latency
--model <model-name>
--input-tokens 32
--output-tokens 128
--batch-size 8
Конфигурации бенчмарков, используемые в CI, находятся в
.buildkite/nightly-benchmarks/tests
Также существует скрипт автонастройки, который управляет бенчмарком serve для поиска настроек аргументов, соответствующих целевым SLO (например, «максимизировать пропускную способность, сохраняя p99 сквозной задержки < 500 мс»), возвращая предлагаемую конфигурацию.
Мы начали с базового ядра движка (UniprocExecutor
), добавили продвинутые функции вроде спекулятивного декодирования и кеширования префиксов, перешли к MultiProcExecutor
(с TP/PP > 1
), и наконец масштабировались горизонтально, обернув все в асинхронный движок и распределенный стек для сервинга — закончив тем, как измерять производительность системы.
vLLM также включает специализированную обработку, которую я не рассматривал. Например:
Разные аппаратные бэкенды: TPU, AWS Neuron (Trainium/Inferentia) и другие.
Архитектуры/техники: MLA
, MoE
, энкодер-декодер (например, Whisper), модели пулинга/эмбеддингов, EPLB
, m-RoPE
, LoRA
, ALiBi
, варианты без механизма внимания, внимание со скользящим окном (sliding-window attention), мультимодальные LM и модели пространства состояний (state-space models) (например, Mamba/Mamba-2, Jamba)
TP/PP/SP
Гибридная логика KV-кэша (Jenga), более сложные методы сэмплирования вроде лучевого поиска (beam sampling) и многое другое
Экспериментальное: асинхронное планирование
Хорошая новость в том, что большинство этих компонентов независимы от основного потока, описанного выше — их можно почти рассматривать как «плагины» (хотя на практике, конечно, существует некоторая связность).
Мне нравится разбираться в системах. При этом на такой высоте обзора детализация неизбежно страдает. В следующих постах я сфокусируюсь на конкретных подсистемах и погружусь в детали.
Спасибо! Это был перевод (крайне непростой и очень трудозатратный), а вот мои самонаписанные крафтовые статейки (и да — тг-канальчик Agentic World):