- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — コードを開く前に確認すべき六行
- まず、コードを直さずにできること
- PagedAttention — 断片化をなくす発想
- 連続バッチングスケジューラが実際に決めること
- プリフィルとデコードは性質の異なる作業である
- ベンチマーキング — 何を固定し何を測るか
- 実際にコードを触る価値のある場所
- おわりに — 測定のないチューニングは好みの表明にすぎない
- 参考資料
はじめに — コードを開く前に確認すべき六行
「vLLMが遅いんですがソースを直すべきでしょうか」と聞かれたとき、私はいつも同じことから確認します。ログに選点(プリエンプション)警告が出ているか、max_num_batched_tokensがいくつか、プレフィックスキャッシュが有効か、GPUメモリ利用率がいくつか、物理コアが何個か、そして今何を測っているかです。
この六行で答えが出ることが大半です。実際にソースを直す必要がある状況は思ったより稀で、直すべきときでさえ、直す場所は数か所に決まっています。
この記事はその順序をたどります。基準はvLLM v0.26.0(2026年7月25日PyPIリリース、Python 3.10以上3.15未満)で、内部構造の説明は該当タグのソースを直接読んで書きました。vLLMは二週間に一度くらいのペースでマイナーバージョンが上がるプロジェクトなので、この記事のファイルパスと引数名も数か月でずれる可能性があります。自分のバージョンのソースと照らし合わせる習慣が必要です。
ひとつ先に押さえておきます。V0エンジンは完全に廃止されました。公式ドキュメントが「We have fully deprecated V0」と明示し、RFC #18571を指しています。インターネットに残っているV0時代のチューニング記事は大抵今は合いません。
まず、コードを直さずにできること
優先順位順に整理します。上にあるほど効果が大きくコストが安いです。
| つまみ | 何を変えるか | いつ触るか |
|---|---|---|
gpu_memory_utilization | KVキャッシュに使うメモリの割合 | 選点警告が見えるとき。デフォルトから上げる |
max_num_batched_tokens | 一ステップで処理する総トークン予算 | TTFTとITLのどちらを立てるか決めるとき |
max_num_seqs | 同時実行するリクエスト数の上限 | メモリが足りないかバッチが浅いとき |
| プレフィックスキャッシュ | 共通接頭辞のKV再利用 | システムプロンプトが長く共有されるとき |
| 量子化 | 重みとKVキャッシュのバイト数 | デコードが帯域幅に縛られているとき |
tensor_parallel_size | 重みをGPUに分割する度合い | モデルが入らないかKV空間が不足しているとき |
| アテンションバックエンド | どのカーネルを使うか | 自動選択が最適でないとき |
最適化レベル-O0から-O3 | 起動時間と定常状態性能の交換 | 開発ループかプロダクションか |
まず選点をなくす
もっともよくある単一の原因です。KVキャッシュが足りないと、vLLMは実行中のリクエストを選点し、あとで再計算します。ログにこの種の行が繰り返されるなら、他のチューニングは意味がありません。
WARNING ... Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode
because there is not enough KV cache space. This can affect the end-to-end
performance. Increase gpu_memory_utilization or tensor_parallel_size ...
V1のデフォルトの選点方式はスワップではなく再計算です。選点されたリクエストのプリフィルを最初からやり直します。だから選点が頻発すると、スループットとレイテンシが一緒に崩れます。対応はドキュメントがそのまま教えてくれます。gpu_memory_utilizationを上げるか、max_num_seqsとmax_num_batched_tokensを下げるか、tensor_parallel_sizeを上げてGPUあたりのKV空間を増やすことです。
トークン予算でTTFTとITLを天秤にかける
max_num_batched_tokensは一ステップでスケジュールできるトークンの総量です。公式ドキュメントが方向を明確に書いています。
- 値が小さいと(例: 2048)ITLがよくなります。 デコードを遅らせるプリフィルの塊が小さくなるからです。
- 値が大きいとTTFTがよくなります。 一バッチにプリフィルトークンをより多く押し込めるからです。
- スループットが目標なら8192より大きくしておくよう勧めています。特に大きいGPUに小さいモデルを載せた場合はそうです。
注意すべき落とし穴がひとつあります。チャンクドプリフィルを切った状態では、max_num_batched_tokensがmax_model_lenより大きくなければならず、そうでないとサーバーが起動時点で落ちることがあります。
from vllm import LLM
# 対話型サービス: トークンあたりの遅延を優先
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=2048)
# バッチ処理: スループットを優先
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=16384)
起動時間も性能である
同じモデルと設定で繰り返し立ち上げる環境なら、ドキュメントが三つの手段を提示します。
- コンパイルキャッシュの再利用。
torch.compileの産物がVLLM_CACHE_ROOT(デフォルトはホーム配下のキャッシュディレクトリ)に保存され、このディレクトリをコンテナイメージに焼き込んだり機材間でコピーしたりできます。VLLM_FORCE_AOT_LOAD=1を与えると、キャッシュが外れたときに黙って再コンパイルする代わりに明示的に失敗します。モデル、設定、関連する環境変数、torchビルド、GPU機種のいずれかが変わればキャッシュは無効になります。 --kv-cache-memoryでメモリプロファイリングを飛ばす。 起動時にvLLMが現在の割り当てを再現する値をログに出します。次の起動でその値を渡すと測定段階を省略できます。ただし代償があります。KVキャッシュが測定値ではなく指定値に固定されるため、保守的に取ると同時実行数が削られ、楽観的に取ると割り当てに失敗します。同じGPU、同じ初期空きメモリのときだけ有効です。--enforce-eager。 コンパイルとCUDAグラフキャプチャを両方飛ばします。起動がもっとも速く、定常状態のデコード性能は悪くなります。開発ループ用であり、起動時間のうちコンパイルが占める割合を測るのにも使います。
CPUを飢えさせない
意外とよく見落とされる項目なので別立てで書きます。vLLM V1はマルチプロセス構成です。GPUがN個ならAPIサーバー1個、エンジンコア1個、GPUワーカーN個で、最低でもN足す2個のプロセスがCPUを取り合います。
ドキュメントは最低ラインを物理コア基準で明示しています。ハイパースレッディングが有効ならvCPU1個は物理コアの半分なので、必要なvCPUは二倍になります。特にエンジンコアプロセスはビジーウェイトループを回すため、CPU飢餓に敏感です。仮想化環境でGPU使用率が理由なく低いなら、ここをまず疑うのが正しいです。
アテンションバックエンドは自動選択が基本
vLLMはGPUアーキテクチャとモデル、設定を見て、優先順位リストから最初に互換性のあるバックエンドを選びます。v0.26.0基準で標準アテンションの優先順位はこうです。
| アーキテクチャ | 1位 | 2位 | 3位 | 4位 | 5位 |
|---|---|---|---|---|---|
| Blackwell (SM 10.x) | FLASHINFER | FLASH_ATTN | TRITON_ATTN | FLEX_ATTENTION | TURBOQUANT |
| Ampere / Hopper (SM 8.x–9.x) | FLASH_ATTN | FLASHINFER | TRITON_ATTN | FLEX_ATTENTION | TURBOQUANT |
手動で変えるにはこうします。互換性のないバックエンドを指定すると理由付きでエラーになります。
vllm serve Qwen/Qwen3-8B --attention-backend FLASH_ATTN
# または構造化設定で
vllm serve Qwen/Qwen3-8B -ac.backend FLASH_ATTN
PagedAttention — 断片化をなくす発想
ここから内部です。vLLMの出発点になった観察は単純です。KVキャッシュをリクエストごとに連続した大きな塊で確保すると、メモリの大部分が無駄になるということです。
無駄は三種類です。リクエストが最大長まで行くかもしれないのであらかじめ確保しておく内部予約分、実際には使われずに終わる過剰割り当て分、そしてサイズがまちまちな塊を返却してまた確保する過程で生まれる外部断片化です。
解法はOSの仮想メモリからそのまま持ってきています。KVキャッシュを固定サイズのブロックに切り、論理的に連続したシーケンスを物理的に散らばったブロックに対応させるブロックテーブルを置きます。
リクエストAの論理KV: [t0 t1 t2 t3] [t4 t5 t6 t7] [t8 t9 __ __]
│ │ │
ブロックテーブルA → ブロック7 ブロック3 ブロック12
リクエストBの論理KV: [t0 t1 t2 t3] [t4 t5 __ __]
│ │
ブロックテーブルB → ブロック7 ブロック5
↑
共通接頭辞なら同じブロックを共有する(プレフィックスキャッシュ)
結果は二つです。第一に、最後のブロックの余りスペース以外は無駄がなくなります。原論文はこれを「near-zero waste in KV cache memory」と表現します。第二に、ブロック単位の共有が可能になります。同じシステムプロンプトを使うリクエストが接頭辞ブロックを物理的に共有し、これがプレフィックスキャッシュです。論文はこの二つによってFasterTransformerとOrca比で同じレイテンシでスループット2倍から4倍を報告しています(Kwon et al., SOSP 2023)。
v0.26.0でこのロジックが住んでいるのはvllm/v1/core/配下です。kv_cache_manager.pyがリクエストごとのブロック割り当てを、block_pool.pyがブロックプールとハッシュベースの再利用を、kv_cache_coordinator.pyが複数種類のキャッシュを併用するモデル(ハイブリッドアテンション)の調整を担当します。
実務的な含意はこうです。プレフィックスキャッシュはシステムプロンプトが長く共有されるときだけ勝ちます。 毎リクエストの接頭辞が違うなら、ハッシュ計算とブロック管理のコストだけが残ります。オンにする前と後を測るのが唯一正しい判断方法です。
連続バッチングスケジューラが実際に決めること
vllm/v1/core/sched/scheduler.pyのschedule()メソッドが毎エンジンステップごとに行うことです。ソース冒頭のコメントが設計を正確に要約しています。
There's no "decoding phase" nor "prefill phase" in the scheduler. Each request just has the num_computed_tokens and num_tokens_with_spec. At each step, the scheduler tries to assign tokens to the requests so that each request's num_computed_tokens can catch up its num_tokens_with_spec.
この一文がV1スケジューラを理解する鍵です。スケジューラはプリフィルとデコードを区別しません。リクエストごとに「これまでに計算されたトークン数」と「計算されるべきトークン数」だけを持っており、毎ステップ前者が後者に追いつくようトークンを配分します。この一つの抽象化で、チャンクドプリフィル、プレフィックスキャッシュ、投機的デコードがすべて特殊ケースなしに表現されます。
実際のループの骨格はこうです。
# vllm/v1/core/sched/scheduler.py の構造を要約したもの(実際のコードではない)
def schedule(self):
token_budget = self.max_num_scheduled_tokens # = max_num_batched_tokens
# 1) まずRUNNINGリクエストから。つまりデコードが優先権を持つ。
for request in self.running:
if token_budget <= 0:
break
num_new = min(need(request), token_budget)
if not kv_cache_has_room(request, num_new):
preempt(self.running.pop()) # 後ろから選点する
continue
schedule_tokens(request, num_new)
token_budget -= num_new
# 2) 残った予算でWAITINGリクエストを付ける。つまりプリフィルはあとまわし。
while self.waiting and token_budget > 0:
if len(self.running) >= self.max_num_running_reqs: # = max_num_seqs
break
request = self.waiting.peek()
num_new = min(need(request), token_budget)
# 全部入らなければ切って入れる → これがチャンクドプリフィル
schedule_tokens(request, num_new)
token_budget -= num_new
ここで、私たちが設定で触る値が正確にどこに刺さるかが見えます。
max_num_batched_tokensはtoken_budgetの初期値です。一ステップの総仕事量です。max_num_seqsはmax_num_running_reqsです。実行キューの長さの上限です。long_prefill_token_thresholdは、プリフィルリクエストひとつが一ステップで持っていけるトークンの上限です。長いプロンプトひとつが予算を独占して他のリクエストを飢えさせるのを防ぎます。
デコードが先に配分を受けるという点が重要です。すでに応答をストリーミング中のユーザーを先に面倒みて、残った予算で新しいリクエストのプリフィルを始めるという方針です。だから負荷が上がるとTTFTから悪化し、ITLは相対的に持ちこたえます。
プリフィルとデコードは性質の異なる作業である
二つの段階はハードウェアの使い方が正反対です。
| 軸 | プリフィル | デコード |
|---|---|---|
| 一度に処理するトークン | プロンプト全体(数千個) | リクエストあたり1個 |
| 算術強度 | 高い。大きな行列積 | 非常に低い |
| ボトルネック | 演算(テンソルコア) | メモリ帯域幅 |
| 関連指標 | TTFT | TPOT、ITL |
| バッチを大きくすると | すでに飽和、利得は小さい | 重み読み込みを共有し利得が大きい |
デコードがメモリバウンドである理由は単純です。トークンひとつを作るためにモデルの重み全体を一度読みます。バッチが1ならその読み込みでトークン1個を得て、バッチが64なら同じ読み込みで64個を得ます。だからデコードのスループットはバッチサイズにほぼ比例して上がっていき、帯域幅の壁に張り付きます。
問題は二つの段階を別々に回すと双方とも損をすることです。プリフィルだけが回るステップではテンソルコアが飽和しメモリパイプが遊びます。デコードだけが回るステップでは逆です。
チャンクドプリフィルはこの問題を正面から扱います。長いプリフィルを切ってデコードリクエストと同じバッチに混ぜます。演算バウンドな作業とメモリバウンドな作業が一つのバッチに共存するので、両方のリソースが同時に使われます。V1では可能な限り常にデフォルトで有効です。
ここでトレードオフが明示的に現れます。チャンクを小さくするとデコードが妨げられにくくなりITLがよくなり、プリフィルが複数ステップに分かれるためTTFTが悪化します。チャンクを大きくすると逆になります。どちらが正しいかはサービスが決めることであり、vLLMが決めることではありません。
ベンチマーキング — 何を固定し何を測るか
ここがこの記事でもっとも重要な節です。前述のすべてのチューニングは、測定が正直なときにだけ意味があります。
測る値の正確な定義
vLLMのベンチマークツールが出力する指標は、定義がソースに刻まれています。
| 指標 | 定義 | 誰が気にするか |
|---|---|---|
| TTFT | Time to First Token。リクエスト送信から最初のトークンまで | ユーザー体感の反応性 |
| TPOT | Time per Output Token、最初のトークンを除いた平均 | ストリーミング体感速度 |
| ITL | Inter-token Latency。連続トークン間の間隔の分布 | 途切れ。平均ではなく裾を見るべき |
| E2EL | End-to-end Latency。リクエスト全体の所要時間 | バッチ作業 |
| Output throughput | 秒あたりの出力トークン数 | コスト |
TPOTとITLを区別して使うことが重要です。TPOTはリクエストひとつの平均で、ITLは間隔ひとつひとつの分布です。平均TPOTが20msでもITLのp99が400msなら、ユーザーは「たまに詰まる」と感じます。平均だけ見ているとこの現象は見えません。
実際のコマンド
# 1) サーバーを立てる。チューニング対象の引数をここで固定する。
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--max-num-batched-tokens 8192 \
--max-num-seqs 256 \
--gpu-memory-utilization 0.90 &
# 2) 負荷をかける。リクエストレートを変えながら複数の点を打つ。
vllm bench serve \
--backend vllm \
--model meta-llama/Llama-3.1-8B-Instruct \
--dataset-name sharegpt --dataset-path sharegpt.json \
--num-prompts 500 \
--request-rate 8 \
--percentile-metrics ttft,tpot,itl,e2el \
--metric-percentiles 50,90,99 \
--save-result --result-filename rate8.json
# 3) オフラインのスループット上限が知りたいならこちら
vllm bench throughput --model meta-llama/Llama-3.1-8B-Instruct \
--dataset-name sharegpt --dataset-path sharegpt.json --num-prompts 1000
固定すべきもの
測定ひとつを信じるには、以下すべてが固定されていなければなりません。ひとつでも揺れると比較は無意味になります。
- 入力と出力の長さ分布。 同じデータセット、同じシード。合成データを使うなら入力長と出力長を明示的に固定します。長さ分布が違えばスループットはいくらでも変わります。
- リクエスト到着率。 無限負荷で測ればスループット上限は出ますが、レイテンシは無意味になります(キュー待ちがすべてです)。リクエストレートを変えながら曲線を描くことが唯一有用な形です。点ひとつでは何も語れません。
- ウォームアップ。 最初のリクエスト群にはコンパイル、CUDAグラフキャプチャ、キャッシュ充填が混ざります。統計から除く必要があります。
- プレフィックスキャッシュの状態。 オンにしたまま同じプロンプトを繰り返すと、二回目からTTFTが劇的によくなります。これを最適化の成果として報告すると嘘になります。キャッシュを空にして測るか、キャッシュがある定常状態を測るか、どちらかに決めるべきです。
- GPUクロックと隣人。 共有機材なら同じ時間帯に他の作業がないようにします。電力制限にかかるとクロックが下がります。
- バージョン。 vLLM、PyTorch、ドライバ、イメージタグを結果と一緒に記録します。
曲線として読む
一点の数値の代わりにリクエストレートを5、10、15、20と上げながら測ると、こういう形が出ます。
p99 TTFT
^
| ╱ ← ここからキューが積み上がり始める
| ╱
| ______╱
| ____________________╱
+--------------------------------------> リクエストレート(req/s)
膝(knee)
運用ポイントは膝の左側に置く。
膝の右側は「スループットは出るがレイテンシが制御不能」な領域である。
チューニングの目標は最大スループットではなく、SLOを満たす最大スループットです。p99 TTFT 500ms以下という条件があるなら、その条件を守りながら出せるリクエストレートが指標です。max_num_batched_tokensを変えながらこの曲線を何本も描けば、どの値が自分たちのサービスに合うかが目に見えます。
ボトルネックを特定したいとき
数値が悪くて理由がわからなければプロファイラです。ただしドキュメントは警告を先に立てます。プロファイリングは開発者向けで推論を大きく遅くするため、エンドユーザーは有効にしてはいけません。 低オーバーヘッドが必要ならNsight Systems、スタックとテンソルの形まで必要ならPyTorchプロファイラを使います。
# サーバーにプロファイラを付けて起動する(--profiler-configはv0.13.0以上)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--profiler-config '{"profiler": "torch", "torch_profiler_dir": "./vllm_profile"}'
# 区間を切って収集
curl -X POST http://localhost:8000/start_profile
# ... リクエストを数個だけ送る。トレースが非常に大きくなる ...
curl -X POST http://localhost:8000/stop_profile
# ベンチマークと一緒に使うこともできる
vllm bench serve --backend vllm --model ... --profile --num-prompts 2
収集したトレースはPerfetto UIで見ます。リクエスト数を少なく保つことが重要です。ドキュメントは70B級モデルで100リクエスト分を落とすのにH100で10分ほどかかると書いています。
実際にコードを触る価値のある場所
ここまでやってもなお足りないなら、そこでソースです。現実的に手を入れる価値がある場所は三箇所です。
1. カスタムロジットプロセッサ — もっとも安全な場所
推奨する理由はソースを修正せずプラグインとして付くという点です。vLLMをフォークしなくてよいです。ロジットプロセッサはバッチ単位で動作し、リクエスト数掛ける語彙サイズの形のロジットテンソルを受け取り変形したのちsoftmaxに渡します。
vllm.v1.sample.logits_processor.LogitsProcessorを継承し、五つを実装します。
# my_pkg/procs.py
import torch
from vllm.config import VllmConfig
from vllm.sampling_params import SamplingParams
from vllm.v1.sample.logits_processor import BatchUpdate, LogitsProcessor
class BanTokenAfterN(LogitsProcessor):
"""出力がN個を超えたら特定トークンを禁止する(例)。"""
@classmethod
def validate_params(cls, params: SamplingParams):
# 誤った引数をエントリポイントであらかじめ弾く。実装しないと
# おかしな値がそのままカーネルまで流れ込む。
v = params.extra_args and params.extra_args.get("ban_after")
if v is not None and not isinstance(v, int):
raise ValueError("ban_after must be int")
def __init__(self, vllm_config: VllmConfig, device: torch.device,
is_pin_memory: bool):
self.device = device
# バッチインデックス -> (禁止トークン、閾値、出力トークンリストの参照)
self.req: dict[int, tuple[int, int, list[int]]] = {}
def is_argmax_invariant(self) -> bool:
# 最大値トークンを変えうるのでFalse。
# Trueにするとバッチ全体がグリーディのときvLLMがこのプロセッサを丸ごと飛ばす。
return False
def update_state(self, batch_update: BatchUpdate | None) -> None:
if batch_update is None:
return
# 必ず removed -> added -> moved の順で処理しなければならない。
for idx in batch_update.removed:
self.req.pop(idx, None)
for idx, params, _prompt_ids, output_ids in batch_update.added:
self.validate_params(params)
n = params.extra_args and params.extra_args.get("ban_after")
if n is None:
self.req.pop(idx, None)
else:
# output_ids は生きたリスト参照なので
# 毎ステップ最新の出力がそのまま見える。
self.req[idx] = (params.extra_args["ban_token"], n, output_ids)
for a, b, direction in batch_update.moved:
va, vb = self.req.pop(a, None), self.req.pop(b, None)
if vb is not None:
self.req[a] = vb
if va is not None and direction.name == "SWAP":
self.req[b] = va
def apply(self, logits: torch.Tensor) -> torch.Tensor:
for idx, (tok, n, out_ids) in self.req.items():
if len(out_ids) >= n:
logits[idx, tok] = float("-inf") # in-place の方がメモリに有利
return logits
付け方は二通りです。完全修飾クラス名を渡すか、パッケージのエントリポイントとして登録します。
vllm serve facebook/opt-125m --logits_processors my_pkg.procs:BanTokenAfterN
# pyproject.toml — インストールするだけで自動的にロードされる
[project.entry-points."vllm.logits_processors"]
ban_after = "my_pkg.procs:BanTokenAfterN"
注意点は三つです。
- ロジットプロセッサの集合はエンジン初期化時点で固定されます。 リクエストごとにあとから追加はできません。リクエストごとにオン・オフするには
SamplingParams.extra_argsで判断してapplyの中で処理する方法しかありません。 is_argmax_invariant()は正直に答えなければなりません。 trueにしておくとバッチが全部グリーディのとき丸ごと飛ばされてただで速くなりますが、実際には最大値を変えるプロセッサにtrueを返すと静かに間違った結果が出ます。applyは毎ステップ、バッチ全体に対して回ります。 リクエスト数だけPythonループを回すとそれ自体がボトルネックになります。テンソル演算でベクトル化できるならそうしてください。- ドキュメント自体がこのAPIについて「design changes are still in progress and the API may change in the near future」と明示しています。バージョン固定を前提に使う方が安全です。
2. カスタムアテンションバックエンド — 値打ちも大きいがコストも大きい
vllm/v1/attention/backends/配下にバックエンドがあり、共通インターフェースはvllm/v1/attention/backend.pyにあります。v0.26.0にはflash_attn.py、flashinfer.py、triton_attn、flex_attention.pyとMLA専用実装が入っています。
自作する理由になる状況は狭いです。標準アテンションの変形が必要で既存バックエンドになく、その変形が性能に決定的なときです。たとえばドメイン特有の疎マスクがあって、全アテンションの10パーセントだけ計算すればよい場合です。
コストは正直に見るべきです。バックエンドはメタデータビルダー、CUDAグラフ互換性、プリフィルとデコードの経路、チャンクドプリフィルとの相互作用、プレフィックスキャッシュとの相互作用を全部合わせなければなりません。カーネルだけ書けば終わりではありません。そしてこのインターフェースはvLLMのリリースごとに変わります。
3. スケジューラポリシー — 最後の手段
vllm/v1/core/sched/配下にscheduler.py、interface.py、request_queue.pyがあります。リクエストの優先順位をドメイン規則で変えたいとき(例: 有料ティア優先、短いリクエスト優先)に手を出すことになります。
ただし順序があります。vLLMはすでにリクエスト優先順位機能を提供しているので、それで表現できるか先に確認すべきです。スケジューラの修正はリスクがもっとも高いです。先に見た通り、このコードはトークン予算、選点、チャンクドプリフィル、プレフィックスキャッシュ、投機的デコードと全部絡み合っています。
アップストリームを追う代償
三箇所すべてに共通する代償です。フォークしたvLLMは自動的に古びます。
vLLMは二週間に一度くらいマイナーリリースが出ます。その間に新モデル対応、新カーネル、新量子化フォーマット、性能改善が入ります。フォークを半年放置すると最新モデルを載せられず、最新アテンションカーネルを使えず、その間に直されたバグをそのまま抱え続けることになります。そのころにはリベースコストが最初の修正コストの何倍にもなっています。
だからコストの順序はこうです。
| 方法 | アップストリーム追跡コスト | いつ |
|---|---|---|
| 設定引数の調整だけ | なし | 常に最初に |
| プラグイン(ロジットプロセッサなど) | 低い。API変更時のみ | 大半のカスタマイズ |
| アップストリームにPRを送りマージされる | レビュー時間。以後0 | 一般的に有用な機能なら最善 |
| フォーク後パッチを維持 | 高い。リリースごとにリベース | 本当に他に方法がないとき |
三行目を強調したいです。私たちが必要とする機能が他の人にも役立つなら、アップストリームに送るのがもっとも安い維持戦略です。マージされた瞬間、維持コストが0になります。
おわりに — 測定のないチューニングは好みの表明にすぎない
この記事で扱ったことの順序が結論そのものです。選点ログをなくし、トークン予算でTTFTとITLを天秤にかけ、CPUを飢えさせず、プレフィックスキャッシュが実際に勝っているかを測ります。そのあとになってようやくPagedAttentionとスケジューラの挙動を理解する必要が出てきて、それを理解してはじめてコードをどこに当てるべきかがわかります。
そしてこの順序のどの段階でも、前後を測らずに進んではいけません。max_num_batched_tokensを8192から16384に上げてスループットが12パーセント増え、p99 TTFTが2倍になったなら、それが改善か改悪かはサービスのSLOだけが答えられます。数字なしにこの判断をするのはチューニングではなく好みの表明です。
最後に一言。vLLMでもっともよく使われる最適化手法は今も「バッチを大きくすること」であり、大半のチームはその余地を使い切る前にソースを開こうとします。 六行を先に確認してください。
参考資料
- vLLM最適化・チューニングガイド: https://docs.vllm.ai/en/latest/configuration/optimization.html
- vLLM V1ユーザーガイド(V0廃止告知を含む): https://docs.vllm.ai/en/latest/usage/v1_guide.html
- vLLMカスタムロジットプロセッサ文書: https://docs.vllm.ai/en/latest/features/custom_logitsprocs.html
- vLLMアテンションバックエンド文書: https://docs.vllm.ai/en/latest/design/attention_backends.html
- vLLMプロファイリング文書: https://docs.vllm.ai/en/latest/contributing/profiling.html
- vLLMベンチマークCLI: https://docs.vllm.ai/en/latest/cli/bench/serve.html
- Kwon et al., Efficient Memory Management for LLM Serving with PagedAttention (SOSP 2023): https://arxiv.org/abs/2309.06180
- Agrawal et al., Sarathi(チャンクドプリフィルの根拠論文): https://arxiv.org/pdf/2308.16369
- vLLMソース(スケジューラ): https://github.com/vllm-project/vllm/blob/main/vllm/v1/core/sched/scheduler.py