Перейти к содержимому
Ступень 55.2 Эффективное внимание — от MHA к MLA

MQA и GQA — делим ключи между головами

Почему на длинном контексте узким местом становится KV-кэш, а не веса, и как Multi-Query и Grouped-Query attention сокращают его в разы.

УРОКstage-5/5-2-efficient-attention/01-mqa-gqa.mdx1 ч 30 минУрок 1 из 2
После урока вы сможете
  • Посчитать размер KV-кэша любой модели по её конфигу и объяснить, почему декодирование упирается в память
  • Объяснить, чем MHA, MQA и GQA отличаются и чем каждая из них платит за экономию
  • Реализовать GQA через повторение KV-голов на NumPy и проверить, что при G = H и G = 1 получаются MHA и MQA

В уроке 4.6.4 вы ускорили генерацию с помощью KV-кэшаKV-кэшEN KV cache: ключи и значения прошлых токенов не пересчитываются, а хранятся. За скорость платим памятью, и на длинном контексте этот счёт становится огромным.

Возьмём Llama 3.1 70B с её контекстом 128 тысяч токенов. Веса в BF16 занимают около 140 ГБ. KV-кэш одного запроса на всю длину контекста — ещё 40 ГиБ. И это уже с экономией из этого урока. Если бы модель использовала классическое многоголовое вниманиемногоголовое вниманиеEN multi-head attention (MHA), кэш одного такого запроса весил бы 320 ГиБ, более чем вдвое больше всей модели. Восемь пользователей с длинными документами заняли бы несколько серверов только под кэш.

Поэтому все современные LLM экономят на кэше. Этот модуль проходит путь от простых решений к изобретению DeepSeek: сначала MQA и GQA (этот урок), затем MLA (следующий). Первые два шага сделали в Google, а последний, самый смелый, — китайская лаборатория DeepSeek.

На каждом слое для каждого токена хранится ключ и значение каждой KV-головы. Формула из урока 4.6.4, но теперь с числом KV-голов GG, которое может быть меньше числа голов запросов HH:

KV-кэш=2⋅L⋅G⋅dh⋅T⋅B⋅(байт на число)\text{KV-кэш} = 2 \cdot L \cdot G \cdot d_h \cdot T \cdot B \cdot \text{(байт на число)}

Здесь LL — число слоёв, dhd_h — размер головы, TT — длина контекста, BB — батч, двойка — это K и V. Посчитаем для Llama 3 8B: L=32L = 32, G=8G = 8, dh=128d_h = 128, BF16 (2 байта):

2⋅32⋅8⋅128⋅2=131 072 байт=128 КиБ на токен2 \cdot 32 \cdot 8 \cdot 128 \cdot 2 = 131\,072 \text{ байт} = 128 \text{ КиБ на токен}

На контексте 8192 токена это ровно 1 ГиБ (2302^{30} байт), на 128K — 16 ГиБ. Сами веса модели — около 16 ГБ. На длинном контексте кэш одного запроса весит столько же, сколько вся модель.

Конфигурации реальных моделей (BF16, ГиБ = 2302^{30} байт):

МодельСлоёв LLГолов Q / KVКэш на токен32K токенов
Llama 3 8B3232 / 8128 КиБ4.0 ГиБ
Llama 3 / 3.1 70B8064 / 8320 КиБ10 ГиБ
Llama 3.1 405B126128 / 8504 КиБ15.8 ГиБ
Qwen2.5-7B2828 / 456 КиБ1.75 ГиБ
Qwen2.5-72B8064 / 8320 КиБ10 ГиБ
Qwen3-8B3632 / 8144 КиБ4.5 ГиБ
Qwen3-235B-A22B9464 / 4188 КиБ5.9 ГиБ
DeepSeek 67B (2023)9564 / 8380 КиБ11.9 ГиБ
Llama 3 70B, если бы MHA8064 / 642.5 МиБ80 ГиБ

