Перейти к содержимому
Ступень 44.4 Механизм внимания

Многоголовое внимание

Зачем трансформеру несколько голов внимания, как разрезать тензор на головы через reshape и transpose, что делает выходная проекция, во что обходится O(n²) и как отсюда вырастают MQA, GQA и MLA.

УРОКstage-4/4-4-attention/03-multi-head.mdx1 ч 50 минУрок 3 из 3
После урока вы сможете
  • Объяснить, зачем нужны несколько голов и почему многоголовое внимание стоит столько же параметров, сколько одна большая голова
  • Разрезать тензор на головы через reshape и transpose, не перемешав токены, и собрать обратно
  • Посчитать FLOPs и память внимания для реальной модели и найти длину контекста, где квадратичная часть начинает доминировать
  • Объяснить, как дорогой KV-кэш многоголового внимания привёл к MQA, GQA и MLA

В прошлом уроке каждый токен получал одно распределение внимания: одна строка softmax, одно взвешенное среднее значений. Это как задать соседям по предложению ровно один вопрос. Но токену обычно нужно узнать несколько вещей сразу.

Возьмём фразу «Сестра вчера подарила мне рыжую кошку, и теперь она спит» и токен «спит». Ему полезно знать:

  • кто спит — нужно посмотреть на «она» (а через неё — на «кошку»);
  • когда — посмотреть на «теперь»;
  • что было прямо перед ним — соседний токен, это важно для грамматики.

Одна голова может разделить вес, скажем, 50 на 50 между «она» и «теперь». Но тогда на выходе будет смесь двух векторов значений, по половине каждого, и оба сигнала размоются. Нужны несколько независимых «взглядов», каждый со своим softmax. Это и есть многоголовое вниманиемногоголовое вниманиеEN multi-head attention (MHA) (multi-head attention, MHA): HH голов, каждая задаёт свой вопрос, а результаты потом собираются вместе.

После урока вы реализуете MHA на NumPy без единого цикла по головам, посчитаете, во что обходится внимание на длинном контексте, и увидите, откуда растут MQA, GQA и MLA из модуля 5.2.

Идея: H голов в маленьких подпространствах

Заголовок раздела «Идея: H голов в маленьких подпространствах»

Каждая голова вниманияголова вниманияEN attention headОдна независимая копия внимания со своими проекциями размера d_h = d_model / H. Головы работают параллельно и смотрят на разные отношения между токенами. — это полноценное scaled dot-product attention со своими матрицами, только уже: размер головы dh=dmodel/Hd_h = d_{\text{model}} / H.

headh=Attention(XWQ(h), XWK(h), XWV(h)),h=1,…,H\mathrm{head}_h = \mathrm{Attention}\big(XW_Q^{(h)},\ XW_K^{(h)},\ XW_V^{(h)}\big), \qquad h = 1, \dots, H MultiHead(X)=Concat(head1,…,headH) WO\mathrm{MultiHead}(X) = \mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_H)\, W_O

Что здесь написано: HH раз считаем обычное внимание, каждый раз со своими WQ(h),WK(h),WV(h)W_Q^{(h)}, W_K^{(h)}, W_V^{(h)} размера dmodel×dhd_{\text{model}} \times d_h. Выходы голов (по dhd_h чисел на токен) ставим рядом, получаем снова H⋅dh=dmodelH \cdot d_h = d_{\text{model}} чисел, и умножаем на выходную проекциювыходная проекция (W_O)EN output projectionМатрица, которая смешивает склеенные выходы голов и возвращает вектор размера d_model. Эквивалентно сумме вкладов голов, у каждой — свой блок W_O. WOW_O.

В оригинальном трансформере dmodel=512d_{\text{model}} = 512, H=8H = 8, значит dh=64d_h = 64.

Неожиданно: столько же, сколько одна большая голова. Все WQ(h)W_Q^{(h)} вместе — это HH матриц dmodel×dhd_{\text{model}} \times d_h, то есть ровно одна матрица dmodel×dmodeld_{\text{model}} \times d_{\text{model}}. То же для WKW_K и WVW_V, плюс WOW_O. Итого в слое внимания без смещений:

4 dmodel2 параметров, независимо от H4\, d_{\text{model}}^2 \ \text{параметров, независимо от } H

