LLMサービングを1から — vLLM・連続バッチングで、GPUを遊ばせない
モデルが動くことと、100人ぶんのリクエストを捌けることは別の問題です。重みも答えも変えず、投げる順番とまとめ方だけで同じGPUの処理量が何倍も変わる——その理屈を演算強度・連続バッチング・PagedAttentionの3点から追い、スループットとレイテンシが同時には良くならない理由と実測値の読み方まで。
Efficient Memory Management for Large Language Model Serving with PagedAttention
一次資料 — この記事の根拠
この解説の公開 2026-08-27
Efficient Memory Management for Large Language Model Serving with PagedAttentionarXiv:2309.06180論文ページ·PDFOrca: A Distributed Serving System for Transformer-Based Generative ModelsOSDI 2022論文ページ
「動く」と「捌ける」は、別の問題
手元のGPUでモデルを起動し、質問を投げたら返事が来た。これで「動いた」。ところが同じサーバに100人が同時につないだ途端、返事は極端に遅くなり、やがてメモリ不足で落ちます。モデルを1回動かすことと、次々に届くリクエストを捌き続けることは、別の仕事です。
後者を担う層をサービング(serving)と呼びます。vLLM、TensorRT-LLM、TGI、SGLang はここに属します。面白いのは、この層がモデルの中身を変えないことです。重みも計算式も、出てくる答えも同じ。変えるのはいつ・どの順で・何とまとめてGPUに投げるかだけ。それだけで、同じGPU1枚が捌ける量が何倍も変わります。
比喩: 相席を断らない食堂
8席の食堂が、8人揃うまで入口で待たせ、一斉に入れ、全員が食べ終わるまで次の客を入れないとします。3分で食べ終わった人の席は、コース料理の人が終わるまで空席のまま。これが静的バッチングです。
対して、1席空いた瞬間に次の人を座らせるのが連続バッチング(continuous batching)。食事のタイミングは揃わないけれど、席が遊ぶ時間はほぼゼロになります。
LLMではこの差が極端に出ます。ある人の返事は12トークンで終わり、別の人は800トークン続く。しかも出力の長さは事前に分かりません。揃うまで待つ設計は、原理的に席を遊ばせるということです。
仕事は2種類ある: プレフィルとデコード
LLMの推論は、性質の違う2つの局面に分かれます。
プレフィルはプロンプト全体を一気に読む局面です。1,000トークンなら1,000個を同時に処理できるので、GPUの演算器はぎっしり埋まります。ここは計算が律速。
デコードは1トークンずつ答えを吐く局面です。1ステップで扱うのはたった1トークン。なのにモデルの重みは全部読み出さねばなりません。数十GBを読んで、掛け算はごくわずか。ここはメモリ帯域が律速で、演算器は遊んでいます。
この非対称がすべての出発点です。過去のKとVを使い回す仕組みはKVキャッシュを1から理解するに譲りますが、必要な前提は一行だけ。デコード中、GPUは計算ではなく重みを読む待ち時間で埋まっている。
なぜ「まとめる」とタダで得をするのか
遊んでいる演算器を埋める方法は1つ。同じ重みを読んでいる間に、別のリクエストの計算も済ませてしまうことです。
これを数字にしたのが演算強度(arithmetic intensity)、1バイト読むごとに何回演算したかです。
が小さいほど「読んでばかりで計算していない」、つまりメモリ帯域に縛られた状態です。
デコード1ステップを当てはめます。パラメータ数 のモデルをfp16(1個2バイト)で持つなら読むのは バイト、 件をまとめて処理すれば演算はおよそ 回。
演算強度はバッチサイズそのものです。 を1から32にしても読むバイト数は変わりません。重みは1回読めば32件ぶんに使い回せる。つまり32件を1件とほぼ同じ時間で処理できる。サービングの「何倍」の正体は、ほぼこれです。
では無限に増やせるかというと、1件抱えるごとにKVキャッシュがメモリを食います。層数32・KVヘッド数8・ヘッド次元128・fp16のモデルなら1トークンあたり バイト=128KiB。2,000トークンの会話1件で256MiB、100件同時なら25GiBです。バッチサイズの上限は計算能力ではなくメモリが決めます。
連続バッチング: ステップ単位で組み直す
そこで最初の工夫が連続バッチングです。原型はOrcaという研究システムのイテレーション単位スケジューリングで、バッチをリクエスト単位ではなく1トークン進めるごとに組み直す(TensorRT-LLMでは in-flight batching)。終わったリクエストを外し、待ち行列の先頭を入れる。出力長がバラバラでも、バッチは常に満席に近く保たれます。
席が空いても座れない: 断片化とPagedAttention
ところが連続バッチングだけでは効きません。空いたのは論理的な席で、メモリの側に座らせる場所がないからです。
KVキャッシュを素朴に実装すると、リクエストごとに最大長ぶんの連続領域を先に確保します。4,096で確保して実際は300トークンで終われば、残りは抱え込まれたまま(内部断片)。長さのばらつきで隙間ができれば、合計では空いているのに新しいリクエストが入らない(外部断片)。vLLMの論文は、既存システムが確保したKV領域のうち実際にトークンが入っていたのは半分にも満たなかったと報告しています。メモリが半分無駄になれば、載るバッチが半分になり、式(1)の演算強度が半分になる。スループットが半分になるということです。
vLLMの答えは、OSが40年前から使う解法の輸入でした。連続した領域を諦めるのです。図書館で連番の棚を10段まとめて予約するのをやめ、空いている棚に分けて置き、目次で辿れるようにする。KVキャッシュを固定トークン数(--block-size)のブロックに切り、「論理ブロック番号 → 物理ブロック番号」の対応表を持つ。attentionカーネルは対応表を辿ってKとVを集めながら計算します。これがPagedAttentionです。
無駄はブロック内の端数だけになり、同時に載る数が増える → バッチが大きくなる → 演算強度が上がる。論文は既存システムと同じレイテンシ水準で2〜4倍のスループットを報告しています。おまけに物理ブロックは共有できます。同じシステムプロンプトで始まる100件はその部分を1つで済ませ、書き込み時に複製する(copy-on-write)。--enable-prefix-caching がこれです。
プレフィルは長さの2乗で効いてくる
もう1つ、実測で必ずぶつかる非対称があります。デコードは1トークンあたりほぼ一定時間ですが、プレフィルのattention部分はプロンプト長の2乗で増えます。2倍なら4倍、10倍なら100倍。
これは最初の1文字が出るまでの時間に直撃し、しかも長いプレフィルが入るとその間デコード中の他のリクエストが全員止まります。--enable-chunked-prefill は長いプレフィルを分割してデコードの合間に混ぜ、この停止を防ぐ機能です。
スケジューラの骨組み
ここまでを擬似コードにすると、中核は驚くほど短くなります。
while True:
# 1) 空きブロックがある限り、待ち行列から入れられるだけ入れる
while waiting and can_allocate(waiting[0]):
running.append(admit(waiting.popleft()))
# 2) 走っている全リクエストを1ステップ進める(1回の前向き計算にまとめる)
logits = model.step(running) # prefillとdecodeが混在しうる
for req, tok in zip(running, sample(logits)):
req.append(tok)
# 3) 終わったものを外し、ブロックを返す
for req in finished(running):
free_blocks(req)
running.remove(req)
# 4) メモリが尽きたら、落とさずに「押し出す」
while out_of_blocks():
preempt(running[-1]) # CPUへ退避、または破棄して後で再計算
4番が肝です。メモリが尽きてもサーバは落ちず、後から来たリクエストを一時的に追い出します(preemption)。追い出された側は後で再開されるので障害としては現れず、代わりに特定のリクエストだけ途中で長く固まるという形で出ます。知らないと原因不明のレイテンシ異常に見えます。
スループットとレイテンシは、同時には良くできない
バッチを大きくすると1ステップの時間は伸び、待ち行列も伸びます。全体の処理量は増えても、1人あたりの体感は遅くなる。この関係を一行で表すのがリトルの法則です。
は系内に同時にいるリクエスト数の平均、 は1秒あたりの到着数、 は1件が入ってから出るまでの平均時間。読み替えると、同時に抱えられる はKVメモリで頭打ちになるので、受け付ける到着率を上げたければ1件の滞在時間を縮めるしかない。縮められないなら待たせるしかない。負荷を上げるとスループットはどこかで頭打ちになり、そこから先は待ち行列だけが伸びます。この飽和点を超えた運用は、遅くなるだけで処理量は1つも増えません。
実測の見方
だから「速い」を1つの数字で語ってはいけません。最低3つ見ます。TTFT(最初の1トークンまで。待ち行列とプレフィルで決まる)、TPOT/ITL(2トークン目以降の1つあたり。デコード中の混み具合で決まる)、スループット(システム全体の出力トークン毎秒。GPU代を割る分母)。
足をすくわれる点を4つ。
- 平均ではなくp95/p99を見る。 連続バッチングは平均を良くする一方でばらつきを広げます。平均だけでは、preemptionで固まった数%の利用者が見えません。
- 「トークン毎秒」の主語を確認する。 1リクエストあたりか、システム全体の合計か。同じ構成でも10倍以上違い、比較記事の食い違いの大半はここです。
- 入出力長の分布を本番に合わせる。 同じ長さの合成データで測ると、プレフィルとデコードの比率が現実とずれて結論が逆になります。
- ウォームアップを捨て、クライアント側を疑う。 最初の数リクエストはCUDAグラフ構築などで遅く、測定スクリプトが遅いだけという結末もよくあります。
vLLM同梱の benchmarks/benchmark_serving.py は到着率を指定して負荷をかけ、これらを一度に出せます。到着率を段階的に上げ、TTFTのp99が許容線を越える点を探す——これが飽和点の実測です。SLO(例: TTFT 1秒以内かつTPOT 50ms以内)を満たしたリクエストだけ数えた実効スループットをgoodputと呼び、近年はこれで比較されます。
現場ではこう使う
誰がいつ触るか。 推論基盤・MLOps・プラットフォームのエンジニアが、社内APIとしてモデルを公開するとき、GPU台数を見積もるとき、「最近遅い」の切り分けをするときに触ります。
実際に回すノブ(vLLMの例)。
--gpu-memory-utilization: 重み以外をKVプールに回す比率。上げるとバッチが増えるが、OOMまでの余裕が減る。--max-model-len: 実需より大きく取ると1件あたりの上限予約が膨らみ、同時実行数が落ちる。「念のため最大」が最も多い設定ミス。--max-num-seqs/--max-num-batched-tokens: 同時実行数と1ステップのトークン上限。レイテンシの上限を作る絞り弁。--enable-chunked-prefill/--enable-prefix-caching: 前者は長いプロンプトでデコードが止まるのを防ぎ、後者は共通の先頭部分が長いほど効く。--tensor-parallel-size: 1枚に載らないとき。通信が増えるので、載るのにむやみに上げない。--quantization/--kv-cache-dtype: 重みやKVを縮めればKVプールが広がり、結果としてバッチが増える。中身は量子化を1から理解するに。
見る数字。 /metrics にPrometheus形式で出ます。vllm:num_requests_running、vllm:num_requests_waiting、vllm:gpu_cache_usage_perc、vllm:time_to_first_token_seconds、vllm:time_per_output_token_seconds の5つでたいていの切り分けは付きます。waiting が積み上がり cache usage が100%近ければ純粋な容量不足。cache usage が低いのに waiting が伸びるなら、max-num-seqs などで人為的に絞りすぎです。
事故になる落とし穴。
- 1件ずつ順番に投げるベンチで「速い」と結論する。本番は同時到着で、数字がまったく違う。
- OOMを恐れて
gpu-memory-utilizationを下げすぎ、KVプールが痩せてpreemptionが多発する。平均は無事なのにp99だけ跳ねる典型。 - 推論GPUに監視エージェントや別プロセスを同居させ、断続的なスパイクを生む。
問われる形。「TTFTだけ悪化した。どこを見るか」——答えの筋道は待ち行列長(num_requests_waiting)とプレフィルのトークン数です。プロンプトが長くなったのか、到着率が上がったのか。前者ならチャンク化、後者なら容量の問題で、対処がまったく違います。
なお、デコードの逐次性そのものを崩す別路線もあります。投機的デコーディングは答えを変えずに1ステップで複数トークン進める手法で、バッチが小さく演算器が余っている状況ほど効きます。狙いは同じ、遊んでいる演算器の埋め方が違うだけです。
まとめ
- デコードはメモリ帯域律速。まとめるほど演算強度が上がり、同じ重みの読み出しを使い回せる
- 連続バッチングはステップ単位でバッチを組み直し、PagedAttentionは断片化を潰して「まとめられる数」を増やす
- スループットとレイテンシは同じつまみの両端。飽和点を超えると、遅くなるだけで処理量は増えない
- 測るなら TTFT・TPOT・スループットを、平均ではなくp99で、本番に近い長さ分布と到着率で
コメント
コメントにはログインが必要です