У всех моделей в таблице dh=128d_h = 128. Qwen3-235B-A22B — MoE-модель, но на размер кэша это не влияет: кэш определяется слоем внимания, а эксперты заменяют FFN (модуль 5.3). Поиграйте с калькулятором: переключите тип внимания с GQA на MHA и посмотрите, как растёт кэш.

Калькулятор KV-кэшаинтерактив
Архитектура
Нагрузка
Та же модель с разными схемами внимания (на токен)
MHA/GQA: на токен хранится 2 · слои · KV-головы · размер головы чисел (K и V). MLA (DeepSeek-V2/V3): на токен хранится только латент c_KV размера d_c и общий RoPE-ключ размера d_rope — (d_c + d_rope) · слои чисел.

Почему важна не только память, но и скорость

Заголовок раздела «Почему важна не только память, но и скорость»

На шаге декодирования модель генерирует один токен. Для этого она читает из видеопамяти все веса и весь KV-кэш, а считает на каждый прочитанный байт очень мало.

Прикинем для внимания одной головы. Чтобы обработать TT закэшированных токенов, нужно прочитать 2⋅T⋅dh2 \cdot T \cdot d_h чисел (в BF16 это 4Tdh4 T d_h байт) и выполнить около 4Tdh4 T d_h операций: скалярные произведения с ключами и взвешенную сумму значений. Выходит около 1 FLOP на байт. А у H100 SXM примерно 989 TFLOPS в BF16 при пропускной способности памяти около 3.35 ТБ/с, то есть порядка 300 FLOP на байт. Вычислитель простаивает, пока ждёт данные: внимание при декодировании ограничено памятью (memory-boundограничено памятью (memory-bound)EN memory-boundОперация, которая дольше ждёт данные из памяти, чем считает. Ускоряется, если читать меньше байт (квантование, GQA, MLA) или делать больше работы на каждый прочитанный байт (батчинг).).

Отсюда вывод: время, которое внимание тратит на шаге декодирования, почти пропорционально объёму кэша. Уменьшили кэш в 8 раз — ускорили внимание почти в 8 раз. Кроме кэша, на каждом шаге читаются и веса модели, но их читают один раз на весь батч. Поэтому чем больше батч и длиннее контексты, тем большую часть шага занимает именно чтение кэша. Подробно о compute-bound и memory-bound — в уроке 7.1.1.

Ноам Шазир (Noam Shazeer, один из авторов «Attention Is All You Need») в 2019 году предложил радикальный шаг: пусть у всех HH голов будут свои запросы, но общие ключи и значения. Это Multi-Query Attention (MQA)многозапросное внимание (MQA)EN multi-query attention (MQA)Все головы запросов делят одну голову ключей и одну голову значений. KV-кэш меньше в H раз, но качество заметно падает (Shazeer, 2019)..

headi=softmax ⁣(QiK⊤dh)V,i=1,…,H\text{head}_i = \mathrm{softmax}\!\left(\frac{Q_i K^\top}{\sqrt{d_h}}\right) V, \qquad i = 1, \dots, H

Матрицы WKW_K и WVW_V сжимаются из d×Hdhd \times H d_h до d×dhd \times d_h, кэш уменьшается в HH раз. Каждый прочитанный ключ теперь используется всеми HH головами, и вычислений на байт становится в HH раз больше.

MQA применяли в PaLM и StarCoder. Но у неё есть цена: головы больше не могут «смотреть» на разные аспекты ключей. DeepSeek честно измерила эту цену в приложении D.1 к статье о DeepSeek-V2. Три плотные модели примерно по 7B параметров (число слоёв подогнали так, чтобы параметров было поровну) обучили на 1.33T токенов:

БенчмаркMQAGQA (8 групп)MHA
MMLU37.941.245.2
C-Eval30.037.742.9
CMMLU34.638.443.5
BBH33.235.637.0

Что это за тесты: MMLU — вопросы с вариантами ответа по 57 предметам (урок 7.3.1), C-Eval и CMMLU — его китайские аналоги, BBH (BIG-Bench Hard) — набор трудных задач на многошаговое рассуждение. Числа — доля верных ответов в процентах. Разница в 7 пунктов MMLU между MQA и MHA — это огромно. Нужна золотая середина.

Grouped-Query Attention (GQA)внимание с группами запросов (GQA)EN grouped-query attention (GQA)Головы запросов делятся на G групп, у каждой группы своя пара K/V. G = H — это MHA, G = 1 — MQA. Стандарт Llama 3 и Qwen (Ainslie и др., 2023). (Ainslie и коллеги из Google, 2023) делит HH голов запросов на GG групп. Внутри группы головы делят одну пару K/V. Голова запроса ii использует KV-голову номер ⌊i/(H/G)⌋\lfloor i / (H/G) \rfloor (скобки ⌊⋅⌋\lfloor \cdot \rfloor — округление вниз, // в Python). Пример: H=4H = 4, G=2G = 2 — головы запросов 0 и 1 берут KV-голову 0, головы 2 и 3 — KV-голову 1.

flowchart TB
subgraph A["MHA: 4 KV-головы"]
  a1["Q1"] --> ak1["KV1"]
  a2["Q2"] --> ak2["KV2"]
  a3["Q3"] --> ak3["KV3"]
  a4["Q4"] --> ak4["KV4"]
end
subgraph B["GQA: 2 KV-головы"]
  b1["Q1"] --> bk1["KV1"]
  b2["Q2"] --> bk1
  b3["Q3"] --> bk2["KV2"]
  b4["Q4"] --> bk2
end
subgraph C["MQA: 1 KV-голова"]
  c1["Q1"] --> ck["KV"]
  c2["Q2"] --> ck
  c3["Q3"] --> ck
  c4["Q4"] --> ck
end
Четыре головы запросов и разное число KV-голов. Кэшируются только KV-головы.

Два крайних случая — старые знакомые: G=HG = H это MHA, G=1G = 1 это MQA. Кэш и чтение памяти уменьшаются в H/GH/G раз. В статье GQA с 8 группами почти догнала MHA по качеству и почти догнала MQA по скорости.

Ещё одна практическая причина выбрать GQA — тензорный параллелизм (урок 5.4.3): большую модель режут по головам между, например, 8 GPU. Единственную KV-голову MQA приходится копировать на каждую GPU, и экономия частично теряется. В GQA с 8 KV-головами каждая GPU хранит ровно свою. Неудивительно, что G=8G = 8 стал стандартом: так устроены все размеры Llama 3 и все плотные модели Qwen3.

Обучать модель с нуля ради другого внимания дорого. Авторы GQA предложили uptraining:

  1. Взять обученный MHA-чекпоинт.
  2. В каждой группе усреднить проекции WKW_K и WVW_V входящих в неё голов (mean-pooling). Это сработало лучше, чем взять одну голову группы или инициализировать заново.
  3. Дообучить модель примерно на 5% исходного вычислительного бюджета.

Тот же приём «сжать готовую модель и чуть дообучить» вы встретите в следующем уроке: так переводят GQA-модели на MLA.

Внутри GQA — обычное многоголовое внимание из урока 4.4.3. Отличие одно: проекции K и V выдают GG голов, а перед умножением их «размножают» до HH. В коде Llama, Qwen и MiniMind это функция repeat_kv, эквивалент torch.repeat_interleave по оси голов.

Важная деталь: копия нужна только для удобства вычислений. В кэше лежат GG голов, а эффективные ядра вроде FlashAttention (они считают внимание блоками, не собирая всю матрицу оценок в памяти) вообще не копируют данные: несколько голов запросов просто читают одну и ту же KV-голову. В PyTorch 2.5+ F.scaled_dot_product_attention(..., enable_gqa=True) принимает K и V с меньшим числом голов напрямую.

Реализуйте repeat_kv, причинное GQA и функцию размера KV-кэша. Тесты проверят крайние случаи MHA и MQA, правильное сопоставление голов, причинность и цифры для Llama 3.

GQA через повторение KV-головnumpy · нужен интернет7 тестов
import numpy as np


def repeat_kv(kv, n_rep):
    """Повторяет каждую KV-голову n_rep раз подряд.

    kv: массив формы (G, T, d) — G голов ключей (или значений).
    Возвращает массив (G * n_rep, T, d), где голова j результата — копия головы j // n_rep.
    Порядок важен: для G=2, n_rep=3 головы идут как [0, 0, 0, 1, 1, 1], а не [0, 1, 0, 1, 0, 1].
    """
    # TODO: одна строка с np.repeat (подумайте, по какой оси)
    ...


def gqa_attention(q, k, v):
    """Причинное grouped-query attention.

    q: (H, T, d) — H голов запросов;
    k, v: (G, T, d) — G голов ключей и значений, H делится на G.
    Возвращает (H, T, d). Масштаб 1/sqrt(d), причинная маска: позиция t видит только позиции <= t.
    При G == H это обычное MHA, при G == 1 — MQA.
    """
    H, T, d = q.shape
    G = k.shape[0]
    # TODO: 1) повторите k и v до H голов; 2) scores = q @ k^T / sqrt(d);
    #       3) замаскируйте будущее (-inf выше диагонали); 4) устойчивый softmax; 5) веса @ v
    ...


def kv_cache_bytes(n_layers, n_kv_heads, head_dim, seq_len, batch=1, bytes_per_elem=2):
    """Размер KV-кэша в байтах: K и V для каждого слоя, KV-головы, позиции и примера в батче."""
    # TODO
    ...


# Проверьте себя кнопкой «Запустить»:
rng = np.random.default_rng(0)
q = rng.normal(size=(8, 4, 16))
k = rng.normal(size=(2, 4, 16))
v = rng.normal(size=(2, 4, 16))
r = repeat_kv(np.arange(2).reshape(2, 1, 1), 3)
print(None if r is None else r.ravel())                      # ожидается [0 0 0 1 1 1]
out = gqa_attention(q, k, v)
print(None if out is None else out.shape)                    # ожидается (8, 4, 16)
gib = kv_cache_bytes(32, 8, 128, 8192)
print(gib if gib is None else gib / 2**30, "ГиБ")             # Llama 3 8B, 8K токенов: 1.0 ГиБ

Модуль GQA, сравнение числа параметров MHA/GQA/MQA при размерах Llama 3 8B, проверка встроенного enable_gqa и конвертация MHA → GQA по рецепту статьи.

import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
class GQA(nn.Module):
"""Grouped-Query Attention: n_heads голов запросов, n_kv_heads голов ключей/значений."""
def __init__(self, d_model, n_heads, n_kv_heads):
super().__init__()
assert n_heads % n_kv_heads == 0
self.h, self.g, self.dh = n_heads, n_kv_heads, d_model // n_heads
self.wq = nn.Linear(d_model, n_heads * self.dh, bias=False)
self.wk = nn.Linear(d_model, n_kv_heads * self.dh, bias=False) # в H/G раз уже, чем в MHA
self.wv = nn.Linear(d_model, n_kv_heads * self.dh, bias=False)
self.wo = nn.Linear(n_heads * self.dh, d_model, bias=False)
def forward(self, x):
B, T, _ = x.shape
q = self.wq(x).view(B, T, self.h, self.dh).transpose(1, 2) # (B, H, T, dh)
k = self.wk(x).view(B, T, self.g, self.dh).transpose(1, 2) # (B, G, T, dh) ← это и кэшируется
v = self.wv(x).view(B, T, self.g, self.dh).transpose(1, 2)
rep = self.h // self.g
k, v = k.repeat_interleave(rep, dim=1), v.repeat_interleave(rep, dim=1) # (B, H, T, dh)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
return self.wo(y.transpose(1, 2).reshape(B, T, -1))
# 1) Сколько параметров внимания в одном слое при d_model = 4096, 32 головы (как в Llama 3 8B)
for name, g in [("MHA", 32), ("GQA-8", 8), ("MQA", 1)]:
n = sum(p.numel() for p in GQA(4096, 32, g).parameters())
print(f"{name:6s} {n / 1e6:5.1f} M параметров, KV-голов: {g}")
# 2) Встроенная поддержка GQA в PyTorch ≥ 2.5 даёт то же самое без копирования голов
m = GQA(256, 8, 2)
x = torch.randn(2, 10, 256)
B, T, _ = x.shape
q = m.wq(x).view(B, T, 8, 32).transpose(1, 2)
k = m.wk(x).view(B, T, 2, 32).transpose(1, 2)
v = m.wv(x).view(B, T, 2, 32).transpose(1, 2)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=True)
y = m.wo(y.transpose(1, 2).reshape(B, T, -1))
print("enable_gqa совпадает:", torch.allclose(y, m(x), atol=1e-5))
# 3) «Uptraining» по рецепту статьи GQA: усредняем K- и V-головы внутри группы
mha = GQA(256, 8, 8)
gqa = GQA(256, 8, 2)
with torch.no_grad():
for src, dst in [(mha.wk, gqa.wk), (mha.wv, gqa.wv)]:
w = src.weight.view(2, 4, 32, 256) # (группы, голов в группе, dh, d_model)
dst.weight.copy_(w.mean(dim=1).reshape(64, 256))
gqa.wq.weight.copy_(mha.wq.weight)
gqa.wo.weight.copy_(mha.wo.weight)
err = (gqa(x) - mha(x)).norm() / mha(x).norm()
print(f"относительная ошибка после mean-pool: {err:.2f} — дальше нужно дообучение")

Ожидаемый вывод (PyTorch 2.5+):

MHA 67.1 M параметров, KV-голов: 32
GQA-8 41.9 M параметров, KV-голов: 8
MQA 34.6 M параметров, KV-голов: 1
enable_gqa совпадает: True
относительная ошибка после mean-pool: 0.87 — дальше нужно дообучение

Обратите внимание: GQA экономит и параметры (−38% в слое внимания), но главное — кэш, который меньше в 4 раза. Большая ошибка после mean-pool на случайных весах ожидаема: случайные головы ничем не похожи друг на друга. Именно поэтому после усреднения модель дообучают.

Проверь себя6 вопросов
1 Сколько КиБ занимает KV-кэш Llama 3 8B на один токен? 32 слоя, 8 KV-голов по 128, BF16.
2 У модели 64 головы запросов и 8 KV-голов. Какую KV-голову использует голова запроса номер 20 (нумерация с нуля)?
3 Какие утверждения верны?Можно выбрать несколько ответов
4 Почему при генерации одного длинного ответа внимание упирается в пропускную способность памяти?
5 Во сколько раз GQA в Qwen3-235B-A22B (64 головы запросов, 4 KV-головы) уменьшает KV-кэш по сравнению с MHA той же формы?
6 Как авторы GQA превращают обученную MHA-модель в GQA?
  • KV-кэш =2⋅L⋅G⋅dh⋅T⋅B⋅= 2 \cdot L \cdot G \cdot d_h \cdot T \cdot B \cdot байт. На длинном контексте он сравним с весами модели или больше их.
  • Декодирование ограничено памятью: время внимания на каждом шаге пропорционально объёму кэша, который нужно прочитать.
  • MQA (G=1G = 1) сжимает кэш в HH раз, но заметно теряет в качестве. GQA (1<G<H1 < G < H) — стандартный компромисс, обычно G=8G = 8 или меньше.
  • MHA-модель можно перевести в GQA усреднением голов и коротким дообучением.

Дальше: DeepSeek нашла способ сделать кэш меньше, чем у GQA, и при этом не потерять качество MHA. Это Multi-head Latent Attention — следующий урок.