Multi-Query / Grouped-Query Attention(MQA/GQA)— KVキャッシュを削ってLLM推論を軽くする

長い文章をLLMに書かせていると、最初の応答が出るまでは待たされるのに、そこから先はスラスラと文字が流れてきます。ところが入力の文脈がどんどん長くなると、今度はその「スラスラ」自体がだんだん重くなっていきます。文脈が10万トークンにもなると、GPUのメモリがぎりぎりになり、複数ユーザーを同時にさばくとさらに苦しくなります。この苦しさの正体は、実は計算量ではなく メモリ帯域 です。

その帯域を食いつぶす最大の犯人が KVキャッシュ です。標準的な Multi-Head Attention(MHA) は、ヘッドの数だけ独立したKey・Valueを持ちます。ヘッドが多いほど表現力は上がりますが、そのぶんキャッシュも膨らみ、1トークン生成するたびに大量のKey・Valueをメモリから読み出さなければなりません。Multi-Query Attention(MQA)Grouped-Query Attention(GQA) は、この「ヘッドごとに別々のK,Vを持つ」という前提そのものを崩し、K,Vを共有することでキャッシュを劇的に減らす手法です。

MQA/GQAを理解すると、次の2つの問いに明確に答えられるようになります。

  • なぜLlama 2 70BやMistralはMHAではなくGQAを採用したのか、それでどれだけメモリと速度が改善するのか
  • K,Vを共有すると品質はどうなるのか、そのトレードオフをどこで折り合わせるのか

本記事の内容

  • なぜ生成はメモリ帯域律速になるのか(KVキャッシュと演算強度の話)
  • MQAの仕組み — 全ヘッドでK,Vを1組だけ共有する
  • GQAの仕組み — グループ単位でK,Vを共有し、MHAとMQAの中間を取る
  • KVキャッシュ量の式とMHA/GQA/MQAでの比較
  • 学習済みMHAをGQAに作り替える「uptraining」
  • PyTorchでの実装と、テンソル形状・キャッシュ量・デコード時間の実測

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
Multi-Head Attentionの理論と実装を完全解説
複数ヘッドに分割してAttentionを計算するMHAの仕組みと、部分空間への射影の意味を解説します。
画像なし
KVキャッシュの仕組み — LLM推論を高速化する基本技術
自己回帰生成で過去トークンのK,Vを保存して再利用するKVキャッシュの原理とメモリ見積もりを解説します。
画像なし
self-attentionの計算量とスケーラビリティ
Attentionの計算量O(N²d)とスケールさせる工夫を解説します。

MQA/GQAは、この3つの続編です。MHAでヘッドを分ける仕組み、KVキャッシュで過去のK,Vを貯める仕組みを前提に、「そのK,Vをどこまで共有できるか」を掘り下げます。

なぜ生成はメモリ帯域律速になるのか

MQA/GQAの狙いを腹落ちさせるには、まず「LLMの生成が何で遅くなっているのか」を正確に押さえる必要があります。素朴には「計算量が多いから遅い」と思いがちですが、自己回帰生成のボトルネックはむしろ メモリからデータを運ぶ速度 にあります。

生成は「行列×ベクトル」で演算強度が低い

学習やプロンプトの一括処理(プリフィル)では、多数のトークンをまとめて処理します。これは「行列×行列」の演算で、1バイトのデータを読むごとに多くの浮動小数点演算をこなせます。GPUのテンソルコアが最も得意とする形です。

一方、1トークンずつ吐き出す生成フェーズ(デコード)では、いま処理しているのは たった1個のトークン です。この1トークンのQueryを、キャッシュに貯まった大量のK,Vと突き合わせます。演算の中身は「1本のベクトル × 大きな行列」、つまり 行列×ベクトル です。

この違いを「演算強度」(1バイトのデータ読み出しあたり何回の演算をするか)という指標で見ると、両者は真逆の位置にいます。生成は演算強度が低く、GPUの演算器はスカスカに空いたまま、K,Vをメモリから読み出す時間だけが伸びていきます。これが メモリ帯域律速 です。

