Skip to content
Published on

vLLM 内部構造 (3) — 連続バッチングが GPU を遊ばせない仕組み

シェア
Authors

静的バッチングがGPUを遊ばせる仕組み

第2回でメモリを見たので、今回は時間を見る。GPUをどれだけ休ませずに動かし続けるかという問題である。

GPUは一度に多くの仕事をこなしてこそ効率が出る。そのためリクエストをまとめてバッチに束ねる。ここまでは誰でも同じである。問題はその先だ。素朴な実装では、バッチを一度組むと、そのバッチが全部終わるまで構成を変えない。

静的バッチング — バッチが全部終わるまで空席のまま待つ

ステップ→     1  2  3  4  5  6  7  8  9 10 11 12
リクエストA   ■  ■  ■  ✓
リクエストB   ■  ■  ■  ■  ■  ■  ■  ■  ■  ■  ■  ✓
リクエストC   ■  ■  ■  ■  ■  ✓
リクエストD   ■  ■  ✓

■ 計算中   ✓ 完了   空欄 = GPUが何もしていない場所

リクエストDは3ステップで終わったが、その席は12ステップ目まで空いたままである。さらに悪いことに、今待機キューにリクエストEが来ていても、このバッチが終わるまでは開始すらできないという点である。LLMでこの損失が特に大きい理由は、応答の長さのばらつきが極端だからである。同じサービスの中で、ある答えは20トークンで、ある答えは2000トークンになる。

内容は 2026-08-12 に公式ドキュメントとソースで確認しました。vLLM は変化が速いため、設定値と挙動は使用中のバージョンのドキュメントで再確認してください。

イテレーション単位のスケジューリング

連続バッチングの発想はシンプルである。バッチの構成をリクエスト単位ではなくステップ単位で組み直す。モデルを一回動かすたびに、スケジューラが改めて問い直す。終わったリクエストはあるか、あれば外し、その席に待機中のリクエストを入れよう、と。

連続バッチング — 空席ができた瞬間に次のリクエストが入る

ステップ→     1  2  3  4  5  6  7  8  9 10 11 12
リクエストA   ■  ■  ■  ✓
リクエストB   ■  ■  ■  ■  ■  ■  ■  ■  ■  ■  ■  ✓
リクエストC   ■  ■  ■  ■  ■  ✓
リクエストD   ■  ■  ✓
リクエストE         ■  ■  ■  ■  ✓
リクエストF            ■  ■  ■  ■  ■  ■  ✓
リクエストG                     ■  ■  ■  ■  ■  ✓

同じ12ステップの中で、リクエストが三つ余分に処理された。GPUがこなした仕事の総量は同程度なのに、処理したリクエスト数が増えた。これがvLLMの処理量の核心である。

ここで自然に付いてくる制約が一つある。新しいリクエストを途中に差し込むには、そのリクエストのKVキャッシュブロックをその場で確保できなければならない。第2回で見たブロック構造がなければ、この柔軟さは成り立たない。連続バッチングとPagedAttentionは、実のところ一つのセットである。

prefillとdecodeは性質が異なる

ここまではどのサービングエンジンの説明にも出てくる話である。実際のチューニングは、その先の事実で分かれる。リクエスト一つの生涯には、性質がまったく異なる二つの区間がある。

プリフィルは、入力プロンプト全体のKVを一気に計算する区間である。トークン4000個のプロンプトであれば、4000個を並列に処理する。計算量が多く、GPUの演算ユニットが忙しく回る。

デコードは、その後トークンを一つずつ作り出していく区間である。リクエスト一つあたり、今回のステップで処理する新しいトークンはちょうど一つである。計算量は少ないのに、モデルの重みとKVキャッシュを全部読み込まなければならない。演算よりもメモリを読み出すほうがボトルネックになる。

この違いは実務でこのように現れる。誰かが4万トークンの文書を貼り付けて要約を依頼すると、そのプリフィルが回っている間、他のユーザーのトークン出力が目に見えて詰まる。プリフィル一つのかたまりがステップを丸ごと占有してしまうからである。

