Материал последовательно раскрывает все основные компоненты и продвинутые возможности современной высокопроизводительной системы инференса LLM — на примере разбора внутреннего устройства vLLM [1].

Это первая часть серии. Изложение построено по принципу «перевёрнутой пирамиды»: сначала общая картина, затем детали, — чтобы сформировать точную ментальную модель всей системы, не утонув в мелочах.

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

Статья разбита на пять частей:

  1. LLM engine и engine core: основы vLLM (планирование, paged attention, continuous batching и т.д.)
  2. Продвинутые возможности: chunked prefill, prefix caching, guided и speculative decoding, disaggregated P/D
  3. Масштабирование: от одного GPU к многопроцессорному выполнению
  4. Уровень обслуживания: распределённая/конкурентная веб-инфраструктура
  5. Бенчмарки и авто-тюнинг: измерение задержки и пропускной способности
📝 Заметки
  • Анализ основан на коммите 42172ad (9 августа 2025).
  • Целевая аудитория: все, кому интересно, как устроены современные LLM-движки, а также те, кто хочет внести вклад в vLLM, SGLang и подобные проекты.
  • Речь пойдёт о движке V1. Также был изучен V0 (теперь устаревший) — это оказалось полезным для понимания эволюции проекта, многие концепции сохранились.
  • Первый раздел про LLM Engine / Engine Core может показаться суховатым и перегруженным — но дальше будет много примеров и иллюстраций. :)

LLM Engine и Engine Core

LLM engine — фундаментальный строительный блок vLLM. Сам по себе он уже обеспечивает высокую пропускную способность инференса, но только в офлайн-режиме — обслуживать клиентов через веб пока нельзя.

В качестве сквозного примера используется следующий фрагмент офлайн-инференса (адаптация basic.py).

from vllm import LLM, SamplingParams

prompts = [
    "Hello, my name is",
    "The president of the United States is",
]

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(prompts, sampling_params)

if __name__ == "__main__":
    main()
📝 Переменные окружения:
  • VLLM_USE_V1="1" # используется движок V1
  • VLLM_ENABLE_V1_MULTIPROCESSING="0" # выполнение в одном процессе

Эта конфигурация:

  • офлайн (без веб/распределённой инфраструктуры)
  • синхронная (всё выполнение — в одном блокирующем процессе)
  • однопроцессорная по GPU (без параллелизма данных/модели/конвейера/экспертов; DP/TP/PP/EP = 1)
  • использует стандартный трансформер [2] (гибридные модели вроде Jamba требуют более сложного гибридного аллокатора памяти KV-кеша)

Далее конфигурация будет постепенно наращиваться до онлайн, асинхронной, многопроцессорной, многоузловой системы инференса — но всё ещё для стандартного трансформера.

В примере выполняются два действия:

  1. Создаётся экземпляр движка
  2. Вызывается generate для сэмплирования по заданным промптам

Начнём с разбора конструктора.

Конструктор LLM Engine

Основные компоненты движка:

  • vLLM config (все настройки модели, кеша, параллелизма и т.д.)
  • processor (превращает сырые входные данные в EngineCoreRequests через валидацию, токенизацию и обработку)
  • engine core client (в примере используется InprocClient, который по сути равен EngineCore; далее будет рассмотрен DPLBAsyncMPClient, позволяющий обслуживать нагрузку в масштабе)
  • output processor (конвертирует сырые EngineCoreOutputs в RequestOutput, который видит пользователь)
📝 Примечание:
С учётом того, что движок V0 устаревает, имена классов и детали реализации могут меняться. Акцент делается на ключевых идеях, а не на точных сигнатурах. Часть деталей опущена, но не все.

Engine core состоит из нескольких подкомпонентов:

  • Model Executor (запускает прямые проходы модели; в текущем примере это UniProcExecutor с единственным процессом Worker на одном GPU). Далее будет рассмотрен MultiProcExecutor, поддерживающий несколько GPU
  • Structured Output Manager (используется для guided decoding — рассмотрим позже)
  • Scheduler (решает, какие запросы попадут в следующий шаг движка) — включает:
    1. политику — либо FCFS (первым пришёл — первым обслужен), либо priority (сначала обслуживаются более приоритетные запросы)
    2. очереди waiting и running
    3. KV-cache manager — сердце paged attention [3]

KV-cache manager поддерживает free_block_queue — пул доступных блоков KV-кеша (часто порядка сотен тысяч, в зависимости от объёма VRAM и размера блока). В paged attention эти блоки служат индексирующей структурой, отображающей токены на вычисленные для них блоки KV-кеша.

LLM engine constructor
Основные компоненты, описанные в этом разделе, и их связи
Размер блока для стандартного слоя трансформера (не-MLA [4]) вычисляется так:
2 (key/value) × block_size (по умолчанию 16) × num_kv_heads × head_size × dtype_num_bytes (например, 2 для bf16)

При создании model executor создаётся объект Worker и выполняются три ключевые процедуры. (Позже, с MultiProcExecutor, эти же процедуры будут выполняться независимо на каждом воркер-процессе на разных GPU.)

  1. Инициализация устройства:
    • Назначение CUDA-устройства (например, "cuda:0") воркеру и проверка поддержки dtype модели (например, bf16)
    • Проверка достаточности VRAM с учётом запрошенного gpu_memory_utilization (например, 0.8 → 80% всей VRAM)
    • Настройка распределённых параметров (DP / TP / PP / EP и т.д.)
    • Создание model_runner (хранит sampler, KV-кеш и буферы прямого прохода, такие как input_ids, positions и т.д.)
    • Создание объекта InputBatch (хранит CPU-буферы прямого прохода, таблицы блоков для индексации KV-кеша, метаданные сэмплирования и т.д.)
  2. Загрузка модели:
    • Создание архитектуры модели
    • Загрузка весов модели
    • Вызов model.eval() (режим инференса PyTorch)
    • Опционально: вызов torch.compile() на модели
  3. Инициализация KV-кеша
    • Получение спецификации KV-кеша по слоям. Раньше это всегда был FullAttentionSpec (однородный трансформер), но с гибридными моделями (sliding window, Transformer/SSM как Jamba) всё усложнилось (см. Jenga [5])
    • Запуск фиктивного/профилирующего прямого прохода и снятие снимка памяти GPU для расчёта числа блоков KV-кеша, помещающихся в доступную VRAM
    • Выделение, изменение формы и привязка тензоров KV-кеша к слоям attention
    • Подготовка метаданных attention (например, установка бэкенда FlashAttention), которые позже используются ядрами во время прямого прохода
    • Если не указан --enforce-eager, для каждого warmup batch size выполняется тестовый запуск и захват CUDA graph. CUDA graph записывает всю последовательность работы GPU в виде DAG. Позже во время прямого прохода запускаются/воспроизводятся заранее подготовленные графы, что снижает накладные расходы на запуск ядер и улучшает задержку.

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

Теперь, когда движок инициализирован, перейдём к функции generate.

Функция generate

Первый шаг — валидация и передача запросов в движок. Для каждого промпта:

  1. Создаётся уникальный ID запроса и фиксируется время его поступления
  2. Вызывается input preprocessor, который токенизирует промпт и возвращает словарь с prompt, prompt_token_ids и type (текст, токены, эмбеддинги и т.д.)
  3. Эта информация упаковывается в EngineCoreRequest с добавлением приоритета, параметров сэмплирования и прочих метаданных
  4. Запрос передаётся в engine core, который оборачивает его в объект Request и устанавливает статус WAITING. Запрос добавляется в очередь waiting планировщика (append для FCFS, или heap-push для приоритетной очереди)

На этом этапе движок «накормлен» и может начинать выполнение. В синхронном примере эти исходные промпты — единственные, что будут обработаны: механизма добавления новых запросов в процессе выполнения нет. Асинхронный движок это поддерживает (так называемый continuous batching [6]): после каждого шага рассматриваются и новые, и старые запросы.

Поскольку прямой проход разворачивает батч в единую последовательность, а специализированные ядра эффективно с этим справляются, continuous batching фактически поддерживается даже в синхронном движке.

Далее, пока есть запросы для обработки, движок многократно вызывает функцию step(). Каждый шаг состоит из трёх стадий:

  1. Планирование: выбор запросов для выполнения на этом шаге (decode и/или (chunked) prefill)
  2. Прямой проход: запуск модели и сэмплирование токенов
  3. Постобработка: добавление сэмплированных ID токенов к каждому Request, детокенизация и проверка условий остановки. Если запрос завершён — очистка (например, возврат блоков KV-кеша в free_block_queue) и досрочный возврат результата
📝 Условия остановки:
  • Запрос превышает лимит длины (max_model_length или собственный max_tokens)
  • Сэмплированный токен — это EOS (если только не включён ignore_eos — полезно при бенчмаркинге, когда нужно принудительно сгенерировать заданное число выходных токенов)
  • Сэмплированный токен совпадает с одним из stop_token_ids, указанных в параметрах сэмплирования
  • В выходе присутствуют стоп-строки — вывод обрезается до первого появления стоп-строки, и запрос прерывается в движке (при этом stop_token_ids будут присутствовать в выводе, а стоп-строки — нет)
Engine loop
Цикл движка
В режиме стриминга промежуточные токены отправлялись бы по мере генерации, но пока это не рассматривается.

Далее — более подробный разбор планирования.

Планировщик

Есть два основных типа нагрузки, с которыми работает движок инференса:

  1. Prefill-запросы — прямой проход по всем токенам промпта. Обычно они compute-bound (порог зависит от железа и длины промпта). В конце сэмплируется один токен из распределения вероятностей для последней позиции.
  2. Decode-запросы — прямой проход только по самому последнему токену. Все более ранние KV-вектора уже закешированы. Такие запросы memory-bandwidth-bound, поскольку всё равно нужно загрузить все веса модели (и KV-кеши) ради вычисления одного токена.
В разделе про бенчмаркинг будет рассмотрена так называемая roofline-модель производительности GPU — там подробнее разбираются профили производительности prefill/decode.

Планировщик V1 может смешивать оба типа запросов в одном шаге благодаря более удачным архитектурным решениям. Для сравнения, движок V0 мог обрабатывать за раз либо только prefill, либо только decode.

Планировщик отдаёт приоритет decode-запросам — то есть тем, что уже находятся в очереди running. Для каждого такого запроса он:

  1. Вычисляет число новых токенов для генерации (не всегда 1 — из-за спекулятивного декодирования и асинхронного планирования, об этом позже).
  2. Вызывает функцию allocate_slots KV-cache manager'а (подробности ниже).
  3. Обновляет токен-бюджет, вычитая число токенов из шага 1.

После этого обрабатываются prefill-запросы из очереди waiting:

  1. Извлекается число уже вычисленных блоков (возвращает 0, если prefix caching отключён — об этом позже).
  2. Вызывается функция allocate_slots KV-cache manager'а.
  3. Запрос извлекается из waiting и перемещается в running, статус устанавливается в RUNNING.
  4. Обновляется токен-бюджет.

Теперь о том, что делает allocate_slots:

  1. Вычисление числа блоков — определяется, сколько новых блоков KV-кеша (n) нужно выделить. Каждый блок по умолчанию хранит 16 токенов. Например, если у prefill-запроса 17 новых токенов, нужно ceil(17/16) = 2 блока.
  2. Проверка доступности — если в пуле менеджера недостаточно блоков, происходит ранний выход. В зависимости от типа запроса (decode или prefill) движок может попытаться выполнить recompute preemption (вытеснение с перевычислением; swap preemption поддерживалось в V0), вытеснив низкоприоритетные запросы (вызвав kv_cache_manager.free, который возвращает блоки KV в пул блоков), либо может пропустить планирование и продолжить выполнение.
  3. Выделение блоков — через координатор KV-cache manager'а извлекаются первые n блоков из пула блоков (упомянутый ранее двусвязный список free_block_queue). Результат сохраняется в req_to_blocks — словарь, отображающий каждый request_id на список его блоков KV-кеша.
KV cache blocks
Список блоков KV-кеша

