第 1 章:vLLM の設計哲学と全体アーキテクチャ概観
手元に A100 が 1 枚あり、LLaMA-7B でオンライン推論サービスを提供したいと仮定しよう。最も素朴な方法は:リクエストが来たら model.generate() を 1 回実行し、結果を返す。この方案は並行数が上がると即座に破綻する——GPU の演算能力が足りないからではなく、2 つの理由による:第一に、VRAM が断片化に食われる。自己回帰生成では各層の Key/Value テンソル(KV Cache)をキャッシュする必要がある。もし各リクエストが max_model_len に従って連続した VRAM ブロックを事前確保すると、4096 token のリクエストは数十 MB を占有するが、実際に生成されるシーケンスは 200 token しかないかもしれない。さらに悪いことに、異なる長さのリクエストが交互に出入りすると、連続 VRAM ブロックが細切れに分割され、最終的に総量は足りているのに十分な大きさの連続空間が見つからない——これが古典的な VRAM 断片化問題である。第二に、バッチ処理効率が低い。従来の静的バッチ処理では、1 つのバッチ内の全リクエストが同時に開始し同時に終了することを要求する。しかし生成タスクの出力長は本質的に予測不可能である:あるリクエストは 10 token で停止するかもしれず、別のリクエストは 2000 生成する必要がある。短いリクエストが終了すると、その占有していたバッチスロットは長いリクエストが完了するまで空待ちするしかなく、GPU 利用率が崖のように急落する。vLLM の 2 つの設計基盤はまさにこの 2 つの痛点に対処している:PagedAttention はページング機構で VRAM 断片化を解消し、Continuous Batching はイテレーションレベルスケジューリングでバッチ処理の空転を解消する。本章ではこの 2 つの機構の実装詳細には深入りせず(それは第 2、4 章のテーマである)、まず全体地図を構築する:vLLM v1 のプロセスアーキテクチャはどのようなものか、各層の責務はどう分担されるか、1 回のリクエストがシステムに入ってから token を吐き出すまでにどのコンポーネントを通過するか。この地図を理解すれば、以降の各章のソースコード解説に足がかりができる。
プロセスアーキテクチャ:なぜ vLLM はシングルプロセスプログラムではないのか
直感的モデル
vLLM をレストランに例えよう。フロント(API Server)は客の接待と注文の記録を担当し、厨房の中核(EngineCore)はどの料理を先に作るか、どのコンロを使うかを決定し、各コンロ(GPU Worker)は 1 人のシェフが独占的に操作する。もし 1 人に接待と調理の両方をさせたら、ピーク時には必ず手忙脚乱になる——これが vLLM がこれらの役割を独立プロセスに分割する理由である。
このマルチプロセス分割の核心的動機は関心の分離である:HTTP 解析、tokenization、マルチモーダルデータ読み込みは CPU 集約的でブロッキングの可能性がある操作であり、モデルフォワードは GPU 集約的である。もし同一プロセスに置くと、Python の GIL が両者を互いに足を引っ張り合う。独立プロセスに分割すれば、API Server は継続的に新リクエストを受信でき、EngineCore は継続的にスケジューリングでき、GPU Worker は継続的に計算でき、三者は ZMQ メッセージキューで疎結合される。
プロセストポロジーと数量関係
vLLM v1 のプロセスアーキテクチャは 1 つの公式で要約できる。N枚の GPU、テンソル並列度TP、パイプライン並列度PP、データ並列度DP、API Server 数Aのデプロイに対して:
| プロセス種別 | 数量 | 責務 |
|---|---|---|
| API Server | A(デフォルトはDP) | HTTP リクエスト処理、入力前処理、結果ストリーミング返却 |
| EngineCore | DP(デフォルト 1) | スケジューリング、KV Cache 管理、GPU Worker の調整 |
| GPU Worker | N(= DP × PP × TP) | 重み読み込み、フォワード実行、VRAM 管理 |
| DP Coordinator | DP > 1のとき 1、それ以外は 0 | DPランク間の負荷分散とMoEウェーブ調整 |
📎 docs/design/arch_overview.md:113-113がこの表の権威ある定義を示している。典型的なシングルマシン4GPU構成(vllm serve -tp=4)では、1つのAPI Server + 1つのEngineCore + 4つのGPU Worker = 6プロセスが生成される📎 docs/design/arch_overview.md:115-115。一方、8GPU 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にリクエストをルーティングできることを意味し、単一障害点を回避している。
データフロー
以下の図は、1回のリクエストがプロセス間を流れる完全な経路を示している。各ノードに実際のクラス名とデータ構造が注記されている点に注意:
flowchart LR
client["客户端 HTTP 请求"] --> api["API Server 进程<br/>输入预处理 + tokenization"]
api -->|"EngineCoreRequest<br/>via ZMQ ADD"| core["EngineCore 进程<br/>Scheduler + KVCacheManager"]
core -->|"SchedulerOutput<br/>via Executor"| worker["GPU Worker 进程<br/>ModelRunner.forward()"]
worker -->|"ModelRunnerOutput<br/>token ids + logprobs"| core
core -->|"EngineCoreOutputs<br/>via 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。
gRPCや共有メモリではなくZMQを選択した理由は、ZMQがプロセス間通信シナリオにおいて極めて低いレイテンシ(マイクロ秒レベル)を持ち、多対多トポロジとメッセージキューのセマンティクスをネイティブにサポートしているためである。初回トークンレイテンシに敏感な推論サービスのようなシナリオでは、通信オーバーヘッドは可能な限り小さくする必要がある。
設計上の考察:なぜEngineCoreはスレッドではなく独立プロセスなのか
自然な疑問は:EngineCoreとAPI Serverが同じマシン上にあるなら、なぜ同一プロセス内でスレッド通信にしないのか?
答えはEngineCoreの動作モードに隠されている。EngineCoreが実行しているのはビジーループ(busy loop)であり、継続的にリクエストをスケジュールし、GPU Workerに作業を分配している📎 docs/design/arch_overview.md:73-73。このループは中断できな��——HTTP解析やトークナイゼーションでブロックされると、推論パイプライン全体にバブルが発生する。独立プロセスはEngineCoreのCPUタイムスライスがフロントエンドロジックに横取りされないことを保証する。
さらに、独立プロセスは障害分離ももたらす:API Serverが不正なリクエストでクラッシュしても、EngineCoreとGPU Workerは影響を受けず、他のAPI Serverから転送されたリクエストを処理し続けられる。
階層的メンタルモデル:エントリポイントからGPUまでの責務境界
直感的モデル
プロセスアーキテクチャが「誰がどこで働くか」だとすれば、階層モデルは「各層が何を決定するか」である。vLLMのコード構成は明確な階層原則に従っている:上位層が何をするかを決め、下位層がどうやるかを決める。エントリ層はどのリクエストを受け付けるかを決め、エンジンコア層は誰を先に処理するかを決め、エグゼキュータ層はどの並列戦略を使うかを決め、Worker層は具体的なハードウェア上でどう結果を出すかを決める。
4層構造
エントリ層(Entrypoints)は2つのインタラクション方式を提供する:オフライン推論のLLMクラスとオンラインサービスのvllm serveコマンド📎 docs/design/arch_overview.md:16-16📎 docs/design/arch_overview.md:56-56。この層の核心的責務は入力前処理——トークナイゼーション、マルチモーダルデータ読み込み、サンプリングパラメータ解析——および出力の逆トークナイゼーションとストリーミング返却である。スケジューリング戦略には関与せず、GPUにも触れない。
エンジンコア層(EngineCore)はシステム全体の頭脳である。Scheduler(各decode stepでどのリクエストを処理するかを決定)とKV Cache Manager(ページングされたVRAMを管理)を保持し、Executor抽象を介してGPU Workerと通信する📎 docs/design/arch_overview.md:79-85。この層の鍵となる設計はスケジューリングと実行の分離である:Schedulerは「このステップでどのトークンを実行するか」の決定(SchedulerOutput)のみを生成し、具体的にGPU上でどう実行するかはWorkerの仕事である。
エグゼキュータ層(Executor)はEngineCoreとWorkerの間の橋渡しである。分散実行戦略をカプセル化する——単一プロセスではUniProcExecutor、マルチプロセスではMultiprocExecutor、RayクラスタではRayDistributedExecutor。Executorの抽象インターフェースにより、EngineCoreは基盤がシングルGPUか8GPU TPかを知る必要がない。
Worker層各GPUに1つのWorkerプロセスがあり、内部にModelRunnerと実際のtorch.nn.Moduleモデルオブジェクトを保持する📎 docs/design/arch_overview.md:171-191。ModelRunnerは入力テンソルの準備、CUDA Graphのキャプチャ、フォワード計算の実行を担当する。この層はGPU VRAMとCUDAストリームを直接操作する唯一の場所である。
設定オブジェクト:全層を貫くグローバル状態
4層間で情報は何を介して伝達されるのか?答えはVllmConfig——全ての設定を含む巨大なdataclass📎 vllm/config/vllm.py:357-357。
@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が核心フィールドを示している。この設計選択の背後にある論理は詳しく展開する価値がある。
ドキュメントでは、なぜ分散したパラメータ渡しではなく1つの大きな設定オブジェクトを使うのかが明確に説明されています:拡張性。ModelRunner にのみ影響する新機能を追加したいとします。その場合、VllmConfigにフィールドを1つ追加するだけで、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には、見落とされがちですが非常に重要な機能がもう1つあります:compute_hash() 📎 vllm/config/vllm.py:464-580。これは計算グラフ構造に影響するすべての設定項目に対して短いハッシュを生成します。
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を使ってモデルのフォワードグラフをコンパイルし、コンパイル結果はディスクにキャッシュされます。次回起動時に設定ハッシュが同じであれば、コンパイルキャッシュを直接再利用でき、時間のかかるコンパイル処理をスキップできます。計算グラフに影響する設定項目がハッシュに含まれていない場合、キャッシュヒットの誤りが発生します——古い設定でコンパイルされたグラフを新しい設定で実行してしまい、結果としてサイレントエラーになります。これが、コメントで「計算グラフに影響するフィールドは必ずハッシュに含める」と繰り返し強調されている理由です。
リクエストライフサイクル Walkthrough:HTTP から Token まで
シナリオ設定
クライアントがvllm serveで起動したサービスに OpenAI 互換の/v1/completionsリクエストを送信し、prompt が "The capital of France is" で、16 トークンの生成を要求するとします。このリクエストの完全な旅をソースコードに沿って追跡します。
Step 1:API Server の受信と前処理
API Server プロセスは HTTP リクエストを受信すると、トークナイゼーションとサンプリングパラメータの解析を行い、その後EngineCoreRequest:
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 ガベージコレクタの負荷を軽減でき、毎秒数千リクエストを処理するシナリオでは必要な最適化です。
Step 2:EngineCore のスケジューリング
EngineCore がリクエストを受信すると、Scheduler がそれを待機キューに入れます。各スケジューリングステップで、Scheduler はこのリクエストを現在のバッチに含めるかどうかを決定します。含める場合、KV Cache Manager が物理ブロックを割り当てます(PagedAttention の中核操作、詳細は第2章参照)。
スケジューリング結果はSchedulerOutputとしてカプセル化され、Executor を通じて GPU Worker に送信されます。
Step 3:GPU Worker のフォワード実行
Worker の ModelRunner はSchedulerOutputを受信し、入力テンソル(block table、slot mapping などの attention metadata を含む)を準備し、モデルのフォワードを実行して次のトークンをサンプリングします。
Step 4:結果の返送
Worker が生成したトークンはEngineCoreOutput:
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が📎 vllm/v1/engine/__init__.py:256-260。
にパッケージされ、ZMQ を通じて API Server に返されます
Step 5:API Server のストリーミング返送EngineCoreOutputsAPI Server はEngineCoreOutputを受信すると、各
に対して逆トークナイゼーションを行い、SSE(Server-Sent Events)を通じてクライアントにストリーミング配信します。
完全なシーケンス
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 via ZMQ ADD
Core->>Sched: add_request(EngineCoreRequest)
loop 每个 decode step
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 via ZMQ
API-->>Client: SSE chunk (new_token_ids)
end
Note over Sched: finish_reason != None 时请求退出コピーこの図の重要な情報:EngineCoreOutputs各 decode step ごとにの返送が発生し
、シーケンス全体の生成が完了するまで待つのではありません。これこそが 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. 最後に非同期スケジューリング、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 分離デプロイを行う場合、これらの connector はibv_reg_mrなどのメカニズムを通じてKV cache の物理メモリページを固定(pin)する。しかし同時にPYTORCH_CUDA_ALLOC_CONF=expandable_segments:Trueを設定すると、PyTorch の CUDA VMM アロケータが実行時に同じ仮想アドレスを異なる物理ページに再マッピングする可能性がある📎 vllm/config/vllm.py:1227-1233。
結果はどうなるか?Connector が登録した 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。
。GPU メモリの pin に関わるあらゆる機能(KV 転送、NCCL 登録バッファなど)は、基盤となる物理ページがアロケータによって密かに移動されないことを保証しなければならない。この種の問題を調査する際、RDMA 転送が最初のクロスノード通信で失敗するのを見たら、最初に確認すべきは
__post_init__落とし穴:非同期スケジューリングの自動降格チェーンasync_schedulingにおける📎 vllm/config/vllm.py:1544-1635の処理ロジックは、綿密に設計された。
自動降格チェーンasync_schedulingを示しているNoneユーザーが
- を明示的に設定していない場合(値が📎
vllm/config/vllm.py:1578-1587 - )、vLLM は自動的に有効化を試みるが、一連の非互換条件を順にチェックする必要がある:📎
vllm/config/vllm.py:1588-1601 - pooling モデルの場合、
disable_padded_drafter_batch=Trueを無効化する📎vllm/config/vllm.py:1602-1610 - speculative メソッドがサポートリストにない場合、📎
vllm/config/vllm.py:1611-1617 - を無効化する📎
vllm/config/vllm.py:1618-1624 - もし📎
vllm/config/vllm.py:1625-1633
なら、📎 vllm/config/vllm.py:1639-1640。
executor バックエンドがサポートしていない場合、を無効化するROCm DeepEP 高スループット DBO の場合、
を無効化する
PP > 1 かつ V1 Model Runner を使用している場合、
1. を無効化するすべてのチェックを通過した場合のみ、最終的に
2. を有効化する〔設計上の推論とアーキテクチャのトレードオフ〕A + DP + Nこの降格チェーンの設計哲学は:
3. デフォルトで最適な設定を有効にし、非互換に遭遇したら静かに降格して警告を記録する。これはユーザーに各互換性スイッチを手動で設定させるよりもはるかに親切である。しかし代償として——性能が期待に達しない場合、ユーザーはログを遡って非同期スケジューリングが自動的に無効化されたことを発見する必要がある。本番環境でスループットの異常を発見した場合、起動ログに「Async scheduling will be disabled」という警告がないか確認することを推奨する。
4. 本章のまとめ本章では vLLM v1 のグローバルなメンタルモデルを確立した。核心的なポイント:compute_hash()vLLM が解決する二つの根本問題__post_init__:メモリ断片化(PagedAttention によるページ管理)とバッチ処理の空回り(Continuous Batching によるイテレーションレベルスケジューリング)。
5. マルチプロセスアーキテクチャ:HTTP → tokenize → EngineCoreRequest → Scheduler → Worker forward → EngineCoreOutput:API Server(エントリ)→ EngineCore(スケジューリング)→ GPU Worker(実行)の三層プロセスで、ZMQ を介した非同期通信。プロセス数は
の公式に従う。
四層階層モデルEngineCoreRequest:エントリ層が前処理を担当し、エンジンコア層がスケジューリング決定を担当し、エグゼキュータ層が分散戦略を担当し、Worker 層が GPU 計算を担当する。msgspec.StructVllmConfig は全層を貫くグローバル状態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リクエストライフサイクル
→ SSE ストリーミング返却。:array_like=True本章の考察とセルフチェックomit_defaults=TrueQ1: もしEngineCoreRequestは全フィールド名を含む辞書構造にエンコードされ、サイズが2〜3倍に膨張する可能性がある。高並行シナリオ(毎秒数千リクエスト)では、API ServerとEngineCore間のZMQメッセージ量が著しく増加し、シリアライズ/デシリアライズのCPUオーバーヘッド増大とネットワーク帯域の浪費を引き起こす。EngineCoreOutputs同様にこれら2つのパラメータを使用している📎 vllm/v1/engine/__init__.py:256-260、そしてそれは各decode stepごとに生成されるため、影響はより大きい。さらにgc=FalseはGCトラッキングを無効化し、高頻度で短命なオブジェクトに対してPython GCの負荷を軽減できる。
Q2:VllmConfig.__post_init__において、async_schedulingの自動有効化ロジック(📎 vllm/config/vllm.py:1576-1635)は「互換性のない条件を順にチェックし、すべて通過した場合のみ有効化する」という戦略を採用している。非同期スケジューリングと互換性のない機能を新たに追加したが、開発者がこのチェックチェーンに対応する分岐の追加を忘れた場合、どのような問題が発生するか?システム動作の観点から分析せよ。
参考解析:チェック分岐の追加を忘れると、非同期スケジューリングが誤って有効化される。非同期スケジューリングの核心的な前提は「現在のstepのスケジューリング決定が前のstepの出力に依存しない」ことであり、これによりEngineCoreは前のstepのGPU計算がまだ完了していない段階で次のstepをスケジューリングできる。新機能がこの前提に違反する場合(例えば前のstepの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が解決しなければならない2つの根本的矛盾——VRAM断片化とバッチ処理の空転——を明らかにし、PagedAttentionとContinuous Batchingという2つの鍵を示した。その後vLLM v1の全体アーキテクチャを俯瞰し、プロセスモデル、コンポーネントの階層化、リクエストの完全なライフサイクルを整理した。この全体マップを得たことで、次章ではvLLMの最も核心的なデータ構造——Request、Sequence、KV Cacheのblock管理メカニズム——に深く入り、PagedAttentionがコードレベルで「論理的に連続、物理的に離散」なVRAMマッピングをいかに実現するかを明らかにする。
第2章:核心抽象:Request、Sequence、KV Cacheデータ構造
前章で我々はvLLM v1の階層的なメンタルモデルを構築し、リクエストがAPI Serverから出発し、EngineCoreを通過し、最終的にWorkerに到達して実行されることを理解した。しかしHTTPリクエストボディ内のJSON文字列は、どのようにしてエンジン内部でスケジューリング可能、追跡可能、中断可能なオブジェクトになるのか?これがRequestクラスが答えるべき問題である。
KV Cacheの仕様体系:KVCacheSpecからレジストリまで
Requestは「誰が計算するか」の問題を解決し、KVCacheSpecは「どこで計算するか」の問題を解決する。PagedAttentionの世界では、各モデル層のKV cacheは正確に記述される必要がある:ヘッドがいくつあるか、各ヘッドのサイズはいくらか、1つのblockに何トークン格納できるか、量子化が必要か。これらの情報はKVCacheSpecの継承体系にエンコードされている。
直感モデル:KVCacheSpecはVRAMの「間取り図」
GPU VRAMを開発予定の土地と想像すると、KVCacheSpecは各建物(各cache group)の間取り図である:各階(各block)にいくつの部屋(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)が同一の仕様を参照し、どこかで変更されて不整合が生じることがない。
基底クラスはサブクラスが実装しなければならない3つの抽象プロパティを定義する:num_heads、tokens_per_state、state_content_size_bytes 📎 vllm/v1/kv_cache_interface.py:182-183。これら3つのプロパティが共同でpage_size_bytes——すなわち1つのblockが占めるバイト数を決定する。
AttentionSpecは最も核心的なサブクラスであり、num_kv_heads、head_size、dtype、kv_quant_modeなどのフィールドを導入する📎 vllm/v1/kv_cache_interface.py:485-498。そのうちtokens_per_stateフィールドの設計は特に巧妙である:デフォルト値は1で、1つのstateが1つのtokenに対応することを示す。しかし1より大きい整数(DeepSeek-V4のスパースMLAのように複数のtokenを1つのstateに圧縮する場合)や、1より小さい分数(Whisperのblock poolingのようにFraction(1, block_pool_size)で1つの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内で全注意力として扱われ(すべてのtokenにblockを割り当てる)、モデル実行時にはスライディングウィンドウに従って計算される📎 vllm/v1/kv_cache_interface.py:540-545。これは保守的に割り当て、正確に計算する戦略である。
MLAAttentionSpecはDeepSeekシリーズモデルの鍵となる仕様である。それはhead_size_vをデフォルトで0に設定する📎 vllm/v1/kv_cache_interface.py:670。MLAは1つの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。Mambaのmax_memory_usage_bytesはmamba_cache_modeに応じて3つの異なる計算方法を持つ📎 vllm/v1/kv_cache_interface.py:1073-1084。これはMambaの状態管理の複雑さを反映している——attentionのように線形に増加するのではなく、固定の状態サイズを持つ。
シナリオ駆動:仕様からVRAMレイアウトへの変換
エンジン起動時、すべての層のKVCacheSpecを実際のVRAMレイアウトに変換する必要がある。このプロセスはKVCacheTensorとcreate_kv_cache_viewsによって行われる。
KVCacheTensorは同形状の層のグループがKV cache割り当てにおいて占める位置を記述する📎 vllm/v1/kv_cache_interface.py:1406-1427。その核心フィールドはlayer_strideとblock_strideである:前者は隣接層間のバイト距離、後者は隣接block間のバイト距離である。docstringは2つのレイアウトモードを詳細に説明している:層最外レイアウト(layer-outermost)は各層に連続領域を与え、ブロック最外レイアウト(block-outermost)は各blockがすべての層のpageを含むようにする📎 vllm/v1/kv_cache_interface.py:1416-1416。
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()"| v3create_kv_cache_views関数はこのプロセスの核心である📎 vllm/v1/kv_cache_interface.py:353-417。それはフラットなint8バッファを受け取り、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より小さい場合(つまり1つの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。それは2つのグローバル辞書を維持する:_REGISTRY_KVCACHESPEC_LISTはspecクラスからメタデータへのマッピングを格納し、_REGISTRY_ROLE_MANAGERSはロールからマネージャへのマッピングを格納する📎 vllm/v1/kv_cache_spec_registry.py:35-36。
get_manager_classメソッドはレジストリの核心的な検索ロジックを示す:specクラスのMRO(メソッド解決順序)を辿って最初に登録された基底クラスを見つける📎 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を使用していることに注意。コメントはこれが本番環境でも有効にするためであると明記している📎 vllm/v1/kv_cache_spec_registry.py:165-174。これは重要なエンジニアリング判断である:Pythonの-Oフラグはassertを除去するが、本番環境の設定エラーは起動時に露呈しなければならず、実行時に初めてクラッシュしてはならない。
レジストリの遅延初期化設計(_ensure_registered)は循環依存問題を解決する:kv_cache_interface.pyはspecタイプをチェックするためにレジストリを参照する必要があり、レジストリはインポートする必要があるsingle_type_kv_cache_managerマネージャークラスを取得し、それはさらに依存するkv_cache_interface。実際の登録を最初のクエリ時まで遅延させることで、この循環を断ち切っている。
本章のまとめ
本章では vLLM v1 の二つの中核データ構造を分析した。Requestはエンジン内部におけるリクエストのライフサイクルキャリアであり、二重トークンリスト、非同期スケジューリングカウンタ、block hash メカニズムを通じて、連続バッチ処理とプレフィックスキャッシュという二大中核機能を支えている。KVCacheSpecおよびその継承体系は KV cache の VRAM レイアウト仕様を定義し、標準的なFullAttentionSpecからMLAAttentionSpec、MambaSpecまで、多様なモデルアーキテクチャの要件をカバーしている。レジストリパターンにより、新しい spec タイプの追加時にコアコードを変更する必要がなくなり、システムの拡張性が保証されている。
ここまでで、Request が EngineCoreRequest からどのように変換されるか、そして状態カウンタや block hash などのメカニズムを通じてどのようにスケジューリング決定を支えるかを明らかにした。しかし、外部リクエストは一体どのように API Server、chat template、マルチモーダル処理を経て、最終的に EngineCoreRequest になるのか?次章ではリクエストエントリ層に入り、この HTTP/CLI から EngineCore への経路を完全に追跡する。
第 3 章:リクエストエントリ:HTTP/CLI から EngineCore への完全な経路
前章では Request と KVCacheSpec というエンジン内部の二つの中核データ構造を分析し、論理シーケンスと物理 VRAM ブロックがどのように分離されているかを理解した。しかし、HTTP リクエストボディや Python 文字列は、一体どのように API Server、chat template、マルチモーダル処理を経て、最終的に EngineCoreRequest になるのか?本章ではこの経路を完全に追跡し、同期 CLI、非同期 API、オフライン LLM クラスという三つのエントリパスがどのように同一のエンジンコアに収束するかを明らかにする。
3.1 三つのエントリパスの収束点:AsyncLLMEngine と LLMEngine
リクエスト解析を深掘りする前に、まず三つのエントリパスのトポロジー構造を明確にする必要がある。vLLM は三つの使用方法を提供している:vllm serveで起動する OpenAI 互換 HTTP サービス、コマンドラインvllmツール、そして Python で直接インスタンス化するLLMクラスによるオフライン推論。これらは一見独立しているが、実際には同一のエンジンコアを共有している。
まず非同期 API パスのエイリアスメカニズムを見る。
📎 vllm/engine/async_llm_engine.py:7-7
このファイルはモジュールとは思えないほど短い——それはただ一つのことだけを行っている:AsyncLLMEngineエイリアスをvllm.v1.engine.async_llm.AsyncLLMに指し示すことだ。これは典型的なアーキテクチャ移行の痕跡である。vLLM v0 時代のAsyncLLMEngineは巨大で複雑なクラスであり、v1 アーキテクチャの書き直し後、新しいAsyncLLMが同じ責務を担っている。既存のユーザーコードを壊さないために、vLLM は旧モジュールパスを互換層として保持している。
この「旧パスのエイリアスが新実装を指す」パターンは vLLM で繰り返し現れており(例えばapi_server.pyの deprecation warning など)、プロジェクトが v0 から v1 への移行において漸進的戦略を取ったことを示している:新コードは新パスを使用し、旧コードはエラーにならないが警告を受け取り、ユーザーに十分な移行期間を与える。
次にオフラインパスのエントリを見る。
📎 vllm/entrypoints/llm.py:344-346
LLM.__init__は最終的にLLMEngine.from_engine_argsを呼び出し、UsageContext.LLM_CLASSを渡す。このUsageContext列挙型はエントリパスを区別する鍵である——エンジンがオフラインバッチ処理モードで動作しているかオンラインサービスモードで動作しているかを知らせ、それに応じてログ、メトリクス、リソース管理戦略を調整する。
📎 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 の解析ロジックがオフラインとオンラインパスで同一のコードであり、呼び出しタイミングが異なるだけであることを意味する。
三つのパスの収束関係は以下のデータフロー図で表せる。
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 互換フォーマット、カスタム拡張、マルチモーダル埋め込み、ツール呼び出しなどすべての入力形態を処理している。その中核的責務は一文で要約できる:ユーザーから渡された任意のメッセージリストを、chat template が理解できるConversationMessageリストに正規化し、同時にマルチモーダルデータを独立した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かつそのモダリティのプロンプトごとの制限が 0 で、元のモダリティが_embedsで終わる場合、数量検証をスキップする。これは埋め込み入力が元のモダリティの数量制限を迂回できるようにするためである——埋め込みは事前計算済みで、元のモダリティの処理リソースを消費しない。
シナリオ駆動:画像付きの chat リクエストがどのように解析されるか
ユーザーが画像 URL とテキストを含む chat リクエストを送信すると仮定する。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は空リストになり、文字列は単一のテキスト part になる。次に_parse_chat_message_content_partsを呼び出す。ここでwrap_dictsパラメータはcontent_format == "openai"によって決定される——これが出力を構造化辞書リストにするか、連結後の文字列にするかを決める。
_parse_chat_message_content_partsは各 part を走査する。
📎 vllm/entrypoints/chat_utils.py:1814-1853
各 part は_parse_chat_message_content_partによって処理される。もしwrap_dicts=Falseなら、最終的にテキストとプレースホルダーを単一の文字列に連結する。もしwrap_dicts=Trueなら、構造化辞書リストを返す。
_parse_chat_message_content_partはディスパッチの核心である。
📎 vllm/entrypoints/chat_utils.py:1875-1884
純粋なテキスト part に対しては、まずプレースホルダー保持チェックを行い、次にwrap_dictsに基づいて返却形式を決定する。構造化 part に対しては、_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の場合、コードは part から直接 URL フィールドを抽出しようとする。この「寛容な解析」は、OpenAI 形式に厳密に従わないクライアントとの互換性のためである。
に戻ると、_parse_chat_message_content_partメディアタイプの 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 は token オフセット位置で連結され、位置が重要であり、もし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 メッセージから token へ:renderer と EngineCore の引き継ぎ
chat_utilsが生成するConversationMessageリストとMultiModalDataDictは、chat template でレンダリングされて初めて token シーケンスになる。このステップは renderer が行い、その後リクエストが実際にエンジンに入る。
シナリオ駆動:chat template レンダリングとリクエスト投入
parse_chat_messages戻った後、呼び出し元(例えばOpenAIServingChat)はconversationとmm_dataを renderer に渡します。renderer は chat template を適用し、ConversationMessageリストをテキストにレンダリングしてから、token ID シーケンスに tokenize します。マルチモーダルプレースホルダー(例えば<##IMAGE##>)は tokenize 後にモデル固有のプレースホルダー token に置換されます。
レンダリング完了後、リクエストは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メソッドは chat パスを示しています。messagesリストを受け取り、_run_chatを呼び出し、その後内部でparse_chat_messagesと renderer を呼び出します。
設計上の考察:なぜ renderer はエンジン内部にあるのか
LLM.__init__におけるself.renderer = self.llm_engine.rendererという行は重要な設計判断を明らかにしています。renderer はエントリ層ではなくエンジンに属するということです。これは、chat template の読み込み、キャッシュ、ウォームアップ(self.renderer.warmup(ChatParams(...)))がすべてエンジン初期化時に行われ、エントリ層は単なる呼び出し元であることを意味します。この利点は、オフラインのLLMとオンラインのAsyncLLMが同一の renderer 実装とキャッシュを共有し、tokenizer と chat template の重複読み込みを避けられることです。同時に、renderer のウォームアップをエンジン起動時に完了できるため、最初のリクエストのコールドスタート遅延を回避できます。
エラー回復と本番環境の落とし穴
_postprocess_messagesにおけるツール呼び出しパラメータの処理は、典型的な本番環境の罠です。
📎 vllm/entrypoints/chat_utils.py:2118-2158
assistant メッセージがtool_callsを含む場合、argumentsフィールドは JSON 文字列、辞書、または無効な JSON である可能性があります。コードは JSON 文字列のパースを試み、失敗した場合は警告を記録して強制的に空オブジェクトに変換します。コメントには理由が説明されています:不正な形式のargumentsが会話履歴に存在する場合、ここでリクエストを失敗させると、以降の毎ターンが失敗し、会話が回復不能になります。これは熟慮されたフォールトトレラント設計です——モデルに空のツールパラメータを見せる方が、会話全体がスタックするよりもましです。
もう一つの罠は、予約プレースホルダーの注入防御です。
📎 vllm/entrypoints/chat_utils.py:1856-1872
が有効な場合、enable_prompt_embedsが分割不可能な特殊 token として登録されます。ユーザーテキストにこのリテラルシーケンスが偶然含まれていると、tokenizer はそれを同じ token ID にエンコードし、renderer はそれを連結点と誤認して、呼び出し元がプレーンテキストコンテンツを通じて連結位置を移動または注入できるようになります。PROMPT_EMBEDS_PLACEHOLDER_TOKENはテキスト part の解析時にこのような入力を拒否し、このセキュリティホールを塞いでいます。_reject_reserved_placeholder_in_textこのチェックは
📎 vllm/entrypoints/chat_utils.py:1889-1892
ブランチと構造化テキストブランチの両方で呼び出されており、すべてのテキストパスが防御を経由することを保証しています。isinstance(part, str)本章のまとめ
本章では、リクエストが外部からシステムに入る最初のチェーンを追跡しました。三つのエントリパス——HTTP API、CLI、オフラインの
クラス——は最終的にLLMのマルチモーダル解析層に収束します。chat_utilsが状態管理を担当し、BaseMultiModalItemTrackerがコンテンツ抽出を担当し、両者を分離することで同期・非同期パスが追跡ロジックを共有できます。BaseMultiModalContentParserは異種メッセージをparse_chat_messagesリストとConversationMessageに正規化し、エンジン内部の renderer に渡して chat template レンダリングと tokenize を完了します。最終的に、リクエストはMultiModalDataDictとしてカプセル化され、EngineCore の入力キューに投入されます。EngineCoreRequest本章の考察とセルフチェック
Q1:
において、_parse_chat_message_content_mm_partという条件を削除した場合(つまりuuid is Noneに変更した場合)、どのようなシナリオで問題が発生するか?if isinstance(part_type, str) and part_type in MM_PARSER_MAP:参考解析
この条件の存在は「ユーザーが UUID を提供したが、メディアデータがリクエストボディにない」シナリオを処理するためです。ユーザーが UUID を提供する場合、メディアデータは他の方法(例えばメディアキャッシュへの事前アップロード)で既にアップロードされている可能性があり、リクエストボディの part には実際の URL やデータではなく UUID のみが含まれることがあります。この条件を削除すると、コードは:uuid is Noneを通じて解析を試みますが、part に対応するデータフィールドがない可能性があり(例えばMM_PARSER_MAP[part_type](part)が空)、image_urlコンテンツが解析されます。さらに深刻なのは、後続のNoneがparse_image(None, uuid)を呼び出し、不要なネットワークリクエストや例外を引き起こす可能性があることです。_connector.fetch_image(None)ブランチは直接フィールド抽出パスを通り、「UUID ありデータなし」のケースを正しく処理します。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に変更した場合、どのような並行シナリオでリソースリークが発生するか?
参考解析:return_exceptions=Falseの場合、asyncio.gatherは最初の例外がスローされた時点で即座に戻りますが、他の進行中のタスクはキャンセルされません——それらはバックグラウンドで実行し続けます。これらのタスクはネットワーク接続、スレッドプールのワークアイテム、またはファイルハンドルを保持している可能性があります。これらのタスクが最終的に失敗した場合、例外はサイレントに破棄され(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フィールドが対話履歴に存在する(assistant メッセージのtool_calls)。もしあるターンの対話でモデルが不正な形式のargumentsを生成した場合、このエラーは対話履歴に保存される。もし_postprocess_messagesが履歴を解析する際に例外を投げると、以降の毎ターンのリクエストが履歴内のこのエラーによって失敗する——たとえ現在のターンの入力が完全に正しくても。ユーザーはこの対話を続けることができず、会話全体を諦めて最初からやり直すしかない。強制的に空オブジェクトに変換することで対話を継続でき、モデルは空のツール引数を見て正しい呼び出しを再生成する。コメントはこの点を説明している:「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 が連続バッチ処理と VRAM 認識戦略でこれらのリクエストをどのように編成するかを見る。
ここまでで、リクエストは外部入力から EngineCoreRequest への正規化変換を完了し、エンジンコアの入口に到達した。しかしリクエストは入った後すぐに実行されるわけではない——エンジンは各ステップでどのリクエストを処理するか、限られた VRAM リソースをどのように配分するかを決定する必要がある。次の章では EngineCore のスケジューリングループを深く掘り下げ、Scheduler が連続バッチ処理においてスループットとレイテンシをどのようにトレードオフするか、また chunked prefill、prefix caching、KV block 割り当てがどのように協調して動作するかを分析する。
第 4 章:スケジューラ:連続バッチ処理と VRAM 認識のリクエスト編成
リクエストが EngineCore の入力キューに入った後、すぐに実行されるわけではない。各ステップでどのリクエストを処理するか、各リクエストにどれだけの token 予算を割り当てるか、VRAM 不足時に誰を優先的に犠牲にするか、これらの決定はすべてScheduler.schedule()メソッドに集中している。本章ではスケジューラのデータ構造から始め、1 回のschedule()呼び出しが waiting キュー、running リスト、KV cache プールをどのように実行可能なバッチに組織するかを追跡する。
4.1 スケジューラのデータ構造:3 つのキューと 1 つの VRAM プール
スケジューラが答えるべき核心的な問いは:限られた token 予算と KV block 予算の下で、このステップでどのリクエストをどれだけ前進させるべきか?これを理解するには、まずそれが手にしている状態を明確にする必要がある。
スケジューラは 3 種類のリクエストコンテナを維持する。self.requestsはグローバル辞書であり、req_id -> Request、すべてのアクティブなリクエストの唯一の真実の源である📎 vllm/v1/core/sched/scheduler.py:208-209。self.waitingとself.skipped_waitingは 2 つの優先度キューであり、前者は正常にスケジューリングを待つリクエストを入れ、後者は非同期依存や制約により一時的にスケジューリングできないリクエスト(リモート 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は 2 つの異なる上限である。前者はmax_num_seqsに由来し、model runner のスロット数を決定する;後者はmax_num_active_seqsに由来し、RUNNING に入れるリクエスト数のみを制限し、デフォルトでは前者と等しい📎 vllm/v1/core/sched/scheduler.py:123-131。この分離により、CUDA graph キャプチャ容量を縮小することなく、実際の並行デコードバッチサイズを抑えることが可能になる。
VRAM 側は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。なぜ共用体型を使うのか?コメントが答えを与えている:ほとんどのハッシュは1つのブロックにしか対応せず、辞書を使うと不必要なGCオーバーヘッドが発生する。同じハッシュが複数のブロックで共有される場合にのみ辞書に昇格させる📎 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。コメントはなぜブロックを外側の次元にしないかを明確に説明している:それはすべてのgroupのブロック数が同じであると仮定することになり、将来的に異なるgroupに異なるblock sizeを設定する可能性があるからだ📎 vllm/v1/core/kv_cache_manager.py:43-48。
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この図はスケジューラとVRAMプール間のデータフローを固定する: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 予算の初期化と閾値の計算
メインループに入る前に、スケジューラはまず2つの予算を設定する: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の処理は個別に見る価値がある。その役割は長いprefillが他のリクエストを飢餓させるのを防ぐことだが、現在1つのリクエストしかない場合は飢餓になる者がいないため、閾値はゼロに設定される📎 vllm/v1/core/sched/scheduler.py:606-616。adaptive_long_prefill_thresholdが有効な場合、閾値はさらにinput_budget // num_eligible_reqsまで引き上げられ、単一リクエストの予算が公平な取り分以下に圧縮されないことが保証される📎 vllm/v1/core/sched/scheduler.py:617-622。
4.2.2 runningリクエストのスケジューリングループ
メインループはself.runningの先頭から走査を開始し、req_indexはカーソルである📎 vllm/v1/core/sched/scheduler.py:624-627。各リクエストに対して、まず一連のスキップ判定を行う:
- 非同期スケジューリング下で、リクエストの出力プレースホルダが
max_tokensに達したことを示す場合、余分なステップを実行しないようスキップする📎vllm/v1/core/sched/scheduler.py:631-645。 - V2 + PP + 非同期のシナリオで、現在のステップがまだ
next_decode_eligible_stepに達していない場合、worker側のサンプリングトークン放送のリズムに合わせるためスキップする📎vllm/v1/core/sched/scheduler.py:647-651。 - DP prefill均衡が有効な場合、リズム非整合ステップ上のprefill chunkは延期される📎
vllm/v1/core/sched/scheduler.py:653-657。
スキップ判定を通過した後、このリクエストがこのステップで何トークン進めるかを計算する:
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による調整を受ける📎 vllm/v1/core/sched/scheduler.py:700-712。
次が最も重要なステップである:KV blockの割り当て。allocate_slotsはwhile Trueループに包まれている📎 vllm/v1/core/sched/scheduler.py:742-747。Noneが返された場合、VRAMが不足していることを示し、スケジューラはプリエンプトを開始する:ポリシーに従って犠牲者を選び(PRIORITYポリシーは優先度が最も低いものを選び、FCFSポリシーはrunningリストの末尾を選ぶ)📎 vllm/v1/core/sched/scheduler.py:761-767、_preempt_requestを呼び出してwaitingキューに追い戻し、その後割り当てを再試行する📎 vllm/v1/core/sched/scheduler.py:801-806。犠牲者が現在のリクエスト自身である場合、プリエンプト可能な対象がもうないことを示し、ループを抜け、現在のリクエストもスケジュールできない📎 vllm/v1/core/sched/scheduler.py:807-813。
プリエンプトロジックには巧妙な細部がある:PRIORITYポリシー下で、プリエンプトされたリクエストがすでにscheduled_running_reqsにある場合(つまりこのステップで既にリソースを割り当てられている場合)、そのトークン予算、block、投機トークン、エンコーダ予算をすべて返却する必要がある📎 vllm/v1/core/sched/scheduler.py:779-797。これにより予算台帳の一貫性が保証される。
割り当て成功後、リクエストはscheduled_running_reqsに追加され、blockとトークン数が記録され、予算が差し引かれる📎 vllm/v1/core/sched/scheduler.py:815-823。投機的デコーディング関連のトークンはここでトリミングされ記録される📎 vllm/v1/core/sched/scheduler.py:825-841。
4.2.3 waitingリクエストの受け入れ
runningループ終了後、このステップでプリエンプトが発生せず、スケジューラが一時停止していない場合、waitingキューの処理を開始する📎 vllm/v1/core/sched/scheduler.py:868-872。受け入れ前に2つの上限をチェックする:max_num_active_reqsとinput_budget 📎 vllm/v1/core/sched/scheduler.py:873-879。
waitingリクエストのスケジューリングはrunningよりプレフィックスキャッシュ検索ステップが1つ多い。request.num_computed_tokens == 0のとき、_get_local_prefix_cache_hitを呼び出してローカルキャッシュヒットを検索する📎 vllm/v1/core/sched/scheduler.py:932-939。KV connectorが設定されている場合、リモートキャッシュヒットも照会する📎 vllm/v1/core/sched/scheduler.py:942-954。
ここにはローカルとリモートのヒット競合を処理する精細なロジックがある。ローカルヒットはブロック整列していない可能性があり(partial_tail)、リモートヒットがローカルの完全ヒットを厳密に超える場合、ローカルのサブブロック末尾を破棄し、リモートロードでそれを上書きさせ、コピーオンライトを回避する📎 vllm/v1/core/sched/scheduler.py:977-988。逆の場合はローカル末尾を保持し、外部をロードしない📎 vllm/v1/core/sched/scheduler.py:989-995。
受け入れ成功後、リクエストはwaitingキューからポップされ、状態はRUNNINGに設定され、runningリストに追加される📎 vllm/v1/core/sched/scheduler.py:1263-1319。このステップの後もまだprefill中である場合(num_computed_tokens + num_new_tokens < request.num_tokens)、_inflight_prefills集合に追加される📎 vllm/v1/core/sched/scheduler.py:1326-1328。
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()の2大ループとプリエンプト分岐をカバーしている。runningループにおけるallocate_slots失敗後のプリエンプト再試行パス、およびwaitingループにおけるblocked状態リクエストの移動に注意skipped_waitingのバイパス。
4.3 メモリ認識の中核:allocate_slots とプリエンプション
allocate_slotsはスケジューラとメモリの間のゲートである。その引数リスト自体がメモリの台帳である:num_new_tokensは新たに計算する token 数、num_new_computed_tokensはプレフィックスキャッシュで新たにヒットした token 数、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:
| < comp > | < new_comp > | < ext_comp > | < new > | < lookahead > |
| < to be computed > |
| < to be allocated > |compは計算済み token、new_compはプレフィックスキャッシュヒット、ext_compは外部ヒット、newは本ステップの新規計算、lookaheadは投機的予約。割り当ては3段階に分かれる:まず不要なブロックを解放し十分な空きブロックがあるか確認し、次にプレフィックス token を処理し、最後に新規計算 token にブロックを割り当てる📎 vllm/v1/core/kv_cache_manager.py:458-461。
4.3.1 ウォーターマークとアドミッション制御
allocate_slotsには2つのアドミッションゲートがある。1つ目はfull_sequence_must_fit:有効時、リクエストシーケンス全体(最初の chunk だけでなく)が収まるか先に確認し、収まらなければ直接None 📎 vllm/v1/core/kv_cache_manager.py:515-531を返す。これにより chunked prefill 下での過剰なアドミッションによる KV cache の揺れを防ぐ。
2つ目はウォーターマークである。watermark_blocksはリクエスト状態が WAITING または PREEMPTED で、かつ既にリクエストがスケジュールされている場合のみ有効になる📎 vllm/v1/core/kv_cache_manager.py:506-513。割り当て後に一定割合の空きブロックを少なくとも保持することを要求し、頻繁な追い出しとプリエンプションを避ける。reserved_blocksは非同期 KV ロードのシナリオで用いられ、in-flight な 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 し直すことを意味する。なぜこう設計したか? vLLM の KV block はリクエスト専有であり、プリエンプト時には全ブロックを解放する必要があり、解放後に再割り当てで同じブロックを取得できる保証がないため、最初から計算し直すしかない。プレフィックスキャッシュの存在がこのコストを部分的に相殺する:プリエンプトされたリクエストのプレフィックスが既にキャッシュされていれば、再スケジュール時にキャッシュヒットし、実際に再計算する必要はない。
プリエンプションは非同期スケジューリング下の「陳腐化した出力」問題にも対処する。num_stale_output_tokensはnum_in_flight_tokensに設定され、全ての in-flight 出力を陳腐化としてマークする📎 vllm/v1/core/sched/scheduler.py:1571-1574。これらの token は依然として配信される(破棄すると投機的デコーディングの受理率を乱すため)が、リセット後のカウンタは変更しない。drop_stale_outputフラグは破棄か配信かを決定する📎 vllm/v1/core/sched/scheduler.py:1539-1547。
4.3.3 遅延解放:非同期コネクタの write-after-read リスク
KV connector を使用し、複数の in-flight バッチが存在する場合、defer_block_freeはTrue 📎 vllm/v1/core/sched/scheduler.py:175-181に設定される。理由:あるステップが解放済みリクエストの KV ブロックにまだ書き込んでいる可能性があり、コンシューマ 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か?コメントが説明している:全 token がキャッシュヒットした場合、logits を得るために最後の token を再計算しなければならない📎 vllm/v1/core/kv_cache_manager.py:289-294。これは見落とされがちな境界である:プレフィックスが完全にヒットしても、少なくとも1つの token は計算する必要がある。
ブロックのライフサイクルは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()メソッド内の2つのループである:runningループは既に実行中のリクエストの前進を優先し、waitingループは予算が許す限り新規リクエストを准入する。VRAM不足時にはrunningリスト内で最も優先度の低いリクエストをプリエンプトして空間を空け、プリエンプトされたリクエストのnum_computed_tokensは0にリセットされるが、プレフィックスキャッシュが再計算コストの一部を相殺する。allocate_slotsはVRAMゲートであり、full_sequence_must_fit、水位線、reserved_blocksの3層准入制御により過剰割り当てを防ぐ。プレフィックスキャッシュはブロックハッシュインデックスによるリクエスト間共有を実現し、ヒット判定はnum_tokens - 1を上限として少なくとも1トークンを計算して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が有効な場合、犠牲者の最後のスケジュールステップがまだ処理されていなければ、そのブロックはまだin-flightの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に変更した場合、どのような状況で出力エラーが生じるか?
参考解説:リクエストのすべてのトークンがキャッシュにヒットした場合、num_computed_tokensはnum_tokensと等しくなる。このときスケジューラは新しいトークンを計算する必要がないと判断するが、サンプリングlogitsには最後の位置の隠れ状態が必要であり、隠れ状態はフォワードパスから得られる。どのトークンも計算されなければ、サンプリングできるlogitsがなく、リクエストはスタックするか誤った出力を生成する。コメントがこの点を明確に説明している📎 vllm/v1/core/kv_cache_manager.py:289-294。さらに、allocate_slotsはnum_computed_tokensがブロックサイズに整列していることを要求し、最後のトークンの再計算がブロック全体の再計算を引き起こす可能性があり、これは現在の実装の既知の制限である。
Q3: _preempt_requestはnum_computed_tokensを0にリセットするが、request.num_tokens(prompt + 生成済みトークン)は保持する。プリエンプトされたリクエストが再スケジュールされたときにプレフィックスキャッシュがミスした場合、何トークンを再計算する必要があるか?ヒットした場合、どれだけ節約できるか?
参考解説:num_computed_tokens = 0は再スケジュール時に最初のトークンから開始することを意味する📎 vllm/v1/core/sched/scheduler.py:1561。request.num_tokensは変わらず、元のpromptと生成済みの出力トークンを含む。プレフィックスキャッシュがミスした場合、すべてのnum_tokensトークンのprefillを再計算する必要がある。ヒットした場合、get_computed_blocksはヒットしたブロックを返し、num_computed_tokensはヒット位置から📎 vllm/v1/core/kv_cache_manager.py:296-300。プリエンプトされたリクエストの出力トークンもnum_tokensに含まれ、それらのプレフィックスハッシュは生成時にキャッシュされている(有効な場合)ため、再スケジュール時にこれらの出力トークンのプレフィックスもヒットする可能性がある。しかしmax_cache_hit_length = num_tokens - 1は最後のトークンが常に再計算されることを意味する。
スケジューラが出力するSchedulerOutputはこのステップの実行内容を明確にする:新規リクエストのブロックID、キャッシュされたリクエストのトークン数、投機トークン、エンコーダ入力など。次章ではこの出力がModelRunnerにどのように消費されるかを追跡し、SchedulerOutputからGPUフォワードパスまでを辿る。
第5章:モデル実行の幹:SchedulerOutputからGPUフォワードパスまで
前章では、Schedulerが各ステップのスケジューリングループでどのリクエストがrunningキューに入り、どれがプリエンプトされ、どれがVRAM不足で待機するかを決定し、最終的にSchedulerOutputを生成することを見た——それはこのステップで何を計算すべきかを記述する:どのリクエスト、それぞれ何トークン、どのKVブロックを使うか。しかしこのリストは論理的な意図に過ぎず、GPUが必要とするのは物理テンソルである。本章ではSchedulerOutputがExecutorによってWorkerに配布され、GPUModelRunnerによってinput_ids、positions、slot_mapping、block tableなどのGPU実行可能な入力に翻訳され、最終的に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:
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設定に基づいて具体的な Executor クラス📎 vllm/v1/executor/abstract.py:51-96を返す。その分岐構造は詳しく見る価値がある:
- 設定自体が
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。
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の呼び出しフローexecutor.execute_model(scheduler_output)。
Executor.execute_modelシナリオを代入:EngineCore が一步のスケジューリングを完了し、📎 vllm/v1/executor/abstract.py:237-238:
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]〔設計推論とアーキテクチャトレードオフ〕output[0]鍵はcollective_rpcにある——これはメソッド名と引数をすべての Worker にブロードキャストし、各 Worker の戻り値リストを収集し、そして📎 vllm/v1/executor/abstract.py:220-221は最初のものだけを取る。なぜ最初のものだけを取るのか? テンソル並列下では、すべての Worker が同じ論理フォワードを実行し、出力は意味的に等価であるため;サンプリング結果は最後の PP ステージまたは rank 0 によって決定され、SchedulerOutputを取ることで重複集約を回避する。
sample_tokensのドキュメントは明確に「制御メッセージのみを送信し、データプレーン通信は別途確立する」ことを推奨している📎 vllm/v1/executor/abstract.py:257-258、これこそがNoneの位置づけである——これは制御メッセージであり、実際の token データは GPU テンソルを通じて Worker 内部で流れる。execute_modelは同じパターンに従うNone、しかし戻り値の型にExecuteModelStateを含まない——サンプリングは必然的に結果を産出する。これら二つのメソッドの分業は vLLM v1 の「実行-サンプリング分離」設計に対応する:
は
collective_rpcを返す可能性がある(フォワードがコミットされたがサンプリングが延期されたことを示す)、この場合状態は@abstractmethod 📎 vllm/v1/executor/abstract.py:186-192に一時保存される。MultiprocExecutor設計思考RayDistributedExecutorはUniProcExecutorとして宣言されている、つまり異なるバックエンドが「どのように RPC を Worker に送るか」を自分で実装しなければならない。
は共有メモリキューを使用し、supported_tasksは Ray actor 呼び出しを使用し、@cached_property 📎 vllm/v1/executor/abstract.py:306-309は直接ローカル呼び出しを行う。この抽象化により、上位層のコードは分散の詳細を完全に気にする必要がなくなる。get_supported_tasks見落としがちな詳細:
は
とマークされ、コメントは「不必要な RPC 呼び出しを避ける」と明言している。なぜなら
GPUModelRunnerはプロセス間通信を必要とし、タスクリストはモデルのライフサイクル内で不変であるため、キャッシュは正確かつ必要な最適化である。SchedulerOutput5.2 GPUModelRunner:SchedulerOutput から入力テンソルへ
直感的モデル
GPUModelRunnerは「翻訳者」である:これは📎 vllm/v1/worker/gpu_model_runner.py:479-480:LoRAModelRunnerMixin、KVConnectorModelRunnerMixin、ECConnectorModelRunnerMixin内の論理記述(リクエスト ID、token 数、ブロック ID)を GPU が直接消費できる物理テンソルに翻訳する。もしこれがなければ、モデル層が「3 番目のリクエストの 7 番目の token はどの KV スロットにあるか」といった問題を自分で処理しなければならない——これは壊滅的な関心の漏洩である。
__init__コア状態とメモリレイアウト📎 vllm/v1/worker/gpu_model_runner.py:488-498は三つの Mixin から継承する
check_ep_fault、それぞれ LoRA 適配、KV コネクタ、エンコーダコネクタ能力を提供する。📎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:データ並列 > 1 かつ MoE モデルの場合のみ、EP all2all マネージャがフォールトトレランスをサポートするかを照会する📎vllm/v1/worker/gpu_model_runner.py:516。
ExecuteModelState:NamedTupleによって決定されるexecute_model():prompt embedding 入力を有効にするかどうかsample_tokens()は📎 vllm/v1/worker/gpu_model_runner.py:463-476であり、logits、hidden_states、sample_hidden_statesとspec_decode_metadata、slot_mappingsの間の一時状態を保持する📎 vllm/v1/worker/gpu_model_runner.py:464-464。
Step-by-Step:_update_states。そのフィールド設計は実行-サンプリング分離の本質を明らかにする:
はフォワードの産物であり、
はサンプリング段階でまだ必要なメタデータである。コメントは明確にこれが「execute_model() が None を返した後に渡される一時キャッシュ状態」であると述べているキャッシュ状態をどのように同期するかfinished_req_idsシナリオを代入:スケジューラが本ステップでリクエスト A(新規リクエスト)、B(前ステップの decode 継続)、C(プリエンプト後に復帰)を処理し、同時にリクエスト D が完了したと決定する。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。
を削除する。コメントが指摘する境界ケースに注意:とnew_block_ids_to_zeroは重複する可能性がある——リクエストが中止された後に同じ ID で再提出された場合、それらは二つの異なるリクエストとして扱われる_zero_block_ids第二步:新しく割り当てられた KV ブロックをゼロクリアする。📎 vllm/v1/worker/gpu_model_runner.py:1219-1222もし
が空でなければ、を呼び出して显存をゼロクリアし、古い NaN がアテンションや SSM 計算を汚染するのを防ぐ📎 vllm/v1/worker/gpu_model_runner.py:1238-1247:
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を構築する。もしサンプリングタイプが📎 vllm/v1/worker/gpu_model_runner.py:1319-1321。
なら、シード付きのを作成する。もしモデルが M-RoPE を使用するなら、scheduled_cached_reqsを呼び出して位置を事前計算するnum_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1402第五步:実行中リクエストを更新する。📎 vllm/v1/worker/gpu_model_runner.py:1437-1448各req_index is Noneに対して、reqs_to_add 📎 vllm/v1/worker/gpu_model_runner.py:1450-1465。
を更新し、ブロック ID の追加または置換を処理する 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:
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は 2 つの形式を返す📎 vllm/v1/worker/gpu_model_runner.py:4078-4078:KV cache group でインデックスされたdict[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。未使用の末尾パディング-1、コメントはこれがreshape_and_cacheの全 CUDA graph モードでの必要性を説明している📎 vllm/v1/worker/gpu_model_runner.py:4118-4122。
_get_block_table各 KV cache group に対してデバイステンソルを取得し、📎 vllm/v1/worker/gpu_model_runner.py:2319-2335で CUDAGraph パディング行を埋める——ブロック 0 はパディング用に予約されているNULL_BLOCK_ID📎 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 に 1 つ)📎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:token 軸上のブールマスク、Trueはパディング行を表す📎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 をキャプチャする。なぜならfused_moe_loraなどのカーネルの grid size がこの値に依存するからである📎 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_modelset_forward_contextが呼び出される📎 vllm/v1/worker/gpu_model_runner.py:4408-4420:
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 またはシーケンス並列 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。
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なぜグローバル変数を使い、明示的なパラメータ渡しを使わないのか? モデル層のget_forward_context()シグネチャは HuggingFace の規約で固定されており、層ごとに追加パラメータを注入できないからである。グローバル変数 + コンテキストマネージャは、モデルコードを変更せずに層をまたぐ注入を実現できる唯一の方法である。代償は暗黙的な依存——set_forward_contextの呼び出し元は自分が
is_paddingのスコープ内にいることを保証しなければならない。📎 vllm/forward_context.py:162-165フィールドの設計は注目に値する
all_moe_layers:コメントは「消費者はこれを使って padding token の作業をスキップできる」と述べている。これは CUDA graph シナリオでの最適化である——padding 行はグラフキャプチャに参加するが、実際の計算を生成すべきではない。moe_layer_index📎 vllm/forward_context.py:170-195とvllm.moe_forwardは巧妙な workaround のペアであるForwardContext。コメントは問題を詳細に説明している:📎 vllm/forward_context.py:182-184。
カスタム演算子は層名文字列をグラフにハードコードし、torch.compile のコールドスタート時間が長くなりすぎる。解決策は層名リストを
に保存し、カスタム演算子が順番に文字列をポップしてカウンタをインクリメントする。コメントは「カスタム演算子が順番に実行され、torch.compile が再配置しない」という仮定に依存することも率直に認めている _update_states設計思考と本番の落とし穴output_token_ids非同期スケジューリングの状態一貫性。📎 vllm/v1/worker/gpu_model_runner.py:1376-1384は非同期投機的デコーディング下で「楽観的仮定」戦略を採用する:前ステップのすべての draft token が受け入れられたと仮定し、まず📎 vllm/v1/worker/gpu_model_runner.py:1509-1510を拡張し、次に遅延修正関数num_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1547-1558を登録する。修正関数はモデルフォワード起動後に
_may_reorder_batchを呼び出し、GPU から実際の受け入れ数を読み取りをロールバックする。この設計の巧妙さは:修正が「バッチ起動済み」の後に発生し、フォワードをブロックせず、非同期パイプラインの連続性を保つことにある。kv_cache_groupsのトリガ条件。📎 vllm/v1/worker/gpu_model_runner.py:1131-1132このメソッドはまずis_attention_free:Mamba モデルも attention-free だが、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=Trueの Event を使用して CUDA ドライバロックのビジーループを回避する📎 vllm/v1/worker/gpu_model_runner.py:296-298。get_output()では先に synchronize してからデバイステンソル参照を解放する📎 vllm/v1/worker/gpu_model_runner.py:336-340、順序を逆にしてはならない——そうでなければテンソルがコピー完了前に回収される可能性がある。
本章のまとめ
本章ではSchedulerOutputEngineCore から GPU フォワードまでの完全なパスを追跡した。Executorcollective_rpcを通じてスケジューリング結果をすべての Worker にブロードキャストし、GPUModelRunnerの_update_statesがキャッシュ状態を同期し、_prepare_inputs入力テンソルを構築し、_get_slot_mappingsKV スロットマッピングを生成し、最後に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を取り除くと、このリクエストは「スケジュール済み」と見なされてバッチに残るが、そのブロック ID は既に置き換えられており(req_state.block_ids = new_block_ids 📎 vllm/v1/worker/gpu_model_runner.py:1448)、block table の古い行と新しいブロック ID が一致しなくなり、アテンション計算が誤った 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)を条件として使用する。もしバッチ内のリクエスト順序が変化した場合(例えばアテンションバックエンドがバッチを再配置した)、しかし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は現在のバッチにおけるそのリクエストの最後の token のフラットインデックスである。もしバッチが再配置されると、prev_indexとflattened_indexの対応関係が変わり、common_indices_matchは False になり、ファストパスはトリガーされない。しかし、もし再配置が偶然prev_index == flattened_indexをすべてのリクエストに対して成立させる場合(例えば同じ token 数を持つ二つのリクエストを交換した場合)、ファストパスは誤ってprev_sampled_token_ids[:num_common_tokens, 0]を直接スライスコピーしてしまう——これによりリクエスト A のサンプリング token がリクエスト B の位置に書き込まれる。max_flattened_index == num_common_tokens - 1この追加条件はまさにこの退化ケースを防ぐためのものである:フラットインデックスが正確に0..N-1の順列であることを要求し、いかなる非自明な再配置も排除する。
Q3: ForwardContextはモジュールレベルのグローバル変数_forward_contextを使用し、スレッドローカル変数ではない。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(または外側の値)にリセットされているからである。これこそがExecuteModelStateが存在する理由である📎 vllm/v1/worker/gpu_model_runner.py:463-476:サンプリングに必要な状態(logits、hidden_states、slot_mappings)は NamedTuple に明示的に保存され、ForwardContextの暗黙的な伝達に依存しない。もし誤ってForwardContextがsample_tokens内で依然として利用可能だと思うと、アサーションエラーが発生するか、誤ったメタデータを読み取ることになる。
ここまでで、SchedulerOutput から GPU フォワード伝播までの完全なパスを歩み終えた:Executor のディスパッチ、Worker の実行、GPUModelRunner が論理マニフェストを物理テンソルに変換し、forward_context を通じてバッチ記述を各層に注入する。しかし、モデルフォワード伝播で最も時間のかかる部分——アテンション計算——はまだ展開されていない。次章ではアテンションバックエンドに深く入り、attn_metadata 内の block table と slot mapping が PagedAttention カーネルによってどのように消費されるか、そして FlashAttention、FlashInfer、Triton などの異なるバックエンドが統一インターフェースを通じてどのように選択・スケジューリングされるかを見ていく。
第 6 章:アテンションバックエンドと PagedAttention カーネル実装
前章では、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 を主軸とします。なぜなら、それは PagedAttention の gather セマンティクス、CUDA Graph 互換性、カスケードアテンション、DCP 分散コンテキストなど、最も豊富な分岐を同時にカバーしているからです。これを読み解けば、他のバックエンドは単なるパラメータマッピングの変種に過ぎません。この「バックエンド登録 + 統一インターフェース」という設計の動機は非常に直接的です:アテンションカーネルの進化は極めて速く(FA2→FA3→FA4、FlashInfer のイテレーション、Triton の自社開発)、モデル層が特定のカーネルに直接依存していると、カーネルがアップグレードするたびにモデルコードを変更する必要があります。抽象層は変化を get_impl_cls() という一つのファクトリメソッドの背後に隔離します。
バックエンド選択:能力宣言とメタデータ構築
直感的モデル
をAttentionBackend採用通知と考えてください:それは実際の作業は行わず、「どの dtype、どの head_size、どの KV cache 量子化フォーマット、どの attention タイプを処理できるか」を宣言するだけです。スケジューラはモデル設定を持ってマッチングを行い、マッチングに失敗すれば次の候補に切り替えます。この宣言層がなければ、システムは実行時に「この head_size はカーネルがサポートしていない」と初めて気づき、直接クラッシュします。
能力マトリクス:フィールドは契約
FlashAttentionBackendのクラス属性がその能力の境界です。supported_dtypesは fp16/bf16 に限定📎 vllm/v1/attention/backends/flash_attn.py:287-287;supported_kv_cache_dtypesは追加で fp8 シリーズを許可📎 vllm/v1/attention/backends/flash_attn.py:298-299。しかし「サポートを宣言」は「無条件サポート」と等しくありません——supports_kv_cache_dtypeは量子化 KV に対してさらにflash_attn_supports_kv_cache_dtypeに委譲してデバイス依存の判断を行います📎 vllm/v1/attention/backends/flash_attn.py:431-438。
さらに精細なのはsupports_combinationです:これは head_size、dtype、block_size、use_mla、has_sink などの一連の組み合わせパラメータを受け取り、Noneを返せば利用可能、文字列を返せば拒否理由を示します📎 vllm/v1/attention/backends/flash_attn.py:454-507。例えば sink は計算能力 < 9.0 で拒否され📎 vllm/v1/attention/backends/flash_attn.py:467-468、SM90 では FP8 KV と mm_prefix の組み合わせは Triton を経由しなければなりません📎 vllm/v1/attention/backends/flash_attn.py:472-472。この「理由文字列を返す」設計により、上位層は静かなフォールバックではなく、診断可能なエラーを出せます。
block_size の選択も同様に能力によって駆動されます。デフォルトではMultipleOf(16)を返しますが、SM90 FP8-KV では 64 が強制され📎 vllm/v1/attention/backends/flash_attn.py:297-324、FA4 の head_size=256 カーネルではFA4_HD256_PAGE_SIZE 📎 vllm/v1/attention/backends/flash_attn.py:326-352が強制されます。これは KV cache のブロックサイズが適当に決められるものではない理由を説明します——それはカーネルの TMA タイルサイズによって逆に制約されるのです。
メタデータ構造:FlashAttentionMetadata のフィールドレイアウト
FlashAttentionMetadataは dataclass であり、フィールドは4つのグループに分かれます📎 vllm/v1/attention/backends/flash_attn.py:511-566:
第一グループは基本的なバッチ記述です:num_actual_tokens(パディングを除いた実際のトークン数)、max_query_len、query_start_loc(プレフィックスサム、varlen カーネルが各シーケンスの開始と終了を特定するために使用)、seq_lens、block_table、slot_mapping 📎 vllm/v1/attention/backends/flash_attn.py:520-526。ソースコードのコメントにある ASCII 図📎 vllm/v1/attention/backends/flash_attn.py:512-518に注意してください。これはcontext_len(履歴 KV)、query_len(今回新規追加)、seq_len(両者の合計)を正確に区別しています——これは varlen カーネルパラメータを理解する鍵です。
第二グループはカスケードアテンションフィールドです:use_cascade、common_prefix_len、cu_prefix_query_lensなど📎 vllm/v1/attention/backends/flash_attn.py:528-533。
第三グループは DCP(Decode Context Parallel)フィールドです:max_dcp_context_kv_len、dcp_context_kv_lens、および decode/prefill リクエスト数を区別するカウンタ📎 vllm/v1/attention/backends/flash_attn.py:535-544。
第四グループはオプションのスケジューリングと特殊マスクです:scheduler_metadata(FA3 AOT スケジューリング用)、causal(bool またはテンソルで、シーケンスごとの因果をサポート)、mm_prefix_query_range_tensor(マルチモーダル双方向範囲)、R-SWA 関連フィールド📎 vllm/v1/attention/backends/flash_attn.py:546-566。
causalフィールドタイプはbool | torch.Tensorであり、純粋な bool ではありません。これは「同一バッチ内で一部のシーケンスが因果的、一部が非因果的」というシナリオ(例:PrefixLM)をサポートするためです。これがテンソルの場合、FA4 のdynamic_causalパラメータが引き継ぎ、FA2/FA3 は直接 NotImplementedError を投げます📎 vllm/v1/attention/backends/flash_attn.py:1429-1433。
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 のみがスケジューリングメタデータの事前計算をサポートします。第三ステップでは、初回 build 時に遅延的にaot_sliding_windowを埋めます:すべてのFlashAttentionImpl層を走査してスライディングウィンドウ設定を収集し、設定が一意であればそれを採用し、複数ある場合は AOT を無効化します📎 vllm/v1/attention/backends/flash_attn.py:848-851。
第四ステップでは、max_num_splitsを計算します。デフォルトは 0(FA3 にヒューリスティックを使わせる)で、full CUDA graph が有効かつトークン数がキャプチャ範囲内にある場合にのみself.max_num_splits 📎 vllm/v1/attention/backends/flash_attn.py:856-866に設定します。コメントには理由が説明されています:num_splits > 1は[num_splits, num_heads, num_tokens, head_size]の中間バッファを割り当て、VRAM コストが高いため、CUDA graph のシナリオでのみ📎 vllm/v1/attention/backends/flash_attn.py:862-865。
だけの価値があります_get_scheduler_metadata第五ステップでは、非カスケード非 DCP ブランチを通り、📎 vllm/v1/attention/backends/flash_attn.py:976-986を呼び出して FA3 のスケジューリングメタデータを生成します_store_scheduler_metadata。第六ステップでは、📎 vllm/v1/attention/backends/flash_attn.py:671-684が CUDA graph シナリオを処理します:新しいメタデータを事前割り当てバッファにコピーし、残りの部分をゼロクリアします📎 vllm/v1/attention/backends/flash_attn.py:671-672。
。このゼロクリアのステップは極めて重要です——コメントは明確に指摘しています。そうでなければ一部の thread block が無効なメタデータを読み取り、出力バッファを上書きしてしまいますFlashAttentionMetadata第七ステップでは、📎 vllm/v1/attention/backends/flash_attn.py:992-1015。
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 の物理レイアウトをカーネルが期待する形状に調整し、その後具体的なカーネルにディスパッチします。このステップがなければ、カーネルは誤ったメモリレイアウトを読み取り、出力はサイレントに誤ります——クラッシュよりも発見が困難です。[num_blocks, num_kv_heads, block_size, 2 * head_size]KV cache のメモリレイアウト変換📎 vllm/v1/attention/backends/flash_attn.py:1246-1247vLLM の KV cache の物理形状は[num_blocks, block_size, num_kv_heads, head_size]。
——K と V が最後の次元に連結されていますforward()。しかし FlashAttention カーネルは K と V が分離されており、レイアウトが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の冒頭で行われます:transposeが
をcanonicalize_singleton_dim_strides 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310に変え、最後の次元に沿って K と V に分割します。注意:num_kv_heads=1は stride のみを変更しデータを移動しないため、後続のカーネルは非連続アクセスをサポートする必要があります。📎 vllm/v1/attention/backends/flash_attn.py:1310-1310直後に
が続きます。コメントは動機を明示しています:
(TP シナリオで一般的)の場合、size-1 次元の stride は退化しており、FA3/FA4 は H100+ で TMA を使用するため、stride が少なくとも 16 バイトにアラインされている必要があります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)(num_sequences, num_kv_heads)ブランチに入った後、パラメータは一つずつマッピングされます.expand()は📎 vllm/v1/attention/backends/flash_attn.py:1258-1258。
を取得し、FP8 量子化の scale ブロードキャストに使用されます——コメントは flash-attn が期待する descale 形状は_maybe_symmetrize_windowであり、(w, 0)を使ってコピーを回避すると説明しています(w, w)次にスライディングウィンドウの対称化処理です。📎 vllm/v1/attention/backends/flash_attn.py:587-589のロジック:因果スライディングウィンドウ📎 vllm/v1/attention/backends/flash_attn.py:1362-1365。
は非因果シナリオでは
に変わり、双方向クエリが両方向を見られるようにしますmm_prefix_query_ranges。コメントはさらに「層自身の window が group の window より優先される」と強調しています。なぜなら一つの KV cache group が同時にウィンドウ層とグローバル層を収容する可能性があるからです(例:Gemma-3 で hybrid KV cache manager を無効にした場合)mask_mod 📎 vllm/v1/attention/backends/flash_attn.py:1374-1407マスク分岐:mm_prefix と R-SWAcausal = Falsesliding_window_size = None 📎 vllm/v1/attention/backends/flash_attn.py:1406-1407が非空かつ FA4 + 静的因果条件を満たす場合、コードは CuTE-DSL の(causal ∧ window) ∨ bidirectional-rangeを構築します。重要なアクションは📎 vllm/v1/attention/backends/flash_attn.py:1402-1405。
_make_mm_prefix_mask_modとfunctools.cacheです。コメントは理由を説明しています:mm_prefix の意味は📎 vllm/v1/attention/backends/flash_attn.py:1793-1802であり、causal の部分集合ではありません;FA #155 以降、mask_mod を設定しても causal/local が自動クリアされなくなり、呼び出し側が明示的に無効化する必要があります。そうでなければ組み込み causal パスが mask_mod をショートカットしますhash_callableはrepr()を使って_load_q_rangeをキャッシュします📎 vllm/v1/attention/backends/flash_attn.py:1793-1802。コメントは核心的な理由を示しています:FA4 の
はクロージャユニットのq_idxをコンパイルキーに混入させ、ネストされたkv_idxは呼び出しごとにアドレスが異なるため、毎回の forward で完全な JIT 再コンパイルがトリガーされますq_abs = q_idx + seqlen_k - seqlen_q。これは本番環境のパフォーマンス罠の典型的なサンプルです。📎 vllm/v1/attention/backends/flash_attn.py:1859-1865。__vec_size__ = 1マスク内部には座標変換の詳細があります:FA4 が渡すのはローカルな_load_q_range(現在の prefill chunk 内の 0-based)であり、📎 vllm/v1/attention/backends/flash_attn.py:1897-1897。
は絶対位置です。コードはcausal & (in_prefix | in_window) 📎 vllm/v1/attention/backends/flash_attn.py:1945-1948を使って絶対位置を復元しますuse_fast_sampling = Trueの設定にもこだわりがあります:📎 vllm/v1/attention/backends/flash_attn.py:1950-1950。
は lane 0 を読み取り、一度の呼び出しで query 行を跨ぐことはできません
R-SWA の mask_mod も類似していますが、意味はself.fa4_hd256であり、かつnum_pages = cdiv(max_seqlen_k, FA4_HD256_PAGE_SIZE),max_seqlen_kにより FA4 が完全にマスクされた KV block をスキップし、そのデータをロードしませんblock_tableFA4 hd256 の特殊処理num_splits = 1 📎 vllm/v1/attention/backends/flash_attn.py:1442-1448
が真の場合、コードは page アラインメントを強制します:_FA4_DENSE_ATTENTION_KERNEL(...)はページ境界に切り上げ、📎 vllm/v1/attention/backends/flash_attn.py:1450-1475。
は正確なページ数に切り捨て、
forward()。コメントは hd256 カーネルがページアラインされた長さ、正確な幅の block table を要求し、SplitKV をサポートしないと説明しています。do_kv_cache_update最終的にreshape_and_cache_flashを呼び出し、q、k、v、out、cu_seqlens_q、seqused_k、block_table、softcap、mask_mod、aux_tensors などを一括して渡しますslot_mappingKV cache 書き込み:do_kv_cache_update📎 vllm/v1/attention/backends/flash_attn.py:1532-1541key/valueは KV cache を読み取るのみで、書き込みはslot_mappingいいえ、ただし手動でのスライスは不要です。なぜなら op がslot_mappingの shape で実際の token 数を決定するからです📎 vllm/v1/attention/backends/flash_attn.py:1527-1531。ここでは stride の正規化は行いません。TMA カーネルが関与していないためです📎 vllm/v1/attention/backends/flash_attn.py:1520-1521。
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_combinationbool ではなく理由文字列を返すのは、上位層が他のバックエンドにフォールバックする際に「なぜ FA を使わなかったのか」を記録できるようにするためであり、オンラインでの調査コストを大幅に削減します。サイレントフォールバックと比べて、この設計は意思決定の根拠を明示化します。
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 の build-time ホスト側の決定(例えばskip_dcp_context_attention())はメタデータの形状を変えてしまい、これらの Python フィールドは graph 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。通過後さらに粗い性能モデルで cascade と FlashDecoding の CTA 数と wave 数を比較します📎 vllm/v1/attention/backends/flash_attn.py:2011-2029。コメントはこのモデルが「very rough」であると率直に認めています📎 vllm/v1/attention/backends/flash_attn.py:2009-2010。
本番での落とし穴:forward()には目立つコメントがあり、piece-wise CUDA graph 下ではこのメソッドが eager モードで実行されること、view/sliceなど一見 GPU 操作がなさそうなメソッドが実際には非常に遅く、変更時には必ず benchmark が必要であることを警告しています📎 vllm/v1/attention/backends/flash_attn.py:1277-1284。これはコード内で[:num_actual_tokens]スライスが多用され、より「エレガント」な書き方がされていない理由を説明しています——その一つ一つが性能トレードオフの結果なのです。
---
本章のまとめ
本章ではFlashAttentionBackendに沿ってアテンションバックエンドの完全なライフサイクルを辿りました:能力宣言(supports_*シリーズ)→ メタデータ構築(build()がCommonAttentionMetadataをFlashAttentionMetadataに変換)→ カーネル呼び出し(forward()が KV cache レイアウトを変換し、マスクを構築し、FA カーネルにディスパッチ)。核心的な仕組みには以下が含まれます:KV cache のtranspose+splitレイアウト変換、退化した 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。末尾をクリアしない場合、前回の build で残ったスケジューラメタデータが今回のカーネルに読み込まれてしまいます。コメントは「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 のコンパイルキーがクロージャのアドレスに影響されるのでしょうか?
参考解説:コメントは FA4 のhash_callableがクロージャセルの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 ループ内で毎ステップコンパイルが走るため、レイテンシはミリ秒級から秒級に劣化する。これは「一見無害な Python クロージャ」が JIT キャッシュを無効化する典型例である。
Q3: supports_draft_decode_metadata_update = self.dcp_world_size == 1この行のコードは DCP シナリオにおいて fused draft decode を無効化している。仮にこれを強制的にTrueに変更した場合、投機的デコーディング + DCP の組み合わせで具体的にどのようなエラーが発生するか?
参考解析:コメントは fused draft decode が draft ステップ間でキャプチャされたメタデータオブジェクトを再利用することを説明しており、DCP の build-time ホスト側の決定(例えばskip_dcp_context_attention())がメタデータの形状/制御パスを変更する。例えばmax_dcp_context_kv_len 📎 vllm/v1/attention/backends/flash_attn.py:736-741。これらの Python フィールドは CUDA graph replay 間でインプレース更新されない。具体的なエラー: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 アテンションをスキップし、出力にコンテキスト情報が欠落する——サイレントエラーであり、クラッシュはしない。これはまさに「性能最適化と正確性が衝突した際に正確性を選ぶ」ことの表れである。
ここまでで、アテンションバックエンドの抽象インターフェースからカーネル実装までの完全な経路が打通された:モデル層は AttentionImpl を通じて統一的に呼び出し、バックエンドは block_table、slot_mapping などのメタデータを具体的なカーネルパラメータに変換する責務を負い、FlashAttentionBackend の PagedAttention 実装はページング KV Cache 下での gather セマンティクスと CUDA Graph 互換戦略を示している。しかしアテンション計算が産出するのは隠れ状態にすぎず、モデルが最終的に出力するのは次の token である。これらの隠れ状態がどのように logits になり、logits がどのようにサンプリングと後処理を経て、最終的にストリーミングテキストとしてクライアントに返されるのか?次章ではこの最後の一マイルを追跡する。
第 7 章:サンプリングと出力:Logits 処理、構造化出力、ストリーミング返却
前章では、アテンションバックエンドが block table をカーネルパラメータに変換し、非連続メモリ上で gather 型アテンション計算を完了する方法を追跡した。しかしアテンションが産出するのは隠れ状態にすぎない——モデルが本当にユーザーに届けるのは次の token のテキストである。本章ではこの最後の一マイルを追跡する:隠れ状態が lm_head で logits に射影された後、綿密に順序付けられたプロセッサチェーン(温度、ペナルティ、top-k/top-p、構造化制約)を通過し、token id としてサンプリングされ、detokenizer を経てテキストに復元されストリーミング配信される。この経路上でいずれかのステップの順序が乱れたり状態が漏洩したりすると、出力品質がサイレントに劣化する。
Sampler:プロセッサチェーンの順序こそが正確性
直感的モデル:Sampler は組立ラインのようなもので、logits は加工待ちの素材である。ライン上の各工位(processor)が素材を変更し、工位の順序が完成品を直接決定する——先に削ってから磨くのと先に磨いてから削るのでは別物ができる。このチェーンがなければ、モデルは生の確率分布しか出力できず、ユーザーが得るのは温度制御も重複抑制もフォーマット制約もできない「裸のサンプリング」である。
データ構造とメモリレイアウト
Sampler 自体はnn.Moduleであるが、その核心状態は極めて薄い:保持するのはtopk_topp_samplerサブモジュール、logprobs_modeとuse_fp64_gumbelフラグ📎 vllm/v1/sample/sampler.py:61-64のみ。真のバッチレベル状態はすべてSamplingMetadataにカプセル化され、forward パラメータとして渡される。この「ステートレス Sampler + 外部メタデータ」設計は意図的である:Sampler インスタンスはエンジンライフサイクル内で一度だけ作成され、各 decode step のバッチ構成は変化するため、状態を外部化することで 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、累積確率が低精度では誤差を蓄積するためであり、特に語彙数が15万に達する場合に顕著である。
第三ステップ、非 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。
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その次元を完全にシンボリック化としてマークし、この再コンパイルを避ける。本番環境で 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は形状が[max_batch_size * (1 + max_num_spec_tokens), vocab_size/32]の int32 テンソルである📎 vllm/v1/structured_output/__init__.py:327-336。各 bit は一つのトークンが合法かどうかに対応する。_full_mask = torch.tensor(-1, dtype=torch.int32)は「全1」を表す——すべてのトークンが合法📎 vllm/v1/structured_output/__init__.py:59。
二つのスレッドプールは役割が明確に分かれている:executorは文法コンパイルを担当する(CPU 集約的、ワーカー数は CPU 数の半分)📎 vllm/v1/structured_output/__init__.py:71-78;executor_for_fillmaskは大バッチのビットマスク並列充填を担当し、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。
ビットマスク生成。各 decode step で、grammar_bitmaskがバッチ内のすべての構造化リクエストに対してマスクを生成する📎 vllm/v1/structured_output/__init__.py:314-442。大バッチは並列パスを通る:16個ずつまとめてスレッドプールに提出する📎 vllm/v1/structured_output/__init__.py:346-373。小バッチは直列パスを通り、トークンごとに文法状態を進める📎 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。
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 每个 req 的每个 spec token
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何番目の token から文法制約を適用するかを決定する📎 vllm/v1/structured_output/__init__.py:220-292。思考連鎖を持つモデルでは、reasoning 段階は JSON 制約を受けるべきではなく、reasoning 終了後にのみ起動する。enable_in_reasoningTrue の場合は直接 0 を返す(全行程制約)📎 vllm/v1/structured_output/__init__.py:235-236。reasoner がfind_reasoning_end_offsetをサポートする場合、それを使って📎 vllm/v1/structured_output/__init__.py:261-267を正確に特定する;そうでなければ token ごとの後退探索にフォールバックする📎 vllm/v1/structured_output/__init__.py:287-291。
validate_tokensのプレフィックス意味論。投機的デコーディング時には draft token が文法に違反する可能性があり、validate_tokens「最長の合法プレフィックス」を返す📎 vllm/v1/structured_output/__init__.py:294-312。これはまず投機的パディング(-1)を除去し、次に制約起点を計算し、最後に制約区間内の token のみに対して文法検証を行うことに注意。
Detokenizer:インクリメンタルデコーディングと stop string の境界闘争
直感的モデル:detokenizer は一字ずつ書き写す書記官のように、token id を人間が読めるテキストに翻訳する。難点は、token と文字が一対一対応ではないこと(1つの token が UTF-8 文字の半分にしか対応しない場合がある)、そして stop string が複数の token にまたがる可能性があることだ。インクリメンタルデコーディングがなければ、毎ステップでシーケンス全体を最初からデコードする必要があり、O(n²) のオーバーヘッドがスループットを圧迫する。
データ構造とメモリレイアウト
IncrementalDetokenizer基底クラスはtoken_idsリストのみを保持する📎 vllm/v1/engine/detokenizer.py:32-33。BaseIncrementalDetokenizerstop 関連フィールドが追加される: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 長から1を引いた値に等しい📎 vllm/v1/engine/detokenizer.py:87-90。この「後退バッファ」により、ストリーミング出力が stop string のプレフィックスである可能性のある文字を早期に吐き出さないことが保証される。
2つの実装パス:FastIncrementalDetokenizertokenizers ライブラリのDecodeStream 📎 vllm/v1/engine/detokenizer.py:166-246;SlowIncrementalDetokenizerPython 側のdetokenize_incrementally 📎 vllm/v1/engine/detokenizer.py:249-305。選択基準は 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フラグを受け取る📎 vllm/v1/engine/detokenizer.py:96-142。stop 終了かつ stop string を含まない場合、最後の token はデコーディングから除外される📎 vllm/v1/engine/detokenizer.py:107-111。その後 token ごとにdecode_nextを呼び出してテキストを累積する📎 vllm/v1/engine/detokenizer.py:117-122。
stop string 検出。 check_stop_strings新規追加文字の範囲内でのみ検索する📎 vllm/v1/engine/detokenizer.py:308-360。検索起点は1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338であり、このオフセットにより token 境界をまたぐ stop string も捕捉できる。複数の stop string が同時にマッチした場合、最も早く完了するものを選択する📎 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。
例外回復。 FastIncrementalDetokenizer._protected_step2種類の例外を処理する:OverflowError/TypeError はログを記録して None を返す📎 vllm/v1/engine/detokenizer.py:225-229;「Invalid prefix」エラーはDecodeStream を再構築してリトライする📎 vllm/v1/engine/detokenizer.py:222-246。後者は tokenizer が非単調な UTF-8 出力を生成する境界ケースに対応する。
設計上の考察と落とし穴
stop_buffer_length のトレードオフ。バッファが長いほどストリーミング遅延が大きくなる(ユーザーがテキストを見る時間が遅れる)が、token をまたぐ stop string の検出漏れが起きにくくなる。「最長 stop string 長から1を引いた値」を取るのは正確な下限である:どの stop string のプレフィックスも最大でその長さしかない。
min_tokens と stop_check_offset。出力 token 数がmin_tokensに達していない場合、stop_check_offsetは継続的にテキスト末尾に押し出される📎 vllm/v1/engine/detokenizer.py:120-122、つまりこのテキストは stop 検出されない。これによりモデルが冒頭で stop string にぶつかって空出力になるのを防ぐ。
Fast パスの added_token_ids キャッシュ。が False の場合、spaces_between_special_tokens特殊 token 間のスペースを抑制する必要がある📎 vllm/v1/engine/detokenizer.py:192-207。コードはadded_token_idsを tokenizer オブジェクトにキャッシュする📎 vllm/v1/engine/detokenizer.py:195-200、毎回の decode で辞書を再構築するのを避けるため。
設計上の考察
3つのモジュールは1つの設計哲学を共有している:状態推進と制約チェックを分離し、GPU 側ではステートレスなテンソル演算のみを行う。Sampler はステートレスで、状態はSamplingMetadataにある;文法状態機械は CPU 側で推進され、GPU はビットマスクを消費するだけ;detokenizer の_last_output_text_offsetは唯一のストリーミングカーソルである。この分離により、GPU 側の各コンポーネントが CUDA Graph でキャプチャ可能になる。
もう一つの主軸は順序即ち意味論。Sampler のプロセッサチェーン順序、構造化出力の制約起点、detokenizer の stop 検出オフセット、いずれかの順序が誤ってもクラッシュせず、静かに誤った結果を生むだけである——これこそがこの種のコードが最もデバッグしにくい所以である。
本章のまとめ
- 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のトークン横断検出を両立し、Fastパスはtokenizers ≥ 0.22.0のDecodeStream。
本章の考察とセルフチェック
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トークンに対してgrammar.accept_tokensを呼び出して構文状態を進め、次の位置のマスクを生成する📎 vllm/v1/structured_output/__init__.py:396-418。しかしこれは「試験的な進行」にすぎない——draftトークンはまだターゲットモデルに検証・受理されていない。もしrollbackを削除すると、構文状態は「すべてのdraftが受理された」位置に永久に留まる。ターゲットモデルが実際に一部のdraftトークンを拒否した場合、実際に受理されたトークン列と構文状態が一致しなくなる: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から全量検索に変更した場合、機能的に正しいか?長い系列のストリーミング場面でどのような性能問題が生じるか?
参考解説:機能的には正しい——0から検索すればトークン境界をまたぐものを含めすべてのマッチが見つかる。しかし性能面では、各ステップでoutput_text全体に対してfindを行い、計算量がO(new_char_count)からO(total_length)に退化し、長い系列ではO(n²)になる。さらに深刻なのは、0からの検索がすでにユーザーに送信された履歴テキスト内のstop string部分文字列にマッチする可能性があり、stopの重複トリガーや誤った切り詰めを引き起こす。元の設計のオフセット1 - new_char_count - stop_string_lenは「新規文字+境界をまたぐ可能性のあるstop stringプレフィックス」という最小必要ウィンドウを正確にカバーし、検出漏れを防ぎつつ履歴の誤マッチを回避している。
ここまでで、単機上の推論全経路が打通された:アテンション計算からサンプリング出力まで、各环节が最終的に納品されるテキスト品質に直接影響する。しかしモデル規模が単卡の容量を超えると、この経路は複数デバイスの協調によって完成させなければならない。次章では単機を離れ、分散並列に入る:TP、PP、EPがどのようにモデルを分割し、通信プリミティブがrank間でこれらのサンプリング結果をどのように同期するか。
第8章:分散並列:TP、PP、EPと通信プリミティブ
前章では単一推論ライフサイクルの最後の一マイルを歩み終えた。logitsサンプリングからストリーミング出力まで。しかしモデルが単卡に収まらないほど大きくなると、このパイプラインは複数デバイスに分割して協調実行しなければならない。分散推論の第一の問題は「どうモデルを切るか」ではなく、「切った後、誰が誰と話し、どのように話すか」である。vLLMはこの二つの問題をそれぞれparallel_state.pyのプロセスグループトポロジーとcustom_all_reduce.pyの通信器実装に委ねている。本章は「グループ構築 → 分割 → 通信 → 負荷再均衡」という経路に沿って、TP、PP、EPの並列戦略と低レベル通信プリミティブを層ごとに分解する。
8.1 プロセスグループトポロジー:一つのrankグリッドからTP/PP/DP/EPをどう切り出すか
直感モデル
8枚のGPUを8席の長テーブルと想像しよう。テンソル並列(Tensor Parallelism、TP)は「同じテーブルの人は同時に杯を挙げなければならない」、パイプライン並列(Pipeline Parallelism、PP)は「隣の席がリレーで料理を運ぶ」、データ並列(Data Parallelism、DP)は「別のテーブルはそれぞれ食べるが最後に照合する」、エキスパート並列(Expert Parallelism、EP)は「トークンを科別にトリアージする」を要求する。統一された席配置がなければ、各モジュールがそれぞれnew_group、「TP グループにいると思っていたら、実は DP グループにいた」という通信のズレが生じる——集合通信で rank が1つでも欠けると、NCCL はエラーを出さずにそのままハングする。
データ構造とメモリレイアウト
GroupCoordinatorがそのすべての担い手である。そのフィールド設計は「1つのプロセスが複数の並列次元上に持つ多重アイデンティティ」に直接対応している:
rankはグローバル rank、ranksは本グループメンバーのグローバル rank リスト、world_sizeはグループサイズ📎vllm/distributed/parallel_state.py:434-436。local_rankはデバイスのバインドに使用、rank_in_groupはグループ内の序番——ソースコードはテーブル1つで両者を正確に区別している:2ノードにまたがる4カードグループで、rank 2 のlocal_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 グループを1つずつ維持する必要があるのか?なぜならbroadcast_object、send_objectのような操作が転送するのは Python オブジェクト(シリアライズされたバイト列)であり、NCCL を通すと VRAM を浪費するうえ、現在の CUDA デバイスを汚染する可能性があるからだ。barrier()のコメントはこの点を率直に述べている:NCCL の barrier は内部的に broadcast であり、こっそり GPU テンソルを生成し、現在のデバイスを混乱させやすい。だから CPU グループを使わなければならない📎 vllm/distributed/parallel_state.py:1355-1362。
Step-by-Step:initialize_model_parallelグリッドをどう切るか
具体的なシナリオを代入する:8カード、TP=2、PP=4、DP=1。核心は1次元の rank 列を多次元グリッドに reshape し、各次元に沿って分割することである。
第一步、rank グリッドを構築する。レイアウト順序は明示的にExternalDP x DP x PP x PCP x TP 📎 vllm/distributed/parallel_state.py:2045-2060:
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)に view してから 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 グループの rank 集合はDP x PCP x TPの積であり、EP が DP と TP の物理カードを再利用しており、独立した次元ではないことを意味する。
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 同 rank 集,独立 PG"]
eplb_check -->|否| no_eplb["_EPLB 保持 None"]
tp --> done["logger.info_once 打印各维度 rank"]
pp --> done
dp --> done
ep --> done
skip --> done
eplb --> done
no_eplb --> done設計上の考察と落とし穴
EPLB はなぜ独立したプロセスグループを必要とするのか?コメントが答えを与えている:EPLB 通信と MoE フォワードの集合通信を隔離し、「実行期の torch.distributed」と「EPLB の torch.distributed」が互いにデッドロックするのを防ぐ📎 vllm/distributed/parallel_state.py:2243-2246。これは典型的な「独立した通信ドメインで決定性を買う」トレードオフである——PG が1つ増える分の VRAM オーバーヘッドと引き換えに、重みの移動時にフォワードが固まらないことを得る。
DP グループの同期制約は本番環境で最もよく踏む落とし穴である:同一 DP グループ内のすべての rank が同時にgenerateを呼び出さなければならない。そうでなければデッドロックする📎 vllm/distributed/parallel_state.py:2048-2051。DP グループ内では勾配/サンプリング結果の all-reduce が行われるため、いずれかの rank が欠けると集合通信が永久にブロックされるからだ。
破棄順序にも同様にこだわりがある。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 を迂回するか
直感モデル
NCCL の all-reduce は「汎用トラック」であり、どんな荷物も運べ、どんな道も走れるが、起動オーバーヘッドとプロトコルオーバーヘッドは固定である。8カード NVLink 全相互接続のマシン上で小さなテンソルの all-reduce を繰り返し行う場合(TP の各 attention/MLP 層で毎回行う)、汎用トラックの「通行料」は無視できなくなる。カスタム 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:8MB の uint8 テンソル、すべての rank の IPC バッファポインタタプルを格納📎vllm/distributed/device_communicators/custom_all_reduce.py:309-315。
なぜバッファを事前登録するのか?CUDA Graph のキャプチャはすべてのアドレスがキャプチャ時に固定されていることを要求するからだ。register_graph_buffersキャプチャ終了時に使用したすべてのバッファアドレスをすべての rank にブロードキャストして登録する📎 vllm/distributed/device_communicators/custom_all_reduce.py:474-491。
Step-by-Step:1回の all-reduce の意思決定フロー
シナリオを代入する:TP グループ内のある層の MLP 出力が all-reduce を必要とし、入力は 4MB の bf16 テンソルである。
第一步、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。
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、その後 MNNVL(Multi-Node NVLink)能力をチェックする。グループ内の全カードが MNNVL をサポートしていない場合、カスタム集合通信を直接無効化📎 vllm/distributed/device_communicators/custom_all_reduce.py:228-233。_group_can_attempt_mnnvl一度の CPU all-reduce(MIN 操作)で全 rank が同じ制御フローを通ることを保証📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73——これは異種クラスタで「一部の rank が MNNVL パスに入り、一部が NCCL を通る」ことによるハングを避けるための重要な防御である。
P2P チェックのコスト:_can_p2pは全 peer を走査してgpu_p2p_access_checkを行う、コメントには初回計算は高コストだがキャッシュされるとある📎 vllm/distributed/device_communicators/custom_all_reduce.py:278-278。本番環境で起動が遅い場合、VLLM_SKIP_P2P_CHECKを設定してスキップし、ドライバの P2P レポートを直接信頼できる📎 vllm/distributed/device_communicators/custom_all_reduce.py:86-100。
reduce-scatter の三段階バックエンド選択は個別に見る価値がある:_select_reduce_scatter_backendは優先度順に返すmnnvl_multimem > mnnvl_lamport > legacy 📎 vllm/distributed/device_communicators/custom_all_reduce.py:601-636。multimem パスは world_size が(2,4,8)にあり、かつデバイス能力が (10,0) または (10,3)(Blackwell 級)であることを要求する📎 vllm/distributed/device_communicators/custom_all_reduce.py:103-104。注意VLLM_BATCH_INVARIANTは multimem パスを無効化する📎 vllm/distributed/device_communicators/custom_all_reduce.py:628——multimem のリダクション順序は不定であり、バッチ不変性を破壊するため。
8.3 EPLB:エキスパート負荷再平衡のスケジューリングロジック
直感的モデル
MoE モデルでは、256 個の論理エキスパートが 32 枚のカードに分配され、各カードに 8 個ずつ。しかし実際のトラフィックでは、一部の「人気エキスパート」(例えば一般的な文法構造を処理するもの)に大量の token がルーティングされ、それを保持するカードがボトルネックとなり、他のカードが遊休する。EPLB(Expert Parallel Load Balancer)は「人気エキスパートにレプリカを追加する」:人気エキスパートの重みを空きカードに複製し、token を分流させる。これがなければ、MoE の実効スループットは最も遅いカードに律速される。
データ構造とメモリレイアウト
EplbModelStateは三つのマッピングテーブルで「論理エキスパート ↔ 物理エキスパート」の関係を記述する:
physical_to_logical_map:形状(num_moe_layers, num_physical_experts)、各物理スロットにそれが担う論理エキスパート id を格納📎vllm/distributed/eplb/eplb_state.py:105-120。logical_to_physical_map:形状(num_moe_layers, num_logical_experts, max_replicas+1)、疎行列、-1 はマッピングなしを表す📎vllm/distributed/eplb/eplb_state.py:123-146。logical_replica_count:各論理エキスパートにいくつのレプリカがあるか📎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。コメントで特に指摘:現在はローカルエキスパートだけでなく全物理エキスパートの負荷を記録し、異なる dispatch 方法(naive all-to-all、DeepEP)で統計が一致するようにしている;naive all-to-all では各 DP rank が同じ token 集合を寄与するため、負荷は dp_size 倍される📎 vllm/distributed/eplb/eplb_state.py:180-187。
Step-by-Step:一回の再配置の完全な経路
シナリオを代入:expert_rearrangement_stepが閾値に達し、rearrange()。
をトリガー。第一步、物理負荷を論理エキスパートにマッピングし直す。scatter_add_をphysical_to_logical_mapで集約、無効スロット(<0)はinvalid_idxバケットに埋めて最後に破棄📎 vllm/distributed/eplb/eplb_state.py:794-816。
第二步、rank 間 all-reduce でグローバル論理負荷を取得。_allreduce_listは複数モデルの負荷を連結して一度 all-reduce してから分割し、複数回の通信を回避📎 vllm/distributed/eplb/eplb_state.py:1045-1068。
第三步、戦略を呼び出して新しいマッピングを計算。policy.rebalance_expertsは host 上で実行されるため、負荷ウィンドウと現在のマッピングを CPU にコピーし戻す必要がある📎 vllm/distributed/eplb/eplb_state.py:859-867。
第四步、ROCm 特化の「再配置スキップ」判定:新しいマッピングによる rank 負荷不均衡の改善が 5% 未満なら、今回の再配置をスキップ📎 vllm/distributed/eplb/eplb_state.py:869-923。これは実用的な最適化である——再配置自体に通信コストがあり、利益が十分でなければ行わない。
第五步、重みの移動を実行し新しいマッピングをコミット📎 vllm/distributed/eplb/eplb_state.py:925-942。
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 跨 rank 聚合
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フラグは GIL に依存してメインスレッドと async worker 間で同期される📎 vllm/distributed/eplb/eplb_state.py:194-203。しかしコメントは警告する:rebalancedは全 rank で一致していなければならない、そうでなければ_all_ranks_result_ready内の all-reduce がハングする📎 vllm/distributed/eplb/eplb_state.py:664-665。_all_ranks_result_readyall-reduce には CPU グループを優先して使う、CPU グループの方が信頼性が高いため📎 vllm/distributed/eplb/eplb_state.py:1024-1043。
スライディングウィンドウの「事前録画」最適化:_should_record_current_stepは次回再配置までwindow_sizeステップ以内のときのみ録画を開始する📎 vllm/distributed/eplb/eplb_state.py:689-709。コメントの説明:各再配置周期の前step_interval - window_sizeステップのデータはスライディングウィンドウに上書きされるため、録画しても無駄で GPU 計算を浪費する📎 vllm/distributed/eplb/eplb_state.py:1196-1199。should_record_tensorは全層で共有される同一のスカラーテンソルであり、一度のfill_で全層を更新📎 vllm/distributed/eplb/eplb_state.py:272-278。
エラスティック EP の容量予約:enable_elastic_ep時、physical_expert_capacityはelastic_ep_max_dp_sizeに従って予約し、マッピングテーブルは余分なスロットを -1 で埋める📎 vllm/distributed/eplb/eplb_state.py:375-386。これによりスケールアウト時に VRAM を再割り当てする必要がなく、-1 スロットに実エキスパートを埋めるだけでよい。reconfigure_physical_expert_slotsはスケールアウト/スケールイン時にビューを更新する責務を負う📎 vllm/distributed/eplb/eplb_state.py:1135-1160。
_commit_eplb_mapsの pin memory 処理:PIN_MEMORYが有効かつソースが CPU のとき、まず pinned メモリにコピーしてからnon_blocking=True非同期で GPU にコピー📎 vllm/distributed/eplb/eplb_state.py:1392-1400。これは H2D コピーがメインスレッドをブロックするのを避けるため——マッピングテーブルは毎層毎ラウンド更新され、同期コピーはボトルネックになる。
設計上の考察
3つのコードは1つの設計哲学を共有している:能力検出で確実なフォールバックを得る。GroupCoordinatorworld_size == 1時には全ての集合通信を直接バイパスする📎 vllm/distributed/parallel_state.py:736-738;CustomAllreduceいずれかの条件が満たされない場合はNone呼び出し元がNCCLにフォールバックできるようにする📎 vllm/distributed/device_communicators/custom_all_reduce.py:532-533EPLBは改善が5%未満の場合は再配置をスキップする📎 vllm/distributed/eplb/eplb_state.py:916この「高速失敗+優雅なフォールバック」パターンにより、同じコードがシングルGPUからマルチノードMNNVLまでの全スペクトラムのハードウェアで動作し、構成ごとに分岐を書く必要がない。
もう一つの共通点は制御フローの一貫性が性能より優先される。_group_can_attempt_mnnvlCPU 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が低速パスを通る」ことよりもはるかに危険である——前者はハングし、後者は単に遅いだけである。
本章のまとめ
GroupCoordinator1次元のrank列をExternalDP x DP x PP x PCP x TPグリッドにreshapeし、各次元に沿ってTP/PP/DP/EP/EPLBプロセスグループを分割する。各グループはCPU(gloo)とdevice(NCCL)の2つのPGを同時に維持する。CustomAllreduce能力検出(同一マシン、NVLink全相互接続、テンソルサイズ、dtype、16バイトアライメント)によりall-reduceを引き継ぐかどうかを決定し、マルチノードシナリオではMNNVLまたはNCCLにフォールバックする。- EPLBは3つのマッピングテーブルで論理/物理エキスパート関係を記述し、スライディングウィンドウで負荷を統計し、戦略で新しいマッピングを計算し、コミュニケータで重みを転送し、同期と非同期の2つのモードをサポートする。
- 3者の共通設計原則:能力検出+確実なフォールバック+制御フローの一貫性優先。
本章の考察とセルフチェック
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を破棄すると、communicatorのdestroy()内部でこれらのPGを使ってbarrierやクリーンアップ通信を行う必要がある場合、既に破棄されたProcessGroupにアクセスし、use-after-freeやNCCL内部アサーション失敗を引き起こす。正しい順序は「依存者が先に死ぬ」:communicatorはPGに依存するので、communicatorが先に破棄される。
Q2: should_custom_arinp_size % 16 == 0 📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508もしこのチェックを外すと、15バイトのbf16テンソル(例えば7.5要素、実際には不可能だが、8要素=16バイトの境界ケースと仮定)はどうなるか?なぜカスタムkernelにこのアライメントが必要なのか?
参考解析カスタムall-reduce kernelは内部的にベクトル化ロード(例えば128-bit load)を使用し、アドレスとサイズが16バイトアライメントであることを要求するfloat4のようなワイドロード命令を使用するためである。アライメントが合わないと、kernelが範囲外読み取りを行ったり、misaligned address例外を引き起こす。さらに隠蔽的なのは、buffer_ptrs事前登録バッファがmax_sizeで割り当てられ、入力サイズが16の倍数でない場合、バッファにコピーした後に末尾に残留データが一緒にリダクションされ、サイレントエラーが発生する可能性がある。したがってこのチェックは正確性の保護であると同時に性能の前提でもある。
Q3: EPLB非同期モードでは、rebalancedフラグは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_readyhas_resultに対してall-reduceの合計を計算し、グループサイズと等しいかどうかを判定する📎 vllm/distributed/eplb/eplb_state.py:1030-1032もしあるrankのrebalancedが早まってFalseになると、そのpending_resultは既に消費されている可能性があり、has_resultが0になり、合計結果がグループサイズより小さくなり、他のrankは待ち続ける。さらに悪いことに、このrankが既にwhile ms.rebalancedループを抜けている場合、以降のall-reduceに参加せず、他のrankのall-reduceは永久にブロックされる——これがコメントの言う「hang at collective communication calls」である。防御手段は_all_ranks_result_readyをdeviceグループではなくCPUグループで使用し、drain_async再配置前に全てのpending resultを明示的にドレインすることである📎 vllm/distributed/eplb/eplb_state.py:985-1022。
ここまでで、カード間通信のグループ構築、分割、負荷再分散の仕組みを整理した。しかし分散推論の通信課題は単一インスタンス内部にとどまらない——prefill と decode が異なるインスタンスに分離されると、KV Cache はノードを跨いで転送される必要がある。次章では「カード間通信」を離れ、「インスタンス間通信」へと進む:KV Cache が分離デプロイされた prefill と decode インスタンス間でどのように転送されるのか、KV Connector 抽象が NIXL、Mooncake などの転送バックエンドをどのように統一するのかを見ていく。
第 9 章:KV Cache 転送と分離デプロイ(PD 分離)
前章では、視点を単一の推論インスタンス内部に固定した:TP/PP/DP/EP プロセスグループがどのように構築され、テンソルがカード間でどのように分割され、EPLB が MoE 層でどのようにエキスパート再分散を行うか。しかしこれらの仕組みはすべて同じ前提の上に成り立っている——prefill と decode が同一インスタンスで動作し、KV Cache が最初から最後までローカル VRAM に存在するという前提だ。分離デプロイ(Prefill-Decode Disaggregation、略称 PD 分離)はこの前提を打ち破る。prefill と decode を二つの独立した vLLM インスタンスに分割する:prefill インスタンスはプロンプトの順伝播計算のみを行い、KV Cache を生成して decode インスタンスに渡す。decode インスタンスはその KV Cache を受け取り、自己回帰生成を続ける。この利点は、リソースを段階特性に応じて独立に構成できることだ——prefill は計算集約型で、大 TP・大バッチに適する。decode はメモリアクセス集約型で、小バッチ・低遅延スケジューリングに適する。両者が互いに足を引っ張り合うことがなくなる。代償は: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)、ハートビート、障害回復機構を用いてこれらの境界をどのように支えるかである。
一、KVConnectorBase_V1:二重ロール抽象とメタデータ契約
直感的モデル
KV Connector は二つの支店間の宅配システムのようなものだ。Prefill 店は半製品(KV Cache)を計算し、梱包して Decode 店に送り、加工を続けてもらう。しかし宅配システムは「発送」という一つの動作だけでは成り立たない——何をどこへ送るかを示す送り状(metadata)が必要であり、相手が受け取ったことを確認する受領確認の仕組みが必要であり、荷物が永遠に路上で棚を占領し続けるのを防ぐタイムアウト規則も必要だ。
もしこの抽象がなければ、各転送バックエンド(NIXL、Mooncake)が自前でスケジューリングロジックを実装しなければならず、vLLM の Scheduler はバックエンドごとに适配コードを書く必要があった。KVConnectorBase_V1 の価値は、この契約を固定化することにある。
二重ロール:Scheduler 側と Worker 側
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:137-142コネクタの二つのロールを定義している:
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-158Scheduler から Worker 方向のメタデータ基底クラスを定義している:
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メソッドを要求する——一つの engine step で複数の worker がそれぞれメタデータを返す可能性があるため、集約してから Scheduler に渡す必要があるからだ。
核心データ構造:KVConnectorTransferResults
📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:87-96転送結果のスナップショット構造を定義している:
@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属性:
@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はハンドシェイクメタデータの基底クラスを定義する:
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 間で直接通信することを意味する。これは NIXL の ZMQ ハンドシェイクプロトコルの伏線となっている。
---
二、NIXL コネクタ:ハンドシェイク、登録、ディスクリプタ構築
直感的モデル
NIXL(NVIDIA Inference Xfer Library)は NVIDIA が提供する低レベル転送ライブラリであり、UCX、GDS など複数のバックエンドをサポートする。NixlBaseConnectorWorker の役割は、宅配会社の仕分けセンターのようなものである——まず相手の仕分けセンターと専用線を確立し(ハンドシェイク)、自分の棚のレイアウトを登録し(KV Cache メモリ領域の登録)、それから初めてアドレスに従って効率的に荷物を出し入れできる。
もしこの仕組みがなければ、転送のたびにアドレスを再ネゴシエートし、接続を再確立する必要があり、レイテンシは受け入れられないほど高くなる。
メモリレイアウト:Region と Descriptor
NIXL の核心概念はregion(メモリ領域)とdescriptor(ディスクリプタ)である。各 KV Cache レイヤーは NIXL において 1 つ以上の region として登録され、各 region はベースアドレス、ブロック長、ブロックストライドを持つ。
📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:740-751は region 関連の核心フィールドを列挙している:
# 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はブロックストライドの由来をさらに説明している:
# 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 と等しくない。BLHNC/BHLNC のようなレイヤー間インターリーブレイアウトでは、1 つの block の実際のスパンはその有効データ長より大きくなる可能性がある。もし直接 block_len をストライドとして使うと、誤ったアドレスを読むことになる。
ハンドシェイクプロトコル:ZMQ + 互換性ハッシュ
ハンドシェイクは NIXL コネクタで最も複雑な部分である。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:974-1128の_nixl_handshakeメソッドがこのプロセスを完全に示している。
最初のステップは CUDA デバイスコンテキストの設定である。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:988-998のコメントがその理由を説明している:
# 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 通信をサイレントに無効化し、低速パスに退化する。
第二のステップは ZMQ によるメタデータクエリの送信である。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1029-1036:
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()5 秒のタイムアウトは、相手が死んだ後に無限に待機するのを防ぐためである。同時に、コードは RTT を用いてクロックオフセット📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1042-1045を推定し、最小 RTT サンプルを保持する——高い RTT は単なるノイズであり、中点推定を歪めるからである。
第三のステップは互換性検証である。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1063-1080:
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で計算される:
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:
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は NIXL がスレッドセーフを保証しないためである。_handshake_lockは_handshake_futuresと_remote_agentsの 2 つの辞書を保護する。
_ensure_handshake 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1257-1317は冪等なハンドシェイク開始を実装している:すでにハンドシェイク成功なら直接 None を返す;ハンドシェイク中なら既存の Future を返す;そうでなければ新しいタスクを投入しコールバックを登録する。
ディスクリプタ構築:block ID から NIXL descriptor へ
ハンドシェイク完了後、各リクエストのためにディスクリプタを構築する必要がある。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:172-310の_compute_desc_idsが核心である。
純粋な attention モデル(SSM なし)の場合、高速パス📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:226-262を通る。コメントが HMA シナリオでの処理を説明している:
# 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.ハイブリッド SSM モデルの場合、ディスクリプタレイアウトはより複雑になる📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:285-304:
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).転送トポロジーと TP マッピング
異種 TP は NIXL コネクタで最も複雑なシナリオである。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2130-2178のadd_remote_agentドキュメントが各種ケースを詳細に説明している:
D.world_size > P.world_size の場合、複数の D worker が同じ P worker から異なる KV head シャードを読み取ります。ドキュメントには具体的な例が示されています:D TP=4、P TP=2、tp_ratio=2。D-Worker0 は P-Worker0 の前半部分の KV head を読み取り、D-Worker1 は後半部分を読み取ります。
MLA モデルの場合、KV Cache は TP worker 間で複製されるため、rank_offset は常に 0 です。
リースとハートビート:block の早期解放を防ぐ
これは NIXL コネクタで最も巧妙な設計の一つです。Prefill インスタンスは KV を送信した後、すぐに block を解放できません——decode インスタンスがまだ読み取っている可能性があるからです。しかし、永遠に解放しないと、VRAM がリークします。
解決策はリース(lease)です。📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:528-528:
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 秒で、ハートビートごとに 20 秒(2/3)延長されます。
ハートビート処理は📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034:
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)——ハートビートはリースを延長することしかできず、短縮することはできません。
リース期限切れ後の回収は📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2986-3012:
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.
"""コメントは犯しやすい間違いを指摘しています:最初の期限切れでないリクエストに遭遇したからといってスキャンを停止してはいけません。ハートビートがその場で有効期限を更新するため、map は有効期限でソートされていないからです。
転送ステートマシンと障害復旧
転送のライフサイクルは_pop_done_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3036-3086で管理されます:
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 転送には 3 つの状態があります:DONE(完了)、PROC(進行中)、その他(失敗)。
失敗処理は📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3103-3127:
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のコメントが非常に重要です:
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が処理ロジックを示しています:
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失敗した block ID は_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 エビクションを実装しています:
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のコメントが理由を説明しています:
"""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.
"""相手の NIC が壊れている場合、転送が永遠にハングし、タイムスタンプが更新されず、エンジンはアイドルに見えます。busyセットはこの状況を明示的に保護します。
ハンドシェイクと転送のタイミング
以下のシーケンス図は、リクエストから転送完了までの核心的なインタラクションを示しています:
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---
三、設計思考:なぜこのように設計するのか
なぜハンドシェイクを非同期にするのか?
ハンドシェイクはネットワーク往復を伴い、数十ミリ秒かかる可能性があります。同期的に実行すると、Scheduler のメインループをブロックし、すべてのリクエストのスケジューリングに影響します。非同期ハンドシェイクにより、Scheduler は他のリクエストを先に処理でき、ハンドシェイク完了後にコールバックで通知されます。
しかし非同期は複雑さももたらします:_handshake_futures辞書はロックで保護する必要があり、コールバックでは成功と失敗の両方のケースを処理し、重複ハンドシェイクも防がなければなりません。
なぜリースを使い、参照カウントを使わないのか?
参照カウントでは、decode インスタンスが prefill に「読み終わった」と明示的に通知する必要があります。しかし decode インスタンスがクラッシュすると、通知は永遠に届かず、prefill の block は永遠にリークします。
リースはより堅牢なソリューションです:decode がクラッシュしても、リース期限切れ後に prefill が自動的に回収します。ハートビート機構は通常時のリース更新を保証します。
なぜ失敗時に 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 を超えると、エンジンはアイドルに見えますが、実際にはまだ読み取られています。この時エビクトすると、進行中の転送が失敗します。
本番環境の落とし穴
1. CUDA コンテキストの問題:ハンドシェイクはバックグラウンドスレッドで実行されるため、明示的にset_deviceする必要があります。そうしないと UCX が NVLink をサイレントに無効化します。
2. 互換性ハッシュの不一致:P/D インスタンスの vLLM バージョン、モデル、dtype、KV layout、attention backend は完全に一致している必要があります。不一致の場合ハンドシェイクが失敗し、エラーメッセージはチェックを無効化する方法を提示します(ただし推奨されません)。
3. リース期限切れ:decode インスタンスの負荷が高い場合、ハートビートが遅延し、リースが期限切れになる可能性があります。ログに「Releasing expired KV blocks」警告が表示されます。kv_lease_duration。
4. TP の不一致:異種 TP には block-contiguous レイアウト(例:LBHNC)が必要です。非連続レイアウトを使用すると、異種 TP は失敗します。
5. NIXL UAR 枯渇:📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:631-636のコメント警告:各 UCX スレッドは DevX を介して UAR(doorbell pages)を割り当て、過剰な NIXL UAR 使用は NIC UAR 空間を枯渇させ、NVSHMEM(DeepEP カーネルが使用)の RDMA 初期化時の失敗を引き起こす。
---
本章のまとめ
本章では KV Connector 体系の核心メカニズムを深く掘り下げた:
1. KVConnectorBase_V1Scheduler 側と Worker 側の二重ロール抽象を定義し、KVConnectorMetadataとKVConnectorTransferResultsを通じてメタデータ交換と転送結果のフィードバックを実現する。
2. NIXL コネクタは最も成熟した実装であり、ZMQ ハンドシェイクプロトコルを通じて P/D インスタンス間の接続を確立し、互換性ハッシュで設定の不一致を防ぎ、非同期スレッドプールでメインループのブロッキングを回避する。
3. リースとハートビートメカニズムは block 解放のタイミング問題を解決する:prefill は KV 送信後すぐに解放せず、decode のハートビート更新またはリース期限切れを待つ。
4. 障害復旧は「誤って使うよりリークする方がマシ」の原則に従う:解放失敗時は handle を保持し、失敗した block ID を 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 の解放を続行する。しかし実際には NIXL バックエンドの DMA がまだ進行中で、このメモリにデータを書き込んでいる可能性がある。block が他のリクエストに再割り当てされると、DMA 書き込みが新しいリクエストの KV Cache を汚染し、出力が文字化けしたり NaN になったりする。さらに悪い場合、block が VRAM プールに解放されて他のテンソルに再利用されると、DMA が不正なアドレスに書き込んでクラッシュする可能性がある。正しい方法は handle と block を保持し、次の_pop_done_transfersで解放をリトライすることである。
Q2: _reap_expired_send_leasesのコメントは「最初の期限切れでないリクエストに遭遇したからといってスキャンを停止してはならない」と述べている。期限切れでない場合に break するように変更した場合、どのようなシナリオで block リークが発生するか?
参考解説:_reqs_to_sendは通常の dict であり、期限切れ時間でソートされた優先度付きキューではない。ハートビート処理_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 が VRAM を占有し続ける。長時間運用でリクエストパターンが混在する場合(頻繁にハートビートで更新されるリクエストもあれば、decode インスタンスがクラッシュしたリクエストもある)、これは深刻な VRAM リークとして蓄積される。
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.」対向の NIC が故障し、NIXL 読み取り操作が TTL(デフォルト 3600 秒)を超えてハングしたと仮定する。_engine_last_activeタイムスタンプは読み取り発行時に付与され、読み取り中は更新されないため、エンジンはアイドルに見える。このとき_evict_stale_enginesがこのエンジンをエビクトすると、_cleanup_remote_engineを呼び出してdst_xfer_side_handlesを解放し、remote agent を削除する。しかし進行中の DMA がまだこれらのリソースを使用しており、解放後に転送失敗やクラッシュを引き起こす。busy集合はこの状況を明示的に保護し、進行中の転送があるエンジンがエビクトされないようにする。
ここまでで、KV Connectorがprefillインスタンスとdecodeインスタンスの間に信頼性の高いデータチャネルをどのように確立するか、そしてリース、ハートビート、障害復旧メカニズムを用いてどのように状態の一貫性を守るかを明らかにしてきた。しかし、インスタンス間転送はPD分離の半分の物語にすぎない——KV Cacheがdecodeインスタンスに到達した後も、推論エンジンは単一インスタンス内部で各ステップの順伝播計算を効率的に実行する必要がある。そしてPythonのスケジューリングとカーネル起動のオーバーヘッドこそが、単一ステップのレイテンシを制約する次のボトルネックである。次章ではコンパイル高速化とCUDA Graphに目を向け、vLLMがtorch.compileとpiecewise backendを用いてこれらのオーバーヘッドをどのように解消し、CUDA Graphと動的バッチ処理の形状をいかに調和させ共存させるかを見ていく。
第10章:コンパイル高速化とCUDA Graph:起動とスケジューリングのオーバーヘッドを解消する
前章では、KV ConnectorがNIXL、Mooncakeなどのコネクタを通じてPrefillエンジンとDecodeエンジンの間でKV cacheを効率的に転送し、分離アーキテクチャがTTFTを削減しつつリソース利用率を向上させることを見た。しかし、転送がどれほど速くても、自己回帰デコードにはアルゴリズムでは解消できない2つの固定コストが依然として存在する:PythonインタプリタのスケジューリングオーバーヘッドとGPUカーネルの起動オーバーヘッドである。モデルの順伝播が数百の演算子に分割され、各演算子が1回のPython関数呼び出しと1回のCUDAカーネル起動を経る必要があるとき、CPU側のオーバーヘッドはGPUを2回の計算の間でアイドル状態にするのに十分である。本章では、vLLMがtorch.compileで演算子を静的グラフに融合し、さらにCUDA Graphでカーネル起動シーケンス全体を1回のリプレイとして記録することで、これら2種類のオーバーヘッドをほぼゼロにまで圧縮する方法を分析する。
コンパイルキャッシュとコンパイラ適応層:コンパイル結果をプロセス間で再利用する
直感的モデル
コンパイル高速化の利点は「一度コンパイルすれば何度も実行できる」ことだが、代償として初回コンパイルに数分かかる場合がある。キャッシュがなければ、サービス再起動のたびに再コンパイルが必要となり、コールドスタート時間は許容できないものになる。CompilerInterfaceこの層が解決しようとしているのはまさに「コンパイル成果物をどのようにシリアライズし、どのようにハッシュで識別し、次回起動時に正確にヒットさせるか」という問題である。これがなければ、システムが直面する災難はクラッシュではなく、再起動のたびに「初回実行」に退化することである——自動スケーリングする本番環境では、これはスケールアウトされたインスタンスが数分間にわたって低レイテンシサービスを提供できないことを意味する。
データ構造とインターフェース契約
CompilerInterfaceコンパイラアダプタの抽象契約を定義しており、核心は4つのメソッドである: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コンパイル戦略の核心的制約である:すべての動的形状は単一変数——トークン数——に帰約される。
シナリオ駆動:1回のコンパイル要求の完全な流れ
サービスが初回起動し、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、これは防御的設計である——コンパイル失敗後も元のグラフをリトライに使用できる。
2番目のステップは一連の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のハッシュ計算はそのコンテキストに依存しているからである。
3番目のステップは_check_can_cacheパッチ、それは直接返し、何のチェックも行わない📎 vllm/compilation/compiler_interface.py:544-551。コメントは動機を説明している:「InductorはDynamoトレーシングコンテキスト外でのグラフのキャッシュを拒否し、また高階演算を持つグラフのキャッシュも無効にする。vLLMの場合、いずれのケースでもグラフをキャッシュしたい」📎 vllm/compilation/compiler_interface.py:544-551。
第四步是清理追踪上下文。这是最微妙的一处:vLLM 从PiecewiseCompileInterpreter内部调用compile_fx,此时 Dynamo 的FakeTensorMode与子图输入的FakeTensorMode不一致,detect_fake_mode()会断言失败📎 vllm/compilation/compiler_interface.py:615-622。代码保存TracingContext后将其置空,并注册回调在退出时恢复📎 vllm/compilation/compiler_interface.py:623-630。
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这个类值得单独剖析。它的文档字符串直白地说明了动机:vLLM 只运行一次 Dynamo 字节码编译,但要用不同形状加一个通用形状多次运行 Inductor 编译;针对特定形状的编译发生在 Dynamo 上下文之外,此时没有 shape environment 提供给 Inductor,会导致 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()、PyTorch 状态torch_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()保持一致📎 vllm/compilation/compiler_interface.py:147-159。如果这两处不一致,就会出现"编译时用了配置 A、缓存键按配置 B 计算"的错配,导致缓存命中却加载了错误的产物。
生产踩坑:_patch_standalone_compile_atomic_save是针对 torch < 2.10.0 的 backport📎 vllm/compilation/compiler_interface.py:205-243。它把CompiledArtifact.save()改为用write_atomic写二进制格式,注释说明目的是"preventing corrupt cache files when multiple processes compile concurrently"📎 vllm/compilation/compiler_interface.py:208-210。在多副本同时冷启动的场景下,多个进程会并发写同一个缓存文件,非原子写会产生半截文件,后续进程读到损坏产物后行为不可预测。
PiecewiseBackend:按形状分档编译与运行时派发
直觉模型
PiecewiseBackend是编译与执行之间的调度中枢。它把"一个 FX 子图"编译成"多个形状档位的可调用对象",并在运行时根据实际 token 数选择最合适的那一个。若没有它,要么所有形状都走同一个通用编译(性能次优),要么每个形状都单独编译(编译时间爆炸)。
数据结构: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"直接抛NotImplementedError,并说明"should be handled inpost_init_cudagraph_sizes" 📎 vllm/compilation/piecewise_backend.py:166-171——这是一个显式的职责边界声明。然后处理compile_ranges(区间),每个区间生成一个 entry📎 vllm/compilation/piecewise_backend.py:173-173。
PiecewiseBackend支持两种互斥模式,构造函数用异或断言强制这一点📎 vllm/compilation/piecewise_backend.py:117-119:编译模式(有 graph,无 compiled_runnables)走compile_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194;预编译模式(无 graph,有 compiled_runnables)走load_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194。这个设计让冷启动与热启动共享同一个类,只是数据来源不同。
场景驱动:从编译到运行时派发
编译阶段:compile_all_ranges遍历所有 range entry,对每个未编译的 entry 调用_log_compile_start记录追踪事件📎 vllm/compilation/piecewise_backend.py:252-256。关键分支在参数构造:如果是单点尺寸,调用create_concrete_args生成具体形状的 FakeTensor📎 vllm/compilation/piecewise_backend.py:258-261;否则调用get_fake_args_from_graph直接复用图中的 placeholder 元数据📎 vllm/compilation/piecewise_backend.py:262-263。
create_concrete_args的实现揭示了符号形状具体化的细节。它构造一个带ShapeEnv的FakeTensorMode 📎 vllm/compilation/piecewise_backend.py:54,然后遍历 placeholder 节点。对SymInt类型的输入,用concretize把所有自由符号替换为size 📎 vllm/compilation/piecewise_backend.py:47-52;对Tensor类型,则要同时具体化 shape、stride、storage_offset,并用compute_required_storage_length算出所需存储长度,再通过as_strided重建张量📎 vllm/compilation/piecewise_backend.py:64-73。なぜ shape だけを変更できないのか?なぜなら stride と storage_offset にもシンボルが含まれる可能性があり、かつ三者が整合していなければ、as_stridedは範囲外アクセスを起こす。
ランタイムディスパッチ:__call__はホットパスである。もしsym_shape_indicesが存在する場合、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。
flowchart TD
call["PiecewiseBackend.__call__(*args)"] --> has_sym{"sym_shape_indices 非空?"}
has_sym -->|"是"| get_shape["runtime_shape = args[sym_shape_indices[0]]"]
get_shape --> find["_find_range_for_shape(runtime_shape)"]
find --> exact{"runtime_shape in compile_sizes?"}
exact -->|"是"| exact_entry["返回 Range(start=shape, end=shape) 的 entry"]
exact -->|"否"| scan["遍历 compile_ranges 找包含区间"]
scan --> found{"找到?"}
found -->|"否"| assert_fail["AssertionError: 形状超出编译范围"]
found -->|"是"| entry_ok["返回对应 entry"]
has_sym -->|"否"| static["取唯一已编译 entry"]
static --> check_count{"compiled_entries 数量 == 1?"}
check_count -->|"否"| count_err["AssertionError"]
check_count -->|"是"| entry_ok
exact_entry --> run["range_entry.runnable(*args)"]
entry_ok --> runコピー
to_bytes〔設計上の推論とアーキテクチャのトレードオフ〕reducer_overrideメソッドはコンパイル成果物をシリアライズし、AOT キャッシュに使用する。ここには巧妙なCachingAutotunerがある:pickle がobj.prepare_for_pickle()に遭遇すると、まず📎 vllm/compilation/piecewise_backend.py:209-218を呼び出してからCachingAutotunerをシリアライズする。なぜこのフックが必要か?prepare_for_pickleは内部的に Triton コンパイル成果物とランタイム状態を保持しており、直接 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であり、シリアライズ時には強制的に
load_all_rangesとなり、成果物が確実にパッケージ化される。compiled_runnablesはホットスタートパスであり、各 range が📎 vllm/compilation/piecewise_backend.py:329-339内で対応する key を見つけられることをアサートし、そうでなければ利用可能な key リストを含むエラー
をスローする。このエラーメッセージは非常に実用的に設計されている——利用可能な key を直接列挙するため、キャッシュバージョンの不一致の調査が容易になる。
CUDA Graph ラッパー:キャプチャ、リプレイ、ネストディスパッチ
直感的モデルCUDAGraphWrapperCUDA Graph は「一連のカーネル起動」を静的なグラフとして記録し、以降のリプレイでは API 呼び出しが一度だけで済む。
は記録とリプレイの実行者である。直面する核心的な課題は:vLLM のバッチサイズは動的であるが、CUDA Graph は入力アドレスが固定であることを要求する。解決策は「batch descriptor ごとに段階的にキャプチャする」こと——各形状段階ごとにグラフを記録し、ランタイムでは descriptor に基づいてテーブルを引いてリプレイする。
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はキャプチャ時の出力(メモリ節約のため弱参照で保存)📎 vllm/compilation/cuda_graph.py:128-135。
CUDAGraphWrapperはデバッグモードでのみリプレイ時の入力アドレス一致を検証するために使用📎 vllm/compilation/cuda_graph.py:158-158のクラスドキュメントはディスパッチ契約を正確に記述している:初期化時にランタイムモード(FULL または PIECEWISE)を割り当てる📎 vllm/compilation/cuda_graph.py:158-158;ランタイムでは 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:164-164ドキュメントはさらに一つの境界を特に宣言している:「CUDAGraphWrapper does not store persistent buffers or copy any runtime inputs into that buffers for replay」
。これは入力バッファの管理は呼び出し側の責任であることを意味する——wrapper はグラフ自体のみを担当する。
シナリオ駆動:一回のキャプチャと一回のリプレイキャプチャパス__call__:📎 vllm/compilation/cuda_graph.py:232-233がトリガーされ、runtime_mode が一致する場合、まず forward context が利用可能か確認する。利用不可の場合(視覚エンコーダのフォワードなど)、直接下位関数
を呼び出す。これはマルチモーダルシナリオの重要な分岐である——ViT フォワードは CUDA Graph を通らない。batch_descriptor次にcudagraph_runtime_mode 📎 vllm/compilation/cuda_graph.py:242-244と📎 vllm/compilation/cuda_graph.py:246-256を取得する。mode が NONE または不一致の場合、直接
を呼び出す。この「不一致なら直通」という設計により、ネストされた wrapper が共存できる:FULL wrapper が外層、PIECEWISE wrapper が内層にあり、ランタイムでは一つだけがアクティブになる。cudagraphentry のvalidate_cudagraph_capturing_enabled()が None の場合、キャプチャに入る。まず📎 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が有効なら、torch.accelerator.empty_cache 📎 vllm/compilation/cuda_graph.py:288-303と📎 vllm/compilation/cuda_graph.py:289-294をパッチする。コメントは理由を説明している:piecewise モードでは各層ごとにグラフをキャプチャする必要があり、繰り返し GC するとキャプチャが極端に遅くなるため、「only run gc for the first graph, and disable gc for the rest」📎 vllm/compilation/cuda_graph.py:305-308。次に graph pool id📎 vllm/compilation/cuda_graph.py:310-312。
を設定し、offloader のコピーストリームを同期するtorch.cuda.graph(cudagraph, pool=..., stream=...)実際のキャプチャはself.runnable(*args, **kwargs) 📎 vllm/compilation/cuda_graph.py:315-321コンテキスト内で実行されるget_offloader().join_after_forward()。キャプチャ後に📎 vllm/compilation/cuda_graph.py:322-326を呼び出して未 join のストリームエラーを回避するweak_ref_output。もし📎 vllm/compilation/cuda_graph.py:327-334が有効なら、output を弱参照に変換してメモリを節約する📎 vllm/compilation/cuda_graph.py:338-339。最後に entry は弱参照 output とグラフオブジェクトを保存する、しかし返されるのは弱参照ではなく元の output である📎 vllm/compilation/cuda_graph.py:343-346。
——コメントはこれが PyTorch にキャプチャ期間中のメモリを正しく管理させるためだと強調しているリプレイパス📎 vllm/compilation/cuda_graph.py:348-357:entry に既にグラフがある場合、デバッグモードで入力アドレスの一致を検証し📎 vllm/compilation/cuda_graph.py:359-361、次に offloader を同期しentry.cudagraph.replay()、entry.output 📎 vllm/compilation/cuda_graph.py:362-363。
設計思考:なぜ出力は弱参照で、戻り値は強参照なのか
これはCUDAGraphWrapperの中で最も直感に反する箇所である。キャプチャ時にoutputは PyTorch の cudagraph pool によって管理される📎 vllm/compilation/cuda_graph.py:320。もし entry が output を強参照すると、このグラフが占有する VRAM は永遠に解放されない。しかしキャプチャ中に弱参照に変換すると、PyTorch がキャプチャ完了前にメモリを回収し、キャプチャが失敗する可能性がある。そのためコードはキャプチャブロック内で弱参照📎 vllm/compilation/cuda_graph.py:334を使い、entry には弱参照📎 vllm/compilation/cuda_graph.py:338を格納するが、関数の戻り値は強参照📎 vllm/compilation/cuda_graph.py:346である。この「三重参照状態」はメモリ安全性と VRAM 効率の精密なバランスである。
もう一つ注目すべき設計は_all_instancesというWeakSet 📎 vllm/compilation/cuda_graph.py:173-176である。これによりclear_all_graphsはすべての wrapper のグラフを一度にクリアでき📎 vllm/compilation/cuda_graph.py:173-176、VRAM が逼迫した際の緊急回収に使われる。通常の集合ではなくWeakSetを使うのは、wrapper が GC されるのを妨げないためである——そうでなければ wrapper 自体がリークする。
本番での落とし穴:__getattr__の実装はデバッグモードで存在しない属性に対してコンテキスト付きのエラーを投げる📎 vllm/compilation/cuda_graph.py:211-217。些細なことに見えるが、「なぜあるメソッド呼び出しが失敗するのか」を調査する際、wrapper がラップする runnable の文字列表現が見えることは、裸の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 バックエンドはバッチが uniform の場合のみ full CUDA Graph をサポートする📎 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-652の RuntimeError を引き起こす可能性がある。これがなぜコメントが「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は PyTorch の cudagraph pool によって管理される📎 vllm/compilation/cuda_graph.py:320。戻り値が弱参照の場合、呼び出し側が受け取るオブジェクトはキャプチャブロックを抜けた直後にGCに回収される可能性がある——この時点でそれを保持する強参照が存在しないためである。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の場合、どのentryにヒットするか?優先順位を逆にした場合、どのような結果になるか?
参考解析:現在のロジックはまずruntime_shape in self.compile_sizesをチェックし、ヒットすればRange(start=8, end=8)の単一点entry📎 vllm/compilation/piecewise_backend.py:342-355を返す。このentryはcreate_concrete_argsでコンパイルされ、形状が完全に具体化されているため、Tritonカーネルは最大限の特化が可能である(例えばset_inductor_configでは単一点サイズでmax_autotune 📎 vllm/compilation/compiler_interface.py:747-754が有効になる)。優先順位を逆にすると、shape=8は区間Range(1,16)のentryにヒットする——それはシンボリック形状でコンパイルされた汎用版であり、性能は次善である。さらに深刻なのは、compile_sizesが通常cudagraph_capture_sizesに由来し、これらのサイズこそがCUDA Graphがキャプチャすべき档位である点である。実行時に汎用entryへディスパッチされると、CUDA Graphがキャプチャしたグラフとディスパッチされたrunnableが一致せず、リプレイ時に形状の不一致が生じる可能性がある。したがって、正確優先は性能上の選択であるだけでなく、正確性の要件でもある。
次章では量子化とカスタムカーネルに移り、vLLMが重みロード段階から精度制御に介入し、高度に特化した演算子で量子化の利益を真にスループット向上として実現する方法を見る。
本章ではvLLMのコンパイル高速化の二層メカニズムを分析した。第一層はCompilerInterfaceとPiecewiseBackendである。前者はコンパイラ適配契約とキャッシュハッシュ戦略を定義し、AlwaysHitShapeEnvでDynamoコンテキスト欠如の問題を回避する。後者は単一のFXサブグラフを複数の形状档位にコンパイルし、実行時にtoken数に応じてディスパッチする。第二層はCUDAGraphWrapperである。これはBatchDescriptorごとにCUDA Graphをキャプチャし、runtime modeマッチングによるネストディスパッチを実現し、FULLとPIECEWISEの両モードを同一コンパイルグラフ上で共存させる。両者の分離が今回のリファクタリングの核心である——コンパイル成果物は2つのCUDA Graphモードで再利用でき、CUDA Graphもコンパイルから独立して動作できる。ただし、コンパイルとグラフキャプチャが解決するのはスケジューリングオーバーヘッドであり、モデル自体の重み精度と演算子効率は依然として別の最適化主線である。次章では量子化とカスタムカーネルに移り、vLLMが量子化設定を解析し、重みロード時にFP8/INT4/AWQ/GPTQなどのフォーマット変換を完了し、_custom_opsとTritonカーネルによってハードウェア性能をさらに引き出す方法を見る。
第11章:量子化とカスタムカーネル:重みロードから高性能演算子まで
前章では、torch.compileとCUDA GraphがPythonスケジューリングとカーネル起動オーバーヘッドを極限まで圧縮することを見た。しかしスケジューリングがどれだけ速くても、重み自体がFP16で行列乗算が汎用GEMMであれば、ハードウェア算力は依然としてメモリ帯域と非効率な演算子に足を引っ張られる。量子化とカスタムカーネルはもう一つの直交する最適化主線である。前者は重みロード段階で精度を圧縮し、後者は量子化の利益を真にスループットとして実現する。本章は量子化設定の解析入口から出発し、_custom_opsの演算子登録とTritonカーネルスケジューリングまでを辿る。
11.1 量子化設定:CLI文字列からQuantKeyへ
直感モデル
量子化設定モジュールの役割は、レストランの注文メニュー翻訳機のようなものである。ユーザーがフロントで「fp8_per_tensorが欲しい」(CLI文字列)と言い、厨房が必要とするのは正確なレシピ番号(QuantKey)である。翻訳機は3種類の入力を処理しなければならない:純粋なCLI簡略表記、checkpoint自带の量子化メタデータ、そして両者が重畳する組み合わせシナリオ。この翻訳層がなければ、厨房は意味の曖昧な文字列の山を受け取り、どのkernelを呼ぶべきか決定できない。
データ構造とメモリレイアウト
核心的なデータ構造はQuantSpecとQuantizationConfigArgsである。前者は単一クラス層(linearまたはMoE)の重みと活性化の量子化キーを記述し、後者はユーザーに見えるトップレベル設定である。
📎 vllm/config/quantization.py:73-99
@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 を使うか」が判定不能になる。
Step-by-Step:一度の--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"]がkFp8StaticTensorSym 📎 vllm/config/quantization.py:188-190。
に解析される。つまり linear と moe の両方がquantization_configを使用するQuantizationConfigArgs第三步、📎 vllm/config/quantization.py:267-268が非空の場合、quantization_config.xxx or base.xxxオブジェクトとして構築される。その後マージロジックに入るor:各フィールドはif is not Noneで決定される——ユーザーが明示的に設定したフィールドが優先され、未設定のものは省略名のデフォルト値を継承する。ここでQuantSpecではなく
を使うのは意図的である:quantizationと空リストはどちらも falsy であり、意味的には「未設定」と「空」は等価である。awq第四步、もしquantization_configが省略名テーブルにない場合(例えば checkpoint に付属のNone)、かつNone 📎 vllm/config/quantization.py:256-257が
なら、関数は直接_DEFERRED_ONLINE_SHORTHANDSを返す。これは「オンライン量子化を重ねない」ことを意味し、checkpoint の量子化メソッドが支配的であり続ける。mxfp4見落としがちな分岐がある:mxfp8 📎 vllm/config/quantization.py:233-235は--quantization mxfp4とquantization_configを含む。これら二つの名前は CLI 省略名であり、同時に checkpoint 量子化メソッド名でもある。ユーザーがNoneのみを渡しbase 📎 vllm/config/quantization.py:267-268を渡さない場合、関数は
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を返し、決定権を checkpoint メタデータに先送りする——checkpoint に量子化情報がない場合にのみ、オンライン省略名にフォールバックする。
_coerce_specコピーlinear設計上の考察と落とし穴moeバリデータは微妙なシナリオを処理した:_ONLINE_SHORTHANDSまたはQuantKeyが文字列を受け取った場合、まず📎 vllm/config/quantization.py:130-139を調べ、ヒットすれば対応するフィールドの spec を取り出す;ヒットしなければ単一の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本番環境でよくある落とし穴:
11.2 _custom_opsの正規表現キーは
で事前コンパイル検証される
_custom_ops.pyが、fnmatch パターンのキーは検証されない。ユーザーがどの層にも永遠にマッチしない fnmatch パターンを書いても、エラーにはならず、その層は未量子化のままである——調査時には層名が本当にマッチするか確認する必要がある。torch.ops._C:オペレータ登録と fake 実装torch.compile直感的モデル_custom_opsは vLLM と基盤の CUDA/C++ オペレータの間の適応層であり、税関のようなものである。PyTorch の
名前空間にはコンパイル済みの C++ オペレータが登録されているが、それらを直接呼び出すには三つの問題がある:異なるプラットフォーム(CUDA/ROCm/CPU/XPU)でオペレータセットが異なる、
出力形状を導出するために fake 実装が必要、一部のオペレータは Python 側のパラメータ前処理が必要。current_platform.import_kernels() 📎 vllm/_custom_ops.py:25-26はこれらの問題を統一的にカプセル化する。register_fakeデータ構造と登録メカニズムTYPE_CHECKINGモジュールロード時にまずtorch.libraryを呼び出し、プラットフォーム層に自身のオペレータライブラリをインポートする機会を与える。その後📎 vllm/_custom_ops.py:25-26。
を定義する——torch.compile下では空のデコレータであり、実行時にscaled_fp4_quantから
📎 vllm/_custom_ops.py:90-100
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)fake 実装の核心的な役割は、hasattrがトレース段階でオペレータの出力形状と dtype を知ることである。実際に実行せずに。例えば_C::scaled_fp4_quant:
create_fp4_output_tensorsコピー📎 vllm/_custom_ops.py:69-87注意is_sf_swizzled_layout=Trueガード:プラットフォームが本当にn // 16を登録した場合にのみ、fake 実装が定義される。これにより CPU や古い GPU でモジュールをインポートしても、オペレータの欠如でクラッシュしないことが保証される。📎 vllm/_custom_ops.py:55-64は FP4 量子化出力のメモリレイアウトの詳細を示す📎 vllm/_custom_ops.py:60-61。
。
の場合、scale テンソルは Tensor Core が要求する 128x4 タイル配置にする必要がある:行数は 128 の倍数に切り上げ、列数(
)は 4 の倍数に切り上げ、4 つの float8_e4m3 を 1 つの int32 にパックするawq_gemm 📎 vllm/_custom_ops.py:587-592。コメントは NVFP4 量子化カーネルがすべての padding された scale エントリを明示的にゼロクリアすることを明確に指摘しているため、別途のゼロ初期化 kernel は不要であるVLLM_USE_TRITON_AWQStep-by-Step:一度の AWQ GEMM の呼び出しフローawq_gemm_tritonシナリオを代入:モデルが AWQ 量子化された重みをロードし、フォワードパスでアクティベーションと量子化重みの行列乗算が必要。
第一步、torch.ops._C.awq_gemmを呼び出す。関数はまず環境変数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) * 8AWQ からのパッキング方式:各 int32 に 8 個の 4-bit 重みを格納する。
第四ステップ、awq_dequantize同様の経路をたどる📎 vllm/_custom_ops.py:553-559、ただし fake 実装の形状推論は異なる:out_c = qout_c * 8、逆量子化後に列数が 8 倍に拡張されるため📎 vllm/_custom_ops.py:587-592。
Marlin シリーズの repack 関数は別のパターンを示している。gptq_marlin_repackの fake 実装が計算する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 で分割されることを示す。MoE 版のgptq_marlin_moe_repackは Python 層で各 expert をループして単一 expert の repack を呼び出す📎 vllm/_custom_ops.py:1154-1172、そしてアサートするsize_k % 16 == 0——これは Marlin フォーマットのハード制約である。
flowchart LR
input["input: torch.Tensor (FP16/BF16)"]
qweight["qweight: torch.Tensor (INT32 packed)"]
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["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)が vLLM コア内の Triton カーネルを独自の実装で置き換える必要がある場合、コアコードを直接変更することはできない——それは上流を汚染してしまう。dispatcherはプラットフォームが代理を登録し、元のカーネルを指すすべての参照を密かに代理に置き換えることを可能にする。この仕組みがなければ、各プラットフォームが fork を維持しなければならず、上流の変更をマージする際に絶えずコンフリクトが発生する。
データ構造とメモリレイアウト
中核となるデータ構造は_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第一ステップ、_resolve_kernel 📎 vllm/triton_utils/dispatcher.py:162-166。_resolve_kernelが overrides を走査し、各名前に対して.を呼び出して名前を最後の📎 vllm/triton_utils/dispatcher.py:83-94でモジュール名と属性名に分割するgetattr。モジュール名の最後のセグメントの頭文字が大文字であれば、カーネルが何らかのクラス(JIT warmup owner)に属することを示し、まず親モジュールをインポートしてから(类, 属性名)でクラスを取得し、(模块, 属性名)。
を返す;そうでなければモジュール自体をインポートし、KernelOverrideを返す_registry 📎 vllm/triton_utils/dispatcher.py:167-169。
第二ステップ、元カーネルオブジェクトを取得した後、_rebind_kernelsラッパーを構築し、📎 vllm/triton_utils/dispatcher.py:97-144に記録するsys.modules第三ステップ、__dict__が全モジュールスキャンを実行するis。それは==内のすべてのモジュールのPlaceholderModuleを走査し、各属性値に対して同一性比較を行う——注意すべきは📎 vllm/triton_utils/dispatcher.py:116-123。
でありsetattrではない、なぜなら一部の属性値(例えば📎 vllm/triton_utils/dispatcher.py:125-135センチネル)は hash/eq 時にインポートや例外を引き起こすためkernel第四ステップ、元カーネルにマッチした属性に対して、直接value.kernelを wrapper に置き換える_kernel_arg_names。JIT warmup owner(インスタンス属性📎 vllm/triton_utils/dispatcher.py:138-139。
が元カーネルを指すオブジェクト)に対しては、_rebind_kernelsを置き換え、キャッシュされた📎 vllm/triton_utils/dispatcher.py:170-174をクリアして、launch バインディングが wrapper から再推論されるようにする📎 vllm/triton_utils/dispatcher.py:170-171。
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: 注册完成完了後に、定義箇所の属性も wrapper に置き換える
KernelOverride.__getitem__。コメントは順序の重要性を説明している:先に定義箇所を置き換えると、スキャン時に元カーネルが見つからなくなるself._launchコピーkernel[grid](**kwargs)設計上の考察と落とし穴📎 vllm/triton_utils/dispatcher.py:63-74。_launchが📎 vllm/triton_utils/dispatcher.py:63-74を返すことで、_forward_by_nameのような Triton 標準 launch 構文が wrapper に対して透過的になるRuntimeErrorの転送ロジックは三つのケースに分かれる
:位置引数がある場合は直接透過;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 の独自性はhasattrガードの普遍的な使用にある。これにより同一のモジュールが CUDA、ROCm、CPU、XPU 上でクラッシュせずにインポートできるが、代償として各演算子に三箇所のコードが必要となる:Python ラッパー、fake 実装、そしてプラットフォームガード。
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 のレイアウトが異なる)、重みのロードが失敗するか誤った結果を生む。より隠蔽的なケースは:checkpoint のmxfp4が異なる 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_kernelsJIT warmup owner のkernel属性を置換した後、value.__dict__.pop("_kernel_arg_names", None)が実行される。もしこの行を除去した場合、どのような状況で launch バインディングエラーが発生するか?
参考解析:JIT warmup owner は_kernel_arg_namesをキャッシュしており、launch 時に kwargs をカーネルパラメータにバインドするために使用する📎 vllm/triton_utils/dispatcher.py:138-139。kernelを wrapper に置換した後、wrapper のarg_namesは元のカーネルと異なる可能性がある(もしプラットフォーム実装のパラメータ名が異なる場合、wrapper のarg_namesは依然として元のカーネルをミラーするが、_forward_by_nameはFalseかもしれない)。もしキャッシュをクリアしないと、warmup メカニズムは古いパラメータ名リストでバインディングを続け、wrapper の launch ロジックは異なるバインディング方法を期待するかもしれない。具体的には、KernelOverride._launchは_forward_by_nameがFalseの時にself.arg_namesの順序で値を抽出する📎 vllm/triton_utils/dispatcher.py:79-80。もしキャッシュされた_kernel_arg_namesが wrapper のarg_namesと一致しない場合、抽出されるパラメータの順序が乱れ、カーネルが誤ったパラメータ値を受け取る。
次章では高度な推論機能に移り、プレフィックスキャッシュがどのように KV block を再利用するか、投機的デコーディングがどのように小モデルで大モデルを加速するか、そして LoRA がどのようにベースモデルの重みを変更せずにアダプタを動的に切り替えるかを見る。
本章では、vLLMの量子化とカスタムカーネルの2層インフラストラクチャを分析した。第1層は量子化設定の解析である。QuantSpecとQuantizationConfigArgsは、CLI文字列、チェックポイントメタデータ、層ごとのオーバーライドをQuantKeyへ統一的に正規化し、resolve_quantization_configは省略形の展開とフィールドのマージを処理し、_DEFERRED_ONLINE_SHORTHANDSは名前衝突のシナリオを解決する。第2層は演算子の適配である。_custom_opsはhasattrガードとregister_fakeによりクロスプラットフォームな演算子登録を実現し、fake実装はtorch.compileをサポートするために実演算子の出力形状を正確にミラーリングする。dispatcherはKernelOverrideと全モジュールスキャンによりTritonカーネルのプラットフォーム置換を実現する。両者は共に、重みのロードからフォワード計算までの量子化收益の実現を支えている。次に、スループットを向上させレイテンシを低減する高度な推論機能へと移る。自動プレフィックスキャッシュがリクエストをまたいでKVをどのように再利用するか、投機的デコーディングがドラフトモデルで生成をどのように加速するか、そしてLoRAがアダプタをどのように動的に切り替えるかを見ていく。
第12章:高度な推論機能:プレフィックスキャッシュ、投機的デコーディング、LoRA
前章ではvLLMの量子化体系とカスタム演算子インフラストラクチャを深く掘り下げ、量子化設定がどのように解析され対応するkernelが選択されるか、またFP8、INT4、AWQ、GPTQなどの方式が重みロード時にどのように変換を完了するかを見てきた。同時に、_custom_opsがCUDA演算子をどのように登録するか、Tritonカーネルのスケジューリング機構、そしてMoE融合カーネルがどのようにVRAMの往復を削減するかを明らかにした。これらの低レベル能力が、より高度な推論最適化への道を切り開いた。本章ではvLLMの3大高度推論機能、すなわち自動プレフィックスキャッシュ(APC)、投機的デコーディング、LoRAに焦点を当てる。これらは一見独立しているが、実際には同一の低レベルインフラストラクチャを共有している。すなわち、KV blockのハッシュ、スケジューラのslot割り当て、そしてモデル実行時の動的重み注入である。それらを理解する鍵は、PagedAttentionのページングセマンティクスを損なうことなく、「再利用」を極限まで徹底する方法を理解することにある。
12.1 プレフィックスキャッシュ:block hashがどのようにプレフィックスを指紋化するか
直感モデル
プレフィックスキャッシュは図書館の「共通段落の抜粋ノート」のようなものである。2人の学生が作文を書き、冒頭で同じ古文を引用する場合、先生はその古文の部分を一度だけ添削すればよく、その後ろのそれぞれ異なる部分を別々に見ればよい。これがなければ、各リクエストはプロンプト全体を最初からprefillする必要があり、長文書質疑応答のシナリオでは計算リソースが数倍に重複消費される。
データ構造:tokenからblock hashへのマッピング
プレフィックスキャッシュの核心は「2つのリクエストのプレフィックスが同一かどうかをどのように判定するか」である。vLLMの答えは、token列をblockごとに分割し、各blockに対してチェーンハッシュを計算するというものである。チェーンとは、N番目のblockのハッシュが前のN-1個のblockのハッシュを含むことを意味し、したがって1つのblock hashが「列の先頭からそのblockの末尾まで」のプレフィックス全体を一意に指紋化する。
ハッシュの担体はBlockHashであり、これはbytesのNewTypeとして定義され、裸のbytesではない。その目的は、型レベルで📎 vllm/v1/core/kv_cache_utils.py:59-62の誤用を防ぐことである。block hashとKV cache group idを組み合わせて辞書キーにする必要がある場合、vLLMはタプルを使わず、4バイトのビッグエンディアンgroup idをhashバイトの末尾に直接連結する📎 vllm/v1/core/kv_cache_utils.py:75-76:
def make_block_hash_with_group_id(block_hash, group_id):
return BlockHashWithGroupId(block_hash + group_id.to_bytes(4, "big", signed=False))これは典型的な「タプル割り当て回避」最適化である。ホットパスでは、各blockの検索ごとにキーを構築する必要があり、タプルは余分なPythonオブジェクト割り当てとハッシュオーバーヘッドをもたらすが、バイト列の連結はC層で完了し、しかもバイト列自体がハッシュ可能である。取り出し時にはスライスkey[:-4]とint.from_bytes(key[-4:])で📎 vllm/v1/core/kv_cache_utils.py:87-89。
を復元する。ハッシュ関数自体はhash_block_tokensが担い、親block hash、現在のblockのtoken idタプル、および追加キーをまとめてハッシュ関数に渡す📎 vllm/v1/core/kv_cache_utils.py:650-680。最初のblockの親ハッシュはNoneではなく、グローバルなNONE_HASH:
if not parent_block_hash:
parent_block_hash = NONE_HASH📎 vllm/v1/core/kv_cache_utils.py:674-675。NONE_HASHのシード選択には安全設計が隠されている。SHA-256のような暗号学的ハッシュでは、シードは固定の"vllm-none-hash"であり、異なるvLLMプロセスが同じ内容に対して同じハッシュを算出し、ノードをまたいでプレフィックスキャッシュを共有できるようにする。一方、xxhashのような非暗号学的ハッシュでは、シードはプロセスごとにランダムである。予測可能なシードは攻撃者に衝突blockをオフラインで事前計算させるからである📎 vllm/v1/core/kv_cache_utils.py:105-126。resolve_none_hash_seedがこの分岐を実装している:PYTHONHASHSEED環境変数が優先され、そうでなければ暗号学的ハッシュは固定シード、非暗号学的ハッシュはos.urandom(32) 📎 vllm/v1/core/kv_cache_utils.py:132-145。
シナリオ駆動:1回のリクエストにおける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すなわち、既に計算済みのブロック数にブロックサイズを掛けたものである。残りのトークンが1ブロック未満の場合は、直接空を返す📎 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が必要である。これは、マルチモーダル入力のプレースホルダートークン自体が意味を持たないため、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これによりチェーン構造が形成される。
マルチブロックサイズの粒度変換
モデルが複数のKV cache groupを持ち、ブロックサイズが異なる場合、ハッシュ粒度とgroupのブロック粒度が一致しないことがある。BlockHashListWithBlockSizeこの問題を解決する。ハッシュを再計算するのではなく、チェーンハッシュの性質を利用する。すなわち、あるtarget blockのハッシュは、その内部の最後のhash blockのハッシュである📎 vllm/v1/core/kv_cache_utils.py:2781-2851例えば、hash blockが16、target blockが32の場合、トークン0-31のハッシュは2番目の16サイズハッシュである(これは既に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。
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設計上の考察と落とし穴
なぜ独立ハッシュではなくチェーンハッシュを使うのか?独立ハッシュでは「同じブロックが異なるプレフィックス位置に現れる」ケースを区別できない。チェーンハッシュはブロックハッシュをプレフィックス全体の一意な指紋とする。これこそが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)は1回のフォワードでこれらの候補を並列検証し、一致する部分を受け入れる。これがなければ、ターゲットモデルはトークンごとに逐次生成するしかなく、decode段階でのGPU利用率は極めて低い。
データ構造:EAGLE groupの注釈
投機的デコーディングのKV cache管理における核心的な問題は、ドラフトモデルのKV層とターゲットモデルのKV層をどのようにグループ化するかである。_annotate_eagle_groups2つのルールでドラフトグループを識別する📎 vllm/v1/core/kv_cache_utils.py:2134-2189:
ルール1はspec駆動である:non_causal_multi_token_decodeフラグビットはMLAAttentionSpec上で宣言され、非因果的多トークンdecodeを実行するドラフトアテンション層によって設定され、merge操作を生き延びられる📎 vllm/v1/core/kv_cache_utils.py:2175-2177。
ルール2は位置フォールバックである:MTPドラフター(例:DeepseekV4/V4.1 DSpark)はターゲットモデル自身のdecoder層を再利用し、spec上にマークはないが、それらのドラフトアテンション層は常にすべてのターゲット層の後に登録されるため、最後に登録された層を保持するgroupを注釈する📎 vllm/v1/core/kv_cache_utils.py:2183-2184このルールは、groupがちょうど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。どのgroupもドラフトグループとして注釈されなかった場合、📎 vllm/v1/core/kv_cache_utils.py:2364-2365は警告を発する_warn_if_unannotated_eagle_mambaコピー📎 vllm/v1/core/kv_cache_utils.py:2192-2222。
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が同じgroupに混在していると、破棄操作がターゲットKVを誤って傷つける。注釈によりスケジューラが正確に回収できる。位置フォールバックルールの脆弱性。
ルール2は「ドラフト層が最後に登録される」という約束に依存しており、コメントにはこれがhacky checkであると明記され、FIXMEが残されている。ドラフトの尾部キャッシュが複数のgroupにまたがる場合、このルールは最後の層を保持するgroupのみを注釈し、一般化が必要である。📎 vllm/v1/core/kv_cache_utils.py:2158-2159Mambaモデルの追加制約。
Mamba 模型的额外约束。投機的デコーディングを有効にしているが、ドラフトグループとして認識される group がなく、かつ Mamba group が存在する場合、警告がトリガーされる📎 vllm/v1/core/kv_cache_utils.py:2211-2213。これは通常、ドラフト層の spec とターゲット層が区別できないことを意味し、モデル登録順序を確認する必要がある。
12.3 LoRA:ベースを再ロードしない動的アダプタ
直感モデル
LoRA は同じスマートフォンに異なるケースを付けるようなものだ。スマートフォン本体(ベースモデル)は変わらず、ケース(アダプタ)を替えることで異なるスタイルになる。これがなければ、各ファインチューニングタスクごとに完全な重みをロードする必要があり、VRAM が耐えられない。
データ構造:デュアル LRU キャッシュと slot 配列
LoRAModelManager2つの LRU キャッシュでアダプタのライフサイクルを管理する📎 vllm/lora/model_manager.py:115-120:
self._registered_adapters: AdapterLRUCache[LoRAModel] = AdapterLRUCache(
self.capacity, self.deactivate_adapter
)
self._active_adapters: AdapterLRUCache[None] = AdapterLRUCache(
self.lora_slots, self._deactivate_adapter
)capacityCPU 側でキャッシュできるアダプタの総数(max_cpu_loras)📎 vllm/lora/model_manager.py:340-342,lora_slotsGPU 側で同時にアクティブ化できるアダプタ数(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 slot インデックスをアダプタ 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。
第二步,空き slot を探す。lora_index_to_idを走査して最初のNone 📎 vllm/lora/model_manager.py:362-362を見つける。空き slot がなければValueError("No free lora slots") 📎 vllm/lora/model_manager.py:368-368。
をスローする 第三步,状態を更新し、すべてのラップ済みモジュールを走査してmodule.set_lora(index, lora_a, lora_b)を呼び出し、重みを GPU の stacked buffer にコピーする📎 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_layerwrapper を作成し、元のモジュールを置き換える📎vllm/lora/model_manager.py:546-553。
設計上の考察と落とし穴
slot レイアウトの変化がマッピング更新をトリガーする。 set_adapter_mappingは mapping が変化したかどうかだけでなく、lora_index_to_idのタプルスナップショットも比較する📎 vllm/lora/model_manager.py:1323-1331。理由はコメントに明確に書かれている:帯域外のadd_lora()が LRU 淘汰と slot の再割り当てをトリガーする可能性があるが、実行中の batch とその mapping は変わらない📎 vllm/lora/model_manager.py:1323-1331。mapping だけを見ると、punica metadata は古い slot レイアウトを使用してしまう。
MoE の EP スライス。エキスパート並列を有効にすると、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。コメントは2つの理由を明確に指摘している:MoE モデルの LoRA 重みの数が多く、早すぎる pin はオーバーヘッドが顕著;パッキングが割り当てを無効化する可能性がある📎 vllm/lora/model_manager.py:916-921。
設計上の考察:三者協調のポイント
3つの機能は KV cache 管理層で交差する。プレフィックスキャッシュは block hash で KV を再利用し;投機的デコーディングはis_eagle_group注釈でドラフト KV を区別し;LoRA は_gen_lora_extra_hash_keysでアダプタ名を block hash に混ぜ込み📎 vllm/v1/core/kv_cache_utils.py:568-581、異なるアダプタの同じ token シーケンスが互いの KV を誤ってヒットしないことを保証する。
generate_block_hash_extra_keysは LoRA キーを追加キーリストの先頭に置く📎 vllm/v1/core/kv_cache_utils.py:640-642、マルチモーダルキー、cache salt、prompt embeds キーとともに完全なハッシュ入力を構成する。これにより保証される:2つのリクエストの token が完全に同じでも、LoRA アダプタが異なれば block hash が異なり、KV が混用されない。
本章のまとめ
本章の考察とセルフチェック
Q1: もしinit_none_hashにおける非暗号学的ハッシュのランダムシードロジックを削除し、常に固定シードを使用するように変更した場合、どのようなシナリオでセキュリティリスクが生じるか?なぜソースコードのコメントは xxhash に秘密のシードが必要であると特に強調しているのか?
参考解析:ソースコードは_NON_CRYPTO_HASH_FUNCTIONSにおいて xxhash と 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を返す。固定シードに変更すると、攻撃者はターゲットプレフィックスと衝突する block をオフラインで事前計算し、ハッシュが同じだが内容が異なるリクエストを構築でき、他人の KV cache をヒットして読み取ることができる——これはクロスリクエストの情報漏洩である。SHA-256 の衝突耐性はシードの秘密性に依存しないため、固定シードは再現性にのみ影響しセキュリティには影響しない📎 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の登録を重複させないことである。「target block のハッシュがその内部の最後の hash block のハッシュと等しい」という性質に依存している。ハッシュ関数がチェーン式でない場合(つまり各 block が独立にハッシュされる場合)、このクラスは正しく動作するだろうか?どのような状況で誤ったキャッシュヒットが発生するか?
参考解析:できない。_get_value_atは直接self.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851を返す。この実装の前提は、最後の hash block のハッシュがそれ以前のすべてのトークンをチェーン上でカバーしていることである。ハッシュが独立している場合、この値は最後の hash block の内容のみを指紋化しており、target block 全体ではない。二つの target block は前半部分が異なっていても最後の hash block が同じである可能性があり、ハッシュ衝突が発生して、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 の三大高度推論機能の基盤メカニズムを分析した。プレフィックスキャッシュの中核はチェーン式 block hash である。hash_block_tokens は親ハッシュ、トークンタプル、追加キーをまとめてハッシュ化し、NONE_HASH のシード戦略はプロセス間共有と衝突安全性の間でトレードオフを行う。投機的デコーディングは is_eagle_group アノテーションでドラフト KV グループを区別する。LoRA は二重 LRU キャッシュと slot 配列でアダプタのライフサイクルを管理し、block hash にアダプタ名を混ぜ込むことでキャッシュ分離を実現する。これらの機能は vLLM の推論最適化における深さと柔軟性を共に示している。次に、vLLM のプラグインシステムと拡張性に移り、プラットフォームプラグインが新しいハードウェアにどのように適応するか、IO processor プラグインがマルチモーダル入力処理にどのように介入するか、エンドポイントプラグインがカスタム API ルートをどのように注入するかを見る。プラグインの登録と検出のロード順序を理解することで、コアコードを変更せずに vLLM の能力を拡張する方法が明らかになる。
第 13 章:プラグインシステムと拡張性:プラットフォーム、IO プロセッサ、エンドポイント拡張
前章では、プレフィックスキャッシュ、投機的デコーディング、LoRA といった高度な機能が、スケジューラ、KV 管理、モデル実行の中核パスに深く結合していることを見た。しかし、推論エンジンが真に本番環境へ進むには、性能だけでは不十分である。より厄介な問題に答えなければならない。コミュニティが新しいハードウェア、新しいマルチモーダル入力形式、またはカスタム HTTP ルートを接続したいとき、コアコードを fork せずにどう実現するか?これこそがプラグインシステムの存在意義である。vLLM のアーキテクチャは本質的にマルチプロセスである。API Server フロントエンドプロセス、EngineCore プロセス、そして各 TP/PP rank に対応する Worker プロセス。もしプラグイン機構が単に「import 時にコードを実行する」だけなら、各プロセスで繰り返し実行されて副作用が積み重なるか、メインプロセスでのみ実行されて Worker が拡張を取得できないかのどちらかになる。本章で解き明かすのは、vLLM が Python 標準の entry_points 機構を、グループ + プロセス境界 + ロードタイミングの三重制約と組み合わせて、すべてのプロセスをカバーしつつ公開範囲を精密に制御できるプラグイン体系をどのように構築しているかである。三つの主線に焦点を当てる。プラットフォームプラグイン(新ハードウェアへの適応)、IO processor プラグイン(マルチモーダル入力処理への介入)、エンドポイントプラグイン(カスタム API ルートの注入)。三者はロード戦略が全く異なり、この差異を理解すれば、vLLM の「拡張能力」と「安全境界」に対するトレードオフの哲学を理解できる。
一、プラグインの検出とロード:entry_points のグループ契約
直感モデル:プラグインの「放送チャンネル」
vLLM のプラグインシステムを放送チャンネルの集合として想像してみよう。各プラグインパッケージはインストール時に、setup.pyのentry_pointsを通じてあるチャンネルに自分のコールサイン(plugin name)と応答関数(plugin value)を「登録」する。vLLM は起動時にこれらのチャンネルをスキャンし、どのチャンネルをどのプロセスで「受信」するかを決定する。
この仕組みがなければ、vLLMの拡張はソースコードを変更するしかなく、コミュニティがハードウェアを追加するたびにforkを維持する必要があり、最終的にバージョンが分裂してしまう。グループ化メカニズムの価値は次の点にある:同じプラグインパッケージを特定のチャンネルにのみ登録でき、それによって特定のプロセスでの読み込みに限定できる。
データ構造:5つのグループ定数とグローバルフラグ
vLLMはvllm/plugins/__init__.pyの先頭で5つのentry point group定数を定義しており、各定数が1つの読み込み戦略に対応する:
📎 vllm/plugins/__init__.py:16-30
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_GROUPprocess0のみかつ非同期モードで;ENDPOINT_PLUGINS_GROUPAPI Serverフロントエンドプロセスのみで。
直後にモジュールレベルのグローバル変数plugins_loaded = False 📎 vllm/plugins/__init__.py:32-33があり、これは冪等読み込みのガードである——コメントには「make sure one process only loads plugins once」と明記されている。
Step-by-Step:1回の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)に入り、📎 vllm/plugins/__init__.py:36-45を通じてそのグループ下のすべてのインストール済みentry points
を取得する。空であれば、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()。なお📎 vllm/plugins/__init__.py:68-72。
はtry/exceptで包まれており、単一のプラグイン読み込み失敗はexceptionログを記録するだけで、他のプラグインには影響しない第五步:実行。load_general_pluginsがfunc() 📎 vllm/plugins/__init__.py:77-90に戻り、読み込まれた各関数に対して直接を呼び出す。これがドキュメントでプラグイン関数が再入可能(re-entrant)
でなければならないと強調されている理由である——複数のプロセスで複数回呼び出される可能性がある。load_plugins_by_group以下のフローチャートは
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カスタム設定ファイルではなくを選んだ核心的な動機はプラグインをPythonパッケージと一緒に配布できるようにするpip install vllm-add-dummy-platformことである。ユーザーが
---
した後、プラグインは自動的に対応するグループに現れ、vLLMの設定を手動で編集する必要がない。これはpytest、flake8などのツールのプラグインエコシステムと一脈通じる。代償はプラグイン発見がパッケージのメタデータに依存することであり、プラグインパッケージのインストールが不完全(ソースディレクトリをコピーしただけでpipを通していないなど)だと、entry_pointsはスキャンできない。
二、プラットフォームプラグイン:ハードウェア適配の抽象層
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:
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_VISIBLE_DEVICESはプラットフォーム非依存の「デバイス可視性環境変数」抽象である——CUDAは📎 vllm/platforms/interface.py:151-152。_global_graph_pool、他のプラットフォームはそれぞれget_global_graph_poolを定義する📎 vllm/platforms/interface.py:1210-1215。
はクラスレベルのCUDA graphメモリプールキャッシュであり、__getattr__を通じて遅延初期化される📎 vllm/platforms/interface.py:1189-1208注目すべきはtorch.<device_type>のフォールバックロジックcurrent_platform.memory_allocated()である:Platform上に存在しない属性にアクセスすると、torch.cuda.memory_allocated()名前空間から転送を試みる。これによりプラットフォームコードは__getstate__と書いて実際にはNoneを呼び出せる。しかしソースコードは意図的にdunderメソッドを除外している——そうでなければpickleが📎 vllm/platforms/interface.py:1182-1185。
をチェックするときに
を取得して呼び出そうとするStep-by-Step:デバイスIDの3名前空間変換プラットフォーム抽象で最もつまずきやすいのは📎 vllm/platforms/interface.py:275-283:
- logicalデバイスID名前空間
_assigned_physical_gpu_ids - visibleである。ソースコードのコメントには3種類の
CUDA_VISIBLE_DEVICESが明記されている:vLLM内部のlocal rankで、 - physicalをインデックスする:現在のプロセスが
で再マッピングされた後のtorch/CUDA番号[4, 5]:NVMLなどのトポロジAPIが使用するグローバルGPU IDで、環境変数の影響を受けないCUDA_VISIBLE_DEVICES=4,5シナリオを当てはめる:あるWorkerプロセスに物理GPUtorch.device("cuda:0")。
が割り当てられ、環境変数 device_id_to_physical_device_id(0)が設定され、現在local rank 0を_assigned_physical_gpu_idsに変換する必要がある4 📎 vllm/platforms/interface.py:296-297第一步:logical → physical。device_control_env_varまず📎 vllm/platforms/interface.py:305-311を調べ、すでに設定されていれば直接インデックスしてを返す。設定されていなければ、からカンマ区切りリストを分割して0番目の項目📎 vllm/platforms/interface.py:296-297。
ステップ2: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の冪等設計も注目に値する:同じ値を繰り返し設定しても何も起こらないが、異なる値を設定すると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_clsget_attn_backend_clsアテンションバックエンドのクラス名を返すget_device_communicator_clsコミュニケータのクラス名を返す
check_and_update_configはプラットフォームプラグインで最も重要なフックである📎 vllm/platforms/interface.py:583-592。これはVllmConfig参照を受け取りその場で変更し、block size、graph mode などを調整できる。ドキュメントは「最も重要なのは 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 に対して課す制約は互いに衝突し、単一の公式では解決できない。段階化により各制約を独立に処理し、最終的にすべての制約を満たす解を取る。
---
三、IO Processor とエンドポイントプラグイン:入力処理と API 拡張
直感的モデル:IO Processor は「マルチモーダル翻訳層」
マルチモーダルモデル(LLaVA など)の入力は純粋なテキストではなく、テキスト + 画像の混合体である。IO Processor プラグインは生のマルチモーダルデータをモデルが消費できるテンソルに変換し、モデル出力を人間が読める形式に戻す役割を担う。それは税関の通訳者のようなものである:入ってくる外国語(画像/音声)をモデルの母語に翻訳し、出ていくモデルの母語を外国語に翻訳し戻す。
Step-by-Step:IO Processor の検出とインスタンス化
シナリオ:io_processor_pluginフィールドを持つ HF config のモデルをロードする。
ステップ1:プラグイン名を決定する。 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。
ステップ2:インストール済みのすべてのプラグインをロードする。を呼び出してload_plugins_by_group(IO_PROCESSOR_PLUGINS_GROUP)そのグループ下のすべてのプラグインを取得する📎 vllm/plugins/io_processors/__init__.py:59-61。
ステップ3:ロード可能なマッピングを構築する。各プラグインを走査し、その関数を呼び出してprocessor_cls_qualnameを取得し、Noneでなければloadable_plugins 📎 vllm/plugins/io_processors/__init__.py:66-76に記録する。ここで各プラグインの関数呼び出しも try/except で包まれており、単一の失敗が他に影響しないことに注意。
ステップ4:検証とインスタンス化。ロード可能なプラグイン数が 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のドキュメント文字列がその理由を明確に説明している:エンドポイントプラグインは API Server に HTTP ルートを追加し、ネットワーク露出面を拡大するため、load_plugins_by_groupよりも厳格な「デフォルト拒否」姿勢を取る📎 vllm/plugins/__init__.py:93-94。
具体的なルールは:プラグイン名が明示的にVLLM_PLUGINSに含まれ、かつそのrequired_tasksがNoneであるか、サーバーがサポートする tasks と交差する場合にのみ、ロードされる📎 vllm/plugins/__init__.py:108-108。
シナリオ:ユーザーがエンドポイントプラグインをインストールしたがVLLM_PLUGINS。
の設定を忘れたステップ1:VLLM_PLUGINS が未設定かどうかを確認する。envs.VLLM_PLUGINS is Noneもし📎 vllm/plugins/__init__.py:126-126なら、まずそのグループ下のプラグインを検出し、あれば warning を記録して「明示的な allowlist が必要」と通知するVLLM_PLUGINS=""。ソースコードのコメントが特に指摘していることに注意:[""]はNoneではなく📎 vllm/plugins/__init__.py:108-108として解析されるため、「どのプラグインにもマッチしない allowlist」と見なされ、「未設定」とは見なされないNone。この境界の区別は重要である——空文字列は明示的な「何もロードしない」であり、
は「未設定」である。ステップ2:ロードとインスタンス化。load_plugins_by_groupを通じてfactory()ファクトリ関数を取得した後、順に📎 vllm/plugins/__init__.py:133-141を呼び出して
をインスタンス化する。インスタンス化の失敗は exception を記録して continue する。plugin.required_tasksステップ3:task ゲーティング。Noneをチェックし、supported_tasks交差がないため、このプラグインをスキップします📎 vllm/plugins/__init__.py:144-145。これにより、同じプラグインパッケージが異なるタスク(embedding vs generation など)に対して異なるエンドポイントを登録できます。
以下のシーケンス図は、エンドポイントプラグインの発見からロードまでの完全なインタラクションを描写しています:
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 is None
Loader->>EP: entry_points(group)
EP-->>Loader: discovered plugins
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設計上の考察:プロセス境界がロード戦略を決定する
3種類のプラグインのロード戦略の違いは、本質的にプロセス境界のマッピングです:
| プラグインタイプ | ロードプロセス | デフォルト動作 | 動機 |
|---|---|---|---|
| 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の上に構築されており、5つのグループ定数で拡張タイプを分類し、プロセス境界でロード範囲を決定し、VLLM_PLUGINSホワイトリストでロードセットを制御します。プラットフォームプラグインはPlatform基底クラスでハードウェアの差異を抽象化し、そのデバイス ID の3名前空間変換(logical/visible/physical)はプロセス間デバイス管理の核心です;IO processor プラグインは HF config のio_processor_pluginフィールドでトリガーされ、マルチモーダル入力の変換を担当します;エンドポイントプラグインは「デフォルト拒否」の姿勢をとり、明示的な allowlist がありかつ task が一致する場合にのみロードされ、ネットワーク露出面を制御します。
3つの主線は同じ発見メカニズムを共有していますが、ロード戦略の違いは 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)の場合の動作の違いは何か?ソースコードはなぜこの2つのケースを特意に区別しているのか?
参考解析:ソースコードのコメントは明確にVLLM_PLUGINS=""が[""]ではなくNoneとして解析されるため、「どのプラグインにもマッチしない allowlist」と見なされる📎 vllm/plugins/__init__.py:108-108と指摘しています。VLLM_PLUGINS is Noneの場合、load_endpoint_pluginsは直接[]を返し warning を記録します📎 vllm/plugins/__init__.py:126-126;一方VLLM_PLUGINS=""の場合、コードはload_plugins_by_groupまで進みますが、空文字列はどのプラグイン名にもマッチしないため、最終的にも空リストを返します。両者の結果は同じ(どちらもエンドポイントプラグインをロードしない)ですが、セマンティクスが異なります:Noneは「ユーザーが未設定、我々が能動的に拒否し警告する」を意味し、""は「ユーザーが明示的に空の allowlist を設定、我々はその意図を尊重し警告しない」を意味します。この区別により、運用担当者は空文字列を設定することで「すべてのエンドポイントプラグインをサイレントに無効化」でき、毎回の起動時の warning ノイズに耐える必要がありません。
Q3: device_id_to_physical_device_idにおいて、なぜソースコードは空のdevice_control_env_varを未設定として扱うのか📎 vllm/platforms/interface.py:302-308?この空文字列チェックを外すと、Ray の CPU-only placement group シナリオで何が起こるか?
参考解析:ソースコードのコメントは、空の環境変数が Ray が GPU ノード上で CPU-only placement group を起動する際の正当な設定であると説明しています📎 vllm/platforms/interface.py:296-297。もし!= ""チェックを外すと、コードはdevice_ids = "".split(",")ブランチに入り、[""]を得て、その後device_ids[device_id]が空文字列を返し、最終的にint("")がスローしますValueError。これにより、エンジンは正当な Ray 設定で起動に失敗します。チェックを保持したまま、空の環境変数はelse分岐へ進み直接device_idを返します。つまり、logical ID が physical ID と等しいと仮定します——これは CPU-only のシナリオでは安全です。GPU のマッピングが不要だからです。この事例が示すのは、環境変数の「未設定」と「空に設定」は分散オーケストレーションシステムにおいて意味が異なり、コードは明示的に処理しなければならないということです。
---
次章ではアーキテクチャのトレードオフ、本番環境での落とし穴、そして将来の進化へと移ります。これまでの13章で分解してきたメカニズムを一堂に集め、vLLM が性能、保守性、拡張性の間でどのような取捨選択を行っているかを検証し、推論エンジンの進化の方向性を展望します。
ここまでで、vLLM が entry_points のグループ化メカニズム、プロセス境界を意識したロードタイミング、そしてプラットフォーム、IO processor、エンドポイントという3種類のプラグインの差別化戦略を通じて、コアコードを安定に保ちながら拡張面を開いていることを見てきました。このプラグイン体系により、新しいハードウェア、新しい入力フォーマット、新しい API ルーティングがすべて非侵襲的に接続できますが、拡張性そのものがより多くの权衡すべき次元を意味します。次章では本書を締めくくり、vLLM の主要な設計判断における緊張関係——連続バッチ処理と VRAM 断片化、CUDA Graph と動的シェイプ、分離デプロイとネットワークオーバーヘッド——を体系的に整理し、本番環境での落とし穴リストと診断パスを提示するとともに、Rust フロントエンド、IR 層、異種ハードウェア方向への進化トレンドを展望します。
第14章:アーキテクチャのトレードオフ、本番環境での落とし穴、将来の進化
前章では vLLM のプラグイン化拡張メカニズムを分解し、プラットフォームプラグイン、IO processor プラグイン、エンドポイントプラグインがコアコードを変更せずにエンジンを新しいハードウェア、新しいモダリティ、新しい API に適応させる方法を見てきました。この拡張性により vLLM は変化に迅速に対応できますが、拡張ポイントが増えるほど、本番環境での相互作用パスは複雑になります。VRAM 断片化、NCCL ハンドシェイク失敗、コンパイルキャッシュ無効化、ネットワークジッターといった実際の問題が同時に発生すると、前13章で紹介したメカニズムが互いに引っ張り合い、理想環境では現れなかった緊張関係が露呈します。本章では新しいコアメカニズムを導入せず、これらのメカニズムを一堂に集め、公式 troubleshooting ドキュメントをアンカーとし、Rust フロントエンド bench ツールの設計と組み合わせて、性能と運用性の間の取捨選択を検証し、実行可能な診断パスを提示します。
一、最適化レベル:起動時間と実行性能の明示的契約
直感的モデル
最適化レベルはカメラの「シーンモード」のようなものです:オートモード(-O2)はほとんどのシーンに適していますが、素早くスナップ(デバッグ)したいときはマニュアルモード(-O0)に切り替えれば即座に応答します。代償は画質(性能)の低下です。vLLM はこのトレードオフを明示的な4段階の契約とし、数十のブールフラグに隠してユーザーに組み立てさせることはしていません。
4段階のフィールドレイアウト
vLLM は-O0から-O3までの4つのレベルを提供します📎 docs/design/optimization_levels.md:5-5。核心的な設計原則は:ユーザーが明示的に設定したフラグは最適化レベルのデフォルト値より優先される 📎 docs/design/optimization_levels.md:5-5。つまり、最適化レベルはデフォルト値の集合に過ぎず、ハードな制約ではありません。
-O0すべてを無効化:autotuning なし、コンパイルなし、cudagraph なし📎 docs/design/optimization_levels.md:32-33。具体的には4つのスイッチに落とし込まれます: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はデフォルト値で、本番向け📎 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ユーザーが
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 がないため、各 kernel の CPU 発行オーバーヘッドが露呈し、高並行シーンではスループットが数倍低下する可能性があります。もう一つの罠は。-O2コンパイルエラーFULL_AND_PIECEWISEです。-O2の cudagraph はモデル構造に対してより強い仮定を持ち、一部のカスタムモデルは-O1ではコンパイルに失敗しますがdebug_dump_pathでは正常です。ドキュメントは📎 docs/design/optimization_levels.md:88を使用してより多くのデバッグ情報を取得することを推奨しています-O0。調査パスは次のとおりです:まず-O1、-O2で機能が正しいことを確認し、段階的に
〔設計推論とアーキテクチャのトレードオフ〕--enforce-eager同じ方法論です:まず最も保守的な設定で正確性を確認し、その後段階的に最適化を有効にし、問題を最小の設定差分に切り分けます。
---
二、本番環境の落とし穴リスト:症状から根本原因への診断パス
直感モデル
本番環境の障害調査は救急トリアージのようなものです:すべての患者に全身検査を行うことはできず、まず症状(OOM、ハング、クラッシュ)に基づいて範囲を素早く絞り込み、その後的を絞って深掘りする必要があります。vLLM のトラブルシューティングドキュメントは本質的にトリアージマニュアルです。
症状の分類と診断ツール
ドキュメントでは一般的な問題をいくつかのカテゴリに分類しており、診断難易度の順に整理します。
第一類:モデルのダウンロード/ロードのハング。症状は起動後に長時間応答がないことです。根本原因は通常、ネットワークが遅いか共有ファイルシステムが遅いことです📎 docs/usage/troubleshooting.md:11-11。診断手段は--load-format dummy重みのロードをスキップし、ダウンロードが遅いのかロードが遅いのかを切り分けることです📎 docs/usage/troubleshooting.md:23-23。これは典型的な「二分法による切り分け」テクニックです。
第二類:VRAM の OOM。ドキュメントは直接 conserving_memory 設定ドキュメントを指しています📎 docs/usage/troubleshooting.md:23。しかし本番環境での OOM は多くの場合、モデルが大きすぎるのではなく、KV キャッシュの断片化や同時リクエスト数が想定を超えていることが原因です。
第三類:生成品質の変化。これは見落とされがちな落とし穴です。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:高頻度出力キューとキャッシュヒット状態NCCL_DEBUG=TRACE:どの CUDA カーネルで問題が発生しているかを特定VLLM_TRACE_FUNCTION=1:NCCL 詳細ログを有効化📎docs/usage/troubleshooting.md:41
:すべての関数呼び出しを記録するが、100 倍以上遅くなる📎 docs/usage/troubleshooting.md:11-11。
ここで重要な運用規律があります:デバッグ後は必ずこれらの環境変数を無効にするか、新しいシェルを開くことです。そうしないと残留したデバッグ設定がシステムを継続的に遅くします
ブレークポイントデバッグのプロセス境界の罠pdbvLLM のマルチプロセスアーキテクチャにより、通常のBdbQuit 📎 docs/usage/troubleshooting.md:45-54ブレークポイントが機能しなくなります——ブレークポイントが子プロセスで実行されるとforked-pdb 📎 docs/usage/troubleshooting.md:57-61がスローされます。2 つの解決策:VLLM_ENABLE_V1_MULTIPROCESSING=0を使用するか、📎 docs/usage/troubleshooting.md:63-68。
〔設計推論とアーキテクチャのトレードオフ〕
2 番目の方法は便利ですが、実行モデルが変わります——シングルプロセスモードでは EngineCore と API Server がキューを介して通信しなくなるため、一部の並行バグが再現できなくなる可能性があります。したがって、論理エラーの特定には適していますが、並行問題の再現には適していません。
分散通信の診断分散デプロイには専用の診断ドキュメントがあります。核心的な推奨事項は:クラスタ作成時に環境変数を設定する📎 docs/serving/distributed_troubleshooting.md:16-16。
。変数はすべてのノードに伝播するためです。シェルで設定するとローカルノードにのみ影響しますNo available node types can fulfill resource request頻繁に発生する問題は📎 docs/serving/distributed_troubleshooting.md:16-16です。クラスタに十分な GPU があってもVLLM_HOST_IPが発生します。根本原因は通常、ノードに複数の IP があり、vLLM が間違ったものを選択していることです。解決策はray statusで明示的に指定し、📎 docs/serving/distributed_troubleshooting.md:16-16。
で検証することです
NCCL 初期化失敗の診断スクリプト📎 docs/usage/troubleshooting.md:89-150ドキュメントでは完全な診断スクリプトが提供されており、通信スタックを層ごとに検証します
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["测试 CUDA Graph 内 all_reduce"]
graph_test --> graph_ok{"g.replay() 后正确?"}
graph_ok -->|否| graph_fail["CUDA Graph 捕获问题<br/>检查 stream 语义"]
graph_ok -->|是| success["sanity check 成功"]コピー📎 docs/usage/troubleshooting.md:90-146このスクリプトの巧妙な点は、層ごとに切り分けることです:まず最下層の PyTorch NCCL を検証し、次に CPU 側の GLOO を検証し、次に vLLM 自身の PyNcclCommunicator ラッパーを検証し、最後に CUDA Graph 内の通信を検証します
。各層の失敗は異なる根本原因を指し示します。pynccl.disabled = Falseスクリプトの中で注目すべき詳細:📎 docs/usage/troubleshooting.md:121-125は 0.6.4 以下との後方互換性のためです
。0.6.5+ ではデフォルトで有効ですが、この行を残すことで最新ドキュメントを読むユーザーが混乱しないようにしています。--rdzv_backend=staticマルチノードテスト時、ドキュメントでは意図的にc10dではなくc10dを使用しています。なぜなら📎 docs/usage/troubleshooting.md:168-168はマルチノード下で DNS 解決失敗により
になるからです。これは典型的な「経験しないとわからない」設定です。
設計上の考察と落とし穴(ncclCommInitRankNCCL 初期化失敗IPC_LOCKは unhandled system error を報告)は通常 2 つの根本原因を指します:/dev/shmcapability の欠如または📎 docs/usage/troubleshooting.md:311-311がマウントされていないこと
。これらはどちらもコンテナ化デプロイの典型的な罠です。(the provided PTX was compiled with an unsupported toolchainCUDA PTX ツールチェーンの不一致📎 docs/usage/troubleshooting.md:325-327)は、wheel 内の PTX がより高いバージョンの CUDA toolkit でコンパイルされていることを示します-e VLLM_ENABLE_CUDA_COMPATIBILITY=1 📎 docs/usage/troubleshooting.md:325-327。解決策は CUDA forward compatibility を有効にすることです:Docker ではcuda-compatを追加し、ベアメタルではVLLM_CUDA_COMPATIBILITY_PATH 📎 docs/usage/troubleshooting.md:325-327。
パッケージをインストールして:vLLM >= 0.4.3, <= 0.10.1.1を設定しますNCCL_CUMEM_ENABLE=0既知の NCCL メモリオーバーヘッド問題📎 docs/usage/troubleshooting.md:375は📎 docs/usage/troubleshooting.md:375を設定して NCCL バグを回避します。外部プロセスが vLLM に接続する際もこの変数を設定する必要があり、そうしないとハングまたはクラッシュします。NCCL 2.22.3 で修正された後、新しいバージョンではパフォーマンス最適化を可能にするためこのオーバーライドが削除されました。このケースが示すのは:
---
プロセス間の環境変数契約は分散システムの暗黙的な依存関係である
ということです。アップグレード時には同期する必要があります。
三、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 を直接サーバーに送信し、サーバー側の tokenization をスキップします📎 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、これはストリーミングレスポンスでよくあるケースです。
シナリオ駆動のリクエストフロー
ベンチマークリクエストが送信されると、データはどのように流れるのか?以下のデータフロー図は入力から出力への変換を示しています:
flowchart LR
input["RequestFuncInput<br/>Arc<str> 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_requestmatchを通じて具体的な実装にディスパッチします📎 rust/src/bench/src/backends/mod.rs:158-168。get_backendBackendKindに基づいて対応するバックエンドを返します📎 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 のブロックテーブルメカニズムはまさにこの高頻度割り当てに対応するためのものです——固定サイズのブロックは外部断片化を排除しますが、ブロックテーブルの間接アドレッシングオーバーヘッドと内部断片化(最後のブロックが埋まらない可能性)を導入します。これは典型的な「間接層で断片化率を交換する」トレードオフであり、OS の仮想メモリページングと同じ考え方です。
CUDA Graph vs 動的形状。CUDA Graph は静的形状を要求しますが、連続バッチ処理のバッチサイズは各ステップで変わります。vLLM の解決策はPIECEWISEとFULL_AND_PIECEWISEモードです📎 docs/design/optimization_levels.md:50,72——静的にできる部分をグラフとしてキャプチャし、動的部分は eager のままにします。-O0cudagraph を完全に無効にするのはデバッグ用で、-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共有と型付き逆シリアライズでクライアントオーバーヘッドをほぼゼロに抑え、ベンチマーク数値がサーバーのパフォーマンスを真に反映することを保証します。
三つの核心的なトレードオフの線が全書を貫いている:連続バッチ処理とVRAM断片化、CUDA Graphと動的形状、分離型デプロイとネットワークオーバーヘッド。これらの緊張関係を理解することは、いかなる単一のメカニズムを覚えるよりも重要である——なぜなら、本番環境でのあらゆるチューニングは、本質的にこれらの緊張関係の間でバランスポイントを見つけることだからである。
本章の考察とセルフチェック
Q1: もし-O2のFULL_AND_PIECEWISEcudagraphを-O1のPIECEWISEに変更した場合、どのようなシナリオで性能回退が発生するか?なぜか?
参考解説:-O2は-O1を基にFULL_AND_PIECEWISEcudagraphモード📎 docs/design/optimization_levels.md:72。FULLモードはフォワードパス全体を一つのグラフとしてキャプチャするが、PIECEWISEは静的にできる部分のみをキャプチャする。バッチ形状が安定した本番シナリオでは、FULLモードはより多くのkernel発射オーバーヘッドを排除でき、スループットが高い。しかし、モデルに動的制御フロー(MoEのtokenルーティングなど)が含まれる場合、FULLモードはキャプチャできないか、キャプチャ後に異常な動作をする可能性があり、その場合はPIECEWISEの方がむしろ安定している。性能回退が発生するのは:バッチサイズが頻繁に変化してFULLグラフがヒットしない場合、またはモデル構造がFULLモードのフォールバックパスをトリガーする場合である。調査方法は、まず-O1でベースラインを確認し、次に-O2に上げて比較し、VLLM_LOG_STATS_INTERVAL=1.でキューの状態を観察する📎 docs/usage/troubleshooting.md:41-41。
Q2: 診断スクリプトにおいて、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、vLLMのPyNcclCommunicatorはbootstrapとしてGLOO groupを必要とする📎 docs/usage/troubleshooting.md:120。GLOOテストをスキップすると、PyNcclの初期化が失敗した場合に、NCCL自体の問題なのかGLOO bootstrapの問題なのかを区別できない。GLOOはネットワークインターフェース設定(GLOO_SOCKET_IFNAME)📎 docs/usage/troubleshooting.md:81-81に依存し、複雑なネットワーク環境ではこれが高頻度の障害点となる。層ごとにテストする価値は、障害を最小の設定差異に隔離できることにある。
Q3: Rust benchツールがArc<str>でpromptを共有しているが、負荷テストシナリオで各リクエストが異なるpromptを送信する必要がある場合、この設計は無効になるか?なぜか?
参考解説:Arc<str>の設計目標は、複数の並行リクエストが同一の不変文字列📎 rust/src/bench/src/backends/mod.rs:50-52を共有することである。各リクエストのpromptが異なる場合、Arcの共有優位性は確かに消失する——各リクエストが自身のArc<str>を構築する必要がある。しかし設計は無効ではない:Arc<str>はStringと比較して、リクエストの流転過程における複数回のクローン(入力キューからbackendへ、さらにpayload構築へ渡すなど)を依然として回避している。真のゼロコピー最適化はprompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77にある——promptテキストが異なっても、事前計算されたtoken ID配列はArcを通じてリクエストライフサイクル内で共有でき、重複割り当てを回避できる。負荷テストツールの設計前提は「同一promptの高並行」または「事前計算token ID」であり、前者はArc<str>でテキストを共有し、後者はArc<[u32]>でtokenシーケンスを共有する。
---
ここに至り、全書十四章のソースコード解説は一区切りとなる。我々は一度のAPI呼び出しから出発し、スケジューラ、KV cacheマネージャ、アテンションバックエンド、分散通信層を経て、最終的にGPU kernelの発射点に到達し、そして本番運用の診断台に戻ってきた。vLLMのあらゆる設計決定の背後には明確なトレードオフがあり、これらのトレードオフを理解してこそ、新しいハードウェア、新しいモデル、新しい負荷に直面したときに正しいエンジニアリング判断ができる。推論エンジンの進化は止まらない——Rustフロントエンド、IR層、異種ハードウェアサポートが急速に進んでいる——しかし、底層のトレードオフの論理は安定しており、これこそが本書が伝えたい核心的な能力である。
ここに至り、我々はリクエストの入口からGPU Kernelまでの完全な旅を歩み終え、また本番環境においてシステムを「動く」から「安定して動く」に変えるトレードオフと落とし穴も明らかにした。vLLMの進化は現在のアーキテクチャで止まることはなく、より効率的なアテンション実装、よりスマートなスケジューリング戦略、よりシームレスな異種サポートが進行中である。しかし未来がどう変わろうとも、これらのメカニズム間の緊張関係と取捨選択を理解することが、常に推論エンジンを操る鍵である。
どんなに複雑なプロジェクトも、実は一冊の良い本があれば読める
この『vLLM ソースコード深度解説:リクエストからTokenへの高性能推論エンジン』は、AiReadCodeが公式オープンソースリポジトリをスキャンして全自動で編纂したものである。 数十万行の大規模オープンソースの名作であれ、企業内部の複雑なプロジェクトであれ、同じように筋道の通った専属の著書をワンクリックで生成できる。