生成が帯域律速になる理由を示すルーフライン模式図

この図は横軸に演算強度、縦軸に到達スループットを取ったルーフライン図です。左側の斜めの領域(赤)は「帯域律速」で、演算強度が低いほどスループットが頭打ちになります。右側の平らな領域(青)は「演算律速」で、演算器の性能が上限になります。自己回帰生成は左端の赤い点にいて、GPUの演算ピークにまるで届いていません。逆に学習・プリフィルは右上の青い点にいて演算律速です。つまり生成を速くしたいなら、演算を減らすのではなく メモリから運ぶバイト数を減らす のが正解だと読み取れます。

運ぶバイト数を支配するのがKVキャッシュ

では生成中に何をいちばん多く運んでいるのでしょうか。それがKVキャッシュです。1トークン生成するたびに、全層・全ヘッドのK,Vをメモリから読み出してQueryと突き合わせます。文脈が長いほど、バッチが大きいほど、この読み出し量はふくらみます。

KVキャッシュの記事 で見たとおり、KVキャッシュの大きさは次の式で決まります。

$$ \begin{equation} \text{Memory}_{\text{KV}} = 2 \times L \times H_{kv} \times d_k \times T \times B \times \text{bytes} \end{equation} $$

ここで $L$ は層数、$H_{kv}$ はKVヘッド数、$d_k$ は1ヘッドあたりの次元、$T$ は系列長、$B$ はバッチサイズ、$\text{bytes}$ はデータ型のバイト数(fp16なら2)です。$2$ はKeyとValueの2種類ぶんです。

この式をよく見ると、生成の重さを直接に左右する項のうち、$L$(層数)や $d_k$(次元)はモデルの表現力に直結していて簡単には削れません。$T$ や $B$ はユーザーの使い方で決まる量です。ところが $H_{kv}$(KVヘッド数)だけは事情が違います。「ヘッドごとに別々のK,Vを持たなければならない」という前提を緩めれば、ここを大きく削れる のです。これがMQA/GQAの着眼点です。

次の節から、その $H_{kv}$ を減らす具体的な仕組みを見ていきます。

MHAの復習 — ヘッドごとに独立したK,Vを持つ

出発点として、標準のMulti-Head Attentionを $H_{kv}$ の視点で振り返っておきましょう。詳しくは Multi-Head Attention の記事にありますが、要点は「Query・Key・Valueをそれぞれ $h$ 個のヘッドに分ける」ことです。

入力 $\bm{x}$ から、ヘッド $i$($i = 1, \dots, h$)ごとに独立した射影行列でQ,K,Vを作ります。

$$ \begin{equation} \bm{q}_i = \bm{x}\bm{W}^Q_i, \quad \bm{k}_i = \bm{x}\bm{W}^K_i, \quad \bm{v}_i = \bm{x}\bm{W}^V_i \quad (i = 1, \dots, h) \end{equation} $$

各ヘッドは自分専用のK,Vを使ってAttentionを計算し、それを連結して出力します。

$$ \begin{equation} \text{head}_i = \text{softmax}\!\left(\frac{\bm{q}_i \bm{k}_i^\top}{\sqrt{d_k}}\right)\bm{v}_i, \quad \text{MHA}(\bm{x}) = \text{concat}(\text{head}_1, \dots, \text{head}_h)\,\bm{W}^O \end{equation} $$

ここで大事なのは、ヘッドの数だけK,Vの組が存在するという点です。ヘッド $i$ のQueryはヘッド $i$ のKey・Valueとしか照合しません。1対1対応です。したがって $H_{kv} = h$ となり、KVキャッシュは「Queryヘッド数」と同じだけ膨らみます。

これは表現力の面では理にかなっています。各ヘッドが別々の部分空間に注目できるからです。しかし生成時には、$h$ 個ぶんのK,Vをすべてメモリから読み出す必要があり、まさにここが帯域を食います。

