Материал последовательно раскрывает все основные компоненты и продвинутые возможности современной высокопроизводительной системы инференса LLM — на примере разбора внутреннего устройства vLLM [1].
Это первая часть серии. Изложение построено по принципу «перевёрнутой пирамиды»: сначала общая картина, затем детали, — чтобы сформировать точную ментальную модель всей системы, не утонув в мелочах.
Следующие материалы будут посвящены отдельным подсистемам подробнее.
Статья разбита на пять частей:
- LLM engine и engine core: основы vLLM (планирование, paged attention, continuous batching и т.д.)
- Продвинутые возможности: chunked prefill, prefix caching, guided и speculative decoding, disaggregated P/D
- Масштабирование: от одного GPU к многопроцессорному выполнению
- Уровень обслуживания: распределённая/конкурентная веб-инфраструктура
- Бенчмарки и авто-тюнинг: измерение задержки и пропускной способности
- Анализ основан на коммите 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-кеша)
Далее конфигурация будет постепенно наращиваться до онлайн, асинхронной, многопроцессорной, многоузловой системы инференса — но всё ещё для стандартного трансформера.
В примере выполняются два действия:
- Создаётся экземпляр движка
- Вызывается
generateдля сэмплирования по заданным промптам
Начнём с разбора конструктора.
Конструктор LLM Engine
Основные компоненты движка:
- vLLM config (все настройки модели, кеша, параллелизма и т.д.)
- processor (превращает сырые входные данные в
EngineCoreRequestsчерез валидацию, токенизацию и обработку) - engine core client (в примере используется
InprocClient, который по сути равенEngineCore; далее будет рассмотренDPLBAsyncMPClient, позволяющий обслуживать нагрузку в масштабе) - output processor (конвертирует сырые
EngineCoreOutputsвRequestOutput, который видит пользователь)
Engine core состоит из нескольких подкомпонентов:
- Model Executor (запускает прямые проходы модели; в текущем примере это
UniProcExecutorс единственным процессомWorkerна одном GPU). Далее будет рассмотренMultiProcExecutor, поддерживающий несколько GPU - Structured Output Manager (используется для guided decoding — рассмотрим позже)
- Scheduler (решает, какие запросы попадут в следующий шаг движка) — включает:
- политику — либо FCFS (первым пришёл — первым обслужен), либо priority (сначала обслуживаются более приоритетные запросы)
- очереди
waitingиrunning - KV-cache manager — сердце paged attention [3]
KV-cache manager поддерживает free_block_queue — пул доступных блоков KV-кеша (часто порядка сотен тысяч, в зависимости от объёма VRAM и размера блока). В paged attention эти блоки служат индексирующей структурой, отображающей токены на вычисленные для них блоки KV-кеша.

