TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

LLM推論の基礎とvLLMの内部:KVキャッシュ、PagedAttention、スケジューラ

状態:ドラフト(2026-10-05)、一部実測値を反映。 公式ドキュメントで確認した記述をベースに執筆した記事であり、筆者の実験環境で直接測定した実測値は「実測」セクション(第9節)に収め、残りはドキュメント基準です。記事の終わりの「確認したバージョンと日付」および「確認できなかったこと」を先にお読みください。執筆ルール:実測データが基本であり、測定できなかったものは「未実測」と表記します。

目次

LLMサービングのスループットは、演算速度よりも次の2つの要素によって決まります。GPUメモリにKVキャッシュをどれだけ格納できるか、そして1つのトークンを生成する際に読み込む重みを何個のリクエストで共有するかです。この記事ではその理由を算術強度で説明し、PagedAttentionのブロック管理、プレフィックスキャッシュ、chunked prefill、プリemption、運用パラメータ、メトリクスを、vLLM v0.30.0のソースコードと公式ドキュメントで確認した通りに整理します。筆者は大規模なプロダクションサービングを運用した経験はなく、コンシューマー向けGPUで測定した経験のみを持っています。

この記事を読むことで面接で説明できるようになること

  1. prefillは演算ネック、decodeはメモリ帯域幅ネックである理由を算術強度を交えて述べ、それゆえにバッチングとKV容量がスループットを決定すると説明できること。
  2. KVキャッシュのサイズを数式で計算し、GQA、ブロック(PagedAttention)、プレフィックスキャッシュ、プリemptionがその容量をどのように使用するかを説明できること。
  3. 運用パラメータ6つと /metrics を紐づけてボトルネックを診断する手順を述べられること。

1. 2つの段階と算術強度

推論は、プロンプト全体を一度に処理してKVキャッシュを埋めるprefillと、トークンを1つずつ生成するdecodeに分かれます。算術強度は、メモリから1バイトを読み込むときに実行する演算数(FLOP/byte)です。パラメータ数がP、重みが2バイトのとき、n個のトークンを一度に処理する線形層の演算量は約 2・P・n FLOPであり、重みは1度しか読み込まないため読み込み量は約 2・P バイトです。したがって強度は約 n FLOP/byte となります(アテンションとKV読み込みを除いた近似です)。

prefillはnがプロンプトの長さに該当するため演算ネックとなります。decodeは1つのリクエストあたりn=1であるため、同時に処理するリクエスト数Bがそのままnになります。BがGPUの演算性能をメモリ帯域幅で割った値(リッジポイント)よりも小さい場合、メモリ帯域幅ネックとなります。説明用の例として、NVIDIAがH100 SXMに記載している3.35 TB/sとFP16テンソルコアの1,979 TFLOPS(スパース性込みの表記)を使用し、密な性能を表記の半分と仮定すると、リッジポイントは約295 FLOP/byteとなります。同時リクエストが数百個に達しない限り、decode時間は重みを読み込む時間が支配的になります。

筆者の実験環境の記録によると、メモリ帯域幅が896 GB/s(NVIDIA表記)のノートPC GPUにおいて、Qwen3.8-27B NVFP4はロードサイズ16.2GBで単一リクエストが39 tok/s、同時4リクエストの合計が138 tok/sでした。39 × 16.2GBは約632 GB/sであり、表記帯域幅の約70%程度です。また、合計が約3.5倍となっているのは、ステップごとに読み込む重みを4つのリクエストで共有した結果と見るのが自然です。ロードサイズをステップあたりの読み込み量とみなした概算であり、測定時のプロンプトや出力の長さの記録がないため検証はできていません。

2. KVキャッシュとサイズ式

アテンションは過去のすべてのトークンのkeyとvalueを再度参照するため、レイヤーごとに保存しておきます。1トークンあたりのサイズは次のように表されます。

토큰당 바이트 = 2(K와 V) × 층 수 L × KV 헤드 수 H_kv × head 차원 d × 원소 바이트 b