Для оригинального трансформера это 4⋅5122=1 048 5764 \cdot 512^2 = 1\,048\,576, для GPT-2 small 4⋅7682=2 359 2964 \cdot 768^2 = 2\,359\,296 (плюс 3072 смещения, итого 2 362 368, ровно как в чекпоинтечекпоинт (контрольная точка)EN checkpointСохранённое состояние обучения: веса модели, состояние оптимизатора, номер шага. Позволяет продолжить обучение после сбоя. GPT-2 — файле с сохранёнными весами). Число операций на оценки тоже не растёт: HH голов по T2dhT^2 d_h — это те же T2dmodelT^2 d_{\text{model}}. Мы не покупаем головы, а делим ширину между ними: вместо одного наблюдателя с 512-мерным взглядом — восемь наблюдателей с 64-мерными.

Модельdmodeld_{\text{model}}Голов HHdhd_hОсобенность
Transformer base (2017)512864исходная статья
GPT-2 small7681264
GPT-3 175B12 28896128
Llama 3 8B409632128GQA: 8 KV-голов
Qwen2.5-7B358428128GQA: 4 KV-головы
DeepSeek-V37168128128 (+64 под RoPE)MLA; H⋅dh=16 384>dmodelH \cdot d_h = 16\,384 > d_{\text{model}}
Kimi K2716864128 (+64 под RoPE)MLA, как у DeepSeek-V3, но голов вдвое меньше

Две вещи видно сразу. Размер головы у современных моделей чаще всего 128, а растёт с размером модели в основном число голов. И H⋅dhH \cdot d_h не обязано равняться dmodeld_{\text{model}}: у DeepSeek-V3 головы вместе шире модели, и WOW_O просто переводит 16 384→716816\,384 \to 7168.

Хранить HH маленьких матриц и считать головы в цикле медленно. На практике делают одну большую проекцию Q=XWQQ = XW_Q формы (B,T,D)(B, T, D) и договариваются, что столбцы 0…dh−10 \dots d_h - 1 принадлежат голове 0, следующие dhd_h — голове 1 и так далее. Потом тензор перекладывают так, чтобы головы стали отдельной осью, как батч:

ШагОперацияФорма
1Q=XWQQ = XW_Q (то же для KK, VV)(B,T,D)(B, T, D)
2reshape(B, T, H, d_h) — режем последнюю ось на HH кусков(B,T,H,dh)(B, T, H, d_h)
3transpose(0, 2, 1, 3) — головы вперёд(B,H,T,dh)(B, H, T, d_h)
4внимание во всех головах сразу: оценки(B,H,T,T)(B, H, T, T)
5веса ×V\times V(B,H,T,dh)(B, H, T, d_h)
6transpose(0, 2, 1, 3) и reshape(B, T, D) — склейка голов(B,T,D)(B, T, D)
7×WO\times W_O(B,T,D)(B, T, D)

Шаг 4 работает без изменений кода из прошлого урока: q @ np.swapaxes(k, -1, -2) умножает две последние оси, а оси BB и HH транслируются, как батч. Вся многоголовость — это перекладывание осей.

Почему нельзя сделать reshape(B, H, T, d_h) за один шаг? reshape не двигает числа в памяти, а просто заново нарезает их подряд. Посмотрим на маленьком примере: 2 токена, D=6D = 6, 3 головы по dh=2d_h = 2.

import numpy as np
B, T, D, H = 1, 2, 6, 3 # 2 токена, 6 признаков, 3 головы по d_h = 2
d_h = D // H
x = np.arange(B * T * D).reshape(B, T, D)
print(x[0]) # токен 0: 0..5, токен 1: 6..11
right = x.reshape(B, T, H, d_h).transpose(0, 2, 1, 3) # (B, H, T, d_h)
wrong = x.reshape(B, H, T, d_h) # та же форма, но...
for h in range(H):
print(f"голова {h}: верно {right[0, h].tolist()} неверно {wrong[0, h].tolist()}")
back = right.transpose(0, 2, 1, 3).reshape(B, T, D) # склеиваем головы обратно
print("обратно совпало:", np.array_equal(back, x))
[[ 0 1 2 3 4 5]
[ 6 7 8 9 10 11]]
голова 0: верно [[0, 1], [6, 7]] неверно [[0, 1], [2, 3]]
голова 1: верно [[2, 3], [8, 9]] неверно [[4, 5], [6, 7]]
голова 2: верно [[4, 5], [10, 11]] неверно [[8, 9], [10, 11]]
обратно совпало: True