2 (key/value) ×
block_size (по умолчанию 16) × num_kv_heads × head_size × dtype_num_bytes (например, 2 для bf16)При создании model executor создаётся объект Worker и выполняются три ключевые процедуры. (Позже, с MultiProcExecutor, эти же процедуры будут выполняться независимо на каждом воркер-процессе на разных GPU.)
- Инициализация устройства:
- Назначение 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-кеша, метаданные сэмплирования и т.д.)
- Загрузка модели:
- Создание архитектуры модели
- Загрузка весов модели
- Вызов model.eval() (режим инференса PyTorch)
- Опционально: вызов torch.compile() на модели
- Инициализация 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. Позже во время прямого прохода запускаются/воспроизводятся заранее подготовленные графы, что снижает накладные расходы на запуск ядер и улучшает задержку.
- Получение спецификации KV-кеша по слоям. Раньше это всегда был
Здесь опущено множество низкоуровневых деталей — но перечисленные компоненты являются ключевыми и будут регулярно упоминаться в следующих разделах.
Теперь, когда движок инициализирован, перейдём к функции generate.
Функция generate
Первый шаг — валидация и передача запросов в движок. Для каждого промпта:
- Создаётся уникальный ID запроса и фиксируется время его поступления
- Вызывается input preprocessor, который токенизирует промпт и возвращает словарь с
prompt,prompt_token_idsиtype(текст, токены, эмбеддинги и т.д.) - Эта информация упаковывается в
EngineCoreRequestс добавлением приоритета, параметров сэмплирования и прочих метаданных - Запрос передаётся в engine core, который оборачивает его в объект
Requestи устанавливает статусWAITING. Запрос добавляется в очередьwaitingпланировщика (append для FCFS, или heap-push для приоритетной очереди)
На этом этапе движок «накормлен» и может начинать выполнение. В синхронном примере эти исходные промпты — единственные, что будут обработаны: механизма добавления новых запросов в процессе выполнения нет. Асинхронный движок это поддерживает (так называемый continuous batching [6]): после каждого шага рассматриваются и новые, и старые запросы.
Далее, пока есть запросы для обработки, движок многократно вызывает функцию step(). Каждый шаг состоит из трёх стадий:
- Планирование: выбор запросов для выполнения на этом шаге (decode и/или (chunked) prefill)
- Прямой проход: запуск модели и сэмплирование токенов
- Постобработка: добавление сэмплированных ID токенов к каждому
Request, детокенизация и проверка условий остановки. Если запрос завершён — очистка (например, возврат блоков KV-кеша вfree_block_queue) и досрочный возврат результата
- Запрос превышает лимит длины (
max_model_lengthили собственныйmax_tokens) - Сэмплированный токен — это EOS (если только не включён
ignore_eos— полезно при бенчмаркинге, когда нужно принудительно сгенерировать заданное число выходных токенов) - Сэмплированный токен совпадает с одним из
stop_token_ids, указанных в параметрах сэмплирования - В выходе присутствуют стоп-строки — вывод обрезается до первого появления стоп-строки, и запрос прерывается в движке (при этом
stop_token_idsбудут присутствовать в выводе, а стоп-строки — нет)

Далее — более подробный разбор планирования.
Планировщик
Есть два основных типа нагрузки, с которыми работает движок инференса:
- Prefill-запросы — прямой проход по всем токенам промпта. Обычно они compute-bound (порог зависит от железа и длины промпта). В конце сэмплируется один токен из распределения вероятностей для последней позиции.
- Decode-запросы — прямой проход только по самому последнему токену. Все более ранние KV-вектора уже закешированы. Такие запросы memory-bandwidth-bound, поскольку всё равно нужно загрузить все веса модели (и KV-кеши) ради вычисления одного токена.
Планировщик V1 может смешивать оба типа запросов в одном шаге благодаря более удачным архитектурным решениям. Для сравнения, движок V0 мог обрабатывать за раз либо только prefill, либо только decode.
Планировщик отдаёт приоритет decode-запросам — то есть тем, что уже находятся в очереди running. Для каждого такого запроса он:
- Вычисляет число новых токенов для генерации (не всегда 1 — из-за спекулятивного декодирования и асинхронного планирования, об этом позже).
- Вызывает функцию
allocate_slotsKV-cache manager'а (подробности ниже). - Обновляет токен-бюджет, вычитая число токенов из шага 1.
После этого обрабатываются prefill-запросы из очереди waiting:
- Извлекается число уже вычисленных блоков (возвращает 0, если prefix caching отключён — об этом позже).
- Вызывается функция
allocate_slotsKV-cache manager'а. - Запрос извлекается из
waitingи перемещается вrunning, статус устанавливается вRUNNING. - Обновляется токен-бюджет.
Теперь о том, что делает allocate_slots:
- Вычисление числа блоков — определяется, сколько новых блоков KV-кеша (
n) нужно выделить. Каждый блок по умолчанию хранит 16 токенов. Например, если у prefill-запроса 17 новых токенов, нужноceil(17/16) = 2блока. - Проверка доступности — если в пуле менеджера недостаточно блоков, происходит ранний выход. В зависимости от типа запроса (decode или prefill) движок может попытаться выполнить recompute preemption (вытеснение с перевычислением; swap preemption поддерживалось в V0), вытеснив низкоприоритетные запросы (вызвав
kv_cache_manager.free, который возвращает блоки KV в пул блоков), либо может пропустить планирование и продолжить выполнение. - Выделение блоков — через координатор KV-cache manager'а извлекаются первые
nблоков из пула блоков (упомянутый ранее двусвязный списокfree_block_queue). Результат сохраняется вreq_to_blocks— словарь, отображающий каждыйrequest_idна список его блоков KV-кеша.

