vLLM 最適化の技法: GPU・CPU・メモリ・I/O の律速を測定から切り分ける
vLLM の最適化では、遅い処理を見つけるだけでは足りない。その処理を変えたとき、リクエストの完了が早まることを確かめる必要がある。CPU、GPU、通信、ストレージは並行して動くため、処理時間の長さと、応答時間への寄与は一致しない。
先に、本記事で使う実測例を示す。
| 症状 | 修正前 | 修正後 | 原因を絞った測定 |
|---|---|---|---|
| 1 並列の decode が遅い | 44.6-45.4 tok/s | 79.4-80.0 tok/s | 同期と gather を別々に省略。gather を forward の外へ移し、decode に full CUDA graph を使用 |
| GPU の処理を減らしても短いプロンプトで効果が小さい | ITL 中央値 13.2-13.6 ms | 10.7 ms | PP の受信遅延と hyper-connection の fusion を個別・同時に比較 |
| 小さい演算の繰り返しが長い | 32 µs/回 | 13.3 µs/回 | hyper-connection を演算単位で測定し、6 カーネルを 2 カーネルに融合 |
| KV の設定を少し増やすとホスト RAM が急増する | 112 GiB | 66 GiB | 同じ 590 MB の KV 設定で pinned allocator の切り上げを切り替え |
| 初回の長いプロンプトだけ TTFT が長い | 43.95 s | 8.26 s | mmap のコールド・ウォーム比較、ランダム読み出し、prefetch の比較 |
各行の数値を同一構成の結果として合成してはいけない。比較条件は各節に記す。
このモデルは MoE、linear attention、sparse attention を組み合わせている。さらに独自パッチで KV をホスト RAM に置き、PLE の n-gram テーブルを mmap している。PLE はトークン列から行番号を計算して参照する埋め込みテーブルである。stock の vLLM で、すべてのモデルが推論中に NVMe を読むわけではない。 本記事では、この構成に固有の現象と、他のモデルにも使える調査方法を分けて説明する。
本記事のために新しい GPU 実験は行っていない。コマンド中の入力長、出力長、測定時間は実行例であり、新しい実測結果ではない。CLI やメトリクス名はバージョンに依存するため、使用中のイメージの --help と /metrics を優先する。
1. 「遅い」を測定可能な症状に分ける
起動、TTFT、decode、同時処理を分ける
| 対象 | 測る区間・値 | 最初に分ける原因 |
|---|---|---|
| 起動時間 | プロセス開始から API が ready になるまで | ウェイト読み込み、repack、コンパイル、graph capture、KV 確保 |
| TTFT | リクエスト送信開始から最初の生成トークンまで | クライアント・ネットワーク、前処理、待ち行列、prefill |
| ITL | 生成トークン間の時間 | decode の GPU 演算、CPU の準備、同期、転送、I/O |
| リクエスト完了時間 | 送信開始から応答完了まで | TTFT、出力の長さ、decode、後処理 |
| throughput | 測定区間に生成したトークンの合計 / 経過時間 | batching、並列方式、KV 容量、負荷量 |
| テールレイテンシ | TTFT・ITL の p90、p99、最大値 | 一時的な I/O 待ち、reclaim、preemption、他の処理との競合 |
最初のトークンが遅い場合と、その後のトークンが遅い場合では調査する区間が違う。起動時のコンパイルを減らしても、起動済みサーバの定常 decode が速くなるとは限らない。
生成トークン数を N、送信開始を t0、最初と最後のトークンの到着を t1、tN とする。各トークンの到着を観測できる場合、次のように定義できる。
TTFT = t1 - t0
平均 TPOT = (tN - t1) / (N - 1) [N > 1]
decode の平均速度 = (N - 1) / (tN - t1)
TPOT は time per output token である。これは N / (応答完了 - t0) と異なる。後者には TTFT や完了通知の時間も含まれる。比較では分母を揃える。
SSE のイベント数を、そのままトークン数にしてはいけない。role のみのイベント、usage、終了通知を除く必要がある。speculative decoding、streaming のまとめ出し、プロキシの buffering があれば、1 イベントに複数トークンが含まれる。reasoning と回答本文の分離も考慮する。トークンごとの時刻が得られない場合は「受信 chunk 間隔」として報告し、サーバ側の token metrics と照合する。
1 並列の速度と合計 throughput を分ける
並列度を増やすと、合計 tok/s が増える一方で、各リクエストの ITL が長くなる場合がある。1 人の応答を早めることと、同じ時間で多くのリクエストを処理することは別の目的である。
調査の最初に「どの入力長・出力長・負荷で、どの指標を改善するか」を決める。たとえば長いコンテキストの 1 並列 decode が対象なら、短いプロンプトの最大 throughput だけを測っても判断できない。
2. 原因は処理時間の合計ではなく依存関係から考える
GPU の起動は非同期である。CPU が次の入力を準備している間に、前に起動した GPU の処理が進む。複数 GPU の通信や、ファイルの prefetch も重なり得る。
応答完了に至る依存関係のうち、最も時間がかかる経路を critical path と呼ぶ。短縮した処理がこの経路に入っていなければ、応答時間は変わらない。
CPU: [入力準備] [GPU 処理を起動] [次の準備........]
GPU: [演算..............]
途中に同期がある場合:
CPU: [起動] [GPU の完了を待つ........] [次の準備]
GPU: [演算...................] [次の演算]
↑ 準備が終わるまで待つ
cudaMemcpy や aten::to が長くても、転送するデータが多いとは限らない。それ以前に同じ stream に投入された仕事の完了を待っている可能性がある。逆に、小さい転送でも CPU の先行実行を止めれば、後続の演算起動が遅れる。
調査では、次の順で確認する。
- 遅いリクエストが、どの区間で時間を使っているか。
- その区間で GPU は演算中か。CPU は実行中か、待機中か。
- 待っている場合、何の完了を待っているか。
- その依存関係だけを変える比較を作れるか。
- 変更後、profiler を外して応答時間が短くなるか。
使用率は候補を絞る情報である。原因を確定する情報は、依存関係と、条件を揃えた比較である。
3. 比較前に再現条件を固定する
保存する条件
| 分類 | 保存するもの |
|---|---|
| ソフトウェア | コンテナの digest、vLLM のバージョン・commit、パッチ、PyTorch・CUDA・driver のバージョン |
| モデル | モデルと tokenizer の revision、量子化形式、attention backend、実際に選ばれた kernel backend |
| 起動設定 | 起動引数、環境変数、TP・PP・DP、KV 設定、graph・compile・speculative decoding の設定 |
| リクエスト | プロンプト、実際の token 数、chat template、出力長、seed、sampling、thinking、structured output |
| 負荷 | 並列度、到着率、測定件数、ウォームアップ、成功・失敗・タイムアウト件数 |
| ハードウェア | GPU の接続、PCIe・NUMA、クロック・温度・電力、他の GPU・CPU・ストレージ負荷 |
起動引数だけでなく、起動ログに出る実効設定も保存する。指定した高速経路に入らず、別の実装へフォールバックしている場合がある。単に環境変数を追加した事実は、その最適化が有効になった証拠にはならない。
3 種類のキャッシュを区別する
| 種類 | 再利用するもの | 比較で確認すること |
|---|---|---|
| prefix cache | 既に計算した prefix の KV や state | ヒットした token 数、新しく計算した token 数 |
| OS のページキャッシュ | mmap やファイル読み出しのページ | 対象ファイルの resident 量、実際のストレージ読み出し |
| コンパイル・autotune・graph | コンパイル結果、選ばれた kernel、capture 済みの実行 | 初回処理を含むか、測定開始前に完了したか |
prefix caching を無効にしても、OS のページキャッシュは無効にならない。サーバを再起動しても、ホストのページキャッシュは残る。prefix cache のリセットと、ファイルのコールド読み出しは別の操作である。
定常 decode の比較には、固定プロンプトと固定 seed を使う。履歴の 9 月 23 日の比較では、ウォームアップ 1 回の後に 3 回測定した。新しい文章のキャッシュミスやドライブの停止を評価する実験は、別に実施した。
この回数は、微小な差の有意性や p99 を保証するものではない。生データ、測定件数、実行間のばらつきを併記する。p99 は少数標本ではほぼ最大値になるため、長い測定でも同じ傾向か確認する。遅かった回を理由なく捨ててはいけない。
4. 最初にクライアントとサーバの基準値を取る
小さい負荷から再現する
次は、起動済みの検証サーバへ送る合成負荷の例である。MODEL はサーバで公開しているモデル名、TOKENIZER は対応する tokenizer のローカルパスに置き換える。
MODEL='your-served-model-name'
TOKENIZER='/path/to/tokenizer'
vllm bench serve \
--backend vllm \
--base-url http://127.0.0.1:8000 \
--endpoint /v1/completions \
--model "$MODEL" \
--tokenizer "$TOKENIZER" \
--dataset-name random \
--random-input-len 1024 \
--random-output-len 256 \
--num-prompts 16 \
--max-concurrency 1 \
--request-rate inf \
--ignore-eos \
--seed 7 \
--percentile-metrics ttft,tpot,itl,e2el \
--metric-percentiles 50,90,99 \
--save-result \
--save-detailed \
--result-dir bench-results
これは /v1/completions の基準測定である。本番が chat、tool call、画像、structured output を使う場合は、同じ経路・template を使う負荷も別に保存する。ランダム入力の結果を、そのまま本番の結果として扱わない。
--ignore-eos は出力長を揃えるための設定である。結果に記録された実際の出力 token 数も確認する。指定値と実績が違えば、分母が変わる。
同じ seed のベンチを繰り返すと、prefix cache がヒットする可能性がある。未キャッシュの prefill を測る場合は、検証サーバで prefix caching を無効にするか、そのバージョンで対応するリセット操作を使う。ヒットが無いことを metrics でも確認する。繰り返しベンチの注意点は vLLM の benchmarking CLI の説明にも記載されている。
負荷の種類を変えて比較する
| 比較 | 固定するもの | 切り分けるもの |
|---|---|---|
| 短い / 長いプロンプト | 出力長、並列度、cache 条件 | prefill、長いコンテキストでの attention |
| 1 並列 / 複数並列 | 入力と出力の分布、cache 条件 | batching、通信、KV 圧迫 |
| 同一文章の再送 / 未読の文章 | 長さと文章の種類、compile 条件 | prefix cache とページキャッシュの効果 |
| localhost / 実際の接続経路 | リクエスト本文、負荷 | TLS、プロキシ、ネットワーク、クライアント |
| profiler 無し / 有り | リクエストとサーバ設定 | 測定器による変化 |
--request-rate inf と並列度の制限を組み合わせた測定では、完了した分だけ次を投入する。処理能力の比較には使えるが、一定の到着率で流入する本番の待ち行列を再現しない。到着率を指定する測定も追加し、実際の送信率と未完了件数を確認する。クライアント側の並列上限や CPU が先に制限していれば、指定した到着率には達しない。
サーバ側の区間も保存する
curl -fsS http://127.0.0.1:8000/metrics > metrics-before.txt
# この間にベンチを実行する
curl -fsS http://127.0.0.1:8000/metrics > metrics-after.txt
rg 'vllm:(num_requests_(running|waiting)|.*preempt|.*cache.*usage|request_(queue|prefill|decode)_time_seconds|time_to_first_token_seconds)' \
metrics-after.txt
waiting、running、KV 使用率を時系列で見る。queue・prefill・decode の histogram は前後の差分を見る。累積 histogram の過去のリクエストを、今回の測定に混ぜない。公開されている項目と意味は vLLM の Production Metricsで確認できる。
TTFT が長く、queue time も長ければ、まず待ち行列を調べる。1 並列では正常でも負荷を増やすと waiting が増え続けるなら、投入量が処理能力を超えている可能性がある。そこで prefill の kernel だけを短くしても、負荷条件を確認しなければ改善を評価できない。
サーバログの Avg prompt throughput を、個々の TTFT に換算してはいけない。履歴では 4,206 tok/s と出た測定窓の一方で、約 42k tokens の初回 TTFT が約 100 s だった。調査したビルドでは prompt token 数の計上時刻が影響していた。クライアントの開始・初トークン時刻を直接測る必要がある。
5. GPU・CPU・RAM・I/O を同じ時間帯に観測する
GPU だけを見てから、別のリクエストで CPU を見ると、同じ停止を比較できない。リクエストの開始・終了時刻と、各ログの時刻を対応付ける。まず軽い観測を行い、profiler はその後に使う。
以下は別ターミナルで動かす例である。WORKER_PID にはホストから見た対象 worker の PID を設定する。API server、engine、各 GPU worker は別プロセスの場合があるため、それぞれを確認する。終了は Ctrl-C で行う。
nvidia-smi \
--query-gpu=timestamp,index,utilization.gpu,utilization.memory,memory.used,power.draw,clocks.sm,clocks.mem,temperature.gpu \
--format=csv -lms 200
WORKER_PID=12345
pidstat -t -u -r -d -w -p "$WORKER_PID" 1
vmstat 1
iostat -x -z -y 1
pidstat と iostat は sysstat のツールである。GPU の項目は製品・driver により N/A になる。200 ms ごとに取得しても、driver 内部の標本化周期より細かい実態が観測できるとは限らない。短い停止は trace で確認する。
値と結論の間に、確認すべきことがある
| 観測 | 候補 | 次に確認すること |
|---|---|---|
| GPU 使用率が高い | 演算、メモリ読み出し、NCCL の待機 kernel | 実行中の kernel 名と hardware counters |
| GPU の処理に空き時間がある | CPU の準備、同期、通信、I/O、入力不足 | 空き時間の直前の CPU stack と依存先 |
| worker の 1 thread が CPU を使い続ける | Python の実行、tokenization、CUDA の busy wait | CPU の stack。実行中か待機中か |
majflt/s と読み出しが同時に増える | mmap のページキャッシュミス | 対象ファイル、fault する箇所、cold/warm の差 |
| RAM が減り I/O が増える | reclaim、swap、ページキャッシュの不足 | MemAvailable、PSI、si/so、cgroup の制限 |
| queue と preemption が増える | 処理能力不足、KV 容量不足 | 到着率、running 数、KV と再計算の増分 |
| 平均は悪いが p50 は正常 | 一部の長い停止 | p99・最大値と、同時刻の I/O・CPU・通信 |
utilization.gpu は、標本期間中に kernel が動いていた時間の割合である。演算能力を何割使ったかではない。utilization.memory も、device memory を読み書きしていた時間の割合であり、ピーク帯域に対する達成率ではない。定義は NVIDIA の nvidia-smi 文書にある。mem% が低いことだけで「帯域が何倍余っている」とは算出できない。
CPU も同様である。全体の使用率が低くても、逐次処理する thread が実行可能な CPU を使い切っている場合がある。反対に CPU 使用率が高くても、CUDA の完了を busy-poll しているだけの場合がある。
CPU の stack から実行と待機を分ける
Python 側の停止位置は、対応する Python バージョンの py-spy で確認できる。
py-spy dump --pid "$WORKER_PID"
同じ場所で繰り返し停止しているかを見る。tokenizer や metadata の生成にいるのか、コピーや通信を呼んだ先で待っているのかを分ける。Python の stack だけでは、native extension 内で何をしているかまで分からない場合がある。
実行中の native code を調べる場合は、短い CPU sampling を追加する。
perf record -F 99 -g -p "$WORKER_PID" -o worker.perf.data -- sleep 15
perf report --stdio -i worker.perf.data
これは perf record による実行中の sampling であり、sleep している thread の待機時間の集計ではない。CUDA の polling が多ければ、CPU の計算量を減らす修正の根拠にはならない。待機中の thread は wchan、stack、GPU trace と対応付ける。attach の権限、取得できる symbol、sampling のオーバーヘッドも環境に依存する。
コンテナの上限と NUMA を確認する
ホストに空きがあっても、コンテナには CPU quota やメモリ上限がある。ホストの cgroup v2 で対象プロセスの所属を調べ、該当するディレクトリの値を前後で取得する。
cat "/proc/$WORKER_PID/cgroup"
# 実際の cgroup のパスに置き換える
CGROUP_DIR='/sys/fs/cgroup/path/to/the/container'
cat "$CGROUP_DIR/cpu.max" "$CGROUP_DIR/cpu.stat"
cat "$CGROUP_DIR/memory.current" "$CGROUP_DIR/memory.max"
cat "$CGROUP_DIR/memory.events" "$CGROUP_DIR/memory.stat"
cat "$CGROUP_DIR/memory.pressure" "$CGROUP_DIR/io.pressure"
nr_throttled と throttled_usec の増加は、その cgroup の CPU quota による制限を調べる手掛かりになる。親 cgroup の制限も確認する。memory.events の high、max、oom、oom_kill の増分は、メモリ制約を調べる情報になる。各値の定義は Linux の cgroup v2 文書を参照する。
複数 socket の環境では nvidia-smi topo -m、numactl --hardware、numastat -p "$WORKER_PID" も確認する。GPU に近い CPU と、ホスト側バッファを確保した NUMA node が違えば、転送に別の経路が加わる。binding を変える場合は CPU とメモリの配置を記録し、同じ負荷で比較する。
6. profiler は時間の長い場所より、待つ理由を見る
少数のリクエストを capture する
対応するビルドでは、検証サーバの起動時に次を指定する。通常のモデル・並列設定も同じコマンドに付ける。
vllm serve /path/to/model \
--profiler-config '{"profiler":"torch","torch_profiler_dir":"/tmp/vllm-traces"}'
curl -fsS -X POST http://127.0.0.1:8000/start_profile
# 保存済みの再現用リクエストを少数送る
curl -fsS -X POST http://127.0.0.1:8000/stop_profile
記録は Perfetto などで開ける。複数 worker では全 rank のファイルを確認する。起動設定、開始・停止 API、短い capture の方法は vLLM の profiling 文書で確認できる。古いビルドでは設定方法が違うため、例をそのまま適用しない。
PyTorch Profiler で演算と呼び出し元を調べる。CPU thread、CUDA API、stream、通信の関係を詳しく見る場合は Nsight Systemsを使う。特定の kernel のメモリ読み出しや演算効率を調べる段階では Nsight Computeを使う。全体の待機時間と、kernel 内部の効率は別の測定である。
同じ軸に並べて確認する
- GPU の空き時間の直前に、何を起動していたか。
- CPU は Python の準備、CUDA API、通信のどこにいるか。
- コピーは H2D か D2H か。サイズと stream は何か。
- NCCL の時間は実データ転送か、送信元の準備待ちか。
- 前段の完了、受信の完了、次段の launch がどう対応するか。
集計表の CUDA 時間を全部足しても、リクエストの時間にはならない。stream 間や GPU 間で重なるためである。CPU の inclusive time と子の時間を足すことも二重計上になる。
独自の host trace を足す場合は、同一ホスト内で monotonic_ns などの単調時計を使う。request ID、step、rank、開始・終了を記録する。別ホストの単調時計の値は、そのまま比較できない。開始時刻だけで通信を対応付けると、先に受信を待っていた別 step と取り違える場合がある。
profiler を付けた tok/s を最終結果にしない
9 月 15 日の調査では、通常約 45 tok/s の decode が profiler 付きで約 24 tok/s になった。9 月 23 日には、full CUDA graph 内の kernel 時間の合計が 12.15 ms と表示された一方、通常時の ITL は 10.66 ms だった。
このため、trace は処理の順序と候補の発見に使った。GPU 区間は graph replay の前後に CUDA event を置いて再測定した。終了 event の確認は測定対象の後で行い、毎 step の synchronize() を新たに挿入しない。別 stream の仕事まで含める場合は、その依存関係も event に反映する。
kernel 時間だけは常に profiler の影響を受けない、という保証もない。 hardware counters の取得では replay や cache 制御も影響する。最終的な効果は profiler と詳細な計装を外した状態で確認する。
7. CPU の実行と同期を分ける: gather を省略する実験
症状: decode が約 45 tok/s で止まる
9 月 15 日の構成では、PLE テーブルをホストで gather していた。問題の経路には、GPU にある行番号を CPU に戻す処理があった。
ids = flat_ids.to(device="cpu", dtype=torch.int64).numpy()
転送サイズだけを見れば小さい。しかし同期 D2H のために CPU が待ち、その後の eager 区間の演算起動が遅れる可能性があった。np.take による gather や NVMe の読み出しが遅い可能性もあった。
原因の確認: 同期を残す比較を作る
検証専用に、一部の処理を省略する実装を作った。条件は RTX 3090 3 枚、PP=3、コンテキスト上限 262,144、--max-num-seqs 4、KV 550 MB、prefill chunk 512、QSA offload 有効、prefix caching 無効である。1 並列、出力 256 tokens、ウォームアップ 1 回、計測 3 回で比較した。
| 比較 | 同期 D2H | gather・H2D | graph | decode、3 回 |
|---|---|---|---|---|
| A: 元の処理 | 有り | 有り | PIECEWISE + breakable | 45.3 / 45.4 / 45.6 tok/s |
| D: 同期だけ残す | 有り | 省略 | PIECEWISE + breakable | 45.5 / 45.4 / 45.7 tok/s |
| B: 同期も省略 | 省略 | 省略 | PIECEWISE + breakable | 73.9 / 74.9 / 75.4 tok/s |
| C: B の graph を変更 | 省略 | 省略 | FULL_DECODE_ONLY | 80.8 / 80.7 / 80.8 tok/s |
A と D はほぼ同じだった。この条件の gather・H2D を省略しても、改善は確認できなかった。D と B の差により、同期によって CPU の先行実行が制限されることが主要因と分かった。
B、C、D は正しい出力を生成する実装ではない。 処理を省略して原因と改善余地を調べた実験であり、利用者向けの性能値ではない。特に出力が変われば MoE の routing や参照ページも変わり得るため、この実験だけで最終的な改善量は確定できない。
修正: 正しい処理を保ち、full graph を使える形にする
実際の修正では gather を省略しなかった。forward の前に既存の固定アドレスの device buffer を埋め、forward 内ではその buffer を読むようにした。CPU の処理が forward の外へ移り、decode を full CUDA graph で実行できるようになった。
同一ビルドで gather の位置と graph を分けて比較した。
| 正しい出力を返す構成 | decode、3 回 |
|---|---|
| forward 内で gather、PIECEWISE | 44.6 / 44.6 / 45.4 tok/s |
| gather を forward の外へ移す、PIECEWISE | 45.1 / 45.1 / 45.8 tok/s |
| gather を forward の外へ移す、FULL_DECODE_ONLY | 79.4 / 80.0 / 80.0 tok/s |
gather を移動するだけでは速くならなかった。移動は full graph を使うための前提条件だった。「gather の移動による改善」と「graph による改善」を別々の加算可能な効果として扱ってはいけない。
最初の記事では、この実験から「gather を速くする価値はゼロ」と書いた。この表現は範囲が広すぎた。正確には、この定常 decode の比較では、gather・H2D の省略による改善を確認できなかったである。11 節に示す、ページキャッシュミスが多い prefill には当てはまらない。
8. CPU と GPU の両方が制限する: PP の受信順序
症状: fusion の効果が入力条件で変わる
hyper-connection の GPU 演算を融合しても、短いプロンプトでは step が約 0.4 ms しか短くならなかった。一方、8k の条件では約 2 ms 短くなった。GPU の演算量を同じように減らしても、応答への寄与が違っていた。
原因: 受信待ちの後に CPU の準備を始めていた
先頭以外の PP rank は、Worker.execute_model の冒頭で irecv_tensor_dict() を呼んでいた。その内部の recv_object が前段からのメタデータを待った。入力準備と attention metadata の構築は、その後に始まっていた。
この CPU 処理は rank あたり 3.2-3.5 ms だった。前段の GPU の処理と同時に実行できれば、待機時間を減らせる。
変更前:
前段 GPU [forward........]
次段 CPU [受信待ち.......][入力・metadata 準備]
次段 GPU [forward]
変更後:
前段 GPU [forward........]
次段 CPU [入力・metadata 準備][受信]
次段 GPU [forward]
これは処理順序の模式図であり、横幅は実測時間を表さない。
修正と結果: 個別と同時適用を比べる
受信を forward の直前まで遅らせた。必要な受信の対応と、GPU 上の受信・forward・送信の順序は維持した。
同一ビルド、prefix caching 有効、QSA offload 有効の構成で、環境変数による比較を行った。短いプロンプト、固定 seed、出力 400 tokens、ウォームアップ 1 回、計測 3 回が基準である。表の値は当時のクライアント計測による ITL 中央値である。
| 受信遅延 | hyper-connection fusion | 短いプロンプトの ITL 中央値 |
|---|---|---|
| 無し | 無し | 13.2-13.6 ms |
| 有り | 無し | 12.7-12.8 ms |
| 無し | 有り | 12.7-13.3 ms |
| 有り | 有り | 10.7 ms |
単独の効果は小さかったが、両方を適用すると大きく短縮した。CPU の準備を重ねても GPU が長ければ GPU に制限される。GPU を短くしても CPU の直列処理が残れば CPU に制限される。
このような場合は、2 つの変更の有無を組み合わせた比較が必要である。単独の効果を測って足し合わせても、同時適用の結果は予測できない。
なお、当時のクライアント測定は SSE の受信イベント間隔から先頭付近の区間を除いて集計していた。MTP 無効の同じ経路での比較には使ったが、token ごとの到着を独立に検証した計測ではない。これらの履歴値を、MTP や別の streaming 実装の token ITL と無条件に比較してはいけない。
9. GPU の内部を調べる: 帯域、演算、小さい kernel
モデル全体の容量から decode の読み出し量を決めない
MoE は毎 token ですべての expert を読まない。decode の読み出し量を見積もる場合は、dense 部分、選択される expert、scale、KV、その他の一時データを分ける。量子化形式による packing と、実際に動く kernel も確認する。
9 月 15 日の調査では、safetensors のヘッダと expert の選択数から、ウェイトの読み出し量を約 5.83 GB/token と見積もった。RTX 3090 の仕様上の帯域 936 GB/s を使うと、単純な読み出し時間は次になる。
5.83 GB / 936 GB/s ≈ 6.23 ms/token
1 / 6.23 ms ≈ 160 tok/s
これは履歴の数値からの計算である。演算、dequantization、通信、KV、CPU の準備を含まない。cache の再利用や読み直しも単純化している。PP の各段を順に通る 1 リクエストについて、ウェイトの読み出しだけを考えた楽観的な基準であり、達成可能な性能の保証ではない。
測定した時間と差があっても、その差を全部「kernel の非効率」としてはいけない。CPU の待機や GPU の空き時間を含む時間で割れば、値は低くなる。
演算律速とメモリ律速を分ける
演算が途切れず動いている区間でも、GPU 内部の制限は複数ある。対象 kernel の実際の shape、dtype、演算数、メモリ階層ごとの読み書きを調べる。
| 仮説 | 確認する測定 | 比較の考え方 |
|---|---|---|
| DRAM の読み出し帯域が制限する | DRAM の実バイト数と時間、L2 hit、メモリ経路の counter | 同じ演算で読み出し量を減らしたときに短縮するか |
| 演算量が制限する | 実行命令、演算 pipeline の稼働、対象 dtype の演算性能 | byte 数だけを減らしても変わらず、演算量を減らすと短縮するか |
| 小さい仕事の固定費が大きい | launch 数、kernel の長さ、CPU からの投入間隔 | fusion や graph で固定費を減らすと短縮するか |
| アクセスの latency が制限する | 帯域が低い理由、依存した load、並列な要求数 | 読み出しを並行化・再利用すると短縮するか |
counter が高いことだけで、最適化案が有効とは決まらない。L2 から供給されている kernel を DRAM の仕様帯域で評価したり、INT4 の dequantization を BF16 の行列演算だけで評価したりすると、比較対象が違う。microbenchmark も実運用と同じ cache 条件、shape、layout で測る必要がある。
kernel 単位で形状と時間を見る
GPU の実行区間が critical path に残った段階で、大きい演算と小さい演算を分けて測った。
| 演算 | shape | 元の時間 | ウェイト量から見積もった実効帯域 |
|---|---|---|---|
| hyper-connection down、INT8 g64 | 320 × 10240 | 19.4 µs | 約 170 GB/s |
| hyper-connection up、INT8 g64 | 10240 × 320 | 7.5 µs | 約 450 GB/s |
| router、BF16 | 512 × 2560 | 12.3 µs | 約 210 GB/s |
これはウェイトのバイト数を時間で割った推定である。DRAM hardware counter で測った帯域と同じではない。L2 cache、scale、読み直しなどの影響を確認するには、別の counter が必要になる。
大きい projection では高い実効帯域が出ていた一方、小さい GEMV は遅かった。小さい演算では、データ量の削減より、kernel 起動回数、中間 tensor、reduction の処理が問題になる場合がある。
hyper-connection の 1 回の処理を 6 カーネルから 2 カーネルへ融合すると、32 µs から 13.3 µs になった。この処理は 1 token あたり 96 回ある。単純な積では (32 - 13.3) µs × 96 ≈ 1.80 ms の短縮余地になる。ただし、応答時間に現れる量は 8 節の CPU との重なりに依存する。
decode の最適化で prefill を遅くしない
decode の小さい batch と、prefill の GEMM は形状が違う。同じ tile を使う根拠にはならない。
履歴では prefill の down projection に 32 × 64 の tile を使った。64 × 64 にすると、当該 kernel が 176 µs となり、prefill 全体が 4% 遅くなった。decode だけで採用を決めず、長いプロンプトの TTFT と activation のピークも確認する。
10. メモリは「容量」と「速度」を分けて調べる
VRAM の使用量を分解する
VRAM にはウェイト以外にも、KV、activation、workspace、CUDA graph、通信 buffer がある。PyTorch の allocator が保持する未使用領域もある。
import torch
print("allocated", torch.cuda.memory_allocated())
print("reserved", torch.cuda.memory_reserved())
print("peak_allocated", torch.cuda.max_memory_allocated())
print("peak_reserved", torch.cuda.max_memory_reserved())
これは worker の中に計測を追加する例である。別プロセスから実行しても、対象 worker の統計は取れない。nvidia-smi と PyTorch の統計には集計対象の違いもある。
起動直後、load 後、repack 後、capture 後、prefill のピーク、decode 中を分ける。OOM が起きたフェーズを確認する。prefill の一時領域で OOM になっているなら、定常時の空きだけでは判断できない。
9 月 23 日の初期実装では、allocated と max_allocated が同じでも reserved が増えた。アイドル時の GPU1 の使用量が約 190 MiB 増えていた。原因は load_model 中の確保パターンの変化だった。repack を省略したことで、他の確保の再利用条件が変わっていた。
この実装では、確保と解放のパターンを調整して改善した。一般に reserved - allocated の全量が断片化とは限らない。再利用可能な cache も含まれる。必要なら memory snapshot で確保の履歴を見る。empty_cache() だけを繰り返しても、生存 tensor や分割済み領域の問題を必ず解消できるわけではない。
ホスト RAM の急増は確保サイズまで追う
QSA の host KV を増やしたところ、RAM 使用量が不連続に増えた。調査時の PyTorch の pinned allocator は、確保サイズを 2 の冪に切り上げていた。
1 layer の必要量は約 4.239 GiB だった。これが 8 GiB に切り上がった。12 layer で確保されるため、必要量との差が大きくなった。
| 同じ KV 設定 590 MB、183 blocks | ホスト RAM 使用量 | available |
|---|---|---|
| pinned allocation の切り上げ有り | 112 GiB | 13 GiB |
| 大きい pinned allocation の切り上げを停止 | 66 GiB | 59 GiB |
このビルドでは pinned_max_round_threshold_mb:2048 による比較で確認した。これは使用した PyTorch の実装に対する診断である。すべてのバージョンに有効な推奨設定ではない。最終的な配布実装では、必要サイズを直接確保して pin する方式に変更した。
ホスト RAM の調査では次を確認する。
free -h
rg 'MemAvailable|Cached|Swap|Mlocked|Unevictable' /proc/meminfo
cat "/proc/$WORKER_PID/smaps_rollup"
cat "/proc/$WORKER_PID/io"
RSS を全 worker で単純に合計すると、共有ページを重複計上する。PSS、anonymous、file-backed、shared の内訳も見る。CUDA が pin した領域を Mlocked だけですべて把握できるとは限らない。allocator の統計、確保ログ、ホスト全体の使用量を突き合わせる。
また pinned RAM は通常のファイルキャッシュのように回収できない。KV 容量を増やすと、PLE のページキャッシュに使える RAM が減る。この構成では、メモリ容量の変更がストレージ I/O と TTFT に影響した。
RAM が足りる場合も、ホストのメモリ帯域は別に調べる
ホストにデータが常駐していても、多数の worker の読み出しや staging のコピーが競合する場合がある。これは page fault やストレージ待ちとは別である。
同じ resident データに対する gather・コピーの時間を測る。NUMA ごとの配置と、同時に読む worker 数を変えて比較する。可能なら CPU のメモリコントローラの counter も取る。LLC miss の件数だけから、DRAM 帯域が飽和しているとは判断しない。
GPU が UVA でホストを読む時間には、ホスト RAM、NUMA 接続、PCIe、GPU 内の load の依存関係が含まれる。ホスト上のコピーが速いことだけでは、GPU からの読み出しも速いと結論できない。
KV の不足は preemption と再計算で確認する
KV が不足すると、待機や preemption が増える場合がある。使用率の瞬間値だけでなく、測定区間の preemption 増分、新しく計算した prompt token 数、完了時間を見る。
--max-num-seqs や --max-num-batched-tokens を下げれば、同時実行量や一時領域を減らせる場合がある。一方で throughput や TTFT は変わる。chunked prefill の大きさと ITL・TTFT の関係は vLLM の tuning 文書に説明されている。目的の負荷で両方を再測定する。
11. I/O は MB/s だけでなくページ数と待ち時間を見る
症状: 初回だけ TTFT が長い
9 月 22 日の構成では、PLE の mmap テーブルは約 95.4 GiB だった。ホスト RAM は約 125 GiB で、QSA の host KV が約 47.5 GiB を pinned で使っていた。テーブル全体をページキャッシュに保持できなかった。fincore で resident 量を確認すると、約 2.5 GiB だった。
prefill は np.take で必要な行を順に読んでいた。参照先がページキャッシュに無い場合、処理する thread がページの読み込みを待つ。この時点で GPU が空いていても、原因は GPU の演算性能ではない。
原因の確認: 同じ gather を CPU 側で測る
同じ mmap と行番号を使ったホスト側の測定は次だった。
| 処理 | 時間 |
|---|---|
コールドな np.take、512 tokens 分 | 1,902 ms |
ウォームな np.take、同じ規模 | 約 2 ms |
| 必要ページを先に要求し、その後 gather | 230 + 50 = 280 ms |
算術処理が同じでも、ページの状態で時間が大きく違った。ここで CPU の計算を最適化する前に、page fault と I/O を調べる根拠が得られた。
PLE は 1 token あたり 16 行を参照する。少量の有効データでも、ページ単位では別々のランダム読み出しになる。対象ドライブの 4 KiB ランダム読み出しは QD1 で約 4.5k IOPS、QD64 以上で約 36k IOPS だった。QD は未完了の I/O 数、IOPS は 1 秒あたりの I/O 数である。
1 件の完了を待ってから次を発行すれば、NVMe でも QD は上がらない。大きいファイルを順次読む GB/s から、この処理の時間は推定できない。
修正: 行番号が確定した時点でページを要求する
この PLE では、プロンプト全体の参照行を、入力 token 列だけから計算できた。そのためリクエスト受付時に行番号を計算し、prefill が到達する前に別 thread からページを要求した。gather の計算そのものは変更していない。
同じマシンで、未読の vLLM の Python ソースを使った約 37k tokens の測定は次だった。
| 条件 | コールド TTFT | 同一プロンプトの 2 回目 |
|---|---|---|
| prefetch 無し | 43.95 s | 6.69 s |
| prefetch 有り | 8.26 s | 6.37 s |
A/B の入力は、長さと種類を近づけた別の未読ファイルである。 完全に同じ入力・同じ resident ページ集合の比較ではない。この表だけから、厳密な同条件の改善倍率を算出しない。host gather と QD の測定も合わせ、直列 page fault が主要因だったと判断した。
他の環境で検証する場合は、cold/warm の定義を先に決める。同じ入力を同じ cache 状態に戻せる隔離環境を使うか、複数の同種入力を A/B に分けて順序を入れ替える。未読の文章でも、共通 n-gram のページは既にキャッシュされている場合がある。resident 量と実際の読み出し量も保存する。
prefix cache をリセットするだけでは、この cold 条件は作れない。共有ホスト全体の cache を消す操作は、比較対象以外の処理まで変えてしまう。
p99 が悪い場合は停止中の thread とドライブを対応付ける
for task_dir in /proc/"$WORKER_PID"/task/*; do
printf '%s ' "${task_dir##*/}"
cat "$task_dir/wchan"
done
履歴では decode 中の 100-780 ms の停止時に folio_wait_bit_common が観測された。ページの I/O 完了待ちを調べる手掛かりになった。wchan だけでは対象ファイルやデバイスまでは決まらないため、stack、/proc/<pid>/io、デバイスの統計も合わせる。
同じ条件の O_DIRECT による 4 KiB ランダム読み出しでは、元のドライブで最大 970 ms、比較したドライブで最大 22 ms が記録された。これは各測定期間中の最大値であり、製品全体の保証値ではない。kernel の修正で改善する現象と、ストレージ側の長い応答を分けて考える必要があった。
次は既存ファイルを読み出す追加調査の例であり、上の履歴値を測ったスクリプトそのものではない。
TABLE_FILE='/path/to/existing/table.bin'
fio --name=table-randread \
--filename="$TABLE_FILE" \
--readonly --rw=randread --bs=4k \
--direct=1 --ioengine=libaio --iodepth=1 --numjobs=1 \
--time_based --runtime=30 --randrepeat=1 \
--group_reporting --output-format=json --output=fio-qd1.json
同じファイル・条件で --iodepth=64 と出力名を変えて比較する。IOPS、帯域、completion latency の分布、実際の depth を保存する。指定した depth に達するかも確認する。O_DIRECT は通常のページキャッシュを避ける測定であり、実際の mmap の cache hit 率や filesystem の動作を再現するものではない。オプションの意味は fio の公式文書を参照する。
12. PCIe・通信・オフロードは「平均帯域」と「待ち」を分ける
転送量が小さくても latency は残る
転送バイト数を帯域で割れば、理想的な転送時間を見積もれる。しかし実際には、そのデータがいつ確定するかが重要である。
QSA では、その layer の indexer が読む行を選ぶ。その選択が終わるまで、必要な K/V の読み出しを始められない。1 秒間の平均 PCIe 帯域に余裕があっても、各 layer の依存関係に沿った待ち時間は残る。
9 月 23 日には QSA offload を無効にした 32k 構成を対照にした。offload 無しではコンテキスト 0、8k、24k の step が約 12.5 ms で一定だった。offload 有りで増えていた約 2 ms を、kernel の計測と照合した。QSA の該当 kernel は 1 layer あたり約 30 µs から 190 µs へ増えていた。
この対照は、offload とコンテキスト長の関係を調べるための実験である。保持できるコンテキスト容量まで同じ構成ではない。通常運用の性能比較とは区別する。
最初の記事の「PCIe 読み出しは計算とオーバーラップできる」という記述は誤りだった。この経路では、選択された行の読み出しが layer 内の依存関係に入り、実測で約 2 ms 増えていた。この訂正は以前の最適化記事にも記した。
転送 API が見えなくてもホストを読んでいる場合がある
UVA で pinned host memory を GPU kernel から直接読む場合、独立した大きい cudaMemcpy としては見えない。遅延は kernel の時間として現れる。ホスト側の配置、読み出し量、PCIe の実効帯域も確認する。
複数 GPU の NCCL も同様である。通信 kernel が長いことだけでは、リンク帯域が不足していると決められない。送り手が遅れていれば、受信側は待機する。全 rank の同じ step を並べ、データの準備から受信完了までを分ける。
接続は nvidia-smi topo -m と lspci -vv で確認する。リンク速度と幅はアイドル時と負荷時で異なる場合がある。仕様上のスロット名ではなく、負荷時に成立している接続を見る。NCCL の debug log は調査用に取得し、最終比較ではログ量も通常に戻す。
PP の 1 並列では各段を通る latency が効く。複数リクエストでは、段ごとの処理時間の偏りも throughput に影響する。TP は計算を分ける代わりに通信を増やす。並列方式の変更は、1 並列の ITL と複数並列の合計 throughput の両方で評価する。
13. 測定から修正へ進むときの比較の作り方
変更前に、仮説と予測を一文で書く
| 仮説 | 仮説が正しければ観測されること | 切り分ける比較 |
|---|---|---|
| CPU の同期が launch を遅らせる | 同期を外すと GPU の空き時間が減る | 同期を残して実作業だけ省略する対照を追加 |
| 小さい kernel の固定費が大きい | fusion で GPU 区間が短くなる | 同じ入力・重みで kernel 数と event 時間を比較 |
| KV の確保がページキャッシュを減らす | KV 増加後に resident 量が減り、I/O が増える | KV 以外を固定し、RAM・fault・TTFT を測定 |
| ランダム読みが直列化している | QD を上げると cold gather が短くなる | ページを先に要求し、同じ gather を実行 |
| 負荷量が処理能力を超える | 到着率に応じて waiting と TTFT が増える | 入力分布を固定して到着率を変更 |
仮説が外れる可能性のある比較を作る。「速い設定を全部有効にして速くなった」だけでは、何が原因だったかを説明できない。
1 変数の比較と、相互作用の比較を使い分ける
まず同じビルドで、1 つの実装だけを有効・無効にする。起動が必要な設定は、再起動ごとのばらつきも含めて測る。同一ビルドであることは、同じプロセスを起動し続けたこととは違う。
差が小さければ、A → B だけでなく B → A の順でも測る。温度、cache、バックグラウンド負荷の時間変化が、設定の差と一致しないようにする。8 節のように両方が必要な場合は、変更 2 つの有無を組み合わせる。
正しさを、実際に変更した経路で検証する
kernel の高速化では、同じ入力・重みの参照出力と比較する。packing はビット単位の往復、top-k は id と tie-break、fusion は丸め位置と tensor の誤差を確認する。prefill、decode、複数リクエスト、異なる長さも対象にする。
サーバ側の実装を変えたなら、そのサーバを通る検証が必要である。Transformers で元の checkpoint を評価しても、vLLM のパッチを通らないため、そのパッチの正しさは確認できない。
生成文の完全一致だけに依存することもできない。9 月 23 日の記録では、prompt logprob の平均絶対差は、旧と新で 0.001995、旧同士の再実行で 0.002010 だった。run 間のばらつきを測ったうえで、tensor の直接比較、分布、タスクの成績を組み合わせる。この比較だけで、あらゆる入力に対する同等性が証明されたわけではない。
speculative decoding では acceptance rate だけで採否を決めない。draft と検証のコスト、確保する KV、TTFT、並列時の throughput も含める。新しく得られた出力 token 数を使って速度を測り、draft token 数と混同しない。
14. 調査を終える前に、改善した条件と制約を残す
最終比較は profiler を外して行う。最初に定義した負荷で、次の値を変更前後に並べる。
| 項目 | 確認すること |
|---|---|
| TTFT・ITL・完了時間 | 中央値、平均、テール、測定件数、run 間のばらつき |
| throughput | 各リクエストと全体、実際の token 数、実際の到着率 |
| 容量 | 最大コンテキスト、同時保持数、KV、preemption |
| リソース | GPU ピーク、host RAM、ページキャッシュ、I/O、電力・温度 |
| 正しさ | 変更した経路の検証、長い入力、複数並列、実際に使う機能 |
一度改善したら、同じ観測をもう一度行う。CPU の同期を減らすと GPU の演算が制限要因になる場合がある。KV を増やすと I/O が制限要因になる場合がある。最初の調査で重要でなかった処理が、次の改善対象になり得る。
実際の切り分けは、次の順で進めると判断しやすい。
- 起動、TTFT、decode、テール、負荷時の待ち行列のどれが遅いかを決める。
- 1 並列で再現し、クライアントの時間とサーバの区間を対応付ける。
- 同じリクエスト中に GPU、CPU、RAM、I/O を観測する。
- trace で待つ理由を調べ、仮説と予測を書く。
- 仮説を区別できる比較を行う。
- 正しい処理を保つ修正を実装し、profiler 無しで再測定する。
- 目的の入力長と並列度で、速度・容量・正しさを再確認する。
まとめ
- 起動時間、TTFT、decode、throughput、テールを分ける。異なる分母の tok/s を比較しない。
- 使用率だけで原因を決めない。CPU の実行と同期、GPU の演算と通信待機、RAM の容量と帯域を分ける。
- prefix cache、ページキャッシュ、コンパイルの状態を別々に記録する。
- trace は仮説を作るために使う。同期と実作業を分けた比較や、個別・同時適用の比較で原因を絞る。
- メモリはピークと確保フェーズまで調べる。pinned RAM の増加は I/O と TTFT にも影響する。
- profiler を外して再測定する。正しさ、prefill、並列時の throughput、保持できる容量も確認する。
Read this post in English: vLLM Optimization Techniques: Diagnosing GPU, CPU, Memory, and I/O Bottlenecks