20
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

vLLMの1年間の進化(v0.6.6→v0.13.0)をソースコードで追う

20
Last updated at Posted at 2025-12-23

はじめに

vLLM は、LLM の推論・サービングを高速に行うための OSS ライブラリです。開発が活発に進められており、継続的に性能改善を実現しています。

本記事では、v0.6.6(2024/12/27リリース)と v0.13.0(2025/12/19リリース)のソースコードを比較し、この約1年間で内部アーキテクチャがどのように変化したのかを追います。

目的は、「推論エンジンが内部でどう動くのか」「高速化のためにどんな工夫が入っているのか」を、実装から理解することです。
内部構造を押さえることで、性能チューニングやトラブルシューティングの際に、原因候補を効率よく絞り込めるようになることを目指します。

バージョン リリース日 リリースノートURL
v0.6.6 2024年12月27日 Release v0.6.6 · vllm-project/vllm · GitHub
v0.13.0 2025年12月19日 Release v0.13.0 · vllm-project/vllm · GitHub

1. vLLMとは?

はじめに vLLM の概要を押さえておきます。

項目 内容
概要 高速かつ使いやすいLLM推論・サービング用ライブラリ
開発元 UC Berkeley の Sky Computing Lab で開発開始。その後 community-driven として発展
初期公開(PyPI v0.1.0) 2023-06-20
実装 Python(主要)/ C++ / GPU向けカーネル(CUDA等)
GitHub Star数 約65,000(2025/12時点)※Hugging Face TGIは約10,700
ライセンス Apache 2.0

1-1. 主要コンポーネントの概要

vLLMの高速化を支える代表的な基盤技術として、ここでは2つを紹介します。これらは v0.6.6 と v0.13.0 のいずれでも中核に位置づけられていますが、後述する V1 エンジンへの移行に伴い、内部実装は大きく変化しています。

PagedAttention

OSの仮想メモリ管理(ページング)に着想を得たアルゴリズムです。従来の Attention では KV キャッシュが連続したメモリ領域を必要としていましたが、PagedAttention は次の仕組みでこの制約を緩和します。

  • KV キャッシュを固定サイズのブロック(ページ)に分割
  • 必要なブロックだけを動的に割り当て/解放
  • 断片化を抑え、GPU メモリの利用効率を高める

image.png

参考:https://docs.vllm.ai/en/latest/design/paged_attention/

Continuous Batching

Static Batching(従来手法)

  • バッチ内の全シーケンスが完了するまで、新規リクエストを受け付けない

image.png

Continuous Batching(vLLM)

  • 完了したシーケンスを即座にバッチから外し、空いたスロットへ新規リクエストを投入
  • イテレーション単位でバッチ構成を動的に更新し、GPU のアイドル時間を最小化

image.png

参考:https://huggingface.co/blog/continuous_batching

2. どれくらい変わったか

次に、定量的な変化を確認します。

指標 v0.6.6 v0.13.0 増加率
全ファイル数 1,564 3,627 2.3倍
vllm/配下のPython行数 169,788 432,195 2.5倍
計測コマンド
# 全ファイル数
$ find vllm(v0.6.6) -type f | wc -l
1564
$ find vllm(v0.13.0) -type f | wc -l
3627

# vllm/ 配下の Python 行数
$ find vllm(v0.6.6)/vllm -name "*.py" -exec cat {} + | wc -l
169788
$ find vllm(v0.13.0)/vllm -name "*.py" -exec cat {} + | wc -l
432195

約1年でコード量が 2倍以上 に増えています。では、何が変わったのでしょうか?

3. 何が変わったか

ここからは、ユーザーに近い API層 から エンジン内部 へ、外側から内側へと順に読み解いていきます。

3-1. 変化の全体像

vLLMプロジェクトが掲げるゴールは "Build the fastest and easiest-to-use open-source LLM inference & serving engine"(最速かつ最も使いやすいオープンソースLLM推論・サービングエンジンを構築する)です。

image.png
引用:2025-11-21 vLLM Bangkok Meetup PDF

v0.6.6 → v0.13.0 の変化を、この fastest / easiest-to-use の2軸で整理すると以下のようになります。

カテゴリ 主な変化 fastest easiest-to-use
エントリーポイントの多様化 Responses API / Anthropic Messages API / Audio API / Rerank API など計7種追加 -
V0 → V1 アーキテクチャへの完全移行 ZMQによるプロセス分離 / 統一スケジューラ / Chunked Prefill / Speculative Decoding -

3-2. エントリーポイントの多様化

vLLMは推論エンジンとしてだけでなく、APIサーバー機能を内蔵しています。v0.13.0では対応するAPIフォーマットが大幅に拡張され、OpenAI互換に加えてAnthropic互換やAudio APIなど7種類の新規APIが追加されました。

vLLM APIサーバーの起動方法

vLLMをインストール後、以下のコマンドでAPIサーバーを起動できます:

$ vllm serve openai/gpt-oss-120b

APIはOpenAI互換のため、既存のOpenAI SDKを使ったコードも base_url を変更するだけで動作します:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
response = client.chat.completions.create(
    model="openai/gpt-oss-120b",
    messages=[{"role": "user", "content": "Hello!"}]
)

3-2-1. APIエンドポイントの変化

v0.6.6で存在したAPI(11種):

  • /health, /version, /v1/models
  • /v1/chat/completions, /v1/completions
  • /v1/embeddings, /pooling, /score, /v1/score
  • /tokenize, /detokenize

v0.13.0までに新規追加されたAPI(7種):

API エンドポイント 概要
Responses API POST /v1/responses OpenAIの新しいResponses APIに対応。ステートフルな会話管理やTool呼び出しをサポート
Anthropic Messages API POST /v1/messages Claude互換のMessages APIを提供
Audio Transcription API POST /v1/audio/transcriptions 音声ファイルからテキストへの書き起こし(Whisper互換)
Audio Translation API POST /v1/audio/translations 音声ファイルの翻訳
Classification API POST /classify テキスト分類タスク用エンドポイント
Rerank API POST /rerank 検索結果のリランキング(/v1/rerank, /v2/rerank も提供)
Server Load API GET /load サーバー負荷メトリクスの取得(リクエスト数・キュー長等)