В правильном варианте голова 0 видит первые два признака каждого токена. В неправильном её «токен 1» — это кусок того же токена 0. Форма верная, код не падает, модель учится, но хуже, а загруженный чужой чекпоинт выдаёт мусор. Если же ошибиться только в одной из двух функций (разрезать неверно, а склеить верно, или наоборот), в причинной модели будущее начнёт просачиваться в прошлое. Первый тест задания ниже ловит именно этот баг, а тест на причинность — его вариант с одной ошибкой.

Того же результата можно добиться через einsumeinsum (нотация Эйнштейна)EN Einstein summation (einsum)Запись тензорных операций строкой индексов, например 'bhqd,bhkd->bhqk'., например оценки всех голов: np.einsum('bhtd,bhsd->bhts', q, k). Это удобно читать, но разрезать на головы всё равно придётся.

После склейки вектор токена — это [head1∣head2∣⋯∣headH][\mathrm{head}_1 \mid \mathrm{head}_2 \mid \dots \mid \mathrm{head}_H]: результаты голов лежат в отдельных «ячейках» и ещё никак не взаимодействовали. WOW_O их смешивает и возвращает вектор размера dmodeld_{\text{model}}, который прибавляется к остаточному потоку (урок 3.4.2).

Есть полезный второй взгляд. Разрежем WOW_O по строкам на HH блоков WO(h)W_O^{(h)} размера dh×dmodeld_h \times d_{\text{model}}. Тогда по правилу умножения блочных матриц:

Concat(head1,…,headH) WO=∑h=1Hheadh WO(h)\mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_H)\, W_O = \sum_{h=1}^{H} \mathrm{head}_h\, W_O^{(h)}

Что здесь написано: выход MHA — сумма независимых вкладов голов. Каждая голова сама решает, что прочитать (через QQ, KK), что взять (VV) и что записать в остаточный поток (WO(h)W_O^{(h)}). Проверим численно:

import numpy as np
rng = np.random.default_rng(0)
T, D, H = 5, 8, 4
d_h = D // H
heads = rng.normal(size=(H, T, d_h)) # выходы 4 голов (как будто уже посчитаны)
W_o = rng.normal(size=(D, D))
concat = heads.transpose(1, 0, 2).reshape(T, D) # (T, H·d_h): головы рядом
y1 = concat @ W_o # склеить и умножить на W_O
y2 = sum(heads[h] @ W_o[h * d_h:(h + 1) * d_h] for h in range(H)) # сумма вкладов голов
print("Concat·W_O == сумма вкладов голов:", np.allclose(y1, y2))
Concat·W_O == сумма вкладов голов: True

Этот взгляд понадобится дважды. В интерпретируемости (разделе науки о том, как модель устроена изнутри) вклад каждой головы изучают отдельно (см. индукционные головы ниже). А в MLA от DeepSeek произведение WV(h)WO(h)W_V^{(h)} W_O^{(h)} каждой головы перемножают заранее: так называемое поглощение матриц.

Никто не говорит головам, на что смотреть, но после обучения они специализируются:

  • BERT (Clark и соавт., 2019): есть головы, которые смотрят на следующий или предыдущий токен, на разделители и точки. Отдельные головы с высокой точностью находят прямое дополнение глагола, определители существительных, объекты предлогов и кореферентные упоминания («она» → «кошка»).
  • Перевод с английского на русский (Елена Войта и соавт., 2019): важные головы делятся на позиционные (смотрят на соседа), синтаксические (конкретные грамматические связи) и головы редких слов. Остальные почти не нужны: если удалить 38 из 48 голов энкодера, качество падает всего на 0.15 BLEU.
  • «Are Sixteen Heads Really Better than One?» (Michel и соавт., 2019): многие головы можно отключить уже после обучения без заметной потери качества, а некоторые слои можно сократить до одной головы.

