Skip to content
Published on

vLLM 内部構造 (4) — スケジューラとプリエンプション、スループットが崩れる地点

シェア
Authors

スケジューラが毎ステップ解いている問題

第3回で、vLLMがステップごとにバッチを組み直すことを見た。その決定を実際に下しているのがスケジューラである。毎ステップ、スケジューラは二つの上限を同時に満たす組み合わせを見つけなければならない。

一つはトークン予算である。今回のステップで処理するトークン数の合計が上限を超えてはならない。V1スケジューラのソースを見ると、リクエストを一つ入れるたびに予算からその分を差し引いていき、合計が上限を超えないことを確認している。

もう一つはKVキャッシュブロックである。こちらのほうがはるかに厄介である。トークン予算は計算すれば出てくるが、ブロックは今実際に空いていなければならない。しかも、すでに実行中のリクエストも毎ステップ新しいトークンを作りながらブロックをもっとくれと要求する。つまり、スケジューラが相手にしている資源はじっとしておらず、絶えず減り続ける。

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

待機キューと実行キュー

構造自体は単純である。V1スケジューラのソースには、待機中のリクエストを入れるキューと、実行中のリクエストのリストが並んで存在する。実行リストはただのリストであり、待機キューはスケジューリングポリシーによって異なる形で作られる。

[待機キュー]  まだ一度も実行されていないか、後退させられたリクエストたち
     │  ブロックが確保されると昇格
[実行リスト]  今回のステップのバッチに入るリクエストたち
     │  ブロックが足りないとプリエンプションされて戻る
     └──────────────▶ 再び待機キューへ(先頭に)

戻っていくこの矢印こそがこの記事の主役である。そしてもう一つ重要な細部がある。プリエンプションされたリクエストは、待機キューの最後尾ではなく先頭に置かれる。ソースの中で、リクエストをキューの先頭に戻して入れる動作として確認できる。たった今押し出されたリクエストが、すぐにまた候補になるという意味であり、おかげで特定のリクエストが押され続けて飢餓状態に陥る事態を避けられる。

プリエンプション: すでに動いていたリクエストを後退させる

KVキャッシュが足りなくなると、スケジューラは新しいリクエストを受け付けないだけでは済まさず、すでに動いていたリクエストを後退させる。これがプリエンプションである。

なぜここまでするのかといえば、代案がないからである。実行中のリクエスト全部が次のトークンを作るためにはブロックがさらに必要なのに、残っているブロックがないなら、誰かが出ていかなければ残りが前進できない。誰も追い出さなければ全部が止まる。

公式の最適化ドキュメントは、このとき出る警告を例として示している。

WARNING 05-09 00:49:33 scheduler.py:1057 Sequence group 0 is preempted by
PreemptionMode.RECOMPUTE mode because there is not enough KV cache space.

ログの形式と行番号はバージョンごとに異なるが、見るべき部分は最後の一節である。KVキャッシュの空間が不足しているということ。この行がログに繰り返し出ているなら、そのデプロイはすでに容量を超えて動いている状態である。

プリエンプションが厄介な理由は、コストが目に見えにくいからである。リクエストは失敗しない。エラーも出ない。ただ遅くなるだけである。それも平均ではなく、テールで遅くなる。ダッシュボードの平均レイテンシは正常なのに、一部のユーザーだけが異常に長く待たされる状況が作られる。

recomputeとswap

後退させられたリクエストのKVキャッシュはどうするのか。歴史的に二つの方法があった。

再計算は、単純に捨ててしまう側である。後で再び実行されるとき、プロンプトから計算をやり直す。メモリを即座に丸ごと回収できる代わりに、それまでの計算を捨てることになる。V1スケジューラのソースを見ると、プリエンプションされたリクエストの状態を記録し、それまでに計算したトークン数を0に戻している。進捗が初期化されるという事実が、この一行にそのまま表れている。

スワップは、KVキャッシュをCPUメモリに移しておいて後で戻す側である。計算は節約できるが、GPUとCPUの間でデータを往復させるコストがかかる。

今の時点で知っておくべきことはこれである。公式ドキュメントは、vLLM V1のデフォルトのプリエンプション方式はスワップではなく再計算であり、V1の構造では再計算側の負担のほうが小さいと述べている。そして、V1ガイドはGPUとCPU間のKVキャッシュスワップが削除された機能の一覧に挙がっていると記している。したがって、古い記事で見たスワップ関連の設定を、今そのまま適用しようとしてはならない。使用中のバージョンにそうした引数が実際に存在するのかどうかを、まず確認してほしい。

スケジューリングポリシー: fcfsとpriority

スケジューラの設定にはポリシー項目がある。mainブランチの SchedulerConfig ソースでは policy のデフォルト値は fcfs であり、先着順処理を意味する。もう一つの選択肢は priority で、リクエストに与えられた優先順位に従う。OpenAI互換サーバーの追加パラメータ一覧にも priority が含まれている。

ポリシーは、プリエンプション対象の選び方まで変える。V1スケジューラのソースを見ると、優先順位ポリシーでは実行中のリクエストのうち、優先順位と到着時刻を基準にして最も後ろに位置するリクエストを選んで後退させ、それ以外のポリシーではリストの最後のリクエストを取り出す。

実務でこの選択が分かれるポイントははっきりしている。すべてのリクエストが対等であれば先着順で十分である。一方、対話型のリクエストと大量のバッチ作業が一つのエンドポイントを共有しているなら、優先順位を使わない限りバッチ作業が対話型ユーザーを押しのける。ただし、ポリシーを変える前に先に問うべき問いがある。今抱えている問題が順序の問題なのか、容量の問題なのかである。容量が不足した状態で順序だけ変えても、誰が損をするかが変わるだけで総量は変わらない。

プリエンプションが見えたら何をすべきか

公式の最適化ドキュメントが示す対応は四つである。 gpu_memory_utilization を上げてKVキャッシュに使える空間を増やすこと、 max_num_seqsmax_num_batched_tokens を減らして一ステップで生きているリクエストを減らすこと、 tensor_parallel_size を上げること、 pipeline_parallel_size を上げることである。

前の二つと後の二つは性質が異なる。前の二つは今のGPUの中で配分を変えることであり、後の二つはGPUをさらに使うことである。順番に試すほうがよい。大半のデプロイでは、配分を直すだけでプリエンプションが消える。ここで max_num_seqs を減らすと処理量が落ちそうに思えるが、実際には逆になることが多い。プリエンプションが繰り返される状態は、すでに再計算で無駄が生じている状態であるため、同時リクエスト数を減らしてプリエンプションをなくすほうが正味の処理量にとって有利である。

試してみる

参考資料