Теперь можно переходить к прямому проходу!
Прямой проход
Вызывается execute_model у model executor, который делегирует выполнение Worker, а тот, в свою очередь, — model runner'у.
Основные шаги:
- Обновление состояний — из
input_batchудаляются завершённые запросы; обновляются служебные метаданные прямого прохода (например, блоки KV-кеша для каждого запроса, используемые для индексации в paged KV-cache памяти). - Подготовка входных данных — копирование буферов CPU→GPU; вычисление позиций; построение
slot_mapping(подробнее в примере); формирование метаданных attention. - Прямой проход — запуск модели со специализированными paged-attn ядрами. Все последовательности разворачиваются и конкатенируются в одну длинную «супер-последовательность». Индексы позиций и маски внимания гарантируют, что каждая последовательность обращается только к своим токенам, что позволяет реализовать continuous batching без right-padding.
- Извлечение состояний последнего токена — извлекаются скрытые состояния для последней позиции каждой последовательности и вычисляются логиты.
- Сэмплирование — токены сэмплируются из полученных логитов согласно конфигурации сэмплирования (greedy, temperature, top-p, top-k и т.д.).
Сам шаг прямого прохода имеет два режима выполнения:
- Eager-режим — стандартный прямой проход PyTorch, когда включено eager-выполнение.
- «Захваченный» режим — выполнение/воспроизведение заранее захваченного CUDA Graph, когда eager не принудительно включён (напомним, эти графы захватывались при инициализации движка на этапе инициализации KV-кеша).
Вот конкретный пример, который должен прояснить работу continuous batching и paged attention:

Продвинутые возможности — расширение базовой логики движка
С базовым потоком выполнения движка разобрались — теперь можно перейти к продвинутым возможностям.
Preemption, paged attention и continuous batching уже были рассмотрены.
Далее пойдёт речь о:
- Chunked prefill
- Prefix caching
- Guided decoding (через конечные автоматы, ограниченные грамматикой)
- Speculative decoding
- Disaggregated P/D (prefill/decoding)
Chunked prefill
Chunked prefill — техника обработки длинных промптов путём разбиения шага prefill на более мелкие фрагменты (чанки). Без неё один очень длинный запрос мог бы монополизировать целый шаг движка, не позволяя выполняться другим prefill-запросам. Это откладывало бы обработку всех остальных запросов и увеличивало их задержку.
Например, пусть каждый чанк содержит n (=8) токенов, обозначенных строчными буквами через «-». Длинный промпт P может выглядеть как x-y-z, где z — неполный чанк (например, 2 токена). Выполнение полного prefill для P заняло бы ≥ 3 шага движка (может быть и больше, если запрос не попадает в планирование на каком-то из шагов), и только на последнем чанке будет сэмплирован новый токен.
Тот же пример визуально:

Реализация проста: ограничивается число новых токенов за шаг. Если запрошенное число превышает 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:
- Функция разбивает
long_prefix + prompts[0]на чанки по 16 токенов. - Для каждого полного чанка вычисляется хеш (встроенный хеш или SHA-256, который медленнее, но даёт меньше коллизий). Хеш объединяет хеш предыдущего блока, текущие токены и опциональные метаданные.
- Каждый результат сохраняется как объект
BlockHash, содержащий хеш и ID токенов. Возвращается список хешей блоков.
Список сохраняется в 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.
После этого прямой проход заполнит KV в paged-памяти KV-кеша, соответствующие выделенным выше блокам.
long_prefix.
При втором вызове generate с тем же префиксом шаги 1-3 повторяются, но теперь find_longest_cache_hit находит совпадения для всех n блоков (через линейный поиск). Движок может напрямую переиспользовать эти блоки KV.

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

Как это работает в vLLM:
- При создании LLM engine создаётся
StructuredOutputManager; у него есть доступ к токенизатору, и он хранит тензор_grammar_bitmask. - При добавлении запроса его статус устанавливается в
WAITING_FOR_FSM, аgrammar_initвыбирает бэкенд-компилятор (например,xgrammar[7]; учтите, что бэкенды — это сторонний код). - Грамматика для запроса компилируется асинхронно.
- Во время планирования, если асинхронная компиляция завершена, статус меняется на
WAITING, аrequest_idдобавляется вstructured_output_request_ids; иначе запрос попадает вskipped_waiting_requestsдля повторной попытки на следующем шаге движка. - После цикла планирования (всё ещё внутри планирования), если есть FSM-запросы,
StructuredOutputManagerпросит бэкенд подготовить/обновить_grammar_bitmask. - После того как прямой проход выдаёт логиты, функция xgr_torch_compile расширяет битовую маску до размера словаря (коэффициент расширения 32x, так как используются 32-битные целые числа) и маскирует запрещённые логиты значением –∞.
- После сэмплирования следующего токена 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.
Ещё более простой пример с vocab_size = 8 и 8-битными целыми числами:

