JA EN
体系推論・高速化
·無料·論文·13分で読めます

LLMサービングを1から — vLLM・連続バッチングで、GPUを遊ばせない

モデルが動くことと、100人ぶんのリクエストを捌けることは別の問題です。重みも答えも変えず、投げる順番とまとめ方だけで同じGPUの処理量が何倍も変わる——その理屈を演算強度・連続バッチング・PagedAttentionの3点から追い、スループットとレイテンシが同時には良くならない理由と実測値の読み方まで。

対象textタスクinference

Efficient Memory Management for Large Language Model Serving with PagedAttention


「動く」と「捌ける」は、別の問題

手元の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バイト読むごとに何回演算したかです。

I=演算回数読んだバイト数I = \frac{\text{演算回数}}{\text{読んだバイト数}}
(1)

II が小さいほど「読んでばかりで計算していない」、つまりメモリ帯域に縛られた状態です。

デコード1ステップを当てはめます。パラメータ数 PP のモデルをfp16(1個2バイト)で持つなら読むのは 2P2P バイト、BB 件をまとめて処理すれば演算はおよそ 2PB2PB 回。

I2PB2P=BI \approx \frac{2PB}{2P} = B

演算強度はバッチサイズそのものです。BB を1から32にしても読むバイト数は変わりません。重みは1回読めば32件ぶんに使い回せる。つまり32件を1件とほぼ同じ時間で処理できる。サービングの「何倍」の正体は、ほぼこれです。

では無限に増やせるかというと、1件抱えるごとにKVキャッシュがメモリを食います。層数32・KVヘッド数8・ヘッド次元128・fp16のモデルなら1トークンあたり 2×32×8×128×2=131,0722 \times 32 \times 8 \times 128 \times 2 = 131{,}072 バイト=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倍。

FIG 1プロンプトが長くなるとプレフィルの負荷は2乗で伸びる。線形と対数を切り替えると、「少し長くしただけ」が待ち時間では桁になることが見える

これは最初の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人あたりの体感は遅くなる。この関係を一行で表すのがリトルの法則です。

L=λWL = \lambda W
(2)

LL は系内に同時にいるリクエスト数の平均、λ\lambda は1秒あたりの到着数、WW は1件が入ってから出るまでの平均時間。読み替えると、同時に抱えられる LL はKVメモリで頭打ちになるので、受け付ける到着率を上げたければ1件の滞在時間を縮めるしかない。縮められないなら待たせるしかない。負荷を上げるとスループットはどこかで頭打ちになり、そこから先は待ち行列だけが伸びます。この飽和点を超えた運用は、遅くなるだけで処理量は1つも増えません。

実測の見方

だから「速い」を1つの数字で語ってはいけません。最低3つ見ます。TTFT(最初の1トークンまで。待ち行列とプレフィルで決まる)、TPOT/ITL(2トークン目以降の1つあたり。デコード中の混み具合で決まる)、スループット(システム全体の出力トークン毎秒。GPU代を割る分母)。

足をすくわれる点を4つ。

  1. 平均ではなくp95/p99を見る。 連続バッチングは平均を良くする一方でばらつきを広げます。平均だけでは、preemptionで固まった数%の利用者が見えません。
  2. 「トークン毎秒」の主語を確認する。 1リクエストあたりか、システム全体の合計か。同じ構成でも10倍以上違い、比較記事の食い違いの大半はここです。
  3. 入出力長の分布を本番に合わせる。 同じ長さの合成データで測ると、プレフィルとデコードの比率が現実とずれて結論が逆になります。
  4. ウォームアップを捨て、クライアント側を疑う。 最初の数リクエストはCUDAグラフ構築などで遅く、測定スクリプトが遅いだけという結末もよくあります。

vLLM同梱の benchmarks/benchmark_serving.py は到着率を指定して負荷をかけ、これらを一度に出せます。到着率を段階的に上げ、TTFTのp99が許容線を越える点を探す——これが飽和点の実測です。SLO(例: TTFT 1秒以内かつTPOT 50ms以内)を満たしたリクエストだけ数えた実効スループットをgoodputと呼び、近年はこれで比較されます。

現場ではこう使う

誰がいつ触るか。 推論基盤・MLOps・プラットフォームのエンジニアが、社内APIとしてモデルを公開するとき、GPU台数を見積もるとき、「最近遅い」の切り分けをするときに触ります。

実際に回すノブ(vLLMの例)。

見る数字。 /metrics にPrometheus形式で出ます。vllm:num_requests_runningvllm:num_requests_waitingvllm:gpu_cache_usage_percvllm:time_to_first_token_secondsvllm:time_per_output_token_seconds の5つでたいていの切り分けは付きます。waiting が積み上がり cache usage が100%近ければ純粋な容量不足。cache usage が低いのに waiting が伸びるなら、max-num-seqs などで人為的に絞りすぎです。

事故になる落とし穴。

問われる形。「TTFTだけ悪化した。どこを見るか」——答えの筋道は待ち行列長(num_requests_waiting)とプレフィルのトークン数です。プロンプトが長くなったのか、到着率が上がったのか。前者ならチャンク化、後者なら容量の問題で、対処がまったく違います。

なお、デコードの逐次性そのものを崩す別路線もあります。投機的デコーディングは答えを変えずに1ステップで複数トークン進める手法で、バッチが小さく演算器が余っている状況ほど効きます。狙いは同じ、遊んでいる演算器の埋め方が違うだけです。

まとめ

参考文献

  1. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180論文ページ·PDF
  2. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022論文ページ

本記事は上記論文の本文にもとづいて執筆しています。数値・主張は原典を優先してください。

コメント

コメントにはログインが必要です