そこで自然な疑問が生まれます。「Queryはヘッドごとに違ってもよいが、K,Vまでヘッドごとに完全に別々である必要は本当にあるのか?」 この問いに「いや、K,Vは1組で足りる」と答えたのがMQAです。

MQA — 全ヘッドでK,Vを1組だけ共有する

直感 — 質問は人それぞれ、資料は共通でよい

図書館の比喩で考えてみましょう。Attentionでは、Queryが「探したい情報」、Key・Valueが「本棚に並ぶ資料とその中身」でした(MHAの記事 参照)。

MHAは、ヘッドごとに 別々の本棚 を用意するようなものです。文法担当ヘッドには文法用の本棚、意味担当ヘッドには意味用の本棚、というふうに。これは贅沢ですが場所を取ります。

Multi-Query Attention(Shazeer, 2019)の発想はこうです。本棚(K,V)は1つの共通のものを全員で使い、質問(Query)だけ人それぞれ変える。文法担当も意味担当も、同じ本棚を見に行きます。ただし「何を探すか」という問いは各自で違うので、同じ本棚からでも違う情報を引き出せます。

数式 — Queryだけ $h$ 個、K,Vは1個

MQAでは、Queryはヘッドごとに作りますが、Key・Valueは全ヘッドで共有する1組だけを作ります。

$$ \begin{equation} \bm{q}_i = \bm{x}\bm{W}^Q_i \quad (i = 1, \dots, h), \qquad \bm{k} = \bm{x}\bm{W}^K, \quad \bm{v} = \bm{x}\bm{W}^V \end{equation} $$

$\bm{W}^K, \bm{W}^V$ の添字 $i$ が消えていることに注目してください。全ヘッドで同じ $\bm{k}, \bm{v}$ を使います。各ヘッドのAttentionは次のように、共有K,Vに対して計算します。

$$ \begin{equation} \text{head}_i = \text{softmax}\!\left(\frac{\bm{q}_i \bm{k}^\top}{\sqrt{d_k}}\right)\bm{v} \quad (i = 1, \dots, h) \end{equation} $$

Queryは $h$ 種類あるので、$h$ 個の異なる注意分布が得られます。共有された同じ本棚から、ヘッドごとに違う読み方をするわけです。

効果 — KVキャッシュが $1/h$ に

KVキャッシュの式で $H_{kv} = h$ が $H_{kv} = 1$ になるので、キャッシュ量はきれいに $1/h$ に縮みます。ヘッド数が32のモデルなら 1/32 です。生成時に読み出すK,Vのバイト数も同じ比率で減るので、帯域律速のデコードがそのぶん速くなります。

代償は表現力です。全ヘッドが同じK,Vを見るため、「ヘッドごとに違う情報を蓄える」という自由度が失われます。実際、MQAはMHAよりわずかに品質が落ちること、学習が不安定になることがあると報告されています。この「品質の落ち込み」を和らげるために提案されたのがGQAです。

その前に、MQAの共有構造を図で確認しておきましょう。

MHA/MQA/GQAのK,V共有パターンの概念図

この図は左からMHA・MQA・GQAのK,V共有パターンを示しています。上段の青い箱がQueryヘッド(8個で共通)、下段の緑の箱がKVヘッドです。左のMHAはQueryヘッド8個に対しKVヘッドも8個で1対1、キャッシュは基準の×1です。中央のMQAはKVヘッドがたった1個で、8本のQueryすべてがそこへ集まり、キャッシュは×1/8です。右のGQAはKVヘッドが2個で、Queryを4本ずつのグループに分けて共有し、キャッシュは×1/4です。矢印の集まり方を見れば、共有が強いほどKVヘッドが減る=メモリが減ることが直感的に読み取れます。

GQA — グループ単位でK,Vを共有する

直感 — 全員で1つは極端、班ごとに1つで折り合う

MHAは「1人1本棚」、MQAは「全員で1本棚」でした。GQA(Grouped-Query Attention、Ainslie et al., 2023)は、その中間の「班ごとに1本棚」を取ります。