3-2-2. ディレクトリ構造の変化

vllm/entrypoints/ ディレクトリの構造を比較します。

v0.6.6

entrypoints/
├── api_server.py
├── chat_utils.py
├── launcher.py
├── llm.py
├── logger.py
├── utils.py
└── openai/
    ├── api_server.py
    ├── cli_args.py
    ├── protocol.py          # 1,336行
    ├── serving_chat.py
    ├── serving_completion.py
    ├── serving_embedding.py
    ├── serving_pooling.py
    ├── serving_score.py
    ├── serving_tokenization.py
    └── tool_parsers/

v0.13.0

entrypoints/
├── api_server.py
├── chat_utils.py
├── launcher.py
├── llm.py
├── logger.py
├── utils.py
├── anthropic/              # 🆕 Anthropic互換
│   ├── protocol.py
│   └── serving_messages.py
├── cli/                    # 🆕 CLIツール群
├── openai/
│   ├── api_server.py
│   ├── protocol.py          # 2,512行(1.9倍)
│   ├── serving_chat.py
│   ├── serving_completion.py
│   ├── serving_responses.py # 🆕 Responses API
│   ├── serving_transcription.py  # 🆕 Audio API
│   └── ...
├── pooling/                # 🆕 Pooling系の分離
│   ├── classify/
│   ├── embed/
│   ├── pooling/
│   └── score/
├── sagemaker/              # 🆕 AWS SageMaker対応
└── serve/                  # 🆕 サービング機能群
    ├── cache/
    ├── disagg/
    ├── elastic_ep/
    ├── instrumentator/      # metrics, health, server_info
    ├── lora/
    ├── profile/
    ├── rlhf/
    ├── rpc/
    ├── sleep/
    └── tokenize/

3-2-3. 各APIの詳細

新規追加された7種の推論APIのうち、特に実装規模が大きく設計上重要なものを解説します。Classification APIはRerank APIと同様のPooling系アーキテクチャ、Server Load APIは
単純なメトリクス返却エンドポイントのため詳細は割愛します。

また、推論APIとは別に新設された運用機能群serve/ ディレクトリ)についても紹介します。

Responses API:OpenAI最新フォーマットへの追従

OpenAIが2025年3月に発表したResponses APIは、従来のChat Completions APIを拡張し、よりリッチな会話管理を可能にします。

v0.13.0では serving_responses.py(2,074行)として実装されています。

# vllm(v0.13.0)/vllm/entrypoints/openai/protocol.py より抜粋

class ResponsesRequest(OpenAIBaseModel):
    # OpenAI公式APIドキュメントに準拠
    # https://platform.openai.com/docs/api-reference/responses/create
    background: bool | None = False
    input: str | list[ResponseInputOutputItem]
    instructions: str | None = None
    max_output_tokens: int | None = None
    reasoning: Reasoning | None = None  # 推論モデル対応
    tools: list[Tool] = Field(default_factory=list)
    # ... 他多数のパラメータ

Anthropic Messages API:Claude互換レイヤー

anthropic/ ディレクトリとして、Claude APIとの互換レイヤーが追加されました。
これにより、Claudeを利用していたアプリケーションをvLLMに移行する際のコード変更を最小限に抑えられます。

内部的にはOpenAIServingChatを継承し、Anthropic形式のリクエストをOpenAI形式に変換してから処理しています。

# vllm(v0.13.0)/vllm/entrypoints/anthropic/serving_messages.py

class AnthropicServingMessages(OpenAIServingChat):
    """Handler for Anthropic Messages API requests"""

    def _convert_anthropic_to_openai_request(
        self, anthropic_request: AnthropicMessagesRequest
    ) -> ChatCompletionRequest:
        """Convert Anthropic message format to OpenAI format"""
        # Anthropic形式 → OpenAI形式への変換ロジック
        ...

Audio API:マルチモーダル対応の拡張

音声モデル(Whisper等)に対応するため、OpenAI互換のAudio APIが追加されました。

# vllm(v0.13.0)/vllm/entrypoints/openai/protocol.py

class TranscriptionRequest(OpenAIBaseModel):
    # OpenAI公式APIドキュメントに準拠
    # https://platform.openai.com/docs/api-reference/audio/createTranscription
    
    file: UploadFile
    """対応フォーマット: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm"""
    
    model: str | None = None
    language: str | None = None  # ISO-639-1形式
    response_format: AudioResponseFormat = "json"
    stream: bool | None = False  # ストリーミング対応
    temperature: float = 0.0

ストリーミング対応(stream: true)により、長い音声ファイルでも段階的に結果を取得できます。

Rerank API:RAG向けリランキング

検索拡張生成(RAG)で重要なリランキング機能が、JinaAI互換のAPIとして追加されました。

# vllm(v0.13.0)/vllm/entrypoints/pooling/score/protocol.py

class RerankRequest(OpenAIBaseModel):
    model: str | None = None
    query: str | ScoreMultiModalParam
    documents: list[str] | ScoreMultiModalParam
    top_n: int = Field(default_factory=lambda: 0)
    truncate_prompt_tokens: Annotated[int, Field(ge=-1)] | None = None

複数のエンドポイント(/rerank, /v1/rerank, /v2/rerank)が提供されており、JinaAI Rerank APIとの互換性を持ちます。
これにより、既存のRAGパイプラインをvLLMに移行しやすくなっています。

serve/ ディレクトリ:運用機能の集約

v0.13.0では、サーバー運用に関する機能が serve/ ディレクトリに集約されました。