Включить это в vLLM можно, передав нужную конфигурацию guided_decoding.
Speculative Decoding
При авторегрессивной генерации каждый новый токен требует прямого прохода большой языковой модели. Это дорого — на каждом шаге заново загружаются и применяются все веса модели ради вычисления одного-единственного токена (при размере батча == 1, в общем случае это B)!
Speculative decoding [8] ускоряет этот процесс за счёт введения меньшей черновой (draft) модели. Draft-модель дёшево предлагает k токенов. Но в конечном счёте сэмплировать из меньшей модели не хочется — она нужна лишь для того, чтобы угадывать варианты продолжения. Решает, что валидно, всё равно большая модель.
Шаги:
- Draft: малая модель запускается на текущем контексте и предлагает
kтокенов - Verify: большая модель запускается один раз на контексте +
kчерновых токенах. Это даёт вероятности для этихkпозиций плюс ещё одну (итогоk+1кандидатов) - 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 токенов.
vLLM V1 не поддерживает метод с полноценной draft-LLM-моделью, вместо этого реализованы более быстрые, но менее точные схемы предложения: n-gram, EAGLE [9] и Medusa [10].
Кратко о каждой:
- n-gram: берутся последние
prompt_lookup_maxтокенов; ищется предшествующее совпадение в последовательности; если найдено — предлагаютсяkтокенов, следовавших за этим совпадением; иначе окно уменьшается и поиск повторяется вплоть доprompt_lookup_min - Eagle: над большой LM выполняется своего рода «хирургия» — эмбеддинги и LM head сохраняются, а стек трансформера заменяется лёгким MLP; эта конструкция дообучается как дешёвая draft-модель
- Medusa: поверх большой модели (перед LM head) обучаются дополнительные линейные головы, предсказывающие следующие
kтокенов параллельно; эти головы используются для более эффективного предложения токенов, чем запуск отдельной малой LM
k токенов после первого совпадения. Логичнее выглядело бы ввести смещение в сторону недавности и искать в обратном направлении (то есть последнее совпадение)?Вот как вызвать 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?
Настройка (при создании движка):
- Инициализация устройства: создаётся
drafter(черновая модель, напримерNgramProposer) иrejection_sampler(часть которого написана на Triton). - Загрузка модели: загружаются веса draft-модели (для n-gram — no-op).
Далее в функции generate (предположим, поступил совершенно новый запрос):
- Выполняется обычный prefill-шаг с большой моделью.
- После прямого прохода и стандартного сэмплирования вызывается
propose_draft_token_ids(k)для сэмплированияkчерновых токенов из draft-модели. - Они сохраняются в
request.spec_token_ids(обновление метаданных запроса). - На следующем шаге движка, когда запрос находится в очереди running, к счётчику «новых токенов» добавляется
len(request.spec_token_ids), чтобыallocate_slotsзарезервировал достаточно блоков KV для прямого прохода. spec_token_idsкопируются вinput_batch.token_ids_cpu, формируя (контекст + черновые) токены.- Метаданные вычисляются через
_calc_spec_decode_metadata(копируются токены изinput_batch.token_ids_cpu, готовятся логиты и т.д.), затем выполняется прямой проход большой модели по черновым токенам. - Вместо обычного сэмплирования из логитов используется
rejection_samplerдля последовательного принятия/отклонения токенов слева направо и формированияoutput_token_ids. - Шаги 2-7 повторяются до выполнения условия остановки.
Лучший способ разобраться в этом — запустить дебаггер и пройтись по коду шаг за шагом, но этот раздел, надеюсь, даёт общее представление. Вот ещё иллюстрации:


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

- Для
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.

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