値はHugging Faceの config.json で確認しました(num_hidden_layers, num_key_value_heads)。すべてBF16であるためb=2であり、ヘッド次元128は設定に head_dim がないため hidden_size ÷ num_attention_heads として計算した値です。

モデル L H_kv 1トークンあたり 32,768トークンリクエスト1個
Qwen2.5-1.5B-Instruct 28 2 28 KiB 0.875 GiB
Qwen2.5-7B-Instruct 28 4 56 KiB 1.75 GiB
Qwen2.5-72B-Instruct 80 8 320 KiB 10 GiB
(説明用の仮定)7BがMHAの場合 28 28 392 KiB 12.25 GiB

MQA(Multi-Query Attention, Shazeer 2019)はすべてのクエリヘッドがK、Vを1つだけ共有し(H_kv=1)、GQA(Grouped-Query Attention, Ainslie et al. 2023)はクエリヘッドをまとめてグループごとに1つずつ配置する中間的な手法です。GQAの論文では、MHAチェックポイントを元の事前学習演算の5%で追加学習すると、MQAに近い速度とMHAに近い品質が得られると主張しています。H_kvが減少すると格納できるリクエスト数が増え、decode時に読み込むバイト数も減ります。さらにKVをFP8で保存すると(--kv-cache-dtype fp8)、b=1となり再び半分になります。

数式は筆者の実験ログに合わせてみました。Qwen2.5-1.5Bは1トークンあたり28,672バイトであり、LMCacheログの「4,864トークン、size 0.1299 gb」は 4,864 × 28,672 ÷ 2^30 = 0.12988 と一致します(gbをGiBとして読み取る場合)。同様の計算で、GPU上の21,520トークンは約0.575 GiBになりますが、サーバーの利用可能なKVメモリログの行を保存していなかったため、直接突き合わせることはできませんでした。

3. 静的バッチングからcontinuous batchingへ

静的バッチングはリクエストの束を同時に開始し同時に終了させるため、短いリクエストが終了したスロットは、最も長いリクエストが終了するまで空いたままになります。Orca(OSDI 2022)は、スケジューリングの単位をリクエストではなくイテレーション(トークン生成1回)に変更し、イテレーションごとに完了したリクエストを取り除き、新しいリクエストを追加できるようにしました。これがcontinuous batchingであり、同論文はGPT-3 175BにおいてFasterTransformerと比較して同等のレイテンシで36.9倍のスループットを主張しています(論文条件における数値)。vLLM V1スケジューラのソースコードのコメントは、これをさらに一般化しています。デコーディング段階もプリフィル段階もなく、リクエストごとに num_computed_tokens が num_tokens_with_spec に追いつくようにトークンを割り当てるだけであり、この表現によりchunked prefill、プレフィックスキャッシュ、推論デコーディングのすべてを扱っていると記載されています。

4. PagedAttentionとブロック管理

従来のサーバーはリクエストごとに最大長分の連続メモリを事前に確保していました。論文の図2(6.2節の実験における平均メモリ無駄遣い率)において、KVメモリのうち実際のトークン状態に使用された割合は、著者らが独自に実装したOrcaの3つのバリアント(Max、Pow2、Oracle)で20.4〜38.2%であり、vLLMは約96%と読み取れます。Orcaの公開実装が存在しないため、これらのバリアントは著者らの実装によるものであり、この割合を既存のすべてのサーバーの値に一般化することはできません。同じ論文におけるスループットの向上(Orca Oracle比で1.7〜2.7倍、Orca Max比で2.7〜8倍)も、この比較対象基準に基づいています。PagedAttention(SOSP 2023)は、オペレーティングシステムの仮想メモリのようにKVを固定サイズのブロック(デフォルト16トークン)に分割し、リクエストごとに論理ブロックから物理ブロックへのテーブル(ブロックテーブル)を持たせることで、物理ブロックを必要なときに1つずつ割り当てます。無駄はリクエストごとの最後のブロックの空きスロットに抑えられ、同じプレフィックスは1つの物理ブロックを共有できます(参照回数とcopy-on-write)。

요청 A 논리 블록        block table(A)   물리 블록 풀(GPU)
B0 [토큰  0-15]    ->   물리 7     ---+
B1 [토큰 16-31]    ->   물리 1        |  물리 7 : ref_cnt=2 (A, B 가 공유)
B2 [토큰 32-37]    ->   물리 3        |  물리 3 : 6/16 만 참  <- 낭비는 여기뿐
요청 B: B0 -> 물리 7 -----------------+  (같은 접두사)

論文の性能は主張として区別されます。A100上でOPT 13B/66B/175BとLLaMA 13BをShareGPTおよびAlpacaのトレースで測定した結果、同一レイテンシでFasterTransformerおよびOrcaと比較してスループットが2〜4倍であり、ブロックテーブルを介する都合上、アテンションカーネルはFasterTransformerよりも20〜26%遅かったと論文では明らかにされています。vLLMの設計ドキュメントのpaged_attentionセクションには、それ自体が原論文に基づく歴史的ドキュメントであり、今日のコードを記述したものではないと記載されているため、概念は有効ですがカーネルの実装は変更されています。ブロックプール(ソースv0.30.0)は、空きブロックを二重連結リストキューで管理し、0番目のブロックをnull blockとして予約します。実験ログの21,520トークンは、ブロックサイズをデフォルトの16とすると1,345ブロックになり、使用可能なものは1,344ブロックです。

5. プレフィックスキャッシュ

ブロックのハッシュは、親ブロックのハッシュ、このブロックのトークン、追加キー(LoRA ID、マルチモーダル入力ハッシュ、cache_salt)から生成され、チェーンを構成します。前半部分が1トークンでも異なると、後ろのブロックがすべて変わってしまうため、プレフィックスのみがヒットします。埋まったブロックのみがキャッシュされるため、ヒットは16トークンの倍数となり、すべてヒットした場合でもlogitsを取得するために最後のトークンは再計算されます(ソースコメント)。リクエストが終了するとブロックを空きキューに返却しつつハッシュを残し、キューの前方からLRU方式で再利用しますが、末尾のブロックが先に解放されるように後ろから返却します(ソースコメント)。前方にあるブロックほど再利用される可能性が高いためという理由は、ソースが明示していない筆者の解釈です。

v0.30.0では enable_prefix_caching はモデルがサポートしていればデフォルトで有効になります。V1ブログによると、V0はヒット率が低い場合にCPUの負荷が大きかったためデフォルトが無効であり、V1はヒット率0%でも性能低下がほとんどないと主張されています。プレフィックスキャッシュはprefillを短縮するだけであり、decode時間を短縮することはできません(公式ドキュメント)。マルチテナント環境では、 cache_salt によって信頼グループを分割します。

6. chunked prefill、スケジューラ、プリemption

スケジューラは、ステップごとのトークン予算(max_num_batched_tokens)を使います。ソースの順序は以下の通りです。まず実行中のリクエスト(decodeと進行中のprefillの断片)にトークンを割り当て、残りの予算でキューの先頭から新しいリクエストを受け付け、予算より長いプロンプトは切り詰めて一部だけを入れます(chunked prefill、公式ドキュメント上で可能なすべてのケースでデフォルトで有効)。長いprefillひとつが他のリクエストのdecodeを何度もステップ停止させないようにし、トークン間の遅延を安定させることが目的です。Sarathi-Serve論文(2024)はこのアイデアにより、当時との比較でvLLM対比の遅延SLO下のサービング容量がMistral-7B/A100単体で2.6倍になると主張しています。

flowchart TD
  A[스텝 시작: 토큰 예산 = max_num_batched_tokens] --> B[실행 중 요청에 토큰 배정]
  B --> C{새 블록을 받을 수 있는가}
  C -- 예 --> D[남은 예산으로 대기열 앞에서 새 요청 받기]
  C -- 아니오 --> E[가장 늦게 들어온 실행 요청 선점: 블록 반납, 대기열 맨 앞으로]
  E --> C
  E -.-> F[선점이 있던 스텝은 새 요청을 받지 않음]
  D --> G[긴 프롬프트는 예산만큼 잘라 넣고 GPU 한 스텝 실행]