8人のヘッドを2つの班(グループ)に分け、各班4人で1つの本棚を共有する。班が違えば本棚も違うので、MQAよりは多様な情報を持てます。同時に、本棚の数は8個から2個に減るので、MHAよりはずっとメモリが軽くなります。品質と効率のちょうどいい妥協点を探る、というのがGQAの思想です。

数式 — $g$ 個のグループでK,Vを共有

$h$ 個のQueryヘッドを $g$ 個のグループに分けます($g$ はKVヘッド数でもあります)。グループ $j$ には $h/g$ 個のQueryヘッドが属します。グループごとに1組のK,Vを作り、そのグループ内のQueryヘッドが共有します。

$$ \begin{equation} \bm{k}^{(j)} = \bm{x}\bm{W}^{K}_j, \quad \bm{v}^{(j)} = \bm{x}\bm{W}^{V}_j \quad (j = 1, \dots, g) \end{equation} $$

Queryヘッド $i$ が属するグループを $g(i)$ と書くと、そのヘッドのAttentionは自分のグループのK,Vを使います。

$$ \begin{equation} \text{head}_i = \text{softmax}\!\left(\frac{\bm{q}_i \,{\bm{k}^{(g(i))}}^\top}{\sqrt{d_k}}\right)\bm{v}^{(g(i))} \end{equation} $$

両端としてのMHAとMQA

この定義の美しいところは、$g$ を動かすだけでMHAもMQAも表現できる点です。

  • $g = h$ のとき:各グループにQueryヘッドが1個ずつ入る。つまりK,Vもヘッドごとに独立 → MHAそのもの
  • $g = 1$ のとき:全ヘッドが1グループに入り、K,Vは1組だけ共有 → MQAそのもの
  • $1 < g < h$ のとき:その中間。GQAの本領。

したがってMHA・GQA・MQAは別々の3手法というより、「グループ数 $g$」という1本のつまみを回してつながった連続スペクトル だと理解するのがいちばん正確です。

グループ数gを変えるとMHAからMQAへ連続的に変化するスペクトル図

この図は、Queryヘッド数を8で固定したまま、グループ数 $g$ を8→4→2→1と減らしていく様子を示しています。$g=8$ は右端のMHA、$g=1$ は左端のMQAに対応し、その間がGQAです。$g$ を減らすほどKVヘッドが減り、キャッシュメモリが $g/8$ に縮む一方で、共有が強まって表現力は下がります。左に行くほど表現力寄り、右に行くほどメモリ効率寄りという、トレードオフの向きが読み取れます。

効果 — キャッシュは $g/h$ に

KVヘッド数が $g$ なので、キャッシュ量はMHA比で $g/h$ になります。Llama 2 70Bは $h=64$ に対して $g=8$(グループサイズ8)を採用し、KVキャッシュを 1/8 に抑えています。Mistral 7Bも $h=32$、$g=8$ でKVキャッシュを1/4にしています。$g = h/8 \sim h/4$ 程度に取ると、MHAとの品質差はごくわずかで、MQAより明確に安定するというのが実験的な知見です。

ここまでで3手法の仕組みが揃いました。次は、この共有がKVキャッシュ量にどれだけ効くかを、具体的な数字で確かめます。

KVキャッシュ量を数字で比較する

抽象的な $g/h$ だけではピンと来ないので、Llama 2 70Bに近い設定で実際のバイト数を計算してみましょう。層数 $L=32$、1ヘッド次元 $d_k=128$、系列長 $T=4096$、バッチ $B=1$、fp16(2バイト)とします。QueryヘッドはMHA基準で32個とします。

キャッシュ量の式 $\text{Memory}_{\text{KV}} = 2 \times L \times H_{kv} \times d_k \times T \times B \times \text{bytes}$ に、$H_{kv}$ だけ変えて代入します。

  • MHA($H_{kv}=32$):$2 \times 32 \times 32 \times 128 \times 4096 \times 1 \times 2 = 2.147$ GB
  • GQA($H_{kv}=8$):$0.537$ GB(MHAの1/4)
  • MQA($H_{kv}=1$):$0.067$ GB(MHAの1/32)