На первом узле движок запускается в 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
Как это работает в vLLM?
На headless-узле сервера
На headless-узле CoreEngineProcManager запускает 2 процесса (по значению --data-parallel-size-local), каждый из которых выполняет EngineCoreProc.run_engine_core. Каждая из этих функций создаёт DPEngineCoreProc (engine core) и затем входит в свой busy loop.
DPEngineCoreProc инициализирует родительский EngineCoreProc (дочерний по отношению к EngineCore), который:
- Создаёт
input_queueиoutput_queue(queue.Queue). - Выполняет начальное рукопожатие с фронтендом на другом узле через
DEALERZMQ-сокет (библиотека асинхронного обмена сообщениями) и получает адресную информацию для координации. - Инициализирует DP-группу (например, используя NCCL-бэкенд).
- Инициализирует
EngineCoreсMultiProcExecutor(TP=4на 4 GPU, как описано ранее). - Создаёт
ready_event(threading.Event). - Запускает поток-демон ввода (
threading.Thread), выполняющийprocess_input_sockets(…, ready_event). Аналогично запускается поток вывода. - В главном потоке ожидает
ready_event, пока все потоки ввода на всех 4 процессах (охватывающих 2 узла) не завершат рукопожатие координации, после чего вызываетсяready_event.set(). - После разблокировки отправляется сообщение "ready" фронтенду с метаданными (например,
num_gpu_blocks, доступными в paged-памяти KV-кеша). - Главный, входной и выходной потоки затем входят в свои busy loop'ы.
Итог: получаем 4 дочерних процесса (по одному на реплику DP), каждый со своим главным, входным и выходным потоком. Они завершают рукопожатие координации с DP-координатором и фронтендом, после чего все три потока каждого процесса работают в устойчивых busy loop'ах.