KVブロックが足りなくなるとプリエムプション(先占)が発生します。ソース(FCFSポリシー)は実行リストの最後尾、すなわち最も遅く入ってきたリクエストを選び、ブロックをすべて返却させて num_computed_tokens を0に戻してキューの先頭に入れます。再計算(recompute)です。公式ドキュメントでも、V1はswapではなくrecomputeがデフォルトであり、オーバーヘッドがより低いと説明しています。プリエムプションは同じ作業を2度行うため、TTFTとスループットの両方を悪化させます。ドキュメントが推奨する緩和策は、 gpu_memory_utilization を上げ、 max_num_seqs や max_num_batched_tokens を減らし、 tensor_parallel_size を増やすことです。

7. 運用パラメータの6つ

デフォルト値はv0.30.0のソースを基準としており、バージョンによって異なる場合があります。 --performance-mode throughput を与えると、ユーザーが指定していない2つのデフォルト値が2倍になります(ソース)。

オプション 変更内容 デフォルト値 方向
--gpu-memory-utilization このインスタンスが使用するGPUメモリの割合。重みとアクティベーションを除いた残りがKVプール 0.92 上げるとKVが増加し、OOMのリスク。筆者のWSL2環境ではWSLが1.3GBを先に確保するため、0.95で起動に失敗
--max-model-len 1リクエストあたりの最大長 モデル設定から導出 下げると同じKVでより多くのリクエストを受け付ける。筆者の実験では262Kを16Kに削減
--max-num-seqs 同時実行リクエスト数の上限 256(70GiB以上の非A100は1024) 大きいとスループットが増加、KVの圧迫とトークン間遅延が増加
--max-num-batched-tokens 1ステップあたりのトークン予算 2048(70GiB以上の非A100は8192、160GiB以上は16384) 大きいとTTFTが減少し、同時に実行されるdecodeの遅延が増加
--tensor-parallel-size レイヤー内の重みを複数のGPUに分割 1 KV空間の増加、重み読み込み時間の減少、通信コストの発生
--enable-prefix-caching プレフィックスキャッシュ 有効(モデルがサポートしている場合) 出力が長いワークロードではメリットが小さい

筆者の実験におけるデプロイ設定は --gpu-memory-utilization=0.93 --max-model-len=16384 --max-num-seqs=4 --max-num-batched-tokens=2048 --kv-cache-dtype=fp8 であり、設定に記載した実測値によるとKVキャッシュは2.9GB(約57,000トークン)です。16,384トークンのリクエストが約3.5個入るサイズであるため、同時4個がすべて長くなった場合にプリエムプションが発生しうるというのは算術的な推定であり、観察されたものではありません。

8. 指標と /metrics

TTFTはリクエスト送信後最初の出力を受け取るまでの時間、TPOTは(全体遅延 - TTFT)÷(出力トークン数 - 1)、ITLはストリームで届く出力間の間隔です。推論デコーディングでは1つの出力に複数のトークンが含まれるため、ITLとTPOTが異なります(vLLMベンチマークドキュメント)。goodputはTTFTやTPOTなどのSLOを満たしたリクエストのみを数えたスループットです(DistServe論文、OSDI 2024)。平均ではなくp50、p95、p99を確認します。

v0.30.0のソースで確認した指標です(Prometheusカウンターには _total が付きます)。

待ち時間が増え、 kv_cache_usage_perc が1に近く、プリエムプションが増える場合はKV不足です。待ち時間が増えているにもかかわらず使用率が低い場合は、 max_num_seqs やトークン予算がボトルネックです。ヒストグラムはバケット境界で分かれているため(TTFTは0.5、0.75、1.0秒など)、 histogram_quantile のp99はバケットの幅の分だけ粗くなります。