Теперь можно переходить к прямому проходу!

Прямой проход

Вызывается execute_model у model executor, который делегирует выполнение Worker, а тот, в свою очередь, — model runner'у.

Основные шаги:

  1. Обновление состояний — из input_batch удаляются завершённые запросы; обновляются служебные метаданные прямого прохода (например, блоки KV-кеша для каждого запроса, используемые для индексации в paged KV-cache памяти).
  2. Подготовка входных данных — копирование буферов CPU→GPU; вычисление позиций; построение slot_mapping (подробнее в примере); формирование метаданных attention.
  3. Прямой проход — запуск модели со специализированными paged-attn ядрами. Все последовательности разворачиваются и конкатенируются в одну длинную «супер-последовательность». Индексы позиций и маски внимания гарантируют, что каждая последовательность обращается только к своим токенам, что позволяет реализовать continuous batching без right-padding.
  4. Извлечение состояний последнего токена — извлекаются скрытые состояния для последней позиции каждой последовательности и вычисляются логиты.
  5. Сэмплирование — токены сэмплируются из полученных логитов согласно конфигурации сэмплирования (greedy, temperature, top-p, top-k и т.д.).

Сам шаг прямого прохода имеет два режима выполнения:

  1. Eager-режим — стандартный прямой проход PyTorch, когда включено eager-выполнение.
  2. «Захваченный» режим — выполнение/воспроизведение заранее захваченного CUDA Graph, когда eager не принудительно включён (напомним, эти графы захватывались при инициализации движка на этапе инициализации KV-кеша).

Вот конкретный пример, который должен прояснить работу continuous batching и paged attention:

fwd pass - continuous batching & paged attn
Прямой проход: continuous batching и paged attention

Продвинутые возможности — расширение базовой логики движка

С базовым потоком выполнения движка разобрались — теперь можно перейти к продвинутым возможностям.

Preemption, paged attention и continuous batching уже были рассмотрены.

Далее пойдёт речь о:

  1. Chunked prefill
  2. Prefix caching
  3. Guided decoding (через конечные автоматы, ограниченные грамматикой)
  4. Speculative decoding
  5. Disaggregated P/D (prefill/decoding)

Chunked prefill

Chunked prefill — техника обработки длинных промптов путём разбиения шага prefill на более мелкие фрагменты (чанки). Без неё один очень длинный запрос мог бы монополизировать целый шаг движка, не позволяя выполняться другим prefill-запросам. Это откладывало бы обработку всех остальных запросов и увеличивало их задержку.

Например, пусть каждый чанк содержит n (=8) токенов, обозначенных строчными буквами через «-». Длинный промпт P может выглядеть как x-y-z, где z — неполный чанк (например, 2 токена). Выполнение полного prefill для P заняло бы ≥ 3 шага движка (может быть и больше, если запрос не попадает в планирование на каком-то из шагов), и только на последнем чанке будет сэмплирован новый токен.

Тот же пример визуально:

Chunked prefilling - pt 1

Реализация проста: ограничивается число новых токенов за шаг. Если запрошенное число превышает long_prefill_token_threshold, оно сбрасывается ровно до этого значения. Описанная ранее логика индексации делает всё остальное.

В vLLM V1 chunked prefill включается установкой long_prefill_token_threshold в положительное целое число. (Технически это может произойти и независимо от этого параметра: если длина промпта превышает токен-бюджет, она обрезается и выполняется chunked prefill.)

Prefix Caching

Чтобы объяснить, как работает prefix caching, возьмём исходный пример кода и немного его изменим:

from vllm import LLM, SamplingParams

long_prefix = "<a piece of text that is encoded into more than block_size tokens>"

prompts = [
    "Hello, my name is",
    "The president of the United States is",
]

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()

Prefix caching позволяет избежать повторного вычисления токенов, которые несколько промптов разделяют в начале — отсюда и «prefix».

Ключевой элемент здесь — long_prefix: он определяется как любой префикс длиннее блока KV-кеша (16 токенов по умолчанию). Для простоты примера пусть long_prefix имеет длину ровно n × block_size (где n ≥ 1).

То есть он идеально совпадает с границей блока — иначе пришлось бы пересчитывать long_prefix_len % block_size токенов, так как неполные блоки кешировать нельзя.

Без prefix caching при каждой обработке нового запроса с тем же long_prefix пришлось бы заново вычислять все n × block_size токенов.

С prefix caching эти токены вычисляются один раз (их KV сохраняются в paged-памяти KV-кеша), а затем переиспользуются, поэтому обрабатывать нужно только новые токены промпта. Это ускоряет prefill-запросы (хотя decode это не касается).

Как это работает в vLLM?

При первом вызове generate, на этапе планирования, внутри kv_cache_manager.get_computed_blocks, движок вызывает hash_request_tokens:

  1. Функция разбивает long_prefix + prompts[0] на чанки по 16 токенов.
  2. Для каждого полного чанка вычисляется хеш (встроенный хеш или SHA-256, который медленнее, но даёт меньше коллизий). Хеш объединяет хеш предыдущего блока, текущие токены и опциональные метаданные.
  3. Опциональные метаданные включают: MM hash, LoRA ID, cache salt (внедряется в хеш первого блока и гарантирует, что переиспользовать блоки смогут только запросы с этим же cache salt).
  4. Каждый результат сохраняется как объект BlockHash, содержащий хеш и ID токенов. Возвращается список хешей блоков.

Список сохраняется в self.req_to_block_hashes[request_id].

Далее движок вызывает find_longest_cache_hit, чтобы проверить, есть ли эти хеши уже в cached_block_hash_to_block. При первом запросе совпадений нет.

Prefix caching logic - pt 1

Затем вызывается allocate_slots, который вызывает coordinator.cache_blocks, связывающий новые записи BlockHash с выделенными блоками KV и сохраняющий их в cached_block_hash_to_block.

После этого прямой проход заполнит KV в paged-памяти KV-кеша, соответствующие выделенным выше блокам.

После многих шагов движка будет выделено больше блоков KV-кеша, но для этого примера это не важно, поскольку промпты расходятся сразу после long_prefix.
Prefix caching logic - pt 2