MHAでは1系列あたり約2.1GBだったキャッシュが、GQAで0.5GB、MQAでは67MB弱まで縮みます。これはあくまで1系列ぶんです。推論サーバーは何十もの並列リクエストをさばくので、この差がそのまま「同じGPUに何ユーザー乗せられるか」に直結します。

MHA/GQA/MQAのKVキャッシュ量の棒グラフ比較

この棒グラフは上記の3設定のキャッシュ量を並べたものです。MHAの2.15GBに対し、GQAは0.54GB、MQAは0.07GBと、共有を強めるほど棒が劇的に低くなります。GQAでも4倍のメモリ節約が得られ、MQAに至ってはほぼ底を這っています。生成時に読み出すバイト数もこの比率なので、帯域律速のデコードがそのぶん軽くなると読み取れます。

系列長・バッチを変えるとどうなるか

キャッシュ量は系列長 $T$ とバッチ $B$ に線形なので、長文・大バッチではこの差が効いてきます。バッチを8にして系列長を伸ばした場合を見てみましょう。

系列長に対するMHA/GQA/MQAのKVキャッシュ量の折れ線

この折れ線は、バッチ8のときの系列長(横軸)に対するキャッシュ量(縦軸)です。破線はRTX 3090(24GB)とA100(80GB)のメモリ上限を示します。MHA(赤)は系列長が伸びるとすぐに24GB・80GBの壁にぶつかりますが、GQA(緑)はずっと余裕があり、MQA(青)はほとんど張り付いています。同じGPUで扱える文脈長やバッチサイズが、共有の度合いで大きく変わることが分かります。

KVヘッド数と系列長を同時に振ると、削減効果の全体像がさらに見えます。

KVヘッド数と系列長ごとのKVキャッシュ量ヒートマップ

このヒートマップは、縦軸にKVヘッド数(下がMQAの1、上がMHAの32)、横軸に系列長を取り、セルの色でキャッシュ量(GB)を示しています。同じ系列長でも、下(MQA)から上(MHA)へ行くほど色が明るくなり、メモリが増えていきます。逆に言えば、上から下へKVヘッドを減らすだけで、系列長を変えずにメモリを大きく節約できると読み取れます。行方向の変化(KVヘッド削減)が、列方向の変化(系列長短縮)と同じくらい効いている点がポイントです。

数字でメモリ削減が確認できました。では、この削減が実際の「速さ」にどう効くのかを次に見ます。

デコード時間はKVヘッド数に比例する

生成が帯域律速だという話に立ち返ると、1ステップのデコード時間は「そのステップで読み出すK,Vのバイト数」にほぼ比例するはずです。KVヘッドを減らせば読むバイトが減るので、時間も減ります。これを簡単な実測で確かめます。

系列長 $T=4096$、$d_k=128$ の設定で、KVヘッド数を1から32まで変えながら「1トークンのQueryを全K,Vと突き合わせる」処理の時間を測ると、次のようになりました。

KVヘッド数 1 2 4 8 16 32
KV読み出し時間 (ms) 0.054 0.094 0.183 0.367 0.731 1.373

KVヘッドが2倍になるとほぼ時間も2倍で、きれいな比例関係が出ています。

KVヘッド数に比例するデコード時のKV読み出し時間

この図は上の表を折れ線にしたものです。KVヘッド数(横軸)に対してKV読み出し時間(縦軸)が直線的に増えています。MQA(KV=1)はMHA(KV=32)に比べて約25倍速く、GQA(KV=8)でもMHAの約1/4の時間で済みます。生成が帯域律速だからこそ、読み出すK,Vのバイト数を減らすことがそのまま速度に効く、という因果がはっきり見えます。

速度とメモリの利点は明快ですが、共有すれば品質が下がるのも事実です。そのトレードオフを整理します。

品質とのトレードオフ