Самый известный пример — индукционные головыиндукционная головаEN induction headПара голов, которая находит в контексте прошлое появление текущего токена и копирует токен, шедший после него ([A][B] … [A] → [B]). Механизм in-context learning (Olsson и др., 2022). (Olsson и соавт., Anthropic, 2022). Это пара голов в разных слоях. Первая записывает в каждый токен информацию о его предшественнике. Вторая ищет в контексте прошлое появление текущего токена и копирует то, что шло после него: [A][B]…[A]→[B][A][B] \dots [A] \to [B]. Если в тексте было «Пушкин родился в Москве», а потом снова встретилось «Пушкин», индукционная голова подскажет «родился». Это один из механизмов обучения в контекстеобучение в контекстеEN in-context learning (ICL)Модель решает новую задачу по примерам в промпте, без изменения весов. Ярко показано в статье о GPT-3 (2020).. Одной головой такой трюк не сделать: вторая голова ищет по информации, которую первая записала слоем ниже. Хороший пример того, зачем трансформеру и много голов, и много слоёв. Как найти такую пару голов по паттернам внимания и доказать её роль выключением, разберём в уроке 9.3.1.

Вывод для инженера: головы специализируются, но и дублируют друг друга. Эта избыточность — ключ к экономии, до которой мы дойдём в конце урока.

Посчитаем операции одного слоя внимания на последовательность длины TT при ширине DD. Операции считают во FLOPsFLOPs, число операций с плавающей точкойEN floating-point operations (FLOPs)Не путайте FLOPs (сколько операций нужно сделать) и FLOP/s, FLOPS (сколько операций железо делает в секунду). — числе арифметических действий над дробными числами; 1 TFLOP (терафлоп) = 101210^{12} таких действий. Умножение матриц (m×k)(k×n)(m \times k)(k \times n) стоит 2mkn2mkn FLOPs: в результате m⋅nm \cdot n чисел, каждое — скалярное произведение длины kk, то есть kk умножений и примерно kk сложений. Пример: (2×3)(3×4)(2 \times 3)(3 \times 4) — это 2⋅2⋅3⋅4=482 \cdot 2 \cdot 3 \cdot 4 = 48 FLOPs.

  • проекции WQ,WK,WV,WOW_Q, W_K, W_V, W_O: четыре умножения (T×D)(D×D)(T \times D)(D \times D) — это 8TD28TD^2;
  • оценки QK⊤QK^\top во всех головах: 2T2D2T^2D;
  • взвешивание AVAV: ещё 2T2D2T^2D.
FLOPs≈8TD2+4T2D\text{FLOPs} \approx 8TD^2 + 4T^2D

Первое слагаемое растёт линейно по длине, второе — квадратично. Они равны при T=2DT = 2D. Возьмём слой шириной D=4096D = 4096, как у Llama 3 8B. Считаем его как обычное MHA; у настоящей Llama из-за GQA (о ней ниже) проекции KK и VV вчетверо дешевле, и квадратичная часть начинает доминировать ещё раньше.

Длина TTПроекции, TFLOPОценки и AVAV, TFLOPДоля квадратичной части
10240.140.0211%
81921.101.1050%
32 7684.4017.680%
131 07217.628194%

Это квадратичная сложностьквадратичная сложность вниманияEN quadratic complexity (O(n²))Каждый из n токенов сравнивается с каждым — n² оценок на голову. При длинном контексте это главная стоимость внимания; отсюда FlashAttention, разреженное и линейное внимание.: на коротких текстах внимание дешёвое, на длинных съедает всё. (Причинная маска позволяет пропустить почти половину оценок, а полносвязные слои мы здесь не считали, но общая картина та же.)

Наивная реализация хранит матрицу весов (B,H,T,T)(B, H, T, T) целиком. Для Llama 3 8B (H=32H = 32, BF16BF16 (bfloat16)EN bfloat16 (brain floating point)16-битный формат: 1 бит знака, 8 бит порядка (как у FP32), 7 бит мантиссы. Диапазон как у FP32, точность — 2–3 значащие цифры. Стандарт обучения LLM. — формат дробного числа в 2 байта, подробно в уроке 5.4.1). Единицы: 1 КиБ = 2102^{10} байт, 1 ГиБ = 2302^{30} байт ≈ 1.07 млрд байт, 1 ТиБ = 2402^{40} байт («двоичные» кило-, гига- и терабайты):

