ステータス:ドラフト(2026-10-13)、一部実測。 おもちゃモデルの測定(第3節)は筆者がノートPCのCPU上で直接計測した値です。GPUサーバーの測定(第4・6節)は、同一サイトの「トスML/AI Infra準備」シリーズにて8GB級GPU1枚で計測された値を引用しています。モデル構成は公式のconfigと論文の表で確認し(2026-10-13閲覧)、KVサイズはその値に基づいて計算しました。 作成ルール:実測データを基本とし、計測できなかったものは「未実測」、確認できなかったものは「未確認」と表記します。
目次
- 1. トークンを1つずつ生成するモデル
- 2. キャッシュがない場合、ステップごとに最初から
- 3. 実験してみる:numpy製おもちゃトランスフォーマー
- 4. キャッシュの代償:メモリ
- 5. サイズを縮小する方法:MHA → GQA → MQA → MLA
- 6. プリフィルとデコード:性質が異なる2つの段階
- 7. まとめ:このシリーズが追う疑問
- 参考文献
1. トークンを1つずつ生成するモデル
チャットボットの返答は一度には出力されません。モデルはこれまでのテキスト(質問 + これまでに生成した返答)を見て 次のトークン1つを選択し、そのトークンをテキストの末尾に追加してさらに次のトークンを選択します。これを自己回帰デコーディングと呼びます。
トークンを1つ選択する際、トランスフォーマーの各層はアテンションを計算します。トークンごとに3つのベクトルが作成されます。
- Q(クエリ):「今、何を求めているか」
- K(キー):「自分はこういう内容である」というインデックス
- V(バリュー):実際に渡す内容
新しいトークンのQを過去のトークンのKと1つずつ比較してスコアを算出し(どれほど関連があるか)、そのスコアでVを重み付けして混ぜ合わせます。数式で書くと softmax(q·Kᵀ/√d)·V となります。
ここで重要な性質が1つあります。言語モデルのアテンションには 未来を見ないようにマスクがかけられており(因果マスク / causal mask)、3番目のトークンのK・Vは1〜3番目のトークンのみを見て作成されるため、後ろにトークンが100個追加されても 変化しません。 であれば、一度作成したK・Vを破棄して再計算する理由はありません。保存しておき、次のステップで取り出して使えばよいのです。これが KVキャッシュです。
Qは保存しません。過去のトークンのQは、そのトークンを生成する際に1度使用したら二度と使うことがないためです。新しいステップで必要なQは、新しいトークンの分が1つあるのみです。
2. キャッシュがない場合、ステップごとに最初から
キャッシュがない場合、t番目のステップでt個のトークンすべてのK・Vを再作成します。層ごとに行う線形射影(Q・K・V・出力)とMLPはトークン数に比例するため、1ステップのコストはtに比例し、nステップを合計すると1+2+…+n ≈ n²/2 となります。キャッシュがある場合、ステップごとに新しいトークン1つのみを射影してMLPに入力するため、nステップでは n になります。
アテンションスコアの計算は、どちらの方式でも残ります。キャッシュがあっても新しいトークンのQは過去すべてのKと比較する必要があるため、1ステップあたりtのコストがかかります(nステップの合計でn²/2)。キャッシュがない場合、この計算すらステップごとにt²となるため、合計がn³/3へと膨れ上がります。コンテキストが非常に長くならない限り、射影とMLPが計算の大半を占めるため、実際に体感する差は「ステップごとにt対1」となります。
3. 実験してみる:numpy製おもちゃトランスフォーマー
言葉では簡単でも体感が難しいため、重みを乱数で埋めた小さなトランスフォーマーをnumpyで実装し、両方式を測定しました。学習済みモデルではないため出力トークンに意味はありませんが、計算の形状(射影・アテンション・MLP・正規化)は本物と同じです。
- モデル:2層、モデル次元128、ヘッド8個(ヘッド次元16)、MLP 512、語彙サイズ512、float32
- 環境:ノートPC CPU、Python 3.9、numpy 2.0.2、シングルプロセス
- 方法:乱数プロンプト32トークンから貪欲デコーディングで最後まで生成。同一条件を3回実行しての中央値。行列積の演算量(乗算・加算を2としてカウント)はコード内で直接計測しました。
- コードと生データ:リポジトリ
docs/experiments/2026-10-13-numpy-kv-cache-toy/(toy_kv_cache.py,result-20261013.txt)
キャッシュあり方式の核心は数行です。今回のトークンのK・Vをあらかじめ確保した配列の末尾に書き込み、アテンションはこれまで蓄積されたすべてを参照します(ヘッドごとに分割するreshapeは省略しました)。
k = mm(h, W["k"]) # 새 K
v = mm(h, W["v"]) # 새 V
n = cache["n"]
cache["k"][:, n:n+T] = k # 끝에 붙임
cache["v"][:, n:n+T] = v
K = cache["k"][:, :n+T] # 지난 것 전부
V = cache["v"][:, :n+T]
a = attend(q, K, V)
結果1. 出力は同じだが、演算量と時間は大きく異なる
演算量(GFLOP、コードによる計測値):
| 全体長 | キャッシュなし | キャッシュあり | 倍率 |
|---|---|---|---|
| 128 | 6.71 | 0.121 | 55倍 |
| 256 | 31.0 | 0.264 | 118倍 |
| 512 | 148.2 | 0.599 | 247倍 |
時間(秒、3回の中央値):
| 全体長 | キャッシュなし | キャッシュあり | 倍率 |
|---|---|---|---|
| 128 | 0.205 | 0.084 | 2.5倍 |
| 256 | 1.16 | 0.131 | 8.9倍 |
| 512 | 8.01 | 0.485 | 16.5倍 |
- 3つの長さすべてにおいて、両方式のトークンは最後まで一致し、ロジットの最大差は1.5×10⁻⁶とfloat32の丸め誤差のレベルでした。 キャッシュとは近似ではなく、同じ計算を繰り返さないことです。
- 長さを2倍にするたびに、キャッシュなしの演算量は約4.6〜4.8倍になり(n²にアテンションのn³が少し混ざる)、キャッシュありは約2.2倍になりました。
- 時間倍率が演算量倍率よりも小さいのは、キャッシュ側の1ステップが非常に小さな行列積であるため、Pythonの呼び出しオーバーヘッドが大きく混入するためです。長さ128では全体が0.1秒未満であるため、ブレも大きくなります(短い確認実行では4.5倍でした)。
結果2. キャッシュがない場合、1ステップの時間がコンテキストに比例して遅くなる。 512トークンの実行において64トークンの区間ごとに1ステップの時間の中央値を見ると、キャッシュなしは1.25msから35.1msに増加し、キャッシュありは0.43msから0.60msへとわずかな増加にとどまりました。キャッシュがあっても少しずつ遅くなるのは、第2節で述べたアテンションスコアの計算(1ステップあたりt)が原因です。
結果3. キャッシュ配列のサイズは公式の数式とバイト単位まで一致する。 1,024トークンをプリフィルした後のキャッシュ配列の nbytes を加算しました。
クエリヘッドはいずれも8個であり、公式の値は「2 × 層数(2) × KVヘッド数 × ヘッド次元(16) × トークン数(1,024) × 4バイト」です。
| 方式 | KVヘッド | 実測バイト数 | 公式の値 |
|---|---|---|---|
| MHA | 8 | 2,097,152 | 2,097,152 |
| GQA | 2 | 524,288 | 524,288 |
| MQA | 1 | 262,144 | 262,144 |
3つの方式すべてにおいて、キャッシュを使用した最後のロジットは、キャッシュなしで最初から計算した値と一致しました(許容誤差10⁻³)。
4. キャッシュの代償:メモリ
キャッシュとは、計算量をメモリに変換したものです。トークン1つが残すKVは層ごとにKとVの1セットずつであるため、以下のようになります。
KVバイト/トークン = 2 × 層数 × KVヘッド数 × ヘッド次元 × データ型のバイト数
この数式が実際のサーバーと一致するかどうかは、別のシリーズで確認しました。Qwen3-1.7B(28層、KVヘッド8、ヘッド次元128、bf16)の数式上のトークンあたりのサイズは114,688バイトであり、vLLMサーバーログのKVキャッシュメモリ(2.36GiB)をトークン数(22,048)で割った値は約114,900バイトとなり、0.2%以内で一致しました(8GB級GPU、vLLM 0.31.0、実測)。KVを8ビット(fp8)にすると、同じメモリに41,920トークンが収まり、およそ2倍近くなりました。
よく知られた公開モデルに当てはめると以下のようになります。層数・ヘッド数は公式資料から確認し、サイズはKVをbf16(2バイト)として計算しました。以下のモデルはすべてヘッド次元が128です。「32K」は32,768トークンのリクエスト1つあたりのKVです。
| モデル | 層数 × KVヘッド | トークンあたりKV | 32K |
|---|---|---|---|
| Qwen2.5-1.5B | 28 × 2 | 28KiB | 0.88GiB |
| Qwen2.5-7B | 28 × 4 | 56KiB | 1.75GiB |
| Llama 3.1 8B | 32 × 8 | 128KiB | 4GiB |
| Mistral-7B | 32 × 8 | 128KiB | 4GiB |
| Qwen3-8B | 36 × 8 | 144KiB | 4.5GiB |
| Llama 3.1 70B | 80 × 8 | 320KiB | 10GiB |
| Llama 3.1 405B | 126 × 8 | 504KiB | 15.75GiB |
| 70BがMHAだった場合(仮定) | 80 × 64 | 2.5MiB | 80GiB |
| DeepSeek-V2 (MLA) | 60層、潜在576 | 67.5KiB | 2.11GiB |
クエリヘッド数は、Qwen2.5-1.5Bが12、Qwen2.5-7Bが28、Llama 3.1 8B・Mistral-7B・Qwen3-8Bが32、70Bが64、405Bが128です。KVヘッドがクエリヘッドよりも少ないのが、第5節で扱うGQAです。
- Llama 3.1は、Llama 3論文のモデル構成表(8B・70B・405BのすべてでKVヘッド8)およびMeta公式リポジトリの構成値で確認しました。Hugging FaceのLlamaのconfigはログインが必要で開けなかったため、ヘッド次元128はモデル次元÷ヘッド数で計算しました。Qwen・Mistralは公開されている
config.jsonで確認しました。 - Llama 3.1は128Kのコンテキストをサポートしています。70Bに128Kトークンをフルに詰め込むと、KVだけで40GiBになります(計算値)。重みがbf16でおよそ140GBあるモデルの傍らで、長めのリクエストがいくつか来ると、GPUメモリをすぐに圧迫します。
- DeepSeek-V2は、K・Vの代わりに圧縮された潜在ベクトル(層あたり512要素)と位置用のキー(64要素)を保存します。論文ではトークンあたりのKVの要素数を「(512 + 64) × 60層 = 34,560」として数えています。67.5KiBはこれをbf16として算出した値です(計算値)。同じヘッド構成(ヘッド数128、ヘッド次元128)をMHAにしていた場合、トークンあたり1,966,080要素になるため、およそ57分の1になります(計算値)。
5. サイズを縮小する方法:MHA → GQA → MQA → MLA
数式の中で削減できるのは、KVヘッド数(またはそれに代わるもの)とバイト数です。層数とヘッド次元はモデルの能力に直結するため、安易に減らすことはできません。
- MHA(マルチヘッド):クエリヘッドごとにK・Vを個別に持ちます。原初の発明であるトランスフォーマー(2017)の方式です。
- MQA(マルチクエリ、Shazeer 2019):すべてのクエリヘッドがK・Vの1セットを共有します。論文の翻訳モデルの実験(パラメータ数約2億、TPUv2)において、出力トークンあたりのデコーダの時間が46µsから3.8µsに短縮され、BLEUは27.7から27.5(貪欲)へとわずかに低下しました。
- GQA(グループクエリ、Ainslieら 2023): その中間に位置します。クエリヘッドをいくつかのグループにまとめ、グループごとにK・Vのセットを1つ配置します。すでにMHAで学習したモデルに対し、元の学習量の5%だけ追加学習を行うことで変更できることが示されており、T5-XXLの実験ではGQA-8はサンプルあたり0.28秒(MHA 1.51秒、MQA 0.24秒)、平均スコア47.1(MHA 47.2、MQA 46.6)でした。Llama 3シリーズが採用している方式がこれです(KVヘッド 8)。
- MLA(DeepSeek-V2, 2024): ヘッド数を減らす代わりに、K・Vを小さな潜在ベクトルに圧縮して保存し、使用時に再度展開します。論文では、DeepSeek 67Bと比較してKVキャッシュを93.3%削減し、最大生成スループットを5.76倍に向上させたと報告されています(H800 8枚の1ノード、デプロイ時の重みFP8・KV約6ビット量子化を含む条件)。93.3%の正確な計算基準は本文から見つかりませんでした(未確認)。
- バイト数を減らす: KVを8ビットにすると半分になります。上記のvLLMの実測において、fp8 KVはトークン数を1.9倍に増やしました。品質への影響はモデルやタスクごとに個別に測定する必要があります。
なぜここまでして減らそうとするのでしょうか。第6節がその答えです。
6. プリフィルとデコード:性格が異なる2つの段階
1つのリクエストは2つの段階を経ます。
- プリフィル(prefill): プロンプト全体を一度に入力してすべてのトークンのK・Vを作成し、キャッシュに書き込みます。最初のトークンはここから出力されます。最初のトークンまでの時間(TTFT)を決定します。
- デコード(decode): その後、1トークンずつ生成します。トークン間の間隔(ITL・TPOT)を決定します。
両者は同じモデルを使用しますが、ハードウェア上での振る舞いは異なります。
- プリフィルは計算がボトルネックです。 重みを一度読み込んで、数千個のプロンプトトークンに対して一括して処理します。読み込んだバイト数に対して実行する計算量が多く、GPUの演算器が忙しく稼働します。
- デコードはメモリ帯域幅がボトルネックです。 トークンを1つ生成するために重み全体と該当リクエストのKVのすべてをメモリから読み込む必要がありますが、それを用いて行う計算はトークン1つ分だけです。bf16の重みはパラメータあたり2バイトであり、1トークンあたりパラメータごとに約2 FLOPを実行するため、1つのリクエストのみをデコードする場合、読み込んだバイトあたり約1 FLOPとなります(計算値)。近年のデータセンター向けGPUは1バイト読み込む間に数百FLOPを実行できるため、演算器の大部分は遊休状態になります。
おもちゃのモデルでもその違いが見られました(キャッシュあり、3回の中央値)。
| プロンプト | プリフィル トークンあたり | デコード 1ステップ | 倍率 |
|---|---|---|---|
| 64 | 0.018ms | 0.445ms | 24倍 |
| 256 | 0.034ms | 0.506ms | 15倍 |
| 1,024 | 0.144ms | 0.852ms | 6倍 |
プリフィル全体の時間は、64トークンで1.16ms、256トークンで8.76ms、1,024トークンで146.9msでした。
1つのトークンをデコードするのに、プリフィルのトークンあたりの時間の6〜24倍のコストがかかりました。CPUのnumpyを使用しているためPythonの呼び出しコストが混ざっており、1,024トークンのプリフィルではアテンションの2乗項が大きくなってトークンあたりのコストが上昇します。そのため、倍率そのものというよりは「一度にまとめて処理する方がトークンあたり圧倒的にコストが低い」という傾向に着目してください。
GPU環境ではこの差がさらに大きくなります。別シリーズの実測値(8GBクラスのGPU、Qwen3-1.7B、vLLM)では、入力処理速度は約8,200トークン/秒であり、1つのリクエストをデコードする際のトークン間隔は15ms(約66トークン/秒)でした。 トークンあたり120倍以上の差が生じます。
デコードがメモリに制約されている場合、スループットを向上させる方法は1つしかありません。 重みを1回読み込む間に複数のリクエストを同時にデコードすること、すなわちバッチサイズを大きくすることです。同じ実測値において、同時リクエストを1から8に増やすとスループットが66から383トークン/秒(5.8倍)に、32から831トークン/秒に増加しました。しかし、バッチに含まれるリクエストごとに自身のKVがGPUメモリ上に存在していなければなりません。入力512 + 出力128トークンであれば1つのリクエストが640トークンとなり、そのGPUのKV領域(22,048トークン)には約34個が入ります。同時に64個を送信したところ、スループットは向上せず、最初のトークンの時間が5.7秒に跳ね上がりました。 スループットの上限を決定づけているのはKVメモリなのです。
7. まとめ:このシリーズで追っていく疑問
| 事実 | そこから生じる疑問 | 取り上げる内容 |
|---|---|---|
| KVは変化しないため、保存して再利用する | — | 第1部 |
| デコードはバッチが大きいほど効率的である | KVメモリを無駄なく使用してバッチサイズを大きくするには? | 第2部(PagedAttention、連続バッチ処理) |
| 前半部分が同じであればKVも同じである | 他のリクエストのKVを再利用するには? | 第2部(プレフィックスキャッシュ)、第3部 |
| GPUメモリは小さい | KVをCPU・ディスク・他のサーバーに置いて読み込むには? | 第3部(LMCache) |
| プリフィルとデコードは性格が異なる | 両者を異なるGPUに振り分けるには? すでにKVが存在する場所に送信するには? | 第4部(Dynamo) |
T.T.の一言。 卓球の試合で、得点を毎回最初のサーブから数え直す審判はいません。スコアボードに記録しておき、1点ずつ足していきますよね。KVキャッシュがまさにそのスコアボードです。ただし、試合会場が数百個もあると、スコアボードを置く場所から足りなくなってくるのです。
参考
- Vaswani et al., Attention Is All You Need: https://arxiv.org/abs/1706.03762
- Shazeer, Fast Transformer Decoding: One Write-Head is All You Need (MQA): https://arxiv.org/abs/1911.02150 (表 1・2)
- Ainslie et al., GQA: https://arxiv.org/abs/2305.13245 (v3, 第3節・表 1)
- DeepSeek-AI, DeepSeek-V2: https://arxiv.org/abs/2405.04434 (v5, アブストラクト, 2.1節 表 1, 3.2.3節)
- Llama 3 論文 モデル構成表: https://arxiv.org/abs/2407.21783 (3.2節 表 3), Meta 構成値: https://github.com/meta-llama/llama-models (
models/sku_list.py) - 公開 config: https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/raw/main/config.json, https://huggingface.co/Qwen/Qwen3-8B/raw/main/config.json, https://huggingface.co/mistralai/Mistral-7B-v0.1/raw/main/config.json, https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/raw/main/config.json (すべて 2026-10-13 閲覧)
- おもちゃのモデルのコードと元データ: リポジトリ
docs/experiments/2026-10-13-numpy-kv-cache-toy/ - GPU実測値の引用: 同サイト「トス ML/AI Infra 準備」第14部(vLLM負荷・KV容量・プレフィックスキャッシュ・チャンク)