MHAは表現力最大・メモリ最大、MQAはメモリ最小・表現力最小、GQAはその間です。実務で問われるのは「どこまで共有しても品質が保てるか」です。

Ainslieらの実験では、MQAはMHAに比べて品質がやや落ちるのに対し、$g = h/8$ 程度のGQAはMHAとほぼ同等の品質を、MQAとほぼ同等の速度で達成できると報告されています。つまりGQAは「MQAの速さを、MHAの品質を大きく損なわずに手に入れる」おいしい妥協点です。

品質とメモリのトレードオフ概念図

この図は横軸にKVヘッド数(左ほどメモリ効率が高い)、縦軸にモデル品質(相対・定性)を取った概念図です。右端のMHAから左へKVヘッドを減らしていくと、しばらくは品質がほぼ横ばいのままメモリだけが減り、GQA(Llama 2の$g=8$相当)付近までは品質がよく保たれます。ところが左端のMQA(KV=1)に近づくと品質がやや落ち込みます。「$g$ をある程度まで減らすのはほぼ無料、そこから先は品質を少し払う」という曲線の形が、GQAが好まれる理由をよく表しています。

なぜ共有すると品質が落ちるのかは、各ヘッドが「見に行く先」を見ると分かります。

MHA/GQA/MQAの最終トークンのattention分布

この図は、ランダム初期化した小さなモデルで、最終トークンの各Queryヘッドが過去トークンにどう注意を配るか(注意重み)をヒートマップにしたものです。左のMHAでは4つのQueryヘッドがそれぞれ別々のK,Vを見るため、行ごとにパターンが大きく違います。中央のGQAでは2ヘッドずつが同じK,Vを共有するため、ペアで似たパターンが現れます。右のMQAでは全ヘッドが同じK,Vを見るので、Queryの違いで多少ずれるものの、パターンが揃ってきます。共有が強いほど「ヘッドの多様性」が減る様子が読み取れ、これが品質低下の源だと理解できます。

品質を回復するには、共有K,Vをきちんと学習し直すのが理想です。ただしゼロから学習するのは高価です。そこで既存のMHAモデルを賢く作り替える方法が使われます。

uptraining — 学習済みMHAをGQAに作り替える

GQAの実務上の魅力は、すでに大金をかけて学習したMHAモデルを、そのまま活かしてGQA化できる ことにあります。この変換をuptrainingと呼びます。

手順はシンプルです。GQAの各グループには、元のMHAで複数のヘッドが対応します。そこで、そのグループに属する複数のヘッドのK射影・V射影を 平均プーリング して、共有K,Vの初期値にします。

$$ \begin{equation} \bar{\bm{W}}^K_j = \frac{1}{|G_j|}\sum_{i \in G_j} \bm{W}^K_i, \quad \bar{\bm{W}}^V_j = \frac{1}{|G_j|}\sum_{i \in G_j} \bm{W}^V_i \end{equation} $$

ここで $G_j$ はグループ $j$ に属するヘッドの集合、$|G_j| = h/g$ です。平均を初期値にするのがポイントで、いきなり1つのヘッドを選ぶより、グループ内の情報をまんべんなく引き継げます。

初期化しただけでは品質が少し落ちるので、そのあと 元の学習データのごく一部(数%)で追加学習 します。ゼロから学び直すより桁違いに安価で、これでMHAに近い品質のGQAモデルが手に入ります。

平均プーリングでMHAをGQAに初期化するuptrainingの概念図

この図はuptrainingの流れを示しています。左の学習済みMHAには、1グループに対応する4つのK射影 $\bm{W}^K_1 \dots \bm{W}^K_4$ があります。これらを平均プーリングして、右のGQAの共有K射影 $\bar{\bm{W}}^K$ の初期値にします。その後、全データのわずか数%でuptrainingすると、初期化のずれが吸収されて品質が回復します。学習済みの資産を捨てずにGQA化できる、という実務上の強みが読み取れます。

これで仕組みは一通り揃いました。最後に、MHA/GQA/MQAを実際にPyTorchで実装し、形状とキャッシュ量を自分の手で確かめます。