При втором вызове generate с тем же префиксом шаги 1-3 повторяются, но теперь find_longest_cache_hit находит совпадения для всех n блоков (через линейный поиск). Движок может напрямую переиспользовать эти блоки KV.

Prefix caching logic - pt 3

Если бы исходный запрос был всё ещё активен, счётчик ссылок для этих блоков увеличился бы (например, до 2). В этом примере первый запрос уже завершён, поэтому блоки были возвращены в пул, а их счётчики ссылок сброшены до 0. Поскольку их удалось получить из cached_block_hash_to_block, известно, что они валидны (логика KV-cache manager'а устроена именно так), поэтому их просто снова убирают из 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.

Guided Decoding (FSM)

Guided decoding — техника, при которой на каждом шаге декодирования логиты ограничиваются грамматическим конечным автоматом (finite state machine). Это гарантирует, что сэмплированы могут быть только токены, допустимые грамматикой.

Это мощный механизм: можно накладывать ограничения от регулярных грамматик (тип 3 по Хомскому, например произвольные регулярные выражения) вплоть до контекстно-свободных грамматик (тип 2, покрывающий большинство языков программирования).

Для наглядности — простейший пример на основе предыдущего кода:

from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams

prompts = [
    "This sucks",
    "The weather is beautiful",
]

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()

В этом игрушечном примере (предположим посимвольную токенизацию): на этапе prefill FSM маскирует логиты так, что допустимы только "P" или "N". Если сэмплировано "P", FSM переходит в ветку "Positive"; на следующем шаге допустима только "o", и так далее.

FSM
Игрушечный пример FSM

Как это работает в vLLM:

  1. При создании LLM engine создаётся StructuredOutputManager; у него есть доступ к токенизатору, и он хранит тензор _grammar_bitmask.
  2. При добавлении запроса его статус устанавливается в WAITING_FOR_FSM, а grammar_init выбирает бэкенд-компилятор (например, xgrammar [7]; учтите, что бэкенды — это сторонний код).
  3. Грамматика для запроса компилируется асинхронно.
  4. Во время планирования, если асинхронная компиляция завершена, статус меняется на WAITING, а request_id добавляется в structured_output_request_ids; иначе запрос попадает в skipped_waiting_requests для повторной попытки на следующем шаге движка.
  5. После цикла планирования (всё ещё внутри планирования), если есть FSM-запросы, StructuredOutputManager просит бэкенд подготовить/обновить _grammar_bitmask.
  6. После того как прямой проход выдаёт логиты, функция xgr_torch_compile расширяет битовую маску до размера словаря (коэффициент расширения 32x, так как используются 32-битные целые числа) и маскирует запрещённые логиты значением –∞.
  7. После сэмплирования следующего токена FSM запроса продвигается вперёд через accept_tokens. Визуально это переход в следующее состояние на диаграмме FSM.

Шаг 6 требует дополнительного пояснения.

Если vocab_size = 32, _grammar_bitmask — это одно целое число; его двоичное представление кодирует, какие токены разрешены ("1"), а какие нет ("0"). Например, "101…001" разворачивается в массив длиной 32: [1, 0, 1, …, 0, 0, 1]; позициям с 0 присваивается логит –∞. Для более крупных словарей используется несколько 32-битных слов, которые соответственно разворачиваются и конкатенируются. Бэкенд (например, xgrammar) отвечает за генерацию этих битовых паттернов на основе текущего состояния FSM.

📝 Примечание:
Основная сложность здесь скрыта в сторонних библиотеках вроде xgrammar.

Ещё более простой пример с vocab_size = 8 и 8-битными целыми числами:

FSM
Игрушечный пример

Включить это в vLLM можно, передав нужную конфигурацию guided_decoding.

Speculative Decoding

При авторегрессивной генерации каждый новый токен требует прямого прохода большой языковой модели. Это дорого — на каждом шаге заново загружаются и применяются все веса модели ради вычисления одного-единственного токена (при размере батча == 1, в общем случае это B)!

Speculative decoding [8] ускоряет этот процесс за счёт введения меньшей черновой (draft) модели. Draft-модель дёшево предлагает k токенов. Но в конечном счёте сэмплировать из меньшей модели не хочется — она нужна лишь для того, чтобы угадывать варианты продолжения. Решает, что валидно, всё равно большая модель.

Шаги:

  1. Draft: малая модель запускается на текущем контексте и предлагает k токенов
  2. Verify: большая модель запускается один раз на контексте + k черновых токенах. Это даёт вероятности для этих k позиций плюс ещё одну (итого k+1 кандидатов)
  3. Accept/reject: проходя слева направо по k черновым токенам:
    • Если вероятность большой модели для черновoго токена ≥ вероятности draft-модели — токен принимается
    • Иначе — принимается с вероятностью p_large(token)/p_draft(token)
    • Остановка на первом отклонении, либо принятие всех k черновых токенов.
      • Если все k черновых токенов приняты, из большой модели «бесплатно» сэмплируется ещё и дополнительный (k+1)-й токен (это распределение уже было вычислено).
      • Если было отклонение — на этой позиции строится новое перебалансированное распределение (p_large - p_draft, отрицательные значения обнуляются, нормализация до суммы 1), и последний токен сэмплируется из него.

Почему это работает: хотя для предложения кандидатов используется малая модель, правило accept/reject гарантирует, что в математическом ожидании последовательность распределена точно так же, как если бы токены сэмплировались по одному из большой модели. Это значит, что speculative decoding статистически эквивалентен стандартному авторегрессивному декодированию — но потенциально гораздо быстрее, поскольку один проход большой модели может дать до k+1 токенов.

📝 Примечание:
Стоит посмотреть на gpt-fast как на простую реализацию, а также на оригинальную статью для математических деталей и доказательства эквивалентности сэмплированию из полной модели.

vLLM V1 не поддерживает метод с полноценной draft-LLM-моделью, вместо этого реализованы более быстрые, но менее точные схемы предложения: n-gram, EAGLE [9] и Medusa [10].

Кратко о каждой:

  1. n-gram: берутся последние prompt_lookup_max токенов; ищется предшествующее совпадение в последовательности; если найдено — предлагаются k токенов, следовавших за этим совпадением; иначе окно уменьшается и поиск повторяется вплоть до prompt_lookup_min
  2. Текущая реализация возвращает k токенов после первого совпадения. Логичнее выглядело бы ввести смещение в сторону недавности и искать в обратном направлении (то есть последнее совпадение)?
  3. Eagle: над большой LM выполняется своего рода «хирургия» — эмбеддинги и LM head сохраняются, а стек трансформера заменяется лёгким MLP; эта конструкция дообучается как дешёвая draft-модель
  4. Medusa: поверх большой модели (перед LM head) обучаются дополнительные линейные головы, предсказывающие следующие k токенов параллельно; эти головы используются для более эффективного предложения токенов, чем запуск отдельной малой LM

Вот как вызвать speculative decoding в vLLM с использованием ngram в качестве draft-метода:

from vllm import LLM, SamplingParams

prompts = [
    "Hello, my name is",
    "The president of the United States is",
]

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?

Настройка (при создании движка):

  1. Инициализация устройства: создаётся drafter (черновая модель, например NgramProposer) и rejection_sampler (часть которого написана на Triton).
  2. Загрузка модели: загружаются веса draft-модели (для n-gram — no-op).

Далее в функции generate (предположим, поступил совершенно новый запрос):

  1. Выполняется обычный prefill-шаг с большой моделью.
  2. После прямого прохода и стандартного сэмплирования вызывается propose_draft_token_ids(k) для сэмплирования k черновых токенов из draft-модели.
  3. Они сохраняются в request.spec_token_ids (обновление метаданных запроса).
  4. На следующем шаге движка, когда запрос находится в очереди running, к счётчику «новых токенов» добавляется len(request.spec_token_ids), чтобы allocate_slots зарезервировал достаточно блоков KV для прямого прохода.
  5. spec_token_ids копируются в input_batch.token_ids_cpu, формируя (контекст + черновые) токены.
  6. Метаданные вычисляются через _calc_spec_decode_metadata (копируются токены из input_batch.token_ids_cpu, готовятся логиты и т.д.), затем выполняется прямой проход большой модели по черновым токенам.
  7. Вместо обычного сэмплирования из логитов используется rejection_sampler для последовательного принятия/отклонения токенов слева направо и формирования output_token_ids.
  8. Шаги 2-7 повторяются до выполнения условия остановки.

Лучший способ разобраться в этом — запустить дебаггер и пройтись по коду шаг за шагом, но этот раздел, надеюсь, даёт общее представление. Вот ещё иллюстрации:

Drafting stage
Verify stage & rejection sampling stage

Disaggregated P/D

Мотивация к разделению prefill и decode (disaggregated P/D) уже упоминалась ранее.

Prefill и decode имеют совершенно разные профили производительности (compute-bound против memory-bandwidth-bound), поэтому разделение их выполнения — разумное архитектурное решение. Это даёт более тонкий контроль над задержкой — как TTFT (время до первого токена), так и ITL (задержка между токенами) — подробнее об этом в разделе про бенчмаркинг.

На практике запускаются N экземпляров vLLM для prefill и M экземпляров для decode, с автомасштабированием на основе текущего микса запросов. Prefill-воркеры записывают KV в отдельный сервис KV-кеша; decode-воркеры читают оттуда. Это изолирует длинный, «пульсирующий» prefill от стабильного, чувствительного к задержке decode.

Как это работает в vLLM?

Для наглядности в примере ниже используется SharedStorageConnector — отладочная реализация коннектора, иллюстрирующая механику работы.

Connector — абстракция vLLM для обмена KV между экземплярами. Интерфейс коннектора пока не стабилен, в ближайшее время планируются доработки, некоторые из которых могут быть breaking changes.

Запускаются 2 экземпляра vLLM (GPU 0 — для prefill, GPU 1 — для decode), затем 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 = [
    "Hello, my name is",
    "The president of the United States is",
]

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()  # notify decode instance that KV cache is ready

  # To keep the prefill node running in case the decode node is not done;
  # otherwise, the script might exit prematurely, causing incomplete decoding.
  try:
      while True:
          time.sleep(1)
  except KeyboardInterrupt:
      print("Script stopped by user.")

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()  # block waiting for KV cache from prefill instance

  # Internally it'll first fetch KV cache before starting the decoding loop
  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 [11] — самый быстрый готовый к продакшену коннектор (использует NVIDIA NIXL в качестве бэкенда), но он всё ещё на переднем крае разработки, и были обнаружены баги. Поскольку большая часть его сложности вынесена во внешний репозиторий, SharedStorageConnector лучше подходит для объяснения.

Вот шаги в vLLM:

  1. Инстанцирование — при создании движка коннекторы создаются в двух местах:
    • внутри процедуры init device воркера (в рамках инициализации распределённого окружения воркера), с ролью "worker".
    • внутри конструктора планировщика, с ролью "scheduler".
  2. Поиск в кеше — когда планировщик обрабатывает prefill-запросы из очереди waiting (после локальных проверок prefix-cache), он вызывает get_num_new_matched_tokens коннектора. Это проверка наличия внешне закешированных токенов на сервере KV-кеша. Для prefill здесь всегда 0; у decode может быть попадание в кеш. Результат добавляется к локальному счётчику перед вызовом allocate_slots.
  3. Обновление состояния — планировщик вызывает connector.update_state_after_alloc, который фиксирует запросы, для которых был найден кеш (для prefill — no-op).
  4. Построение метаданных — в конце планирования планировщик вызывает meta = connector.build_connector_meta:
    • Prefill добавляет все запросы с is_store=True (для выгрузки KV).
    • Decode добавляет запросы с is_store=False (для получения KV).
  5. Контекстный менеджер — перед прямым проходом движок входит в контекстный менеджер KV-коннектора:
    • При входе: вызывается kv_connector.start_load_kv. Для decode это загружает KV с внешнего сервера и внедряет их в paged-память. Для prefill — no-op.
    • При выходе: вызывается kv_connector.wait_for_save. Для prefill это блокирует выполнение до завершения выгрузки KV на внешний сервер. Для decode — no-op.

Наглядный пример:

disaggregated P/D
disaggregated P/D
📝 Дополнительные заметки:
  • Для SharedStorageConnector «внешний сервер» — это просто локальная файловая система.
  • В зависимости от конфигурации передача KV может выполняться также послойно (до/после каждого слоя attention).
  • Decode загружает внешний KV только один раз, на первом шаге своих запросов; далее вычисляет/хранит локально.

От UniprocExecutor к MultiProcExecutor

С базовыми техниками разобрались — теперь речь о масштабировании.

Допустим, веса модели больше не помещаются в VRAM одного GPU.

Первый вариант — разбить модель на несколько GPU в пределах одного узла с помощью tensor parallelism (например, TP=8). Если модель по-прежнему не помещается, следующий шаг — pipeline parallelism между узлами.

📝 Заметки:
  • Пропускная способность внутри узла значительно выше, чем между узлами, поэтому tensor parallelism (TP) обычно предпочтительнее pipeline parallelism (PP). (Также верно, что PP передаёт меньше данных, чем TP.)
  • Expert parallelism (EP) здесь не рассматривается, поскольку речь идёт о стандартных трансформерах, а не MoE; аналогично не рассматривается sequence parallelism, так как на практике чаще всего применяются именно TP и PP.

На этом этапе нужны несколько процессов GPU (воркеров) и уровень оркестрации для их координации. Именно это и предоставляет MultiProcExecutor.

MultiProcExecutor
MultiProcExecutor в конфигурации TP=8 (driver worker с rank 0)

Как это работает в vLLM:

  1. MultiProcExecutor инициализирует очередь сообщений rpc_broadcast_mq (реализована через shared memory).
  2. Конструктор проходит по world_size (например, TP=8 ⇒ world_size=8) и запускает демон-процесс для каждого rank через WorkerProc.make_worker_process.
  3. Для каждого воркера родительский процесс сначала создаёт пару pipe для чтения и записи.
  4. Новый процесс выполняет WorkerProc.worker_main, который создаёт воркер (проходя через те же этапы "init device", "load model" и т.д., что и в UniprocExecutor).
  5. Каждый воркер определяет, является ли он driver'ом (rank 0 в TP-группе) или обычным воркером. Каждый воркер настраивает две очереди:
    • rpc_broadcast_mq (общая с родителем) для получения заданий.
    • worker_response_mq для отправки ответов обратно.
  6. Во время инициализации каждый дочерний процесс отправляет свой хендл worker_response_mq родителю через pipe. Как только все получены, родитель разблокируется — координация завершена.
  7. Затем воркеры входят в busy loop, блокируясь на rpc_broadcast_mq.dequeue. Когда приходит рабочий элемент, они его выполняют (так же, как в UniprocExecutor, но теперь с частями работы, разделёнными по TP/PP). Результаты отправляются обратно через worker_response_mq.enqueue.
  8. В runtime, при поступлении запроса, MultiProcExecutor помещает его в rpc_broadcast_mq (неблокирующим образом) для всех дочерних воркеров. Затем он ожидает на worker_response_mq.dequeue назначенного выходного rank'а сбора итогового результата.

С точки зрения движка ничего не изменилось — вся эта сложность многопроцессности скрыта за вызовом execute_model у model executor'а.

  • В случае UniProcExecutor: execute_model напрямую приводит к вызову execute_model на воркере
  • В случае MultiProcExecutor: execute_model приводит к вызову execute_model на каждом воркере через rpc_broadcast_mq

На этом этапе становится возможным запускать модели настолько большие, насколько позволяют ресурсы, используя тот же интерфейс движка.

Следующий шаг — масштабирование «вширь»: включение data parallelism (DP > 1), реплицирующего модель по узлам, добавление лёгкого слоя координации DP, балансировка нагрузки между репликами и размещение перед ними одного или нескольких API-серверов для обработки входящего трафика.

Распределённая система обслуживания vLLM

Настроить инфраструктуру обслуживания можно множеством способов, но для конкретики рассмотрим пример: два узла H100, на которых нужно запустить четыре движка vLLM.

Если модели требуется TP=4, узлы можно сконфигурировать так.

server configuration with 2 8xH100 nodes
Конфигурация сервера с двумя узлами 8xH100 (1 headless, 1 api server)

На первом узле движок запускается в 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 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-узле сервера

На headless-узле CoreEngineProcManager запускает 2 процесса (по значению --data-parallel-size-local), каждый из которых выполняет EngineCoreProc.run_engine_core. Каждая из этих функций создаёт DPEngineCoreProc (engine core) и затем входит в свой busy loop.

DPEngineCoreProc инициализирует родительский EngineCoreProc (дочерний по отношению к EngineCore), который:

  1. Создаёт input_queue и output_queue (queue.Queue).
  2. Выполняет начальное рукопожатие с фронтендом на другом узле через DEALER ZMQ-сокет (библиотека асинхронного обмена сообщениями) и получает адресную информацию для координации.
  3. Инициализирует DP-группу (например, используя NCCL-бэкенд).
  4. Инициализирует EngineCore с MultiProcExecutor (TP=4 на 4 GPU, как описано ранее).
  5. Создаёт ready_event (threading.Event).
  6. Запускает поток-демон ввода (threading.Thread), выполняющий process_input_sockets(…, ready_event). Аналогично запускается поток вывода.
  7. В главном потоке ожидает ready_event, пока все потоки ввода на всех 4 процессах (охватывающих 2 узла) не завершат рукопожатие координации, после чего вызывается ready_event.set().
  8. После разблокировки отправляется сообщение "ready" фронтенду с метаданными (например, num_gpu_blocks, доступными в paged-памяти KV-кеша).
  9. Главный, входной и выходной потоки затем входят в свои busy loop'ы.

Итог: получаем 4 дочерних процесса (по одному на реплику DP), каждый со своим главным, входным и выходным потоком. Они завершают рукопожатие координации с DP-координатором и фронтендом, после чего все три потока каждого процесса работают в устойчивых busy loop'ах.

distributed system with 4 DPEngineCoreProc
Распределённая система с 4 репликами DP, выполняющими 4 DPEngineCoreProc

Текущее устойчивое состояние:

  • Входной поток — блокируется на входном сокете, пока API-сервер не направит запрос; при получении декодирует полезную нагрузку, ставит рабочий элемент в очередь через input_queue.put_nowait(...) и возвращается к блокировке на сокете.
  • Главный поток — просыпается на input_queue.get(...), передаёт запрос движку; MultiProcExecutor выполняет прямой проход и ставит результаты в output_queue.
  • Выходной поток — просыпается на output_queue.get(...), отправляет результат обратно API-серверу, затем снова блокируется.

Дополнительная механика:

  • Счётчик DP wave — система отслеживает «волны»: когда все движки становятся простаивающими, они переходят в режим покоя, а счётчик увеличивается при поступлении новой работы (полезно для координации/метрик).
  • Управляющие сообщения — API-сервер может отправлять не только запросы на инференс (например, отмены и служебные/управляющие RPC).
  • Фиктивные шаги для lockstep — если у любой из реплик DP есть работа, все реплики выполняют шаг прямого прохода; реплики без запросов выполняют фиктивный шаг, чтобы участвовать в требуемых точках синхронизации (это позволяет не блокировать активную реплику).
Уточнение по lockstep: на самом деле это требуется только для MoE-моделей, где слои экспертов формируют EP- или TP-группу, тогда как слои attention остаются DP. Сейчас это всегда делается вместе с DP — просто потому, что «встроенный» не-MoE DP имеет ограниченную пользу, ведь можно просто запустить несколько независимых экземпляров vLLM и балансировать между ними обычным способом.

Теперь о второй части — что происходит на узле API-сервера?

На узле API-сервера

Создаётся объект AsyncLLM (asyncio-обёртка вокруг LLM engine). Внутри создаётся DPLBAsyncMPClient (data-parallel, load-balancing, асинхронный, многопроцессный клиент).

Внутри родительского класса MPClient выполняется функция launch_core_engines, которая:

  1. Создаёт ZMQ-адреса, используемые для рукопожатия при запуске (как видели на headless-узле).
  2. Запускает процесс DPCoordinator.
  3. Создаёт CoreEngineProcManager (аналогично headless-узлу).

Внутри AsyncMPClient (дочернего по отношению к MPClient):

  1. Создаётся outputs_queue (asyncio.Queue).
  2. Создаётся asyncio-задача process_outputs_socket, которая через выходной сокет общается с выходными потоками всех 4 DPEngineCoreProc и пишет в outputs_queue.
  3. Ещё одна asyncio-задача output_handler в AsyncLLM читает из этой очереди и в итоге отправляет информацию в функцию create_completion.

Внутри DPAsyncMPClient создаётся asyncio-задача run_engine_stats_update_task, которая общается с DP-координатором.

DP-координатор выступает посредником между фронтендом (API-сервером) и бэкендом (engine cores). Он:

  • Периодически отправляет информацию для балансировки нагрузки (размеры очередей, число ожидающих/выполняющихся запросов) в задачу фронтенда 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 и монтирует эндпоинты вроде 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
}'

Что происходит дальше:

  1. Запрос попадает в маршрут create_completion класса OpenAIServingCompletion на API-сервере.
  2. Функция асинхронно токенизирует промпт и готовит метаданные (ID запроса, параметры сэмплирования, временную метку и т.д.).
  3. Далее вызывается AsyncLLM.generate, который следует той же логике, что и синхронный движок, в итоге вызывая DPAsyncMPClient.add_request_async.
  4. Это, в свою очередь, вызывает get_core_engine_for_request, которая балансирует нагрузку между движками на основе состояния DP-координатора (выбирается движок с минимальным «счётом» / наименьшей нагрузкой: score = len(waiting) * 4 + len(running)).
  5. Запрос ADD отправляется во входной сокет выбранного движка.
  6. На этом движке:
    • Входной поток — разблокируется, декодирует данные из входного сокета и ставит рабочий элемент в input_queue для главного потока.
    • Главный поток — разблокируется на input_queue, добавляет запрос в движок и многократно вызывает engine_core.step(), помещая промежуточные результаты в output_queue до выполнения условия остановки.
    • Напоминание: step() вызывает планировщик, model executor (который, в свою очередь, может быть MultiProcExecutor!) и т.д. Это уже было рассмотрено!
    • Выходной поток — разблокируется на output_queue и отправляет результаты обратно через выходной сокет.
  7. Эти результаты запускают выходные asyncio-задачи AsyncLLM (process_outputs_socket и output_handler), которые передают токены обратно в маршрут create_completion FastAPI.
  8. FastAPI добавляет метаданные (причину завершения, logprobs, информацию об использовании и т.д.) и возвращает JSONResponse через Uvicorn обратно в терминал!