サブディレクトリ 機能
cache/ KVキャッシュの管理API
disagg/ Disaggregated Prefill用のトークン転送API
elastic_ep/ Expert Parallelismのスケーリング
instrumentator/ Prometheusメトリクス(/metrics)・ヘルスチェック(/health)・サーバー情報(/server_info
lora/ LoRAアダプターの動的ロード/アンロード
profile/ プロファイリング開始/停止
rlhf/ RLHFワークフロー用API
rpc/ RPC通信用エンドポイント
sleep/ サーバーのスリープ/ウェイク制御
tokenize/ トークン化・デトークン化API

これらのAPIにより、vLLMサーバーを外部から細かく制御できるようになりました。

3-2-4. APIサーバーのアーキテクチャ概要

これらの多様なAPIを支えるv0.13.0のサーバーアーキテクチャを紹介します。vLLMはFastAPIフレームワークを採用しており、以下の構造でリクエストを処理します。

APIサーバーのアーキテクチャ.png

層の責務まとめ

主要ファイル 責務
Uvicorn (外部ライブラリ) HTTP受信、ASGIプロトコル変換
FastAPI Layer api_server.py 認証、ルーティング
Serving Layer serving_chat.py, serving_responses.py APIフォーマット変換、パラメータ検証
EngineClient Protocol engine/protocol.py エンジンへの抽象インターフェース
V1 Engine v1/engine/ 推論処理の実行(次セクションで詳述)

Uvicorn(ASGIサーバー)

HTTPリクエストを受け付け、ASGI仕様に従ってFastAPIアプリケーションにリクエストを渡します。

ASGI(Asynchronous Server Gateway Interface)はPythonの非同期Webアプリケーション標準インターフェースで、ストリーミングレスポンスや高い同時接続数の処理に適しています。

参考:https://asgi.readthedocs.io/en/latest/

# entrypoints/launcher.py より(v0.13.0、簡略化)
async def serve_http(app: FastAPI, sock: socket.socket | None, **uvicorn_kwargs):
    # Uvicorn設定オブジェクトを作成(host, port, ssl設定等を含む)
    config = uvicorn.Config(app, **uvicorn_kwargs)
    config.load()  # 設定を検証し、SSLコンテキスト等を初期化
    server = uvicorn.Server(config)  # HTTPサーバーインスタンスを作成
    
    # 非同期タスクとしてサーバーを起動(バックグラウンドで動作)
    loop = asyncio.get_running_loop()
    server_task = loop.create_task(server.serve(sockets=[sock] if sock else None))
    # ...

① Middleware Stack(リクエスト前処理)

HTTPリクエスト/レスポンスの前後に介入する「フィルター」層です。

# vllm(v0.13.0)/vllm/entrypoints/openai/api_server.py

# CORS設定(ブラウザからのクロスオリジンリクエストを許可)
app.add_middleware(
    CORSMiddleware,
    allow_origins=args.allowed_origins,
    allow_credentials=args.allow_credentials,
    allow_methods=args.allowed_methods,
    allow_headers=args.allowed_headers,
)
# ...(例外ハンドラ定義)...

# API Key認証(CLIオプションまたは環境変数で指定時のみ有効)
if tokens := [key for key in (args.api_key or [envs.VLLM_API_KEY]) if key]:
    app.add_middleware(AuthenticationMiddleware, tokens=tokens)

# リクエストID付与(トレーシング用、オプション)
if args.enable_request_id_headers:
    app.add_middleware(XRequestIdMiddleware)

# スケーリング中は503を返却(常時有効)
app.add_middleware(ScalingMiddleware)
Middleware 役割 有効条件
ScalingMiddleware スケーリング中の503応答 常時
XRequestIdMiddleware X-Request-Idヘッダ付与 --enable-request-id-headers指定時
AuthenticationMiddleware Bearer Token認証 --api-key指定時 or VLLM_API_KEY環境変数設定時
CORSMiddleware Cross-Origin Resource Sharing設定 常時

② API Routers(ルーティング)

URLパスとハンドラ関数を対応付け、関連するエンドポイントをグループ化して管理します。各ハンドラは app.state を通じてServing層のインスタンスを取得し、処理を委譲します。

# vllm(v0.13.0)/vllm/entrypoints/openai/api_server.py より

router = APIRouter()  # エンドポイントをグループ化

# app.stateからServing層のインスタンスを取得するヘルパー
def chat(request: Request) -> OpenAIServingChat | None:
    return request.app.state.openai_serving_chat

@router.post("/v1/chat/completions", ...)
@with_cancellation  # クライアント切断時に推論をキャンセル
@load_aware_call    # 負荷メトリクス収集・過負荷時の制御
async def create_chat_completion(
    request: ChatCompletionRequest,  # JSONボディ(FastAPIが自動パース)
    raw_request: Request             # app.stateアクセス用
):
    handler = chat(raw_request)
    if handler is None:
        return base(raw_request).create_error_response(...)
    
    # Serving層に委譲 → ストリーミング or 単一レスポンスを返却
    generator = await handler.create_chat_completion(request, raw_request)
    # ... レスポンス型に応じた処理
Router エンドポイント例
Main Router /v1/chat/completions, /v1/completions, /v1/responses
Serve Routers /lora, /cache, /profile, /health, /metrics
Pooling Routers /classify, /rerank, /v1/embeddings

EngineClient:Serving層とエンジンの境界

EngineClient は、Serving層がエンジン内部の実装詳細を知らずに推論処理を呼び出すための 抽象基底クラス(ABC) です。

# vllm(v0.13.0)/vllm/engine/protocol.py より(抜粋)

class EngineClient(ABC):
    """Protocol class for Clients to Engine"""

    @abstractmethod
    def generate(
        self,
        prompt: EngineCoreRequest | PromptType,
        sampling_params: SamplingParams,
        request_id: str,
        *,
        lora_request: LoRARequest | None = None,
        priority: int = 0,
        # ... 他のパラメータは省略
    ) -> AsyncGenerator[RequestOutput, None]:
        """Generate outputs for a request."""
        ...

    @abstractmethod
    def encode(
        self,
        prompt: PromptType,
        pooling_params: PoolingParams,
        request_id: str,
        # ... 他のパラメータは省略
    ) -> AsyncGenerator[PoolingRequestOutput, None]:
        """Generate outputs for a request from a pooling model."""
        ...

    @abstractmethod
    async def abort(self, request_id: str | Iterable[str]) -> None:
        """Abort a request."""
        ...
    
    # 他にも check_health(), add_lora(), sleep(), wake_up() 等を定義

主要なメソッドは以下の通りです:

メソッド 用途
generate() テキスト生成(Chat/Completion API用)
encode() 埋め込み生成(Embedding/Pooling API用)
abort() リクエストの中断
check_health() ヘルスチェック
add_lora() LoRAアダプターの動的追加
sleep() / wake_up() サーバーのスリープ制御

この抽象化のメリット:
Serving層(OpenAIServingChat, AnthropicServingMessages 等)は、エンジンの実装詳細ではなく EngineClient のみに依存しています。そのため、新しいAPIフォーマットを追加する際は、基本的に次の2点で対応できます。

  1. Serving層に新しいクラスを追加(例:AnthropicServingMessages
  2. リクエストを generate()encode() に渡す形式に変換

この2点だけで対応できるため、エンジン内部を変更する必要がありません。この境界の明確化により、v0.13.0では多様なAPIを効率的に追加できています。

エンジン内部の詳細については、次の「V0 → V1 アーキテクチャへの完全移行」セクションで解説します。

3-3. V0 → V1 アーキテクチャへの完全移行

前セクションでは、Serving層が EngineClient を介してエンジンと通信することを確認しました。
本セクションでは、その先にあるエンジン内部の構造変化を読み解きます。

本記事で比較している2つのバージョンは、それぞれ異なるアーキテクチャのエンジンで動作しています:

バージョン アーキテクチャ 備考
v0.6.6 V0(旧アーキテクチャ) VLLM_USE_V1=1 でV1を試験的に利用可能
v0.13.0 V1(新アーキテクチャ) V0コードは完全に削除済み

最も象徴的な変化は async_llm_engine.py の行数です。v0.6.6(V0)では1,265行あったこのファイルが、v0.13.0(V1)ではわずか6行(実質的にはエイリアス定義1行)になりました。

ただし、これは「コードが減った」わけではありません。V1全体で見ると v1/engine/ ディレクトリだけで約7,900行あり、責務が分離・再配置されたことを示しています。つまり、この変化は単なるリファクタリングではなく、V1アーキテクチャへの完全移行を意味しています。

では、V0からV1で何がどう変わったのでしょうか?

3-3-1. V0 vs V1 比較表

V0からV1への移行で最も重要な変化は、プロセス分離によるGIL競合回避と、統一スケジューラによる設計のシンプル化です。これにより、スループットの大幅な向上とコードの保守性向上が実現されています。

観点 V0(v0.6.6) V1(v0.13.0)
エンジン構造 単一プロセス(asyncio協調) マルチプロセス(ZMQで推論コアを分離し、CPU側の影響を隔離)
スケジューラ Prefill/Decode分離、キュー3つ 統一スケジューラ(キュー2つ、Recompute中心)
Chunked Prefill 条件付きで有効(デフォルト無効) デフォルト有効
Speculative Decoding 専用Worker(SpecDecodeWorker ModelRunnerに統合

スループット比較(公式ベンチマーク)

PyTorch公式ブログのベンチマーク例では、代表的なハイブリッドモデルにおいて、vLLM V1がV0を上回るスループットを示しています(図1)。
※数値はモデル/ハードウェア/設定に依存します。ここでは「V1移行で改善し得る」ことの根拠として参照します。

V0→V1 Throughput

Figure 1:代表的なハイブリッドモデル(granite-4.0-h-tiny:総パラメータ7B、うちアクティブ1B)における、vLLM V0→V1移行時のスループット改善

出典:https://pytorch.org/blog/hybrid-models-as-first-class-citizens-in-vllm/

3-3-2. V1エンジンの全体アーキテクチャ

V1全体アーキテクチャ図.png

3-3-3. エンジン構造の変化(プロセス分離)

V0からV1への最も根本的な変化は、エンジンのプロセス分離です。これは、API入出力や前後処理と推論コアをプロセス境界で分離し、CPU側の実行系(GILを含む)による影響を推論ループから切り離しやすくするための設計変更です。

GIL(Global Interpreter Lock)とは?

CPythonの排他ロック(mutex)で、同時に1つのスレッドしかPythonバイトコードを実行できないよう制限します。これはCPythonのメモリ管理がスレッドセーフでないために必要な仕組みです。

I/OやNumPy演算はGILを解放して実行されますが、トークナイズやシリアライズなどのPythonコードはGILを保持したまま実行されます。単一プロセス内でこれらの処理と推論ループが混在すると、GILの奪い合いがボトルネックになり得ます。プロセスを分離すれば、各プロセスが独自のGILを持つためこの競合を回避できます。

参考:GlobalInterpreterLock - Python Wiki

V0の特徴:単一プロセスでの協調実行(CPU側負荷の影響を受けやすい)

V0(v0.6.6)では、AsyncLLMEngineが単一プロセス内でasyncio協調により動作していました:

# vllm(v0.6.6)/vllm/engine/async_llm_engine.py(1,265行)
class AsyncLLMEngine(EngineClient):
    def __init__(self, ...):
        self.engine = self._engine_class(*args, **kwargs)  # LLMEngineを内包
        
        # 同一プロセス内でバックグラウンドループを起動
        if start_engine_loop:
            self.start_background_loop()

    async def run_engine_loop(self):
        # 同一プロセス内でリクエスト処理とモデル実行を協調
        while True:
            new_requests, aborted_requests = self._request_tracker.get_new_and_aborted_requests()
            # スケジューリングと推論実行
            await self.engine.step_async(...)  # 同一プロセス内

この構造では、APIサーバー(リクエスト受信・トークン送信)とエンジンコア(推論実行)が同一プロセスで動作する構成になりやすく、CPU側の処理(I/O、シリアライズ、トークナイズ/デトークナイズ等)の負荷が推論ループの進行に影響しやすい構造になります。

V1の解決策:ZMQによるプロセス分離

ZMQ(ZeroMQ)とは?

軽量・高速なメッセージングライブラリで、ソケットAPIを抽象化してプロセス間通信(IPC)やTCP通信を簡潔に実装できます。vLLMではフロントエンド(APIサーバー)とバックエンド(推論エンジン)間の非同期メッセージパッシングに使用されています。

参考:ZeroMQ

V1(v0.13.0)では、エンジンをフロントエンド(AsyncLLM)とバックエンド(EngineCore)に分離しました。

ここでいうフロントエンドは、EngineCoreClient を保持してEngineCoreと通信するプロセスで、APIサーバーの場合もあれば、マルチサーバー起動用のプロセスの場合もあります。

ZMQによるプロセス分離.png

V1の主要コンポーネント

1. AsyncLLM(フロントエンド)

async_llm.pyに実装されたAsyncLLMクラスは、EngineClientプロトコルを実装するフロントエンドです:

# vllm(v0.13.0)/vllm/v1/engine/async_llm.py
class AsyncLLM(EngineClient):
    def __init__(self, vllm_config, executor_class, ...):
        # 入力処理:プロンプトのトークナイズ
        self.input_processor = InputProcessor(self.vllm_config, tokenizer)
        
        # 出力処理:トークンのデトークナイズとストリーム管理
        self.output_processor = OutputProcessor(self.tokenizer, ...)
        
        # バックエンドプロセスへの接続(ZMQ経由)
        self.engine_core = EngineCoreClient.make_async_mp_client(
            vllm_config=vllm_config,
            executor_class=executor_class,
            ...
        )

2. EngineCoreClient(プロセス間通信)

core_client.pyには、用途に応じた複数のクライアント実装があります:

クラス 用途 特徴
InprocClient V0互換・デバッグ用 同一プロセス内でEngineCore直接呼び出し
SyncMPClient 同期API (LLM) ZMQ + 別プロセス(同期I/O)
AsyncMPClient 非同期API (AsyncLLM) ZMQ + 別プロセス(非同期I/O)
# vllm(v0.13.0)/vllm/v1/engine/core_client.py
class AsyncMPClient(MPClient):
    """Asyncio-compatible client for multi-proc EngineCore."""
    
    async def add_request_async(self, request: EngineCoreRequest) -> None:
        # ZMQ経由でリクエストを送信
        await self._send_input(EngineCoreRequestType.ADD, request)
    
    async def get_output_async(self) -> EngineCoreOutputs:
        # ZMQソケットから出力を非同期受信
        outputs = await self.outputs_queue.get()
        return outputs

3. EngineCoreProc(バックエンドプロセス)

core.pyEngineCoreProcが、別プロセスで推論ループを実行します:

# vllm(v0.13.0)/vllm/v1/engine/core.py
class EngineCoreProc(EngineCore):
    """EngineCore running in a separate process."""
    
    def run_busy_loop(self):
        """Core busy loop of the EngineCore."""
        while True:
            # 1) 入力キューからリクエストを取得
            self._process_input_queue()
            # 2) スケジューリング → モデル実行 → 出力送信
            self._process_engine_step()
    
    def _process_engine_step(self) -> bool:
        # スケジューラで次のバッチを決定
        outputs, model_executed = self.step_fn()
        # 結果を出力キューに積み、I/OスレッドがZMQ経由で送信
        for output in outputs.items():
            self.output_queue.put_nowait(output)

プロセス分離でCPU側の影響を隔離する仕組み

V1がCPU側の負荷(GILを含む)の影響を受けにくくする主因は、フロントエンド側の入出力処理と、推論コア(スケジューリング〜GPU実行)をプロセス境界で分離したことです。加えて、I/Oやシリアライズを専用スレッド/ライブラリに委譲することで、推論ループと独立して進めやすくしています(実装上も、受信側スレッドでデシリアライズやADD要求の前処理まで行い、busy loopへキュー投入しています):

# EngineCoreProc内のI/Oスレッド(daemon=True)
input_thread = threading.Thread(
    target=self.process_input_sockets,
    args=(addresses.inputs, ...),
    daemon=True,
)
input_thread.start()

self.output_thread = threading.Thread(
    target=self.process_output_sockets,
    args=(addresses.outputs, ...),
    daemon=True,
)
self.output_thread.start()

パフォーマンス上のメリット

プロセス分離により、以下の並行処理を行いやすくなりました:

  1. APIサーバー側(フロントエンドプロセス)

    • HTTP/gRPC リクエスト受信
    • トークナイズ / デトークナイズ
    • ストリーミングレスポンス送信
  2. EngineCore側(バックエンドプロセス)

    • スケジューリング
    • GPU上でのモデル実行
    • KVキャッシュ管理

V0では上記すべてが同一プロセス内で実行されますが、V1ではこれらをプロセス境界で分離するため、CPU側の処理負荷が推論コアの進行に与える影響を抑えやすくなり、結果としてGPUを安定して活用しやすい設計になっています。

3-3-4. 統一スケジューラ

V1では、スケジューラの設計が根本から刷新され、Prefill/Decodeという「フェーズ」を前提にせず、単一のスケジューリング処理で統一的に扱う方式に変更されました。

ここでいう「統一」とは、Prefill/Decodeを同一の「未計算トークンをどれだけ前進させるか」というスケジューリング問題として扱うという意味です(実行カーネルや最適化までが完全に同一になる、という意味ではありません)。

以下、この節で参照する主要カウンタの定義を整理します:

  • num_computed_tokens:そのRequestでこれまでに「計算が進んだ」トークン数(通常はステップごとに増える進捗。Prefix Caching等で初期状態から進む場合もある)
  • num_tokens_with_spec:スケジューラが「追いつくべき」目標トークン数。実装上は len(all_token_ids) + len(spec_token_ids)all_token_ids は prompt + 生成済み output)
  • num_output_placeholders:非同期スケジューリング等のためのプレースホルダ数(上の2つとは別カウンタとして扱われる)

実装上は「Prefill」「Decode」を別々にスケジュールするのではなく、各Requestが持つ

  • num_computed_tokens(これまでに計算済みのトークン数)
  • num_tokens_with_spec(prompt + 生成済みoutput + 投機トークンを含めた“追いつくべき”トークン数。実装上は len(all_token_ids) + len(spec_token_ids)

の差分を、反復ごとのトークン予算(max_num_batched_tokens)の範囲でどれだけ前進させるか、という形に一般化されています。

V0のスケジューラ:3つのキューと複雑なプリエンプション

V0(v0.6.6)のスケジューラは、リクエストを3つのキューで管理していました:

# vllm(v0.6.6)/vllm/core/scheduler.py
class Scheduler:
    def __init__(self, ...):
        # 3つのキューでリクエストを管理
        self.waiting: Deque[SequenceGroup] = deque()  # 新規/再計算待ち
        self.running: Deque[SequenceGroup] = deque()  # 実行中(Decode)
        self.swapped: Deque[SequenceGroup] = deque()  # CPUにスワップアウト済み

スケジューリングも3段階に分かれていました:

  1. _schedule_running: 実行中のDecodeリクエストをスケジュール
  2. _schedule_swapped: スワップアウトされたリクエストをスワップイン
  3. _schedule_prefills: 新規Prefillリクエストをスケジュール

そして、KVキャッシュが不足した場合のプリエンプションには2つの方式がありました:

# vllm(v0.6.6)/vllm/core/scheduler.py
class PreemptionMode(enum.Enum):
    SWAP = enum.auto()      # KVキャッシュをCPUメモリにスワップアウト
    RECOMPUTE = enum.auto() # KVキャッシュを破棄し、後で再計算

def _preempt(self, seq_group, blocks_to_swap_out) -> PreemptionMode:
    # 単一シーケンス → Recompute
    # 複数シーケンス(beam search等) → Swap
    if seq_group.get_max_num_running_seqs() == 1:
        preemption_mode = PreemptionMode.RECOMPUTE
    else:
        preemption_mode = PreemptionMode.SWAP
    ...

この設計は柔軟性がある反面、以下の複雑さがありました:

  • SequenceGroupという複数シーケンスを束ねる抽象化が必要(beam search対応のため)
  • Swap操作のためにCPU-GPU間のメモリ転送コードが必要
  • スケジューリング処理が分散し、コードの見通しが悪い

V1のスケジューラ:シンプルな統一設計

V1(v0.13.0)のスケジューラは、大幅にシンプル化されました:

# vllm(v0.13.0)/vllm/v1/core/sched/scheduler.py
class Scheduler(SchedulerInterface):
    def __init__(self, ...):
        # 2つのキューのみ(swappedキュー廃止)
        self.waiting = create_request_queue(self.policy)  # 待機中
        self.running: list[Request] = []                   # 実行中
        
        # Requestは単一リクエスト(SequenceGroup廃止)
        self.requests: dict[str, Request] = {}

主な変更点:

観点 V0 V1
管理単位 SequenceGroup(複数Sequence) Request(単一リクエスト)
キュー数 3(waiting/running/swapped) 2(waiting/running)
Prefill/Decode 別メソッドでスケジュール 統一的にスケジュール
プリエンプション Swap / Recompute swappedキュー廃止(少なくともV0のSWAPキュー前提の経路は単純化。状況によりKV転送等は別途存在)

統一スケジューリングの流れ

V1のスケジュール処理は、単一のschedule()メソッドで完結します:

# vllm(v0.13.0)/vllm/v1/core/sched/scheduler.py(簡略化)
def schedule(self) -> SchedulerOutput:
    token_budget = self.max_num_scheduled_tokens

    # V1では「prefill phase」「decoding phase」を分けず、
    # 各Requestの (num_tokens_with_spec - num_computed_tokens) を
    # トークン予算の範囲で前進させるように割り当てる。
    
    # 1) RUNNINGリクエスト(前ステップから継続中)を前進させる
    for request in self.running:
        if token_budget <= 0:
            break
        remaining = request.num_tokens_with_spec - request.num_computed_tokens
        if remaining <= 0:
            continue
        num_tokens = min(remaining, token_budget)
        token_budget -= num_tokens
        scheduled_running_reqs.append(request)

    # 2) WAITINGリクエスト(新規/再開)を前進させる
    while self.waiting and token_budget > 0:
        request = self.waiting.peek_request()
        remaining = request.num_tokens_with_spec - request.num_computed_tokens
        num_tokens = min(remaining, token_budget)

        # Chunked Prefillは「残り予算で前進させる」ことの自然な帰結として現れる
        # (実際にはKV割当、各種上限、Spec Decode等の条件を加味して決まる)
        new_blocks = self.kv_cache_manager.allocate_slots(request, num_tokens, ...)
        if new_blocks is None:
            break  # メモリ不足なら終了

        token_budget -= num_tokens
        scheduled_new_reqs.append(request)

    # ※補足:ここでは概念を掴むために単純化しています。
    # 実装では num_output_placeholders、long_prefill_token_threshold による上限、max_model_len クランプ、
    # max_num_running_reqs、KV割当失敗時のプリエンプト等も考慮して最終的な割当が決まります。

この設計のメリット:

  1. コードがシンプル:1つのメソッドで「未計算トークンを前進させる」問題に統一
  2. Chunked Prefillが自然に実現:長いプロンプトもトークン予算に合わせて分割される
  3. レイテンシが安定しやすい:長いPrefillがトークン予算を占有し続ける状況を避けやすく、短いDecodeが詰まりにくい
  4. プリエンプションが単純:V0のSWAPキュー前提の経路が減り、経路が単純化

3-3-5. Chunked Prefillのデフォルト有効化

Chunked Prefill
長い入力プロンプトの Prefill(入力トークンの処理) を一度に実行せず、小さなチャンク(塊)に分割して、Decode(生成)リクエストと同じバッチ/スケジューリングループ内で混在させながら段階的に進める方式です。vLLM では Decode を優先し、残りのトークン予算(例:max_num_batched_tokens)に収まる範囲で Prefill を進め、収まらない場合は自動的にチャンク化します。これにより、長い Prefill が他リクエストの生成を長時間ブロックしにくくなり、ITL(トークン間レイテンシ)やレイテンシのばらつきの改善、および Prefill(計算律速)と Decode(メモリ律速)を同居させることで GPU 利用率向上が期待できます。

参考:https://docs.vllm.ai/en/stable/configuration/optimization/#chunked-prefill

V1ではenable_chunked_prefillがデフォルトでTrueになりました:

# vllm(v0.13.0)/vllm/config/scheduler.py
class SchedulerConfig:
    enable_chunked_prefill: bool = True  # V1ではデフォルト有効

この設定により、長いプロンプトがある場合でも他リクエストの進行を阻害しにくくなり、レイテンシが安定しやすくなります(実際の効果はモデル/負荷/設定に依存します)。

この効果が「どの状況で効くか/効きにくいか」を考えるには、KVキャッシュが 容量帯域 のどちらで詰まっているかを切り分けるのが近道です。

補足:KVキャッシュは「容量」と「帯域」の2種類のボトルネックになり得る

V1のRecompute中心設計やChunked Prefillの効果を理解するには、KVキャッシュが「容量」と「帯域」の両面でボトルネックになり得ることを押さえるのが重要です。PagedAttentionは「KVキャッシュをページ化して断片化を抑える」設計ですが、それだけでKVキャッシュ起因の性能課題が消えるわけではありません

  • 容量ボトルネック(KVキャッシュが足りない)

    • max_model_len が大きい、同時実行リクエストが多い、長文のリクエストが多い、などでGPU上のKVキャッシュ使用率が上がると、空きブロック不足が発生します。
    • このときエンジンは「一部を捨てて後で再計算(Recompute)する」か、「CPU等に退避して戻す(Offload/Swap)」といった形で進行を維持しますが、いずれも追加のオーバーヘッドになります。
  • 帯域ボトルネック(KVキャッシュを読むのが遅い)

    • 特に生成(Decode)では、過去トークン分のK/VをKVキャッシュから参照するため、コンテキスト長に比例して読み出し量が増えます
    • パラメータ数が少ないモデルや、長コンテキスト・大バッチサイズの条件では、GPUの演算能力よりもメモリ帯域がボトルネックになりやすくなります。

v0.13.0(V1)では、このあたりの当たりを付けるための 観測ポイント(Prometheusメトリクス) が整備されています。代表例は以下です。

  • vllm:kv_cache_usage_perc:GPU KVキャッシュ使用率(1.0=100%)
  • vllm:kv_block_lifetime_seconds / vllm:kv_block_idle_before_evict_seconds:KVブロックの滞在時間・アイドル時間(サンプル計測)
  • vllm:prefix_cache_queries / vllm:prefix_cache_hits:Prefix Cachingのクエリ/ヒット(トークン単位)

参考:https://docs.vllm.ai/en/stable/design/metrics/

次は、デコードを高速化するために統合された Speculative Decoding を見ていきます。

3-3-6. Speculative Decodingの統合

Speculative Decoding(投機的デコード)は、小型のドラフトモデルに先読みで複数トークンを提案させ、大型のターゲットモデルがそれらをまとめて検証・採択することで、1トークンずつ逐次生成する場合の待ち時間(レイテンシ)を減らし、生成を高速化する推論最適化です。

vLLMのV1では、この処理を統合スケジューラのリクエスト管理と ModelRunner(実行系) に組み込み、通常の推論パイプライン上で扱えるようにしたため、専用コンポーネントに分岐させる必要が減り、全体のアーキテクチャが簡潔になりました。

V0の実装:独立したSpecDecodeWorker

V0(v0.6.6)では、Speculative Decoding専用のSpecDecodeWorkerが存在し、通常のWorkerとは別の処理フローを持っていました:

# vllm(v0.6.6)/vllm/spec_decode/spec_decode_worker.py(1,172行)
class SpecDecodeWorker(LoraNotSupportedWorkerBase):
    """Worker which implements speculative decoding."""
    
    @classmethod
    def create_worker(cls, scorer_worker, draft_worker_kwargs, ...):
        # ターゲットモデル用Worker
        # ドラフトモデル用Worker(MultiStepWorker, NGramWorker, MedusaWorker等)
        # これらを組み合わせてSpecDecodeWorkerを構成
        ...

V0のvllm/spec_decode/ディレクトリには、Speculative Decodingに関連する実装が多数配置されていました:

ファイル 役割
spec_decode_worker.py 投機デコードのメインWorker
draft_model_runner.py ドラフトモデル実行
target_model_runner.py ターゲットモデル実行
multi_step_worker.py 複数ステップ予測
ngram_worker.py N-gramベース予測
medusa_worker.py Medusaヘッド予測
batch_expansion.py バッチ拡張処理
mqa_scorer.py MQAスコアリング

この設計の問題点:

  • コードの分散:通常推論とSpeculative Decodingで処理フローが分離
  • 重複コード:ModelRunner相当のロジックが複数箇所に存在
  • 保守コスト:新機能追加時に両方の経路を更新する必要がある

V1の実装:スケジューラ/ModelRunnerへの統合

V1(v0.13.0)では、Speculative Decodingが通常の推論パイプラインに統合されました:

vllm(v0.13.0)/vllm/v1/spec_decode/
├── eagle.py        # EAGLEアルゴリズム実装
├── medusa.py       # Medusa実装
├── ngram_proposer.py # N-gram予測
├── suffix_decoding.py # Suffix Decoding予測
├── metadata.py     # メタデータ構造
├── metrics.py      # 統計収集
└── utils.py        # ユーティリティ

vllm(v0.13.0)/vllm/v1/worker/gpu/spec_decode/
├── eagle.py              # Eagle用GPUワーカー処理
├── eagle_cudagraph.py    # CUDAGraph最適化
└── rejection_sample.py   # リジェクションサンプリング

統合のポイント1:スケジューラでのドラフトトークン管理
スケジューラは speculative_config を読み取り、先読み(speculative)トークン数などの設定を保持します。スケジューリング時には、前ステップで生成されたドラフトトークン(spec_token_ids)を SchedulerOutput.scheduled_spec_decode_tokens に格納し、リクエスト側はクリアします。これにより、Speculative Decoding用のトークンも通常のスケジューリングループ内で統一的に扱えます。

# vllm(v0.13.0)/vllm/v1/core/sched/scheduler.py
class Scheduler:
    def __init__(self, ...):
        # Speculative Decodingの設定
        speculative_config = vllm_config.speculative_config
        self.use_eagle = False
        self.num_spec_tokens = self.num_lookahead_tokens = 0
        
        if speculative_config is not None:
            self.num_spec_tokens = speculative_config.num_speculative_tokens
            ...
    
    def schedule(self) -> SchedulerOutput:
        # 前ステップで生成済みの spec_token_ids を、今回のスケジューリング対象として管理
        for request in self.running:
            if request.spec_token_ids:
                num_scheduled_spec_tokens = (
                    num_new_tokens
                    + request.num_computed_tokens
                    - request.num_tokens
                    - request.num_output_placeholders
                )
                if num_scheduled_spec_tokens > 0:
                    del request.spec_token_ids[num_scheduled_spec_tokens:]
                    scheduled_spec_decode_tokens[request.request_id] = request.spec_token_ids
                # 次ステップに持ち越さないよう、いったん request 側はクリア
                request.spec_token_ids = []

統合のポイント2:ModelRunnerでの一括処理

実行側は、通常と同じ Worker.execute_model → model_runner.execute_model の経路で進みます。ただし v0.13.0 では、ModelRunnerの旧実装/新実装が併存しており、どちらを使うかで「Spec Decodeの実装の見え方」が少し変わります。

この移行は一気に置き換えるのではなく、まず旧実装をデフォルトとして維持しつつ、新実装を環境変数で有効化できる形で段階的に導入されています。

  • GPUModelRunner(旧実装)vllm(v0.13.0)/vllm/v1/worker/gpu_model_runner.py
    • speculative_config が有効な場合、EagleProposer / MedusaProposer / NgramProposer / SuffixDecodingProposer などの drafter(提案器)RejectionSampler を用意し、通常の execute_model ループの中でドラフト提案・検証・採択(rejection sampling)を行います。
  • GPUModelRunner(新実装)vllm(v0.13.0)/vllm/v1/worker/gpu/model_runner.py
    • speculative_config が有効な場合、init_speculator()speculative_config を見て EagleSpeculator を生成し(現状 use_eagle() のみ対応)、同じく通常の execute_model の中で提案・検証・採択(rejection_sample カーネル)を行います。

どちらの経路になるかは VLLM_USE_V2_MODEL_RUNNER(環境変数)で切り替わります。実際、GPU Workerはこのフラグに応じてModelRunnerを選択・構築します。

# vllm(v0.13.0)/vllm/v1/worker/gpu_worker.py
# Construct the model runner
if self.use_v2_model_runner:
    from vllm.v1.worker.gpu.model_runner import GPUModelRunner as GPUModelRunnerV2
    self.model_runner = GPUModelRunnerV2(self.vllm_config, self.device)
else:
    from vllm.v1.worker.gpu_model_runner import GPUModelRunner as GPUModelRunnerV1
    self.model_runner = GPUModelRunnerV1(self.vllm_config, self.device)

統合のポイント3:EAGLEへのフォーカス

V1では特に EAGLE(Extrapolation Algorithm for Greater Language-model Efficiency) が厚く実装されています。例えば、V2の init_speculator() は現状EAGLEにフォーカスしており(use_eagle() 以外は未実装)、EAGLE向けのGPU側実装(vllm/v1/worker/gpu/spec_decode/)も用意されています:

# vllm(v0.13.0)/vllm/v1/spec_decode/eagle.py
class EagleProposer:
    """EAGLE speculative decoding proposer."""
    
    def __init__(self, vllm_config, device, runner=None):
        self.num_speculative_tokens = self.speculative_config.num_speculative_tokens
        # EAGLEはターゲットモデルの隠れ状態を使いつつ、提案(draft)生成を行う
        # ※ vLLMの実装ではドラフト側のモデル(expert)もロードして利用する
        ...

EAGLE(Extrapolation Algorithm for Greater Language-model Efficiency)

LLM の生成(デコード)を高速化する Speculative Decoding(投機的デコード) の一方式です。

典型的な投機的デコードでは「小さなドラフトモデルが複数トークンを先に生成し、大きなターゲットモデルがまとめて検証する」ため、ドラフトモデルを別途走らせる計算コストや運用コストがボトルネックになりがちです。

EAGLE の特徴は、別の“完全な小型モデル”を用意するのではなく、ターゲットモデルの内部表現(特に上位層の hidden state/特徴ベクトル)を利用して、軽量なドラフト(小さなヘッド/1層程度の軽量ネットワーク)で先読み候補を作る点にあります。つまり、トークン列を直接当てにいくよりも扱いやすい「特徴(feature)レベル」の自己回帰予測を主軸にし、そこから複数トークンの候補を提案します。

これにより、ドラフト生成のオーバーヘッドを小さくしつつ、1回の検証で複数トークンを前進できる可能性を高めています。

参考:https://arxiv.org/abs/2401.15077

V1の統合によるメリット

観点 V0 V1
コード構造 Spec Decode が独立パスになりやすい スケジューラ/実行系に統合され、通常パイプラインに合流
処理フロー 通常推論と分離 統一パイプライン
新機能対応 変更点が分散しやすい 変更点を統合ポイント周辺に集約
CUDA Graph 通常経路と別の都合(制約/分岐)を抱えやすい 通常経路に合流するため併用しやすい(ただしEAGLE等では専用最適化もある)

V1では、Speculative Decodingが通常のスケジューリング/実行ループに組み込まれたことで、「特別扱いの分岐」を減らしつつ拡張・最適化しやすい形になっています。

おわりに

本記事では、vLLM v0.6.6 と v0.13.0 のソースコードを比較し、この約1年間でアーキテクチャがどう変化したかを整理しました。

分量の多い記事でしたが、ここまで目を通していただきありがとうございます!
今後の性能チューニングやトラブルシューティングの一助になれば幸いです。

参考文献

20
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?