PyTorchでの実装と検証

MHA・GQA・MQAは、KVヘッド数 $H_{kv}$ を変えるだけの1つの実装で書けます。共有の肝は、少ないK,Vヘッドをグループ内のQueryヘッド数だけ複製して形を合わせる repeat_interleave の一行です。

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)


def attention(q, k, v):
    # q: (B, Hq, T, dk), k,v: (B, Hkv, T, dk)  ※Hkv <= Hq
    B, Hq, T, dk = q.shape
    Hkv = k.shape[1]
    rep = Hq // Hkv
    # KVヘッドをグループ内Queryヘッド数だけ複製して形を合わせる
    k = k.repeat_interleave(rep, dim=1)  # (B, Hq, T, dk)
    v = v.repeat_interleave(rep, dim=1)
    scores = (q @ k.transpose(-1, -2)) / (dk ** 0.5)  # (B, Hq, T, T)
    attn = F.softmax(scores, dim=-1)
    return attn @ v  # (B, Hq, T, dk)


class GroupedAttention(nn.Module):
    def __init__(self, d_model, n_q_heads, n_kv_heads):
        super().__init__()
        assert n_q_heads % n_kv_heads == 0
        self.hq, self.hkv = n_q_heads, n_kv_heads
        self.dk = d_model // n_q_heads
        self.Wq = nn.Linear(d_model, n_q_heads * self.dk, bias=False)
        self.Wk = nn.Linear(d_model, n_kv_heads * self.dk, bias=False)  # KVは少ない
        self.Wv = nn.Linear(d_model, n_kv_heads * self.dk, bias=False)
        self.Wo = nn.Linear(n_q_heads * self.dk, d_model, bias=False)

    def forward(self, x):
        B, T, _ = x.shape
        q = self.Wq(x).view(B, T, self.hq, self.dk).transpose(1, 2)
        k = self.Wk(x).view(B, T, self.hkv, self.dk).transpose(1, 2)
        v = self.Wv(x).view(B, T, self.hkv, self.dk).transpose(1, 2)
        print(f"  Q={tuple(q.shape)}  K={tuple(k.shape)}  V={tuple(v.shape)}")
        o = attention(q, k, v)                      # (B, Hq, T, dk)
        o = o.transpose(1, 2).reshape(B, T, self.hq * self.dk)
        return self.Wo(o)

WkWv の出力次元が n_kv_heads * dk になっている点が要です。ここが n_q_heads より小さいぶんだけ、K,Vの射影パラメータもキャッシュも減ります。では同じ入力に対して、KVヘッド数だけ変えて3手法を動かし、テンソル形状を見てみます。

B, T, d_model, Hq = 2, 5, 64, 8
x = torch.randn(B, T, d_model)
print("入力 x:", tuple(x.shape))

print("[MHA] Hq=8, Hkv=8")
o1 = GroupedAttention(d_model, Hq, 8)(x); print("  出力:", tuple(o1.shape))
print("[GQA] Hq=8, Hkv=2")
o2 = GroupedAttention(d_model, Hq, 2)(x); print("  出力:", tuple(o2.shape))
print("[MQA] Hq=8, Hkv=1")
o3 = GroupedAttention(d_model, Hq, 1)(x); print("  出力:", tuple(o3.shape))

実行結果:

入力 x: (2, 5, 64)
[MHA] Hq=8, Hkv=8
  Q=(2, 8, 5, 8)  K=(2, 8, 5, 8)  V=(2, 8, 5, 8)
  出力: (2, 5, 64)
[GQA] Hq=8, Hkv=2
  Q=(2, 8, 5, 8)  K=(2, 2, 5, 8)  V=(2, 2, 5, 8)
  出力: (2, 5, 64)
[MQA] Hq=8, Hkv=1
  Q=(2, 8, 5, 8)  K=(2, 1, 5, 8)  V=(2, 1, 5, 8)
  出力: (2, 5, 64)