Вот так и приходит ответ на completion — вся распределённая машинерия скрыта за простой командой curl! :) Красота!

📝 Дополнительные заметки:
  • При добавлении дополнительных API-серверов балансировка нагрузки обрабатывается на уровне ОС/сокетов. С точки зрения приложения ничего существенно не меняется — сложность скрыта.
  • С Ray в качестве DP-бэкенда можно открыть URL-эндпоинт (/scale_elastic_ep), позволяющий автоматически увеличивать или уменьшать число реплик движка.

Бенчмарки и авто-тюнинг — задержка против пропускной способности

До этого момента рассматривались «частицы газа» — внутреннее устройство того, как запросы проходят через движок/систему. Теперь стоит взглянуть на систему в целом и задаться вопросом: как измерять производительность системы инференса?

На самом верхнем уровне есть две конкурирующие метрики:

  1. Задержка (Latency) — время от отправки запроса до получения токенов
  2. Пропускная способность (Throughput) — число токенов/запросов в секунду, которое система способна генерировать/обрабатывать

Задержка важнее всего для интерактивных приложений, где пользователи ждут ответа.

Пропускная способность важна для офлайн-нагрузок вроде генерации синтетических данных для pre/post-training, очистки/обработки данных и вообще любых офлайн-задач батчевого инференса.