チャンクドプリフィル: 二つの性質を一つのバッチに混ぜる

解法は、長いプリフィルを丸ごと処理せず、切り分けて複数のステップに分けて入れることである。これがchunked prefillである。そうすると、一ステップのバッチが「長いプロンプトのプリフィルの一部 + 複数のリクエストのデコードトークン」に混ざる。

チャンクドプリフィルなし
  ステップN   : [40000トークン プリフィル]                    ← 他のリクエストは全部待機
  ステップN+1 : [デコード・デコード・デコード・デコード]

チャンクドプリフィルあり
  ステップN   : [プリフィル断片・デコード・デコード・デコード]
  ステップN+1 : [プリフィル断片・デコード・デコード・デコード]
  ステップN+2 : [プリフィル断片・デコード・デコード・デコード]

V1がこれを自然に行える理由はスケジューラの設計にある。公式のV1ガイドによれば、V1の統合スケジューラはプロンプトトークンと出力トークンを区別せず同じものとして扱い、リクエストごとに今回のステップで何トークン割り当てるかを決めて、固定されたトークン予算を分け合う。プリフィル段階とデコード段階を厳密に分けないため、チャンクドプリフィルやプレフィックスキャッシュのような機能が一つの枠組みの中で動く。

デフォルト値も変わった。V1ガイドは、チャンクドプリフィルが可能な限り常にデフォルトで有効になると明記しており、V0ではモデルの特性に応じて条件付きで有効になっていたと対比して説明している。mainブランチの SchedulerConfig ソースでも、 enable_chunked_prefill の宣言上のデフォルト値はTrueになっている。

トークン予算という一つのつまみ

そのため、このステップに入れるトークンの総量を決める値が max_num_batched_tokens であり、同時に処理するシーケンス数の上限が max_num_seqs である。mainブランチの SchedulerConfig には、それぞれのクラスのデフォルト値が2048と128として宣言されている。ただし、実際に適用される値は使用状況と環境によって調整されることがあるため、起動ログで確認してほしい。

公式の最適化ドキュメントが示す方向性はこうである。 max_num_batched_tokens を2048のように小さくすると、トークン間の間隔、つまりストリーミングが途切れない感覚が良くなる。大きくすると最初のトークンまでの時間が良くなる。処理量を優先するなら、ドキュメントはこの値を8192より大きくすることを勧めており、特に大きなGPUに小さなモデルを載せた場合にそうだと付け加えている。

注意すべき条件もドキュメントに明記されている。チャンクドプリフィルを切った場合には、 max_num_batched_tokensmax_model_len より大きくなければならない。そうでなければ、最大長のプロンプトが来たときに一ステップに収める方法がない。

何を先に調整するか

整理すると、順序はこうなる。

ストリーミングがぶつぶつ途切れるという不満があれば、 max_num_batched_tokens を減らす方向を見る。長いプロンプトが短いリクエストたちを塞いでいる可能性が高い。

最初の応答が遅いという不満なら、逆に増やす方向を見る。ただし、このとき個々のユーザーのトークン出力は多少滑らかさを失うことがある。この二つの指標は同じ予算を分け合う関係にあるため、一方を良くすればもう一方が押される。

同時接続が多いのに処理量が上がらない場合は、 max_num_seqs とKVキャッシュの余裕を合わせて見る。シーケンス上限ではなくキャッシュ不足で入れられない状況であれば、次回扱うプリエンプションがすでに起きている可能性がある。

試してみる

参考資料

  • vLLM V1 Guide (docs.vllm.ai) — 統合スケジューラがプロンプトと出力トークンを同じものとして扱うという説明、チャンクドプリフィルのデフォルト有効化に関する記述の出典である。
  • vLLM Optimization and Tuning (docs.vllm.ai)max_num_batched_tokens の調整方向と、チャンクドプリフィルを切った場合の制約が書かれている場所である。
  • vLLM SchedulerConfig ソース (GitHub main)enable_chunked_prefill のデフォルト値とトークン予算関連のクラスデフォルト値を読み取った場所である。