出力形状はどれも入力と同じ (2, 5, 64) で、3手法とも問題なく計算できています。注目すべきはKとVの2番目の軸(ヘッド数)です。MHAは 8、GQAは 2、MQAは 1 と、Queryの 8 はそのままにK,Vだけが減っています。キャッシュに貯めるのはこのK,Vなので、この軸の縮小がそのままメモリ削減になる わけです。Queryヘッド数は変わらないので、出力の表現力(ヘッドの本数)は保たれる点も確認できます。

キャッシュ量を実際に計算する

続いて、Llama 2 70B相当の設定でキャッシュのバイト数を計算し、先ほどの図の数字を再現します。

def kv_bytes(L, hkv, dk, T, B, byt=2):
    return 2 * L * hkv * dk * T * B * byt

L, dk, T, Bb = 32, 128, 4096, 1
for name, hkv in [("MHA", 32), ("GQA", 8), ("MQA", 1)]:
    b = kv_bytes(L, hkv, dk, T, Bb)
    print(f"{name}(KV={hkv:2d}): {b/1e9:.3f} GB  (MHA比 {hkv/32:.3f})")

実行結果:

MHA(KV=32): 2.147 GB  (MHA比 1.000)
GQA(KV= 8): 0.537 GB  (MHA比 0.250)
MQA(KV= 1): 0.067 GB  (MHA比 0.031)

計算結果は棒グラフと完全に一致します。GQAでMHAの1/4(0.537GB)、MQAで1/32(0.067GB)です。KVヘッド数を式に入れるだけで、これだけの差が出ることを自分の手で確認できました。

実装・形状・キャッシュ量がすべて理論どおりに揃いました。最後に要点をまとめます。

まとめ

本記事では、MQAとGQAがKVキャッシュを削ってLLM推論を軽くする仕組みを解説しました。

  • 生成は帯域律速: 自己回帰生成は行列×ベクトルで演算強度が低く、GPUの演算器ではなくメモリ帯域がボトルネック。速くするには演算でなく「運ぶバイト数」を減らす
  • 運ぶバイトの主役はKVキャッシュ: 大きさは $2 L H_{kv} d_k T B \times \text{bytes}$。このうちKVヘッド数 $H_{kv}$ は、K,V共有で削れる唯一の項
  • MQA: 全ヘッドでK,Vを1組だけ共有。キャッシュを $1/h$ に激減させるが、表現力がやや落ちる
  • GQA: $g$ グループでK,Vを共有。$g=h$ でMHA、$g=1$ でMQAとなる連続スペクトルの中間点。$g=h/8$ 程度でMHA並みの品質とMQA並みの速度を両立し、Llama 2 70BやMistralが採用
  • 実測の一致: PyTorchでK,Vのヘッド軸だけが縮むことを形状で確認。キャッシュ量はMHA 2.15GB → GQA 0.54GB → MQA 0.07GB、デコード時間もKVヘッド数に比例
  • uptraining: 学習済みMHAの複数ヘッドの射影を平均プーリングしてGQAを初期化し、少量再学習するだけで品質を回復できる

MQA/GQAは、KVキャッシュのメモリと帯域を根本から削る「アーキテクチャ側の対策」です。これに、キャッシュの管理を効率化するPagedAttentionや、K,Vをint8/int4に落とすKV量子化などの「実装側の対策」を組み合わせることで、長文・大バッチのLLM推論が現実的な速度で回るようになります。

次のステップとして、以下の記事も参考にしてください。

画像なし
KVキャッシュの仕組み — LLM推論を高速化する基本技術
MQA/GQAが削減する対象であるKVキャッシュそのものの原理とメモリ見積もりを解説します。
画像なし
Multi-Head Attentionの理論と実装を完全解説
MQA/GQAの出発点であるMHAの仕組みと、ヘッド分割の幾何学的意味を解説します。
画像なし
self-attentionの計算量とスケーラビリティ
Attentionの計算量とスケールさせる工夫を、演算量・メモリの両面から解説します。