Прежде чем объяснять, почему задержка и пропускная способность конкурируют друг с другом, определим несколько распространённых метрик инференса:

МетрикаОпределение
TTFT
(время до первого токена)
Время от отправки запроса до получения первого выходного токена
ITL
(задержка между токенами)
Время между двумя последовательными токенами (например, от токена i-1 к токену i)
TPOT
(время на выходной токен)
Средний ITL по всем выходным токенам запроса
Latency / E2E
(сквозная задержка)
Общее время обработки запроса, то есть TTFT + сумма всех ITL, или, что то же самое, время между отправкой запроса и получением последнего выходного токена
ThroughputОбщее число обработанных токенов в секунду (входных, выходных или обоих), либо запросов в секунду
GoodputПропускная способность, удовлетворяющая целевым показателям уровня обслуживания (SLO), таким как максимальный TTFT, TPOT или сквозная задержка. Например, учитываются только токены запросов, удовлетворяющих этим SLO
ttft, itl, e2e latency
TTFT, ITL, сквозная задержка

Вот упрощённая модель, объясняющая конкурирующую природу этих двух метрик.

Предположение: доминирует ввод-вывод весов, а не ввод-вывод KV-кеша, то есть речь о коротких последовательностях.

Компромисс становится очевиден, если посмотреть, как размер батча B влияет на один шаг decode. По мере уменьшения B к 1, ITL падает: на шаг приходится меньше работы, и токену не приходится «конкурировать» с другими. По мере роста B к бесконечности ITL растёт, поскольку на шаг выполняется больше FLOPs — но пропускная способность растёт (пока не достигнута пиковая производительность), поскольку ввод-вывод весов амортизируется на большее число токенов.