32⋅81922⋅2 байт=4 ГиБ на слой,32⋅131 0722⋅2 байт=1 ТиБ на слой\begin{gathered} 32 \cdot 8192^2 \cdot 2 \text{ байт} = 4 \text{ ГиБ на слой}, \\ 32 \cdot 131\,072^2 \cdot 2 \text{ байт} = 1 \text{ ТиБ на слой} \end{gathered}

Терабайт на один слой одного запроса — так считать нельзя. Поэтому все современные системы используют FlashAttention (Tri Dao и соавт., 2022, arXiv:2205.14135). Он считает внимание блоками: берёт кусок запросов и кусок ключей, обновляет «бегущие» максимум и сумму экспонент для softmax и никогда не держит матрицу T×TT \times T в видеопамяти. Результат совпадает с формулой точно (до округления), память — O(T)O(T), а работает быстрее, потому что гораздо реже читает видеопамять. FLOPs при этом остаются O(T2)O(T^2). F.scaled_dot_product_attention в PyTorch использует такие ядра автоматически. Как именно он устроен, вы разберёте и напишете сами в уроке 5.7.2.

Чтобы уйти от O(T2)O(T^2) в вычислениях, нужны другие идеи: разреженное внимание (NSA и DSA от DeepSeek) или линейное (Lightning Attention в MiniMax-01, Gated DeltaNet в Qwen3-Next, KDA в Kimi Linear). Это модуль 5.5. А при генерации, где каждый новый токен смотрит на все прошлые, спасает KV-кэш (урок 4.6.4).

При генерации модель хранит ключи и значения всех прошлых токенов, чтобы не пересчитывать их заново для каждого нового токена. Это KV-кэшKV-кэшEN KV cache. В MHA у каждой головы свои KK и VV, поэтому на токен на слой нужно 2⋅H⋅dh2 \cdot H \cdot d_h чисел. Для Llama 3 8B в MHA-варианте это 2⋅32⋅128=81922 \cdot 32 \cdot 128 = 8192 числа на слой, а на все 32 слоя в BF16 — 8192⋅32⋅28192 \cdot 32 \cdot 2 байт = 512 КиБ на токен. На контексте 32K это 16 ГиБ на один запрос, примерно столько же, сколько весят все веса модели.

Головы во многом дублируют друг друга, так что можно поделиться ключами и значениями:

flowchart LR
A["MHA: у каждой головы свои K и V"] --> B["MQA: одна пара K/V на все головы (кэш ÷ H)"]
A --> C["GQA: одна пара K/V на группу голов (Llama 3: 32 → 8)"]
A --> D["MLA: K и V всех голов сжаты в один латентный вектор (DeepSeek-V2)"]
Три способа сократить KV-кэш многоголового внимания
  • MQAмногозапросное внимание (MQA)EN multi-query attention (MQA)Все головы запросов делят одну голову ключей и одну голову значений. KV-кэш меньше в H раз, но качество заметно падает (Shazeer, 2019). (Shazeer, 2019): все головы запросов используют одну голову ключей и значений. Кэш меньше в HH раз, но качество может заметно просесть.
  • GQAвнимание с группами запросов (GQA)EN grouped-query attention (GQA)Головы запросов делятся на G групп, у каждой группы своя пара K/V. G = H — это MHA, G = 1 — MQA. Стандарт Llama 3 и Qwen (Ainslie и др., 2023). (Ainslie и соавт., 2023): головы делятся на GG групп, у группы — своя пара K/V. Llama 3 8B: 32 головы запросов, 8 KV-голов, кэш 128 КиБ на токен вместо 512.
  • MLAмногоголовое латентное внимание (MLA)EN multi-head latent attention (MLA)Предложено DeepSeek в DeepSeek-V2 (2024). (DeepSeek-V2, 2024): ключи и значения всех голов восстанавливаются из одного латентного вектора, и кэшируется только он: 512 + 64 числа на токен на слой у DeepSeek-V3. Для MHA той же ширины (128 голов по 128) было бы 2⋅128⋅128=32 7682 \cdot 128 \cdot 128 = 32\,768, почти в 57 раз больше.

Подробно, с кодом и честными таблицами качества — в модуле 5.2: MQA и GQA и MLA. Внутри всех трёх — то самое многоголовое внимание, которое вы сейчас напишете.

Реализуйте split_heads, merge_heads, multi_head_attention и score_matrix_bytes. Функция attention из прошлого урока дана готовой. Тесты проверят раскладку голов, обратимость склейки, совпадение с эталоном-циклом по головам, причинность и размер матрицы внимания для Llama 3.

Многоголовое внимание на NumPynumpy · нужен интернет8 тестов
import numpy as np


# ── Готовые функции из прошлого урока (их писать не нужно) ──────────────────────
def softmax(x):
    x = x - x.max(axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)


def attention(q, k, v, causal=True):
    """Scaled dot-product attention для массивов (..., T, d). Возвращает (out, weights)."""
    T, d = q.shape[-2], q.shape[-1]
    scores = q @ np.swapaxes(k, -1, -2) / np.sqrt(d)          # (..., T, T)
    if causal:
        scores = np.where(np.tril(np.ones((T, T), dtype=bool)), scores, -np.inf)
    w = softmax(scores)
    return w @ v, w


# ── Ваша часть ─────────────────────────────────────────────────────────────────
def split_heads(x, n_heads):
    """(B, T, D) -> (B, H, T, D // H).

    Голова h получает столбцы h*d_h : (h+1)*d_h каждого токена, где d_h = D // H.
    Сначала reshape в (B, T, H, d_h), потом поменять местами оси T и H.
    """
    # TODO
    ...


def merge_heads(x):
    """(B, H, T, d_h) -> (B, T, H * d_h) — обратная операция к split_heads."""
    # TODO: сначала вернуть оси в порядок (B, T, H, d_h), потом reshape
    ...


def multi_head_attention(X, W_q, W_k, W_v, W_o, n_heads, causal=True):
    """Многоголовое self-attention.

    X: (B, T, D); W_q, W_k, W_v, W_o: (D, D); D делится на n_heads.
    Возвращает (out, weights): out — (B, T, D), weights — (B, H, T, T).
    Шаги: проекции -> split_heads -> attention во всех головах сразу -> merge_heads -> @ W_o.
    """
    # TODO
    ...


def score_matrix_bytes(batch, n_heads, seq_len, bytes_per_elem=2):
    """Сколько байт занимает матрица весов внимания (B, H, T, T) одного слоя, если её хранить целиком."""
    # TODO
    ...


# Проверьте себя кнопкой «Запустить»:
x = np.arange(2 * 3 * 4).reshape(1, 2, 12)          # B=1, T=2, D=12
s = split_heads(x, 3)
print(None if s is None else s.shape)                # ожидается (1, 3, 2, 4)
print(None if s is None else s[0, 1])                # голова 1: [[4 5 6 7] [16 17 18 19]]
rng = np.random.default_rng(0)
X = rng.normal(size=(2, 5, 8))
W = [rng.normal(size=(8, 8)) / np.sqrt(8) for _ in range(4)]
res = multi_head_attention(X, *W, n_heads=2)
if res is not None:
    print(res[0].shape, res[1].shape)                # ожидается (2, 5, 8) (2, 2, 5, 5)
b = score_matrix_bytes(1, 32, 8192)
print(b if b is None else b / 2**30, "ГиБ")          # Llama 3 8B, 8K токенов: 4.0 ГиБ на слой

Модуль MHA в стиле nanoGPT и проверка на встроенном nn.MultiheadAttention: копируем в него наши веса и сравниваем выходы.

import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
class MultiHeadAttention(nn.Module):
"""Причинное многоголовое self-attention в стиле GPT-2 / nanoGPT."""
def __init__(self, d_model, n_heads):
super().__init__()
assert d_model % n_heads == 0, "d_model должен делиться на n_heads"
self.h, self.dh = n_heads, d_model // n_heads
# одна большая матрица D×D на все головы сразу; bias=False — без смещения, как в формулах урока
self.wq = nn.Linear(d_model, d_model, bias=False)
self.wk = nn.Linear(d_model, d_model, bias=False)
self.wv = nn.Linear(d_model, d_model, bias=False)
self.wo = nn.Linear(d_model, d_model, bias=False) # выходная проекция W_O
def forward(self, x):
B, T, D = x.shape
# (B, T, D) -> (B, T, H, d_h) -> (B, H, T, d_h)
q = self.wq(x).view(B, T, self.h, self.dh).transpose(1, 2)
k = self.wk(x).view(B, T, self.h, self.dh).transpose(1, 2)
v = self.wv(x).view(B, T, self.h, self.dh).transpose(1, 2)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True) # (B, H, T, d_h)
y = y.transpose(1, 2).reshape(B, T, D) # склеиваем головы
return self.wo(y)
d_model, n_heads = 64, 8
ours = MultiHeadAttention(d_model, n_heads)
print("параметров:", sum(p.numel() for p in ours.parameters()), "= 4·d² =", 4 * d_model**2)
# Сверяемся со встроенным nn.MultiheadAttention: копируем в него те же веса
ref = nn.MultiheadAttention(d_model, n_heads, bias=False, batch_first=True) # batch_first: вход (B, T, D)
with torch.no_grad(): # копирование весов — не часть обучения, градиенты не нужны
# встроенный модуль хранит W_Q, W_K, W_V одной матрицей, сложенной «стопкой» по строкам
ref.in_proj_weight.copy_(torch.cat([ours.wq.weight, ours.wk.weight, ours.wv.weight]))
ref.out_proj.weight.copy_(ours.wo.weight)
x = torch.randn(2, 10, d_model)
future = torch.ones(10, 10, dtype=torch.bool).triu(1) # True — запрещено (так принято в nn.MultiheadAttention)
y_ref, _ = ref(x, x, x, attn_mask=future, need_weights=False)
print("совпадает с nn.MultiheadAttention:", torch.allclose(ours(x), y_ref, atol=1e-5))

Ожидаемый вывод:

параметров: 16384 = 4·d² = 16384
совпадает с nn.MultiheadAttention: True

Обратите внимание на маску: у nn.MultiheadAttention булево True значит «запрещено», поэтому здесь triu(1), а не tril(). Об этой разнице в соглашениях мы предупреждали в прошлом уроке.

Проверь себя6 вопросов
1 Сколько параметров в слое многоголового внимания без смещений при dmodel=768d_{\text{model}} = 768 и 12 головах?
2 Как правильно превратить x формы (B,T,D)(B, T, D) в головы формы (B,H,T,dh)(B, H, T, d_h)?
3 Сколько ГиБ занимает матрица весов внимания одного слоя при H=32H = 32, T=8192T = 8192, BF16 (2 байта), батч 1?
4 FLOPs слоя внимания ≈ 8TD2+4T2D8TD^2 + 4T^2D. При какой длине TT квадратичная часть сравняется с проекциями, если D=4096D = 4096?
5 Какие утверждения верны?Можно выбрать несколько ответов
6 Как называется пара голов, которая продолжает повторяющийся в контексте паттерн [A][B]…[A]→[B][A][B] \dots [A] \to [B]? (англ. или рус. термин)
  • Одна голова — один softmax, один «вопрос» на токен. HH голов смотрят на разные отношения одновременно, каждая в своём подпространстве размера dh=dmodel/Hd_h = d_{\text{model}} / H.
  • Параметров столько же, сколько у одной большой головы: 4dmodel24d_{\text{model}}^2. Головы не покупают, а делят ширину.
  • Реализация — перекладывание осей: reshape(B, T, H, d_h) → transpose → внимание → обратно → WOW_O. Выход MHA — сумма вкладов голов.
  • Стоимость слоя ≈8TD2+4T2D\approx 8TD^2 + 4T^2D: после T=2DT = 2D доминирует квадратичная часть. FlashAttention убирает O(T2)O(T^2) по памяти, но не по FLOPs.
  • Свои K и V у каждой головы делают KV-кэш дорогим. Отсюда MQA, GQA и MLA (модуль 5.2), а число голов — инженерный компромисс (Kimi K2: 64 вместо 128).

Дальше: внимание — только половина блока трансформера. Добавим полносвязный слой, остаточные связи и нормализацию — Блок трансформера. Домашняя работа по вниманию (s4-hw02-attention) ждёт вас в конце модуля 4.6, когда из этих деталей соберётся целая GPT.