CHAPTER 01

Глава 1: Философия дизайна vLLM и архитектура высокопроизводительного инференса

Проект: vllm-project/vllm · Прогресс книги: глава 1 / 14 · Статус верификации: FACT — реальная привязка к номерам строк

Предположим, у вас есть A100, и вы хотите предоставлять онлайн-сервис инференса с помощью LLaMA-7B. Самый простой подход: приходит запрос — запускаем model.generate() — возвращаем результат. Это решение мгновенно рухнет при росте конкурентности — не потому, что не хватает вычислительной мощности GPU, а по двум причинам. Во-первых, видеопамять съедается фрагментацией. Авторегрессионная генерация требует кэширования тензоров Key/Value каждого слоя (KV Cache). Если для каждого запроса предварительно выделять целый непрерывный блок видеопамяти размером max_model_len, то запрос на 4096 токенов займёт десятки МБ, тогда как реально сгенерированная последовательность может содержать всего 200 токенов. Хуже того, запросы разной длины входят и выходят поочерёдно, непрерывные блоки видеопамяти дробятся на куски, и в итоге, хотя суммарного объёма достаточно, не находится достаточно большой непрерывный участок — это классическая проблема фрагментации видеопамяти. Во-вторых, низкая эффективность батчевой обработки. Традиционная статическая батчевая обработка требует, чтобы все запросы в одном батче начинались и заканчивались одновременно. Но длина вывода генеративных задач по своей природе непредсказуема: один запрос может остановиться на 10 токенах, другому нужно сгенерировать 2000. После завершения короткого запроса его слот в батче может только простаивать в ожидании завершения длинного запроса, и загрузка GPU обваливается. Два краеугольных камня дизайна vLLM как раз нацелены на эти две болевые точки: PagedAttention устраняет фрагментацию видеопамяти с помощью механизма страничной адресации, Continuous Batching устраняет простой батчевой обработки с помощью планирования на уровне итераций. В этой главе мы не углубляемся в детали реализации этих двух механизмов (это темы глав 2 и 4), а сначала строим глобальную карту: как выглядит процессная архитектура vLLM v1, как разделены обязанности между слоями, через какие компоненты проходит запрос от входа в систему до выдачи токена. Понимание этой карты даёт опору для разбора исходного кода в каждой последующей главе.

Процессная архитектура: почему vLLM — не однопроцессная программа

Интуитивная модель

Представьте vLLM как ресторан. Ресепшн (API Server) принимает гостей и записывает заказы; ядро кухни (EngineCore) решает, какое блюдо готовить первым и на какой плите; каждой плитой (GPU Worker) управляет один повар единолично. Если заставить одного человека и принимать гостей, и готовить, в час пик неизбежно будет суматоха — вот почему vLLM разделяет эти роли на независимые процессы.

〔Проектные предположения и архитектурные компромиссы〕

Ключевая мотивация такого многопроцессного разделения —разделение ответственности: разбор HTTP, токенизация, загрузка мультимодальных данных — это операции, интенсивно использующие CPU и способные блокироваться, тогда как прямой проход модели интенсивно использует GPU. Если поместить их в один процесс, GIL в Python заставит их тормозить друг друга. После разделения на независимые процессы API Server может непрерывно принимать новые запросы, EngineCore — непрерывно планировать, GPU Worker — непрерывно вычислять, а все три развязываются через очередь сообщений ZMQ.

Топология процессов и количественные соотношения

Процессную архитектуру vLLM v1 можно обобщить одной формулой. Для развёртывания сNGPU, степенью тензорного параллелизмаTP, степенью конвейерного параллелизмаPP, степенью параллелизма данныхDP, количеством API ServerA:

Тип процессаКоличествоОбязанности
API ServerA(по умолчанию равноDP)Обработка HTTP-запросов, предобработка входных данных, потоковый возврат результатов
EngineCoreDP(по умолчанию 1)Планирование, управление KV Cache, координация GPU Worker
GPU WorkerN(= DP × PP × TP)Загрузка весов, выполнение прямого прохода, управление видеопамятью
DP CoordinatorDP > 1равно 1, иначе 0Балансировка нагрузки между рангами DP и координация волн MoE

📎 docs/design/arch_overview.md:113-113даёт авторитетное определение этой таблицы. Типичное развёртывание на одной машине с 4 GPU (vllm serve -tp=4) создаёт 1 API Server + 1 EngineCore + 4 GPU Worker = 6 процессов📎 docs/design/arch_overview.md:115-115. А развёртывание с 8 GPU при TP=2/DP=4 раздувается до 4 + 4 + 8 + 1 = 17 процессов📎 docs/design/arch_overview.md:123-123。

Здесь есть одна легко упускаемая деталь:Количество API Server по умолчанию следует за размером DP. Когда--data-parallel-size 4, автоматически запускаются 4 API Server, каждый из которых через ZMQ соединяется по топологии «многие ко многим» со всеми EngineCore📎 docs/design/arch_overview.md:73-73. Это означает, что любой API Server может маршрутизировать запрос к любому EngineCore, избегая единой точки отказа.

Поток данных

Приведённая ниже схема показывает полный путь прохождения одного запроса между процессами. Обратите внимание, что на каждом узле указаны реальные имена классов и структур данных:

mermaid
flowchart LR
    client["HTTP-запрос клиента"] --> api["Процесс API Server<br/>предобработка ввода + tokenization"]
    api -->|"EngineCoreRequest<br/>через ZMQ ADD"| core["Процесс EngineCore<br/>Scheduler + KVCacheManager"]
    core -->|"SchedulerOutput<br/>через Executor"| worker["Процесс GPU Worker<br/>ModelRunner.forward()"]
    worker -->|"ModelRunnerOutput<br/>token ids + logprobs"| core
    core -->|"EngineCoreOutputs<br/>через ZMQ"| api
    api -->|"Потоковый SSE-ответ"| client

Ключевой момент этой схемы в том, что:Между API Server и EngineCore — асинхронная передача сообщений, а не вызов функций. Запрос сериализуется вEngineCoreRequestструктуру (одинmsgspec.Struct, см.📎 vllm/v1/engine/__init__.py:109-113), отправляется через ZMQ с типом сообщенияADD📎 vllm/v1/engine/__init__.py:287-299. После обработки EngineCore упаковывает результат вEngineCoreOutputsи возвращает📎 vllm/v1/engine/__init__.py:256-260。

〔Проектные предположения и архитектурные компромиссы〕

Выбор ZMQ вместо gRPC или разделяемой памяти обусловлен тем, что ZMQ обеспечивает крайне низкую задержку (микросекундного уровня) в сценариях межпроцессного взаимодействия и естественно поддерживает топологию «многие ко многим» и семантику очередей сообщений. Для сценариев инференс-сервиса, чувствительных к задержке первого токена, накладные расходы на коммуникацию должны быть как можно меньше.

Размышление о дизайне: почему EngineCore — отдельный процесс, а не поток

Естественный вопрос: раз EngineCore и API Server находятся на одной машине, почему бы не поместить их в один процесс и не общаться через потоки?

Ответ кроется в режиме работы EngineCore. EngineCore выполняетbusy loop(цикл занятости), непрерывно планируя запросы и распределяя работу между GPU Worker📎 docs/design/arch_overview.md:73-73. Этот цикл нельзя прерывать — как только он блокируется на разборе HTTP или токенизации, во всём конвейере инференса появляются пузыри. Отдельный процесс гарантирует, что квант времени CPU EngineCore не будет вытеснен фронтенд-логикой.

Кроме того, отдельный процесс обеспечиваетизоляцию отказов: если API Server падает из-за какого-то некорректного запроса, EngineCore и GPU Worker не затрагиваются и могут продолжать обслуживать запросы, перенаправленные от других API Server.

Многоуровневая ментальная модель: границы ответственности от входа до GPU

Интуитивная модель

Если процессная архитектура — это «кто где работает», то многоуровневая модель — это «какой уровень за какие решения отвечает». Организация кода vLLM следует чёткому принципу слоистости:верхний уровень решает, что делать, нижний — как делать. Уровень входа решает, какие запросы принимать, ядро движка решает, кого обрабатывать первым, уровень исполнителя решает, какую стратегию параллелизма использовать, уровень Worker решает, как получить результат на конкретном оборудовании.

Четырёхуровневая структура

Уровень входа (Entrypoints)предоставляет два способа взаимодействия: классLLMдля офлайн-инференса и командуvllm serveдля онлайн-сервиса📎 docs/design/arch_overview.md:16-16📎 docs/design/arch_overview.md:56-56. Основная задача этого уровня — предобработка входа: токенизация, загрузка мультимодальных данных, разбор параметров сэмплирования — а также обратная токенизация выхода и потоковая отдача. Он не заботится о стратегии планирования и не трогает GPU.

Ядро движка (EngineCore)— это мозг всей системы. Оно содержит Scheduler (решает, какие запросы обрабатывать на каждом шаге декодирования) и KV Cache Manager (управляет страничной видеопамятью), взаимодействует с GPU Worker через абстракцию Executor📎 docs/design/arch_overview.md:79-85. Ключевой дизайн этого уровня —разделение планирования и исполнения: Scheduler выдаёт только решение «какие токены запускать на этом шаге» (SchedulerOutput), а как именно запускать на GPU — дело Worker.

Уровень исполнителя (Executor)— это мост между EngineCore и Worker. Он инкапсулирует стратегии распределённого исполнения — для одного процесса используетсяUniProcExecutor, для многопроцессных —MultiprocExecutor, для кластера Ray —RayDistributedExecutor. Абстрактный интерфейс Executor позволяет EngineCore не знать, что под ним — одна карта или 8 карт TP.

Уровень Worker— по одному процессу Worker на каждый GPU, внутри которого находятся ModelRunner и реальный объект моделиtorch.nn.Module📎 docs/design/arch_overview.md:171-191. ModelRunner отвечает за подготовку входных тензоров, захват CUDA Graph, выполнение прямого прохода. Этот уровень — единственное место, где напрямую работают с видеопамятью GPU и потоками CUDA.

Объект конфигурации: глобальное состояние, пронизывающее все уровни

Чем передаётся информация между четырьмя уровнями? Ответ —VllmConfig— гигантский dataclass, содержащий всю конфигурацию📎 vllm/config/vllm.py:357-357。

python
@config(config=ConfigDict(arbitrary_types_allowed=True))
class VllmConfig:
    """Dataclass which contains all vllm-related configuration."""
    model_config: ModelConfig = None
    cache_config: CacheConfig = Field(default_factory=CacheConfig)
    parallel_config: ParallelConfig = Field(default_factory=ParallelConfig)
    scheduler_config: SchedulerConfig = Field(default_factory=SchedulerConfig.default_factory)
    # ... 还有 20+ 个子配置

📎 vllm/config/vllm.py:363-371показывает ключевые поля. Логику, стоящую за этим проектным выбором, стоит раскрыть.

〔Проектные предположения и архитектурные компромиссы〕

В документации явно объясняется, почему используется один большой объект конфигурации вместо разрозненной передачи параметров:Расширяемость. Предположим, нужно добавить новую функцию, влияющую только на ModelRunner — достаточно добавить одно поле вVllmConfig, и ModelRunner просто прочитает его, не требуется изменять сигнатуры конструкторов Engine, Worker, Model📎 docs/design/arch_overview.md:203-203. В быстро развивающемся фреймворке для инференса такая способность «добавлять поля без изменения интерфейсов» значительно снижает трение при разработке.

Цена — это то, чтоVllmConfigстановится чрезвычайно громоздким — из📎 vllm/config/vllm.py:356-3509видно, что этот класс занимает более 3000 строк кода, содержит десятки полей и методов валидации.__post_init__Метод📎 vllm/config/vllm.py:1405-2317занимает более 900 строк и отвечает за всю кросс-конфигурационную валидацию и вывод значений по умолчанию.

Хеширование и кэширование конфигурации

VllmConfigЕсть ещё одна легко упускаемая, но очень важная возможность:compute_hash() 📎 vllm/config/vllm.py:464-580. Он генерирует короткий хеш для всех конфигурационных параметров, влияющих на структуру вычислительного графа.

python
def compute_hash(self, include_version: bool = True) -> str:
    factors: list[Any] = []
    vllm_factors: list[Any] = []
    if include_version:
        from vllm import __version__
        vllm_factors.append(__version__)
    if self.model_config:
        vllm_factors.append(self.model_config.compute_hash())
    # ... 逐个追加各子配置的哈希
    hash_str = safe_hash(str(factors).encode(), usedforsecurity=False).hexdigest()[:10]
    return hash_str

📎 vllm/config/vllm.py:479-580демонстрирует полный процесс вычисления хеша. Обратите внимание на предупреждение в комментарии: «Whenever a new field is added to this config, ensure that it is included in the factors list if it affects the computation graph»📎 vllm/config/vllm.py:465-467。

〔Проектные выводы и архитектурные компромиссы〕

Назначение этого хеша —ключ кэша torch.compile. vLLM используетtorch.compileдля компиляции графа прямого прохода модели, результат компиляции кэшируется на диск. При следующем запуске, если хеш конфигурации совпадает, можно напрямую переиспользовать кэш компиляции, пропустив трудоёмкий процесс компиляции. Если какой-либо конфигурационный параметр, влияющий на вычислительный граф, не включён в хеш, это приведёт к ошибочному попаданию в кэш — граф, скомпилированный со старой конфигурацией, будет использоваться для новой конфигурации, что приведёт к тихим ошибкам. Именно поэтому в комментариях неоднократно подчёркивается: «поля, влияющие на вычислительный граф, должны быть включены в хеш».

Обход жизненного цикла запроса: от HTTP до токена

Постановка сценария

Предположим, клиент отправляет наvllm serveзапущенный сервис OpenAI-совместимый/v1/completionsзапрос с промптом "The capital of France is" и требованием сгенерировать 16 токенов. Проследим полный путь этого запроса по исходному коду.

Шаг 1: API Server принимает и выполняет предобработку

После получения HTTP-запроса процесс API Server выполняет токенизацию и разбор параметров сэмплирования, затем конструируетEngineCoreRequest:

python
class EngineCoreRequest(
    msgspec.Struct,
    array_like=True,
    omit_defaults=True,
    gc=False,
):
    request_id: str
    prompt_token_ids: list[int] | None
    mm_features: list[MultiModalFeatureSpec] | None
    sampling_params: SamplingParams | None
    pooling_params: PoolingParams | None
    arrival_time: float
    lora_request: LoRARequest | None
    cache_salt: str | None
    data_parallel_rank: int | None
    prompt_embeds: torch.Tensor | None = None
    # ... 更多字段

📎 vllm/v1/engine/__init__.py:109-124определяет базовую структуру запроса. Обратите внимание наmsgspec.Structв сочетании сarray_like=Trueиomit_defaults=Trueкомбинацию📎 vllm/v1/engine/__init__.py:109-113— это сделано дляпроизводительности сериализации。array_likeчтобы msgspec использовал позиционные массивы вместо словарей для кодирования,omit_defaultsпропуская поля со значениями по умолчанию — вместе это значительно уменьшает размер ZMQ-сообщений.

〔Проектные выводы и архитектурные компромиссы〕

gc=Falseже указывает msgspec не генерировать код отслеживания GC для этой структуры📎 vllm/v1/engine/__init__.py:109-113. Для часто создаваемых/уничтожаемых объектов сообщений отключение отслеживания GC снижает нагрузку на сборщик мусора Python, что является необходимой оптимизацией в сценариях обработки тысяч запросов в секунду.

Шаг 2: Планирование в EngineCore

После получения запроса EngineCore, Scheduler помещает его в очередь ожидания. На каждом шаге планирования Scheduler решает, включать ли этот запрос в текущий батч. Если включается, KV Cache Manager выделяет для него физические блоки (ключевая операция PagedAttention, подробнее в главе 2).

Результат планирования инкапсулируется вSchedulerOutputи через Executor отправляется GPU Worker.

Шаг 3: GPU Worker выполняет прямой проход

ModelRunner в Worker получаетSchedulerOutput, подготавливает входные тензоры (включая block table, slot mapping и другие attention metadata), выполняет прямой проход модели, сэмплирует следующий токен.

Шаг 4: Возврат результата

Токен, произведённый Worker, инкапсулируется вEngineCoreOutput:

python
class EngineCoreOutput(
    msgspec.Struct,
    array_like=True,
    omit_defaults=True,
    gc=False,
):
    request_id: str
    new_token_ids: list[int]
    new_logprobs: LogprobsLists | None = None
    finish_reason: FinishReason | None = None
    stop_reason: int | str | None = None
    # ...

📎 vllm/v1/engine/__init__.py:199-217определяет структуру вывода.finish_reasonявляетсяIntEnum, возможные значения включаютSTOP、LENGTH、ABORT、ERROR、REPETITION 📎 vllm/v1/engine/__init__.py:68-69. Комментарий объясняет, почему используетсяIntвместоStr:「Int rather than Str for more compact serialization」📎 vllm/v1/engine/__init__.py:56-57— снова оптимизация размера сериализации.

НесколькоEngineCoreOutputупаковываются вEngineCoreOutputsи через ZMQ возвращаются API Server📎 vllm/v1/engine/__init__.py:256-260。

Шаг 5: Потоковый возврат от API Server

После полученияEngineCoreOutputsAPI Server выполняет де-токенизацию каждогоEngineCoreOutput, затем потоково отправляет клиенту через SSE (Server-Sent Events).

Полная временная диаграмма

Приведённая ниже диаграмма последовательности показывает полное межпроцессное взаимодействие с указанием реальных имён функций и структур данных на каждом шаге:

mermaid
sequenceDiagram
    participant Client as Клиент
    participant API as Процесс API Server
    participant Core as Процесс EngineCore
    participant Sched as Scheduler
    participant Worker as Процесс GPU Worker

    Client->>API: POST /v1/completions
    API->>API: tokenize(prompt) -> prompt_token_ids
    API->>Core: EngineCoreRequest через ZMQ ADD
    Core->>Sched: add_request(EngineCoreRequest)
    loop каждый шаг decode
        Sched->>Sched: schedule() -> SchedulerOutput
        Sched->>Worker: execute_model(SchedulerOutput)
        Worker->>Worker: ModelRunner.forward() + sample()
        Worker-->>Sched: ModelRunnerOutput
        Sched->>Sched: update_from_output() -> EngineCoreOutput
        Core-->>API: EngineCoreOutputs через ZMQ
        API-->>Client: SSE-чанк (new_token_ids)
    end
    Note over Sched: запрос завершается при finish_reason != None

Ключевая информация этой диаграммы:Каждый decode step порождает однуEngineCoreOutputsобратную передачу, а не ожидание завершения генерации всей последовательности. Именно в этом проявляется Continuous Batching — завершённые последовательности немедленно выходят, новые запросы немедленно добавляются, вывод потоково возвращается клиенту.

Проектные размышления и подводные камни в продакшене

Паттерн «пост-инициализации» валидации конфигурации

VllmConfig.__post_init__является ядром всей системы конфигурации. Это не простое присваивание значений полям, амногоэтапный конвейер валидации:

1. Сначала анализируется режим мультимодального энкодера📎 vllm/config/vllm.py:1416-1416

2. Затем вызываетсяtry_verify_and_update_config(), чтобы специфичные для модели хуки конфигурации могли изменить конфигурацию📎 vllm/config/vllm.py:1434-1434

3. Далее проверяется согласованность между параллельной конфигурацией, конфигурацией квантования и конфигурацией LoRA📎 vllm/config/vllm.py:1442-1444

4. Наконец, выполняется проверка совместимости таких runtime-функций, как асинхронное планирование, CUDA Graph, KV Transfer📎 vllm/config/vllm.py:1544-1635

〔Проектные выводы и архитектурные компромиссы〕

Этот паттерн «пост-инициализации» решает фундаментальное противоречие:между элементами конфигурации существуют зависимости, но пользователь может задавать их в произвольном порядке. Например,async_schedulingвключён ли , зависит от типа метода speculative_config, поддержки со стороны бэкенда executor, использования pipeline parallelism и многих других условий📎 vllm/config/vllm.py:1544-1575. Если поместить эту логику в__set__поля, возникнут сложные циклические зависимости. Если же централизованно обрабатывать её в__post_init__в определённом порядке, логика остаётся ясной и легко отлаживаемой.

Подводный камень: конфликт между KV Connector и expandable_segments

📎 vllm/config/vllm.py:1219-1260в_verify_kv_transfer_compatраскрывает очень скрытую производственную ловушку.

При использовании KV Connector (например, NIXL, Mooncake) для развёртывания с разделением PD эти коннекторы черезibv_reg_mrи другие механизмызакрепляют (pin) физические страницы памяти KV cache. Но если одновременно заданPYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, аллокатор CUDA VMM в PyTorch может во время выполнения переназначить один и тот же виртуальный адрес на другие физические страницы📎 vllm/config/vllm.py:1227-1233。

Каковы последствия? Область памяти RDMA, зарегистрированная коннектором, указывает на уже недействительные физические страницы. При первой же межузловой передаче KV возникнет ошибкаIBV_WC_REM_ACCESS_ERRилиNIXL_ERR_REMOTE_DISCONNECT 📎 vllm/config/vllm.py:1232-1233。

Стратегия vLLM —консервативный отказ: как только обнаруженоexpandable_segments:Trueи настроен любой KV connector, сразу выбрасывается исключение📎 vllm/config/vllm.py:1249-1260. Единственное исключение — включёнenable_cumem_allocator, потому что аллокатор CuMem отключаетexpandable_segments 📎 vllm/config/vllm.py:1238-1241。

вокруг своего пула памяти

〔Проектные выводы и архитектурные компромиссы〕Урок этого случая таков:регистрация памяти RDMA и переотображение виртуальной памяти семантически несовместимыPYTORCH_CUDA_ALLOC_CONF。

. Любая функция, связанная с pin-ning памяти GPU (передача KV, регистрация буферов NCCL и т. д.), должна гарантировать, что базовые физические страницы не будут незаметно перемещены аллокатором. При диагностике подобных проблем, если передача RDMA падает при первой межузловой коммуникации, первая реакция должна быть — проверить

__post_init__Подводный камень: цепочка автоматической деградации асинхронного планированияasync_schedulingв📎 vllm/config/vllm.py:1544-1635логика обработкидемонстрирует тщательно продуманную。

цепочку автоматической деградацииasync_schedulingКогда пользователь явно не задалNone(значение

  • ), vLLM попытается автоматически включить его, но для этого необходимо последовательно проверить ряд условий несовместимости:📎 vllm/config/vllm.py:1578-1587
  • Если это pooling-модель, отключить📎 vllm/config/vllm.py:1588-1601
  • Если speculative-метод отсутствует в списке поддерживаемых, отключитьdisable_padded_drafter_batch=TrueЕсли📎 vllm/config/vllm.py:1602-1610
  • , отключить📎 vllm/config/vllm.py:1611-1617
  • Если бэкенд executor не поддерживает, отключить📎 vllm/config/vllm.py:1618-1624
  • Если это ROCm DeepEP high-throughput DBO, отключить📎 vllm/config/vllm.py:1625-1633

Если PP > 1 и используется V1 Model Runner, отключить📎 vllm/config/vllm.py:1639-1640。

Только если все проверки пройдены, оно в итоге включается

〔Проектные выводы и архитектурные компромиссы〕Философия дизайна этой цепочки деградации такова:по умолчанию включать оптимальную конфигурацию, при несовместимости — тихо деградировать и записывать предупреждение

. Это гораздо дружелюбнее, чем требовать от пользователя вручную настраивать каждый переключатель совместимости. Но цена такова: когда производительность ниже ожидаемой, пользователю приходится просматривать логи, чтобы обнаружить, что асинхронное планирование было автоматически отключено. В производственной среде при обнаружении аномальной пропускной способности рекомендуется проверить, есть ли в логах запуска предупреждение "Async scheduling will be disabled".

Резюме главы

1. В этой главе построена глобальная ментальная модель vLLM v1, ключевые моменты:Две фундаментальные проблемы, которые решает vLLM

2. : фрагментация видеопамяти (постраничное управление PagedAttention) и простой при батчинге (планирование на уровне итераций Continuous Batching).Многопроцессная архитектураA + DP + N: три уровня процессов API Server (вход) → EngineCore (планирование) → GPU Worker (исполнение), асинхронная коммуникация через ZMQ. Количество процессов следует формуле

3. .Четырёхуровневая модель

4. : уровень входа отвечает за предобработку, уровень ядра движка — за решения о планировании, уровень исполнителя — за распределённую стратегию, уровень Worker — за вычисления на GPU.VllmConfig — это глобальное состояние, пронизывающее все уровниcompute_hash(), через__post_init__поддерживается кэш компиляции, через

5. реализуется валидация между элементами конфигурации и вывод значений по умолчанию.:HTTP → tokenize → EngineCoreRequest → Scheduler → Worker forward → EngineCoreOutputЖизненный цикл запроса

→ потоковый возврат SSE.

Вопросы для размышления и самопроверки к этой главеEngineCoreRequestQ1: Если изменитьmsgspec.Structпараметрarray_like=True, omit_defaults=Trueсarray_like=False, omit_defaults=Falseна значение по умолчанию (то есть📎 vllm/v1/engine/__init__.py:109-113), в каких сценариях это приведёт к проблемам с производительностью? Проанализируйте с учётом📎 vllm/v1/engine/__init__.py:256-260и

Справочный разбор:array_like=Trueзаставляет msgspec кодировать структуры с помощью позиционных массивов вместо словарей,omit_defaults=Trueпропускает поля со значениями по умолчанию. При конфигурации по умолчанию каждыйEngineCoreRequestкодируется в словарную структуру, содержащую все имена полей, объём может увеличиться в 2-3 раза. В сценариях с высокой конкурентностью (тысячи запросов в секунду) объём ZMQ-сообщений между API Server и EngineCore значительно возрастает, что приводит к росту накладных расходов CPU на сериализацию/десериализацию и неэффективному использованию пропускной способности сети.EngineCoreOutputsтакже использует эти два параметра📎 vllm/v1/engine/__init__.py:256-260, а он генерируется на каждом шаге decode, что оказывает ещё большее влияние. Кроме того,gc=Falseотключает отслеживание GC, что позволяет снизить нагрузку на Python GC для высокочастотных короткоживущих объектов.

Вопрос 2: ВVllmConfig.__post_init__,async_schedulingлогика автоматического включения (📎 vllm/config/vllm.py:1576-1635) использует стратегию «последовательной проверки условий несовместимости, включение только при прохождении всех проверок». Если добавить новую функцию, несовместимую с асинхронным планированием, но разработчик забудет добавить соответствующую ветку в эту цепочку проверок, к каким проблемам это приведёт? Проанализируйте с точки зрения поведения системы.

Справочный разбор: Если забыть добавить ветку проверки, асинхронное планирование будет ошибочно включено. Ключевое допущение асинхронного планирования состоит в том, что «решение о планировании текущего шага не зависит от вывода предыдущего шага», что позволяет EngineCore планировать следующий шаг до завершения вычислений на GPU предыдущего шага. Если новая функция нарушает это допущение (например, некоторая логика постобработки, требующая чтения logits предыдущего шага), асинхронное планирование приведёт к гонке данных или некорректным результатам. Что ещё более скрыто, такие баги могут срабатывать только при определённой временной последовательности конкурентных операций и трудно воспроизводимы. Именно поэтому📎 vllm/config/vllm.py:1549-1552в явном пути включения используется стратегия «hard fail» — при активном включении пользователем происходит прямая ошибка, а не тихая деградация, что заставляет разработчика столкнуться с проблемой совместимости.

Q3: VllmConfig.compute_hash()предупреждение в комментарии «поля, влияющие на граф вычислений, должны быть добавлены в список factors» (📎 vllm/config/vllm.py:465-467). Предположим, что некоторое новое полеattention_sink_tokensвлияет на логику вычисления attention, но было пропущено в хеше — какой тип сбоя это вызовет в производственной среде? Почему такие сбои особенно опасны?

Справочный разбор:compute_hash()выход используется в качестве ключа кэша компиляции torch.compile. Еслиattention_sink_tokensвлияет на структуру графа вычислений, но не включено в хеш, то когда пользователь переходит сattention_sink_tokens=0наattention_sink_tokens=4, значение хеша не меняется, и vLLM повторно использует ранее скомпилированный граф (без логики sink token). В результате модель молча выдаёт ошибочный вывод — без ошибок, без падений, просто неверный результат. Такие сбои особенно опасны по следующим причинам: (1) они не вызывают никаких исключений или предупреждений в логах; (2) вывод по-прежнему представляет собой «выглядящий разумным» текст, лишь с ухудшением качества или аномальным поведением; (3) для диагностики требуется сопоставлять попадания в кэш компиляции с фактическими различиями в конфигурации, что крайне затратно. Именно поэтому в комментарии неоднократно подчёркивается, что для каждого нового поля необходимо оценивать, влияет ли оно на граф вычислений.

本章从一次朴素推理请求的崩溃现场出发,揭示了 vLLM 必须解决的两个根本矛盾:显存碎片与批处理空转,并给出了 PagedAttention 与 Continuous Batching 这两把钥匙。我们随后鸟瞰了 vLLM v1 的整体架构,理清了进程模型、组件分层以及请求的完整生命周期。有了这张全局地图,下一章将深入 vLLM 最核心的数据结构——Request、Sequence 和 KV Cache 的 block 管理机制,揭示 PagedAttention 如何在代码层面实现「逻辑连续、物理离散」的显存映射。

CHAPTER 02

Глава 2: Базовые абстракции и структуры данных: Request, Sequence и KV Cache

Проект: vllm-project/vllm · Прогресс по книге: Глава 2 / 14 · Статус верификации: FACT — реальная привязка номеров строк

В предыдущей главе мы построили многоуровневую ментальную модель vLLM v1 и знаем, что запрос начинается с API Server, проходит через EngineCore и в конечном итоге достигает Worker для выполнения. Но как строка JSON из тела HTTP-запроса превращается в объект внутри движка, который можно планировать, отслеживать и прерывать? Именно на этот вопрос призван ответить класс Request.

Система спецификаций KV Cache: от KVCacheSpec до реестра

Request решает вопрос «кто должен вычислять», аKVCacheSpecрешает вопрос «где вычислять». В мире PagedAttention каждый слой модели требует точного описания KV cache: сколько у него голов, каков размер каждой головы, сколько токенов может хранить один блок, нужна ли квантизация. Эта информация закодирована вKVCacheSpecиерархии наследования.

Интуитивная модель: KVCacheSpec — это «план этажа» видеопамяти

〔Проектные предположения и архитектурные компромиссы〕

Если представить видеопамять GPU как участок земли под застройку,KVCacheSpec— это планировка каждого здания (каждой cache group): она определяет, сколько комнат (head slot) на каждом этаже (в каждом блоке), какова площадь каждой комнаты (head_size), сколько человек может проживать (block_size токенов). АKVCacheConfig— это план застройки всего микрорайона: сколько всего зданий, сколько земли занимает каждое здание, какие здания используют один и тот же фундамент (block table).

Без этой системы спецификаций распределение KV cache могло бы опираться только на жёстко закодированные предположения и не смогло бы поддерживать разнообразные требования моделей — от стандартного MHA до MLA, от полного внимания до скользящего окна, от FP16 до FP8-квантования.

Структура данных: дерево наследования KVCacheSpec и ключевые поля

KVCacheSpecявляется базовым классом всех спецификаций, это@dataclass(frozen=True) 📎 vllm/v1/kv_cache_interface.py:150-152. frozen означает, что объект спецификации после создания неизменяем — это гарантирует, что несколько компонентов (планировщик, Worker, KV Cache Manager) видят одну и ту же спецификацию и не возникает несогласованности из-за изменения где-либо.

Базовый класс определяет три абстрактных свойства, которые должны быть реализованы подклассами:num_heads、tokens_per_state、state_content_size_bytes 📎 vllm/v1/kv_cache_interface.py:182-183. Эти три свойства совместно определяютpage_size_bytes— то есть количество байтов, занимаемых одним block.

AttentionSpecявляется наиболее важным подклассом, он вводитnum_kv_heads、head_size、dtype、kv_quant_modeи другие поля📎 vllm/v1/kv_cache_interface.py:485-498. Особенно изящно спроектировано полеtokens_per_state: значение по умолчанию — 1, что означает, что одно state соответствует одному token; но оно может быть задано целым числом больше 1 (например, разреженный MLA в DeepSeek-V4 сжимает несколько token в один state) или дробью меньше 1 (например, block pooling в Whisper используетFraction(1, block_pool_size)для обозначения того, что один token соответствует нескольким state)📎 vllm/v1/kv_cache_interface.py:501-501。

FullAttentionSpecНа основеAttentionSpecдобавляетsliding_windowиattention_chunk_size 📎 vllm/v1/kv_cache_interface.py:566-566. Обратите внимание, что его docstring объясняет важное проектное решение: когда смешанный аллокатор отключён, слой скользящего окна внимания в KV Cache Manager обрабатывается как полное внимание (block выделяется для всех token), но во время выполнения модели по-прежнему вычисляется как скользящее окно📎 vllm/v1/kv_cache_interface.py:540-545. Этоконсервативное распределение, точное вычисление— такая стратегия.

MLAAttentionSpecявляется ключевой спецификацией для моделей серии DeepSeek. Она устанавливаетhead_size_vпо умолчанию в 0📎 vllm/v1/kv_cache_interface.py:670, поскольку MLA хранит только один latent vector и не имеет отдельного V.alignmentПоле используется для выравнивания страниц📎 vllm/v1/kv_cache_interface.py:646-652, что критически важно для бэкендов вроде FlashMLA, требующих определённого выравнивания.

MambaSpecже вообще не идёт по пути attention. Он используетshapesиdtypesкортежи для описания формы тензора состояния📎 vllm/v1/kv_cache_interface.py:1027-1028,state_content_size_bytes— это сумма размеров всех тензоров состояния📎 vllm/v1/kv_cache_interface.py:1048-1052. У Mambamax_memory_usage_bytesв зависимости отmamba_cache_modeимеет три различных способа вычисления📎 vllm/v1/kv_cache_interface.py:1073-1084, что отражает сложность управления состоянием Mamba — оно не растёт линейно, как attention, а имеет фиксированный размер состояния.

Сценарии в действии: преобразование спецификаций в раскладку видеопамяти

При запуске движка необходимо преобразоватьKVCacheSpecвсех слоёв в фактическую раскладку видеопамяти. Этот процесс выполняетсяKVCacheTensorиcreate_kv_cache_views.

KVCacheTensorописывает положение группы слоёв одинаковой формы в распределении KV cache📎 vllm/v1/kv_cache_interface.py:1406-1427. Его ключевые поля —layer_strideиblock_stride: первое — это байтовое расстояние между соседними слоями, второе — байтовое расстояние между соседними block. Docstring подробно объясняет два режима раскладки: layer-outermost даёт каждому слою непрерывную область, block-outermost позволяет каждому block содержать page всех слоёв📎 vllm/v1/kv_cache_interface.py:1416-1416。

mermaid
flowchart LR
    subgraph spec["Уровень KVCacheSpec"]
        fas["FullAttentionSpec<br/>num_kv_heads=32<br/>head_size=128<br/>block_size=16"]
    end
    subgraph tensor["Уровень KVCacheTensor"]
        kt["KVCacheTensor<br/>size=2GB<br/>layer_stride=page*num_blocks<br/>block_stride=page"]
    end
    subgraph view["Представление torch.Tensor"]
        v1["layer_0: [B, H, N, C]"]
        v2["layer_1: [B, H, N, C]"]
        v3["layer_N: [B, H, N, C]"]
    end
    fas -->|"compute_layer_kv_cache_shape_bytes()"| kt
    kt -->|"create_kv_cache_views()"| v1
    kt -->|"create_kv_cache_views()"| v2
    kt -->|"create_kv_cache_views()"| v3

create_kv_cache_viewsФункция является ядром этого процесса📎 vllm/v1/kv_cache_interface.py:353-417. Она принимает плоский int8 buffer и черезtorch.as_stridedсоздаёт 4D-представление для каждого слоя[B, H, N, C]. Ключевой параметр —strides, который вычисляется изcompute_layout_strides📎 vllm/v1/kv_cache_interface.py:314-350. Эта функция в порядке размерностей, заданномlayout.stride_order, вычисляет байтовый шаг каждой размерности в обратном порядке начиная с самой внутренней размерности.

Здесь есть заслуживающая внимания проверка границ: когда kernel_block_size меньше spec.block_size (то есть один manager block разбивается на несколько kernel block), код проверяет, равен ли block_stride dense_page_size📎 vllm/v1/kv_cache_interface.py:381-382. Если не равен, это означает наличие padding в раскладке и невозможность равномерного разбиения; в этом случае выбрасывается ValueError с чёткой рекомендацией по исправлению.

Проектные соображения: паттерн реестра и расширяемость

KVCacheSpecRegistryявляется ключевым элементом дизайна расширяемости vLLM📎 vllm/v1/kv_cache_spec_registry.py:39-40. Он поддерживает два глобальных словаря:_REGISTRY_KVCACHESPEC_LISTхранит отображение классов spec на метаданные,_REGISTRY_ROLE_MANAGERSхранит отображение ролей на менеджеры📎 vllm/v1/kv_cache_spec_registry.py:35-36。

get_manager_classМетод демонстрирует основную логику поиска в реестре: он идёт вверх по MRO (порядку разрешения методов) класса spec и находит первый зарегистрированный базовый класс📎 vllm/v1/kv_cache_spec_registry.py:129-130. Это означает, что пользовательскийCustomFullAttentionSpec, если он не зарегистрирован отдельно, автоматически наследует менеджерFullAttentionSpec. Такойпоиск на основе наследованияпозволяет при добавлении нового типа spec регистрировать только различия.

check_kv_cache_spec_registryМетод при запуске проверяет, что spec всех слоёв зарегистрированы📎 vllm/v1/kv_cache_spec_registry.py:165-174. Обратите внимание, что он используетraise ValueError, а неassert, и комментарий явно указывает, что это сделано для того, чтобы работало и в production-среде📎 vllm/v1/kv_cache_spec_registry.py:165-174. Это важное инженерное решение: флаг Python-Oудаляет assert, но ошибки конфигурации в production должны выявляться при запуске, а не приводить к падению во время выполнения.

〔Проектные выводы и архитектурные компромиссы〕

Дизайн ленивой инициализации реестра (_ensure_registered) решает проблему циклической зависимости:kv_cache_interface.pyтребуется ссылаться на реестр для проверки типа spec, а реестру нужно импортироватьsingle_type_kv_cache_managerдля получения класса менеджера, который, в свою очередь, зависит отkv_cache_interface. За счёт откладывания фактической регистрации до момента первого запроса этот цикл разрывается.

Краткое содержание главы

В этой главе были проанализированы две ключевые структуры данных vLLM v1.Request— это носитель жизненного цикла запроса внутри движка; через двойной список токенов, асинхронный счётчик планирования и механизм block hash он поддерживает две ключевые функции: непрерывную пакетную обработку и кэширование префиксов.KVCacheSpecи его иерархия наследования определяют спецификацию размещения видеопамяти для KV cache, от стандартногоFullAttentionSpecдоMLAAttentionSpec、MambaSpec, охватывая потребности разнообразных архитектур моделей. Шаблон реестра позволяет добавлять новые типы spec без изменения ядра кода, обеспечивая расширяемость системы.

На данный момент мы уже увидели, как Request преобразуется из EngineCoreRequest и как он через счётчики состояний, block hash и другие механизмы поддерживает решения планировщика. Но как именно внешний запрос проходит через API Server, chat template и мультимодальную обработку, в конечном итоге превращаясь в EngineCoreRequest? В следующей главе мы перейдём на уровень входной точки запросов и полностью проследим этот путь от HTTP/CLI до EngineCore.

CHAPTER 03

Глава 3: Входная точка запросов: полный путь от HTTP/CLI до EngineCore

Проект: vllm-project/vllm · Прогресс по книге: Глава 3 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы проанализировали две ключевые структуры данных внутри движка — Request и KVCacheSpec — и поняли, как логическая последовательность отделяется от физических блоков видеопамяти. Но как именно тело HTTP-запроса или строка Python проходит через API Server, chat template и мультимодальную обработку, в конечном итоге превращаясь в EngineCoreRequest? В этой главе мы полностью проследим этот путь и покажем, как три входных пути — синхронный CLI, асинхронный API и офлайн-класс LLM — сходятся к одному ядру движка.

3.1 Точка сходимости трёх входных путей: AsyncLLMEngine и LLMEngine

Прежде чем углубляться в разбор запросов, необходимо сначала ясно увидеть топологию трёх входных путей. vLLM предоставляет три способа использования:vllm serveзапускаемый OpenAI-совместимый HTTP-сервис, инструмент командной строкиvllm, а также прямое создание экземпляра классаLLMв Python для офлайн-инференса. На первый взгляд они независимы, но фактически используют одно и то же ядро движка.

Сначала рассмотрим механизм псевдонимов для асинхронного API-пути.

📎 vllm/engine/async_llm_engine.py:7-7

Этот файл настолько короткий, что почти не похож на модуль — он делает только одно: направляет псевдонимAsyncLLMEngineнаvllm.v1.engine.async_llm.AsyncLLM. Это типичный след архитектурной миграции. Во времена vLLM v0AsyncLLMEngineбыл огромным и сложным классом; после переписывания архитектуры v1 новыйAsyncLLMвзял на себя те же обязанности. Чтобы не ломать существующий пользовательский код, vLLM сохранил старый путь модуля как слой совместимости.

〔Проектные предположения и архитектурные компромиссы〕

Такой шаблон «старый путь-псевдоним указывает на новую реализацию» неоднократно встречается в vLLM (например, deprecation warning вapi_server.py), что говорит о том, что проект при переходе от v0 к v1 выбрал постепенную стратегию: новый код использует новые пути, старый код не выдаёт ошибку, но получает предупреждение, давая пользователям достаточное окно для миграции.

Теперь рассмотрим входную точку офлайн-пути.

📎 vllm/entrypoints/llm.py:344-346

LLM.__init__в конечном итоге вызываетLLMEngine.from_engine_args, передаваяUsageContext.LLM_CLASS. Этот enumUsageContextявляется ключом к различению входных путей — он позволяет движку понимать, работает ли он в режиме офлайн-пакетной обработки или в режиме онлайн-обслуживания, и соответственно настраивать логирование, метрики и стратегию управления ресурсами.

📎 vllm/entrypoints/llm.py:357-359

Обратите внимание на присваивание здесьself.renderer = self.llm_engine.rendererиself.input_processor = self.llm_engine.input_processor. Офлайн-классLLMне реализует рендеринг chat template самостоятельно, а повторно использует внутреннийrendererдвижка. Это означает, что логика разбора chat template в офлайн- и онлайн-путях — один и тот же код, различается только момент вызова.

Отношения сходимости трёх путей можно представить следующей диаграммой потоков данных.

mermaid
flowchart LR
    subgraph entry["Уровень входа"]
        http["Тело HTTP-запроса<br/>ChatCompletionRequest"]
        cli["Аргументы CLI<br/>vllm serve / vllm chat"]
        offline["Вызов из Python<br/>LLM.chat(messages)"]
    end

    subgraph parse["Уровень разбора"]
        chat_utils["chat_utils.parse_chat_messages<br/>-> ConversationMessage + mm_data"]
        renderer["renderer<br/>apply_chat_template -> token_ids"]
    end

    subgraph engine["Уровень движка"]
        async_llm["AsyncLLM<br/>add_request()"]
        llm_engine["LLMEngine<br/>add_request()"]
        core["EngineCore<br/>input_queue"]
    end

    http --> chat_utils
    cli --> chat_utils
    offline --> chat_utils
    chat_utils --> renderer
    renderer --> async_llm
    renderer --> llm_engine
    async_llm --> core
    llm_engine --> core

Эта диаграмма раскрывает ключевой проектный принцип: независимо от того, приходит ли запрос из HTTP, CLI или Python,chat_utilsявляется единственной входной точкой для мультимодальной обработки и обработки chat template. Он унифицирует разнородные форматы ввода в списокConversationMessageплюсMultiModalDataDict, а затем передаёт renderer для генерации последовательности токенов.

3.2 chat_utils: от разнородных сообщений к единой структуре диалога

chat_utils.py— самый сложный модуль во всём входном слое запросов: 2264 строки кода обрабатывают OpenAI-совместимый формат, пользовательские расширения, мультимодальные встраивания, вызовы инструментов и все остальные формы ввода. Его основную задачу можно сформулировать одной фразой: нормализовать произвольный список сообщений, переданный пользователем, в списокConversationMessage, понятный chat template, одновременно извлекая мультимодальные данные в отдельныйMultiModalDataDict.

Интуитивная модель: переводчик и сортировщик багажа

Представьтеchat_utilsкак переводчика и одновременно сортировщика багажа в аэропорту. Пассажиры (пользователи) приезжают из разных стран (формат OpenAI, пользовательский формат, формат Harmony) и говорят на разных языках. Переводчик сначала переводит слова всех на единый рабочий язык (ConversationMessage), одновременно сортируя зарегистрированный багаж пассажира (изображения, аудио, видео) на отдельные конвейерные ленты (MultiModalDataDict), наклеивая метки (UUID), и наконец отправляя людей и багаж на один и тот же самолёт (движок).

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

Структура данных: двойное взаимодействие классов трекера и парсера

chat_utilsЯдром является взаимодействие двух групп классов:BaseMultiModalItemTrackerи его подклассы отвечают за «отслеживание» мультимодальных элементов,BaseMultiModalContentParserи его подклассы отвечают за «разбор» содержимого.

Сначала рассмотрим структуру полей трекера.

📎 vllm/entrypoints/chat_utils.py:598-601

_items_by_modalityявляетсяdefaultdict[str, list[_T]], хранящим ожидающие обработки элементы, сгруппированные по модальности (image, audio, video и т. д.)._modality_orderже специально дляvision_chunkмодальности записывает исходную модальность каждого chunk (image или video), поскольку унифицированная модель визуального chunk отображает обе наvision_chunk, но последующая обработка должна знать исходный тип.

📎 vllm/entrypoints/chat_utils.py:613-615

use_unified_vision_chunk_modalityявляетсяcached_property, читаемым из конфигурации HuggingFace флагомuse_unified_vision_chunk. Используетсяcached_propertyвместо обычного атрибута, потому что эта проверка срабатывает при каждом вызовеadd, и кэширование позволяет избежать повторных затрат наgetattr.

Методaddтрекера является основной точкой входа.

📎 vllm/entrypoints/chat_utils.py:656-684

addМетод сначала вызывает_validate_addдля валидации, затем в зависимости от того, используется ли унифицированная модальность визуального chunk, сохраняет элементы под разными ключами. Обратите внимание наprompt_embedsособую обработку: он напрямую добавляется к_items_by_modality["prompt_embeds"]и возвращаетNone, поскольку предвычисленные эмбеддинги не проходят через HF processor и не имеют строки-заполнителя.

_validate_addЛогика валидации в заслуживает подробного рассмотрения.

📎 vllm/entrypoints/chat_utils.py:686-721

Здесь есть тонкая ветвь: когдаenable_mm_embeds=Trueи лимит на каждый prompt для этой модальности равен 0, и исходная модальность заканчивается на_embeds, проверка количества пропускается. Это сделано для того, чтобы позволить эмбеддинг-входу обойти ограничение количества исходной модальности — эмбеддинги предвычислены и не занимают ресурсы обработки исходной модальности.

Сценарий: как разбирается один chat-запрос с изображением

Предположим, пользователь отправляет chat-запрос, содержащий URL изображения и текст.parse_chat_messagesявляется точкой входа синхронного пути.

📎 vllm/entrypoints/chat_utils.py:2161-2197

parse_chat_messagesсоздаётMultiModalItemTracker, перебирает каждое сообщение, вызывая_parse_chat_message_content, в конце вызывает_postprocess_messagesдля обработки параметров вызова инструментов, затем черезmm_tracker.resolve_items()материализует мультимодальные данные.

_parse_chat_message_contentотвечает за разбор одного сообщения.

📎 vllm/entrypoints/chat_utils.py:2007-2029

Сначала он нормализует content:Noneпревращается в пустой список, строка — в единственную текстовую часть. Затем вызывается_parse_chat_message_content_parts, где параметрwrap_dictsопределяетсяcontent_format == "openai"— это определяет, будет ли вывод структурированным списком словарей или объединённой строкой.

_parse_chat_message_content_partsперебирает каждую часть.

📎 vllm/entrypoints/chat_utils.py:1814-1853

Каждая часть обрабатывается_parse_chat_message_content_part. Еслиwrap_dicts=False, в итоге текст и заполнители объединяются в одну строку; еслиwrap_dicts=True, возвращается структурированный список словарей.

_parse_chat_message_content_partявляется ядром диспетчеризации.

📎 vllm/entrypoints/chat_utils.py:1875-1884

Для чисто текстовой части сначала выполняется проверка сохранения заполнителей, затем в зависимости отwrap_dictsопределяется формат возврата. Для структурированной части вызывается_parse_chat_message_content_mm_partдля извлечения типа и содержимого.

📎 vllm/entrypoints/chat_utils.py:1690-1723

_parse_chat_message_content_mm_partчерезMM_PARSER_MAPнаходит соответствующую функцию разбора. Обратите внимание на условиеuuid is None— если пользователь предоставил UUID, это означает, что медиаданные могут отсутствовать в теле запроса (загружены другим способом), и тогда выполняется ветвь прямого извлечения поля URL ниже.

📎 vllm/entrypoints/chat_utils.py:1731-1733

Когдаpart_type is Noneилиuuid is not None, код пытается напрямую извлечь поле URL из part. Этот «мягкий разбор» предназначен для совместимости с клиентами, не строго следующими формату OpenAI.

Возвращаясь к_parse_chat_message_content_part, части медиатипа направляются в соответствующийmm_parserметод.

📎 vllm/entrypoints/chat_utils.py:1923-1968

Каждый медиатип вызывает соответствующийparse_*метод, который внутри вызываетtracker.addдля добавления элемента в трекер и возвращает строку-заполнитель. В конце в зависимости отinterleave_stringsрешается, возвращать заполнитель илиNone。

📎 vllm/entrypoints/chat_utils.py:1984-1999

prompt_embedsобрабатывается особым образом: независимо отinterleave_strings, всегда возвращаетсяPROMPT_EMBEDS_PLACEHOLDER_TOKEN. Комментарий объясняет причину — prompt_embeds конкатенируются по смещению токенов, позиция важна, и если пойти поmissing_placeholdersлогике предварительного заполнения, порядок будет нарушен.

Различия асинхронного пути

Асинхронный путь используетAsyncMultiModalItemTrackerиAsyncMultiModalContentParser. Основное различие вresolve_items。

📎 vllm/entrypoints/chat_utils.py:906-952

Асинхронная версия используетasyncio.gatherдля параллельного ожидания всех модальных элементов. Комментарий явно указывает: каждый элемент трекинга уже является независимым awaitable, асинхронный коннектор выгружает блокирующую работу декодирования в пул потоков, поэтому последовательное ожидание одной модальности за другой лишь без необходимости увеличивает задержку.return_exceptions=Trueпозволяет всем задачам завершиться или завершиться с ошибкой, а затем единообразно выбросить исключение, избегая отказа от всё ещё выполняющихся сетевых запросов при первой же ошибке.

Размышления о дизайне: почему трекер и парсер разделены

〔Предположения о дизайне и архитектурные компромиссы〕

Разделение трекера и парсера — это дизайн, достойный размышления. Трекер отвечает за «управление состоянием» — запись количества элементов каждой модальности, проверку ограничений количества, поддержание исходного порядка модальностей vision_chunk. Парсер отвечает за «извлечение содержимого» — получение изображений по URL, декодирование эмбеддингов из base64, обработку преобразования аудиоформатов. Это разделение позволяет синхронному и асинхронному путям совместно использовать логику трекинга (BaseMultiModalItemTrackerявляется абстрактным базовым классом), разветвляясь только на уровне парсера. Если объединить в один класс, различия между синхронным и асинхронным путями проникли бы в логику трекинга, что привело бы к дублированию кода и усложнению управления состоянием.

3.3 От сообщения к токену: передача между renderer и EngineCore

chat_utilsпроизводитConversationMessageсписок иMultiModalDataDictещё должны пройти рендеринг через chat template, чтобы превратиться в последовательность токенов. Этот шаг выполняется renderer, после чего запрос действительно попадает в движок.

Сценарий: рендеринг chat template и отправка запроса

parse_chat_messagesПосле возврата вызывающая сторона (например,OpenAIServingChat) передастconversationиmm_dataв renderer. Renderer применяет chat template, преобразует списокConversationMessageв текст, а затем токенизирует его в последовательность token ID. Мультимодальные заполнители (например,<##IMAGE##>) после токенизации заменяются на специфичные для модели токены-заполнители.

После завершения рендеринга запрос инкапсулируется вEngineCoreRequestи черезAsyncLLM.add_request()илиLLMEngine.add_request()отправляется во входную очередь EngineCore.

📎 vllm/entrypoints/llm.py:420-484

Офлайн-методLLM.generateдемонстрирует эту цепочку: сначала он проверяетrunner_type, получает параметры сэмплирования по умолчанию, затем вызывает_run_completion。_run_completion, внутри которого вызывается renderer для рендеринга prompt, а затем черезllm_engineотправляется запрос.

📎 vllm/entrypoints/llm.py:615-708

LLM.chatМетодmessagesдемонстрирует путь chat: он принимает список_run_chat, вызываетparse_chat_messages, который внутри вызывает

Проектное размышление: почему renderer находится внутри движка

〔Проектные выводы и архитектурные компромиссы〕

LLM.__init__Вself.renderer = self.llm_engine.rendererстрокаself.renderer.warmup(ChatParams(...))раскрывает важное проектное решение: renderer принадлежит движку, а не слою входа. Это означает, что загрузка, кэширование и прогрев chat template (LLM) выполняются при инициализации движка, а слой входа является лишь вызывающей стороной. Преимущество такого подхода: офлайн-AsyncLLMи онлайн-

используют одну и ту же реализацию renderer и кэш, что позволяет избежать повторной загрузки tokenizer и chat template. Кроме того, прогрев renderer может быть выполнен при запуске движка, что позволяет избежать задержки холодного старта первого запроса.

_postprocess_messagesОбработка ошибок и подводные камни в production

📎 vllm/entrypoints/chat_utils.py:2118-2158

Обработка параметров вызова инструментов вtool_calls— типичная ловушка production-среды.argumentsКогда сообщение assistant содержитarguments, поле

может быть JSON-строкой, словарём или невалидным JSON. Код пытается распарсить JSON-строку, и если это не удаётся, записывает предупреждение и принудительно преобразует в пустой объект. Комментарий объясняет причину: некорректно отформатированный

📎 vllm/entrypoints/chat_utils.py:1856-1872

присутствует в истории диалога, и если здесь запрос завершится ошибкой, то каждый последующий раунд также будет завершаться ошибкой, и диалог невозможно будет восстановить. Это продуманный дизайн отказоустойчивости — лучше позволить модели увидеть пустые параметры инструмента, чем заблокировать весь диалог.enable_prompt_embedsЕщё одна ловушка — защита от инъекции зарезервированного заполнителя.PROMPT_EMBEDS_PLACEHOLDER_TOKENКогда_reject_reserved_placeholder_in_textвключён,

📎 vllm/entrypoints/chat_utils.py:1889-1892

регистрируется как неделимый специальный токен. Если пользовательский текст случайно содержит эту буквальную последовательность, tokenizer закодирует её в тот же token ID, и renderer ошибочно решит, что это точка склейки, что позволит вызывающей стороне через обычный текстовый контент перемещать или внедрять позицию склейки.isinstance(part, str)При разборе текстовой части

отклоняет такой ввод, закрывая эту уязвимость.

Обратите внимание, что эта проверка вызывается как в веткеLLM, так и в ветке структурированного текста, что гарантирует прохождение всех текстовых путей через защиту.chat_utilsРезюме главыBaseMultiModalItemTrackerВ этой главе прослежен первый участок цепочки попадания запроса в систему извне. Три входных пути — HTTP API, CLI и офлайн-классBaseMultiModalContentParser— в конечном итоге сходятся к слою мультимодального разбораparse_chat_messages.ConversationMessageотвечает за управление состоянием,MultiModalDataDictотвечает за извлечение содержимого; их разделение позволяет синхронному и асинхронному путям совместно использовать логику трассировки.EngineCoreRequestнормализует разнородные сообщения в список

и

, а затем передаёт их внутреннему renderer движка для выполнения рендеринга chat template и токенизации. В итоге запрос инкапсулируется в_parse_chat_message_content_mm_partи отправляется во входную очередь EngineCore.uuid is NoneВопросы для размышления и самопроверки по главеif isinstance(part_type, str) and part_type in MM_PARSER_MAP:Q1: В

, если убрать условие:uuid is None(то есть заменить наMM_PARSER_MAP[part_type](part)), в каких сценариях это приведёт к проблемам?image_urlЭталонный разборNoneУсловиеparse_image(None, uuid)существует для обработки сценария «пользователь предоставил UUID, но медиаданные отсутствуют в теле запроса». Когда пользователь предоставляет UUID, медиаданные могли быть загружены другим способом (например, предварительно загружены в медиакэш), и тогда part в теле запроса может содержать только UUID без фактического URL или данных. Если убрать это условие, код попытается выполнить разбор через_connector.fetch_image(None), но в part может не быть соответствующего поля данных (например,uuid is not Noneпуст), что приведёт к разбору📎 vllm/entrypoints/chat_utils.py:1713-1723содержимого. Что ещё серьёзнее, последующий📎 vllm/entrypoints/chat_utils.py:1731-1733。

Q2: AsyncMultiModalItemTracker.resolve_itemsвызоветasyncio.gather(..., return_exceptions=True), что может вызвать ненужные сетевые запросы или исключения.return_exceptions=FalseВеткаFalseидёт по пути прямого извлечения полей и корректно обрабатывает случай «есть UUID, нет данных». См.

и:return_exceptions=FalseИспользуетсяasyncio.gatherвместо стандартногоreturn_exceptions=TrueЗаставляем все задачи завершиться или провалиться, а затем выполняем единую проверку, чтобы гарантировать, что ни одна задача не осталась брошенной. Комментарий явно поясняет это: «Gathering with return_exceptions=True lets every task finish (or itself fail) before we raise, instead of abandoning still-in-flight fetches (real network/thread-pool work) the moment the first one fails.» См.📎 vllm/entrypoints/chat_utils.py:924-931。

Q3: _postprocess_messages, когдаargumentsявляется недействительным JSON, код выбирает принудительное преобразование в пустой объект вместо выброса исключения. Если изменить на выброс исключения, в каких производственных сценариях это приведёт к невосстановимому состоянию диалога?

Справочный анализ:argumentsПоле существует в истории диалога (в сообщении assistanttool_calls). Если в каком-либо раунде диалога модель сгенерировала некорректно отформатированныйarguments, эта ошибка сохранится в истории диалога. Если_postprocess_messagesпри разборе истории выбрасывает исключение, то каждый последующий раунд запроса будет завершаться неудачей из-за этой ошибки в истории — даже если входные данные текущего раунда полностью корректны. Пользователь не сможет продолжить этот диалог и будет вынужден abandon всю сессию и начать заново. Принудительное преобразование в пустой объект позволяет диалогу продолжиться, и модель, увидев пустые аргументы инструмента, сгенерирует корректный вызов заново. Комментарий объясняет это: «A malformed arguments string lives in conversation history, so failing the request here would fail every subsequent turn too and leave the conversation unrecoverable.» См.📎 vllm/entrypoints/chat_utils.py:2124-2139。

Следующая глава перейдёт к планировщику, чтобы посмотреть, как EngineCore организует эти запросы с помощью непрерывной батчевой обработки и стратегии с учётом видеопамяти.

На этом запрос завершил нормализованное преобразование из внешнего ввода в EngineCoreRequest и достиг входа в ядро движка. Но после входа запрос не выполняется немедленно — движку нужно решить, какие запросы обрабатывать на каждом шаге и как распределять ограниченные ресурсы видеопамяти. Следующая глава углубится в цикл планирования EngineCore, проанализирует, как Scheduler балансирует пропускную способность и задержку в непрерывной батчевой обработке, а также как chunked prefill, prefix caching и распределение KV block работают совместно.

CHAPTER 04

Глава 4: Управление памятью и PagedAttention: виртуализация KV Cache

Проект: vllm-project/vllm · Прогресс книги: Глава 4 / 14 · Статус проверки: FACT номера строк реально привязаны

После входа запроса во входную очередь EngineCore он не выполняется немедленно. Какие запросы обрабатывать на каждом шаге, сколько token-бюджета выделять каждому запросу, кого в первую очередь принести в жертву при нехватке видеопамяти — все эти решения сосредоточены в методеScheduler.schedule(). Эта глава начинается со структур данных планировщика и отслеживает, как один вызовschedule()организует очередь waiting, список running и пул KV cache в исполняемый батч.

4.1 Структуры данных планировщика: три очереди и один пул видеопамяти

Ключевой вопрос, на который должен ответить планировщик:При ограниченном token-бюджете и бюджете KV block, какие запросы должны продвинуться на сколько token на этом шаге?Чтобы понять это, сначала нужно увидеть, какими состояниями он располагает.

Планировщик поддерживает три типа контейнеров запросов.self.requests— это глобальный словарь,req_id -> Request, единственный источник истины для всех активных запросов📎 vllm/v1/core/sched/scheduler.py:208-209。self.waitingиself.skipped_waiting— это две очереди с приоритетами: первая содержит запросы, нормально ожидающие планирования, вторая — запросы, временно не подлежащие планированию из-за асинхронных зависимостей или ограничений (например, ожидание удалённого KV, ожидание компиляции грамматики структурированного вывода)📎 vllm/v1/core/sched/scheduler.py:208-209。self.running— это обычный список, содержащий запросы, уже перешедшие в рабочее состояние и удерживающие KV block📎 vllm/v1/core/sched/scheduler.py:208-209。

Здесь есть легко упускаемый из виду дизайн:max_num_running_reqsиmax_num_active_reqs— это два разных верхних предела. Первый происходит изmax_num_seqs, определяет количество слотов model runner; второй происходит изmax_num_active_seqs, ограничивает только число запросов, которые могут войти в RUNNING, по умолчанию равен первому📎 vllm/v1/core/sched/scheduler.py:123-131. Это разделение позволяет снизить фактический размер батча параллельного декодирования без уменьшения ёмкости захвата CUDA graph.

Со стороны видеопамяти всем управляетKVCacheManager, внутри него содержитсяBlockPool。BlockPoolЯдроself.blocks— этоKVCacheBlock(список всехfree_block_queue) и📎 vllm/v1/core/block_pool.py:171-177(двусвязный список свободных блоков, упорядоченный по порядку вытеснения)null_block. Обратите внимание на наличиеis_null=True: это первый блок, извлекаемый из головы очереди свободных,📎 vllm/v1/core/block_pool.py:183-187, счётчик ссылок не участвует в обычном обслуживании и используется специально как заполнитель

. Когда для какой-либо позиции token запроса не требуется реальный KV block (например, позиция, пропущенная скользящим окном), в block table подставляется этот null block.BlockHashToBlockMapСтруктура индекса префиксного кэша —BlockHashWithGroupId, она отображаетKVCacheBlockв{block_id: KVCacheBlock}или в словарь📎 vllm/v1/core/block_pool.py:56-59. Почему используется объединённый тип? Комментарий даёт ответ: большинство хешей соответствуют только одному блоку, использование словаря создаёт ненужные накладные расходы на сборку мусора; повышение до словаря происходит только тогда, когда один и тот же хеш совместно используется несколькими блоками📎 vllm/v1/core/block_pool.py:56-59. Это типичный компромисс между сложностью типов и накладными расходами во время выполнения.

KVCacheBlocks— это объект интерфейса между планировщиком и менеджером KV cache, который скрывает внутренние структуры данных. Егоblocksполе — этоtuple[Sequence[KVCacheBlock], ...], внешняя размерность — KV cache group, внутренняя — последовательность блоков📎 vllm/v1/core/kv_cache_manager.py:41-54. Комментарий явно объясняет, почему блоки не используются в качестве внешней размерности: это предполагало бы, что все группы имеют одинаковое количество блоков, тогда как в будущем разным группам может быть настроен разный размер блока📎 vllm/v1/core/kv_cache_manager.py:43-48。

mermaid
flowchart LR
    subgraph Sched["Состояние Scheduler"]
        W["waiting<br/>RequestQueue"]
        SW["skipped_waiting<br/>RequestQueue"]
        R["running<br/>list[Request]"]
        REQ["requests<br/>dict[str, Request]"]
    end
    subgraph KV["KVCacheManager"]
        BP["BlockPool.blocks<br/>list[KVCacheBlock]"]
        FQ["free_block_queue<br/>FreeKVCacheBlockQueue"]
        MAP["cached_block_hash_to_block<br/>BlockHashToBlockMap"]
    end
    W -->|"admit + allocate_slots"| R
    R -->|"preempt"| W
    R -->|"free / pop_blocks_for_free"| FQ
    FQ -->|"get_new_blocks"| BP
    BP -->|"cache_full_blocks"| MAP
    MAP -->|"get_cached_block"| W

Этот рисунок фиксирует поток данных между планировщиком и пулом видеопамяти: запросы из очереди waiting черезallocate_slotsпереходят в running, вытесненные запросы из running возвращаются в waiting, освобождённые блоки возвращаются в очередь свободных, а хеш-таблица префиксного кеша является точкой входа для попадания запросов из waiting в кеш.

4.2 Основной поток schedule(): приоритет running, дополнение waiting, вытеснение как крайняя мера

schedule()— это основной метод всего планировщика, он возвращаетSchedulerOutput, описывающий, что нужно выполнить на этом шаге. Комментарий в начале метода определяет философию дизайна: в планировщике нет разделения на «фазу декодирования» и «фазу предзаполнения», у каждого запроса есть толькоnum_computed_tokensиnum_tokens_with_spec, и задача планировщика — позволить первому догнать второе📎 vllm/v1/core/sched/scheduler.py:559-568. Этот унифицированный взгляд является основой для сосуществования chunked prefill, prefix caching и спекулятивного декодирования.

4.2.1 Инициализация бюджета и вычисление порогов

Перед входом в основной цикл планировщик сначала устанавливает два бюджета:token_budgetинициализируется какmax_num_scheduled_tokens,input_budgetинициализируется какmax_num_batched_tokens 📎 vllm/v1/core/sched/scheduler.py:577-580. Обычно они равны, но когда модель может добавлять токены в пакете (например, при спекулятивном декодировании),max_num_scheduled_tokensбудет меньшеmax_num_batched_tokens, и разница — это пространство, оставленное для draft token.

long_prefill_token_thresholdОбработка📎 vllm/v1/core/sched/scheduler.py:606-616заслуживает отдельного рассмотрения. Её назначение — предотвратить голодание других запросов из-за одного длинного prefill, но если в данный момент есть только один запрос, то никто не будет голодать, поэтому порог устанавливается в нольadaptive_long_prefill_threshold. Когдаinput_budget // num_eligible_reqsвключён, порог также поднимается до📎 vllm/v1/core/sched/scheduler.py:617-622。

, чтобы гарантировать, что бюджет отдельного запроса не будет снижен ниже справедливой доли

4.2.2 Цикл планирования запросов runningself.runningОсновной цикл начинает обход с головыreq_index,📎 vllm/v1/core/sched/scheduler.py:624-627— это курсор

  • . Для каждого запроса сначала выполняется ряд проверок пропуска:max_tokensПри асинхронном планировании, если плейсхолдер вывода запроса показывает, что он уже достиг📎 vllm/v1/core/sched/scheduler.py:631-645。
  • , пропустить, чтобы избежать лишнего шагаnext_decode_eligible_stepВ сценарии V2 + PP + асинхронный, если текущий шаг ещё не достиг📎 vllm/v1/core/sched/scheduler.py:647-651。
  • , пропустить, чтобы соответствовать ритму широковещательной рассылки токенов выборки на стороне worker📎 vllm/v1/core/sched/scheduler.py:653-657。

При включённой балансировке DP prefill, prefill chunk на шагах, не выровненных по ритму, откладывается

code
num_new_tokens = request.num_tokens_with_spec
               + request.num_output_placeholders
               - request.num_computed_tokens

Копироватьlong_prefill_token_threshold、token_budget、input_budget - draft_slotsЗатем последовательно ограничиваетсяmax_model_lenи📎 vllm/v1/core/sched/scheduler.py:670-688. Если запрос содержит вход энкодера, он также корректируется через_try_schedule_encoder_inputsДалее — самый критичный шаг: выделение KV block.📎 vllm/v1/core/sched/scheduler.py:700-712。

обёрнут в циклallocate_slots. Если возвращаетсяwhile True, это означает нехватку видеопамяти, и планировщик начинает вытеснение: по стратегии выбирается жертва (стратегия PRIORITY выбирает с наименьшим приоритетом, стратегия FCFS выбирает из конца списка running)📎 vllm/v1/core/sched/scheduler.py:742-747, вызываетсяNoneдля возврата её в очередь waiting, затем выделение повторяется📎 vllm/v1/core/sched/scheduler.py:761-767. Если жертвой оказывается сам текущий запрос, это означает, что объектов для вытеснения больше нет, цикл прерывается, и текущий запрос также не может быть запланирован_preempt_requestВ логике вытеснения есть тонкая деталь: при стратегии PRIORITY, если вытесняемый запрос уже находится в📎 vllm/v1/core/sched/scheduler.py:801-806(то есть на этом шаге для него уже были выделены ресурсы), необходимо вернуть его бюджет токенов, блоки, спекулятивные токены и бюджет энкодера📎 vllm/v1/core/sched/scheduler.py:807-813。

. Это обеспечивает согласованность учёта бюджета.scheduled_running_reqsПосле успешного выделения запрос добавляется в📎 vllm/v1/core/sched/scheduler.py:779-797, записываются блоки и количество токенов, вычитается бюджет

. Токены, связанные со спекулятивным декодированием, здесь обрезаются и записываютсяscheduled_running_reqs4.2.3 Допуск запросов waiting📎 vllm/v1/core/sched/scheduler.py:815-823После завершения цикла running, если на этом шаге не произошло вытеснения и планировщик не приостановлен, начинается обработка очереди waiting📎 vllm/v1/core/sched/scheduler.py:825-841。

. Перед допуском проверяются два верхних предела:

и📎 vllm/v1/core/sched/scheduler.py:868-872Планирование запросов waiting отличается от running наличием шага поиска в префиксном кеше. Когдаmax_num_active_reqs, вызываетсяinput_budget 📎 vllm/v1/core/sched/scheduler.py:873-879。

для поиска попадания в локальный кешrequest.num_computed_tokens == 0. Если настроен KV connector, также выполняется запрос попадания в удалённый кеш_get_local_prefix_cache_hitЗдесь есть тонкая логика обработки конфликта между локальным и удалённым попаданием. Локальное попадание может быть не выровнено по блокам (📎 vllm/v1/core/sched/scheduler.py:932-939), и если удалённое попадание строго превышает локальное полное попадание, хвост локального подблока отбрасывается, чтобы удалённая загрузка перекрыла его, избегая копирования при записи📎 vllm/v1/core/sched/scheduler.py:942-954。

. В противном случае локальный хвост сохраняется, внешняя загрузка не выполняетсяpartial_tailПосле успешного допуска запрос извлекается из очереди waiting, состояние устанавливается в RUNNING, он добавляется в список running📎 vllm/v1/core/sched/scheduler.py:977-988. Если после этого шага он всё ещё находится в prefill (📎 vllm/v1/core/sched/scheduler.py:989-995。

), он добавляется в множество📎 vllm/v1/core/sched/scheduler.py:1263-1319Копироватьnum_computed_tokens + num_new_tokens < request.num_tokensЭтот граф потока управления охватывает_inflight_prefillsдва основных цикла и ветвь вытеснения. Обратите внимание на путь повторной попытки вытеснения после неудачи📎 vllm/v1/core/sched/scheduler.py:1326-1328。

mermaid
flowchart TD
    start["schedule() начало"] --> init["инициализация token_budget / input_budget"]
    init --> run_loop{"цикл running<br/>req_index < len(running)<br/>и token_budget > 0?"}
    run_loop -->|да| skip_check{"условие пропуска?<br/>max_tokens достигнут /<br/>decode_eligible / defer_prefills"}
    skip_check -->|пропустить| run_inc["req_index += 1"]
    run_inc --> run_loop
    skip_check -->|не пропускать| calc["вычислить num_new_tokens<br/>с учётом множества ограничений"]
    calc --> alloc{"allocate_slots<br/>вернул None?"}
    alloc -->|успех| admit_run["добавить в scheduled_running_reqs<br/>уменьшить бюджет"]
    admit_run --> run_inc
    alloc -->|неудача| can_preempt{"есть вытесняемые запросы?<br/>_request_blocks_can_be_freed"}
    can_preempt -->|нет| break_run["выйти из цикла running"]
    can_preempt -->|да| preempt["_preempt_request<br/>вернуть в waiting"]
    preempt --> alloc
    break_run --> wait_loop{"нет вытеснения и не приостановлено?<br/>waiting непуст и token_budget > 0?"}
    run_loop -->|нет| wait_loop
    wait_loop -->|да| blocked{"состояние blocked?<br/>_is_blocked_waiting_status"}
    blocked -->|да и невозможно повысить| skip_wait["переместить в skipped_waiting"]
    skip_wait --> wait_loop
    blocked -->|нет| prefix{"num_computed_tokens == 0?<br/>поиск префиксного кэша"}
    prefix -->|попадание| alloc_wait["allocate_slots<br/>с new_computed_blocks"]
    prefix -->|промах| alloc_wait
    alloc_wait --> wait_ok{"выделение успешно?"}
    wait_ok -->|да| admit_wait["добавить в running<br/>установить статус RUNNING"]
    admit_wait --> wait_loop
    wait_ok -->|нет| break_wait["выйти из цикла waiting"]
    wait_loop -->|нет| build["построить SchedulerOutput"]
    break_wait --> build

Эта блок-схема потока управления охватываетschedule()два основных цикла и ветвь вытеснения. Обратите внимание на путь повторной попытки вытеснения после неудачиallocate_slotsв цикле running, а также на перемещение запросов в состоянии blocked в цикле waitingskipped_waitingобход.

4.3 Ядро учёта видеопамяти: allocate_slots и вытеснение

allocate_slots— это шлюз между планировщиком и видеопамятью. Сам список его параметров представляет собой учётную книгу видеопамяти:num_new_tokens— количество токенов, которые нужно вычислить заново,num_new_computed_tokens— количество токенов, попавших в кэш префиксов,num_external_computed_tokens— количество внешних попаданий, предоставленных connector,num_lookahead_tokens— зарезервированные слоты для спекулятивного декодирования📎 vllm/v1/core/kv_cache_manager.py:371-383。

Комментарий в начале метода с помощью ASCII-схемы точно описывает раскладку блоков📎 vllm/v1/core/kv_cache_manager.py:417-438:

code
| < comp > | < new_comp > | < ext_comp >  | < new >  | < lookahead > |
                                          |   < to be computed >     |
                        |            < to be allocated >           |

comp— уже вычисленные токены,new_comp— попадания в кэш префиксов,ext_comp— внешние попадания,new— вычисляемые на этом шаге,lookahead— спекулятивный резерв. Выделение делится на три этапа: сначала освобождаются ненужные блоки и проверяется, достаточно ли свободных блоков, затем обрабатываются токены префикса, и наконец выделяются блоки для новых вычисляемых токенов📎 vllm/v1/core/kv_cache_manager.py:458-461。

4.3.1 Порог и контроль допуска

allocate_slotsсодержит два шлюза допуска. Первый —full_sequence_must_fit: когда он включён, сначала проверяется, поместится ли вся последовательность запроса (а не только первый chunk), и если не помещается, сразу возвращаетсяNone 📎 vllm/v1/core/kv_cache_manager.py:515-531. Это предотвращает избыточный допуск при chunked prefill, который приводит к колебаниям KV cache.

Второй — порог.watermark_blocksдействует только тогда, когда состояние запроса — WAITING или PREEMPTED и уже есть запланированные запросы📎 vllm/v1/core/kv_cache_manager.py:506-513. Он требует после выделения сохранять как минимум определённую долю свободных блоков, чтобы избежать частого вытеснения и преемпции.reserved_blocksже используется в сценариях асинхронной загрузки KV, чтобы гарантировать, что зарезервированные блоки для prefill в полёте не будут съедены новыми запросами📎 vllm/v1/core/kv_cache_manager.py:564-570。

4.3.2 Цена преемпции и восстановление

〔Проектное предположение и архитектурный компромисс〕

_preempt_requestделает вещь, которая выглядит грубой, но необходимой: сбрасываетnum_computed_tokensзапроса в 0📎 vllm/v1/core/sched/scheduler.py:1560-1561. Это означает, что вытесненный запрос при следующем планировании должен заново выполнить prefill с начала. Почему так спроектировано? Потому что KV block в vLLM является приватным для запроса, при преемпции необходимо освободить все блоки, а после освобождения нельзя гарантировать, что при повторном выделении будут получены те же блоки, поэтому приходится вычислять с начала. Наличие кэша префиксов частично компенсирует эту цену: если префикс вытесненного запроса уже закэширован, при повторном планировании произойдёт попадание в кэш и не придётся реально пересчитывать.

Преемпция также решает проблему «устаревшего вывода» при асинхронном планировании.num_stale_output_tokensустанавливается вnum_in_flight_tokens, помечая все выводы в полёте как устаревшие📎 vllm/v1/core/sched/scheduler.py:1571-1574. Эти токены всё равно будут доставлены (их отбрасывание нарушило бы коэффициент принятия спекулятивного декодирования), но не будут изменять сброшенные счётчики.drop_stale_outputФлаг определяет, отбрасывать или доставлять📎 vllm/v1/core/sched/scheduler.py:1539-1547。

4.3.3 Отложенное освобождение: риск read-after-write у асинхронных connector'ов

Когда используется KV connector и в полёте находится несколько батчей,defer_block_freeустанавливается вTrue 📎 vllm/v1/core/sched/scheduler.py:175-181. Причина: один шаг может всё ещё записывать KV-блоки уже освобождённого запроса, а consumer connector может через загрузку, не упорядоченную относительно этой записи, повторно выделить и заполнить эти блоки.

Отложенное освобождение реализуется черезdeferred_freesдвустороннюю очередь, каждый элемент которой —(fence_seq, blocks) 📎 vllm/v1/core/sched/scheduler.py:388-390。_free_request_blocksпроверяет_request_blocks_can_be_freed, и если последний шаг планирования запроса ещё не обработан, помещает блоки в очередь отложенного освобождения📎 vllm/v1/core/sched/scheduler.py:2679-2688。_drain_deferred_freesпродвигается вupdate_from_outputи вызывается послеprocessed_step_seq, освобождая блоки, для которых fence уже выполнен📎 vllm/v1/core/sched/scheduler.py:2701-2706。

4.4 Определение попадания в кэш префиксов и жизненный цикл блоков

Точка входа для поиска в кэше префиксов —KVCacheManager.get_computed_blocks. Сначала проверяется, включён ли кэш и не помечен ли запрос как пропускающий чтение📎 vllm/v1/core/kv_cache_manager.py:286-287. Затем вызываетсяcoordinator.find_longest_cache_hit, передаваяrequest.block_hashesиmax_cache_hit_length = request.num_tokens - 1 📎 vllm/v1/core/kv_cache_manager.py:295-300。

Почемуnum_tokens - 1? Комментарий объясняет: когда все токены попадают в кэш, необходимо пересчитать последний токен, чтобы получить logits📎 vllm/v1/core/kv_cache_manager.py:289-294. Это легко упускаемая граница: даже при полном попадании префикса нужно вычислить хотя бы один токен.

Жизненный цикл блока управляетсяBlockPool.get_new_blocksизвлекает блок из головы очереди свободных, и если кэш включён, сначала вызывает_maybe_evict_cached_blockдля очистки метаданных хэша, затем увеличивает счётчик ссылок📎 vllm/v1/core/block_pool.py:683-702。free_blocksже в зависимости от наличия хэша у блока решает, вернуть его в голову или хвост очереди: блоки без хэша переиспользуются LIFO (лучшая локальность GPU), блоки с хэшем — FIFO (поведение вытеснения LRU)📎 vllm/v1/core/block_pool.py:785-805。

cache_full_blocks— это момент, когда блок записывается в хэш-таблицу кэша префиксов. Он обходит новые заполненные блоки, пропускает null-блоки и замаскированные блоки, вычисляет хэш для каждого блока и вставляет вcached_block_hash_to_block 📎 vllm/v1/core/block_pool.py:272-300. Если у блока уже есть хэш (сценарий повышения частичного блока до полного), сначала удаляется старый хэш, затем вставляется новый📎 vllm/v1/core/block_pool.py:285-293。

touchМетод обрабатывает счётчик ссылок при попадании в кэш: если блок находится в очереди свободных (ref_cnt == 0), сначала удаляет его из очереди, затем увеличивает счётчик ссылок📎 vllm/v1/core/block_pool.py:754-770. Это гарантирует, что блок, в который произошло попадание, не будет вытеснен.

Размышления о дизайне

〔Проектное предположение и архитектурный компромисс〕

Почему преемпция выбирает «пересчёт с начала», а не «частичное сохранение»?Частичное сохранение требует записи физического расположения блоков каждого запроса на момент преемпции и попытки восстановить отображение при повторном планировании. Но пул блоков является глобально общим, и другие запросы могли уже занять эти блоки. Сложность и накладные расходы памяти на поддержание такого отображения превышают цену пересчёта, особенно когда кэш префиксов может покрыть большую часть префикса.

〔Проектное предположение и архитектурный компромисс〕

Почему порог по умолчанию равен 0?Порог — это страховка от частой преемпции, но она достигается ценой снижения утилизации видеопамяти. Отключение по умолчанию означает, что vLLM в первую очередь стремится к пропускной способности, а не к стабильности, и пользователю нужно включать его самостоятельно в зависимости от характеристик нагрузки.

〔Проектное предположение и архитектурный компромисс〕

skipped_waitingСмысл существования очереди.Без этой очереди заблокированные запросы постоянно занимали бы голову очереди waiting, из-за чего последующие запросы не могли бы быть запланированы (при стратегии FCFS). Выделив их отдельно, планировщик может пропускать заблокированные запросы и продолжать обработку последующих, сохраняя при этом состояние заблокированных запросов для последующего повышения.

Резюме главы

Ядро планировщика — этоschedule()два цикла в методе: цикл running в первую очередь обеспечивает продвижение уже запущенных запросов, цикл waiting при наличии бюджета допускает новые запросы. При нехватке видеопамяти пространство освобождается путём вытеснения запроса с наименьшим приоритетом из списка running; у вытесненного запросаnum_computed_tokensсбрасывается в 0, но префиксный кэш компенсирует часть затрат на пересчёт.allocate_slotsявляется шлюзом видеопамяти, посредствомfull_sequence_must_fit, уровня воды иreserved_blocksтрёхуровневого контроля допуска предотвращает избыточное выделение. Префиксный кэш обеспечивает совместное использование между запросами через индекс по хэшу блоков, а определение попадания ограниченоnum_tokens - 1сверху, чтобы гарантировать вычисление хотя бы одного token для получения logits.

Вопросы для размышления и самопроверки по главе

Q1: Вschedule()цикле running, еслиallocate_slotsвозвращаетNoneи_request_blocks_can_be_freedдля жертвы возвращаетFalse, кодbreakвыходит из цикла. Если убрать эту проверку и напрямую вызвать_preempt_request, в каком сценарии это приведёт к несогласованности состояния?

Справочный разбор:_request_blocks_can_be_freedПроверкаrequest.last_sched_seq <= self.processed_step_seq 📎 vllm/v1/core/sched/scheduler.py:2672-2677. Когдаdefer_block_freeвключён, если последний шаг планирования жертвы ещё не обработан, её блоки могут всё ещё записываться GPU-шагами в полёте. Прямое вытеснение вызовет_free_request_blocks, а последний при_request_blocks_can_be_freedравномFalseпоместит блоки вdeferred_freesвместо немедленного освобождения📎 vllm/v1/core/sched/scheduler.py:2679-2688. Но семантика вытеснения — «немедленно освободить блоки для текущего запроса», отложенное освобождение не может удовлетворить эту потребность,allocate_slotsснова потерпит неудачу, образуя бесконечный цикл. Что ещё серьёзнее, если блоки жертвы после отложенного освобождения будут выделены текущему запросу, а GPU всё ещё записывает в блоки жертвы, возникнет гонка данных.

Q2: get_computed_blocksВmax_cache_hit_length = request.num_tokens - 1. Если изменить наrequest.num_tokens, в каких случаях это приведёт к ошибке вывода?

Справочный разбор: когда все token запроса попадают в кэш,num_computed_tokensбудет равноnum_tokens. В этот момент планировщик считает, что не нужно вычислять ни одного нового token, но для сэмплирования logits требуется скрытое состояние последней позиции, а скрытое состояние получается из прямого прохода. Если ни один token не вычислен, logits для сэмплирования отсутствуют, запрос зависнет или выдаст ошибочный результат. Комментарий явно указывает на это📎 vllm/v1/core/kv_cache_manager.py:289-294. Кроме того,allocate_slotsтребует, чтобыnum_computed_tokensбыло выровнено по размеру блока; пересчёт последнего token может вызвать пересчёт всего блока — это известное ограничение текущей реализации.

Q3: _preempt_requestсбрасываетnum_computed_tokensв 0, но сохраняетrequest.num_tokens(prompt + сгенерированные token). Если при повторном планировании вытесненного запроса префиксный кэш не попал, сколько token нужно пересчитать? А если попал, сколько удастся сэкономить?

Справочный разбор:num_computed_tokens = 0означает, что при повторном планировании вычисление начинается с первого token📎 vllm/v1/core/sched/scheduler.py:1561。request.num_tokensостаётся неизменным, включая исходный prompt и уже сгенерированные выходные token. Если префиксный кэш не попал, нужно пересчитать prefill для всехnum_tokenstoken. Если попал,get_computed_blocksвернёт попавшие блоки,num_computed_tokensначиная с позиции попадания📎 vllm/v1/core/kv_cache_manager.py:296-300. Заметьте, что выходные token вытесненного запроса также находятся вnum_tokens, их префиксные хэши были закэшированы при генерации (если это включено), поэтому при повторном планировании префиксы этих выходных token тоже могут попасть. Ноmax_cache_hit_length = num_tokens - 1означает, что последний token всегда пересчитывается.

Выход планировщикаSchedulerOutputявно определяет содержание выполнения этого шага: ID блоков новых запросов, количество token кэшированных запросов, спекулятивные token, входы энкодера и т. д. В следующей главе будет прослежено, как этот выход потребляется ModelRunner, отSchedulerOutputвплоть до прямого прохода на GPU.

CHAPTER 05

Глава 5: Движок непрерывного батчинга: динамическое планирование на уровне итераций

Проект: vllm-project/vllm · Прогресс книги: Глава 5 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы видели, как Scheduler на каждом шаге цикла планирования решает, какие запросы попадут в очередь running, какие будут вытеснены, какие будут ждать из-за нехватки видеопамяти, и в итоге формирует SchedulerOutput — он описывает, что нужно вычислить на этом шаге: какие запросы, сколько token для каждого, какие KV block использовать. Но этот список — лишь логическое намерение, а GPU нужны физические тензоры. В этой главе прослеживается, как SchedulerOutput распределяется Executor'ом по Worker'ам, затем GPUModelRunner переводит его в исполняемые на GPU входы, такие как input_ids, positions, slot_mapping и block table, и в конечном счёте через forward_context описание батча, разделяемое между слоями, внедряется в каждый слой модели, завершая переход от решения планировщика к прямому проходу.

5.1 Executor: доставка результата планирования на каждую карту

Интуитивная модель

Executor— это «глашатай» между EngineCore и GPU Worker. Без него EngineCore пришлось бы самому знать, сколько карт в кластере, в каком процессе находится каждая карта, какSchedulerOutputСериализация прошлого — логика планирования переплетается с распределённой топологией.ExecutorВыделим эту ответственность: EngineCore только вызываетexecute_model(scheduler_output), а всё остальное — «кому отправить, как отправить, сколько результатов получить» — решает Executor.

Иерархия классов и поля

Executor— это абстрактный базовый класс, поля уровня класса которого напрямую кодируют возможности бэкенда📎 vllm/v1/executor/abstract.py:48-49:

python
uses_ray: bool = False  # whether the executor uses Ray for orchestration.
supports_pp: bool = False  # whether the executor supports PP

Эти два флага не декоративны — код верхнего уровня читает их, чтобы решить, включать ли определённые пути оптимизации.__init__Вsleeping_tags、kv_output_aggregator、ec_output_aggregatorинициализируются📎 vllm/v1/executor/abstract.py:119-120три поля состояния

, соответственно для отслеживания метки режима сна, агрегации вывода KV-коннектора и агрегации вывода коннектора энкодера.get_classВыбор бэкенда:

get_classмаршрутизация ветвленийdistributed_executor_backend— это статическая фабрика, которая по📎 vllm/v1/executor/abstract.py:51-96конфигурации возвращает конкретный класс Executor

  • . Её структура ветвлений заслуживает внимательного рассмотрения:typeЕсли сама конфигурация являетсяExecutor, проверить, является ли она подклассом📎 vllm/v1/executor/abstract.py:52-61;
  • "ray", и затем напрямую использоватьVLLM_USE_RAY_V2_EXECUTOR_BACKENDВ веткеRayExecutorV2есть ещё вторичное ветвление:RayDistributedExecutor 📎 vllm/v1/executor/abstract.py:64-72;
  • "mp"ЕслиMultiprocExecutor,"uni"истинно, использоватьUniProcExecutor 📎 vllm/v1/executor/abstract.py:73-80;
  • , иначе использоватьresolve_obj_by_qualnameотображается на📎 vllm/v1/executor/abstract.py:85-90。
mermaid
flowchart TD
    start["Executor.get_class(vllm_config)"] --> check_type{"backend имеет тип type?"}
    check_type -->|да| verify_sub{"issubclass(Executor)?"}
    verify_sub -->|нет| err_type["raise TypeError"]
    verify_sub -->|да| use_direct["executor_class = backend"]
    check_type -->|нет| check_ray{"backend == 'ray'?"}
    check_ray -->|да| ray_v2{"VLLM_USE_RAY_V2?"}
    ray_v2 -->|да| use_rayv2["RayExecutorV2"]
    ray_v2 -->|нет| use_ray["RayDistributedExecutor"]
    check_ray -->|нет| check_mp{"backend == 'mp'?"}
    check_mp -->|да| use_mp["MultiprocExecutor"]
    check_mp -->|нет| check_uni{"backend == 'uni'?"}
    check_uni -->|да| use_uni["UniProcExecutor"]
    check_uni -->|нет| check_ext{"backend == 'external_launcher'?"}
    check_ext -->|да| use_ext["ExecutorWithExternalLauncher"]
    check_ext -->|нет| check_str{"backend имеет тип str?"}
    check_str -->|да| resolve["resolve_obj_by_qualname"]
    check_str -->|нет| err_unknown["raise ValueError"]

Пользовательский бэкенд в виде строки динамически разрешается черезexecute_modelКопировать

Пошагово: поток вызова одногоSchedulerOutputПодставим сценарий: EngineCore завершает шаг планирования, получаетexecutor.execute_model(scheduler_output)。

Executor.execute_model, вызывает📎 vllm/v1/executor/abstract.py:237-238:

python
def execute_model(
    self, scheduler_output: SchedulerOutput, non_block: bool = False
) -> ModelRunnerOutput | None | Future[ModelRunnerOutput | None]:
    output = self.collective_rpc(
        "execute_model", args=(scheduler_output,), non_block=non_block
    )
    return output[0]
Копировать

〔Проектные выводы и архитектурные компромиссы〕collective_rpcКлюч вoutput[0]— он транслирует имя метода и аргументы всем Worker, собирает список возвращаемых значений каждого Worker, а затемoutput[0]берёт только первый. Почему только первый? Потому что при тензорном параллелизме все Worker выполняют один и тот же логический forward, и выходы семантически эквивалентны; результат сэмплирования определяется последней стадией PP или rank 0, и взятиеcollective_rpcпозволяет избежать повторной агрегации.📎 vllm/v1/executor/abstract.py:220-221ДокументацияSchedulerOutputявно рекомендует «передавать только управляющие сообщения, а коммуникацию плоскости данных устанавливать отдельно»

sample_tokens, и именно в этом заключается позиционирование📎 vllm/v1/executor/abstract.py:257-258— это управляющее сообщение, а реальные данные токенов передаются внутри Worker через GPU-тензоры.Noneидёт по той же схемеexecute_model, но тип возвращаемого значения не содержитNone— сэмплирование обязательно даёт результат. Разделение этих двух методов соответствует дизайну vLLM v1 «разделение выполнения и сэмплирования»:ExecuteModelStateможет вернуть

(означая, что forward отправлен, но сэмплирование отложено), и в этом случае состояние временно сохраняется в

collective_rpc.@abstractmethod 📎 vllm/v1/executor/abstract.py:186-192Проектные соображенияMultiprocExecutorобъявлен какRayDistributedExecutor, что означает, что разные бэкенды должны сами реализовать «как отправить RPC к Worker».UniProcExecutorиспользует очереди разделяемой памяти,

использует вызовы Ray actor,supported_tasksнапрямую локальные вызовы. Такая абстракция позволяет коду верхнего уровня полностью не заботиться о деталях распределённости.@cached_property 📎 vllm/v1/executor/abstract.py:306-309Одна легко упускаемая деталь:get_supported_tasksпомечен как

, и комментарий прямо говорит «избегать ненужных RPC-вызовов». Поскольку

требует межпроцессного взаимодействия, а список задач не меняется в течение жизненного цикла модели, кэширование является корректной и необходимой оптимизацией.

GPUModelRunner5.2 GPUModelRunner: от SchedulerOutput к входным тензорамSchedulerOutputИнтуитивная модель

— это «переводчик»: он переводит логическое описание в

GPUModelRunner(ID запроса, число токенов, ID блоков) в физические тензоры, которые GPU может напрямую потреблять. Без него уровень модели должен был бы сам разбираться с вопросами вроде «в каком KV-слоте находится 7-й токен 3-го запроса» — это катастрофическая утечка ответственности.📎 vllm/v1/worker/gpu_model_runner.py:479-480:LoRAModelRunnerMixin、KVConnectorModelRunnerMixin、ECConnectorModelRunnerMixinКлючевое состояние и раскладка памяти

__init__наследуется от трёх Mixin📎 vllm/v1/worker/gpu_model_runner.py:488-498, которые соответственно предоставляют возможности адаптации LoRA, KV-коннектора и коннектора энкодера.

  • check_ep_faultВ📎 vllm/v1/worker/gpu_model_runner.py:507-509;
  • is_pooling_modelкэшируются все объекты конфигурацииrunner_type == "pooling", и инициализируются несколько ключевых флагов:📎 vllm/v1/worker/gpu_model_runner.py:515;
  • enable_prompt_embeds: только когда data parallel > 1 и модель является MoE, запрашивается у менеджера EP all2all, поддерживает ли он отказоустойчивость📎 vllm/v1/worker/gpu_model_runner.py:516。

ExecuteModelState: определяетсяNamedTuple: включён ли ввод prompt embeddingexecute_model()— этоsample_tokens(), несущий временное состояние между📎 vllm/v1/worker/gpu_model_runner.py:463-476иlogits、hidden_states、sample_hidden_states. Дизайн его полей раскрывает суть разделения выполнения и сэмплирования:spec_decode_metadata、slot_mappings— это продукт forward,📎 vllm/v1/worker/gpu_model_runner.py:464-464。

Step-by-Step:_update_states— метаданные, всё ещё необходимые на этапе сэмплирования. Комментарий явно говорит, что это «временное кэшированное состояние, передаваемое после того, как execute_model() возвращает None»

Как синхронизируется кэшированное состояние

Подставим сценарий: планировщик решает, что на этом шаге обрабатываются запрос A (новый запрос), B (продолжение decode с предыдущего шага), C (восстановление после вытеснения), и при этом запрос D уже завершён.Первый шаг: очистка завершённых запросов.finished_req_idsОбходитсяself.requests, из словаряinput_batchизвлекается состояние, из📎 vllm/v1/worker/gpu_model_runner.py:1202-1217удаляетсяfinished_req_ids. Обратите внимание на граничный случай, указанный в комментарии:scheduled_req_idsи📎 vllm/v1/worker/gpu_model_runner.py:1211-1215。

могут пересекаться — когда запрос был прерван, а затем повторно отправлен с тем же ID, они рассматриваются как два разных запросаВторой шаг: обнуление вновь выделенных KV-блоков.new_block_ids_to_zeroЕсли_zero_block_idsне пусто, вызывается📎 vllm/v1/worker/gpu_model_runner.py:1219-1222для обнуления видеопамяти, чтобы предотвратить загрязнение вычислений attention или SSM устаревшими NaN

. Это обязательное условие безопасности повторного использования блоков PagedAttention.Третий шаг: вычисление множества не запланированных запросов.📎 vllm/v1/worker/gpu_model_runner.py:1238-1247:

python
scheduled_req_ids = scheduler_output.num_scheduled_tokens.keys()
cached_req_ids = self.input_batch.req_id_to_index.keys()
resumed_req_ids = scheduler_output.scheduled_cached_reqs.resumed_req_ids
unscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)

Копироватьscheduled_req_ids - resumed_req_idsКомментарий объясняет, почему используетсяscheduled_req_ids, а не напрямуюcached_req_ids: обычноresumed_req_idsиreset_prefix_cacheне пересекаются, но в сценарии принудительного вытеснения, вызванного📎 vllm/v1/worker/gpu_model_runner.py:1241-1246。

, восстановленные запросы должны сначала быть удалены из постоянного батча, а затем добавлены зановоЧетвёртый шаг: обработка новых запросов.scheduled_new_reqsДля каждогоCachedRequestState 📎 vllm/v1/worker/gpu_model_runner.py:1295-1308конструируетсяRANDOM_SEED. Если тип сэмплирования —torch.Generator 📎 vllm/v1/worker/gpu_model_runner.py:1277-1284, создаётся_init_mrope_positionsс seed. Если модель использует M-RoPE, вызывается📎 vllm/v1/worker/gpu_model_runner.py:1319-1321。

для предварительного вычисления позицийПятый шаг: обновление выполняющихся запросов.scheduled_cached_reqsДля каждогоnum_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1402обновляется📎 vllm/v1/worker/gpu_model_runner.py:1437-1448, обрабатывается добавление или замена ID блоковreq_index is None. Если запрос отсутствует в постоянном батче (reqs_to_add 📎 vllm/v1/worker/gpu_model_runner.py:1450-1465。

), он добавляется в condense()Заполнение пустот, оставленных запросами на удаление📎 vllm/v1/worker/gpu_model_runner.py:1511-1512,_may_reorder_batchПереупорядочивание бэкенда внимания по требованию📎 vllm/v1/worker/gpu_model_runner.py:1513-1514,refresh_metadata()Обновление метаданных батча📎 vllm/v1/worker/gpu_model_runner.py:1515-1516。

Подготовка входных тензоров:_prepare_input_idsасинхронный быстрый путь

_prepare_input_idsОбработка тонкой проблемы: при асинхронном планировании семплированный токен предыдущего шага всё ещё находится на GPU, а текущий шагinput_idsтребует их заполнения📎 vllm/v1/worker/gpu_model_runner.py:1767-1772。

Обычный путь (prev_sampled_token_ids is None) напрямую копирует CPU-тензоры на GPU📎 vllm/v1/worker/gpu_model_runner.py:1788-1794. Асинхронный путь перебирает запросы, вычисляя индекс последнего токена каждого запроса в плоскомinput_ids📎 vllm/v1/worker/gpu_model_runner.py:1809-1836. В комментариях приведён конкретный пример:cu_num_tokens = [2, 5, 8]、draft_tokens = [1, 2, 2]приsample_flattened_indices = [0, 2, 5],spec_flattened_indices = [1, 3, 4, 6, 7] 📎 vllm/v1/worker/gpu_model_runner.py:1820-1822。

имеется ключевая оптимизация📎 vllm/v1/worker/gpu_model_runner.py:1859-1868:

python
if common_indices_match and max_flattened_index == (num_common_tokens - 1):
    self.input_ids.gpu[:num_common_tokens].copy_(
        self.input_batch.prev_sampled_token_ids[:num_common_tokens, 0],
        non_blocking=True,
    )
    return

Когда батч не изменился и нет переупорядочивания, индексы представляют собой0..N-1ту же перестановку, можно напрямую использовать одну операцию среза, избегая накладных расходов scatter. Это прямое проявление оптимизации постоянного батча.

slot_mappingи block table

_get_slot_mappingsвозвращает два формата📎 vllm/v1/worker/gpu_model_runner.py:4078-4078: индексированный по KV cache groupdict[int, torch.Tensor]для использования в метаданных внимания, индексированный по имени слояdict[str, torch.Tensor]дляForwardContextиспользования. Для encoder-only KV cache group slot mapping — это полностью нулевой тензор📎 vllm/v1/worker/gpu_model_runner.py:4096-4115; иначе срез изblock_table.slot_mapping.gpu📎 vllm/v1/worker/gpu_model_runner.py:4107-4109. Неиспользуемый хвостовой padding-1, в комментариях указано, что этоreshape_and_cacheтребуется в режиме полного CUDA graph📎 vllm/v1/worker/gpu_model_runner.py:4118-4122。

_get_block_tableполучение device-тензора для каждой KV cache group📎 vllm/v1/worker/gpu_model_runner.py:2319-2335, и заполнение строк CUDAGraph padding с помощьюNULL_BLOCK_ID— блок 0 зарезервирован для padding📎 vllm/v1/worker/gpu_model_runner.py:2332-2334。

5.3 forward_context: описание батча, разделяемое между слоями

Интуитивная модель

forward_context— это «единая доска объявлений», висящая перед классом: каждый слой модели, подняв голову, видит рассадку на текущем экзамене (attention metadata) и правила (slot mapping), не нужно спрашивать у каждого отдельно. Без неё каждый слой внимания должен был бы получать эту информацию из параметров — а сигнатураforwardслоя модели фиксирована, невозможно передавать параметры отдельно для каждого слоя.

Структура данных

ForwardContext— это@dataclass 📎 vllm/forward_context.py:141-202, ключевые поля:

  • no_compile_layers: копируется изstatic_forward_context, помечает слои, не участвующие в компиляции📎 vllm/forward_context.py:132-137;
  • attn_metadata: отображение имени слоя в метаданные внимания, в режиме DBO это список длиной 2 (по одному на microbatch)📎 vllm/forward_context.py:144-152;
  • slot_mapping: отображение имени слоя в тензор slot mapping📎 vllm/forward_context.py:145;
  • cudagraph_runtime_mode: режим CUDA graph во время выполнения, по умолчаниюNONE 📎 vllm/forward_context.py:155-157;
  • batch_descriptor: дескриптор батча, используется для диспетчеризации CUDA graph📎 vllm/forward_context.py:158;
  • is_padding: булева маска по оси токенов,Trueобозначает строки padding📎 vllm/forward_context.py:162-165。

BatchDescriptor— это ещё один@dataclass(frozen=True) 📎 vllm/forward_context.py:30-57, дизайн полей следует принципу «минимизации описательных элементов»:num_tokens、num_reqs(в режиме PIECEWISE может быть None),uniform(все запросы имеют одинаковое число токенов),has_lora、num_active_loras. В комментариях объясняется причина существованияnum_active_loras: когдаcudagraph_specialize_lora_countвключён, каждое значение количества LoRA захватывает независимый CUDA graph, поскольку grid size таких ядер, какfused_moe_lora, зависит от этого значения📎 vllm/forward_context.py:60-64。

Глобальный синглтон и управление контекстом

_forward_context— это модульная глобальная переменная📎 vllm/forward_context.py:199-201, через контекстный менеджерoverride_forward_contextсохраняет старое значение при входе и восстанавливает при выходе📎 vllm/forward_context.py:263-274。set_forward_context— это обёртка более высокого уровня📎 vllm/forward_context.py:277-394, она дополнительно обрабатывает построение метаданных DP, автоматическое создание batch descriptor, внедрение платформенно-специфичных kwargs.

Step-by-Step: отexecute_modelк прямому проходу модели

Подставим сценарий:GPUModelRunner.execute_modelвсе входные тензоры готовы, предстоит вызов модели.

Вexecute_model,set_forward_contextвызывается📎 vllm/v1/worker/gpu_model_runner.py:4408-4420:

python
with (
    set_forward_context(
        attn_metadata,
        self.vllm_config,
        num_tokens=num_tokens_padded,
        num_tokens_across_dp=num_tokens_across_dp,
        cudagraph_runtime_mode=cudagraph_mode,
        batch_descriptor=batch_desc,
        ubatch_slices=ubatch_slices_padded,
        slot_mapping=slot_mappings,
        skip_compiled=has_encoder_input,
        is_padding=is_padding,
    ),
    ...
):
    model_output = self._model_forward(...)

set_forward_contextвнутри сначала конструируетDPMetadata(если включён DP или sequence parallel MoE)📎 vllm/forward_context.py:299-328, затем вызываетcreate_forward_contextдля конструирования экземпляраForwardContext, наконец через📎 vllm/forward_context.py:347-358устанавливает глобальную переменнуюoverride_forward_contextСлой модели через📎 vllm/forward_context.py:361-362。

читаетget_forward_context(). Если не установлено, утверждение не проходит и предлагается использовать📎 vllm/forward_context.py:208-214копированиеset_forward_context。

mermaid
sequenceDiagram
    participant EC as EngineCore
    participant EX as Executor
    participant W as Worker
    participant MR as GPUModelRunner
    participant FC as ForwardContext
    participant M as Model Layers

    EC->>EX: execute_model(SchedulerOutput)
    EX->>W: collective_rpc("execute_model", args)
    W->>MR: execute_model(scheduler_output)
    MR->>MR: _update_states(scheduler_output)
    MR->>MR: _prepare_inputs(...)
    MR->>MR: _get_slot_mappings(...)
    MR->>FC: set_forward_context(attn_metadata, slot_mapping, ...)
    FC-->>MR: context manager entered
    MR->>M: _model_forward(input_ids, positions, ...)
    M->>FC: get_forward_context()
    FC-->>M: ForwardContext
    M-->>MR: hidden_states
    MR->>MR: compute_logits(sample_hidden_states)
    MR-->>W: ExecuteModelState / None
    W-->>EX: ModelRunnerOutput
    EX-->>EC: output[0]

Размышления о дизайне

〔Дизайнерские предположения и архитектурные компромиссы〕

Почему глобальная переменная, а не явная передача параметров? Потому что сигнатураforwardслоя модели фиксирована соглашением HuggingFace, невозможно внедрить дополнительные параметры для каждого слоя. Глобальная переменная + контекстный менеджер — единственное решение, позволяющее реализовать межслойное внедрение без изменения кода модели. Цена — неявная зависимость:get_forward_context()вызывающий должен убедиться, что находится в области действияset_forward_context.

is_paddingдизайн поля заслуживает внимания📎 vllm/forward_context.py:162-165: в комментариях сказано «потребители могут использовать это для пропуска работы с padding token». Это оптимизация в сценарии CUDA graph — строки padding участвуют в захвате графа, но не должны производить фактических вычислений.

all_moe_layersиmoe_layer_index— это пара остроумных workaround'ов📎 vllm/forward_context.py:170-195. В комментариях подробно объясняется проблема:vllm.moe_forwardпользовательские операторы жёстко кодируют строку имени слоя в граф, что приводит к чрезмерно долгому холодному старту torch.compile. Решение — хранить список имён слоёв вForwardContext, пользовательские операторы по порядку извлекают строки и инкрементируют счётчик. В комментариях также честно признаётся, что это зависит от предположения «пользовательские операторы выполняются по порядку и torch.compile не переупорядочивает»📎 vllm/forward_context.py:182-184。

Размышления о дизайне и подводные камни в продакшене

Согласованность состояния при асинхронном планировании. _update_statesпри асинхронном спекулятивном декодировании использует стратегию «оптимистичного предположения»: предполагается, что все draft token предыдущего шага приняты, сначала расширяетсяoutput_token_ids, затем регистрируется функция отложенной коррекции📎 vllm/v1/worker/gpu_model_runner.py:1376-1384. Функция коррекции вызывается после запуска прямого прохода модели📎 vllm/v1/worker/gpu_model_runner.py:1509-1510, считывает фактическое число принятых с GPU и откатываетnum_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1547-1558. Изящество этого дизайна в том, что коррекция происходит после «запуска батча», не блокирует прямой проход, сохраняя непрерывность асинхронного конвейера.

_may_reorder_batchусловие срабатывания.этот метод сначала проверяетkv_cache_groupsпуст ли📎 vllm/v1/worker/gpu_model_runner.py:1131-1132. В комментариях объясняется, почему нельзя просто проверитьis_attention_free:Модель Mamba также не использует attention, но она хранит внутреннее состояние с помощью KV cache📎 vllm/v1/worker/gpu_model_runner.py:1116-1139. Только модели, у которых действительно нет KV cache group, пропускают переупорядочивание.

_prepare_input_idsловушка вычисления индексов.Когда в батче есть как decode-запросы с предыдущего шага, так и новые запросы,num_common_tokens < total_without_spec, необходимо сначала скопировать тензор CPU, а затем выполнить scatter📎 vllm/v1/worker/gpu_model_runner.py:1849-1854. Еслиnum_common_tokens == 0, это означает, что ни один запрос не пересекается с предыдущим шагом, и нужно сразу вернуть📎 vllm/v1/worker/gpu_model_runner.py:1855-1858. Различение этих двух ветвей критически важно — пропуск любой из них приведёт к тому, чтоinput_idsчастично останется неинициализированным.

AsyncGPUModelRunnerOutputсинхронизация потоков.Копирование вывода выполняется в отдельном CUDA stream📎 vllm/v1/worker/gpu_model_runner.py:308-328, используяblocking=TrueEvent, чтобы избежать занятого опроса блокировки драйвера CUDA📎 vllm/v1/worker/gpu_model_runner.py:296-298。get_output()сначала synchronize, затем освобождение ссылки на тензор устройства📎 vllm/v1/worker/gpu_model_runner.py:336-340, порядок нельзя менять — иначе тензор может быть освобождён до завершения копирования.

Итоги главы

В этой главе прослеженSchedulerOutputполный путь от EngineCore до прямого прохода на GPU.ExecutorС помощьюcollective_rpcрезультаты планирования транслируются всем Worker'ам,GPUModelRunnerс помощью_update_statesсинхронизируется состояние кэша,_prepare_inputsконструируются входные тензоры,_get_slot_mappingsгенерируется отображение KV-слотов, и наконецset_forward_contextописание батча внедряется в глобальный контекст для использования всеми слоями модели. Путь асинхронного планирования поддерживает непрерывность конвейера через оптимистичное предположение + отложенную коррекцию, аForwardContextглобальный синглтон-дизайн решает противоречие между фиксированной сигнатурой слоёв модели и внедрением кросс-слойных метаданных.

Вопросы для размышления и самопроверки

Q1: _update_statesВunscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)это выражение, если убратьresumed_req_idsиз вычитания, превратив вcached_req_ids - scheduled_req_ids, в каких сценариях это приведёт к несогласованности состояния?

Эталонный разбор: В комментарии явно указано, что📎 vllm/v1/worker/gpu_model_runner.py:1241-1246,cached_req_idsиresumed_req_idsобычно не пересекаются, но в сценарии принудительного вытеснения, вызванногоreset_prefix_cache, один запрос может одновременно находиться вcached_req_idsиresumed_req_ids. В этом случаеscheduled_req_ids - resumed_req_idsисключит этот запрос из множества «запланированных», так что он попадёт вunscheduled_req_ids, тем самым сначала будет удалён из постоянного батча, а затем заново добавлен через обычный путь resumed. Если убратьresumed_req_ids, этот запрос будет считаться «запланированным» и останется в батче, но его block ID уже заменён (req_state.block_ids = new_block_ids 📎 vllm/v1/worker/gpu_model_runner.py:1448), что приведёт к несоответствию старой строки в block table и нового block ID, и вычисление attention будет читать неправильные позиции KV.

Q2: _prepare_input_idsБыстрый путь📎 vllm/v1/worker/gpu_model_runner.py:1859-1868используетcommon_indices_match and max_flattened_index == (num_common_tokens - 1)в качестве условия. Если порядок запросов в батче изменился (например, бэкенд attention переупорядочил батч), ноcommon_indices_matchпо-прежнему True, что произойдёт?

Эталонный разбор:common_indices_matchВ цикле черезprev_index == flattened_indexнакапливается📎 vllm/v1/worker/gpu_model_runner.py:1835。prev_indexизprev_positions, отображая текущую позицию в батче на позицию в батче предыдущего шага;flattened_index— это плоский индекс последнего токена этого запроса в текущем батче. Если батч переупорядочен, соответствие междуprev_indexиflattened_indexизменится,common_indices_matchстанет False, и быстрый путь не сработает. Но если переупорядочивание случайно таково, чтоprev_index == flattened_indexвыполняется для всех запросов (например, поменяли местами два запроса с одинаковым числом токенов), быстрый путь ошибочно выполнит прямое копирование срезом поprev_sampled_token_ids[:num_common_tokens, 0]— это запишет сэмплированный токен запроса A на позицию запроса B.max_flattened_index == num_common_tokens - 1Это дополнительное условие как раз и предназначено для предотвращения такой вырожденной ситуации: оно требует, чтобы плоские индексы были в точности перестановкой0..N-1, исключая любое нетривиальное переупорядочивание.

Q3: ForwardContextИспользует модульную глобальную переменную_forward_context, а не thread-local переменную. При асинхронном планировании, когдаexecute_modelиsample_tokensразделены, еслиsample_tokensбудет вызван до завершения прямого прохода,get_forward_context()что вернёт? К каким проблемам это приведёт?

Эталонный разбор:set_forward_context— это контекстный менеджер📎 vllm/forward_context.py:278-288, который при выходе из блокаwithчерезoverride_forward_contextвосстанавливает старое значениеfinally. В📎 vllm/forward_context.py:263-274,execute_modelблокset_forward_contextоборачивает только вызовwith, и после возврата прямого прохода контекст восстанавливается. Если_model_forwardвызвать после завершения прямого прохода,📎 vllm/v1/worker/gpu_model_runner.py:4408-4433произойдёт ошибка утвержденияsample_tokens, потому чтоget_forward_context()уже сброшен в📎 vllm/forward_context.py:208-214(или внешнее значение). Именно для этого существует_forward_context: состояние, необходимое для сэмплирования (None), явно сохраняется в NamedTuple, а не полагается на неявную передачу черезExecuteModelState. Если ошибочно считать, что📎 vllm/v1/worker/gpu_model_runner.py:463-476всё ещё доступен вlogits、hidden_states、slot_mappings, это вызовет ошибку утверждения или чтение неправильных метаданных.ForwardContextНа этом мы завершили полный путь от SchedulerOutput до прямого прохода на GPU: Executor выполняет диспетчеризацию, Worker — исполнение, GPUModelRunner переводит логический список в физические тензоры и через forward_context внедряет описание батча в каждый слой. Однако самая затратная по времени часть прямого прохода модели — вычисление attention — ещё не раскрыта. В следующей главе мы углубимся в бэкенды attention, рассмотрим, как block table и slot mapping из attn_metadata потребляются ядром PagedAttention, и как различные бэкенды — FlashAttention, FlashInfer, Triton и другие — выбираются и планируются через единый интерфейс.ForwardContext← Предыдущая глава: Глава 4sample_tokensВернуться наверх ↑

Следующая глава: Глава 6 →

CHAPTER 06

Глава 6: ModelRunner и распределенный параллелизм: планирование и исполнение

Проект: vllm-project/vllm · Прогресс по книге: глава 6 / 14 · Статус верификации: FACT-номера строк реально привязаны

В предыдущей главе мы увидели, как GPUModelRunner преобразует результаты планирования в физические тензоры, такие как input_ids, slot_mapping и block_table, и внедряет их в каждый слой через forward_context. Но основная часть, потребляющая время GPU — вычисление внимания — всё ещё остаётся нераскрытой. Кто именно потребляет те тензоры в attn_metadata? Почему реализации FlashAttention, FlashInfer и Triton могут быть взаимозаменяемы в одном и том же коде модели? Ответ кроется в слое абстракции AttentionBackend. Он разделяет «как вычисляется внимание» и «как его вызывает модель»: слой модели хранит только ссылку на AttentionImpl и вызывает унифицированный forward(query, key, value, kv_cache, attn_metadata, output); а конкретный бэкенд отвечает за преобразование block_table, slot_mapping, seq_lens в параметры, которые может принимать его собственное ядро. В этой главе основное внимание уделяется FlashAttentionBackend, поскольку он охватывает наиболее богатый набор ветвей: семантику gather в PagedAttention, совместимость с CUDA Graph, каскадное внимание, распределённый контекст DCP и другие. Разобравшись в нём, вы поймёте, что остальные бэкенды — лишь вариации отображения параметров. Мотивация дизайна «регистрация бэкендов + унифицированный интерфейс» вполне очевидна: ядра внимания развиваются чрезвычайно быстро (FA2→FA3→FA4, итерации FlashInfer, собственные разработки на Triton), и если бы слой модели напрямую зависел от конкретного ядра, при каждом обновлении ядра приходилось бы менять код модели. Слой абстракции изолирует изменения за единственным фабричным методом get_impl_cls().

Выбор бэкенда: объявление возможностей и построение метаданных

Интуитивная модель

ПредставьтеAttentionBackendкак объявление о вакансии: он не выполняет работу, а лишь заявляет, «какие dtype, какие head_size, какие форматы квантования KV cache, какие типы attention я могу обрабатывать». Планировщик сопоставляет конфигурацию модели с этим объявлением, и при неудаче переходит к следующему кандидату. Без этого слоя объявлений система обнаружила бы «это ядро не поддерживает данный head_size» только во время выполнения и сразу бы упала.

Матрица возможностей: поля как контракт

FlashAttentionBackendАтрибуты классаsupported_dtypesи есть границы его возможностей.📎 vllm/v1/attention/backends/flash_attn.py:287-287;supported_kv_cache_dtypesОграничивает fp16/bf16📎 vllm/v1/attention/backends/flash_attn.py:298-299Дополнительно допускает серию fp8supports_kv_cache_dtype. Но «заявленная поддержка» не означает «безусловная поддержка» —flash_attn_supports_kv_cache_dtypeДля квантованного KV дополнительно делегирует📎 vllm/v1/attention/backends/flash_attn.py:431-438。

для принятия решения, зависящего от устройстваsupports_combinationЕщё более тонким являетсяNone: он принимает целый набор комбинированных параметров, таких как head_size, dtype, block_size, use_mla, has_sink, и возвращает📎 vllm/v1/attention/backends/flash_attn.py:454-507означающее доступность, или строку с причиной отказа📎 vllm/v1/attention/backends/flash_attn.py:467-468. Например, sink отклоняется на вычислительных возможностях < 9.0📎 vllm/v1/attention/backends/flash_attn.py:472-472, а на SM90 FP8 KV с mm_prefix обязан идти через Triton

. Такой дизайн с «возвратом строки причины» позволяет верхнему уровню выдавать диагностируемые ошибки, а не молчаливый откат.MultipleOf(16)Выбор block_size также определяется возможностями. По умолчанию возвращается📎 vllm/v1/attention/backends/flash_attn.py:297-324, но на SM90 FP8-KV принудительно 64FA4_HD256_PAGE_SIZE 📎 vllm/v1/attention/backends/flash_attn.py:326-352, а ядро FA4 с head_size=256 принудительно

. Это объясняет, почему размер блока KV cache не задаётся произвольно — он обратно ограничен размером TMA tile ядра.

FlashAttentionMetadataСтруктура метаданных: расположение полей FlashAttentionMetadata📎 vllm/v1/attention/backends/flash_attn.py:511-566:

Это dataclass, поля разделены на четыре группыnum_actual_tokensПервая группа — базовое описание батча:max_query_len、query_start_loc(реальное число токенов без padding),seq_lens、block_table、slot_mapping 📎 vllm/v1/attention/backends/flash_attn.py:520-526(префиксные суммы, используемые ядрами varlen для определения начала и конца каждой последовательности),📎 vllm/v1/attention/backends/flash_attn.py:512-518. Обратите внимание на ASCII-диаграмму в комментариях исходного кодаcontext_len, она точно различаетquery_len(исторический KV),seq_len(добавленный в этот раз),

(их сумму) — это ключ к пониманию параметров ядер varlen.use_cascade、common_prefix_len、cu_prefix_query_lensВторая группа — поля каскадного внимания:📎 vllm/v1/attention/backends/flash_attn.py:528-533。

и т.д.max_dcp_context_kv_len、dcp_context_kv_lensТретья группа — поля DCP (Decode Context Parallel):📎 vllm/v1/attention/backends/flash_attn.py:535-544。

, а также счётчики, различающие число запросов decode/prefillscheduler_metadataЧетвёртая группа — опциональное планирование и специальные маски:causal(для планирования FA3 AOT),mm_prefix_query_range_tensor(может быть bool или тензором, поддерживает по-последовательный causal),📎 vllm/v1/attention/backends/flash_attn.py:546-566。

(двунаправленные диапазоны для мультимодальности), поля, связанные с R-SWA

causal〔Проектные выводы и архитектурные компромиссы〕bool | torch.TensorТип поляdynamic_causal, а не чистый bool, — это сделано для поддержки сценариев, когда «в одном батче часть последовательностей causal, а часть — нет» (например, PrefixLM). Когда это тензор, параметр📎 vllm/v1/attention/backends/flash_attn.py:1429-1433。

FA4 берёт управление на себя, а FA2/FA3 сразу выбрасывают NotImplementedError

Пошаговое выполнение build()

Сценарий: смешанный батч, 3 последовательности decode + 2 последовательности prefill, без каскада, без DCP.common_attn_metadataШаг первый: из📎 vllm/v1/attention/backends/flash_attn.py:824-832. Второй шаг — решить, включать ли AOT-планирование:aot_schedule = self.aot_schedule and not fast_build and not envs.VLLM_BATCH_INVARIANT 📎 vllm/v1/attention/backends/flash_attn.py:836-838。self.aot_scheduleВ__init__определяетсяget_flash_attn_version() == 3решает📎 vllm/v1/attention/backends/flash_attn.py:709-709— только FA3 поддерживает предварительно вычисленные метаданные планирования. Третий шаг — при первой сборке лениво заполнитьaot_sliding_window: обойти всеFlashAttentionImplслоёв и собрать конфигурации скользящего окна; если конфигурация единственная — принять её, если больше одной — отключить AOT📎 vllm/v1/attention/backends/flash_attn.py:848-851。

Четвёртый шаг — вычислитьmax_num_splits. По умолчанию 0 (чтобы FA3 использовал эвристику); устанавливается вself.max_num_splits 📎 vllm/v1/attention/backends/flash_attn.py:856-866только когда включён full CUDA graph и число токенов попадает в диапазон захвата. Комментарий объясняет причину:num_splits > 1выделяет[num_splits, num_heads, num_tokens, head_size]промежуточный буфер, что дорого по видеопамяти, и оправдано только в сценарии CUDA graph📎 vllm/v1/attention/backends/flash_attn.py:862-865。

Пятый шаг — пойти по ветке не-каскадного и не-DCP, вызвать_get_scheduler_metadataдля генерации метаданных планирования FA3📎 vllm/v1/attention/backends/flash_attn.py:976-986. Шестой шаг —_store_scheduler_metadataобрабатывает сценарий CUDA graph: копирует новые метаданные в предварительно выделенный буфер и обнуляет оставшуюся часть📎 vllm/v1/attention/backends/flash_attn.py:671-684. Этот шаг обнуления критически важен — комментарий прямо указывает, что иначе некоторые thread block прочитают недействительные метаданные и перезапишут выходной буфер📎 vllm/v1/attention/backends/flash_attn.py:671-672。

Седьмой шаг — сконструироватьFlashAttentionMetadataи вернуть📎 vllm/v1/attention/backends/flash_attn.py:992-1015。

mermaid
flowchart TD
    start["build(common_prefix_len, common_attn_metadata)"] --> unpack["解包 query_start_loc / seq_lens / block_table / slot_mapping"]
    unpack --> aot{"aot_schedule 且非 fast_build 且非 BATCH_INVARIANT?"}
    aot -->|是| sw_check{"aot_sliding_window 已初始化?"}
    aot -->|否| maxsplit
    sw_check -->|否, 首次| collect["_get_sliding_window_configs 收集层滑窗"]
    collect --> sw_unique{"配置数量 == 1?"}
    sw_unique -->|是| set_sw["设置 aot_sliding_window"]
    sw_unique -->|否, >1| disable_aot["self.aot_schedule = False"]
    set_sw --> maxsplit
    disable_aot --> maxsplit
    sw_check -->|是| maxsplit["计算 max_num_splits"]
    maxsplit --> cg_check{"use_full_cuda_graph 且 tokens <= max_cudagraph_size?"}
    cg_check -->|是| set_splits["max_num_splits = self.max_num_splits"]
    cg_check -->|否| zero_splits["max_num_splits = 0"]
    set_splits --> branch
    zero_splits --> branch
    branch{"dcp_world_size > 1?"}
    branch -->|是| dcp_path["计算 dcp_context_kv_lens, 可能 skip"]
    branch -->|否| cascade_check{"common_prefix_len > 0?"}
    cascade_check -->|是| cascade_path["构造 prefix/suffix 双份 scheduler_metadata"]
    cascade_check -->|否| normal_path["_get_scheduler_metadata 单份"]
    dcp_path --> store
    cascade_path --> store
    normal_path --> store
    store["_store_scheduler_metadata: CUDA graph 时拷入预分配缓冲并清零尾部"] --> build_meta["构造 FlashAttentionMetadata"]
    build_meta --> mm_check{"mm_req_doc_ranges 非空?"}
    mm_check -->|是| fill_mm["fill_mm_prefix_query_ranges + 拷贝到 GPU"]
    mm_check -->|否| rswa_check
    fill_mm --> rswa_check{"rswa_window 非空?"}
    rswa_check -->|是| copy_rswa["拷贝 prefix_lens 到持久缓冲"]
    rswa_check -->|否| done
    copy_rswa --> done["返回 attn_metadata"]

---

forward(): полная цепочка от метаданных до вызова ядра

Интуитивная модель

forward()— это «сборочный цех» бэкенда: он получает вычисленные на уровне модели Q/K/V, тензоры KV cache и построенные на предыдущем шаге метаданные, приводит физическую раскладку KV cache к форме, ожидаемой ядром, а затем диспетчеризует на конкретное ядро. Без этого шага ядро прочитает неверную раскладку памяти и выдаст тихую ошибку — её сложнее отладить, чем падение.

Преобразование раскладки памяти KV cache

Физическая форма KV cache в vLLM — это[num_blocks, num_kv_heads, block_size, 2 * head_size]— K и V объединены в последнем измерении📎 vllm/v1/attention/backends/flash_attn.py:1246-1247. Но ядра FlashAttention ожидают K и V раздельно, с раскладкой[num_blocks, block_size, num_kv_heads, head_size]。

Преобразование происходит в началеforward():kv_cache.transpose(1, 2).split(self.head_size, dim=-1) 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310。transpose(1,2)превращает[blocks, heads, block_size, 2D]в[blocks, block_size, heads, 2D],splitразрезая по последнему измерению на K и V. Заметьте, чтоtransposeменяет только stride, не перемещая данные, поэтому последующие ядра обязаны поддерживать неконтигуозный доступ.

Сразу за этим идётcanonicalize_singleton_dim_strides 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310. Комментарий проясняет мотивацию: когдаnum_kv_heads=1(часто в сценариях TP), stride размерности размера 1 вырожден, а FA3/FA4 на H100+ используют TMA, требующий выравнивания stride минимум на 16 байт📎 vllm/v1/attention/backends/flash_attn.py:1310-1310. Это типичная ловушка «логически эквивалентно, физически недопустимо».

Передача параметров в не-каскадном пути

После входа в веткуif not attn_metadata.use_cascadeпараметры отображаются по одному📎 vllm/v1/attention/backends/flash_attn.py:1326-1342:cu_seqlens_q = query_start_loc,seqused_k = seq_lens,block_table = attn_metadata.block_table。descale_shapeберёт(batch_size, num_kv_heads), используется для broadcast scale при FP8-квантизации — комментарий поясняет, что flash-attn ожидает форму descale(num_sequences, num_kv_heads), а.expand()применяется, чтобы избежать копирования📎 vllm/v1/attention/backends/flash_attn.py:1258-1258。

Затем — симметризация скользящего окна._maybe_symmetrize_windowлогика: причинное скользящее окно(w, 0)в не-причинном сценарии должно стать(w, w), чтобы двунаправленный query мог смотреть в обе стороны📎 vllm/v1/attention/backends/flash_attn.py:587-589. Комментарий также подчёркивает, что «собственное окно слоя имеет приоритет над окном группы», поскольку одна группа KV cache может одновременно содержать оконные и глобальные слои (например, в Gemma-3 при отключённом hybrid KV cache manager)📎 vllm/v1/attention/backends/flash_attn.py:1362-1365。

Ветка маски: mm_prefix и R-SWA

Когдаmm_prefix_query_rangesнепусто и выполнены условия FA4 + статической причинности, код конструирует CuTE-DSLmask_mod 📎 vllm/v1/attention/backends/flash_attn.py:1374-1407. Ключевые действия —causal = Falseиsliding_window_size = None 📎 vllm/v1/attention/backends/flash_attn.py:1406-1407. Комментарий объясняет причину: семантика mm_prefix — это(causal ∧ window) ∨ bidirectional-range, а не подмножество causal; после FA #155 установка mask_mod больше не очищает автоматически causal/local, и вызывающая сторона должна явно отключить их, иначе встроенный causal-путь замкнёт mask_mod накоротко📎 vllm/v1/attention/backends/flash_attn.py:1402-1405。

_make_mm_prefix_mask_modиспользуетfunctools.cacheдля кэширования📎 vllm/v1/attention/backends/flash_attn.py:1793-1802. Комментарий даёт вескую причину:hash_callableв FA4 подмешиваетrepr()замыкающей ячейки в ключ компиляции, а вложенный_load_q_rangeпри каждом вызове имеет разный адрес, что приводит к полной JIT-перекомпиляции на каждом forward📎 vllm/v1/attention/backends/flash_attn.py:1793-1802. Это типичный образец ловушки производительности в продакшене.

Внутри маски есть деталь преобразования координат: FA4 передаёт локальныйq_idx(0-based внутри текущего prefill chunk), тогда какkv_idx— абсолютная позиция. Код используетq_abs = q_idx + seqlen_k - seqlen_qдля восстановления абсолютной позиции📎 vllm/v1/attention/backends/flash_attn.py:1859-1865。__vec_size__ = 1настройка также имеет тонкость:_load_q_rangeчитает lane 0, один вызов не может пересекать строку query📎 vllm/v1/attention/backends/flash_attn.py:1897-1897。

mask_mod у R-SWA аналогичен, но семантика —causal & (in_prefix | in_window) 📎 vllm/v1/attention/backends/flash_attn.py:1945-1948, иuse_fast_sampling = Trueзаставляет FA4 пропускать полностью замаскированные KV-блоки, не загружая их данные📎 vllm/v1/attention/backends/flash_attn.py:1950-1950。

Особая обработка FA4 hd256

Когдаself.fa4_hd256истинно, код принудительно выравнивает страницы:num_pages = cdiv(max_seqlen_k, FA4_HD256_PAGE_SIZE),max_seqlen_kокругляется вверх до границы страницы,block_tableусекается до точного числа страниц,num_splits = 1 📎 vllm/v1/attention/backends/flash_attn.py:1442-1448. Комментарий поясняет, что ядро hd256 требует выровненной по страницам длины, точной ширины block table и не поддерживает SplitKV.

Финальный вызов_FA4_DENSE_ATTENTION_KERNEL(...)передаёт q, k, v, out, cu_seqlens_q, seqused_k, block_table, softcap, mask_mod, aux_tensors и т. д. в📎 vllm/v1/attention/backends/flash_attn.py:1450-1475。

Запись в KV cache: do_kv_cache_update

forward()только читает KV cache, запись выполняетdo_kv_cache_update. Он вызываетreshape_and_cache_flash, используяslot_mappingдля scatter-записи только что вычисленных K/V в cache📎 vllm/v1/attention/backends/flash_attn.py:1532-1541. Комментарий отмечает:key/valueявляется padded, аslot_mappingНет, но ручное разбиение на срезы не требуется, поскольку op используетslot_mappingформу для определения фактического числа токенов📎 vllm/v1/attention/backends/flash_attn.py:1527-1531. Здесь нормализация stride не выполняется, так как ядро TMA не участвует📎 vllm/v1/attention/backends/flash_attn.py:1520-1521。

mermaid
sequenceDiagram
    participant Model as Слой модели Attention
    participant Impl as FlashAttentionImpl
    participant KVC as тензор kv_cache
    participant Kernel as flash_attn_varlen_func
    Model->>Impl: forward(query, key, value, kv_cache, attn_metadata, output)
    Impl->>Impl: output_scale не пусто? выбросить NotImplementedError
    Impl->>Impl: attn_metadata is None? вернуть output.fill_(0)
    Impl->>KVC: transpose(1,2).split(head_size)
    KVC-->>Impl: key_cache, value_cache
    Impl->>Impl: canonicalize_singleton_dim_strides(key_cache)
    Impl->>Impl: use_cascade?
    alt не каскадное
        Impl->>Impl: отобразить cu_seqlens_q / seqused_k / block_table
        Impl->>Impl: _maybe_symmetrize_window
        Impl->>Impl: mm_prefix или R-SWA? построить mask_mod
        Impl->>Kernel: _FA4_DENSE_ATTENTION_KERNEL(q, k, v, out, ...)
        Kernel-->>Impl: output записывается на месте
    else каскадное
        Impl->>Kernel: cascade_attention(prefix + suffix два вызова)
        Kernel-->>Impl: merge_attn_states объединяет
    end
    Impl-->>Model: output

---

Проектные размышления: почему написано именно так

〔Проектные выводы и архитектурные компромиссы〕

Разделение объявления возможностей и реализации。supports_combinationВозвращает строку причины, а не bool — это сделано для того, чтобы верхний уровень при откате к другим бэкендам мог зафиксировать, «почему не использовался FA», что значительно снижает стоимость диагностики в production. По сравнению с молчаливым откатом такой дизайн делает основание для решения явным.

Совместимость с CUDA Graph — неявное ограничение дизайна метаданных。_store_scheduler_metadataРежим «копирование внутрь + обнуление хвоста»📎 vllm/v1/attention/backends/flash_attn.py:671-684неоднократно встречается в持久ном буфере R-SWA📎 vllm/v1/attention/backends/flash_attn.py:787-798и временной области mm_prefix📎 vllm/v1/attention/backends/flash_attn.py:800-813. Общий паттерн: в__init__Предварительно выделяется постоянный буфер максимального размера,build()выполняет только копирование, без выделения. Причина указана в комментарии — во время захвата CUDA graph не должно быть операций выделения📎 vllm/v1/attention/backends/flash_attn.py:1044-1046。

Взаимоисключение DCP и fused draft decode。supports_draft_decode_metadata_update = self.dcp_world_size == 1 📎 vllm/v1/attention/backends/flash_attn.py:742-742. В комментарии объясняется: fused draft decode повторно использует захваченные объекты метаданных между шагами draft, но решения DCP на стороне хоста во время сборки (например,skip_dcp_context_attention()) изменяют форму метаданных, и эти поля Python не обновляются на месте между replay графа📎 vllm/v1/attention/backends/flash_attn.py:736-741. Это типичный компромисс «при конфликте производительности и корректности выбирается корректность».

Эвристический порог каскадного внимания。use_cascade_attentionиспользует ряд пороговых фильтров: common_prefix_len < 256 — немедленный отказ📎 vllm/v1/attention/backends/flash_attn.py:1967-1967, alibi/sliding_window/local_attention не поддерживаются📎 vllm/v1/attention/backends/flash_attn.py:1978-1979, число запросов < 8 — отказ📎 vllm/v1/attention/backends/flash_attn.py:1982-1984, в сценариях DCP отключено📎 vllm/v1/attention/backends/flash_attn.py:1985-1987. После прохождения проверок ещё требуется с помощью грубой модели производительности сравнить число CTA и число wave для cascade и FlashDecoding📎 vllm/v1/attention/backends/flash_attn.py:2011-2029. В комментарии честно признаётся, что эта модель «very rough»📎 vllm/v1/attention/backends/flash_attn.py:2009-2010。

Подводные камни в production:forward()содержит заметный комментарий, предупреждающий, что при piece-wise CUDA graph этот метод выполняется в eager-режиме,view/sliceи другие методы, выглядящие как не содержащие операций на GPU, на самом деле очень медленные, изменения обязательно нужно бенчмаркать📎 vllm/v1/attention/backends/flash_attn.py:1277-1284. Это объясняет, почему в коде массово используются[:num_actual_tokens]срезы вместо более «элегантного» написания — каждое место является результатом компромисса по производительности.

---

Итоги главы

Эта глава прошла поFlashAttentionBackendполный жизненный цикл бэкенда внимания: объявление возможностей (supports_*серия) → построение метаданных (build()переводитCommonAttentionMetadataвFlashAttentionMetadata) → вызов ядра (forward()преобразует компоновку KV-кэша, строит маску, выполняет диспетчеризацию в ядро FA). Ключевые механизмы включают: преобразованиеtranspose+split布局 KV cache、退化 stride 的归一化、CUDA graph 下的持久缓冲区模式、为 mm_prefix/R-SWA 构建 CuTE-DSL 掩码,以及级联注意力的启发式决策。

Ключевые проектные принципы: разделение объявления возможностей и реализации, предварительное выделение метаданных, обусловленное совместимостью с CUDA graph, приоритет корректности при конфликте производительности и корректности (DCP отключает fused draft decode).

Следующая глава перейдёт к сэмплированию и выводу:logitsкак через цепочку процессоров (температура, top-p, штрафы) получается token, как структурированный вывод ограничивает декодирование и как потоковый возврат взаимодействует с планировщиком.

Вопросы для размышления и самопроверки в этой главе

Q1: Если удалить_store_scheduler_metadataвself.scheduler_metadata[n:] = 0операцию обнуления, в каких сценариях это приведёт к ошибкам вывода? Почему в комментарии это особо подчёркивается?

Разбор ответа:_store_scheduler_metadataв сценарии CUDA graph копирует новые метаданные в первые n позиций предварительно выделенного буфера📎 vllm/v1/attention/backends/flash_attn.py:671-684. Если не обнулить хвост, остаточные метаданные планировщика от предыдущей сборки будут прочитаны текущим ядром. В комментарии явно указано: «some thread blocks may use the invalid scheduler metadata and overwrite the output buffer»📎 vllm/v1/attention/backends/flash_attn.py:671-672. Сценарий срабатывания: размер батча уменьшается с большого до малого (например, с 8 последовательностей до 3), первые 3 позиции буфера содержат новые данные, но позиции с 4 по 8 всё ещё содержат данные старого батча. Метаданные планировщика FA3 включают информацию о распределении tile; если при чтении по batch_size в вычислении batch_size есть отклонение или ядро сканирует с фиксированным stride, оно прочитает грязные данные и испортит вывод. Это классическая ловушка повторного использования буфера в CUDA graph: жизненный цикл буфера охватывает несколько replay, и его необходимо явно очищать.

Q2: _make_mm_prefix_mask_modиспользуетfunctools.cacheкэш; в комментарии сказано, что иначе это «force a full JIT recompile every forward». Насколько деградирует производительность, если убрать этот декоратор кэширования? Почему ключ компиляции FA4 зависит от адреса замыкания?

Разбор ответа: в комментарии объясняется, чтоhash_callableFA4 включаетrepr()ячейки замыкания в ключ компиляции📎 vllm/v1/attention/backends/flash_attn.py:1793-1802。_make_mm_prefix_mask_modвнутри определена вложенная функция_load_q_range, каждый вызов фабричной функции создаёт новый объект функции, и егоrepr()Содержит адрес памяти, адрес каждый раз разный → ключ компиляции каждый раз разный → FA4 считает, что требуется повторная JIT-компиляция. После кэширования — одинаковый(sliding_window, sliding_window_left)Параметры повторно используют один и тот же объект функции, ключ компиляции стабилен. Степень деградации производительности зависит от времени компиляции FA4, но можно утверждать, что «полная компиляция запускается на каждом forward», в цикле decode компиляция выполняется на каждом шаге, и задержка деградирует с миллисекунд до секунд. Это типичный случай инвалидации JIT-кэша, вызванной «кажущимся безобидным Python-замыканием».

Q3: supports_draft_decode_metadata_update = self.dcp_world_size == 1Эта строка кода в сценарии DCP отключает fused draft decode. Предположим, вы принудительно измените её наTrue— какая конкретная ошибка возникнет при комбинации спекулятивного декодирования и DCP?

Справочный разбор: комментарий поясняет, что fused draft decode повторно использует захваченный объект метаданных между шагами draft, а решения на стороне хоста во время сборки DCP (например,skip_dcp_context_attention()) изменяют форму метаданных/путь управления, напримерmax_dcp_context_kv_len 📎 vllm/v1/attention/backends/flash_attn.py:736-741. Эти Python-поля не обновляются на месте между replay CUDA graph. Конкретная ошибка: между шагами draft длина последовательности растёт,skip_dcp_context_attentionусловие может измениться с True на False (или наоборот), но повторно используемый объект метаданных всё ещё хранит старое значение. Если старое значение —max_dcp_context_kv_len = 0, ядро пойдёт по пути «без DCP context»📎 vllm/v1/attention/backends/flash_attn.py:1565-1589, пропустив context-внимание между rank, что приведёт к отсутствию контекстной информации в выводе — тихая ошибка, без падения. Именно это воплощает принцип «при конфликте производительности и корректности выбирать корректность».

На этом полная цепочка от абстрактного интерфейса до реализации ядра для бэкенда внимания уже пройдена: уровень модели единообразно вызывает через AttentionImpl, бэкенд отвечает за преобразование таких метаданных, как block_table, slot_mapping, в конкретные параметры ядра, а реализация PagedAttention в FlashAttentionBackend демонстрирует семантику gather при страничном KV Cache и стратегию совместимости с CUDA Graph. Но вычисление внимания производит лишь скрытые состояния, а модель в конечном итоге должна выдать следующий token. Как эти скрытые состояния превращаются в logits, как logits проходят через сэмплирование и постобработку и в итоге возвращаются клиенту в виде потокового текста? В следующей главе мы проследим эту последнюю милю.

CHAPTER 07

Глава 7: CUDA Graph и аппаратное ускорение: устранение накладных расходов запуска

Проект: vllm-project/vllm · Прогресс книги: Глава 7 / 14 · Статус верификации: строки FACT реально привязаны

В предыдущей главе мы проследили, как бэкенд внимания преобразует block table в параметры ядра и выполняет gather-вычисление внимания на несмежной видеопамяти. Но внимание производит лишь скрытые состояния — модель на самом деле должна доставить пользователю текст следующего token. В этой главе мы проследим эту последнюю милю: как скрытые состояния после проекции через lm_head в logits проходят через тщательно упорядоченную цепочку процессоров (температура, штрафы, top-k/top-p, структурные ограничения), сэмплируются в token id, затем через detokenizer восстанавливаются в текст и отправляются потоком. Любой сбой порядка или утечка состояния на этом пути приведут к тихой деградации качества вывода.

Sampler: порядок цепочки процессоров — это и есть корректность

Интуитивная модель: Sampler подобен сборочному конвейеру, logits — это заготовка, подлежащая обработке. Каждая станция (processor) на конвейере изменяет заготовку, и порядок станций напрямую определяет готовое изделие — сначала обточить, потом отшлифовать и сначала отшлифовать, потом обточить дают две разные вещи. Без этой цепочки модель могла бы выдавать только исходное распределение вероятностей, и пользователь получал бы «голое сэмплирование» без возможности управлять температурой, подавлять повторы или ограничивать формат.

Структуры данных и layout памяти

Сам Sampler —nn.Module, но его ключевое состояние крайне тонкое: он хранит только подмодульtopk_topp_sampler, флагиlogprobs_modeиuse_fp64_gumbel📎 vllm/v1/sample/sampler.py:61-64. Всё настоящее состояние уровня батча инкапсулировано вSamplingMetadataи передаётся через параметры forward. Такой дизайн «Sampler без состояния + внешние метаданные» намеренный: экземпляр Sampler создаётся только один раз за жизненный цикл движка, а состав батча на каждом шаге decode меняется, и вынесение состояния наружу позволяет безопасно воспроизводить Sampler после захвата CUDA Graph.

Ключевая константа —_SAMPLING_EPS = 1e-5 📎 vllm/v1/sample/sampler.py:18. Она одновременно несёт две семантики: температура ниже этого значения считается жадной, а такжеapply_temperatureслужит запасным вариантом для предотвращения деления на ноль.

Step-by-Step Walkthrough

Сценарий: в одном batch смешаны жадные запросы и запросы со случайным сэмплированием, часть запросов также включает logprobs.

Первый шаг — снимок исходных logprobs.До применения любых штрафов или температуры, если запросу нужны logprobs, сначала поlogprobs_modeопределяется содержимое снимка📎 vllm/v1/sample/sampler.py:84-93. Обратите внимание, что комментарий явно указывает на отличие от V0: V1 используетисходные logits(до штрафов и температуры) для вычисления top-k logprobs📎 vllm/v1/sample/sampler.py:72-77. Это семантический контракт — logprob, который видит пользователь, должен отражать истинное распределение модели, а не распределение, искажённое после применения штрафов.

Второй шаг — приведение к float32. 📎 vllm/v1/sample/sampler.py:95-96Независимо от того, является ли вход bf16 или fp16, выполняется повышение до float32. Причина в том, что последующие log_softmax, top-k и накопление вероятностей при низкой точности накапливают ошибку, особенно при размере словаря до 150 тысяч.

Третий шаг — цепочка обработчиков, не изменяющих argmax. apply_logits_processorsПоследовательно применяются: маска белого списка allowed token, исключение bad words,non_argmax_invariantобработчики, штрафные члены📎 vllm/v1/sample/sampler.py:391-404. Классификация здесь — ключевой элемент дизайна —non_argmax_invariantобозначает те, которые изменяют результат жадного выбораобработчики (такие как min_tokens, logit_bias), они должны применяться до жадной выборки; аargmax_invariantобработчики (такие как min_p) не изменяют argmax и могут быть отложены до применения температуры.

Четвёртый шаг — выборка. sampleМетод сначала определяет, является ли выборка полностью случайной📎 vllm/v1/sample/sampler.py:256-271: еслиall_greedy, сразу возвращается argmax; иначе сначала вычисляется жадный результат для последующего использования, затем применяются температура, обработчики, не изменяющие argmax, top-k/top-p📎 vllm/v1/sample/sampler.py:275-291. В конце с помощьюtorch.whereпо порогу температуры выбирается между жадным и случайным результатами📎 vllm/v1/sample/sampler.py:305-306, и переиспользуетсяgreedy_sampledтензор в качестве выходного буфера, чтобы избежать дополнительного выделения памяти.

Пятый шаг — сбор logprobs и упаковка вывода.Поnum_logprobsразличаются три случая: None возвращает только logprobs указанных токенов; -1 возвращает полный неотсортированный набор logprobs; иначе top-k📎 vllm/v1/sample/sampler.py:120-131. В итоге token id преобразуется в int32 для сжатия объёма и расширяется до[num_requests, 1]двумерного тензора📎 vllm/v1/sample/sampler.py:138-148。

mermaid
flowchart TD
    in_logits["logits (bf16/fp16)"] --> snap{"нужны logprobs?"}
    snap -->|да| raw["compute_logprobs / clone<br/>снимок raw_logprobs"]
    snap -->|нет| f32
    raw --> f32["logits.to(float32)"]
    f32 --> proc["apply_logits_processors"]
    proc --> mask{"allowed_token_ids_mask?"}
    mask -->|да| fill["masked_fill_(-inf)"]
    mask -->|нет| bad
    fill --> bad{"bad_words_token_ids?"}
    bad -->|да| apply_bad["apply_bad_words"]
    bad -->|нет| noninv
    apply_bad --> noninv["non_argmax_invariant процессоры"]
    noninv --> pen["apply_penalties"]
    pen --> sample["sample()"]
    sample --> allg{"all_greedy?"}
    allg -->|да| greedy["greedy_sample (argmax)"]
    allg -->|нет| temp["apply_temperature"]
    temp --> arginv["argmax_invariant процессоры"]
    arginv --> topp["topk_topp_sampler"]
    topp --> where["torch.where(temp < EPS)"]
    greedy --> out
    where --> out["SamplerOutput<br/>sampled_token_ids"]

Размышления о дизайне и подводные камни

Почему штрафные члены должны применяться до температуры?Температура — это масштабирование распределения, штраф — это добавление или вычитание баллов для конкретных токенов. Если сначала масштабировать, а потом штрафовать, абсолютная величина штрафа будет увеличена или уменьшена температурой, что приведёт к несогласованному поведению одного и того же набора параметров штрафа при разных температурах. В V1 штраф зафиксирован до температуры, что обеспечивает стабильность семантики параметров.

mark_unbackedЛовушка компиляцииВgather_logprobs,batched_count_greater_thanкомпилируется, а при изменении размерности batch с 1 на ≥2 срабатывает специализированная перекомпиляция dynamo для 0/1📎 vllm/v1/sample/sampler.py:345-348。mark_unbackedпомечает эту размерность как полностью символьную, избегая этой перекомпиляции. Если в production после первого запроса decode внезапно возникает однократная задержка, скорее всего, это именно такая перекомпиляция.

gpu_sync_allowedГраница синхронизации batched_count_greater_thanвнутри может вызывать синхронизацию GPU, vLLM используетgpu_sync_allowed(first_only=True)контекст, явно объявляя «здесь синхронизация разрешена, но только в первый раз»📎 vllm/v1/sample/sampler.py:345-348. Если синхронизация случайно происходит внутри области захвата CUDA Graph, это приведёт к сбою захвата — это ключевая подсказка при диагностике проблем захвата графа.

Структурированный вывод: двухдорожечный конечный автомат битовых масок и грамматики

Интуитивная модель: структурированный вывод — это как надеть на сэмплер «грамматические очки» — на каждом шаге видны только токены, соответствующие JSON schema или грамматике. Без этого модель может генерировать синтаксически некорректный JSON, и нижестоящий парсер просто упадёт. Суть реализации в vLLM в том, что конечный автомат грамматики продвигается на стороне CPU, а ограничения передаются на сторону GPU для выборки в виде битовой маски.

Структуры данных и раскладка памяти

StructuredOutputManager— это синглтон уровня движка, содержащийbackend(один из xgrammar/guidance/outlines/lm-format-enforcer),reasoner_clsи два пула потоков📎 vllm/v1/structured_output/__init__.py:39-98。

Битовая маска — ключевая структура данных:_grammar_bitmask— это тензор int32 формы[max_batch_size * (1 + max_num_spec_tokens), vocab_size/32]📎 vllm/v1/structured_output/__init__.py:327-336. Каждый бит соответствует допустимости одного токена._full_mask = torch.tensor(-1, dtype=torch.int32)означает «все единицы» — все токены допустимы📎 vllm/v1/structured_output/__init__.py:59。

Два пула потоков имеют чёткое разделение обязанностей:executorотвечает за компиляцию грамматики (CPU-интенсивная задача, число worker'ов равно половине числа CPU)📎 vllm/v1/structured_output/__init__.py:71-78;executor_for_fillmaskотвечает за параллельное заполнение битовых масок для больших batch, включается только при batch больше 128📎 vllm/v1/structured_output/__init__.py:62-69。

Step-by-Step Walkthrough

Инициализация грамматики.При первом поступлении запросаgrammar_initвызывается📎 vllm/v1/structured_output/__init__.py:115-176. Если backend не инициализирован, реализация выбирается согласно конфигурации📎 vllm/v1/structured_output/__init__.py:130-165. Затем отправляется задача компиляции: по умолчанию используется асинхронныйexecutor.submit, но в режимеexternal_launcherобязательна синхронная📎 vllm/v1/structured_output/__init__.py:167-176。

Генерация битовой маски.На каждом шаге decodegrammar_bitmaskгенерирует маски для всех структурированных запросов в batch📎 vllm/v1/structured_output/__init__.py:314-442. Большой batch идёт по параллельному пути: отправка в пул потоков группами по 16📎 vllm/v1/structured_output/__init__.py:346-373. Малый batch идёт по последовательному пути, продвигая состояние грамматики токен за токеном📎 vllm/v1/structured_output/__init__.py:374-433。

Выравнивание масок при спекулятивном декодировании.Это самая изящная часть. При наличии draft token каждому запросу требуется1 + max_num_spec_tokensстрок масок. Последовательный путь обрабатывает токен за токеном: если некоторый draft token отклоняется грамматикой, записываетсяfailed_index, а последующие строки просто копируют маску этой строки📎 vllm/v1/structured_output/__init__.py:396-418. Это гарантирует, что «после отклонения draft состояние ограничений для последующих позиций откатывается к точке отклонения».

Откат состояния.В процессе заполнения битовой маски состояние грамматики продвинулось наstate_advancementsшагов, но draft token ещё не был реально принят, поэтому необходимоgrammar.rollback(state_advancements)откатить📎 vllm/v1/structured_output/__init__.py:422-430. Реальное принятие происходит вaccept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466。

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Mgr as StructuredOutputManager
    participant Pool as executor_for_fillmask
    participant Gram as StructuredOutputGrammar
    participant GPU as GPU Runner

    Sched->>Mgr: grammar_bitmask(requests, ids, spec_tokens)
    Mgr->>Mgr: allocate_token_bitmask(max_batch*(1+spec))
    alt batch > 128 и без спекуляции
        Mgr->>Pool: _async_submit_fill_bitmask(batch)
        Pool->>Gram: fill_bitmask(bitmask, index)
        Gram-->>Pool: записать биты допустимых token
        Pool-->>Mgr: Future.result()
    else малый batch или со спекуляцией
        loop для каждого spec token каждого req
            Mgr->>Gram: fill_bitmask(bitmask, cumulative_index)
            Mgr->>Gram: accept_tokens(req_id, [token])
            Gram-->>Mgr: True/False
            Note over Mgr: при неудаче записать failed_index<br/>последующие строки копируют эту строку
        end
        Mgr->>Gram: rollback(state_advancements)
    end
    Mgr-->>Sched: bitmask.numpy() (NDArray int32)
    Sched->>GPU: передать в ядро сэмплирования

Размышления о дизайне и подводные камни

Почему external_launcher требует синхронной компиляции?Комментарий даёт точную причину: асинхронная компиляция приводит к тому, что переходы состоянияWAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR → WAITINGпроисходят в разные моменты на разных TP rank, нарушая предположение о детерминизме, на которое опирается external_launcher📎 vllm/v1/structured_output/__init__.py:47-56. Это типичный случай конфликта между распределённой детерминированностью и асинхронной оптимизацией.

Ограничение начальной точки в моделях вывода. _get_constraint_startОпределяет, с какого токена начинать применение синтаксических ограничений📎 vllm/v1/structured_output/__init__.py:220-292. Для моделей с цепочкой рассуждений этап reasoning не должен ограничиваться JSON, ограничения включаются только после завершения reasoning.enable_in_reasoningПри значении True сразу возвращается 0 (ограничения на всём протяжении)📎 vllm/v1/structured_output/__init__.py:235-236. Если reasoner поддерживаетfind_reasoning_end_offset, использовать его для точного определения📎 vllm/v1/structured_output/__init__.py:261-267; иначе откатиться к пошаговому поиску с возвратом📎 vllm/v1/structured_output/__init__.py:287-291。

validate_tokensсемантика префикса.При спекулятивном декодировании draft-токены могут нарушать грамматику,validate_tokensвозвращается "наидлиннейший допустимый префикс"📎 vllm/v1/structured_output/__init__.py:294-312. Обратите внимание: сначала удаляется спекулятивное заполнение (-1), затем вычисляется начальная точка ограничений, и только после этого выполняется синтаксическая проверка токенов в интервале ограничений.

Detokenizer: инкрементальное декодирование и пограничная игра со stop string

Интуитивная модель: detokenizer подобен переписчику, копирующему символ за символом, переводя token id в читаемый человеком текст. Сложность в том, что токены и символы не соответствуют взаимно однозначно (один токен может соответствовать лишь половине UTF-8 символа), и stop string может охватывать несколько токенов. Без инкрементального декодирования на каждом шаге пришлось бы декодировать всю последовательность с нуля, и затраты O(n²) обрушили бы пропускную способность.

Структуры данных и размещение в памяти

IncrementalDetokenizerБазовый класс содержит толькоtoken_idsсписок📎 vllm/v1/engine/detokenizer.py:32-33。BaseIncrementalDetokenizerдобавлены поля, связанные со stop:stopсписок,min_tokens、include_stop_str_in_output、stop_buffer_lengthи_last_output_text_offset 📎 vllm/v1/engine/detokenizer.py:70-94。

stop_buffer_lengthявляются ключевыми: когда stop string не содержится в выводе, это значение равно длине самого длинного stop string минус один📎 vllm/v1/engine/detokenizer.py:87-90. Этот "буфер отката" гарантирует, что потоковый вывод не выдаст преждевременно символы, которые могут быть префиксом stop string.

Два пути реализации:FastIncrementalDetokenizerиспользоватьDecodeStream 📎 vllm/v1/engine/detokenizer.py:166-246;SlowIncrementalDetokenizerбиблиотеки tokenizers, использоватьdetokenize_incrementally 📎 vllm/v1/engine/detokenizer.py:249-305на стороне Python. Критерий выбора — версия tokenizers ≥ 0.22.0 и совпадение типа tokenizer📎 vllm/v1/engine/detokenizer.py:32-33📎 vllm/v1/engine/detokenizer.py:61-63。

Step-by-Step Walkthrough

инкрементальное декодирование. updateпринимает новые token ids и флагstop_terminatedЕсли stop завершает и не содержит stop string, последний токен исключается из декодирования📎 vllm/v1/engine/detokenizer.py:96-142. Затем для каждого токена вызывается📎 vllm/v1/engine/detokenizer.py:107-111накопление текстаdecode_nextобнаружение stop string.📎 vllm/v1/engine/detokenizer.py:117-122。

поиск ведётся только в диапазоне новых символов check_stop_strings. Начальная точка поиска —📎 vllm/v1/engine/detokenizer.py:308-360, это смещение гарантирует, что stop string, пересекающие границы токенов, также будут обнаружены. При одновременном совпадении нескольких stop string выбирается1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338завершившийся раньше всехсрез потокового вывода.📎 vllm/v1/engine/detokenizer.py:342-347。

в соответствии с параметром get_next_output_textопределяется, возвращать полный объём или приращениеdelta. При незавершённости сохраняются📎 vllm/v1/engine/detokenizer.py:148-163символов, не выдаваемых наружуstop_buffer_length, с помощью📎 vllm/v1/engine/detokenizer.py:145-146фиксируется отправленная позиция_last_output_text_offsetвосстановление после исключений.📎 vllm/v1/engine/detokenizer.py:148-163。

Обрабатываются два типа исключений: OverflowError/TypeError логируются и возвращается None FastIncrementalDetokenizer._protected_step; при ошибке "Invalid prefix" выполняется📎 vllm/v1/engine/detokenizer.py:225-229пересоздание DecodeStreamи повторная попытка. Последнее обрабатывает граничные случаи, когда tokenizer порождает немонотонный вывод UTF-8.📎 vllm/v1/engine/detokenizer.py:222-246Размышления о дизайне и подводные камни

Компромисс по stop_buffer_length.

Чем длиннее буфер, тем больше задержка потоковой передачи (пользователь видит текст позже), но тем меньше вероятность пропустить stop string, охватывающий несколько токенов. Значение "длина самого длинного stop string минус один" является точной нижней границей: любой префикс stop string имеет длину не более этой.min_tokens и stop_check_offset.

Когда число выходных токенов не достигает,min_tokensпродолжает сдвигаться к концу текстаstop_check_offset, это означает, что данный текст не будет проверяться на stop. Это предотвращает ситуацию, когда модель в самом начале натыкается на stop-строку и выдаёт пустой вывод.📎 vllm/v1/engine/detokenizer.py:120-122Кэш added_token_ids быстрого пути.

Когдаравно False, необходимо подавлять пробелы между специальными токенамиspaces_between_special_tokens. Код кэширует📎 vllm/v1/engine/detokenizer.py:192-207в объекте tokenizeradded_token_ids, избегая пересоздания словаря при каждом decode.📎 vllm/v1/engine/detokenizer.py:195-200Размышления о дизайне

Три модуля разделяют одну философию дизайна:

разделение продвижения состояния и проверки ограничений, чтобы сторона GPU выполняла только тензорные операции без состояния. Sampler не имеет состояния, состояние находится в; конечный автомат грамматики продвигается на стороне CPU, GPU лишь потребляет битовую маску;SamplingMetadatadetokenizer — единственный потоковый курсор. Такое разделение позволяет каждому компоненту на стороне GPU быть захваченным CUDA Graph._last_output_text_offsetДругая основная линия —

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

Цепочка обработчиков Sampler строго упорядочена: снимок исходных logprobs → float32 → белый список/bad words → non-argmax-invariant → штрафы → температура → argmax-invariant → top-k/top-p.

  • Цепочка обработчиков Sampler строго упорядочена: снимок исходных logprobs → float32 → белый список/bad words → non-argmax-invariant → штрафы → температура → argmax-invariant → top-k/top-p.
  • Структурированный вывод использует битовую маску для передачи состояния синтаксиса со стороны CPU на GPU, а при спекулятивном декодировании черезfailed_indexкопирование иrollbackобеспечивается согласованность состояния.
  • Detokenizer используетstop_buffer_lengthбуфер отката для балансировки задержки потоковой передачи и обнаружения stop string через границы токенов; быстрый путь зависит от tokenizers ≥ 0.22.0DecodeStream。

Вопросы для размышления и самопроверки к этой главе

Q1: Если перенестиapply_logits_processorsштрафной член (apply_penalties) после температуры, какие конкретные отклонения возникнут в сценарии высокотемпературной выборки при temperature=2.0? Почему?

Справочный разбор: температура — это масштабирование всего вектора logits (logits.div_(temp))📎 vllm/v1/sample/sampler.py:241-242. Штрафной член (например, repetition penalty) — это мультипликативная/аддитивная корректировка конкретных токенов. Если сначала масштабировать, а потом штрафовать, абсолютная величина штрафа будет усилена температурой в 2 раза, из-за чего одна и та же группаrepetition_penaltyпараметров при высокой температуре подавляет гораздо сильнее, чем при низкой, и семантика параметров дрейфует вместе с температурой. V1 фиксирует штраф перед температурой📎 vllm/v1/sample/sampler.py:403-404, обеспечивая развязку амплитуды штрафа и температуры. Кроме того, штраф относится к категорииnon_argmax_invariant(влияет на результат жадной стратегии), а жадный путь возвращается ещё до температуры📎 vllm/v1/sample/sampler.py:261-271; если перенести его после температуры, жадные запросы полностью обойдут штраф, что приведёт к несогласованному поведению.

Q2: Вgrammar_bitmaskпоследовательном пути, если удалить строкуgrammar.rollback(state_advancements) 📎 vllm/v1/structured_output/__init__.py:422-430, что произойдёт при комбинации спекулятивного декодирования и структурированного вывода? Проанализируйте с учётом момента вызоваaccept_tokens.

Справочный разбор: при заполнении битовой маски код для каждого draft token вызываетgrammar.accept_tokensдля продвижения состояния синтаксиса с целью генерации маски следующей позиции📎 vllm/v1/structured_output/__init__.py:396-418, но это лишь «пробное продвижение» — draft token ещё не подтверждён целевой моделью. Если удалитьrollback, состояние синтаксиса навсегда останется в позиции «все draft приняты». Когда целевая модель фактически отклоняет часть draft token, реально принятая последовательность токенов не соответствует состоянию синтаксиса:accept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466будет проверяться на основе ошибочного состояния синтаксиса, что приведёт к отклонению допустимых токенов или пропуску недопустимых. В результате JSON-вывод молча повреждается: без падения, но с ошибкой парсинга на стороне потребителя.

Q3: check_stop_stringsНачальная точка поиска в1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338— это

. Если изменить на полный поиск с 0, будет ли это функционально корректно? Какие проблемы производительности это вызовет в сценарии потоковой передачи длинных последовательностей?Справочный разборoutput_text: функционально корректно — поиск с 0 найдёт все совпадения, включая пересекающие границы токенов. Но по производительности на каждом шаге выполняетсяfindдля всего, сложность деградирует с O(new_char_count) до O(total_length), а на длинных последовательностях — до O(n²). Что ещё серьёзнее, поиск с 0 может совпасть сподстрокой stop string в историческом тексте, уже отправленном пользователю1 - new_char_count - stop_string_len, что приведёт к повторному срабатыванию stop или ошибочному усечению. Исходное смещение

точно покрывает минимально необходимый диапазон «новые символы + возможный пересекающий границу префикс stop string», гарантируя отсутствие пропусков и избегая ложных совпадений с историей.

CHAPTER 08

Глава 8: Передача KV Cache и раздельное развертывание: разделение фаз Prefill и Decode

Глава 8: Распределённый параллелизм: TP, PP, EP и примитивы коммуникации · Проект: vllm-project/vllm · Прогресс книги: Глава 8 / 14

Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы прошли последнюю милю жизненного цикла одного инференса — от выборки logits до потокового вывода. Но когда модель слишком велика, чтобы поместиться на одной карте, этот конвейер приходится разделять между несколькими устройствами для совместного выполнения. Первоочередной вопрос распределённого инференса — не «как разрезать модель», а «после разрезания кто с кем говорит и каким способом». vLLM передаёт эти два вопроса соответственно топологии групп процессов в parallel_state.py и реализации коммуникатора в custom_all_reduce.py. В этой главе мы идём по цепочке «создание групп → разделение → коммуникация → перебалансировка нагрузки», послойно разбирая стратегии параллелизма TP, PP, EP и низкоуровневые примитивы коммуникации.

8.1 Топология групп процессов: как из сетки rank'ов вырезаются TP/PP/DP/EP

Интуитивная модельnew_group, возникнет коммуникационное рассогласование типа «я думал, что ты в группе TP, а ты на самом деле в группе DP» — как только в коллективной коммуникации отсутствует хотя бы один rank, NCCL просто зависнет, а не выдаст ошибку.

Структуры данных и компоновка памяти

GroupCoordinatorявляется носителем всего этого. Дизайн его полей напрямую отражает «множественную идентичность одного процесса в нескольких измерениях параллелизма»:

  • rank— это глобальный rank,ranks— список глобальных rank'ов членов данной группы,world_size— размер группы,📎 vllm/distributed/parallel_state.py:434-436。
  • local_rankиспользуется для привязки устройства,rank_in_group— порядковый номер внутри группы — в исходном коде эти два понятия точно различаются с помощью таблицы: в группе из 4 карт на двух узлах у rank 2local_rankравно 0 (на узле 1 это первая карта), ноrank_in_groupравно 2📎 vllm/distributed/parallel_state.py:437-445。
  • cpu_groupиdevice_groupсуществуют в паре: первый использует gloo для передачи метаданных/объектов, второй использует NCCL для передачи тензоров📎 vllm/distributed/parallel_state.py:446-447。

Здесь есть один ключевой момент проектирования:Почему каждая группа должна поддерживать отдельную группу CPU?Потому чтоbroadcast_object、send_objectТакие операции передают объекты Python (сериализованные байты), и использование NCCL приводит к бесполезному расходу видеопамяти и может загрязнить текущее устройство CUDA.barrier()В комментариях к этому прямо указано: barrier внутри NCCL по сути является broadcast, который незаметно создаёт тензоры на GPU и может нарушить работу текущего устройства, поэтому необходимо использовать группу CPU.📎 vllm/distributed/parallel_state.py:1355-1362。

Step-by-Step:initialize_model_parallelКак разбивать сетку

Рассмотрим конкретный сценарий: 8 GPU, TP=2, PP=4, DP=1. Суть в том, чтобы преобразовать одномерную последовательность rank в многомерную сетку, а затем разбить её по каждому измерению.

Первый шаг — построение сетки rank. Порядок размещения явно определён какExternalDP x DP x PP x PCP x TP 📎 vllm/distributed/parallel_state.py:2045-2060:

python
all_ranks = torch.arange(world_size).reshape(
    -1, data_parallel_size, pipeline_model_parallel_size,
    prefill_context_model_parallel_size, tensor_model_parallel_size,
)

Второй шаг — разбиение группы TP: преобразуем сетку в представление(-1, tp_size)затем выполняем unbind и получаем[g0,g1],[g2,g3],... 📎 vllm/distributed/parallel_state.py:2065-2077. Обратите внимание, что для группы TP дополнительно передаётсяuse_message_queue_broadcaster=True, поскольку группе TP требуется широковещательная передача через разделяемую память для распространения метаданных.

Третий шаг — разбиение группы PP:all_ranks.transpose(2, 4)Переставляем измерение PP в последнее измерение и выполняем разбиение, получая[g0,g2,g4,g6],[g1,g3,g5,g7] 📎 vllm/distributed/parallel_state.py:2175-2188. Именно этот пример приведён в строке документации.📎 vllm/distributed/parallel_state.py:1997-1997。

Четвёртый шаг — разделение группы DP:transpose(1, 4)Разделение после📎 vllm/distributed/parallel_state.py:2195-2202。

Пятый шаг — разделение группы EP. Здесь есть легко упускаемая деталь: группа EP создаётся только для MoE-моделей, для dense-моделей она пропускается.📎 vllm/distributed/parallel_state.py:2210-2241. Набор рангов группы EP — этоDP x PCP x TPпроизведение, что означает, что EP переиспользует физические карты DP и TP, а не является независимым измерением.

mermaid
flowchart TD
    start["initialize_model_parallel()"] --> grid["all_ranks = arange(world_size).reshape(-1, DP, PP, PCP, TP)"]
    grid --> tp["TP: view(-1, tp_size).unbind(0)"]
    grid --> pp["PP: transpose(2,4).reshape(-1, pp_size)"]
    grid --> dp["DP: transpose(1,4).reshape(-1, dp_size)"]
    grid --> ep_check{"model_config.is_moe?"}
    ep_check -->|да| ep["EP: transpose(1,2).reshape(-1, DP*PCP*TP)"]
    ep_check -->|нет| skip["_EP остаётся None"]
    ep --> eplb_check{"enable_eplb?"}
    eplb_check -->|да| eplb["EPLB: тот же набор рангов, что и EP, независимая PG"]
    eplb_check -->|нет| no_eplb["_EPLB остаётся None"]
    tp --> done["logger.info_once выводит ранги по каждому измерению"]
    pp --> done
    dp --> done
    ep --> done
    skip --> done
    eplb --> done
    no_eplb --> done

Проектные соображения и подводные камни

Почему для EPLB нужна независимая группа процессов?Комментарий даёт ответ: изолировать коммуникацию EPLB от коллективной коммуникации прямого прохода MoE, чтобы предотвратить взаимную блокировку между "torch.distributed во время выполнения" и "torch.distributed в EPLB"📎 vllm/distributed/parallel_state.py:2243-2246. Это типичный компромисс "независимый домен коммуникации в обмен на детерминированность" — дополнительный расход видеопамяти на одну PG в обмен на то, что прямой проход не зависнет при переносе весов.

Ограничение синхронизации группы DP— это самый частый подводный камень в производственной среде: все ранги внутри одной группы DP должны одновременно вызватьgenerate, иначе возникнет взаимоблокировка📎 vllm/distributed/parallel_state.py:2048-2051. Поскольку внутри группы DP выполняется all-reduce градиентов/результатов сэмплирования, отсутствие любого ранга приведёт к бессрочной блокировке коллективной коммуникации.

Порядок уничтоженияТакже имеет свои тонкости.destroy()Сначала уничтожается device communicator, затем device_group и cpu_group📎 vllm/distributed/parallel_state.py:1380-1393. В комментариях объясняется причина: device communicator может содержать рабочие области коллективных коммуникаций, зависящие от этих PG (например, FlashInfer PCIe IPC barrier), которые должны быть освобождены первыми📎 vllm/distributed/parallel_state.py:1377-1377。

8.2 Коммуникационные примитивы: как пользовательский all-reduce обходит NCCL

Интуитивная модель

All-reduce в NCCL — это «универсальный грузовик», способный перевезти любой груз по любой дороге, но с фиксированными накладными расходами на запуск и протокол. Когда вам нужно многократно выполнять all-reduce небольших тензоров на машине с 8 GPU, полностью соединёнными через NVLink (на каждом слое attention/MLP в TP), «плата за проезд» универсального грузовика становится недопустимо высокой. Пользовательский all-reduce — это «специализированная тележка»: он активируется только в сценариях с одной машиной, полносвязным NVLink и подходящим размером тензора, позволяя за один разcudaMemcpyустранить накладные расходы NCCL на рукопожатие и протокол.

Структуры данных и компоновка памяти

CustomAllreduceИнициализация представляет собой комбинацию «зондирования возможностей + предварительного выделения ресурсов». Ключевые поля:

  • _SUPPORTED_WORLD_SIZES = [2, 4, 6, 8, 16]: поддерживаются только эти размеры групп📎 vllm/distributed/device_communicators/custom_all_reduce.py:113-129。
  • meta_ptrs: буфер для синхронизации метаданных и промежуточных результатов, размерops.meta_size() + max_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:291-294。
  • buffer_ptrs: предварительно зарегистрированный IPC-буфер; в eager-режиме входной тензор сначала копируется сюда, затем вычисляется📎 vllm/distributed/device_communicators/custom_all_reduce.py:298-305。
  • rank_data: тензор uint8 размером 8 МБ, хранящий кортежи указателей IPC-буферов всех рангов📎 vllm/distributed/device_communicators/custom_all_reduce.py:309-315。

Почему буферы должны быть предварительно зарегистрированы?Поскольку захват CUDA Graph требует, чтобы все адреса были фиксированы на момент захвата.register_graph_buffersВ конце захвата транслировать адреса всех используемых буферов всем рангам и зарегистрировать их📎 vllm/distributed/device_communicators/custom_all_reduce.py:474-491。

Пошагово: поток принятия решений для одного all-reduce

Рассмотрим сценарий: выход некоторого слоя MLP внутри TP-группы требует all-reduce, вход — тензор bf16 размером 4 МБ.

Первый шаг,custom_all_reduceпроверить, отключено ли, выполнено ли условиеshould_custom_ar 📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-533。

Второй шаг,should_custom_arПоэлементная фильтрация: world_size > 8 — отклонить; dtype должен быть fp32/fp16/bf16; число байт должно быть кратно 16; должна быть слабая непрерывность; продолжить только если world_size==2 или полносвязная топология📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508。

Третий шаг — ветвление в зависимости от того, находимся ли мы в захвате CUDA Graph: при захвате используетсяregistered=True(адрес уже зафиксирован), иначеregistered=False(требуется сначала memcpy в предварительно зарегистрированный буфер)📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-545。

Четвёртый шаг — фактический вызовops.all_reduce, передачаbuffer_ptrs[rank]иmax_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:519-527。

mermaid
flowchart TD
    call["custom_all_reduce(input)"] --> disabled{"self.disabled?"}
    disabled -->|"да"| ret_none["return None → откат к NCCL"]
    disabled -->|"нет"| should{"should_custom_ar(input)?"}
    should -->|"нет"| ret_none
    should -->|"да"| capturing{"self._IS_CAPTURING?"}
    capturing -->|"да"| stream_cap{"is_current_stream_capturing()?"}
    stream_cap -->|"да"| reg["all_reduce(registered=True)"]
    stream_cap -->|"нет"| mimic["return empty_like(input) имитация выделения"]
    capturing -->|"нет"| eager["all_reduce(registered=False) сначала memcpy"]
    reg --> out["вернуть тензор out"]
    eager --> out

Размышления о дизайне и подводные камни

Путь деградации в многоузловых сценариях— это самая изящная часть данного кода.same_nodeКогдаmnnvl_onlyложно,📎 vllm/distributed/device_communicators/custom_all_reduce.py:198-199устанавливается в истину📎 vllm/distributed/device_communicators/custom_all_reduce.py:228-233。_group_can_attempt_mnnvl, затем проверяется поддержка MNNVL (Multi-Node NVLink). Если не каждая карта в группе поддерживает MNNVL, пользовательская коллективная коммуникация немедленно отключается📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73используется один CPU all-reduce (операция MIN), чтобы гарантировать, что все rank'и пойдут по одному и тому же потоку управления

— это ключевая защита в гетерогенных кластерах от зависания, вызванного тем, что "часть rank'ов идёт по пути MNNVL, а часть — через NCCL".:_can_p2pСтоимость проверки P2Pgpu_p2p_access_checkвыполнит обход всех peer'ов с📎 vllm/distributed/device_communicators/custom_all_reduce.py:278-278, в комментарии сказано, что первое вычисление очень дорогое, но кэшируетсяVLLM_SKIP_P2P_CHECK. Если в продакшене обнаружена медленная загрузка, можно установить📎 vllm/distributed/device_communicators/custom_all_reduce.py:86-100。

для пропуска и довериться отчёту драйвера о P2PТрёхуровневый выбор бэкенда для reduce-scatter_select_reduce_scatter_backendзаслуживает отдельного рассмотрения:mnnvl_multimem > mnnvl_lamport > legacy 📎 vllm/distributed/device_communicators/custom_all_reduce.py:601-636возвращает по приоритету(2,4,8). Путь multimem требует, чтобы world_size находился в📎 vllm/distributed/device_communicators/custom_all_reduce.py:103-104, а возможности устройства были (10,0) или (10,3) (уровень Blackwell)VLLM_BATCH_INVARIANT. Обратите внимание:📎 vllm/distributed/device_communicators/custom_all_reduce.py:628отключает путь multimem

— поскольку порядок редукции в multimem недетерминирован, что нарушает пакетную инвариантность.

8.3 EPLB: логика планирования ребалансировки нагрузки экспертов

Интуитивная модель

В модели MoE 256 логических экспертов распределены по 32 картам, по 8 на карту. Но при реальном трафике некоторые "популярные эксперты" (например, обрабатывающие распространённые синтаксические структуры) получают большое количество маршрутизированных токенов, из-за чего карта, на которой они находятся, становится узким местом, а остальные карты простаивают. EPLB (Expert Parallel Load Balancer) — это "добавление реплик для популярных экспертов": веса популярных экспертов копируются на свободные карты, чтобы токены распределялись и туда. Без него реальная пропускная способность MoE была бы ограничена самой медленной картой.

EplbModelStateСтруктуры данных и разметка памяти

  • physical_to_logical_mapиспользует три таблицы отображения для описания связи "логический эксперт ↔ физический эксперт":(num_moe_layers, num_physical_experts): форма📎 vllm/distributed/eplb/eplb_state.py:105-120。
  • logical_to_physical_map, каждый физический слот хранит id логического эксперта, который он несёт(num_moe_layers, num_logical_experts, max_replicas+1): форма📎 vllm/distributed/eplb/eplb_state.py:123-146。
  • logical_replica_count, разреженная матрица, -1 означает отсутствие отображения📎 vllm/distributed/eplb/eplb_state.py:147-161。

expert_load_window: сколько реплик у каждого логического эксперта(window_size, num_moe_layers, num_physical_experts) 📎 vllm/distributed/eplb/eplb_state.py:180-187— это скользящее окно, форма📎 vllm/distributed/eplb/eplb_state.py:180-187。

. В комментарии особо отмечено: теперь записывается нагрузка всех физических экспертов, а не только локальных, чтобы обеспечить согласованность статистики для разных методов dispatch (naive all-to-all, DeepEP); при naive all-to-all каждый DP rank вносит одинаковый набор токенов, и нагрузка умножается на dp_size

Пошагово: полная цепочка одной перестановкиexpert_rearrangement_stepСценарий:rearrange()。

достигает порога, запускаетсяscatter_add_Первый шаг — отобразить физическую нагрузку обратно на логических экспертов. С помощьюphysical_to_logical_mapагрегируется поinvalid_idx, недействительные слоты (<0) заполняются в📎 vllm/distributed/eplb/eplb_state.py:794-816。

корзину и в конце отбрасываются_allreduce_listВторой шаг — межранговый all-reduce для получения глобальной логической нагрузки.📎 vllm/distributed/eplb/eplb_state.py:1045-1068。

выполняет конкатенацию нагрузок нескольких моделей, затем один all-reduce и разбиение обратно, чтобы избежать множественных коммуникацийpolicy.rebalance_expertsТретий шаг — вызов стратегии для вычисления нового отображения.📎 vllm/distributed/eplb/eplb_state.py:859-867。

выполняется на host, поэтому окно нагрузки и текущее отображение нужно скопировать обратно на CPU📎 vllm/distributed/eplb/eplb_state.py:869-923Четвёртый шаг — специфичная для ROCm проверка "пропуска перестановки": если улучшение неравномерности нагрузки по rank'ам от нового отображения меньше 5%, перестановка пропускается

. Это прагматичная оптимизация — сама перестановка имеет коммуникационные затраты, и если выгода недостаточна, её не делают.📎 vllm/distributed/eplb/eplb_state.py:925-942。

mermaid
sequenceDiagram
    participant Main as Главный поток step()
    participant Policy as DefaultEplbPolicy
    participant Comm as EplbCommunicator
    participant Async as Поток async_worker
    Main->>Main: expert_rearrangement_step >= interval
    Main->>Main: scatter_add_ физическая нагрузка→логическая нагрузка
    Main->>Main: _allreduce_list агрегация между рангами
    Main->>Policy: rebalance_experts(load, replicas, groups, nodes, gpus, map)
    Policy-->>Main: new_physical_to_logical_map
    alt Синхронный режим
        Main->>Comm: rearrange_expert_weights_inplace()
        Comm-->>Main: Перемещение весов завершено
        Main->>Main: _commit_eplb_maps()
    else Асинхронный режим
        Main->>Main: eplb_stats = EplbStats(...); rebalanced = True
        Main->>Async: rearrange_event.record()
        Async->>Comm: Фоновое перемещение весов в expert_buffer
        Async-->>Main: pending_result готов
        Main->>Main: _move_to_workspace() фиксация
    end

копирование

Размышления о дизайне и подводные камниПримитивы синхронизации в асинхронном режимеrebalanced— самое тонкое место в этом коде.📎 vllm/distributed/eplb/eplb_state.py:194-203Флагrebalancedсинхронизируется через GIL между главным потоком и async worker'ом_all_ranks_result_ready. Но в комментарии предупреждают:📎 vllm/distributed/eplb/eplb_state.py:664-665。_all_ranks_result_readyдолжен быть согласован на всех rank'ах, иначе📎 vllm/distributed/eplb/eplb_state.py:1024-1043。

внутри all-reduce приведёт к зависанию:_should_record_current_stepПредпочтительно использовать CPU-группу для all-reduce, так как CPU-группа надёжнееwindow_sizeОптимизация "предварительной записи" скользящего окна📎 vllm/distributed/eplb/eplb_state.py:689-709включает запись только тогда, когда до следующей перестановки остаётся не болееstep_interval - window_sizeшагов📎 vllm/distributed/eplb/eplb_state.py:1196-1199。should_record_tensor. В комментарии объясняется: данные заfill_шагов перед каждым циклом перестановки будут перезаписаны скользящим окном, записывать их бессмысленно — это пустая трата вычислений GPU📎 vllm/distributed/eplb/eplb_state.py:272-278。

— это один и тот же скалярный тензор, общий для всех слоёв, одно:enable_elastic_epобновляет все слоиphysical_expert_capacityРезервирование ёмкости для эластичного EPelastic_ep_max_dp_sizeпри📎 vllm/distributed/eplb/eplb_state.py:375-386резервируется поreconfigure_physical_expert_slots, таблица отображения заполняет лишние слоты значением -1📎 vllm/distributed/eplb/eplb_state.py:1135-1160。

_commit_eplb_maps. Таким образом, при масштабировании не нужно перераспределять видеопамять — достаточно заполнить слоты -1 реальными экспертами.отвечает за обновление представления при масштабировании вверх/внизPIN_MEMORYОбработка pin memory вnon_blocking=True: когда📎 vllm/distributed/eplb/eplb_state.py:1392-1400включён и источник находится на CPU, сначала выполняется копирование в pinned-память, затем

асинхронное копирование на GPU

Три блока кода разделяют одну философию проектирования:Обмен обнаружения возможностей на детерминированную деградацию。GroupCoordinatorПриworld_size == 1напрямую обходить все коллективные коммуникации📎 vllm/distributed/parallel_state.py:736-738;CustomAllreduceпри невыполнении любого условия возвращатьNoneпозволяя вызывающей стороне откатиться на NCCL📎 vllm/distributed/device_communicators/custom_all_reduce.py:532-533; EPLB пропускает перебалансировку, если улучшение менее 5%📎 vllm/distributed/eplb/eplb_state.py:916. Этот паттерн «быстрый отказ + изящная деградация» позволяет одному и тому же коду работать на всём спектре оборудования — от одной карты до многоузлового MNNVL — без необходимости писать ветвления для каждой конфигурации.

Ещё одна общая черта —согласованность потока управления важнее производительности。_group_can_attempt_mnnvlИспользование CPU all-reduce для принудительного направления всех rank по одной ветке📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73,_all_ranks_result_readyАналогично📎 vllm/distributed/eplb/eplb_state.py:1024-1043. В распределённых системах ситуация «часть rank пошла по быстрому пути, часть — по медленному» гораздо опаснее, чем «все rank пошли по медленному пути» — первая приводит к зависанию, вторая лишь к замедлению.

Итоги главы

  • GroupCoordinatorПреобразование одномерной последовательности rank вExternalDP x DP x PP x PCP x TPсетку, с разбиением по измерениям на группы процессов TP/PP/DP/EP/EPLB; каждая группа одновременно поддерживает два PG: CPU (gloo) и device (NCCL).
  • CustomAllreduceЧерез обнаружение возможностей (один узел, полносвязный NVLink, размер тензора, dtype, выравнивание на 16 байт) определяется, брать ли на себя all-reduce; в многоузловых сценариях происходит деградация до MNNVL или NCCL.
  • EPLB использует три таблицы отображения для описания связей логических/физических экспертов, подсчитывает нагрузку через скользящее окно, вычисляет новое отображение стратегией, коммуникатор перемещает веса, поддерживаются синхронный и асинхронный режимы.
  • Общий принцип проектирования всех трёх: обнаружение возможностей + детерминированная деградация + приоритет согласованности потока управления.

Вопросы для размышления и самопроверки к главе

Q1: GroupCoordinator.destroy()Сначала уничтожается device communicator, затем process group📎 vllm/distributed/parallel_state.py:1380-1393. Если поменять порядок — сначала уничтожить PG, а потом communicator — в каком сценарии произойдёт крах?

Разбор ответа: В комментарии явно указано, что device communicator может удерживать рабочие области коллективных коммуникаций, зависящие от этих PG, например FlashInfer PCIe IPC barrier📎 vllm/distributed/parallel_state.py:1377-1377. Если сначала уничтожить PG, то внутри communicatordestroy()при необходимости выполнить barrier или очистить коммуникации с использованием этих PG произойдёт обращение к уже уничтоженному ProcessGroup, что вызовет use-after-free или сбой внутренней проверки NCCL. Правильный порядок — «зависимый умирает первым»: communicator зависит от PG, поэтому communicator уничтожается первым.

Q2: should_custom_arТребуетсяinp_size % 16 == 0 📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508. Если убрать эту проверку, что произойдёт с bf16-тензором размером 15 байт (например, 7,5 элемента — на практике невозможно, но предположим граничный случай 8 элементов = 16 байт)? Почему пользовательскому kernel нужно это выравнивание?

Разбор ответа: Пользовательский all-reduce kernel внутри использует векторизованную загрузку (например, 128-битную), требующую выравнивания адреса и размера на 16 байт, чтобы применять широкие инструкции загрузки вродеfloat4. Невыравненность приведёт к выходу kernel за границы чтения или вызовет исключение misaligned address. Что ещё более скрыто —buffer_ptrsпредварительно зарегистрированный буфер выделяется поmax_sizeесли размер входных данных не кратен 16, после копирования в буфер в хвосте могут остаться остаточные данные, которые будут включены в редукцию, породив тихую ошибку. Поэтому эта проверка — одновременно и защита корректности, и предпосылка производительности.

Q3: В асинхронном режиме EPLBrebalancedфлаг зависит от синхронизации через GIL📎 vllm/distributed/eplb/eplb_state.py:194-203, и в комментарии предупреждается, что все rank должны сохранять согласованность, иначе all-reduce зависнет📎 vllm/distributed/eplb/eplb_state.py:664-665. Предположим, что на некотором rank из-за сетевых колебаний async worker досрочно установилrebalancedв False, тогда как остальные rank всё ещё имеют True,_all_ranks_result_readyчто произойдёт?

Разбор ответа:_all_ranks_result_readyДляhas_resultвыполняется all-reduce суммирование, затем проверяется равенство размеру группы📎 vllm/distributed/eplb/eplb_state.py:1030-1032. Если на некотором rankrebalancedдосрочно стало False, егоpending_resultвозможно уже израсходован,has_resultравно 0, из-за чего результат суммирования окажется меньше размера группы, и остальные rank будут бесконечно ждать. Хуже того, если этот rank уже вышел изwhile ms.rebalancedцикла, он больше не будет участвовать в последующих all-reduce, и all-reduce остальных rank заблокируется навсегда — это и есть описанное в комментарии «hang at collective communication calls». Средства защиты —_all_ranks_result_readyиспользовать CPU-группу вместо device-группы, иdrain_asyncперед перебалансировкой явно опустошать все pending result📎 vllm/distributed/eplb/eplb_state.py:985-1022。

Итак, мы разобрались с механизмами создания групп, разделения и перебалансировки нагрузки для межкарточного взаимодействия. Однако проблемы коммуникации в распределённом инференсе не ограничиваются одним экземпляром — когда prefill и decode разделены на разные экземпляры, KV Cache необходимо передавать между узлами. В следующей главе мы покинем «межкарточное взаимодействие» и перейдём к «межэкземплярному взаимодействию»: как KV Cache передаётся между экземплярами prefill и decode при раздельном развёртывании, и как абстракция KV Connector унифицирует транспортные бэкенды, такие как NIXL и Mooncake.

CHAPTER 09

Глава 9: Реализация спекулятивного декодирования: параллельная верификация кандидатов

Проект: vllm-project/vllm · Прогресс книги: Глава 9 / 14 · Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы сосредоточились на внутреннем устройстве одного экземпляра инференса: как формируются группы процессов TP/PP/DP/EP, как тензоры разделяются между картами, как EPLB выполняет перебалансировку экспертов на уровне MoE. Но все эти механизмы основаны на одном предположении — prefill и decode работают в одном экземпляре, а KV Cache от начала до конца находится в локальной видеопамяти. Раздельное развёртывание (Prefill-Decode Disaggregation, сокращённо PD-разделение) разрушает это предположение. Оно разделяет prefill и decode на два независимых экземпляра vLLM: экземпляр prefill выполняет только прямой проход по промпту, создаёт KV Cache и передаёт его экземпляру decode; экземпляр decode использует этот KV Cache для продолжения авторегрессионной генерации. Преимущество такого подхода в том, что ресурсы можно независимо конфигурировать в соответствии с характеристиками этапов — prefill является вычислительно-интенсивным, подходит для большого TP и большого batch; decode является интенсивным по доступу к памяти, подходит для малого batch и планирования с низкой задержкой. Они больше не мешают друг другу. Цена этого — необходимость передачи KV Cache между экземплярами. Это и есть главный герой данной главы — KV Connector. Комментарий в заголовке файла vllm/distributed/kv_transfer/kv_connector/v1/base.py уже перечисляет основные примитивы всей абстракции: сторона Scheduler отвечает за привязку метаданных, запрос попаданий в удалённый кэш и решение об асинхронном освобождении блоков; сторона Worker отвечает за фактическую загрузку и сохранение KV. Цель проектирования этого интерфейса — полностью развязать логику планирования верхнего уровня и транспортные бэкенды нижнего уровня (NIXL, Mooncake, MoRIIO). С инженерной точки зрения наибольший риск PD-разделения — не медленная передача, а несогласованность состояний: экземпляр prefill считает, что KV уже отправлен, а экземпляр decode его не получил; или экземпляр decode освободил блок раньше времени, а prefill всё ещё пишет в него. В данной главе мы выясним, как именно эта система коннекторов использует протокол рукопожатия, аренду (lease), heartbeat и механизмы восстановления после сбоев, чтобы закрыть эти граничные случаи.

I. KVConnectorBase_V1: абстракция двух ролей и контракт метаданных

Интуитивная модель

KV Connector похож на курьерскую систему между двумя филиалами. Магазин Prefill подготовил полуфабрикат (KV Cache), упаковал и отправил его в магазин Decode для дальнейшей обработки. Но курьерская система не может состоять только из действия «отправить» — ей нужна транспортная накладная (metadata), описывающая, что и куда отправляется; нужен механизм подтверждения получения; а также набор правил тайм-аута, чтобы посылка не застряла навсегда в пути, занимая полку.

Без этой абстракции каждому транспортному бэкенду (NIXL, Mooncake) пришлось бы самостоятельно реализовывать логику планирования, а Scheduler в vLLM должен был бы писать код адаптации для каждого бэкенда. Ценность KVConnectorBase_V1 в том, что он фиксирует этот контракт.

Две роли: сторона Scheduler и сторона Worker

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:137-142определяет две роли коннектора:

python
class KVConnectorRole(enum.Enum):
    # Connector running in the scheduler process
    SCHEDULER = 0
    # Connector running in the worker process
    WORKER = 1

Это разделение не случайно. Процесс Scheduler отвечает за глобальные решения планирования — какие запросы требуют передачи, когда можно освободить блок; процесс Worker отвечает за фактическое перемещение данных. Они взаимодействуют черезKVConnectorMetadata.

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:153-158определяет базовый класс метаданных для направления Scheduler → Worker:

python
class KVConnectorMetadata(ABC):  # noqa: B024
    """Abstract Metadata used to communicate
    Scheduler KVConnector -> Worker KVConnector.
    """
    pass

Обратное направление Worker → Scheduler,📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:161-176определяетKVConnectorWorkerMetadata, который требует реализации методаaggregate— потому что в одном шаге движка несколько worker'ов могут возвращать свои метаданные, и их нужно агрегировать перед передачей Scheduler'у.

Ключевая структура данных: KVConnectorTransferResults

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:87-96определяет структуру снимка результатов передачи:

python
@dataclass
class KVConnectorTransferResults:
    finished_sending: set[str] = field(default_factory=set)
    finished_recving: set[str] = field(default_factory=set)
    failed_recving: set[str] = field(default_factory=set)

Обратите внимание на ключевые проектные решения в комментариях:Неудачные приёмы также появляются вfinished_recving. Это делается для того, чтобы Scheduler мог освободить запрос из состояния "ожидание передачи" — даже если передача не удалась, запрос не должен зависнуть навсегда. Информация об ошибке передаётся отдельно черезfailed_recving, и Scheduler на её основе решает, повторить попытку или выполнить деградацию.

Хуки жизненного цикла: от запроса до освобождения

Весь жизненный цикл коннектора строится вокруг нескольких ключевых хуков. На стороне Scheduler:

  • get_num_new_matched_tokens 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:485-518: запрашивает, сколько токенов может попасть в удалённый кэш. В комментарии особо подчёркивается, что "следует учитывать только фактически доступный максимальный префикс" — если некоторые токены недоступны из-за проблем с соединением или вытеснения, их нельзя засчитывать.
  • update_state_after_alloc 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:520-544: обновляет состояние после выделения block. В комментарии есть легко упускаемая ловушка — решение о необходимости загрузки должно основываться наnum_external_tokens, а не на том, пуст лиblocks, потому что невыбранные подконнекторы MultiConnector также получают реальные block.
  • request_finished 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:579-598: вызывается при завершении запроса, возвращаетTrue, что означает, что коннектор берёт на себя ответственность за асинхронное освобождение block.

На стороне Worker:

  • start_load_kv / wait_for_layer_load: послойная загрузка с поддержкой конвейеризации.
  • save_kv_layer / wait_for_save: послойное сохранение.
  • get_transfer_results 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:396-397: возвращает статус завершения асинхронной передачи.

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:192-201Есть ещё один легко упускаемый, но критически важный дизайн —requires_kv_deliveryсвойство:

python
@property
def requires_kv_delivery(self) -> bool:
    """Whether this connector hands off KV that must be reliably delivered.
    ...
    """
    return self._kv_transfer_config.is_kv_producer

В комментарии объясняется мотивация: если запрос вытесняется до завершения передачи KV, его следует пересчитать, а не позволять ему завершиться и передать уже освобождённые вытеснением block. Только роль producer требует надёжной доставки; потеря best-effort кэша — это всего лишь будущий cache miss.

Метаданные рукопожатия

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:145-150определяет базовый класс метаданных рукопожатия:

python
class KVConnectorHandshakeMetadata(ABC):  # noqa: B024
    """Metadata used for out of band connector handshake between
    P/D workers. This needs to serializable.
    """
    pass

"Out of band" означает, что рукопожатие идёт не по обычному пути запроса, а через прямое взаимодействие между P/D worker. Это закладывает основу для протокола рукопожатия ZMQ в NIXL.

---

II. Коннектор NIXL: рукопожатие, регистрация и построение дескрипторов

Интуитивная модель

NIXL (NVIDIA Inference Xfer Library) — это низкоуровневая библиотека передачи от NVIDIA, поддерживающая UCX, GDS и другие бэкенды. Роль NixlBaseConnectorWorker подобна сортировочному центру курьерской компании — сначала нужно установить выделенную линию с сортировочным центром партнёра (рукопожатие), зарегистрировать layout своих стеллажей (зарегистрировать области памяти KV Cache), и только затем можно эффективно забирать и отправлять грузы по адресам.

Без этого механизма при каждой передаче пришлось бы заново согласовывать адреса и устанавливать соединения, а задержка стала бы неприемлемо высокой.

Разметка памяти: Region и Descriptor

Ключевые концепции NIXL —region(область памяти) иdescriptor(дескриптор). Каждый слой KV Cache регистрируется в NIXL как одна или несколько region, каждая region имеет базовый адрес, длину блока и шаг блока.

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:740-751перечисляет ключевые поля, связанные с region:

python
# Number of NIXL regions. Currently one region per cache
# (so 1 per layer for MLA, otherwise 2 per layer)
self.num_regions = 0
self.region_mem_types: list[str] = []
self.region_group_ids: list[int] = []
self._uses_region_group_mapping = False
self.region_names: list[str] = []
self.region_num_blocks: list[int] = []
self._mixed_mem_types = False

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:897-900далее поясняет источник шага блока:

python
# Per-region block stride in bytes. Taken from the registered tensor's
# stride(0) so it stays correct under layouts that interleave layers
# within a block (BLHNC/BHLNC), where stride > block_len.
self.block_stride_per_layer = list[int]()

Ключевое наблюдение здесь:block_stride не равен block_len. При таких чередующихся по слоям layout, как BLHNC/BHLNC, фактический шаг одного block может быть больше длины его полезных данных. Если напрямую использовать block_len в качестве шага, адреса будут читаться неправильно.

Протокол рукопожатия: ZMQ + хэш совместимости

Рукопожатие — самая сложная часть коннектора NIXL.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:974-1128Метод_nixl_handshakeв

полностью демонстрирует этот процесс.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:988-998Первый шаг — установка контекста устройства CUDA. Комментарий в

python
# the first time we connect to a remote agent.
# be careful, the handshake happens in a background thread.
# it does not have an active cuda context until any cuda runtime
# call is made. when UCX fails to find a valid cuda context, it will
# disable any cuda ipc communication, essentially disabling any NVLink
# communication.
if not self.use_host_buffer:
    current_platform.set_device(self.device_id)

Копировать

Это очень скрытая ловушка: рукопожатие выполняется в фоновом потоке, и если явно не установить устройство, UCX не найдёт действительный контекст CUDA и молча отключит связь NVLink, деградировав до медленного пути.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1029-1036:

python
msg = msgspec.msgpack.encode(
    (GET_META_MSG, remote_pp_rank, remote_rank)
)
# Set receive timeout to 5 seconds to avoid hanging on dead server
sock.setsockopt(zmq.RCVTIMEO, 5000)  # milliseconds
start_time = time.perf_counter()
sock.send(msg)
reply_parts = sock.recv_multipart()

Копировать📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1042-1045Тайм-аут в 5 секунд предотвращает бесконечное ожидание после смерти удалённой стороны. Одновременно код использует RTT для оценки смещения часов

, сохраняя минимальный образец RTT — потому что высокий RTT — это лишь шум, искажающий оценку средней точки.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1063-1080:

python
assert self.compat_hash is not None
if (
    self.enforce_compat_hash
    and handshake_payload.compatibility_hash != self.compat_hash
):
    raise RuntimeError(
        f"NIXL compatibility hash mismatch. "
        ...
    )

Копировать📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1372-1376Хэш совместимости вычисляется в

python
self.compat_hash = compute_nixl_compatibility_hash(
    self.vllm_config,
    self.backend_name,
    transfer_mode=self._TRANSFER_MODE,
)

Копироватьtransfer_modeОбратите внимание, что📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:163-166также участвует в хэше — комментарий в

поясняет: push (WRITE) коннектор и pull (READ) коннектор никогда не должны успешно выполнить рукопожатие.

Асинхронное планирование рукопожатия📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:824-835:

python
self._handshake_initiation_executor = ThreadPoolExecutor(
    # NIXL is not guaranteed to be thread-safe, limit 1 worker.
    max_workers=1,
    thread_name_prefix="vllm-nixl-handshake-initiator",
)
self._ready_requests = queue.Queue[tuple[ReqId, ReqMeta]]()
self._handshake_futures: dict[
    EngineId, Future[tuple[dict[tuple[int, int], str], float]]
] = {}
# Protects _handshake_futures and _remote_agents.
self._handshake_lock = threading.RLock()

max_workers=1Копировать_handshake_lock— потому что NIXL не гарантирует потокобезопасность._handshake_futuresзащищает_remote_agentsи

_ensure_handshake 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1257-1317— два словаря.

реализует идемпотентную инициацию рукопожатия: если рукопожатие уже успешно завершено, сразу возвращается None; если рукопожатие выполняется, возвращается существующий Future; иначе отправляется новая задача и регистрируется callback.

Построение дескрипторов: от block ID к NIXL descriptor📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:172-310После завершения рукопожатия необходимо построить дескрипторы для каждого запроса._compute_desc_idsМетод

в📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:226-262— ядро этого процесса.

python
# NOTE (NickLucche) With HMA, every kv group has the same number of layers
# and layers from different groups share the same kv tensor.
# eg block_ids=[[1, 2], [3]]->blocks [1, 2] need to be
# read across all regions, same for [3], but group0-group1 blocks will
# always differ (different areas). Therefore we can just flatten the
# block_ids and compute the descs ids for all groups at once.

. Комментарий объясняет обработку в сценарии HMA:📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:285-304:

python
elif _is_ssm_spec(spec_type):
    # NOTE (NickLucche) SSM and Attention block regions can
    # be exchanged arbitrarily by manager.  Therefore, descs
    # are laid out as:
    #   [descs_fa (all regions) | descs_ssm (all regions)].
    # num_fa_descs offset must be computed per-engine since
    # P and D can have different num_blocks (and thus
    # different FA desc counts).

Для гибридных SSM-моделей layout дескрипторов сложнее

Копировать📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2130-2178Топология передачи и отображение TPadd_remote_agentГетерогенный TP — самый сложный сценарий коннектора NIXL.

Когда D.world_size > P.world_size, несколько D worker читают разные фрагменты KV head из одного и того же P worker. В документации приведён конкретный пример: D TP=4, P TP=2, tp_ratio=2. D-Worker0 читает первую половину KV head из P-Worker0, D-Worker1 читает вторую половину.

Для моделей MLA KV Cache реплицируется между TP worker, поэтому rank_offset всегда равен 0.

Аренда и heartbeat: предотвращение преждевременного освобождения block

Это один из самых изящных элементов дизайна NIXL-коннектора. После отправки KV экземпляр Prefill не может немедленно освободить block — потому что экземпляр decode может всё ещё читать. Но если никогда не освобождать, видеопамять будет утекать.

Решение — аренда (lease).📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:528-528:

python
kv_lease_duration: int = vllm_config.kv_transfer_config.get_from_extra_config(
    "kv_lease_duration", 30
)
# NOTE (NickLucche): For now we use a hardcoded value for a simpler interface.
self._lease_extension = kv_lease_duration * 2 // 3

По умолчанию аренда составляет 30 секунд, каждый heartbeat продлевает её на 20 секунд (2/3).

Обработка heartbeat находится в📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034:

python
def _handle_heartbeat(self, payload: str) -> None:
    new_expiry = time.perf_counter() + self._lease_extension
    for req_id in payload.split(","):
        if req_id in self._reqs_to_send:
            old = self._reqs_to_send[req_id]
            self._reqs_to_send[req_id] = max(old, new_expiry)

Обратите вниманиеmax(old, new_expiry)— heartbeat может только продлить аренду, но не сократить её.

Освобождение после истечения аренды находится в📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2986-3012:

python
def _reap_expired_send_leases(self, done_sending: set[str]) -> None:
    """Reclaim expired send-side KV leases into ``done_sending``.

    ``_reqs_to_send`` is not ordered by expiry: heartbeats update the
    deadline in place, and mixed TTLs share the map, so a live head
    entry can sit in front of already-expired ones. Scan every entry
    rather than stopping at the first still-live request.
    """

Комментарий указывает на легко допускаемую ошибку: нельзя останавливать сканирование при обнаружении первого непросроченного запроса, потому что heartbeat обновляет время истечения на месте, из-за чего map не отсортирован по времени истечения.

Конечный автомат передачи и восстановление после сбоев

Жизненный цикл передачи управляется через_pop_done_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3036-3086:

python
for handle in handles:
    try:
        xfer_state = self.nixl_wrapper.check_xfer_state(handle)
        if xfer_state == "DONE":
            res = self.nixl_wrapper.get_xfer_telemetry(handle)
            self.xfer_stats.record_transfer(res)
            self.nixl_wrapper.release_xfer_handle(handle)
        elif xfer_state == "PROC":
            in_progress.append(handle)
        else:
            self._log_failure(
                failure_type="transfer_failed",
                req_id=req_id,
                xfer_state=xfer_state,
            )

Передача NIXL имеет три состояния:DONE(завершено),PROC(выполняется), другие (ошибка).

Обработка ошибок находится в📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3103-3127:

python
def _handle_failed_transfer(
    self,
    req_id: str,
    handle: int | None,
    failed_req_ids: set[str] | None = None,
    record_failed_transfer: bool = True,
) -> bool:
    if record_failed_transfer:
        self.xfer_stats.record_failed_transfer()
    if failed_req_ids is not None:
        failed_req_ids.add(req_id)
    return handle is None or self._try_release_xfer_handle(req_id, handle)

_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101Комментарий в критически важен:

python
except Exception as e:
    # A status error does not guarantee that the backend stopped DMA.
    self._log_failure(
        failure_type="transfer_release_failed",
        msg="Retaining handle and blocks until release succeeds",
        ...
    )
    return False

Ошибка состояния не гарантирует, что бэкенд остановил DMA. Если освобождение не удалось, необходимо сохранить handle и block до успешного освобождения. Это типичный дизайн в духе «лучше утечка, чем ошибка».

Обработка block для неудачных запросов

При неудачном приёме,📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2876-2891демонстрирует логику обработки:

python
for req_id in done_recving:
    meta = self._recving_metadata.pop(req_id, None)
    assert meta is not None, f"{req_id} not found in recving_metadata list"

    # Skip KV sync and post-processing for failed requests
    if req_id in failed_recv_reqs:
        self._pending_recv_notifs.pop(req_id, None)
        # TODO (NickLucche) handle failed transfer for HMA.
        if not self._is_hma_required:
            self._invalid_block_ids.put(set(meta.local_block_ids[0]))
        logger.warning(
            "Skipping KV post-processing for failed request %s",
            req_id,
        )
        continue

ID неудачного block помещается в очередь_invalid_block_ids, Scheduler извлекает его черезget_block_ids_with_load_errors 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3491-3504и решает, повторять ли попытку.

TTL-вытеснение удалённых движков

Долго работающие экземпляры постоянно сталкиваются с новыми удалёнными движками; без очистки память будет бесконечно расти.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3506-3532В_evict_stale_enginesреализовано TTL-вытеснение:

python
def _evict_stale_engines(self) -> None:
    """Scan for and evict remote engines that have exceeded their TTL.

    Called from the main thread in when a new remote engine appears.
    We can only go OOM as we discover and register a new remote, therefore we make
    sure we clean up stale engine data structures before then.
    """
    if self._engine_ttl <= 0:
        return

    now = time.perf_counter()
    busy = self._engines_with_inflight_transfers()
    for eid, last_active in list(self._engine_last_active.items()):
        if now - last_active > self._engine_ttl and eid not in busy:
            self._cleanup_remote_engine(eid)

Ключевое ограничение —busyмножество — движки с активными передачами не могут быть вытеснены.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546Комментарий в объясняет причину:

python
"""Remote engines a transfer is still reading from.

The timestamp is stamped when a read is issued and not refreshed while
it runs, so a transfer that outlives the TTL leaves its engine looking
idle. A peer that has lost its NIC holds one indefinitely.
"""

Если сетевая карта на противоположной стороне сломана, передача может зависнуть навсегда, временная метка не будет обновляться, и движок будет выглядеть простаивающим.busyМножество явно защищает эту ситуацию.

Тайминг рукопожатия и передачи

Приведённая ниже диаграмма последовательности показывает ключевое взаимодействие от запроса до завершения передачи:

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Worker as NixlWorker
    participant BgThread as Фоновый поток рукопожатия
    participant Remote as Удалённый NIXL Agent

    Sched->>Worker: build_connector_meta()
    Worker->>Worker: _ensure_handshake(engine_id)
    alt Рукопожатие выполнено
        Worker->>Worker: Немедленный возврат None
    else Рукопожатие в процессе
        Worker->>BgThread: Вернуть существующий Future
    else Новое рукопожатие
        Worker->>BgThread: submit(_nixl_handshake)
        BgThread->>Remote: ZMQ GET_META_MSG
        Remote-->>BgThread: NixlHandshakePayload
        BgThread->>BgThread: Проверка compat_hash
        BgThread->>Remote: add_remote_agent()
        BgThread-->>Worker: done_callback регистрирует _remote_agents
    end
    Worker->>Remote: prep_xfer_dlist + make_xfer_req
    Worker->>Worker: _recving_transfers[req_id] = handles
    Sched->>Worker: get_transfer_results()
    Worker->>Worker: _pop_done_transfers()
    alt xfer_state == DONE
        Worker->>Remote: release_xfer_handle
        Worker-->>Sched: finished_recving
    else xfer_state == PROC
        Worker->>Worker: Сохранить handle до следующего раунда
    else Ошибка
        Worker->>Worker: _handle_failed_transfer
        Worker-->>Sched: failed_recving + invalid_block_ids
    end

---

III. Размышления о дизайне: почему сделано именно так

Почему рукопожатие асинхронное?

Рукопожатие включает сетевой round-trip и может занимать десятки миллисекунд. При синхронном выполнении оно заблокирует основной цикл Scheduler, повлияв на планирование всех запросов. Асинхронное рукопожатие позволяет Scheduler сначала обработать другие запросы, а по завершении рукопожатия уведомить через callback.

Но асинхронность также приносит сложность:_handshake_futuresсловарь требует защиты блокировкой, в callback нужно обрабатывать как успех, так и неудачу, а также предотвращать повторные рукопожатия.

Почему используется аренда, а не подсчёт ссылок?

Подсчёт ссылок требует, чтобы экземпляр decode явно уведомил prefill «я закончил чтение». Но если экземпляр decode упадёт, уведомление никогда не придёт, и block в prefill будет утекать навсегда.

Аренда — более надёжное решение: даже если decode упадёт, prefill автоматически освободит ресурсы по истечении аренды. Механизм heartbeat обеспечивает продление аренды в нормальных условиях.

Почему при ошибке сохраняется handle?

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101Комментарий в ясно говорит: ошибка состояния не гарантирует остановку DMA. Если в этот момент освободить handle, DMA может всё ещё записывать данные в освобождённую память, что приведёт к повреждению данных или краху. Лучше временная утечка, чем этот риск.

Почему TTL-вытеснение проверяет busy?

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546Комментарий в раскрывает скрытый сценарий бага: временная метка ставится при инициации чтения и не обновляется во время чтения. Если передача длится дольше TTL, движок выглядит простаивающим, но фактически всё ещё читается. Если в этот момент выполнить вытеснение, активная передача завершится ошибкой.

Подводные камни в production

1. Проблема с контекстом CUDA: рукопожатие выполняется в фоновом потоке, необходимо явноset_device, иначе UCX молча отключит NVLink.

2. Несовпадение хеша совместимости: версия vLLM, модель, dtype, KV layout, attention backend экземпляров P/D должны полностью совпадать. При несовпадении рукопожатие завершится ошибкой, сообщение об ошибке подскажет, как отключить проверку (но это не рекомендуется).

3. Истечение аренды: если экземпляр decode сильно загружен, heartbeat может задерживаться, что приведёт к истечению аренды. В логах появится предупреждение "Releasing expired KV blocks". Можно увеличитьkv_lease_duration。

4. Несовпадение TP: гетерогенный TP требует block-contiguous layout (например, LBHNC). При использовании неконтинуального layout гетерогенный TP завершится ошибкой.

5. Исчерпание NIXL UAR:📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:631-636предупреждение в комментариях: каждый поток UCX выделяет UAR (doorbell pages) через DevX, чрезмерное использование NIXL UAR исчерпает пространство UAR NIC, что приведёт к сбою NVSHMEM (используемого ядром DeepEP) при инициализации RDMA.

---

Резюме главы

В этой главе подробно рассмотрены ключевые механизмы системы KV Connector:

1. KVConnectorBase_V1определена двусторонняя абстракция ролей на стороне Scheduler и на стороне Worker, черезKVConnectorMetadataиKVConnectorTransferResultsреализован обмен метаданными и обратная связь о результатах передачи.

2. Коннектор NIXLявляется наиболее зрелой реализацией: он устанавливает соединение между экземплярами P/D через протокол рукопожатия ZMQ, использует хеш совместимости для предотвращения несоответствия конфигураций и применяет асинхронный пул потоков для избежания блокировки основного цикла.

3. Механизм аренды и heartbeatрешает проблему временной последовательности освобождения block: prefill после отправки KV не освобождает его немедленно, а ожидает продления heartbeat от decode или истечения аренды.

4. Восстановление после сбоевследует принципу «лучше утечка, чем неправильное использование»: при неудачном освобождении handle сохраняется, а ID неудачного block передаётся Scheduler для принятия решения о повторной попытке.

5. Вытеснение по TTLпредотвращает неограниченный рост состояния удалённых движков при длительной работе, но должно защищать движки с активными передачами.

В следующей главе мы перейдём к другому направлению устранения накладных расходов: ускорение компиляции и CUDA Graph. Когда разделение PD решило проблему использования ресурсов, накладные расходы на запуск одного прямого прохода стали новым узким местом — как с помощью CUDA Graph сжать запуск сотен и тысяч ядер в одно воспроизведение.

Вопросы для размышления и самопроверки к этой главе

Q1: Если убрать обработку исключений в_try_release_xfer_handleи напрямую вызватьrelease_xfer_handle, в каких сценариях это приведёт к повреждению данных? Почему?

Справочный анализ:_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101В комментариях к явно указано: "A status error does not guarantee that the backend stopped DMA." Если убрать обработку исключений, то когдаrelease_xfer_handleвыбрасывает исключение, вызывающая сторона будет считать, что освобождение прошло успешно, и продолжит освобождать block. Но на самом деле DMA бэкенда NIXL может всё ещё выполняться, записывая данные в эту память. Как только block будет перераспределён для другого запроса, запись DMA загрязнит KV Cache нового запроса, что приведёт к искажению вывода или NaN. Хуже того, если block будет возвращён в пул видеопамяти и переиспользован другим тензором, DMA может записать по недопустимому адресу и вызвать сбой. Правильный подход — сохранить handle и block и повторить попытку освобождения в следующем раунде_pop_done_transfers.

Q2: _reap_expired_send_leasesВ комментариях к сказано: «нельзя останавливать сканирование при обнаружении первого непросроченного запроса». Если изменить на break при обнаружении непросроченного, в каких сценариях это вызовет утечку block?

Справочный анализ:_reqs_to_send— это обычный dict, а не приоритетная очередь, отсортированная по времени истечения. Обработка heartbeat_handle_heartbeat 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034обновляет время истечения на месте:self._reqs_to_send[req_id] = max(old, new_expiry)这意味着,先前添加的请求由于持续收到心跳而可能具有非常晚的过期时间,而排在其后的请求可能已经过期。如果在遇到第一个未过期请求时就 break,那么其后已经过期的请求将永远不会被回收,它们的 block 将持续占用显存。在长时间运行且请求模式混合的场景中(一些请求经常被心跳续期,而另一些请求的 decode 实例已经挂掉),这会累积成严重的显存泄漏。

Q3: _evict_stale_enginesиспользует_engines_with_inflight_transfersдля защиты движков с активными передачами. Если убрать эту защиту, в каких сценариях сетевых сбоев это приведёт к сбою передачи?

Справочный анализ:_engines_with_inflight_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546В комментариях к объясняется ключевой сценарий: "The timestamp is stamped when a read is issued and not refreshed while it runs, so a transfer that outlives the TTL leaves its engine looking idle. A peer that has lost its NIC holds one indefinitely." Предположим, что сетевая карта удалённой стороны отказала, и операция чтения NIXL зависла дольше TTL (по умолчанию 3600 секунд)._engine_last_activeВременная метка ставится при инициации чтения и не обновляется во время чтения, поэтому движок выглядит уже простаивающим. Если в этот момент_evict_stale_enginesвытеснит этот движок, будет вызван_cleanup_remote_engineдля освобожденияdst_xfer_side_handlesи удаления remote agent. Но выполняющийся DMA всё ещё использует эти ресурсы, и после освобождения это приведёт к сбою передачи или даже краху.busyНабор явно защищает этот случай, гарантируя, что движки с активными передачами не будут вытеснены.

К этому моменту мы уже увидели, как KV Connector устанавливает надёжный канал передачи данных между экземплярами prefill и decode, а также как он поддерживает согласованность состояния с помощью аренды, heartbeat и механизмов восстановления после сбоев. Но передача между экземплярами — лишь половина истории PD-разделения: когда KV Cache достигает экземпляра decode, движок вывода всё ещё должен эффективно выполнять каждый шаг прямого вычисления внутри одного экземпляра. А накладные расходы на планирование Python и запуск ядер — это следующее узкое место, ограничивающее задержку одного шага. В следующей главе мы перейдём к ускорению компиляции и CUDA Graph, чтобы увидеть, как vLLM устраняет эти накладные расходы с помощью torch.compile и piecewise backend, а также обеспечивает согласованное сосуществование CUDA Graph и динамических форм батчей.

CHAPTER 10

Глава 10: Квантизация и высокопроизводительные ядра: AWQ, GPTQ и FP8

Проект: vllm-project/vllm · Прогресс книги: Глава 10 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы видели, как KV Connector через такие коннекторы, как NIXL и Mooncake, эффективно перемещает KV cache между движками Prefill и Decode, позволяя разделённой архитектуре снижать TTFT и повышать utilisation ресурсов. Но даже если передача происходит очень быстро, в авторегрессионном декодировании остаются две фиксированные издержки, которые нельзя устранить алгоритмически: накладные расходы на планирование интерпретатора Python и накладные расходы на запуск ядер GPU. Когда прямой проход модели разбивается на сотни операторов, каждый оператор проходит через вызов функции Python и запуск ядра CUDA, и накладные расходы на стороне CPU足以 заставляют GPU простаивать между двумя вычислениями. В этой главе анализируется, как vLLM использует torch.compile для слияния операторов в статический граф, а затем CUDA Graph для записи всей последовательности запусков ядер в одно воспроизведение, сводя оба типа накладных расходов почти к нулю.

Кэш компиляции и слой адаптации компилятора: повторное использование результатов компиляции между процессами

Интуитивная модель

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

Структуры данных и контракты интерфейсов

CompilerInterfaceОпределяет абстрактный контракт адаптера компилятора, ядром которого являются четыре метода:initialize_cacheОтвечает за перенаправление собственного каталога кэша компилятора в каталог кэша vLLM📎 vllm/compilation/compiler_interface.py:36-51;compute_hashСобирает информацию о конфигурации, связанной с компилятором, для генерации хэша📎 vllm/compilation/compiler_interface.py:53-62;compileВыполняет компиляцию и возвращает вызываемый объект и дескриптор📎 vllm/compilation/compiler_interface.py:64-95;loadВосстанавливает артефакт компиляции из дескриптора📎 vllm/compilation/compiler_interface.py:97-103。

Ключевой дизайн здесь —compileвозвращает кортеж из двух элементов(callable, handle)。callable— это результат компиляции, напрямую вызываемый в текущем процессе;handle— это «учётные данные для восстановления при следующем запуске», и документация явно требует, чтобы это был «plain Python object, preferably a string or a file path»📎 vllm/compilation/compiler_interface.py:81-81. Это разделение позволяет пути попадания в кэш и пути первой компиляции идти по совершенно разному коду — при попадании вообще не нуженcompile, нужен толькоload。

compile_rangeПараметр несёт семантику динамических форм. Комментарий поясняет, что он «could be concrete size (if compile_sizes is provided), e.g. [4, 4] or a range [5, 8]», и «Right now we only support one variable in ranges for all inputs, which is the batchsize (number of tokens) during inference»📎 vllm/compilation/compiler_interface.py:74-74. Это ключевое ограничение стратегии компиляции vLLM: все динамические формы сводятся к одной переменной — числу токенов.

Сценарий: полный поток одного запроса на компиляцию

Предположим, сервис запускается впервые,InductorAdaptor.compileвызывается. Сначала он увеличивает счётчик компиляций📎 vllm/compilation/compiler_interface.py:477-489, затем входит в тщательно сконструированный стек патчей.

Первый шаг — глубокая копия графа. Комментарий указывает: «inductor can inplace modify the graph, so we need to copy it»📎 vllm/compilation/compiler_interface.py:500-502, это защитный дизайн — после неудачной компиляции исходный граф всё ещё можно использовать для повторной попытки.

Второй шаг — установка серии monkey-patch.hijacked_compile_fx_innerОборачивает внутреннюю функцию компиляции Inductor, после завершения компиляции извлекает хэш изinductor_compiled_graph._fx_graph_cache_keyизвлекает хэш📎 vllm/compilation/compiler_interface.py:512-536。hijack_compiled_fx_graph_hashперехватывает саму функцию вычисления хэша📎 vllm/compilation/compiler_interface.py:538-542. Зачем «перехватывать» хэш? Потому что vLLM нужно компилировать отдельно вне контекста трассировки Dynamo, а вычисление хэша Inductor зависит от этого контекста.

Третий шаг —_check_can_cacheпатч, он напрямую возвращает результат, не выполняя никаких проверок📎 vllm/compilation/compiler_interface.py:544-551. Комментарий объясняет мотивацию: "Inductor refuses to cache the graph outside of Dynamo tracing context, and also disables caching for graphs with high-order ops. For vLLM, in either case, we want to cache the graph"📎 vllm/compilation/compiler_interface.py:544-551。

Четвёртый шаг — очистка контекста трассировки. Это самое тонкое место: vLLM изPiecewiseCompileInterpreterвнутренне вызываетcompile_fx, в этот момент DynamoFakeTensorModeи входные данные подграфаFakeTensorModeне согласованы,detect_fake_mode()вызовет сбой утверждения📎 vllm/compilation/compiler_interface.py:615-622. Код сохраняетTracingContext, затем обнуляет его и регистрирует обратный вызов для восстановления при выходе📎 vllm/compilation/compiler_interface.py:623-630。

mermaid
flowchart TD
    start["InductorAdaptor.compile()"] --> deepcopy["copy.deepcopy(graph)"]
    deepcopy --> patch_stack["ExitStack установка патчей"]
    patch_stack --> p1["patch compiled_fx_graph_hash"]
    patch_stack --> p2["patch FxGraphCache._get_shape_env"]
    patch_stack --> p3["patch _check_can_cache"]
    patch_stack --> p4["Очистить TracingContext"]
    p4 --> call_fx["compile_fx(graph, example_inputs)"]
    call_fx --> check{"hash_str is None?"}
    check -->|"да"| err["RuntimeError: компиляция не удалась<br/>рекомендуется удалить torch_compile_cache"]
    check -->|"нет"| check2{"file_path is None?"}
    check2 -->|"да"| assert_err["AssertionError"]
    check2 -->|"нет"| ret["return (compiled_graph, (hash_str, file_path))"]
    err --> cleanup["Выход из ExitStack<br/>восстановление TracingContext"]
    assert_err --> cleanup
    ret --> cleanup

Проектные соображения: AlwaysHitShapeEnv и согласованность кэша

AlwaysHitShapeEnvЭтот класс заслуживает отдельного анализа. Его docstring прямо указывает мотивацию: vLLM выполняет компиляцию байт-кода Dynamo только один раз, но должен многократно запускать компиляцию Inductor с разными формами плюс одной универсальной формой; компиляция для конкретной формы происходит вне контекста Dynamo, в этот момент Inductor не предоставляется shape environment, что приводит к сбою поиска в кэше кода Inductor📎 vllm/compilation/compiler_interface.py:114-131。

Решение — предоставить фиктивное shape environment, которое "всегда попадает":evaluate_guards_expressionвсегда возвращаетTrue 📎 vllm/compilation/compiler_interface.py:144-145,get_pruned_guardsвозвращает пустой список📎 vllm/compilation/compiler_interface.py:144-145,produce_guards_expressionвозвращает пустую строку📎 vllm/compilation/compiler_interface.py:147-159. Комментарий честно признаёт, что эти методы "obtained by trial-and-error until it works"📎 vllm/compilation/compiler_interface.py:137-142— это хрупкое место, связанное с внутренней реализацией PyTorch, и наиболее подверженное проблемам при обновлении PyTorch.

Состав хэша кэша также критичен.get_inductor_factorsСобирает три категории факторов: состояние системыCacheBase.get_system(), состояние PyTorchtorch_key(), а также конфигурацию Inductor и functorch📎 vllm/compilation/compiler_interface.py:165-185. Обратите внимание, что конфигурация functorch собирается в контекстеpatch(_get_vllm_functorch_config()), это гарантирует, что "конфигурация во время компиляции и ключ кэша всегда согласованы" — комментарий явно указывает, что это делается для согласованности📎 vllm/compilation/compiler_interface.py:188-189иset_functorch_config()Обеспечить согласованностьget_inductor_factors(). Если эти два места не согласованы, возникнет рассогласование "при компиляции использовалась конфигурация A, а ключ кэша вычислен по конфигурации B", что приведёт к попаданию в кэш, но загрузке неправильного артефакта.📎 vllm/compilation/compiler_interface.py:147-159Производственные подводные камни:

это backport для torch < 2.10.0_patch_standalone_compile_atomic_save. Он изменяет📎 vllm/compilation/compiler_interface.py:205-243на использованиеCompiledArtifact.save()для записи в бинарном формате, комментарий поясняет цель: "preventing corrupt cache files when multiple processes compile concurrently"write_atomic. В сценарии одновременного холодного запуска нескольких реплик несколько процессов будут параллельно записывать один и тот же файл кэша; неатомарная запись создаст обрезанный файл, и последующие процессы, прочитав повреждённый артефакт, будут вести себя непредсказуемо.📎 vllm/compilation/compiler_interface.py:208-210PiecewiseBackend: компиляция по диапазонам форм и диспетчеризация во время выполнения

Интуитивная модель

это диспетчерский центр между компиляцией и выполнением. Он компилирует "один FX-подграф" в "вызываемые объекты для нескольких диапазонов форм" и во время выполнения выбирает наиболее подходящий из них на основе фактического числа токенов. Без него либо все формы шли бы через одну универсальную компиляцию (неоптимальная производительность), либо каждая форма компилировалась бы отдельно (взрыв времени компиляции).

PiecewiseBackendСтруктуры данных: RangeEntry и диапазон компиляции

Ключевая структура данных —

, она связывает флагRangeEntryиcompile_range、compiledвместеrunnableподдерживает📎 vllm/compilation/piecewise_backend.py:80-83。PiecewiseBackendПостроение диапазона компиляции состоит из двух шагов. Сначала обрабатываетсяrange_entries: dict[Range, RangeEntry] 📎 vllm/compilation/piecewise_backend.py:166-171。

(точные размеры), для каждого размера генерируетсяcompile_sizesодноточечный интервалRange(start=size, end=size). Обратите внимание, что здесь для строки📎 vllm/compilation/piecewise_backend.py:166-171сразу выбрасывается"cudagraph_capture_sizes", и поясняется "should be handled inNotImplementedError— это явное объявление границы ответственности. Затем обрабатываетсяpost_init_cudagraph_sizes" 📎 vllm/compilation/piecewise_backend.py:166-171(интервалы), для каждого интервала генерируется entrycompile_rangesподдерживает два взаимоисключающих режима, конструктор принудительно обеспечивает это через XOR-утверждение📎 vllm/compilation/piecewise_backend.py:173-173。

PiecewiseBackend: режим компиляции (есть graph, нет compiled_runnables) идёт через📎 vllm/compilation/piecewise_backend.py:117-119; режим предкомпиляции (нет graph, есть compiled_runnables) идёт черезcompile_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194. Этот дизайн позволяет холодному и горячему запуску использовать один и тот же класс, различаются только источники данных.load_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194Сценарии использования: от компиляции к диспетчеризации во время выполнения

Этап компиляции

обходит все range entry, для каждого нескомпилированного entry вызывает:compile_all_rangesзаписывает событие трассировки_log_compile_start. Ключевое ветвление — в построении аргументов: если это одноточечный размер, вызывается📎 vllm/compilation/piecewise_backend.py:252-256для генерации FakeTensor конкретной формыcreate_concrete_args; иначе вызывается📎 vllm/compilation/piecewise_backend.py:258-261для прямого переиспользования метаданных placeholder из графаget_fake_args_from_graphреализация раскрывает детали конкретизации символьных форм. Она создаёт📎 vllm/compilation/piecewise_backend.py:262-263。

create_concrete_argsсShapeEnv, затем обходит узлы placeholder. Для входов типаFakeTensorMode 📎 vllm/compilation/piecewise_backend.py:54используетSymIntдля замены всех свободных символов наconcretize; для типаsize 📎 vllm/compilation/piecewise_backend.py:47-52необходимо одновременно конкретизировать shape, stride, storage_offset и с помощьюTensorвычислить требуемую длину хранилища, затем черезcompute_required_storage_lengthвосстановить тензорas_stridedВосстановить тензор📎 vllm/compilation/piecewise_backend.py:64-73. Почему нельзя изменить только shape? Потому что stride и storage_offset также могут содержать символы, и все три должны быть согласованы, иначеas_stridedвыйдет за границы.

Диспетчеризация во время выполнения:__call__— это горячий путь. Если существуетsym_shape_indices, извлечь runtime-форму изargs, затем вызвать📎 vllm/compilation/piecewise_backend.py:357-362для поиска. Логика поиска имеет приоритет: сначала проверить, попадает ли в точный_find_range_for_shape, при попадании вернуть этот точечный интервалcompile_sizes; иначе перебрать📎 vllm/compilation/piecewise_backend.py:342-355в поисках интервала, содержащего эту формуcompile_rangesКопирование📎 vllm/compilation/piecewise_backend.py:342-355。

mermaid
flowchart TD
    call["PiecewiseBackend.__call__(*args)"] --> has_sym{"sym_shape_indices не пуст?"}
    has_sym -->|"да"| get_shape["runtime_shape = args[sym_shape_indices

〔Проектные выводы и архитектурные компромиссы〕

Метод отвечает за сериализацию скомпилированных артефактов для AOT-кэша. Здесь есть изящный

to_bytes: когда pickle встречаетreducer_override, сначала вызываетсяCachingAutotuner, затем сериализуетсяobj.prepare_for_pickle(). Зачем нужен этот хук?📎 vllm/compilation/piecewise_backend.py:209-218Внутри хранит артефакты компиляции Triton и состояние времени выполнения; прямой pickle может завершиться неудачей или создать неиспользуемые повторно объекты;CachingAutotunerочевидно, преобразует объект в сериализуемую чистую форму.prepare_for_pickleПри сериализации также временно включается

, что перекликается с логикой вbundled_autograd_cache 📎 vllm/compilation/piecewise_backend.py:222— когда_get_vllm_functorch_configне включён, эта конфигурация равнаVLLM_USE_MEGA_AOT_ARTIFACT, при сериализации принудительно устанавливается вFalse 📎 vllm/compilation/compiler_interface.py:160-161, чтобы гарантировать упаковку артефактов.True— это путь горячего запуска, он утверждает, что каждый range может быть найден в

load_all_rangesсоответствующий key, иначе выбрасывается ошибка со списком доступных keycompiled_runnables. Это сообщение об ошибке спроектировано очень практично — сразу перечисляет доступные key, что упрощает диагностику несоответствия версий кэша.📎 vllm/compilation/piecewise_backend.py:329-339Обёртка CUDA Graph: захват, воспроизведение и вложенная диспетчеризация

Интуитивная модель

CUDA Graph записывает «последовательность запусков ядер» в статический граф, после чего каждое воспроизведение требует лишь одного вызова API.

— 这是捕获与回放执行器。它面临的主要难点是:vLLM 中批大小是动态的,而 CUDA Graph 要求固定的输入地址。解决方案是“按档位 batch descriptor 捕获”——为每个形状档位记录一张图,运行时通过 descriptor 在表中查找并回放。CUDAGraphWrapperСтруктуры данных: CUDAGraphEntry и контракт диспетчеризации

Содержит три ключевых поля:

CUDAGraphEntryв качестве ключа диспетчеризацииbatch_descriptor— захваченный объект графа📎 vllm/compilation/cuda_graph.py:128-135、cudagraph— выход при захвате (сохраняется как слабая ссылка для экономии памяти)📎 vllm/compilation/cuda_graph.py:128-135、outputиспользуется только в режиме отладки для проверки совпадения адресов входа при воспроизведении📎 vllm/compilation/cuda_graph.py:128-135。input_addressesДокументация класса точно описывает контракт диспетчеризации: при инициализации выделяется runtime mode (FULL или PIECEWISE)📎 vllm/compilation/cuda_graph.py:128-135。

CUDAGraphWrapper; во время выполнения из forward context принимаются runtime_mode и batch_descriptor и «blindly trust them»📎 vllm/compilation/cuda_graph.py:158-158; если runtime_mode равен NONE или не совпадает, напрямую вызывается📎 vllm/compilation/cuda_graph.py:158-158; иначе выполняется захват или воспроизведение📎 vllm/compilation/cuda_graph.py:158-158Документация также особо оговаривает границу: «CUDAGraphWrapper does not store persistent buffers or copy any runtime inputs into that buffers for replay»📎 vllm/compilation/cuda_graph.py:158-158。

. Это означает, что управление входными буферами — ответственность вызывающей стороны; wrapper отвечает только за сам граф.📎 vllm/compilation/cuda_graph.py:164-164Сценарий: один захват и одно воспроизведение

Путь захвата

: когдасрабатывает и runtime_mode совпадает, сначала проверяется доступность forward context. Если недоступен (например, forward визуального энкодера), напрямую вызывается нижележащая функция__call__. Это ключевая ветвь для мультимодальных сценариев — forward ViT не идёт через CUDA Graph.📎 vllm/compilation/cuda_graph.py:232-233Затем берутся

иbatch_descriptor. Если mode равен NONE или не совпадает, напрямую вызываетсяcudagraph_runtime_mode 📎 vllm/compilation/cuda_graph.py:242-244. Этот дизайн «не совпало — пропускаем напрямую» позволяет сосуществовать вложенным wrapper: FULL wrapper снаружи, PIECEWISE wrapper внутри, во время выполнения активируется только один.📎 vllm/compilation/cuda_graph.py:246-256Если у entry

равен None, выполняется захват. Сначала вызываетсяcudagraphдля проверки корректностиvalidate_cudagraph_capturing_enabled(), затем записываются адреса входа📎 vllm/compilation/cuda_graph.py:279, создаётся📎 vllm/compilation/cuda_graph.py:281-284В контексте захвата есть несколько ключевых операций. Еслиtorch.cuda.CUDAGraph() 📎 vllm/compilation/cuda_graph.py:285。

включён, то патчатсяgc_disableиgc.collect. Комментарий объясняет причину: в режиме piecewise каждый слой должен захватить один граф, повторный GC сделает захват крайне медленным, поэтому «only run gc for the first graph, and disable gc for the rest»torch.accelerator.empty_cache 📎 vllm/compilation/cuda_graph.py:288-303. Затем устанавливается graph pool id📎 vllm/compilation/cuda_graph.py:289-294, и синхронизируется поток копирования offloader📎 vllm/compilation/cuda_graph.py:305-308Настоящий захват выполняется в контексте📎 vllm/compilation/cuda_graph.py:310-312。

. После захвата вызываетсяtorch.cuda.graph(cudagraph, pool=..., stream=...)во избежание ошибок неjoin-нутых потоковself.runnable(*args, **kwargs) 📎 vllm/compilation/cuda_graph.py:315-321. Еслиget_offloader().join_after_forward()включён, output преобразуется в слабую ссылку для экономии памяти📎 vllm/compilation/cuda_graph.py:322-326. Наконец, entry сохраняет слабую ссылку output и объект графаweak_ref_output, но📎 vllm/compilation/cuda_graph.py:327-334возвращается исходный output, а не слабая ссылка📎 vllm/compilation/cuda_graph.py:338-339— комментарий подчёркивает, что это нужно, чтобы PyTorch корректно управлял памятью во время захватаПуть воспроизведения: если у entry уже есть граф, в режиме отладки проверяется совпадение адресов входа📎 vllm/compilation/cuda_graph.py:343-346。

, затем синхронизируется offloader, вызывается📎 vllm/compilation/cuda_graph.py:348-357и возвращается📎 vllm/compilation/cuda_graph.py:359-361, вызовentry.cudagraph.replay()и возвратentry.output 📎 vllm/compilation/cuda_graph.py:362-363。

Проектное размышление: почему выход должен быть слабой ссылкой, а возвращаемое значение — сильной

ЭтоCUDAGraphWrapper— самое неинтуитивное место вoutputуправляется пулом cudagraph PyTorch📎 vllm/compilation/cuda_graph.py:320. Если entry хранит сильную ссылку на output, то видеопамять, занятая этим графом, никогда не освободится; но если во время захвата преобразовать её в слабую ссылку, PyTorch может освободить память до завершения захвата, что приведёт к сбою захвата. Поэтому код внутри блока захвата использует слабую ссылку📎 vllm/compilation/cuda_graph.py:334, в entry хранится слабая ссылка📎 vllm/compilation/cuda_graph.py:338, но возвращаемое значение функции — сильная ссылка📎 vllm/compilation/cuda_graph.py:346. Это «тройственное состояние ссылок» — точный баланс между безопасностью памяти и эффективностью видеопамяти.

Ещё один заслуживающий внимания дизайн —_all_instancesэтотWeakSet 📎 vllm/compilation/cuda_graph.py:173-176. Он позволяетclear_all_graphsза один раз очистить графы всех wrapper'ов📎 vllm/compilation/cuda_graph.py:173-176, для экстренного освобождения при нехватке видеопамяти. ИспользованиеWeakSetвместо обычного множества сделано для того, чтобы не препятствовать сборке мусора wrapper'а — иначе сам wrapper будет утекать.

Производственные подводные камни:__getattr__в режиме отладки выбрасывает ошибку с контекстом для несуществующего атрибута📎 vllm/compilation/cuda_graph.py:211-217. Это кажется мелочью, но при отладке «почему не удался вызов некоторого метода» возможность увидеть строковое описание runnable, обёрнутого wrapper'ом, гораздо полезнее, чем голыйAttributeError.

Проектное размышление: развязка компиляции и CUDA Graph

Проектный документ явно фиксирует мотивацию этого рефакторинга. Ранняя piecewise-компиляция была предназначена для поддержки захвата piecewise CUDA Graph, исключая операторы, не поддерживающие CUDA Graph (в основном attention)📎 docs/design/cuda_graphs.md:25. Позже была добавлена поддержка full CUDA Graph, но «this tight coupling between compilation and cudagraph capture led to an all-or-nothing experience with little flexibility»📎 docs/design/cuda_graphs.md:25。

После рефакторинга поставлены четыре цели: явно различать prefill/mixed и uniform-decode батчи и захватывать их отдельно📎 docs/design/cuda_graphs.md:25-25; развязать логику захвата CUDA Graph и компиляцию, чтобы «capturing piecewise and full cudagraphs using the same compiled graph»📎 docs/design/cuda_graphs.md:25-25; диспетчеризация во время выполнения по составу батча📎 docs/design/cuda_graphs.md:25-25; централизованное управление для снижения сложности📎 docs/design/cuda_graphs.md:25-25。

BatchDescriptor— ключевая структура ключа диспетчеризации, содержитnum_tokens、num_reqs、uniform、has_loraчетыре поля📎 docs/design/cuda_graphs.md:86-93。uniformфлаг особенно важен — многие бэкенды attention поддерживают full CUDA Graph только при uniform батче📎 docs/design/cuda_graphs.md:95-95. Документ также предвещает возможное расширение этой структуры, например добавлениеuniform_query_lenдля поддержки нескольких длин uniform decode📎 docs/design/cuda_graphs.md:95-95。

Приоритет диспетчеризации —FULL > PIECEWISE > None, если ключ диспетчеризации отсутствует, происходит откат к режиму NONE для eager-выполнения📎 docs/design/cuda_graphs.md:112-115. Эта стратегия «деградация вместо ошибки» гарантирует выполнение любой комбинации батчей, различается лишь производительность.

AttentionCGSupportперечисление квантифицирует возможности CUDA Graph бэкенда, значенияALWAYS=3 > UNIFORM_BATCH=2 > UNIFORM_SINGLE_TOKEN_DECODE=1 > NEVER=0 📎 docs/design/cuda_graphs.md:153-162. Смешанные attention-модели (например, mamba mixer) берут минимум возможностей всех бэкендов и на основании этого деградируют режим CUDA Graph📎 docs/design/cuda_graphs.md:173-175. Этот дизайн развязывает «декларацию возможностей» и «выбор режима» — новому бэкенду достаточно объявить возможности, стратегия деградации сработает автоматически.

Резюме главы

Размышления и самопроверка главы

Q1: если убрать_check_can_cacheпатч (📎 vllm/compilation/compiler_interface.py:544-551), позволив Inductor самому решать, кэшировать ли, в каких сценариях это приведёт к инвалидации кэша компиляции? Почему в комментарии сказано «Inductor refuses to cache the graph outside of Dynamo tracing context»?

Эталонный разбор:_check_can_cacheвозвращает напрямую, не выполняя никаких проверок; комментарий поясняет, что Inductor отказывается кэшировать в двух случаях: вне контекста трассировки Dynamo и если граф содержит высокоуровневые операторы📎 vllm/compilation/compiler_interface.py:544-551. Процесс компиляции vLLM как раз вне контекста Dynamo (compile_fxвызываетсяPiecewiseCompileInterpreter, и код явно очищаетTracingContext 📎 vllm/compilation/compiler_interface.py:623-625). Если убрать патч, Inductor решит «некэшируемо», и каждая загрузка будет перекомпилировать, время холодного старта деградирует с секунд до минут. Что ещё более скрыто: поскольку vLLM полагается наhijacked_compile_fx_innerдля извлеченияhash_str, если путь кэша пропущен,hash_strможет быть None, что вызовет📎 vllm/compilation/compiler_interface.py:640-652RuntimeError. Это объясняет, почему в комментарии подчёркивается «vLLM today assumes and requires the monkey-patched functions to get hit»📎 vllm/compilation/compiler_interface.py:596-598。

Q2: CUDAGraphWrapperпри захвате преобразует output в слабую ссылку и сохраняет в entry (📎 vllm/compilation/cuda_graph.py:338), но возвращает сильную ссылку (📎 vllm/compilation/cuda_graph.py:346). Если и возвращаемое значение сделать слабой ссылкой, в каких сценариях произойдёт краш?

Эталонный разбор: во время захватаoutputуправляется пулом cudagraph PyTorch📎 vllm/compilation/cuda_graph.py:320. Если возвращаемое значение является слабой ссылкой, объект, полученный вызывающей стороной, может быть немедленно собран сборщиком мусора после выхода из блока захвата — поскольку в этот момент никакая сильная ссылка его не удерживает. PyTorch во время захвата требует, чтобы output оставался живым для корректного установления соответствия с пулом памяти; как только он будет собран, при последующем воспроизведенииentry.outputуказывающая на него слабая ссылка станет недействительной,replay()объект, возвращённый после, может быть уже перезаписан или освобождён. Комментарий явно говорит: "we need to return the output, rather than the weak ref of the output, so that pytorch can correctly manage the memory during cuda graph capture"📎 vllm/compilation/cuda_graph.py:343-345. Этот дизайн представляет собой точный баланс между "сильной ссылкой на этапе захвата и слабой ссылкой на этапе хранения".

Q3: ВPiecewiseBackend._find_range_for_shape(📎 vllm/compilation/piecewise_backend.py:342-355) поиск точного размера имеет приоритет над поиском по диапазону. Предположим,compile_sizes=[8]、compile_ranges=[Range(1,16)], во время выполнения shape=8, какая запись будет выбрана? Если поменять приоритет местами, какие будут последствия?

Справочный разбор: текущая логика сначала проверяетruntime_shape in self.compile_sizes, при совпадении возвращаетRange(start=8, end=8)точечную запись📎 vllm/compilation/piecewise_backend.py:342-355. Эта запись скомпилирована сcreate_concrete_args, форма полностью конкретизирована, ядро Triton может выполнить максимальную специализацию (например, вset_inductor_configдля точечного размера включаетсяmax_autotune 📎 vllm/compilation/compiler_interface.py:747-754). Если поменять приоритет местами, shape=8 попадёт в запись диапазонаRange(1,16)— это универсальная версия, скомпилированная с символьными формами, с субоптимальной производительностью. Что ещё серьёзнее,compile_sizesобычно происходит изcudagraph_capture_sizes, эти размеры как раз являются теми позициями, которые должен захватывать CUDA Graph; если во время выполнения диспетчеризация идёт на универсальную запись, захваченный CUDA Graph граф не будет соответствовать диспетчеризованному runnable, что может привести к несоответствию форм при воспроизведении. Поэтому приоритет точного совпадения — это не только выбор производительности, но и требование корректности.

Следующая глава перейдёт к квантизации и пользовательским ядрам, чтобы посмотреть, как vLLM вмешивается в контроль точности уже на этапе загрузки весов и с помощью высокоспециализированных операторов действительно превращает выгоду от квантизации в рост пропускной способности.

В этой главе проанализированы два уровня механизма ускорения компиляции в vLLM. Первый уровень — CompilerInterface и PiecewiseBackend: первый определяет контракт адаптации компилятора и стратегию хеширования кэша, используя AlwaysHitShapeEnv для обхода проблемы отсутствия контекста Dynamo; второй компилирует отдельный подграф FX в несколько позиций форм, а во время выполнения выполняет диспетчеризацию по числу токенов. Второй уровень — CUDAGraphWrapper: он захватывает CUDA Graph по позициям BatchDescriptor, реализует вложенную диспетчеризацию через сопоставление runtime mode, позволяя режимам FULL и PIECEWISE сосуществовать на одном скомпилированном графе. Развязка этих двух уровней является ядром данной рефакторизации — артефакты компиляции могут быть переиспользованы обоими режимами CUDA Graph, а CUDA Graph может работать независимо от компиляции. Однако компиляция и захват графа решают проблему накладных расходов на планирование; точность весов самой модели и эффективность операторов остаются другой основной линией оптимизации. Следующая глава перейдёт к квантизации и пользовательским ядрам, чтобы посмотреть, как vLLM разбирает конфигурацию квантизации, выполняет преобразование форматов FP8/INT4/AWQ/GPTQ и т.д. при загрузке весов и с помощью _custom_ops и ядер Triton дополнительно выжимает производительность аппаратного обеспечения.

CHAPTER 11

Глава 11: AsyncLLMEngine и API Server: потоковая отдача и асинхронный конвейер

Проект: vllm-project/vllm · Прогресс книги: Глава 11 / 14 · Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы увидели, как torch.compile и CUDA Graph довели накладные расходы на планирование Python и запуск ядер до минимума. Но как бы быстро ни было планирование, если сами веса имеют формат FP16, а матричное умножение использует универсальный GEMM, аппаратные вычислительные возможности всё ещё сдерживаются пропускной способностью видеопамяти и неэффективными операторами. Квантизация и пользовательские ядра — это другая ортогональная основная линия оптимизации: первая снижает точность уже на этапе загрузки весов, вторая действительно превращает выгоду от квантизации в пропускную способность. Эта глава начинается с точки входа разбора конфигурации квантизации и доходит до регистрации операторов в _custom_ops и диспетчеризации ядер Triton.

11.1 Конфигурация квантизации: от строки CLI до QuantKey

Интуитивная модель

Роль модуля конфигурации квантизации подобна переводчику меню в ресторане. Пользователь на входе говорит "я хочу fp8_per_tensor" (строка CLI), а кухне нужен точный номер рецепта (QuantKey). Переводчик должен обработать три типа входных данных: чистые сокращения CLI, метаданные квантизации, прилагаемые к checkpoint, и комбинированные сценарии, где оба накладываются. Без этого слоя перевода кухня получит кучу неоднозначных строк и не сможет решить, какое ядро вызывать.

Структуры данных и компоновка памяти

Основные структуры данных —QuantSpecиQuantizationConfigArgs. Первая описывает ключи квантизации весов и активаций для одного типа слоя (linear или MoE), вторая — видимая пользователю конфигурация верхнего уровня.

📎 vllm/config/quantization.py:73-99

python
@config
class QuantSpec:
    weight: QuantKeyField = None
    activation: QuantKeyField = None

    def __str__(self) -> str:
        def quant_key_str(quant_key: QuantKey | None) -> str:
            if quant_key is None:
                return "None"
            return next(
                (
                    name
                    for name, known_quant_key in QUANT_KEY_NAMES.items()
                    if known_quant_key == quant_key
                ),
                str(quant_key),
            )
        return quant_key_str(self.weight)

weightиactivationоба являются необязательнымиQuantKey。Noneозначает «откат к собственному значению по умолчанию класса метода» — обычно наследуется от checkpoint, а в сценарии онлайн-квантования это означает отсутствие квантования📎 vllm/config/quantization.py:74-74。QuantKeyсам по себе является сложным типом, содержащим объявленияNamedTupleиClassVar[GroupShape], pydantic не может напрямую его интроспектировать, поэтому автор использовалGetPydanticSchemaдля внедрения пользовательского валидатора_coerce_quant_key, который унифицированно нормализует строки илиQuantKey📎 vllm/config/quantization.py:60-69。

QuantizationConfigArgsСтруктура полей заслуживает внимания📎 vllm/config/quantization.py:102-126:

  • linear / moe: они применяются соответственно кLinearBaseиFusedMoEFactoryслоям;
  • ignore: список имён слоёв, пропускаемых при квантовании; онлайн-квантование также поддерживает подстановку fnmatch;
  • targets: послойное переопределение онлайн-квантования; ключом может быть точное имя слоя,re:регулярное выражение с префиксом или шаблон fnmatch; значение взаимоисключающе сlinear/moe.

targetsВзаимоисключениеlinear/moeиmodel_validatorобеспечивается📎 vllm/config/quantization.py:172-179. Это ограничение не формализм:targetsидёт по пути послойного переопределения,linear/moeидёт по пути глобальных значений по умолчанию; одновременное наличие обоих делает неопределённым, «какой именно spec использует конкретный слой».

Пошагово: один разбор--quantization fp8_per_tensor

Сценарий: пользователь передаёт в командной строке--quantization fp8_per_tensorи одновременно через--quantization-configуказывает активационное квантование для слоёв MoE.

Первый шаг:resolve_quantization_configвызывается с аргументами — строкой CLI и словарём конфигурации📎 vllm/config/quantization.py:233-235. Сначала он проверяет, находится лиquantizationвONLINE_QUANT_SHORTHAND_NAMES— этот кортеж содержит все сокращённые имена плюс один"online" 📎 vllm/config/quantization.py:216-222。

Второй шаг:fp8_per_tensorпопадает в таблицу сокращений,baseразбирается как_ONLINE_SHORTHANDS["fp8_per_tensor"], то есть и linear, и moe используютkFp8StaticTensorSym 📎 vllm/config/quantization.py:188-190。

Третий шаг:quantization_configнепусто, конструируется как объектQuantizationConfigArgs. Затем вступает логика слияния📎 vllm/config/quantization.py:267-268: каждое поле определяется с помощьюquantization_config.xxx or base.xxx— поля, явно заданные пользователем, имеют приоритет; незаданные наследуют значения по умолчанию из сокращения. Здесь используетсяor, а неif is not None, намеренно:QuantSpecи пустой список оба falsy, семантически «не задано» и «пусто» эквивалентны.

Четвёртый шаг: еслиquantizationотсутствует в таблице сокращений (например, это собственныйawqиз checkpoint) иquantization_configравноNone, функция напрямую возвращаетNone 📎 vllm/config/quantization.py:256-257. Это означает «не накладывать онлайн-квантование»; метод квантования из checkpoint остаётся главным.

Есть легко упускаемая ветка:_DEFERRED_ONLINE_SHORTHANDSсодержитmxfp4иmxfp8 📎 vllm/config/quantization.py:233-235. Эти два имени являются одновременно и сокращениями CLI, и именами методов квантования checkpoint. Когда пользователь передаёт только--quantization mxfp4безquantization_config, функция возвращаетNone, а неbase 📎 vllm/config/quantization.py:267-268, откладывая решение на метаданные checkpoint — только если в checkpoint нет информации о квантовании, происходит откат к онлайн-сокращению.

mermaid
flowchart TD
    start["resolve_quantization_config(quantization, quantization_config)"]
    check_shorthand{"quantization in ONLINE_QUANT_SHORTHAND_NAMES?"}
    checkpoint_path{"quantization_config is None?"}
    return_none1["return None (checkpoint 主导)"]
    build_args["QuantizationConfigArgs(**quantization_config)"]
    get_base["base = _ONLINE_SHORTHANDS.get(quantization)"]
    cfg_none{"quantization_config is None?"}
    deferred{"quantization in _DEFERRED_ONLINE_SHORTHANDS?"}
    return_none2["return None (推迟到 checkpoint)"]
    return_base["return base"]
    merge["逐字段合并: cfg.xxx or base.xxx"]
    return_merged["return 合并后的 QuantizationConfigArgs"]

    start --> check_shorthand
    check_shorthand -->|否| checkpoint_path
    checkpoint_path -->|是| return_none1
    checkpoint_path -->|否| build_args
    check_shorthand -->|是| get_base
    get_base --> cfg_none
    cfg_none -->|是| deferred
    deferred -->|是| return_none2
    deferred -->|否| return_base
    cfg_none -->|否| merge
    merge --> return_merged

Размышления о дизайне и подводные камни

_coerce_specВалидатор обрабатывает тонкий сценарий: когдаlinearилиmoeполучает строку, сначала проверяется_ONLINE_SHORTHANDS; при совпадении извлекается spec соответствующего поля; при несовпадении строка трактуется как одно имяQuantKey📎 vllm/config/quantization.py:130-139. Это означает, чтоlinear="fp8_per_tensor"иlinear="fp8_per_tensor_static"идут двумя разными путями — первый является сокращением полной конфигурации, второй — отдельным ключом квантования. Если в сокращении это поле равноNone(например, вint8_per_channel_weight_onlyнет поляlinear), выбрасывается явноеValueError, а не молчаливый возвратNone 📎 vllm/config/quantization.py:130-139。

Частая ловушка в продакшене:targetsрегулярные ключи в_validate_targetsпредварительно компилируются и проверяются📎 vllm/config/quantization.py:166-167, но ключи-шаблоны fnmatch не проверяются. Если пользователь написал шаблон fnmatch, который никогда не совпадёт ни с одним слоем, ошибки не будет — просто этот слой останется неквантованным; при диагностике нужно проверять, действительно ли совпадают имена слоёв.

11.2 _custom_ops: регистрация операторов и fake-реализации

Интуитивная модель

_custom_ops.py— это слой адаптации между vLLM и низкоуровневыми операторами CUDA/C++, как таможня. В пространстве имёнtorch.ops._CPyTorch регистрируются скомпилированные операторы C++, но прямой вызов их имеет три проблемы: наборы операторов различаются на разных платформах (CUDA/ROCm/CPU/XPU),torch.compileнужны fake-реализации для вывода формы выходных данных, часть операторов требует предварительной обработки параметров на стороне Python._custom_opsинкапсулирует все эти проблемы единообразно.

Структуры данных и механизм регистрации

При загрузке модуля сначала вызываетсяcurrent_platform.import_kernels() 📎 vllm/_custom_ops.py:25-26, чтобы уровень платформы мог импортировать свою библиотеку операторов. Затем определяетсяregister_fake— подTYPE_CHECKINGэто пустой декоратор; во время выполнения изtorch.libraryимпортируется📎 vllm/_custom_ops.py:25-26。

Основная задача fake-реализации — позволитьtorch.compileна этапе трассировки знать форму и dtype выходных данных оператора без фактического выполнения. На примереscaled_fp4_quant:

📎 vllm/_custom_ops.py:90-100

python
if hasattr(torch.ops, "_C") and hasattr(torch.ops._C, "scaled_fp4_quant"):

    @register_fake("_C::scaled_fp4_quant")
    def _scaled_fp4_quant_fake(
        input: torch.Tensor,
        input_scale: torch.Tensor,
        is_sf_swizzled_layout: bool,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        n = input.shape[-1]
        m = input.numel() // n
        return create_fp4_output_tensors(m, n, input.device, is_sf_swizzled_layout)

Обратите внимание на защитуhasattr: fake-реализация определяется только тогда, когда платформа действительно зарегистрировала_C::scaled_fp4_quant. Это гарантирует, что импорт модуля на CPU или старом GPU не упадёт из-за отсутствия оператора.

create_fp4_output_tensorsдемонстрирует детали раскладки памяти выходных данных FP4-квантования📎 vllm/_custom_ops.py:69-87. Когдаis_sf_swizzled_layout=True, тензор scale должен быть расположен в тайлах 128x4, как требуется Tensor Core: число строк округляется вверх до кратного 128, число столбцов (n // 16) округляется вверх до кратного 4, каждые 4 float8_e4m3 упаковываются в один int32📎 vllm/_custom_ops.py:55-64. В комментарии явно указано, что ядро NVFP4-квантования явно обнуляет все padding-элементы scale, поэтому отдельное ядро нулевой инициализации не требуется📎 vllm/_custom_ops.py:60-61。

Пошагово: поток вызова одного AWQ GEMM

Сценарий: модель загрузила AWQ-квантованные веса; при прямом проходе нужно выполнить матричное умножение активаций и квантованных весов.

Первый шаг: вызываетсяawq_gemm 📎 vllm/_custom_ops.py:587-592. Функция сначала проверяет переменную окруженияVLLM_USE_TRITON_AWQ. Если она истинна, отложенно импортируетсяawq_gemm_tritonи вызывается — это чисто Triton-путь реализации, используемый на платформах без поддержки операторов CUDA или в сценариях отладки.

Второй шаг: путь по умолчанию вызываетtorch.ops._C.awq_gemm, передавая input, qweight, scales, qzeros иsplit_k_iters 📎 vllm/_custom_ops.py:598-598。

Третий шаг: еслиtorch.ops._C.awq_gemmсуществует, fake-реализация зарегистрирована📎 vllm/_custom_ops.py:601-616. Форма, возвращаемая fake:(split_k_iters, num_in_feats, qweight.size(1) * 8)затем.sum(0)— это точно моделирует форму промежуточного результата split-K и итоговую форму после редукции.qweight.size(1) * 8из способа упаковки AWQ: каждый int32 хранит 8 4-битных весов.

Четвёртый шаг,awq_dequantizeидёт по аналогичному пути📎 vllm/_custom_ops.py:553-559, но у fake-реализации другой вывод формы:out_c = qout_c * 8, поскольку после деквантизации число столбцов увеличивается в 8 раз📎 vllm/_custom_ops.py:587-592。

Функции repack серии Marlin демонстрируют другой паттерн.gptq_marlin_repackfake-реализация вычисляетpack_factor = 32 // num_bits, выходная форма —(size_k // 16, size_n * 16 // pack_factor) 📎 vllm/_custom_ops.py:1103-1119. Здесь16— это Marlin tile size,size_k // 16означает, что измерение K разбивается по tile. В версии для MoEgptq_marlin_moe_repackна уровне Python в цикле по каждому expert вызывается repack для одного expert📎 vllm/_custom_ops.py:1154-1172, и утверждаетсяsize_k % 16 == 0— это жёсткое ограничение формата Marlin.

mermaid
flowchart LR
    input["вход: torch.Tensor (FP16/BF16)"]
    qweight["qweight: torch.Tensor (INT32 упакованный)"]
    scales["scales: torch.Tensor"]
    qzeros["qzeros: torch.Tensor"]
    check_env{"VLLM_USE_TRITON_AWQ?"}
    triton_path["awq_gemm_triton(input, qweight, scales, qzeros, split_k_iters)"]
    cuda_path["torch.ops._C.awq_gemm(...)"]
    output["выход: torch.Tensor (FP16/BF16)"]

    input --> check_env
    qweight --> check_env
    scales --> check_env
    qzeros --> check_env
    check_env -->|да| triton_path
    check_env -->|нет| cuda_path
    triton_path --> output
    cuda_path --> output

Проектные соображения и подводные камни

fake-реализация должна полностью совпадать по выходной форме с реальным оператором, иначеtorch.compileтрассируемый граф при выполнении столкнётся с несовпадением форм.create_fp4_output_tensorsВ комментарии особо подчёркивается «Must match the C++ scaled_fp4_quant_func allocation exactly when padded_n is None»📎 vllm/_custom_ops.py:69-74. Это легко ошибочное место: если на стороне C++ изменили логику выделения памяти, а fake не синхронизировали, скомпилированный граф упадёт при воспроизведении CUDA Graph.

Другая ловушка —torch.library.custom_opправила алиасинга.safeFusedQuantizeNvВ комментарии указано, что torch 2.12+ не разрешает выходу пользовательского оператора быть алиасом любого входа, поэтому автор изменил возвращаемый тензор на in-place параметр📎 vllm/_custom_ops.py:4650-4655. Такой подход «изменение формы API ради обхода ограничений фреймворка» очень распространён в слое адаптации операторов, и при отладке нужно обращать внимание, согласуется ли объявлениеmutates_argsс фактическим поведением.

CPUDNNLGEMMHandlerдемонстрирует другой способ управления ресурсами: указатель handler хранится в int64 tensor,__del__при вызовеrelease_dnnl_matmul_handlerосвобождает📎 vllm/_custom_ops.py:3708-3717. Хранение указателя в tensor нужно, чтобы Python не оптимизировал его как целое число — это классический приём низкоуровневой привязки.

11.3 Планирование ядер Triton:KernelOverrideи кросс-модульная перепривязка

Интуитивная модель

Роль планировщика ядер Triton похожа на систему подмены должностей в компании. Когда некоторая платформа (например, ROCm) хочет заменить своим реализации ядра Triton в ядре vLLM, нельзя напрямую менять основной код — это загрязнит upstream.dispatcherпозволяет платформе зарегистрировать замену, а затем незаметно подменить все ссылки на исходное ядро заменой. Без этого механизма каждой платформе пришлось бы поддерживать собственный fork, и при слиянии изменений upstream возникали бы постоянные конфликты.

Структуры данных и layout памяти

Основная структура данных —_registryсловарь иKernelOverrideкласс📎 vllm/triton_utils/dispatcher.py:29-36。

KernelOverrideключевые поля📎 vllm/triton_utils/dispatcher.py:50-61:

  • _impl: функция реализации платформы;
  • arg_names: кортеж имён параметров, зеркально отражающий исходное ядро, используется для привязки по ключевым словам при launch;
  • constexprs: объявления constexpr, унаследованные от исходного ядра;
  • func: указывает на функцию реализации, используется для интроспекции при warmup;
  • _forward_by_name: булев флаг, определяющий, передавать ли параметры при launch по ключевым словам или позиционно.

_forward_by_nameЛогика вычисленияinspect.signature(impl).parametersтакова: сравнитьarg_namesс📎 vllm/triton_utils/dispatcher.py:50-61исходного ядра на полное равенство. Если равны, значит имена параметров реализации совпадают с ядром, и можно безопасно передавать по ключевым словам; иначе необходимо передавать позиционно в порядке параметров исходного ядра.

Step-by-Step: однаregister_kernelsперепривязка

Сценарий: платформа ROCm при инициализации вызываетregister_kernels({"vllm.v1.sample.rejection_sampler.expand_kernel": my_expand_impl})。

Первый шаг,register_kernelsпроходит по overrides, для каждого имени вызывает_resolve_kernel 📎 vllm/triton_utils/dispatcher.py:162-166。_resolve_kernelразбивает имя по последнему.на имя модуля и имя атрибута📎 vllm/triton_utils/dispatcher.py:83-94. Если первая буква последнего сегмента имени модуля заглавная, значит ядро принадлежит некоторому классу (JIT warmup owner), нужно сначала импортировать родительский модуль, затемgetattrполучить класс и вернуть(类, 属性名); иначе импортировать сам модуль и вернуть(模块, 属性名)。

Второй шаг, получив объект исходного ядра, построитьKernelOverridewrapper и записать в_registry 📎 vllm/triton_utils/dispatcher.py:167-169。

Третий шаг,_rebind_kernelsвыполняет сканирование всех модулей📎 vllm/triton_utils/dispatcher.py:97-144. Он обходитsys.modulesвсех модулей в__dict__, для каждого значения атрибута выполняет сравнение по идентичности — заметьте,is, а не==, потому что некоторые значения атрибутов (например,PlaceholderModulesentinel) при hash/eq могут вызвать импорт или исключение📎 vllm/triton_utils/dispatcher.py:116-123。

Четвёртый шаг, для атрибутов, совпавших с исходным ядром, напрямуюsetattrзаменить на wrapper📎 vllm/triton_utils/dispatcher.py:125-135. Для JIT warmup owner (объектов, у которых атрибут экземпляраkernelуказывает на исходное ядро), заменитьvalue.kernelи очистить кэшированный_kernel_arg_names, чтобы привязка launch заново выводилась из wrapper📎 vllm/triton_utils/dispatcher.py:138-139。

Пятый шаг,_rebind_kernelsпосле завершения только тогда заменить атрибут в месте определения на wrapper📎 vllm/triton_utils/dispatcher.py:170-174. В комментарии объясняется важность порядка: если сначала заменить место определения, при сканировании исходное ядро уже не будет найдено📎 vllm/triton_utils/dispatcher.py:170-171。

mermaid
sequenceDiagram
    participant Platform as "ROCm 平台"
    participant Dispatcher as "register_kernels"
    participant Resolver as "_resolve_kernel"
    participant Scanner as "_rebind_kernels"
    participant Modules as "sys.modules"

    Platform->>Dispatcher: register_kernels({"vllm...expand_kernel": my_impl})
    Dispatcher->>Resolver: _resolve_kernel("vllm...expand_kernel")
    Resolver-->>Dispatcher: (module, "expand_kernel")
    Dispatcher->>Dispatcher: KernelOverride(original, my_impl)
    Dispatcher->>Scanner: _rebind_kernels([(original, wrapper)])
    Scanner->>Modules: 遍历所有模块 __dict__
    Modules-->>Scanner: 属性值列表
    Scanner->>Scanner: lookup(value) 身份比较
    Scanner->>Modules: setattr(module, attr, wrapper)
    Scanner->>Modules: value.kernel = wrapper (JIT owner)
    Scanner-->>Dispatcher: 重绑定完成
    Dispatcher->>Modules: setattr(host, attr, wrapper)
    Dispatcher-->>Platform: 注册完成

Проектные соображения и подводные камни

KernelOverride.__getitem__возвращаетself._launch, что делаетkernel[grid](**kwargs)такой стандартный синтаксис launch Triton прозрачным для wrapper📎 vllm/triton_utils/dispatcher.py:63-74。_launchЛогика пересылки📎 vllm/triton_utils/dispatcher.py:63-74делится на три случая_forward_by_name: при наличии позиционных аргументов они передаются напрямую;RuntimeErrorесли истинно, передача по ключевым словам; иначе проверяется, есть ли в kwargs имена параметров, неизвестные исходному ядру, и если есть — выбрасывается

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

Одна ловушка в производственной среде:_rebind_kernelsСканирование в имеет сложность O(число модулей × число атрибутов × число ядер). Для крупных моделейsys.modulesможет быть тысячи модулей, каждый с сотнями атрибутов. Хотя это выполняется только один раз при инициализации, при большом числе зарегистрированных ядер время запуска заметно возрастает.lookupФункция использует линейный поиск вместо хеш-поиска, и комментарий объясняет причину — некоторые значения атрибутов не хешируемы📎 vllm/triton_utils/dispatcher.py:116-123. Это типичный компромисс «корректность важнее производительности».

Ещё одна ловушка:_resolve_kernelОпределение того, является ли атрибут атрибутом класса, выполняется по принципу «первая буква последнего сегмента имени модуля заглавная»📎 vllm/triton_utils/dispatcher.py:83-94. Если имя модуля случайно начинается с заглавной буквы (что не соответствует соглашениям именования Python, но синтаксически допустимо), оно будет ошибочно определено как класс. Это дизайн по принципу «соглашение важнее конфигурации», опирающийся на внутренние соглашения об именовании vLLM.

Размышления о дизайне

Два уровня механизмов — конфигурация квантизации и регистрация операторов — совместно образуют «поверхность регулировки точность-производительность» в vLLM.QuantizationConfigArgsДизайн отражает разделение «намерения пользователя» и «значения по умолчанию метода»:Noneозначает не «не квантовать», а «позволить классу метода решить самому». Такое отложенное решение позволяет одной и той же конфигурации адаптироваться как к квантизации checkpoint, так и к онлайн-квантизации.

_custom_opsПаттерн fake-реализации является стандартомtorch.compileэкосистемы, но уникальность vLLM заключается в повсеместном использованииhasattrguard-ов. Это позволяет одному и тому же модулю импортироваться на CUDA, ROCm, CPU, XPU без падения, ценой необходимости трёх фрагментов кода для каждого оператора: Python-обёртки, fake-реализации и платформенного guard-а.

Кросс-модульная перепривязка Triton dispatcher — это радикальное решение. Она не полагается на хуки импорта Python или__getattr__, а напрямую сканирует и заменяет все ссылки. Преимущество такого подхода — полнота: независимо от того, в сколькие места ядро былоfrom mod import kernelскопировано, оно будет заменено; недостаток — хрупкость: любой новый способ хранения ссылки на ядро (например, захват замыканием) может избежать сканирования.

Резюме главы

Вопросы для размышления и самопроверки к этой главе

Q1: Вresolve_quantization_config, если убрать ветку_DEFERRED_ONLINE_SHORTHANDS(то есть когдаquantization in _DEFERRED_ONLINE_SHORTHANDSвозвращатьbaseвместоNone), что произойдёт при загрузке модели, checkpoint которой содержитquant_method: "mxfp4", а пользователь передаёт только--quantization mxfp4?

Эталонный разбор:_DEFERRED_ONLINE_SHORTHANDSЗамысел дизайна — дать приоритет методу квантизации из checkpoint📎 vllm/config/quantization.py:233-235. Если убрать эту ветку,mxfp4попадёт в_ONLINE_SHORTHANDSи вернётbase(то естьQuantSpec(weight=kMxfp4Static))📎 vllm/config/quantization.py:198-210. В этом случае конфигурация онлайн-квантизации перекроет метод квантизации checkpoint, а веса checkpoint хранятся в форматеmxfp4— еслиkMxfp4Staticонлайн-конфигурации не полностью совпадает с фактическим форматом checkpoint (например, различается раскладка scale), загрузка весов завершится ошибкой или даст неверные результаты. Более скрытый случай: в checkpointmxfp4может использоваться другой group size или scale dtype, значения по умолчанию онлайн-конфигурации не совпадут с ними, что приведёт к снижению точности инференса без сообщения об ошибке.

Q2: KernelOverride._launchВ , если_forward_by_nameравноFalseи вызывающая сторона передаёт kwargs, содержащий имя параметра, неизвестное исходному ядру, код выброситRuntimeError. Если убрать эту проверку и заменить её молчаливым игнорированием неизвестных параметров, в каких сценариях это приведёт к трудно диагностируемым проблемам?

Эталонный разбор:_forward_by_nameравноеFalseозначает, что имена параметров платформенной реализации не совпадают с исходным ядром, и необходимо пересылать позиционно📎 vllm/triton_utils/dispatcher.py:50-61. Если вызывающая сторона передаёт параметр, неизвестный исходному ядру (например, вышестоящий код добавил новый опциональный параметр), молчаливое игнорирование приведёт к потере значения этого параметра. В сценарии с Triton-ядром это обычно означает, что какой-то constexpr или измерение grid не был передан, и ядро может запуститься со значениями по умолчанию — результатом может стать неверный результат вычисления, а не падение. Поскольку ошибочные результаты Triton-ядер часто проявляются как числовые отклонения, а не исключения, диагностика крайне сложна. ЯвноеRuntimeErrorпозволяет выявить проблему уже при первом launch📎 vllm/triton_utils/dispatcher.py:63-74。

Q3: _rebind_kernelsПосле замены атрибута у JIT warmup owner выполняетсяkernel. Если убрать эту строку, в каких случаях это приведёт к ошибке привязки при launch?value.__dict__.pop("_kernel_arg_names", None)Эталонный разбор

: JIT warmup owner кэширует, используемый при launch для привязки kwargs к параметрам ядра_kernel_arg_names. После замены📎 vllm/triton_utils/dispatcher.py:138-139на wrapper,kernelwrapper-а может отличаться от исходного ядра (если имена параметров платформенной реализации различаются,arg_nameswrapper-а по-прежнему зеркалирует исходное ядро, ноarg_namesможет быть_forward_by_name). Если не очистить кэш, механизм warmup продолжит использовать старый список имён параметров для привязки, тогда как логика launch wrapper-а может ожидать иной способ привязки. Конкретно,FalseприKernelOverride._launchравном_forward_by_nameизвлекает значения в порядкеFalse, и если кэшированныйself.arg_namesне совпадает с📎 vllm/triton_utils/dispatcher.py:79-80wrapper-а, порядок извлечённых параметров будет нарушен, что приведёт к получению ядром неверных значений параметров._kernel_arg_namesСледующая глава переключится на продвинутые возможности инференса: как префиксный кэш переиспользует KV-блоки, как спекулятивное декодирование ускоряет большую модель с помощью маленькой, и как LoRA позволяет динамически переключать адаптеры без изменения весов базовой модели.arg_namesнесоответствие, порядок извлечённых параметров будет нарушен, что приведёт к передаче ядру неверных значений параметров.

В следующей главе мы перейдём к продвинутым функциям инференса и рассмотрим, как префиксное кеширование переиспользует KV-блоки, как спекулятивное декодирование ускоряет большую модель с помощью малой, и как LoRA позволяет динамически переключать адаптеры без изменения весов базовой модели.

В этой главе разобраны два уровня инфраструктуры квантования и пользовательских ядер в vLLM. Первый уровень — разбор конфигурации квантования: QuantSpec и QuantizationConfigArgs унифицируют CLI-строки, метаданные checkpoint и поуровневые переопределения в QuantKey, resolve_quantization_config обрабатывает раскрытие сокращений и слияние полей, а _DEFERRED_ONLINE_SHORTHANDS решает сценарии конфликта имён. Второй уровень — адаптация операторов: _custom_ops через защиту hasattr и register_fake реализует кросс-платформенную регистрацию операторов, fake-реализация точно повторяет выходные формы реальных операторов для поддержки torch.compile; dispatcher через KernelOverride и полное сканирование модулей обеспечивает платформенную замену ядер Triton. Оба уровня совместно поддерживают реализацию выгод от квантования — от загрузки весов до прямого вычисления. Далее мы перейдём к продвинутым функциям инференса, повышающим пропускную способность и снижающим задержку: как автоматическое префиксное кэширование переиспользует KV между запросами, как спекулятивное декодирование ускоряет генерацию с помощью черновой модели и как LoRA динамически переключает адаптеры.

CHAPTER 12

Глава 12: Профилирование производительности и бенчмаркинг: ключевые метрики инференса

Проект: vllm-project/vllm · Прогресс книги: Глава 12 / 14 · Статус проверки: FACT — номера строк реально привязаны

В предыдущей главе мы углубились в систему квантования и инфраструктуру пользовательских операторов vLLM, увидели, как разбирается конфигурация квантования и выбирается соответствующее ядро, а также как схемы FP8, INT4, AWQ, GPTQ выполняют преобразование при загрузке весов. Кроме того, мы выяснили, как _custom_ops регистрирует операторы CUDA, механизм планирования ядер Triton и как融合-ядра MoE сокращают обращения к видеопамяти. Эти низкоуровневые возможности проложили путь к более продвинутым оптимизациям инференса. В этой главе мы сосредоточимся на трёх продвинутых функциях инференса vLLM: автоматическом префиксном кэшировании (APC), спекулятивном декодировании и LoRA. Они кажутся независимыми, но на самом деле разделяют одну и ту же низкоуровневую инфраструктуру — хеширование KV-блоков, распределение слотов планировщиком и динамическое внедрение весов при выполнении модели. Ключ к их пониманию — понять, как они, не нарушая семантику страничной адресации PagedAttention, доводят «переиспользование» до предела.

12.1 Префиксное кэширование: как block hash отпечатывает префикс

Интуитивная модель

Префиксное кэширование похоже на «общий сборник выдержек» в библиотеке: два студента пишут сочинения, оба начинают с цитаты одного и того же древнего текста, учителю достаточно один раз проверить этот фрагмент, а затем отдельно посмотреть различающиеся части. Без него каждый запрос должен с нуля выполнять prefill всего промпта, и в сценариях вопросов-ответов по длинным документам вычислительные ресурсы расходуются многократно.

Структура данных: отображение из token в block hash

Ядро префиксного кэширования — «как определить, что префиксы двух запросов совпадают». Ответ vLLM: разбить последовательность token на блоки и вычислить для каждого блока цепной хеш. Цепной означает, что хеш N-го блока включает хеши предыдущих N-1 блоков, поэтому один block hash уникально отпечатывает весь префикс «от начала последовательности до конца этого блока».

Носителем хеша являетсяBlockHash, он определён какbytesотNewType, а не голыйbytes, чтобы на уровне типов предотвратить ошибочное использование📎 vllm/v1/core/kv_cache_utils.py:59-62. Когда нужно объединить block hash с KV cache group id в ключ словаря, vLLM не использует кортеж, а напрямую приклеивает 4-байтовый big-endian group id к хвосту байтов хеша📎 vllm/v1/core/kv_cache_utils.py:75-76:

python
def make_block_hash_with_group_id(block_hash, group_id):
    return BlockHashWithGroupId(block_hash + group_id.to_bytes(4, "big", signed=False))
〔Проектное предположение и архитектурный компромисс〕

Это типичная оптимизация «избегания выделения кортежей»: на горячем пути для каждого поиска блока нужно построить ключ, кортеж приносит дополнительные накладные расходы на выделение Python-объекта и хеширование, тогда как конкатенация байтовых строк выполняется на уровне C, и байтовая строка сама по себе хешируема. При извлечении срезомkey[:-4]иint.from_bytes(key[-4:])восстанавливаются📎 vllm/v1/core/kv_cache_utils.py:87-89。

Саму хеш-функцию выполняетhash_block_tokens, она передаёт хеш-функции родительский block hash, кортеж token id текущего блока и дополнительные ключи📎 vllm/v1/core/kv_cache_utils.py:650-680. Заметьте, что родительский хеш первого блока — неNone, а глобальныйNONE_HASH:

python
if not parent_block_hash:
    parent_block_hash = NONE_HASH

📎 vllm/v1/core/kv_cache_utils.py:674-675。NONE_HASHВыбор seed скрывает проектное решение по безопасности: для криптографических хешей вроде SHA-256 seed фиксирован"vllm-none-hash", так что разные процессы vLLM вычисляют одинаковый хеш для одинакового содержимого и могут совместно использовать префиксный кэш между узлами; а для некриптографических хешей вроде xxhash seed случаен для каждого процесса, потому что предсказуемый seed позволил бы злоумышленнику офлайн предвычислить коллизионные блоки📎 vllm/v1/core/kv_cache_utils.py:105-126。resolve_none_hash_seedреализует это ветвление:PYTHONHASHSEEDпеременная окружения имеет приоритет, иначе для криптографического хеша используется фиксированный seed, для некриптографического —os.urandom(32) 📎 vllm/v1/core/kv_cache_utils.py:132-145。

Сценарий: вычисление block hash для одного запроса

Предположим, запрос входит со 128 токенами, размер блока равен 16.get_request_block_hasherВозвращаемое замыкание отвечает за инкрементальные вычисления📎 vllm/v1/core/kv_cache_utils.py:802-861:

Первый шаг — определить, с чего начать вычисления.start_token_idx = len(request.block_hashes) * hash_block_size 📎 vllm/v1/core/kv_cache_utils.py:812-812, то есть количество уже вычисленных блоков, умноженное на размер блока. Если оставшихся токенов меньше одного блока, сразу вернуть пустой📎 vllm/v1/core/kv_cache_utils.py:812-812。

Второй шаг — обработка мультимодального смещения. Если начальная позиция попадает внутрь некоторого мультимодального входа, нужно использоватьget_mm_features_in_windowдля перепозиционированияcurr_mm_idx 📎 vllm/v1/core/kv_cache_utils.py:823-832. Это связано с тем, что placeholder-токены мультимодального входа сами по себе не несут семантики, поэтому идентификатор mm-признака и его смещение внутри блока необходимо подмешать в хеш как дополнительные ключи.

Третий шаг — циклическое вычисление каждого блока.generate_block_hash_extra_keysСобрать все дополнительные ключи📎 vllm/v1/core/kv_cache_utils.py:611-647, включая имя LoRA, мультимодальные ключи, cache salt, хеш prompt embeds. При этом cache salt действует только на первом блоке📎 vllm/v1/core/kv_cache_utils.py:633-635, и это сделано намеренно: назначение salt — изолировать всё пространство имён кеша, и его достаточно внедрить один раз в начале цепочки.

Четвёртый шаг,hash_block_tokensхешировать вместе родительский хеш, кортеж токенов и дополнительные ключи, результат становится родительским хешем для следующего блока📎 vllm/v1/core/kv_cache_utils.py:851-857. Так формируется цепочечная структура.

Преобразование гранулярности при нескольких block size

Когда у модели есть несколько групп KV cache с разными block size, гранулярность хеширования и гранулярность блоков группы могут не совпадать.BlockHashListWithBlockSizeРешение этой проблемы: оно не пересчитывает хеш, а использует свойство цепочечного хеширования — хеш целевого блока есть хеш последнего hash-блока внутри него📎 vllm/v1/core/kv_cache_utils.py:2781-2851. Например, при hash block равном 16 и target block равном 32 хеш токенов 0-31 — это второй 16-size хеш (он уже цепочечно покрывает 0-31)📎 vllm/v1/core/kv_cache_utils.py:2794-2806。_get_value_atРеализация этого — этоself.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851。

mermaid
flowchart TD
    req["Request 到达"] --> check{"剩余 token >= hash_block_size?"}
    check -->|否| empty["返回空列表"]
    check -->|是| mm{"起始位置在多模态窗口内?"}
    mm -->|是| reloc["get_mm_features_in_window 重定位 curr_mm_idx"]
    mm -->|否| extra
    reloc --> extra["generate_block_hash_extra_keys 收集 LoRA/MM/salt/embeds 键"]
    extra --> hash["hash_block_tokens 链式哈希"]
    hash --> append["追加到 new_block_hashes"]
    append --> advance["start_token_idx += hash_block_size"]
    advance --> check

Размышления о дизайне и подводные камни

Почему используется цепочечное хеширование, а не независимое?Независимое хеширование не может различить случай «одинаковый блок встречается в разных позициях префикса». Цепочечное хеширование делает block hash уникальным отпечатком всего префикса, и именно это является предпосылкой того, чтоfind_longest_cache_hitможет безопасно переиспользовать KV.

Ловушка некриптографического хеширования при межпроцессном взаимодействии.Если используется xxhash и не заданPYTHONHASHSEED, то в каждом процессеNONE_HASHразличается, что приводит к полному отказу межэкземплярного кеширования префиксов.init_none_hashбудет выводить предупреждение📎 vllm/v1/core/kv_cache_utils.py:161-169. В продакшене, если развёрнуто несколько экземпляров с общим кешем, необходимо явно задатьPYTHONHASHSEEDили перейти на sha256.

Тонкости мультимодальных смещений. _gen_mm_extra_hash_keysИспользовать(mm_identifier, offset - start_token_idx)в качестве дополнительного ключа📎 vllm/v1/core/kv_cache_utils.py:552. Смещение задаётся относительно начала блока, так что один и тот же mm-элемент в разных позициях блока даёт разные хеши, что предотвращает ложные попадания.

12.2 Спекулятивное декодирование: совместная работа черновика и верификации

Интуитивная модель

Спекулятивное декодирование похоже на то, как секретарь сначала набрасывает несколько вариантов ответа за руководителя, а руководителю остаётся лишь быстро отметить, какой вариант подходит. Модель-черновик (drafter) с крайне низкой стоимостью предсказывает несколько токенов-кандидатов, целевая модель (target) за один прямой проход параллельно верифицирует этих кандидатов, принимая совпавшую часть. Без этого целевая модель могла бы генерировать только последовательно токен за токеном, и загрузка GPU на этапе decode была бы крайне низкой.

Структура данных: аннотирование EAGLE group

Ключевой вопрос спекулятивного декодирования в управлении KV cache: как группируются KV-слои модели-черновика и KV-слои целевой модели?_annotate_eagle_groupsИспользуются два правила для идентификации группы черновика📎 vllm/v1/core/kv_cache_utils.py:2134-2189:

Правило первое — управляется spec:non_causal_multi_token_decodeфлаг объявляется наMLAAttentionSpec, устанавливается слоем внимания черновика, выполняющим некаузальное многотокенное декодирование, и способен пережить операциюmerge📎 vllm/v1/core/kv_cache_utils.py:2175-2177。

Правило второе — откат по позиции: MTP-черновики (например, DeepseekV4/V4.1 DSpark) переиспользуют собственные decoder-слои целевой модели, на spec нет меток, но их слои внимания черновика всегда регистрируются после всех целевых слоёв, поэтому аннотируется та группа, которая содержит последний зарегистрированный слой📎 vllm/v1/core/kv_cache_utils.py:2183-2184. Это правило действует только тогда, когда группа точно разделяетkv_cache_specвсе слои📎 vllm/v1/core/kv_cache_utils.py:2183-2184。

Сценарий: распределение KV при спекулятивном декодировании

Когдаspeculative_configвключён иuse_eagle_block_drop()истинно,_annotate_eagle_groupsвызывается📎 vllm/v1/core/kv_cache_utils.py:2175-2177. Результат аннотированияis_eagle_groupвлияет на последующую стратегию распределения блоков — блоки группы черновика могут быть отброшены после верификации.

В основном путиget_kv_cache_groupsаннотирование происходит после группировки📎 vllm/v1/core/kv_cache_utils.py:2364-2365. Если ни одна группа не аннотирована как группа черновика,_warn_if_unannotated_eagle_mambaвыдаст предупреждение📎 vllm/v1/core/kv_cache_utils.py:2192-2222。

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Drafter as 草稿模型
    participant Target as 目标模型
    participant KV as KV Cache Manager
    Sched->>Drafter: 请求生成 k 个候选 token
    Drafter->>KV: 分配草稿组 block (is_eagle_group=True)
    Drafter-->>Sched: 返回候选 token 序列
    Sched->>Target: 并行验证候选 (一次前向)
    Target->>KV: 读取目标组 block
    Target-->>Sched: 返回接受/拒绝掩码
    Sched->>KV: 丢弃被拒绝的草稿 block

Размышления о дизайне и подводные камни

Почему группу черновика нужно аннотировать отдельно?Токены, сгенерированные моделью-черновиком, после верификации могут быть отклонены, и соответствующие KV нужно отбросить. Если KV черновика и целевые KV смешаны в одной группе, операция отбрасывания может ошибочно задеть целевые KV. Аннотирование позволяет планировщику точно освобождать ресурсы.

Хрупкость правила отката по позиции.Правило второе опирается на договорённость «слои черновика регистрируются последними», в комментариях явно указано, что это hacky check, и оставлен FIXME📎 vllm/v1/core/kv_cache_utils.py:2158-2159. Когда хвостовой кеш черновика охватывает несколько групп, это правило аннотирует только группу, содержащую последний слой, и требует обобщения.

Дополнительные ограничения модели Mamba.Если включено спекулятивное декодирование, но ни одна группа не распознана как черновая, и существует группа Mamba, будет вызвано предупреждение📎 vllm/v1/core/kv_cache_utils.py:2211-2213. Обычно это означает, что spec чернового слоя невозможно отличить от целевого слоя, и необходимо проверить порядок регистрации модели.

12.3 LoRA: динамические адаптеры без перезагрузки базовой модели

Интуитивная модель

LoRA похожа на смену разных чехлов для одного и того же телефона: сам телефон (базовая модель) не меняется, а при смене чехла (адаптера) он приобретает другой стиль. Без этого для каждой задачи дообучения пришлось бы загружать полный набор весов, что не поместилось бы в видеопамять.

Структура данных: двойной LRU-кэш и массив слотов

LoRAModelManagerДва LRU-кэша управляют жизненным циклом адаптеров📎 vllm/lora/model_manager.py:115-120:

python
self._registered_adapters: AdapterLRUCache[LoRAModel] = AdapterLRUCache(
    self.capacity, self.deactivate_adapter
)
self._active_adapters: AdapterLRUCache[None] = AdapterLRUCache(
    self.lora_slots, self._deactivate_adapter
)

capacity— это общее количество адаптеров, которые можно кэшировать на стороне CPU (max_cpu_loras)📎 vllm/lora/model_manager.py:340-342,lora_slots— это количество адаптеров, которые могут быть одновременно активны на стороне GPU (max_loras)📎 vllm/lora/model_manager.py:345-346。_registered_adaptersпри удалении вызываетdeactivate_adapterобратный вызов📎 vllm/lora/model_manager.py:71-74, гарантируя, что при вытеснении из кэша CPU копия на GPU также будет очищена.

lora_index_to_id— это массив длинойlora_slots, который сопоставляет индекс слота GPU с id адаптера📎 vllm/lora/model_manager.py:122. Этот массив является ключевым индексом для punica wrapper при выполнении пакетных вычислений LoRA.

Сценарий: активация адаптера

Когда запрос поступает с LoRA-адаптером,activate_adapterвызывается📎 vllm/lora/model_manager.py:352-409:

Первый шаг — проверить, активирован ли он уже; если да, сразу вернуть📎 vllm/lora/model_manager.py:352-354。

Второй шаг — найти свободный слот. Перебратьlora_index_to_idнайти первыйNone 📎 vllm/lora/model_manager.py:362-362. Если свободного слота нет, выброситьValueError("No free lora slots") 📎 vllm/lora/model_manager.py:368-368。

Третий шаг — обновить состояние и перебрать все обёрнутые модули, вызватьmodule.set_lora(index, lora_a, lora_b)скопировать веса в stacked buffer на GPU📎 vllm/lora/model_manager.py:377-401. Если у какого-либо модуля нет соответствующих весов LoRA, вызватьreset_lora(index)обнулить📎 vllm/lora/model_manager.py:378-385。

Четвёртый шаг — если ни один вес не был применён, вывести одноразовый отладочный лог📎 vllm/lora/model_manager.py:411-416. Это ожидаемое поведение при конвейерном параллелизме или экспертном параллелизме — некоторые rank не содержат адаптированных слоёв.

Обёртка модулей: от nn.Linear до BaseLayerWithLoRA

_create_lora_modulesПеребрать все именованные модули модели📎 vllm/lora/model_manager.py:462-606. Ключевая логика:

  • ПропуститьPPMissingLayer 📎 vllm/lora/model_manager.py:473-474。
  • Фильтрация поtarget_modules: если не указано, использоватьis_supported_lora_moduleдля определения, иначе использовать_match_target_modules 📎 vllm/lora/model_manager.py:479-493。
  • Обработка алиасных модулей: один и тот же базовый модуль может быть доступен по нескольким путям (например, MoE gate находится и в block, и в runner). В этом случае атрибут алиаса перенаправляется на тот же wrapper, но без повторной регистрации, иначеactivate_adapterвызовет для алиасаreset_loraи очистит только что установленные веса📎 vllm/lora/model_manager.py:512-527。
  • Использоватьfrom_layerсоздать wrapper и заменить исходный модуль📎 vllm/lora/model_manager.py:546-553。

Размышления о дизайне и подводные камни

Изменение раскладки слотов вызывает обновление маппинга. set_adapter_mappingсравнивает не только изменение mapping, но иlora_index_to_idснимок кортежа📎 vllm/lora/model_manager.py:1323-1331. Причина ясно указана в комментарии: один внеполосныйadd_lora()может вызвать вытеснение LRU и перераспределение слотов, тогда как выполняющийся batch и его mapping не изменились📎 vllm/lora/model_manager.py:1323-1331. Если смотреть только на mapping, punica metadata будет использовать устаревшую раскладку слотов.

EP-срезы для MoE.При включении экспертного параллелизма checkpoint содержит веса всех глобальных экспертов, но каждый rank владеет толькоlocal_num_expertsиз них._stack_moe_lora_weightsСначала выполнитьglobal_num_expertsreshape, затем срез[expert_start:expert_end] 📎 vllm/lora/model_manager.py:966-977. Без EP срез является no-op.

Момент выполнения pin_memory.Упаковка весов (например,pack_moe) может сделать распределение pin_memory недействительным, поэтому pin_memory выполняется после объединения всех весов📎 vllm/lora/model_manager.py:916-934. В комментарии явно указаны две причины: в MoE-моделях количество весов LoRA велико, и преждевременный pin даёт значительные накладные расходы; упаковка может сделать распределение недействительным📎 vllm/lora/model_manager.py:916-921。

Размышления о дизайне: точки взаимодействия трёх компонентов

Три особенности пересекаются на уровне управления KV cache. Префиксный кэш переиспользует KV через block hash; спекулятивное декодирование черезis_eagle_groupпомечает и различает черновые KV; LoRA через_gen_lora_extra_hash_keysподмешивает имя адаптера в block hash📎 vllm/v1/core/kv_cache_utils.py:568-581, гарантируя, что одинаковые последовательности токенов для разных адаптеров не будут ошибочно попадать в KV друг друга.

generate_block_hash_extra_keysПомещает ключ LoRA в начало списка дополнительных ключей📎 vllm/v1/core/kv_cache_utils.py:640-642, вместе с ключами мультимодальности, cache salt и prompt embeds формируя полный вход хеширования. Это гарантирует: даже если токены двух запросов полностью совпадают, при разных LoRA-адаптерах их block hash будет разным, и KV не будут переиспользованы ошибочно.

Краткое содержание главы

Вопросы для размышления и самопроверки к этой главе

Q1: Если убрать логику случайного seed для некриптографического хеша вinit_none_hashи всегда использовать фиксированный seed, в каких сценариях это создаст угрозу безопасности? Почему в комментариях к исходному коду особо подчёркивается, что xxhash требует секретного seed?

Разбор ответа: В исходном коде в_NON_CRYPTO_HASH_FUNCTIONSxxhash и xxhash_cbor явно указаны как алгоритмы, не устойчивые к коллизиям📎 vllm/v1/core/kv_cache_utils.py:125-126。resolve_none_hash_seedдля таких алгоритмов возвращаетсяos.urandom(32).hex() 📎 vllm/v1/core/kv_cache_utils.py:143-144. Если перейти на фиксированный seed, злоумышленник сможет офлайн предвычислить блок, коллизирующий с целевым префиксом, и сконструировать запрос с тем же хешем, но другим содержимым, тем самым попасть в чужой KV cache и прочитать его — это утечка информации между запросами. Устойчивость SHA-256 к коллизиям не зависит от секретности seed, поэтому фиксированный seed влияет только на воспроизводимость, но не на безопасность📎 vllm/v1/core/kv_cache_utils.py:97-111。

Q2: _create_lora_modulesпри обработке алиасных модулей, если убрать логику «не повторять регистрацию» и напрямую вызватьregister_moduleтакже для алиаса, вactivate_adapterчто произойдёт? Пожалуйста, проанализируйте в сочетании сreset_loraпутём вызова.

Справочный анализ:activate_adapterобходитself.modulesи для каждого модуля вызываетset_loraилиreset_lora 📎 vllm/lora/model_manager.py:377-401. Если и псевдоним, и каноническое имя зарегистрированы, один и тот же базовый wrapper будет доступен дважды. По пути канонического имени_get_lora_layer_weightsможет найти веса и вызватьset_loraдля записи; по пути псевдонима из-за несовпадения имён_get_lora_layer_weightsвозвращает None, что вызываетreset_lora(index) 📎 vllm/lora/model_manager.py:378-385, обнуляя только что записанные веса. Комментарии в исходном коде явно указывают на эту ловушку📎 vllm/lora/model_manager.py:519-523. Правильный подход — перенаправить атрибут псевдонима на тот же wrapper, но не регистрировать его повторно📎 vllm/lora/model_manager.py:531-537。

Q3: BlockHashListWithBlockSizeзависит от свойства «хеш целевого блока равен хешу его последнего внутреннего hash-блока». Если хеш-функция не является цепной (то есть каждый блок хешируется независимо), сможет ли этот класс корректно работать? В каких случаях возникнут ошибочные попадания в кеш?

Справочный анализ: Нет._get_value_atнапрямую возвращаетself.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851, и это реализация предполагает, что хеш последнего hash-блока уже цепно покрывает все токены перед ним. Если хеширование независимое, это значение отпечатывает только содержимое последнего hash-блока, а не всего целевого блока. Два целевых блока могут различаться в первой половине, но иметь одинаковый последний hash-блок, что приведёт к коллизии хешей,find_longest_cache_hitошибочно переиспользует несовпадающий KV. Комментарии в исходном коде явно указывают: «Each hash_block_size hash is already chained over its entire prefix»📎 vllm/v1/core/kv_cache_utils.py:2787-2792。

Следующая глава перейдёт к системе плагинов и расширяемости и покажет, как vLLM поддерживает разнообразные формы развёртывания через абстракцию платформ, IO-процессоры и расширения эндпоинтов.

В этой главе разобраны внутренние механизмы трёх ключевых продвинутых функций инференса vLLM. Ядро префиксного кеширования — цепной хеш блоков: hash_block_tokens хеширует вместе родительский хеш, кортеж токенов и дополнительные ключи, а стратегия seed для NONE_HASH балансирует между межпроцессным совместным использованием и безопасностью от коллизий. Спекулятивное декодирование различает группы черновых KV через аннотацию is_eagle_group. LoRA управляет жизненным циклом адаптеров через двойной LRU-кеш и массив слотов, а также подмешивает имя адаптера в хеш блоков для изоляции кеша. Вместе эти функции демонстрируют глубину и гибкость vLLM в оптимизации инференса. Далее мы перейдём к системе плагинов и расширяемости vLLM и посмотрим, как плагины платформ адаптируются к новому оборудованию, как плагины IO processor вмешиваются в обработку мультимодального ввода и как плагины эндпоинтов внедряют пользовательские маршруты API. Понимание порядка загрузки при регистрации и обнаружении плагинов покажет, как расширять возможности vLLM без изменения核心ного кода.

CHAPTER 13

Глава 13: Производственное развертывание и отказоустойчивость: стабильность в кластере

Проект: vllm-project/vllm · Прогресс по книге: глава 13 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы увидели, что такие продвинутые функции, как префиксное кеширование, спекулятивное декодирование и LoRA, глубоко связаны с ядром планировщика, управления KV и выполнения модели. Но чтобы движок инференса действительно вышел в продакшен, одной производительности недостаточно — он должен ответить на более сложный вопрос: когда сообщество хочет подключить новое оборудование, новый формат мультимодального ввода или пользовательский HTTP-маршрут, как сделать это без форка核心ного кода? Именно в этом смысл системы плагинов. Архитектура vLLM изначально многопроцессная: фронтенд-процесс API Server, процесс EngineCore и Worker-процесс для каждого TP/PP rank. Если бы механизм плагинов просто «выполнял код при import», он либо повторно выполнялся бы в каждом процессе, накапливая побочные эффекты, либо выполнялся бы только в главном процессе, и Worker не получал бы расширения. В этой главе мы разберём, как vLLM использует стандартный механизм Python entry_points вместе с тремя ограничениями — группа (group) + граница процесса + момент загрузки — чтобы построить систему плагинов, которая охватывает все процессы и при этом точно контролирует поверхность暴露. Мы сосредоточимся на трёх основных линиях: плагины платформ (адаптация нового оборудования), плагины IO processor (вмешательство в обработку мультимодального ввода), плагины эндпоинтов (внедрение пользовательских маршрутов API). Стратегии загрузки у всех трёх совершенно разные, и понимание этого различия означает понимание философии компромисса vLLM между «возможностью расширения» и «границей безопасности».

I. Обнаружение и загрузка плагинов: групповой контракт entry_points

Интуитивная модель: «радиоканалы» плагинов

Представьте систему плагинов vLLM как набор радиоканалов. Каждый пакет плагина при установке черезsetup.pyизentry_points«регистрирует» на некотором канале свой позывной (plugin name) и функцию отклика (plugin value). vLLM при запуске сканирует эти каналы и решает, какие каналы в каких процессах будут «прослушиваться».

Без этого механизма расширение vLLM возможно только через изменение исходного кода — сообществу при добавлении каждой новой аппаратной платформы приходилось бы поддерживать отдельный форк, что в итоге привело бы к расщеплению версий. Ценность механизма группировки заключается в следующем:Один и тот же пакет плагина может быть зарегистрирован только в определённом канале, что ограничивает его загрузку конкретными процессами。

Структура данных: пять констант группировки и глобальный флаг

В vLLM вvllm/plugins/__init__.pyв верхней части определены пять констант entry point group, каждая из которых соответствует определённой стратегии загрузки:

📎 vllm/plugins/__init__.py:16-30

python
DEFAULT_PLUGINS_GROUP = "vllm.general_plugins"
IO_PROCESSOR_PLUGINS_GROUP = "vllm.io_processor_plugins"
PLATFORM_PLUGINS_GROUP = "vllm.platform_plugins"
STAT_LOGGER_PLUGINS_GROUP = "vllm.stat_logger_plugins"
ENDPOINT_PLUGINS_GROUP = "vllm.endpoint_plugins"

В комментариях скрыта ключевая информация:DEFAULT_PLUGINS_GROUPввсе процессызагрузка (process0, engine core, worker);IO_PROCESSOR_PLUGINS_GROUP только в process0;PLATFORM_PLUGINS_GROUPзагружается во всех процессах, но момент срабатывания —current_platformпри первом обращении;STAT_LOGGER_PLUGINS_GROUPтолько в process0 и в асинхронном режиме;ENDPOINT_PLUGINS_GROUPтолько во фронтенд-процессе API Server.

Сразу за этим следует модульная глобальная переменнаяplugins_loaded = False 📎 vllm/plugins/__init__.py:32-33, которая является защитой идемпотентной загрузки — в комментарии чётко указано "make sure one process only loads plugins once".

Пошагово: полный поток вызовов одногоload_plugins_by_groupвызова

Сценарий: пользователь вsetup.pyзарегистрировалvllm.general_pluginsподregister_dummy_model, теперь vLLM запускается, некоторый процесс вызываетload_general_plugins()。

Шаг первый: защита идемпотентности. load_general_pluginsсначала проверяетplugins_loaded, если ужеTrue— сразу возвращает📎 vllm/plugins/__init__.py:77-90. Обратите внимание на тонкость: защита устанавливаетсядозагрузки, что означает, что даже если последующая загрузка выбросит исключение, повторной попытки не будет. Это сделано намеренно — сбой загрузки плагина не должен приводить к повторным попыткам процесса.

Шаг второй: обнаружение.входит вload_plugins_by_group, черезimportlib.metadata.entry_points(group=group)получает все установленные entry points📎 vllm/plugins/__init__.py:36-45данной группы. Если пусто, записывает debug-лог и возвращает пустой словарь.

Шаг третий: классификация логов.Исходный код различает уровень логирования для групп по умолчанию и не по умолчанию:is_default_groupесли истинно, используетсяlogger.debug, иначеlogger.info 📎 vllm/plugins/__init__.py:47-54. Мотивация вполне практичная —vllm.general_pluginsобычно содержит множество плагинов регистрации моделей, и использование INFO привело бы к заспамливанию логов; а плагинов платформ/эндпоинтов немного и они важны, поэтому заслуживают видимости на уровне INFO.

Шаг четвёртый: фильтрация по белому списку.читаетenvs.VLLM_PLUGINS, еслиNone— загружает все, иначе загружает только плагины, имена которых есть в списке📎 vllm/plugins/__init__.py:62-70. Обратите внимание, чтоplugin.load()обёрнут в try/except, сбой загрузки одного плагина лишь записывает exception-лог и не влияет на другие плагины📎 vllm/plugins/__init__.py:68-72。

Шаг пятый: выполнение.возвращается вload_general_plugins, для каждой загруженной функции напрямую вызываетсяfunc() 📎 vllm/plugins/__init__.py:77-90. Именно поэтому документация подчёркивает, что функции плагинов должны бытьповторно входимыми (re-entrant)— они могут вызываться многократно в нескольких процессах.

Приведённая ниже блок-схема описывает полный путь принятия решенийload_plugins_by_group:

mermaid
flowchart TD
    start["load_plugins_by_group(group)"] --> discover["entry_points(group=group)"]
    discover --> empty{"len(discovered) == 0?"}
    empty -->|是| ret_empty["返回 {}"]
    empty -->|否| log["按 is_default_group 选 log_level"]
    log --> loop["遍历 discovered_plugins"]
    loop --> check{"allowed_plugins is None<br/>或 plugin.name in allowed?"}
    check -->|否| skip["跳过该插件"]
    check -->|是| load["func = plugin.load()"]
    load --> load_ok{"加载成功?"}
    load_ok -->|否| log_exc["logger.exception 记录"]
    load_ok -->|是| add["plugins[name] = func"]
    skip --> next["下一个插件"]
    log_exc --> next
    add --> next
    next --> loop
    loop --> ret["返回 plugins 字典"]

Размышления о дизайне: почему используются entry_points, а не файл конфигурации

〔Предположения о дизайне и архитектурные компромиссы〕

Выборentry_pointsвместо пользовательского файла конфигурации обусловлен ключевой мотивацией —распространять плагины вместе с Python-пакетом. После того как пользовательpip install vllm-add-dummy-platform, плагин автоматически появляется в соответствующей группе, без необходимости вручную редактировать конфигурацию vLLM. Это продолжает традицию экосистемы плагинов таких инструментов, как pytest, flake8. Цена — обнаружение плагинов зависит от метаданных пакета: если пакет плагина установлен не полностью (например, скопирован только каталог исходников без pip), entry_points не будут обнаружены.

---

II. Плагины платформ: уровень абстракции для адаптации оборудования

Интуитивная модель: платформа — это "переводчик аппаратных диалектов"

PlatformКласс— единственный переводчиквсего взаимодействия vLLM с оборудованием. Код модели вызывает толькоcurrent_platform.get_attn_backend_cls()、current_platform.is_cuda_alike()такие абстрактные методы и никогда напрямуюimport torch.cuda. Без этого уровня абстракции для поддержки каждой новой аппаратной платформы пришлось бы добавлять в код модели ветвленияif device == "xpu", что в итоге превратилось бы в спагетти-код.

Структура данных: расположение полей базового класса Platform

Platform— это чистый класс (не используется через экземпляры), ключевые атрибуты класса определены в началеvllm/platforms/interface.py📎 vllm/platforms/interface.py:135-179:

python
class Platform:
    _enum: PlatformEnum
    device_name: str
    device_type: str
    dispatch_key: str = "CPU"
    ray_device_key: str = ""
    device_control_env_var: str = "VLLM_DEVICE_CONTROL_ENV_VAR_PLACEHOLDER"
    ray_noset_device_env_vars: list[str] = []
    simple_compile_backend: str = "inductor"
    dist_backend: str = ""
    supported_quantization: list[str] = []
    additional_env_vars: list[str] = []
    _global_graph_pool: Any | None = None

_enum— это значение перечисленияPlatformEnum, определяющееis_cuda()、is_rocm()и другие проверки📎 vllm/platforms/interface.py:69-78。device_control_env_var— это платформо-независимая абстракция "переменной окружения видимости устройств" — для CUDA этоCUDA_VISIBLE_DEVICES, другие платформы определяют свои📎 vllm/platforms/interface.py:151-152。_global_graph_pool— это кэш пула памяти CUDA graph на уровне класса, лениво инициализируемый черезget_global_graph_pool📎 vllm/platforms/interface.py:1210-1215。

Примечательна__getattr__логика подстраховки📎 vllm/platforms/interface.py:1189-1208: при обращении к несуществующему атрибуту Platform она пытается перенаправить его из пространства имёнtorch.<device_type>. Это позволяет коду платформы писатьcurrent_platform.memory_allocated(), а фактически вызыватьtorch.cuda.memory_allocated(). Но исходный код намеренно исключает dunder-методы — иначе проверка pickle__getstate__получила быNoneи попыталась бы его вызвать📎 vllm/platforms/interface.py:1182-1185。

Пошагово: преобразование трёх пространств имён идентификаторов устройств

Наиболее подверженный ошибкам аспект абстракции платформы —пространства имён идентификаторов устройств. В комментариях исходного кода явно перечислены три📎 vllm/platforms/interface.py:275-283:

  • logical: внутренний local rank vLLM, индекс_assigned_physical_gpu_ids
  • visible: номер torch/CUDA текущего процесса после переназначения черезCUDA_VISIBLE_DEVICES
  • physical: глобальный GPU ID, используемый топологическими API вроде NVML, не зависящий от переменных окружения

Сценарий: процессу Worker назначен физический GPU[4, 5], переменная окруженияCUDA_VISIBLE_DEVICES=4,5, теперь нужно преобразовать local rank 0 вtorch.device("cuda:0")。

Шаг первый: logical → physical. device_id_to_physical_device_id(0)сначала проверяет_assigned_physical_gpu_ids, если установлено — сразу возвращает индекс4 📎 vllm/platforms/interface.py:296-297. Если не установлено, изdevice_control_env_varразбирает список через запятую и берёт элемент 0📎 vllm/platforms/interface.py:305-311. Обратите внимание, что исходный код намеренно обрабатываетпустую строкукак неустановленное значение — это допустимая конфигурация при запуске движка Ray на placement group только с CPU📎 vllm/platforms/interface.py:296-297。

Шаг второй: physical → visible. logical_device_id_to_visible_device_id(0)Получив physical4, затем разбить переменные окружения на[4, 5], найти4индекс0вернуть📎 vllm/platforms/interface.py:316-339. Если physical ID отсутствует в списке видимых, выброситьRuntimeError— это жёсткая защита от межпроцессного ошибочного использования невидимых устройств.

set_assigned_physical_gpu_idsидемпотентный дизайн также заслуживает внимания: повторная установка того же значения — no-op, установка другого значения выбрасываетRuntimeError 📎 vllm/platforms/interface.py:38-56. Это предотвращает случайное перезаписывание маппинга устройств в многопоточной среде.

Регистрация и внедрение конфигурации платформенных плагинов

Платформенные плагины регистрируются черезvllm.platform_pluginsгруппу, функция плагина возвращает полное квалифицированное имя класса платформы (илиNoneозначает, что текущая среда не поддерживается)📎 docs/design/plugin_system.md:50-50. Минимальная реализация, приведённая в документации, требует📎 docs/design/plugin_system.md:100-100:

  • _enumобычно устанавливается вPlatformEnum.OOT(out-of-tree)
  • device_typeвозвращает строку типа устройства, распознаваемую PyTorch
  • check_and_update_configвызывается на раннем этапе инициализации vLLM,необходимо здесь установитьworker_cls
  • get_attn_backend_clsвозвращает имя класса бэкенда внимания
  • get_device_communicator_clsвозвращает имя класса коммуникатора

check_and_update_config— самый критичный хук платформенного плагина📎 vllm/platforms/interface.py:583-592. Он принимаетVllmConfigссылку и модифицирует на месте, может настраивать размер блока, режим графа и т.д. Документация подчёркивает: "самое важное — worker_cls должен быть установлен здесь"📎 docs/design/plugin_system.md:105-105— потому что vLLM нужно знать, какой класс Worker использовать для инстанцирования рабочего процесса.

Размышления о дизайне: трёхэтапная стратегия выравнивания block size

Самая сложная логика в платформенном интерфейсе — этоupdate_block_size_for_backend 📎 vllm/platforms/interface.py:666-708. Она разделена на три этапа для обеспечения совместимости block size с бэкендом внимания:

Phase 1: если пользователь явно не указал--block-size, вызвать_preferred_block_size_for_backendsвыбрать минимальный block size, поддерживаемый всеми бэкендами📎 vllm/platforms/interface.py:687-697. Эта функция использует LCM (наименьшее общее кратное) для перебора кандидатов, потому что некоторые бэкенды (например, CPU_MLA) принимают только точные размеры, а не кратные📎 vllm/platforms/interface.py:622-663。

Phase 2: гибридные модели (attention + mamba) требуют выравнивания block и mamba page size📎 vllm/platforms/interface.py:699-702。

Phase 3: когда несколько KV dtype совместно используют block pool (например, nvfp4 основной + неквантованные skip-слои), необходимо расширить основной block до покрытия максимального padded spec page📎 vllm/platforms/interface.py:704-708。

〔Проектные выводы и архитектурные компромиссы〕

Такой поэтапный дизайн отражает реальность, с которой сталкивается vLLM: ограничения на block size от разного оборудования, разных схем квантования и разных архитектур моделей конфликтуют между собой и не могут быть решены одной формулой. Поэтапность позволяет обрабатывать каждое ограничение независимо и в итоге получить решение, удовлетворяющее всем ограничениям.

---

III. IO Processor и плагины эндпоинтов: обработка ввода и расширение API

Интуитивная модель: IO Processor — это "слой мультимодального перевода"

Вход мультимодальных моделей (например, LLaVA) — не чистый текст, а смесь текста и изображений. Плагин IO Processor отвечает за преобразование исходных мультимодальных данных в тензоры, которые может потреблять модель, а затем за преобразование вывода модели обратно в человекочитаемый формат. Он как переводчик на таможне: входящий иностранный язык (изображения/аудио) переводится на родной язык модели, исходящий родной язык модели переводится обратно на иностранный.

Пошагово: обнаружение и инстанцирование IO Processor

Сценарий: загрузка модели с HF config, содержащим полеio_processor_plugin.

Шаг первый: определить имя плагина. get_io_processorприоритетно использовать явно переданныйplugin_from_init, иначе прочитать изhf_configполяio_processor_pluginзначение📎 vllm/plugins/io_processors/__init__.py:42-50. Если оба пусты, вернутьNone— означает, что модель не нуждается в IO processor📎 vllm/plugins/io_processors/__init__.py:52-54。

Шаг второй: загрузить все установленные плагины.вызватьload_plugins_by_group(IO_PROCESSOR_PLUGINS_GROUP)получить все плагины в этой группе📎 vllm/plugins/io_processors/__init__.py:59-61。

Шаг третий: построить загружаемый маппинг.обойти каждый плагин, вызвать его функцию для полученияprocessor_cls_qualname, если неNoneто записать вloadable_plugins 📎 vllm/plugins/io_processors/__init__.py:66-76. Обратите внимание, что вызов функции каждого плагина также обёрнут в try/except, единичная ошибка не влияет на остальные.

Шаг четвёртый: валидация и инстанцирование.если количество загружаемых плагинов равно 0, выброситьValueErrorс сообщением "требуется плагин IOProcessor, но ни один не установлен"📎 vllm/plugins/io_processors/__init__.py:66-76. Если требуемое моделью имя плагина отсутствует в списке загружаемых, выброситьValueErrorи перечислить все доступные имена плагинов📎 vllm/plugins/io_processors/__init__.py:80-81. Наконец, черезresolve_obj_by_qualnameразрешить имя класса и инстанцировать📎 vllm/plugins/io_processors/__init__.py:80-81。

Плагины эндпоинтов: безопасная позиция "по умолчанию отказ"

Плагины эндпоинтов — самый особый класс в этой главе, потому что онипо умолчанию не загружаются。load_endpoint_pluginsстрока документации явно объясняет причину: плагины эндпоинтов добавляют HTTP-маршруты к API Server, расширяя поверхность сетевой экспозиции, поэтому применяется более строгая позиция "по умолчанию отказ", чемload_plugins_by_group📎 vllm/plugins/__init__.py:93-94。

Конкретное правило: только когда имя плагинаявно присутствует вVLLM_PLUGINS, и егоrequired_tasksравноNoneили пересекается с tasks, поддерживаемыми сервером, он загружается📎 vllm/plugins/__init__.py:108-108。

Сценарий: пользователь установил плагин эндпоинта, но забыл установитьVLLM_PLUGINS。

Шаг первый: проверить, не установлен ли VLLM_PLUGINS.еслиenvs.VLLM_PLUGINS is None, сначала обнаружить плагины в этой группе, если есть — записать warning "требуется явный allowlist"📎 vllm/plugins/__init__.py:126-126. Обратите внимание, что комментарий в исходном коде особо указывает:VLLM_PLUGINS=""парсится как[""]а неNone, поэтому рассматривается как "allowlist, не совпадающий ни с одним плагином", а не "не установлен"📎 vllm/plugins/__init__.py:108-108. Это разграничение важно — пустая строка это явное "ничего не загружать", аNoneэто "не сконфигурировано".

Шаг второй: загрузка и инстанцирование.получив фабричную функцию черезload_plugins_by_group, поочерёдно вызватьfactory()инстанцировать📎 vllm/plugins/__init__.py:133-141. Ошибка инстанцирования записывается как exception и continue.

Шаг третий: task-гейтинг.проверитьplugin.required_tasks, если неNoneи пересекается сsupported_tasksНет пересечения, пропустить этот плагин📎 vllm/plugins/__init__.py:144-145. Это позволяет одному пакету плагина регистрировать разные конечные точки для разных задач (например, embedding vs generation).

Приведённая ниже диаграмма последовательности описывает полное взаимодействие плагина конечной точки от обнаружения до загрузки:

mermaid
sequenceDiagram
    participant App as "API Server 前端进程"
    participant Loader as "load_endpoint_plugins()"
    participant Env as "envs.VLLM_PLUGINS"
    participant EP as "entry_points(ENDPOINT_PLUGINS_GROUP)"
    participant Factory as "plugin factory()"

    App->>Loader: load_endpoint_plugins(supported_tasks)
    Loader->>Env: 读取 VLLM_PLUGINS
    alt VLLM_PLUGINS 为 None
        Loader->>EP: entry_points(group)
        EP-->>Loader: 发现的插件
        Loader-->>App: 返回 [](记录 warning)
    else VLLM_PLUGINS 已设置
        Loader->>EP: load_plugins_by_group(group)
        EP-->>Loader: factories 字典
        loop 每个 factory
            Loader->>Factory: factory()
            Factory-->>Loader: EndpointPlugin 实例
            Loader->>Loader: 检查 required_tasks 交集
            alt tasks 不匹配
                Loader->>Loader: 跳过(记录 info)
            else tasks 匹配
                Loader->>Loader: append 到结果列表
            end
        end
        Loader-->>App: 返回 endpoint_plugins 列表
    end

Размышление о дизайне: граница процесса определяет стратегию загрузки

Различия в стратегиях загрузки трёх типов плагинов по сути являются отображениемграницы процесса:

Тип плагинаПроцесс загрузкиПоведение по умолчаниюМотивация
generalВсе процессыЗагружаются всеРегистрация модели должна быть видна каждому Worker
platformВсе процессыЗагружаются всеАбстракция оборудования используется всеми процессами
io_processorТолько process0Загружаются всеОбработка ввода происходит только на фронтенде
stat_loggerТолько process0 (асинхронно)Загружаются всеЛоги собираются только в главном процессе
endpointТолько API ServerПо умолчанию отклоняетсяРасширение поверхности сетевого воздействия требует явной авторизации
〔Проектные предположения и архитектурные компромиссы〕

«Отклонение по умолчанию» для плагинов конечных точек — стандартная практика в области безопасности: любое расширение, увеличивающее поверхность атаки, должно быть opt-in. Остальные плагины загружаются по умолчанию, поскольку они не предоставляют напрямую сетевые интерфейсы, а экосистеме сообщества необходим низкопороговый опыт подключения.

Производственная ловушка: тихая деградация при неудачной загрузке плагина

load_plugins_by_groupДля каждого плагинаplugin.load()обёрнут в try/except, при неудаче только записывается exception📎 vllm/plugins/__init__.py:68-72. Это означает, чтоповреждённый плагин не помешает запуску vLLM, но и не выдаст явную ошибку — пользователь может быть озадачен вопросом «почему мой плагин не работает».

Рекомендация по диагностике: установите уровень логирования DEBUG, найдите"Failed to load plugin". Если плагин находится в группеvllm.general_plugins, уровень логирования по умолчанию — DEBUG, и его нужно явно включить, чтобы увидеть детали загрузки📎 vllm/plugins/__init__.py:49-50。

Другая ловушка — момент установкиplugins_loadedзащитного флага📎 vllm/plugins/__init__.py:77-90: он устанавливается до загрузкиTrue. Если первая загрузка по какой-то причине не удалась (например, исключение при сканировании entry_points), последующие вызовы будут сразу возвращаться без повторной попытки. В тестовой среде это может привести к странному явлению «плагин то работает, то нет».

---

Краткое содержание главы

Система плагинов vLLM построена на Pythonentry_points, черезпять констант группировкиразделяет типы расширений, черезграницу процессаопределяет область загрузки, черезVLLM_PLUGINSбелый списокуправляет набором загрузки. Плагины платформы используютPlatformбазовый класс для абстрагирования различий оборудования; преобразование трёх пространств имён идентификаторов устройств (logical/visible/physical) является ядром управления устройствами между процессами; плагины IO processor срабатывают через полеio_processor_pluginконфигурации HF и отвечают за трансляцию мультимодального ввода; плагины конечных точек придерживаются позиции «отклонение по умолчанию» и загружаются только при явном allowlist и совпадении task, чтобы контролировать поверхность сетевого воздействия.

Три основные линии используют один и тот же механизм обнаружения, но различия в стратегиях загрузки отражают компромисс vLLM между «удобством расширения» и «границами безопасности»: плагины, не предоставляющие сетевой доступ, загружаются по умолчанию, а плагины, предоставляющие сетевой доступ, требуют opt-in.

Вопросы для размышления и самопроверки по главе

Q1: Если убратьload_plugins_by_groupвplugin.load()try/except и позволить ошибке загрузки выбрасываться напрямую, как это повлияет на многопроцессный запуск vLLM? В каких сценариях это, наоборот, лучший дизайн?
〔Проектные предположения и архитектурные компромиссы〕

Справочный анализ: Текущая реализация📎 vllm/plugins/__init__.py:68-72позволяет неудаче загрузки отдельного плагина тихо проглатываться, записывая только лог exception. Если убрать try/except, ошибка загрузки распространится вверх доload_general_plugins, что прервёт запуск процесса. В многопроцессном сценарии это приведёт к тому, что при неудаче загрузки плагина в каком-либо Worker-процессе весь движок не сможет запуститься — это может быть хорошо (быстрый отказ, избежание несогласованности состояния из-за работы части процессов с ошибками), а может быть плохо (баг одного опционального плагина обрушит весь сервис). Лучшим дизайном могло бы быть введениеVLLM_PLUGINS_STRICTпеременной окружения: по умолчанию мягкий режим (текущее поведение), в строгом режиме ошибка загрузки сразу выбрасывает исключение. Так производственная среда может требовать «все объявленные плагины должны успешно загрузиться», а среда разработки сохраняет отказоустойчивость.

Q2: load_endpoint_pluginsВVLLM_PLUGINS="",VLLM_PLUGINSиNoneне установлены (
) — в чём разница в поведении? Почему исходный код特意 различает эти два случая?

〔Проектные предположения и архитектурные компромиссы〕Справочный анализVLLM_PLUGINS="": Комментарий в исходном коде явно указывает, что[""]интерпретируется какNone, а не📎 vllm/plugins/__init__.py:108-108, поэтому рассматривается как «allowlist, не совпадающий ни с одним плагином»VLLM_PLUGINS is None. Когдаload_endpoint_plugins,[]сразу возвращает📎 vllm/plugins/__init__.py:126-126и записывает warningVLLM_PLUGINS=""; а когдаload_plugins_by_group, код продолжит выполнение до, но поскольку пустая строка не совпадает ни с одним именем плагина, в итоге также возвращается пустой список. У обоихрезультат одинаков(плагины конечных точек не загружаются), но:Noneсемантика разная""означает «пользователь не настроил, мы активно отклоняем и предупреждаем»,

Q3: device_id_to_physical_device_idозначает «пользователь явно настроил пустой allowlist, мы уважаем его намерение и не предупреждаем». Такое различие позволяет эксплуатации «тихо отключить все плагины конечных точек», установив пустую строку, не терпя шум warning при каждом запуске.device_control_env_varВ📎 vllm/platforms/interface.py:302-308, почему исходный код обрабатывает пустой

как неустановленный? Если убрать эту проверку на пустую строку, что произойдёт в сценарии Ray с CPU-only placement group?📎 vllm/platforms/interface.py:296-297Справочный анализ!= "": Комментарий в исходном коде объясняет, что пустая переменная окружения — это легитимная конфигурация Ray при запуске CPU-only placement group на GPU-узлахdevice_ids = "".split(","). Если убрать проверку[""], код войдёт в веткуdevice_ids[device_id], получитint(""), затемValueError. Это приводит к сбою запуска движка при допустимой конфигурации Ray. После сохранения проверки пустая переменная окружения идёт вelseветку и напрямую возвращаетdevice_id, то есть предполагается, что logical ID равен physical ID — это безопасно в сценарии CPU-only, поскольку нет GPU, которые нужно отображать. Этот случай показывает: «не установлена» и «установлена в пустое значение» для переменных окружения имеют разную семантику в системах распределённой оркестрации, и код должен обрабатывать это явно.

---

Следующая глава перейдёт к архитектурным компромиссам, производственным подводным камням и будущей эволюции. Мы соберём вместе механизмы, разобранные в предыдущих тринадцати главах, рассмотрим компромиссы vLLM между производительностью, сопровождаемостью и расширяемостью, а также обсудим направления эволюции inference-движков.

К этому моменту мы уже увидели, как vLLM через механизм группировки entry_points, момент загрузки с учётом границ процессов и дифференцированные стратегии для трёх типов плагинов — платформенных, IO processor и endpoint — открывает поверхность расширения, сохраняя стабильность ядра кода. Эта система плагинов позволяет подключать новое оборудование, новые форматы ввода и новые маршруты API неинвазивным способом, но сама расширяемость означает и больше измерений для компромиссов. Следующая глава завершит книгу, системно упорядочив напряжения в ключевых проектных решениях vLLM — continuous batching и фрагментация видеопамяти, CUDA Graph и динамические формы, разделённое развёртывание и сетевые накладные расходы — и представит список подводных камней production-среды и путь диагностики, а также обсудит тенденции эволюции в направлении Rust-фронтенда, IR-слоя и гетерогенного оборудования.

CHAPTER 14

Глава 14: Архитектурная эволюция vLLM V1 и перспективы развития

Проект: vllm-project/vllm · Прогресс книги: Глава 14 / 14 · Статус проверки: строки FACT реально привязаны

В предыдущей главе мы разобрали механизм плагинного расширения vLLM и увидели, как платформенные плагины, плагины IO processor и endpoint-плагины позволяют адаптировать движок к новому оборудованию, новым модальностям и новым API без изменения ядра кода. Эта расширяемость позволяет vLLM быстро принимать изменения, но чем больше точек расширения, тем сложнее пути взаимодействия в production-среде. Когда фрагментация видеопамяти, сбой рукопожатия NCCL, инвалидация кэша компиляции и сетевой джиттер возникают одновременно, механизмы, описанные в предыдущих тринадцати главах, начинают тянуть друг друга в разные стороны, обнажая напряжения, невидимые в идеальных условиях. Эта глава не вводит новых ключевых механизмов, а собирает эти механизмы вместе, опираясь на официальную документацию troubleshooting и учитывая дизайн инструмента bench для Rust-фронтенда, рассматривает компромиссы между производительностью и эксплуатационной пригодностью и даёт практический путь диагностики.

I. Уровни оптимизации: явный контракт между временем запуска и производительностью выполнения

Интуитивная модель

Уровни оптимизации похожи на «сюжетные режимы» камеры: автоматический режим (-O2) подходит для большинства сценариев, но когда нужно быстро поймать момент (отладка), переключение в ручной режим (-O0) даёт мгновенный отклик ценой снижения качества изображения (производительности). vLLM сделал этот компромисс явным контрактом из четырёх уровней, а не спрятал его в десятках булевых флагов, которые пользователь должен собирать сам.

Раскладка полей четырёх уровней

vLLM предоставляет-O0до-O3четыре уровня📎 docs/design/optimization_levels.md:5-5. Ключевой принцип дизайна:явно установленные пользователем флаги имеют приоритет над значениями по умолчанию уровня оптимизации 📎 docs/design/optimization_levels.md:5-5. Это означает, что уровень оптимизации — лишь набор значений по умолчанию, а не жёсткое ограничение.

-O0отключает всё: без autotuning, без компиляции, без cudagraph📎 docs/design/optimization_levels.md:32-33. Конкретно это сводится к четырём переключателям:cudagraph_mode=NONE、mode=NONE, все fusion отключены,enable_flashinfer_autotune=False 📎 docs/design/optimization_levels.md:37-40。

-O1— балансная точка для сценариев разработки: включаетPIECEWISEcudagraph иVLLM_COMPILEрежим📎 docs/design/optimization_levels.md:50-51. Обратите внимание на тонкую деталь:fuse_norm_quantиfuse_act_quantвключаются только тогда, когда один из операторов использует пользовательский kernel, иначе автоматическое слияние Inductor даёт лучший эффект📎 docs/design/optimization_levels.md:61. Это типичное проектное решение в духе «не отбирай работу у компилятора».

-O2— значение по умолчанию, ориентированное на production📎 docs/design/optimization_levels.md:66-67. Оно на основе-O1добавляетFULL_AND_PIECEWISEcudagraph иfuse_allreduce_rms 📎 docs/design/optimization_levels.md:72-73。-O3в настоящее время эквивалентен-O2, резервируя📎 docs/design/optimization_levels.md:80-81。

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

Когда пользователь выполняетvllm serve model -O1, что происходит внутри? Приведённая ниже блок-схема показывает, как уровень оптимизации взаимодействует с пользовательскими флагами:

mermaid
flowchart TD
    start["用户启动 vllm serve -O1"] --> parse["解析 optimization_level=1"]
    parse --> load_defaults["加载 O1 默认值集合"]
    load_defaults --> check_user{"用户是否显式设置了<br/>cudagraph_mode?"}
    check_user -->|是| user_wins["使用用户值<br/>覆盖 O1 默认"]
    check_user -->|否| use_default["使用 O1 默认<br/>PIECEWISE"]
    user_wins --> check_fusion{"fuse_norm_quant<br/>是否涉及自定义 kernel?"}
    use_default --> check_fusion
    check_fusion -->|是| enable_fuse["启用该 fusion"]
    check_fusion -->|否| skip_fuse["跳过,交给 Inductor"]
    enable_fuse --> done["配置完成,进入引擎初始化"]
    skip_fuse --> done

Ключевой момент этой схемы —check_userветка: явно установленное пользователем всегда имеет приоритет📎 docs/design/optimization_levels.md:5-5. Это позволяет избежать трудно диагностируемых проблем вроде «уровень оптимизации тихо перезаписал мой отладочный флаг».

Проектные соображения и подводные камни

Самая распространённая производственная ловушка уровней оптимизации —слишком долгое время запуска. Документация явно рекомендует: при слишком долгом времени запуска использовать-O0или-O1 📎 docs/design/optimization_levels.md:87. Но здесь есть скрытая цена —-O0при

нет cudagraph, накладные расходы CPU на запуск каждого kernel становятся заметны, и в сценариях с высокой конкурентностью пропускная способность может упасть в несколько раз.Другая ловушка —。-O2ошибки компиляцииFULL_AND_PIECEWISE:-O2cudagraph предъявляет более сильные предположения к структуре модели; некоторые пользовательские модели не компилируются при-O1, но нормально работают приdebug_dump_path. Документация рекомендует использовать📎 docs/design/optimization_levels.md:88для получения более подробной отладочной информации-O0. Путь диагностики должен быть таким: сначала с помощью-O1、-O2убедиться в функциональной корректности, затем постепенно подниматься до

, локализуя, на каком уровне возникла проблема.

〔Проектные выводы и архитектурные компромиссы〕--enforce-eagerЭто одна и та же методология: сначала подтвердить корректность с наиболее консервативной конфигурацией, затем постепенно включать оптимизации, изолируя проблему до минимального различия в конфигурации.

---

II. Список подводных камней в продакшене: путь диагностики от симптома к первопричине

Интуитивная модель

Устранение неполадок в продакшене похоже на сортировку в отделении неотложной помощи: вы не можете провести полное обследование всех пациентов, сначала нужно по симптомам (OOM, зависание, краш) быстро сузить область поиска, а затем целенаправленно копать глубже. Документация по troubleshooting в vLLM по сути представляет собой руководство по сортировке.

Классификация симптомов и инструменты диагностики

Документация делит типичные проблемы на несколько больших категорий; мы рассмотрим их в порядке возрастания сложности диагностики.

Первая категория: зависание при скачивании/загрузке модели.Симптом — длительное отсутствие отклика после запуска. Первопричина обычно в медленной сети или медленной общей файловой системе📎 docs/usage/troubleshooting.md:11-11. Средство диагностики —--load-format dummyпропустить загрузку весов, чтобы изолировать, что именно медленно: скачивание или загрузка📎 docs/usage/troubleshooting.md:23-23. Это типичный приём «изоляции методом дихотомии».

Вторая категория: OOM видеопамяти.Документация напрямую указывает на конфигурационный документ conserving_memory📎 docs/usage/troubleshooting.md:23. Но OOM в продакшене часто вызван не слишком большим размером модели, а фрагментацией KV cache или превышением ожидаемого числа параллельных запросов.

Третья категория: изменение качества генерации.Это легко упускаемый подводный камень. v0.8.0 изменил источник параметров сэмплирования по умолчанию: с нейтральных значений по умолчанию vLLM на значения автора моделиgeneration_config.json 📎 docs/usage/troubleshooting.md:23-23. В большинстве случаев это улучшает качество, но для некоторых моделей их конфигурация оказывается хуже📎 docs/usage/troubleshooting.md:23-23. Метод диагностики — откатиться к--generation-config vllmи сравнить📎 docs/usage/troubleshooting.md:23-23。

Четвёртая категория: зависание (hang).Это наиболее сложная для диагностики категория. Документация приводит набор постепенно расширяемых переменных окружения для отладки📎 docs/usage/troubleshooting.md:41-41:

  • VLLM_LOGGING_LEVEL=DEBUG: включить подробные логи
  • VLLM_LOG_STATS_INTERVAL=1.: высокочастотный вывод состояния очереди и попаданий в кэш
  • CUDA_LAUNCH_BLOCKING=1: определить, какой именно CUDA kernel даёт сбой
  • NCCL_DEBUG=TRACE: включить подробные логи NCCL
  • VLLM_TRACE_FUNCTION=1: записывать все вызовы функций, но это замедляет более чем в 100 раз📎 docs/usage/troubleshooting.md:41

Здесь есть важная эксплуатационная дисциплина: после отладки необходимо отключить эти переменные окружения или просто открыть новый shell, иначе оставшаяся отладочная конфигурация будет постоянно замедлять систему📎 docs/usage/troubleshooting.md:11-11。

Ловушка границ процессов при отладке с точками останова

Многопроцессная архитектура vLLM делает обычныеpdbточки останова неработоспособными — если точка останова срабатывает в дочернем процессе, будет выброшеноBdbQuit 📎 docs/usage/troubleshooting.md:45-54. Два решения: использоватьforked-pdb 📎 docs/usage/troubleshooting.md:57-61, или установитьVLLM_ENABLE_V1_MULTIPROCESSING=0чтобы оставить планировщик в том же процессе📎 docs/usage/troubleshooting.md:63-68。

〔Проектные выводы и архитектурные компромиссы〕

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

Диагностика распределённой коммуникации

Для распределённого развёртывания есть отдельный документ по диагностике. Ключевая рекомендация:задавать переменные окружения при создании кластера, потому что переменные распространяются на все узлы; а установка в shell влияет только на локальный узел📎 docs/serving/distributed_troubleshooting.md:16-16。

Частая проблема —No available node types can fulfill resource request, возникает даже при достаточном количестве GPU в кластере📎 docs/serving/distributed_troubleshooting.md:16-16. Первопричина обычно в том, что у узла несколько IP, и vLLM выбрал неправильный. Решение — явно указать с помощьюVLLM_HOST_IPи проверить с помощьюray status📎 docs/serving/distributed_troubleshooting.md:16-16。

Диагностический скрипт для сбоя инициализации NCCL

Документация предоставляет полный диагностический скрипт, послойно проверяющий стек коммуникации📎 docs/usage/troubleshooting.md:89-150. Его дизайн очень многоуровневый:

mermaid
flowchart TD
    start["Запустить диагностический скрипт"] --> nccl_test["Тест PyTorch NCCL<br/>dist.all_reduce"]
    nccl_test --> nccl_ok{"value == world_size?"}
    nccl_ok -->|Нет| hw_broken["Аппаратная/драйверная неисправность<br/>Обратитесь к системному администратору"]
    nccl_ok -->|Да| gloo_test["Тест PyTorch GLOO<br/>CPU-коммуникация"]
    gloo_test --> gloo_ok{"value == world_size?"}
    gloo_ok -->|Нет| gloo_fail["Проблема конфигурации GLOO<br/>Проверьте сетевые интерфейсы"]
    gloo_ok -->|Да| pynccl_test["Тест vLLM PyNcclCommunicator"]
    pynccl_test --> pynccl_ok{"all_reduce корректен?"}
    pynccl_ok -->|Нет| pynccl_fail["Проблема обёртки vLLM NCCL"]
    pynccl_ok -->|Да| graph_test["Тест all_reduce внутри CUDA Graph"]
    graph_test --> graph_ok{"Корректно после g.replay()?"}
    graph_ok -->|Нет| graph_fail["Проблема захвата CUDA Graph<br/>Проверьте семантику stream"]
    graph_ok -->|Да| success["sanity check успешен"]

Изящество этого скрипта в том, что он послойно изолирует: сначала проверяет самый нижний уровень PyTorch NCCL, затем GLOO на стороне CPU, затем собственную обёртку vLLM PyNcclCommunicator, и наконец коммуникацию внутри CUDA Graph📎 docs/usage/troubleshooting.md:90-146. Сбой на каждом уровне указывает на разные первопричины.

Одна деталь в скрипте, заслуживающая внимания:pynccl.disabled = Falseпредназначена для обратной совместимости с версиями 0.6.4 и ниже📎 docs/usage/troubleshooting.md:121-125. В 0.6.5+ она включена по умолчанию, но эта строка кода сохранена, чтобы пользователи, читающие последнюю документацию, не запутались.

При многоузловом тестировании документация намеренно использует--rdzv_backend=staticвместоc10d, потому чтоc10dпри многоузловой конфигурации даёт сбой из-за ошибки разрешения DNS📎 docs/usage/troubleshooting.md:168-168. Это типичная конфигурация из разряда «поймёшь, только наступив на грабли».

Проектные размышления и подводные камни

Сбой инициализации NCCL(ncclCommInitRankвыдаёт unhandled system error) обычно указывает на две первопричины: отсутствиеIPC_LOCKcapability или/dev/shmне смонтирован📎 docs/usage/troubleshooting.md:311-311. Обе — классические ловушки контейнеризованного развёртывания.

Несоответствие инструментальной цепочки CUDA PTX(the provided PTX was compiled with an unsupported toolchain) означает, что PTX в wheel был скомпилирован более новой версией CUDA toolkit📎 docs/usage/troubleshooting.md:325-327. Решение — включить прямую совместимость CUDA: в Docker добавить-e VLLM_ENABLE_CUDA_COMPATIBILITY=1 📎 docs/usage/troubleshooting.md:325-327, на голом железе установить пакетcuda-compatи задатьVLLM_CUDA_COMPATIBILITY_PATH 📎 docs/usage/troubleshooting.md:325-327。

Известная проблема накладных расходов памяти NCCL:vLLM >= 0.4.3, <= 0.10.1.1устанавливаетNCCL_CUMEM_ENABLE=0для обхода бага NCCL; при подключении внешнего процесса к vLLM также необходимо задать эту переменную, иначе будет hang или краш📎 docs/usage/troubleshooting.md:375. После исправления в NCCL 2.22.3 новые версии убрали это переопределение, чтобы разрешить оптимизацию производительности📎 docs/usage/troubleshooting.md:375. Этот случай показывает:межпроцессный контракт переменных окружения — это неявная зависимость распределённых систем, и при обновлении его необходимо синхронизировать.

---

III. Rust-фронтенд: философия проектирования zero-copy в инструменте bench

Интуитивная модель

Если Python-фронтенд — это «функционально полный, но громоздкий» швейцарский нож, то Rust-инструмент bench — это «скальпель, созданный только для нагрузочного тестирования». Его цель проектирования — не покрытие функциональности, а минимизация собственных накладных расходов клиента при высокой параллельности, чтобы измеренные цифры реально отражали производительность сервера.

Структуры данных и компоновка памяти

Основной структурой данных инструмента bench являетсяRequestFuncInput 📎 rust/src/bench/src/backends/mod.rs:59-89. Он активно используетArc<str>иArc<[u32]>вместоString/Vec, что является ядром дизайна с нулевым копированием.

Рассмотрим несколько ключевых полей:prompt: Arc<str> 📎 rust/src/bench/src/backends/mod.rs:50-52— несколько параллельных запросов могут совместно использовать одну и ту же строку prompt, избегая клонирования для каждого запроса.prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77— предварительно вычисленные token ID отправляются напрямую на сервер, минуя токенизацию на стороне сервера📎 rust/src/bench/src/backends/mod.rs:74-76。

Самое изящное —multi_modal_content: Option<Arc<[Arc<str>]>> 📎 rust/src/bench/src/backends/mod.rs:81. Комментарий поясняет: мультимодальный контент передаётся как предварительно сериализованный фрагмент JSON, chat backend напрямую встраивает его в поток байтов payload, избегая любого парсинга или глубокого копирования base64-данных изображения📎 rust/src/bench/src/backends/mod.rs:78-80. Это двухуровневая структураArc: внешний уровеньArc<[...]>разделяет весь массив, внутренний уровеньArc<str>разделяет отдельные фрагменты.

chat_messages_json: Option<Arc<str>>имеет наивысший приоритет и вставляется в payload как есть📎 rust/src/bench/src/backends/mod.rs:82-85。

Десериализация без аллокаций

Парсинг SSE-потока — ещё одна ключевая точка производительности. В комментарии явно указано: используется типизированная десериализация, чтобы избежать построения полного дереваserde_json::Value, извлекаются только нужные поля📎 rust/src/bench/src/backends/mod.rs:20-24。

CompletionChunkсохраняются толькоchoicesиusageдва поля📎 rust/src/bench/src/backends/mod.rs:20-24,ChatChunkАналогично📎 rust/src/bench/src/backends/mod.rs:33-37。#[serde(default)]позволяет отсутствующему полюchoicesпо умолчанию быть пустым массивом📎 rust/src/bench/src/backends/mod.rs:20-24, что типично для потоковых ответов.

Сценарий-ориентированный поток запросов

Как данные перемещаются при отправке нагрузочного запроса? Диаграмма потока данных ниже показывает преобразование от входа к выходу:

mermaid
блок-схема LR
    input["RequestFuncInput<br/>Arc&lt;str&gt; prompt"] --> build["build_headers<br/>+ сборка payload"]
    build --> send["reqwest::Client<br/>send_request"]
    send --> sse["SSE потоковый ответ<br/>поток байтов"]
    sse --> parse["CompletionChunk<br/>типизированная десериализация"]
    parse --> output["RequestFuncOutput<br/>ttft/itl/tpot"]

BackendПеречисление использует статическую диспетчеризацию, чтобы избежать проблем с async trait object📎 rust/src/bench/src/backends/mod.rs:150-154。send_requestЧерезmatchдиспетчеризуется к конкретной реализации📎 rust/src/bench/src/backends/mod.rs:158-168。get_backendВ зависимости отBackendKindвозвращается соответствующий бэкенд📎 rust/src/bench/src/backends/mod.rs:172-181。

Одна деталь:API_KEYиспользуетOnceLockкэширование, чтобы избежать syscall для переменных окружения на каждом запросе📎 rust/src/bench/src/backends/mod.rs:186-188。build_headersПоследовательно вставляются Content-Type, Authorization, extra headers, request-id📎 rust/src/bench/src/backends/mod.rs:191-215。

Размышления о дизайне и подводные камни

〔Проектные выводы и архитектурные компромиссы〕

Дизайн с нулевым копированием в Rust-инструменте bench отражает важное суждение:накладные расходы клиента инструмента нагрузочного тестирования становятся источником погрешности измерений. Если каждый запрос клонирует prompt, парсит полный JSON, глубоко копирует base64-изображения, то в измеренную задержку примешиваются накладные расходы клиента, и она не отражает реальную производительность сервера. ИспользованиеArcдля разделения неизменяемых данных и типизированной десериализации для пропуска ненужных полей по сути сводит накладные расходы клиента почти к нулю.

RequestFuncOutputДизайн полейttft(time to first token)、itl(массив inter-token latency),tpot(time per output token)📎 rust/src/bench/src/backends/mod.rs:93-105. Эти три метрики соответствуют разным аспектам производительности: TTFT отражает prefill и задержку в очереди, ITL — стабильность decode, TPOT — общую пропускную способность. При нагрузочном тестировании, если смотреть только на среднюю задержку, можно скрыть джиттер ITL.

---

Размышления о дизайне: глубинная логика архитектурных компромиссов

Сопоставив механизмы этой главы с предыдущими тринадцатью, можно увидеть несколько ключевых линий компромиссов в vLLM.

〔Проектные выводы и архитектурные компромиссы〕

Непрерывная батчевая обработка vs фрагментация видеопамяти.Непрерывная батчевая обработка позволяет батчу перестраиваться на каждом шаге, значительно повышая пропускную способность, но ценой этого является крайне частое выделение и освобождение KV cache. Механизм таблицы блоков PagedAttention как раз предназначен для обработки такого высокочастотного выделения — блоки фиксированного размера устраняют внешнюю фрагментацию, но вносят накладные расходы на косвенную адресацию через таблицу блоков и внутреннюю фрагментацию (последний блок может быть не заполнен). Это типичный компромисс «слой косвенности в обмен на уровень фрагментации», та же идея, что и виртуальная память с постраничной организацией в операционных системах.

CUDA Graph vs динамические формы.CUDA Graph требует статических форм, но размер батча при непрерывной батчевой обработке меняется на каждом шаге. Решение vLLM —PIECEWISEиFULL_AND_PIECEWISEрежимы📎 docs/design/optimization_levels.md:50,72— статизируемая часть захватывается в граф, динамическая остаётся eager.-O0Полное отключение cudagraph предназначено для отладки,-O2полное включение — для продакшена, промежуточный-O1— компромисс.

Раздельное развёртывание vs сетевые накладные расходы.KV Connector позволяет разделить prefill и decode на разные инстансы, но передача KV cache между инстансами вносит сетевую задержку. Требования к конфигурации GPUDirect RDMA в документации (IPC_LOCK、/dev/shm)📎 docs/usage/troubleshooting.md:311-311) указывают на жёсткие требования этого пути к инфраструктуре. Сетевой джиттер приводит к таймауту передачи KV, что вызывает повторные попытки или деградацию.

Эксплуатируемость vs производительность.Уровни оптимизации, отладочные переменные окружения, диагностические скрипты — всё это цена, уплачиваемая за эксплуатируемость.VLLM_TRACE_FUNCTION=1замедляет в 100 раз📎 docs/usage/troubleshooting.md:41, но это последнее средство для локализации проблем с зависанием. Зрелый движок обязан предоставлять такие «медленные, но позволяющие всё разглядеть» инструменты.

---

Резюме главы

Эта глава завершает книгу, пересматривая механизмы предыдущих тринадцати глав с производственной точки зрения.

Уровни оптимизации (-O0до-O3) — это явный контракт между временем запуска и производительностью, пользовательские флаги всегда имеют приоритет над значениями по умолчанию уровня📎 docs/design/optimization_levels.md:5-5. Список подводных камней в продакшене охватывает полный диагностический путь от загрузки модели, OOM видеопамяти, изменения качества генерации до сбоев распределённой коммуникации; ключевая методология — «изоляция методом дихотомии» и «послойная верификация». Rust-инструмент bench с помощьюArcразделения и типизированной десериализации сводит накладные расходы клиента почти к нулю, гарантируя, что цифры нагрузочного тестирования реально отражают производительность сервера.

Три ключевые линии компромиссов проходят через всю книгу: непрерывная батчевая обработка и фрагментация видеопамяти, CUDA Graph и динамические формы, разделённое развёртывание и сетевые накладные расходы. Понимание этих напряжений важнее запоминания любого отдельного механизма — потому что каждая настройка в производственной среде по сути представляет собой поиск точки равновесия между этими напряжениями.

Вопросы для размышления и самопроверки к этой главе

Вопрос 1: Если заменить-O2вFULL_AND_PIECEWISEcudagraph на-O1вPIECEWISE, в каких сценариях это вызовет регресс производительности? Почему?

Справочный анализ:-O2В-O1дополнительно добавляетсяFULL_AND_PIECEWISEрежим cudagraph📎 docs/design/optimization_levels.md:72。FULLРежим захватывает весь прямой проход в один граф, тогда какPIECEWISEзахватывает только статически фиксируемые фрагменты. В производственных сценариях со стабильной формой батчаFULLрежим позволяет устранить больше накладных расходов на запуск ядер, обеспечивая более высокую пропускную способность. Однако если модель содержит динамический поток управления (например, маршрутизацию токенов в MoE),FULLрежим может не справиться с захватом или после захвата вести себя некорректно — в этом случаеPIECEWISEоказывается надёжнее. Регресс производительности проявится в случаях: частого изменения размера батча, из-за чегоFULLграф не может быть использован, или когда структура модели запускаетFULLрезервный путь режима. Метод диагностики: сначала с помощью-O1подтвердить базовый уровень, затем перейти к-O2для сравнения, используяVLLM_LOG_STATS_INTERVAL=1.для наблюдения за состоянием очереди📎 docs/usage/troubleshooting.md:41-41。

Вопрос 2: В диагностическом скрипте, почему перед тестированием vLLM PyNcclCommunicator нужно сначала протестировать PyTorch GLOO? Если пропустить тест GLOO и сразу тестировать PyNccl, что будет упущено?

Справочный анализ: Порядок выполнения скрипта — PyTorch NCCL → PyTorch GLOO → vLLM PyNccl → CUDA Graph📎 docs/usage/troubleshooting.md:90-146. GLOO тестирует коммуникацию на стороне CPU📎 docs/usage/troubleshooting.md:106-112, аPyNcclCommunicatorв vLLM требует GLOO-группу в качестве bootstrap📎 docs/usage/troubleshooting.md:120. Если пропустить тест GLOO, то при сбое инициализации PyNccl вы не сможете различить, проблема ли это самого NCCL или проблема GLOO bootstrap. GLOO зависит от конфигурации сетевого интерфейса (GLOO_SOCKET_IFNAME)📎 docs/usage/troubleshooting.md:81-81, и в сложных сетевых средах это частая точка отказа. Ценность послойного тестирования в том, что оно позволяет изолировать неисправность до минимального различия в конфигурации.

Вопрос 3: Инструмент бенчмаркинга на Rust используетArc<str>для совместного использования prompt. Если в сценарии нагрузочного тестирования каждый запрос должен отправлять разный prompt, теряет ли этот дизайн смысл? Почему?

Справочный анализ:Arc<str>Цель дизайна📎 rust/src/bench/src/backends/mod.rs:50-52— позволить нескольким конкурентным запросам совместно использовать одну неизменяемую строкуArc. Если prompt каждого запроса различается,Arc<str>преимущество совместного использования действительно исчезает — каждому запросу нужно создавать свойArc<str>. Но дизайн не теряет смысла:Stringпо сравнению сprompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77по-прежнему избегает множественного клонирования в процессе прохождения запроса (например, из входной очереди в backend, затем в построение payload). Настоящая оптимизация нулевого копирования заключается вArc— даже если текст prompt различается, предвычисленный массив token ID всё ещё может быть разделён черезArc<str>в течение жизненного цикла запроса, избегая повторного выделения. Дизайн инструмента нагрузочного тестирования предполагает либо «один prompt при высокой конкурентности», либо «предвычисленные token ID»: в первом случаеArc<[u32]>используется для совместного использования текста, во втором — для совместного использования последовательности токенов.

---

На этом разбор исходного кода всех четырнадцати глав книги завершается. Мы начали с одного вызова API, прошли через планировщик, менеджер KV cache, бэкенды внимания, уровень распределённой коммуникации, добрались до точки запуска GPU-ядер и вернулись к диагностическому пульту производственной эксплуатации. За каждым проектным решением vLLM стоит чётко определённый компромисс; понимание этих компромиссов — единственный способ принимать правильные инженерные решения при столкновении с новым оборудованием, новыми моделями и новыми нагрузками. Эволюция inference-движков не остановится — Rust-фронтенд, слой IR, поддержка гетерогенного оборудования стремительно развиваются — но базовая логика компромиссов стабильна, и именно эту ключевую способность книга стремится передать.

На этом мы завершили полный путь от точки входа запроса до GPU Kernel и увидели те компромиссы и подводные камни в производственной среде, которые превращают систему из «работающей» в «работающую стабильно». Эволюция vLLM не остановится на текущей архитектуре: более эффективные реализации внимания, более интеллектуальные стратегии планирования, более бесшовная гетерогенная поддержка — всё это уже в пути. Но как бы ни менялось будущее, понимание напряжений и компромиссов между этими механизмами всегда остаётся ключом к управлению inference-движком.

Чтобы разобраться в любом сложном проекте, на самом деле нужна всего одна хорошая книга

Эта книга «vLLM: глубокий разбор исходного кода — высокопроизводительный inference-движок от запроса до Token» была полностью автоматически составлена AiReadCode путём сканирования официального открытого репозитория. Будь то крупный открытый проект на сотни тысяч строк или сложная внутренняя корпоративная система, вы можете в один клик сгенерировать столь же чётко структурированную персональную монографию.

Бесплатно скачать клиент AiReadCode Смотреть другие открытые книги →
🇨🇳 Китайский · 🇺🇸 EN · 🇯🇵 Японский · 🇰🇷 한국어 · 🌐 Традиционный китайский · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU