MQA и GQA — делим ключи между головами
Почему на длинном контексте узким местом становится KV-кэш, а не веса, и как Multi-Query и Grouped-Query attention сокращают его в разы.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В уроке 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-кэш
Заголовок раздела «Сколько весит KV-кэш»На каждом слое для каждого токена хранится ключ и значение каждой KV-головы. Формула из урока 4.6.4, но теперь с числом KV-голов , которое может быть меньше числа голов запросов :
Здесь — число слоёв, — размер головы, — длина контекста, — батч, двойка — это K и V. Посчитаем для Llama 3 8B: , , , BF16 (2 байта):
На контексте 8192 токена это ровно 1 ГиБ ( байт), на 128K — 16 ГиБ. Сами веса модели — около 16 ГБ. На длинном контексте кэш одного запроса весит столько же, сколько вся модель.
Конфигурации реальных моделей (BF16, ГиБ = байт):
| Модель | Слоёв | Голов Q / KV | Кэш на токен | 32K токенов |
|---|---|---|---|---|
| Llama 3 8B | 32 | 32 / 8 | 128 КиБ | 4.0 ГиБ |
| Llama 3 / 3.1 70B | 80 | 64 / 8 | 320 КиБ | 10 ГиБ |
| Llama 3.1 405B | 126 | 128 / 8 | 504 КиБ | 15.8 ГиБ |
| Qwen2.5-7B | 28 | 28 / 4 | 56 КиБ | 1.75 ГиБ |
| Qwen2.5-72B | 80 | 64 / 8 | 320 КиБ | 10 ГиБ |
| Qwen3-8B | 36 | 32 / 8 | 144 КиБ | 4.5 ГиБ |
| Qwen3-235B-A22B | 94 | 64 / 4 | 188 КиБ | 5.9 ГиБ |
| DeepSeek 67B (2023) | 95 | 64 / 8 | 380 КиБ | 11.9 ГиБ |
| Llama 3 70B, если бы MHA | 80 | 64 / 64 | 2.5 МиБ | 80 ГиБ |
У всех моделей в таблице . Qwen3-235B-A22B — MoE-модель, но на размер кэша это не влияет: кэш определяется слоем внимания, а эксперты заменяют FFN (модуль 5.3). Поиграйте с калькулятором: переключите тип внимания с GQA на MHA и посмотрите, как растёт кэш.
Почему важна не только память, но и скорость
Заголовок раздела «Почему важна не только память, но и скорость»На шаге декодирования модель генерирует один токен. Для этого она читает из видеопамяти все веса и весь KV-кэш, а считает на каждый прочитанный байт очень мало.
Прикинем для внимания одной головы. Чтобы обработать закэшированных токенов, нужно прочитать чисел (в BF16 это байт) и выполнить около операций: скалярные произведения с ключами и взвешенную сумму значений. Выходит около 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.
MQA: одна пара K/V на все головы
Заголовок раздела «MQA: одна пара K/V на все головы»Ноам Шазир (Noam Shazeer, один из авторов «Attention Is All You Need») в 2019 году предложил радикальный шаг: пусть у всех голов будут свои запросы, но общие ключи и значения. Это Multi-Query Attention (MQA)многозапросное внимание (MQA)EN multi-query attention (MQA)Все головы запросов делят одну голову ключей и одну голову значений. KV-кэш меньше в H раз, но качество заметно падает (Shazeer, 2019)..
Матрицы и сжимаются из до , кэш уменьшается в раз. Каждый прочитанный ключ теперь используется всеми головами, и вычислений на байт становится в раз больше.
MQA применяли в PaLM и StarCoder. Но у неё есть цена: головы больше не могут «смотреть» на разные аспекты ключей. DeepSeek честно измерила эту цену в приложении D.1 к статье о DeepSeek-V2. Три плотные модели примерно по 7B параметров (число слоёв подогнали так, чтобы параметров было поровну) обучили на 1.33T токенов:
| Бенчмарк | MQA | GQA (8 групп) | MHA |
|---|---|---|---|
| MMLU | 37.9 | 41.2 | 45.2 |
| C-Eval | 30.0 | 37.7 | 42.9 |
| CMMLU | 34.6 | 38.4 | 43.5 |
| BBH | 33.2 | 35.6 | 37.0 |
Что это за тесты: MMLU — вопросы с вариантами ответа по 57 предметам (урок 7.3.1), C-Eval и CMMLU — его китайские аналоги, BBH (BIG-Bench Hard) — набор трудных задач на многошаговое рассуждение. Числа — доля верных ответов в процентах. Разница в 7 пунктов MMLU между MQA и MHA — это огромно. Нужна золотая середина.
GQA: золотая середина
Заголовок раздела «GQA: золотая середина»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) делит голов запросов на групп. Внутри группы головы делят
одну пару K/V. Голова запроса использует KV-голову номер (скобки —
округление вниз, // в Python). Пример: , — головы запросов 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
Два крайних случая — старые знакомые: это MHA, это MQA. Кэш и чтение памяти уменьшаются в раз. В статье GQA с 8 группами почти догнала MHA по качеству и почти догнала MQA по скорости.
Ещё одна практическая причина выбрать GQA — тензорный параллелизм (урок 5.4.3): большую модель режут по головам между, например, 8 GPU. Единственную KV-голову MQA приходится копировать на каждую GPU, и экономия частично теряется. В GQA с 8 KV-головами каждая GPU хранит ровно свою. Неудивительно, что стал стандартом: так устроены все размеры Llama 3 и все плотные модели Qwen3.
Как получить GQA из готовой модели
Заголовок раздела «Как получить GQA из готовой модели»Обучать модель с нуля ради другого внимания дорого. Авторы GQA предложили uptraining:
- Взять обученный MHA-чекпоинт.
- В каждой группе усреднить проекции и входящих в неё голов (mean-pooling). Это сработало лучше, чем взять одну голову группы или инициализировать заново.
- Дообучить модель примерно на 5% исходного вычислительного бюджета.
Тот же приём «сжать готовую модель и чуть дообучить» вы встретите в следующем уроке: так переводят GQA-модели на MLA.
Как это выглядит в коде
Заголовок раздела «Как это выглядит в коде»Внутри GQA — обычное многоголовое внимание из урока 4.4.3.
Отличие одно: проекции K и V выдают голов, а перед умножением их «размножают» до .
В коде Llama, Qwen и MiniMind это функция repeat_kv, эквивалент torch.repeat_interleave по оси голов.
Важная деталь: копия нужна только для удобства вычислений. В кэше лежат голов, а эффективные ядра вроде
FlashAttention (они считают внимание блоками, не собирая всю матрицу оценок в памяти) вообще не копируют данные:
несколько голов запросов просто читают одну и ту же KV-голову.
В PyTorch 2.5+ F.scaled_dot_product_attention(..., enable_gqa=True) принимает K и V с меньшим числом голов напрямую.
Практика
Заголовок раздела «Практика»Задание в браузере
Заголовок раздела «Задание в браузере»Реализуйте repeat_kv, причинное GQA и функцию размера KV-кэша. Тесты проверят крайние случаи MHA и MQA,
правильное сопоставление голов, причинность и цифры для Llama 3.
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 ГиБ
То же на PyTorch (запустите локально)
Заголовок раздела «То же на PyTorch (запустите локально)»Модуль GQA, сравнение числа параметров MHA/GQA/MQA при размерах Llama 3 8B, проверка встроенного enable_gqa
и конвертация MHA → GQA по рецепту статьи.
import torchimport torch.nn as nnimport 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.shapeq = 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-голов: 32GQA-8 41.9 M параметров, KV-голов: 8MQA 34.6 M параметров, KV-голов: 1enable_gqa совпадает: Trueотносительная ошибка после mean-pool: 0.87 — дальше нужно дообучениеОбратите внимание: GQA экономит и параметры (−38% в слое внимания), но главное — кэш, который меньше в 4 раза. Большая ошибка после mean-pool на случайных весах ожидаема: случайные головы ничем не похожи друг на друга. Именно поэтому после усреднения модель дообучают.
Проверь себя
Заголовок раздела «Проверь себя»- KV-кэш байт. На длинном контексте он сравним с весами модели или больше их.
- Декодирование ограничено памятью: время внимания на каждом шаге пропорционально объёму кэша, который нужно прочитать.
- MQA () сжимает кэш в раз, но заметно теряет в качестве. GQA () — стандартный компромисс, обычно или меньше.
- MHA-модель можно перевести в GQA усреднением голов и коротким дообучением.
Дальше: DeepSeek нашла способ сделать кэш меньше, чем у GQA, и при этом не потерять качество MHA. Это Multi-head Latent Attention — следующий урок.