Текущее устойчивое состояние:
- Входной поток — блокируется на входном сокете, пока API-сервер не направит запрос; при получении декодирует полезную нагрузку, ставит рабочий элемент в очередь через
input_queue.put_nowait(...)и возвращается к блокировке на сокете. - Главный поток — просыпается на
input_queue.get(...), передаёт запрос движку;MultiProcExecutorвыполняет прямой проход и ставит результаты вoutput_queue. - Выходной поток — просыпается на
output_queue.get(...), отправляет результат обратно API-серверу, затем снова блокируется.
Дополнительная механика:
- Счётчик DP wave — система отслеживает «волны»: когда все движки становятся простаивающими, они переходят в режим покоя, а счётчик увеличивается при поступлении новой работы (полезно для координации/метрик).
- Управляющие сообщения — API-сервер может отправлять не только запросы на инференс (например, отмены и служебные/управляющие RPC).
- Фиктивные шаги для lockstep — если у любой из реплик DP есть работа, все реплики выполняют шаг прямого прохода; реплики без запросов выполняют фиктивный шаг, чтобы участвовать в требуемых точках синхронизации (это позволяет не блокировать активную реплику).
Теперь о второй части — что происходит на узле API-сервера?
На узле API-сервера
Создаётся объект AsyncLLM (asyncio-обёртка вокруг LLM engine). Внутри создаётся DPLBAsyncMPClient (data-parallel, load-balancing, асинхронный, многопроцессный клиент).
Внутри родительского класса MPClient выполняется функция launch_core_engines, которая:
- Создаёт ZMQ-адреса, используемые для рукопожатия при запуске (как видели на headless-узле).
- Запускает процесс
DPCoordinator. - Создаёт
CoreEngineProcManager(аналогично headless-узлу).
Внутри AsyncMPClient (дочернего по отношению к MPClient):
- Создаётся
outputs_queue(asyncio.Queue). - Создаётся asyncio-задача
process_outputs_socket, которая через выходной сокет общается с выходными потоками всех 4DPEngineCoreProcи пишет вoutputs_queue. - Ещё одна 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
}'
Что происходит дальше:
- Запрос попадает в маршрут
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_queueдля главного потока. - Главный поток — разблокируется на
input_queue, добавляет запрос в движок и многократно вызываетengine_core.step(), помещая промежуточные результаты вoutput_queueдо выполнения условия остановки. - Выходной поток — разблокируется на
output_queueи отправляет результаты обратно через выходной сокет.
Напоминание:step()вызывает планировщик, model executor (который, в свою очередь, может бытьMultiProcExecutor!) и т.д. Это уже было рассмотрено! - Входной поток — разблокируется, декодирует данные из входного сокета и ставит рабочий элемент в
- Эти результаты запускают выходные asyncio-задачи
AsyncLLM(process_outputs_socketиoutput_handler), которые передают токены обратно в маршрутcreate_completionFastAPI. - FastAPI добавляет метаданные (причину завершения, logprobs, информацию об использовании и т.д.) и возвращает
JSONResponseчерез Uvicorn обратно в терминал!
Вот так и приходит ответ на completion — вся распределённая машинерия скрыта за простой командой curl! :) Красота!
- При добавлении дополнительных API-серверов балансировка нагрузки обрабатывается на уровне ОС/сокетов. С точки зрения приложения ничего существенно не меняется — сложность скрыта.
- С Ray в качестве DP-бэкенда можно открыть URL-эндпоинт (
/scale_elastic_ep), позволяющий автоматически увеличивать или уменьшать число реплик движка.
Бенчмарки и авто-тюнинг — задержка против пропускной способности
До этого момента рассматривались «частицы газа» — внутреннее устройство того, как запросы проходят через движок/систему. Теперь стоит взглянуть на систему в целом и задаться вопросом: как измерять производительность системы инференса?
На самом верхнем уровне есть две конкурирующие метрики:
- Задержка (Latency) — время от отправки запроса до получения токенов
- Пропускная способность (Throughput) — число токенов/запросов в секунду, которое система способна генерировать/обрабатывать
Задержка важнее всего для интерактивных приложений, где пользователи ждут ответа.
Пропускная способность важна для офлайн-нагрузок вроде генерации синтетических данных для pre/post-training, очистки/обработки данных и вообще любых офлайн-задач батчевого инференса.
Прежде чем объяснять, почему задержка и пропускная способность конкурируют друг с другом, определим несколько распространённых метрик инференса:
| Метрика | Определение |
|---|---|
TTFT(время до первого токена) | Время от отправки запроса до получения первого выходного токена |
ITL(задержка между токенами) | Время между двумя последовательными токенами (например, от токена i-1 к токену i) |
TPOT(время на выходной токен) | Средний ITL по всем выходным токенам запроса |
Latency / E2E(сквозная задержка) | Общее время обработки запроса, то есть TTFT + сумма всех ITL, или, что то же самое, время между отправкой запроса и получением последнего выходного токена |
Throughput | Общее число обработанных токенов в секунду (входных, выходных или обоих), либо запросов в секунду |
Goodput | Пропускная способность, удовлетворяющая целевым показателям уровня обслуживания (SLO), таким как максимальный TTFT, TPOT или сквозная задержка. Например, учитываются только токены запросов, удовлетворяющих этим SLO |

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

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
.buildkite/nightly-benchmarks/tests.Также есть скрипт авто-тюнинга, который прогоняет бенчмарк serve для поиска настроек аргументов, удовлетворяющих целевым SLO (например, «максимизировать пропускную способность при p99 сквозной задержке < 500 мс»), и возвращает предлагаемую конфигурацию.
Заключение
Разбор начался с базового engine core (UniprocExecutor), затем были добавлены продвинутые возможности вроде speculative decoding и prefix caching, масштабирование до MultiProcExecutor (с TP/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 за прочтение пре-релизной версии материала и обратную связь!
Источники
- vLLM https://github.com/vllm-project/vllm
- "Attention Is All You Need", https://arxiv.org/abs/1706.03762
- "Efficient Memory Management for Large Language Model Serving with PagedAttention", https://arxiv.org/abs/2309.06180
- "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model", https://arxiv.org/abs/2405.04434
- "Jenga: Effective Memory Management for Serving LLM with Heterogeneity", https://arxiv.org/abs/2503.18292
- "Orca: A Distributed Serving System for Transformer-Based Generative Models", https://www.usenix.org/conference/osdi22/presentation/yu
- "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models", https://arxiv.org/abs/2411.15100
- "Accelerating Large Language Model Decoding with Speculative Sampling", https://arxiv.org/abs/2302.01318
- "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty", https://arxiv.org/abs/2401.15077
- "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads", https://arxiv.org/abs/2401.10774
- LMCache, https://github.com/LMCache/LMCache