9. 実測:循環アクセスでLRUが崩壊した

筆者が測定した実測値です。8GB級のノートPC用GPU1枚、イメージ lmcache/vllm-openai:latest(vLLM 0.29.0、LMCache 0.5.5)、モデルQwen2.5-1.5B-Instruct、 --gpu-memory-utilization 0.60 --max-model-len 8192 --max-num-seqs 4 --enable-prefix-caching です。サーバーログのGPU KVキャッシュは21,520トークンでした。乱数語彙で作成され、相互のプレフィックスが重複しない10個の文書(各約5,011トークン、合計約50,000トークン)を順番通りに、同じ順番でもう一度、そして逆順に読み込ませ、リクエストは1つずつ送信しました。

設定 パス TTFT p50 (ms) プレフィックスキャッシュヒット(トークン)
baseline 1 初回 575.5 0
baseline 2 同じ順番でもう一度 576.2 0
baseline 3 逆順 505.4 21,488
LMCache (CPU 1.5GB) 2 同じ順番でもう一度 48.6 GPU 0, 外部 48,640

文書の合計がGPU容量の2.3倍であるため、LRUで同じ順番でもう一度読み込むと、必要な瞬間にその文書がちょうど放出された後になっており、ヒット数が0になります。逆順に読み込む3回目のヒット21,488トークンは、使用可能な1,344ブロックのうち1,343ブロック(×16)に相当します。この説明を第10節のブロックLRUモデルで確認したところ、モデルも1回目0、2回目0、3回目21,488という結果を出しました。モデルが実測と一致したことは、説明が一貫している証拠であって証明ではありません。

LMCacheがKVをCPUメモリにオフロードした場合、2回目のTTFTは48.6msでした。実験記録には、文書あたり4,864トークンをCPUから読み込むのにログのタイムスタンプ基準で約8〜9ms、同じトークンを新しく計算するのに約560msかかったと記されています。初めて読み込む際は保存コストがかかるため、TTFT p50が575.5から597.8msへと約4%増加しました。

限界をそのまま明らかにします。 同時実行数が1であるため、リクエストが重なる状況は測定していません。モデルが1.5BでGPUが1枚(8GB)であり、文書が合成されたものであるため、大きなモデルにおいてメリットがさらに大きくなるという実験記録の予想を、この測定結果が示しているわけではありません。1度測定した値であり、標準偏差がなく、パスあたりのリクエストが10個であるため、p95は実質的に最大値です。メリットを生み出したのはPagedAttentionではなくCPUオフロードであり、プレフィックスが長く繰り返される最適シナリオです。実験の後にvLLMドキュメントで独自の OffloadingConnector を確認しましたが、同じ条件で再測定はしていません。

10. 実際にやってみる

1、2番はローカル計算または読み取り専用です。

# 1) 클러스터 없이: KV 크기 계산
curl -sL https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/resolve/main/config.json | python3 -c '
import json,sys; c=json.load(sys.stdin)
d=c["hidden_size"]//c["num_attention_heads"]
b=2*c["num_hidden_layers"]*c["num_key_value_heads"]*d*2
print(b, "바이트/토큰", b*32768/2**30, "GiB/32K 토큰")'

# 2) 읽기 전용 확인(vLLM 서버가 떠 있는 클러스터에서. 서버를 새로 켜는 일은 변경이라 하지 않는다)
kubectl logs <vLLM 파드 이름> | grep -E "GPU KV cache size|Maximum concurrency"
kubectl exec <vLLM 파드 이름> -- python3 -c "import urllib.request as u;print(u.urlopen('http://127.0.0.1:8000/metrics').read().decode())" | grep -E "^vllm:(num_requests|kv_cache_usage|num_preemptions|prefix_cache)"

3番はブロックLRUモデルです。実測値ではなく、筆者が単純化したモデルです(ブロック16トークン、末尾から放出、部分ブロックはハッシュなし)。