Здесь помогает roofline-модель: ниже точки насыщения батча B_sat время шага определяется пропускной способностью HBM (потоковая передача весов слой за слоем в чиповую память), поэтому задержка шага почти постоянна — вычисление 1 или 10 токенов может занимать примерно одинаковое время. За пределами B_sat ядра становятся compute-bound, и время шага растёт примерно пропорционально B; каждый дополнительный токен увеличивает ITL.

roofline perf model
Roofline-модель производительности
📝 Примечание:
Для более строгого рассмотрения нужно учитывать авто-тюнинг ядер: по мере роста B runtime может переключаться на более эффективные ядра для данной формы данных, изменяя достигнутую производительность P_kernel. Задержка шага равна t = FLOPs_step / P_kernel, где FLOPs_step — объём работы на шаге. Видно, что по мере приближения P_kernel к P_peak увеличение объёма вычислений на шаг напрямую ведёт к росту задержки.

Как проводить бенчмаркинг в vLLM

vLLM предоставляет CLI-команду vllm bench {serve,latency,throughput}, которая оборачивает vllm / benchmarks / {server,latency,throughput}.py.

Что делают эти скрипты:

  • latency — использует короткий вход (по умолчанию 32 токена) и сэмплирует 128 выходных токенов с небольшим батчем (по умолчанию 8). Выполняется несколько итераций, и сообщается сквозная задержка для батча.
  • 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 мс»), и возвращает предлагаемую конфигурацию.

Заключение

Разбор начался с базового engine core (UniprocExecutor), затем были добавлены продвинутые возможности вроде speculative decoding и prefix caching, масштабирование до MultiProcExecutorTP/PP > 1), и наконец масштабирование «вширь» с оборачиванием всего в асинхронный движок и распределённый стек обслуживания — завершая тем, как измерять производительность системы.

vLLM также включает специализированную обработку, оставшуюся за рамками материала. Например:

  • Разнообразные аппаратные бэкенды: TPU, AWS Neuron (Trainium/Inferentia) и т.д.
  • Архитектуры/техники: MLA, MoE, encoder-decoder (например, Whisper), pooling/embedding-модели, EPLB, m-RoPE, LoRA, ALiBi, варианты без attention, sliding-window attention, мультимодальные LM и модели state-space (например, Mamba/Mamba-2, Jamba)
  • TP/PP/SP
  • Гибридная логика KV-кеша (Jenga), более сложные методы сэмплирования вроде beam sampling и другое
  • Экспериментальное: асинхронное планирование

Хорошая новость в том, что большинство этих возможностей ортогональны основному потоку выполнения, описанному выше — их можно почти рассматривать как «плагины» (хотя на практике есть некоторая связанность).

Разбор систем — увлекательное занятие. При этом на такой высоте обзора детализация неизбежно страдает. В следующих материалах — более пристальный взгляд на отдельные подсистемы и детали реализации.

Благодарности

Огромная благодарность Hyperstack за предоставленные H100 для экспериментов на протяжении последнего года!

Спасибо Nick Hill (ключевой контрибьютор vLLM, RedHat), Mark Saroufim (PyTorch), Kyle Krannen (NVIDIA, Dynamo) и Ashish Vaswani за прочтение пре-релизной версии материала и обратную связь!

Источники

  1. vLLM https://github.com/vllm-project/vllm
  2. "Attention Is All You Need", https://arxiv.org/abs/1706.03762
  3. "Efficient Memory Management for Large Language Model Serving with PagedAttention", https://arxiv.org/abs/2309.06180
  4. "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model", https://arxiv.org/abs/2405.04434
  5. "Jenga: Effective Memory Management for Serving LLM with Heterogeneity", https://arxiv.org/abs/2503.18292
  6. "Orca: A Distributed Serving System for Transformer-Based Generative Models", https://www.usenix.org/conference/osdi22/presentation/yu
  7. "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models", https://arxiv.org/abs/2411.15100
  8. "Accelerating Large Language Model Decoding with Speculative Sampling", https://arxiv.org/abs/2302.01318
  9. "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty", https://arxiv.org/abs/2401.15077
  10. "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads", https://arxiv.org/abs/2401.10774
  11. LMCache, https://github.com/LMCache/LMCache