vLLM 最適化の技法: GPU・CPU・メモリ・I/O の律速を測定から切り分ける

· 33 min · llm, vllm, gpu, profiling, performance

vLLM の最適化では、遅い処理を見つけるだけでは足りない。その処理を変えたとき、リクエストの完了が早まることを確かめる必要がある。CPU、GPU、通信、ストレージは並行して動くため、処理時間の長さと、応答時間への寄与は一致しない。

先に、本記事で使う実測例を示す。

症状修正前修正後原因を絞った測定
1 並列の decode が遅い44.6-45.4 tok/s79.4-80.0 tok/s同期と gather を別々に省略。gather を forward の外へ移し、decode に full CUDA graph を使用
GPU の処理を減らしても短いプロンプトで効果が小さいITL 中央値 13.2-13.6 ms10.7 msPP の受信遅延と hyper-connection の fusion を個別・同時に比較
小さい演算の繰り返しが長い32 µs/回13.3 µs/回hyper-connection を演算単位で測定し、6 カーネルを 2 カーネルに融合
KV の設定を少し増やすとホスト RAM が急増する112 GiB66 GiB同じ 590 MB の KV 設定で pinned allocator の切り上げを切り替え
初回の長いプロンプトだけ TTFT が長い43.95 s8.26 smmap のコールド・ウォーム比較、ランダム読み出し、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 の先行実行を止めれば、後続の演算起動が遅れる。

調査では、次の順で確認する。

  1. 遅いリクエストが、どの区間で時間を使っているか。
  2. その区間で GPU は演算中か。CPU は実行中か、待機中か。
  3. 待っている場合、何の完了を待っているか。
  4. その依存関係だけを変える比較を作れるか。
  5. 変更後、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 waitCPU の 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 内部の効率は別の測定である。

同じ軸に並べて確認する

  1. GPU の空き時間の直前に、何を起動していたか。
  2. CPU は Python の準備、CUDA API、通信のどこにいるか。
  3. コピーは H2D か D2H か。サイズと stream は何か。
  4. NCCL の時間は実データ転送か、送信元の準備待ちか。
  5. 前段の完了、受信の完了、次段の 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 回で比較した。

比較同期 D2Hgather・H2Dgraphdecode、3 回
A: 元の処理有り有りPIECEWISE + breakable45.3 / 45.4 / 45.6 tok/s
D: 同期だけ残す有り省略PIECEWISE + breakable45.5 / 45.4 / 45.7 tok/s
B: 同期も省略省略省略PIECEWISE + breakable73.9 / 74.9 / 75.4 tok/s
C: B の graph を変更省略省略FULL_DECODE_ONLY80.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、PIECEWISE44.6 / 44.6 / 45.4 tok/s
gather を forward の外へ移す、PIECEWISE45.1 / 45.1 / 45.8 tok/s
gather を forward の外へ移す、FULL_DECODE_ONLY79.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 g64320 × 1024019.4 µs約 170 GB/s
hyper-connection up、INT8 g6410240 × 3207.5 µs約 450 GB/s
router、BF16512 × 256012.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 GiB13 GiB
大きい pinned allocation の切り上げを停止66 GiB59 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
必要ページを先に要求し、その後 gather230 + 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 s6.69 s
prefetch 有り8.26 s6.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 が制限要因になる場合がある。最初の調査で重要でなかった処理が、次の改善対象になり得る。

実際の切り分けは、次の順で進めると判断しやすい。

  1. 起動、TTFT、decode、テール、負荷時の待ち行列のどれが遅いかを決める。
  2. 1 並列で再現し、クライアントの時間とサーバの区間を対応付ける。
  3. 同じリクエスト中に GPU、CPU、RAM、I/O を観測する。
  4. trace で待つ理由を調べ、仮説と予測を書く。
  5. 仮説を区別できる比較を行う。
  6. 正しい処理を保つ修正を実装し、profiler 無しで再測定する。
  7. 目的の入力長と並列度で、速度・容量・正しさを再確認する。

まとめ

  • 起動時間、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