FlashAttention: внимание без матрицы n×n
Как online softmax и блоки в SRAM позволяют посчитать точное внимание, ни разу не записав матрицу n×n в видеопамять, почему в обратном проходе выгоднее пересчитать, чем хранить, и что изменили FlashAttention-2 и -3.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В уроке 4.4.3 вы посчитали: у Llama 3 8B на контексте 8192 матрица весов
внимания занимает 4 ГиБ на слой, а на 131 072 токенах — терабайт. Там же было сказано, что все современные системы
используют FlashAttentionFlashAttentionEN FlashAttentionТочный алгоритм внимания (Tri Dao и соавт., 2022), который не хранит матрицу n×n в видеопамяти: Q, K, V обрабатываются блоками в SRAM с online softmax, а в обратном проходе блоки пересчитываются. Память — O(n) вместо O(n²). Версии 2 и 3 улучшают параллелизм и используют возможности Hopper. и что F.scaled_dot_product_attention включает его сам. Теперь, когда вы знаете
устройство GPU, можно понять, как он работает.
Идея умещается в одну фразу: посчитать внимание блоками, которые помещаются в быструю SRAMshared memory (SRAM)EN shared memoryБыстрая память внутри SM, общая для потоков одного блока; физически та же SRAM, что и кэш L1. На Ada — до 100 КБ на SM. В статьях о FlashAttention её называют просто SRAM., и ни разу не записать матрицу в видеопамять. Трудность — в softmax: чтобы нормировать строку, нужно знать её максимум и сумму экспонент по всей строке, а блок видит только кусок. Решение называется online softmax. Разберём его, потом соберём из него весь алгоритм и напишем его на NumPy.
Где теряется время у обычного внимания
Заголовок раздела «Где теряется время у обычного внимания»Обычное внимание для одной головы — четыре шага, и каждый ходит в видеопамять:
- — матмул, запись матрицы .
- Маска и softmax: чтение , запись (тоже ).
- — чтение .
- Для обратного прохода нужно хранить до самого конца шага.
Посчитаем для , в BF16. Одна матрица — байт = 32 МиБ. Четыре прохода по таким матрицам — 134 МБ, а сами — всего 2 МБ. Операций GFLOP (два матмула по ). Интенсивность:
— в четыре раза ниже точки перегиба RTX 4080 (136, урок 5.7.1). Внимание упирается в память: по roofline 0.19 мс на память против 0.044 мс на вычисления. Если бы и не покидали SRAM, по видеопамяти шли бы только , и интенсивность выросла бы до — далеко в зоне вычислений.
Вторая беда — память для обратного прохода. Модель уровня GPT-2 small (12 голов) с батчем 8 на контексте 4096 хранит размером байт = 3.2 ГБ на каждый слой, 38.7 ГБ на 12 слоёв. В 32 ГБ вашей карты это не помещается, хотя сама модель весит меньше гигабайта.
Online softmax: нормировка за один проход
Заголовок раздела «Online softmax: нормировка за один проход»Обычный устойчивый softmax (урок 1.3.2) проходит по строке три раза: найти максимум , посчитать сумму , поделить. online softmaxonline softmaxEN online softmaxSoftmax за один проход по данным с бегущими максимумом m и суммой ℓ: при новом максимуме старая сумма умножается на e^(m_старый − m_новый). Milakov и Gimelshein (NVIDIA, 2018); основа FlashAttention. (Milakov и Gimelshein, NVIDIA, 2018) находит и за один проход. Пусть мы прочитали часть строки и знаем для неё и . Пришло новое число :
Читаем: «новый максимум — больший из старого и нового числа; старую сумму пересчитываем к новому максимуму, умножив на , и добавляем вклад нового числа». Почему это верно: , а нам нужно . По правилу степеней , и общий множитель выносится за сумму. Если максимум не изменился, множитель равен 1.
Пример. Строка :
| Шаг | множитель | |||
|---|---|---|---|---|
| 1 | 1 | 1 | — | |
| 2 | 3 | 3 | ||
| 3 | 2 | 3 |
Проверка «в лоб»: . Вероятности — те же, что у логитов в уроке 1.3.2, только в другом порядке.
Главный шаг для внимания. Нам нужны не сами вероятности, а взвешенная сумма . Её тоже можно копить на лету: держим ненормированный накопитель и пересчитываем его тем же множителем:
Для и значений : после трёх шагов , , . Точный ответ . Ни одной вероятности мы не хранили — только три числа , , .
Прямой проход FlashAttention
Заголовок раздела «Прямой проход FlashAttention»Теперь вместо чисел — блоки. Разрежем на блоки по строк, а и — на блоки по строк (это тайлингтайлинг (разбиение на блоки)EN tilingПриём быстрых ядер: данные режутся на тайлы, которые помещаются в быструю память, и каждый тайл используется многократно. Тайловый матмул с тайлом t читает из медленной памяти в t раз меньше, чем наивный. из прошлого урока). Для блока запросов заводим в SRAM векторы и (по числу на строку) и накопитель размером . Затем идём по блокам ключей:
После последнего блока — один раз записываем в видеопамять. Это ровно формулы online softmax, только стал строкой блока , а — блоком .
flowchart LR subgraph HBM["Видеопамять"] Q["Q: T×d"] KV["K, V: T×d"] O["O: T×d<br/>L: T чисел"] end subgraph SRAM["SRAM одного SM"] QI["блок Q_i"] KJ["блок K_j, V_j"] ST["S, P̃: B_r×B_c"] ACC["m, ℓ, A"] end Q -->|"один раз"| QI KV -->|"по очереди j = 1, 2, …"| KJ QI --> ST KJ --> ST ST --> ACC ACC -->|"в конце: A / ℓ"| O
Сколько это занимает в SRAM. При и в BF16: блоки , , — по 8 КиБ, блок во FP32 — 16 КиБ, накопитель во FP32 — 16 КиБ. Итого около 56 КиБ — помещается в 100 КБ shared memory SM у Ada. (В реальных ядрах и живут прямо в регистрах.)
Параллелизм. Блоки запросов независимы друг от друга, поэтому каждый обрабатывает свой блок потоковблок потоковEN thread block (CTA)До 1024 потоков, которые выполняются на одном SM и делят его shared memory. Ядро запускается сеткой (grid) из многих блоков. на своём SM. При батче 8, 12 головах и с это блока — работы хватит всем 76 SM.
Причинная маска. Если весь блок лежит правее последней строки блока , все его оценки замаскированы, и блок можно не считать вовсе. Маска нужна только на диагональных блоках. При и блоках по 128 из пар блоков считаются — почти вдвое меньше работы.
Почему ответ точный
Заголовок раздела «Почему ответ точный»FlashAttention — не приближение вроде разреженного или линейного внимания (модуль 5.5). Это та же формула , переписанная в другом порядке. Online softmax — тождество: на каждом шаге и в точности равны суммам по уже просмотренным столбцам, приведённым к текущему максимуму. Отличия от наивной версии возникают только из-за другого порядка сложения чисел с плавающей точкой. В FP64 это , в FP32 — около , в BF16 — около . В практике ниже вы проверите это тестом.
Обратный проход: пересчитать дешевле, чем хранить
Заголовок раздела «Обратный проход: пересчитать дешевле, чем хранить»Для градиентов нужны вероятности . Обычная реализация хранит их с прямого прохода — те самые 38.7 ГБ из примера выше. FlashAttention сохраняет только выход и одно число на строку:
— logsumexp строки (урок 1.3.2). В обратном проходе блоки считаются заново, и вероятности восстанавливаются без всякой нормировки: . Дальше градиенты по формулам (их вывод — то же цепное правило, что в уроке 1.3.2 для ):
Читаем вторую строку: градиент по оценке — это вероятность, умноженная на разницу «градиент по этой вероятности минус его среднее по строке». Среднее считается без матрицы: .
Пересчёт добавляет FLOPs, но убирает чтение огромной матрицы — а внимание, как мы выяснили, упирается в память. По рис. 2 статьи FlashAttention (внимание GPT-2 medium на A100): у обычной реализации 66.6 GFLOP и 40.3 ГБ чтений и записей видеопамяти, у FlashAttention — 75.2 GFLOP и 4.4 ГБ; время — 41.7 мс против 7.3 мс. Операций больше на 13%, трафик меньше в 9 раз, время меньше в 5.7 раза.
Проверка формул обратного прохода на PyTorch (CPU, 10 секунд)
import mathimport torch
torch.manual_seed(0)T, d = 64, 16Q, K, V = (torch.randn(T, d, dtype=torch.float64, requires_grad=True) for _ in range(3))mask = torch.tril(torch.ones(T, T, dtype=torch.bool))
# эталон: autograd по обычному причинному вниманиюS = (Q @ K.T / math.sqrt(d)).masked_fill(~mask, float("-inf"))O = torch.softmax(S, dim=-1) @ VdO = torch.randn_like(O)O.backward(dO)
# «как во FlashAttention»: из прямого прохода сохранены только O и Lwith torch.no_grad(): S = (Q @ K.T / math.sqrt(d)).masked_fill(~mask, float("-inf")) L = torch.logsumexp(S, dim=-1) # одно число на строку P = torch.exp(S - L[:, None]) # пересчёт вероятностей, без нормировки D = (dO * O).sum(dim=-1) # D_i = dO_i · O_i dV = P.T @ dO dS = P * (dO @ V.T - D[:, None]) dQ, dK = dS @ K / math.sqrt(d), dS.T @ Q / math.sqrt(d)
for name, mine, ref in [("dQ", dQ, Q.grad), ("dK", dK, K.grad), ("dV", dV, V.grad)]: print(name, f"{(mine - ref).abs().max().item():.1e}")dQ 1.1e-15dK 8.9e-16dV 6.7e-16Здесь матрица строится целиком ради простоты; в настоящем ядре те же формулы применяются к блокам.
FlashAttention-2, -3 и дальше
Заголовок раздела «FlashAttention-2, -3 и дальше»FlashAttention-2 (Tri Dao, 2023) не меняет математику, только раскладку работы по железу. Ненормированный накопитель делится
на один раз в конце, а не на каждом блоке, — меньше операций вне тензорных ядер. Блоки запросов распределяются по SM
(параллелизм по длине последовательности, а не только по батчу и головам) — длинные последовательности с маленьким батчем
загружают всю карту. Внутри блока работа делится между варпами по строкам , и им не нужно обмениваться частичными суммами
через shared memory. Итог — в 1.7–3 раза быстрее первой версии. Именно FlashAttention-2 стоит внутри
F.scaled_dot_product_attention в PyTorch и работает на вашей RTX 4080.
FlashAttention-3 (2024) написан под Hopper (H100, H800): асинхронные копирования и специализация варпов (одни варпы грузят данные, другие считают), чередование матмулов и softmax, чтобы тензорные ядра не ждали экспонент, и FP8 с поблочным масштабированием. По статье — до 740 TFLOPS в FP16 (75% пика H100) и около 1.2 PFLOPS в FP8. FlashAttention-4 в том же репозитории нацелен на Hopper и Blackwell. На Ada эти версии не запускаются.
Где вы это уже видели. Блочное линейное внимание из урока 5.5.3 (Lightning Attention, ядра flash-linear-attention) — та же идея: блок в SRAM, внутри — матмулы, между блоками — бегущее состояние. NSA из урока 5.5.2 — FlashAttention, который считает только выбранные блоки. FlashMLA из урока 5.2.2 — FlashAttention для decode с MLA.
Практика
Заголовок раздела «Практика»Реализуйте online-нормировку и блочное внимание в стиле FlashAttention-2 с причинной маской и пропуском блоков из будущего. Тесты сравнят результат с наивным вниманием до на длинах, которые не делятся на размер блока, проверят logsumexp и посчитают экономию памяти.
import math
import numpy as np
# ── Готовый эталон: обычное внимание с матрицей T×T ───────────────────────────
def naive_attention(Q, K, V, causal=False):
"""softmax(Q Kᵀ / √d) V «в лоб»: строит всю матрицу оценок S размером T×T.
Q, K, V: (T, d). При causal=True позиция i видит только позиции j ≤ i.
"""
T, d = Q.shape
S = Q @ K.T / math.sqrt(d) # (T, T) — та самая матрица n×n
if causal:
S = np.where(np.tril(np.ones((T, T), dtype=bool)), S, -np.inf)
S = S - S.max(axis=1, keepdims=True) # устойчивый softmax (урок 1.3.2)
P = np.exp(S)
P = P / P.sum(axis=1, keepdims=True)
return P @ V
# ── Ваша часть ───────────────────────────────────────────────────────────────
def online_normalizer(xs):
"""Online softmax: за ОДИН проход по xs найти m = max(xs) и l = Σ exp(x - m).
xs может быть итератором, который можно пройти только один раз.
Правило обновления для нового x:
m_new = max(m, x); l = l · exp(m - m_new) + exp(x - m_new); m = m_new
Возвращает (m, l).
"""
m, l = -math.inf, 0.0
# TODO: один цикл for x in xs
...
return m, l
def flash_attention(Q, K, V, block_q=16, block_k=16, causal=False):
"""Блочное внимание в стиле FlashAttention-2 без матрицы T×T.
Внешний цикл — блоки строк Q по block_q, внутренний — блоки K, V по block_k.
Для блока строк хранятся только m (бегущий максимум), l (бегущая сумма) и acc (ненормированный выход).
При causal=True блоки K целиком «из будущего» пропускаются, а на диагонали ставится маска.
T не обязано делиться на размеры блоков.
Возвращает (O, L): O — выход (T, d), L — logsumexp каждой строки оценок (T,): L = m + log(l).
"""
T, d = Q.shape
scale = 1.0 / math.sqrt(d)
O = np.zeros((T, V.shape[1]))
L = np.zeros(T)
for qs in range(0, T, block_q):
qe = min(qs + block_q, T)
q = Q[qs:qe]
m = np.full(qe - qs, -np.inf)
l = np.zeros(qe - qs)
acc = np.zeros((qe - qs, V.shape[1]))
# TODO: цикл по блокам K/V: оценки s, маска, m_new, p, alpha, обновление l и acc
...
# TODO: нормировка O[qs:qe] и L[qs:qe]
return O, L
def count_blocks(T, block_q, block_k, causal=False):
"""Сколько пар блоков (Q-блок, K-блок) вычисляет flash_attention.
Без маски — все ceil(T/bq)·ceil(T/bk); с причинной маской — только те, где ks ≤ qe − 1."""
# TODO
...
def extra_memory(T, bytes_per_elem=2):
"""Память под внимание одной головы сверх Q, K, V и O, которую надо хранить до обратного прохода.
Обычное внимание хранит матрицу вероятностей P: T·T чисел по bytes_per_elem байт.
FlashAttention хранит только L (logsumexp строк) во FP32: T чисел по 4 байта.
Возвращает (naive_bytes, flash_bytes).
"""
# TODO
...
if __name__ == "__main__":
print(online_normalizer(iter([1.0, 3.0, 2.0]))) # ждём (3.0, 1.5032...)
rng = np.random.default_rng(0)
Q, K, V = rng.standard_normal((3, 100, 16))
O, L = flash_attention(Q, K, V, 32, 32)
print("max |flash - naive| =", np.abs(O - naive_attention(Q, K, V)).max())
После решения поэкспериментируйте: замерьте naive_attention и flash_attention на через time.perf_counter.
На NumPy блочная версия медленнее — питоновские циклы и маленькие матмулы. Выигрыш FlashAttention — от иерархии памяти GPU,
а не от алгоритма как такового. В следующем уроке вы перенесёте этот код на Triton, и тогда блоки
окажутся в настоящей SRAM.
Проверь себя
Заголовок раздела «Проверь себя»- Обычное внимание memory-bound: матрицы несколько раз ходят через видеопамять, а для обратного прохода хранится целиком — гигабайты на слой.
- Online softmax копит максимум , сумму и ненормированный выход за один проход, пересчитывая старое множителем .
- FlashAttention применяет это к блокам , , в SRAM: матрица оценок существует только кусками, память — , причинная маска позволяет пропустить почти половину блоков. Результат точный.
- В обратном проходе блоки пересчитываются из , и сохранённого logsumexp : больше FLOPs, но в разы меньше трафика. FlashAttention-2 лучше раскладывает работу по SM и варпам, FlashAttention-3 использует асинхронность и FP8 на Hopper.
Дальше: Пишем ядра на Triton — от сложения векторов до тайлового матмула и forward FlashAttention на настоящей SRAM вашей видеокарты.