from collections import OrderedDict
BS, TOTAL, DOCS, N = 16, 1344, 10, 5033   # 쓸 수 있는 블록 = 전체 1345 - null block
free, fresh = OrderedDict(), TOTAL        # free: 해시가 붙은 빈 블록, 앞쪽이 먼저 방출됨

def serve(d):
    global fresh
    hs = [(d, i) for i in range(N // BS)]            # 가득 찬 블록의 해시
    hit = 0
    while hit < len(hs) and hs[hit] in free and (hit + 1) * BS <= N - 1:
        hit += 1
    for h in hs[:hit]:
        del free[h]                                  # 적중한 블록은 사용 중
    for _ in range(-(-N // BS) - hit):               # 모자란 블록을 새로 받는다
        if fresh: fresh -= 1
        else: free.popitem(last=False)               # 가장 오래된 블록을 내보냄
    for h in reversed(hs):
        free[h] = 1                                  # 끝나면 꼬리부터 큐에 넣는다
    fresh += N % BS > 0                              # 부분 블록은 바로 재사용
    return hit * BS

for name, order in (("1차", range(DOCS)), ("2차", range(DOCS)), ("3차 거꾸로", reversed(range(DOCS)))):
    print(name, sum(serve(d) for d in order))

4番は一時Podでの再現です。元の実験はGPU1枚とメモリ6Giを使用する一時Podで実行しました。リスクは、同じノード上の他のワークロードとのメモリ競合およびGPU占有です。Podが残った場合でも1時間後に自動終了するようにし、オブジェクトの削除は人が直接実行します。この記事ではこの再現を再実行していません。

11. 面接で出そうな質問

  1. decodeがなぜメモリ帯域幅の限界なのですか? 1つのリクエストが1つのトークンを生成する際、すべての重みを読み込みますが、重みあたりの乗算は1回程度であるため、算術強度が同時リクエスト数Bと同程度になります。リッジポイント(H100基準で数百)よりも小さいため演算ユニットがアイドル状態になり、リクエストを集めて重みの読み込みを分割して行うバッチ処理がスループットを向上させます。
  2. KVキャッシュのサイズを計算し、GQAの効果について説明してください。 2 × レイヤー数 × KVヘッド数 × ヘッド次元 × バイト数がトークンあたりのサイズです。Qwen2.5-7Bは28 × 4 × 128であるため56KiBであり、クエリヘッド28個のすべてがKVを持つMHAであった場合は7倍の392KiBになるため、GQAにより同時に格納できるリクエスト数がその分増加します。
  3. PagedAttentionは何を解決し、ブロックサイズはどのように決定されますか? 最大長で連続メモリを事前に確保していた方式のフラグメンテーションと無駄を、ブロック単位の動的割り当てによって解消しました。ブロックが大きいとカーネル効率は良いものの最後のブロックの無駄が大きくなり、小さいとその逆になります。論文ではこのトレードオフとしてデフォルトの16を選択しています。
  4. KVキャッシュが一杯になるとvLLMはどう動き、どのように気づきますか? 最も遅く入ってきた実行中のリクエストをプリエムプションしてブロックを返却させ、後から最初から再計算します。 kv_cache_usage_perc が1に近づき、 num_preemptions_total が増えてキューが長くなります。対応策は使用率の引き上げ、 max_num_seqs の縮小、KV FP8、テンソル並列の増設です。
  5. プレフィックスキャッシュを有効にしたのにヒットしません。何を確認すべきですか? prefix_cache_hits_total ÷ queries_total で実際のヒット率を確認し、プロンプトの先頭にタイムスタンプやリクエストIDなどの可変トークンが含まれていないか確認します。ハッシュチェーンであるため、前半が異なると後半がすべて一致しなくなります。再利用間隔の間に読み込む総量がKV容量を超えてLRUが崩壊した可能性もあり、筆者の実験がまさにそのケースでした。

12. よくある誤解

参考資料

実際に確認したもののみを記載しています。

確認したバージョンと日付、確認できなかったこと

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます