Перейти к содержимому
Ступень 55.7 GPU изнутри — архитектура, ядра, несколько карт

FlashAttention: внимание без матрицы n×n

Как online softmax и блоки в SRAM позволяют посчитать точное внимание, ни разу не записав матрицу n×n в видеопамять, почему в обратном проходе выгоднее пересчитать, чем хранить, и что изменили FlashAttention-2 и -3.

УРОКstage-5/5-7-gpu-internals/02-flashattention.mdx2 чУрок 2 из 4
После урока вы сможете
  • Вывести online softmax и объяснить, как бегущие максимум и сумма заменяют два прохода по данным
  • Описать прямой проход FlashAttention по блокам Q, K и V и посчитать, сколько памяти и трафика он экономит
  • Объяснить, почему метод точный и почему пересчёт матрицы в обратном проходе быстрее хранения
  • Реализовать блочное причинное внимание на NumPy и доказать тестом совпадение с наивным

В уроке 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., и ни разу не записать матрицу T×TT \times T в видеопамять. Трудность — в softmax: чтобы нормировать строку, нужно знать её максимум и сумму экспонент по всей строке, а блок видит только кусок. Решение называется online softmax. Разберём его, потом соберём из него весь алгоритм и напишем его на NumPy.

Обычное внимание для одной головы — четыре шага, и каждый ходит в видеопамять:

  1. S=QK⊤/dS = Q K^\top / \sqrt{d} — матмул, запись матрицы T×TT \times T.
  2. Маска и softmax: чтение SS, запись PP (тоже T×TT \times T).
  3. O=PVO = P V — чтение PP.
  4. Для обратного прохода PP нужно хранить до самого конца шага.

Посчитаем для T=4096T = 4096, d=64d = 64 в BF16. Одна матрица T×TT \times T — 40962⋅24096^2 \cdot 2 байт = 32 МиБ. Четыре прохода по таким матрицам — 134 МБ, а сами Q,K,V,OQ, K, V, O — всего 2 МБ. Операций 4T2d≈4.34 T^2 d \approx 4.3 GFLOP (два матмула по 2T2d2T^2 d). Интенсивность:

I=4.3⋅109136⋅106≈32 FLOP/байтI = \frac{4.3 \cdot 10^9}{136 \cdot 10^6} \approx 32\ \text{FLOP/байт}

— в четыре раза ниже точки перегиба RTX 4080 (136, урок 5.7.1). Внимание упирается в память: по roofline 0.19 мс на память против 0.044 мс на вычисления. Если бы SS и PP не покидали SRAM, по видеопамяти шли бы только Q,K,V,OQ, K, V, O, и интенсивность выросла бы до 4T2d/(4Td⋅2)=T/2=20484T^2 d / (4 T d \cdot 2) = T/2 = 2048 — далеко в зоне вычислений.

Вторая беда — память для обратного прохода. Модель уровня GPT-2 small (12 голов) с батчем 8 на контексте 4096 хранит PP размером 8⋅12⋅40962⋅28 \cdot 12 \cdot 4096^2 \cdot 2 байт = 3.2 ГБ на каждый слой, 38.7 ГБ на 12 слоёв. В 32 ГБ вашей карты это не помещается, хотя сама модель весит меньше гигабайта.

Обычный устойчивый softmax (урок 1.3.2) проходит по строке три раза: найти максимум mm, посчитать сумму ℓ=∑jexj−m\ell = \sum_j e^{x_j - m}, поделить. online softmaxonline softmaxEN online softmaxSoftmax за один проход по данным с бегущими максимумом m и суммой ℓ: при новом максимуме старая сумма умножается на e^(m_старый − m_новый). Milakov и Gimelshein (NVIDIA, 2018); основа FlashAttention. (Milakov и Gimelshein, NVIDIA, 2018) находит mm и ℓ\ell за один проход. Пусть мы прочитали часть строки и знаем для неё mm и ℓ\ell. Пришло новое число xx:

m′=max⁡(m,x),ℓ′=ℓ⋅em−m′+ex−m′.m' = \max(m, x), \qquad \ell' = \ell \cdot e^{m - m'} + e^{x - m'}.

Читаем: «новый максимум — больший из старого и нового числа; старую сумму пересчитываем к новому максимуму, умножив на em−m′e^{m - m'}, и добавляем вклад нового числа». Почему это верно: ℓ=∑jexj−m\ell = \sum_j e^{x_j - m}, а нам нужно ∑jexj−m′\sum_j e^{x_j - m'}. По правилу степеней exj−m′=exj−m⋅em−m′e^{x_j - m'} = e^{x_j - m} \cdot e^{m - m'}, и общий множитель выносится за сумму. Если максимум не изменился, множитель равен 1.

Пример. Строка x=(1,3,2)x = (1, 3, 2):

Шагxxmmмножитель emстарый−me^{m_{\text{старый}} - m}ℓ\ell
111—e0=1e^0 = 1
233e−2=0.1353e^{-2} = 0.13531⋅0.1353+e0=1.13531 \cdot 0.1353 + e^0 = 1.1353
323111.1353+e−1=1.50321.1353 + e^{-1} = 1.5032

Проверка «в лоб»: e−2+e0+e−1=0.1353+1+0.3679=1.5032e^{-2} + e^0 + e^{-1} = 0.1353 + 1 + 0.3679 = 1.5032. Вероятности ex−3/1.5032=(0.090,0.665,0.245)e^{x - 3} / 1.5032 = (0.090, 0.665, 0.245) — те же, что у логитов (1,2,3)(1, 2, 3) в уроке 1.3.2, только в другом порядке.

Главный шаг для внимания. Нам нужны не сами вероятности, а взвешенная сумма o=∑jpjvjo = \sum_j p_j v_j. Её тоже можно копить на лету: держим ненормированный накопитель a=∑jexj−mvja = \sum_j e^{x_j - m} v_j и пересчитываем его тем же множителем:

a′=a⋅em−m′+ex−m′ v,o=a/ℓ в самом конце.a' = a \cdot e^{m - m'} + e^{x - m'} \, v, \qquad o = a / \ell \text{ в самом конце}.

Для x=(1,3,2)x = (1, 3, 2) и значений v=(10,20,30)v = (10, 20, 30): после трёх шагов a=32.39a = 32.39, ℓ=1.5032\ell = 1.5032, o=21.547o = 21.547. Точный ответ 0.090⋅10+0.665⋅20+0.245⋅30=21.5470.090 \cdot 10 + 0.665 \cdot 20 + 0.245 \cdot 30 = 21.547. Ни одной вероятности мы не хранили — только три числа mm, ℓ\ell, aa.

Теперь вместо чисел — блоки. Разрежем QQ на блоки по BrB_r строк, а KK и VV — на блоки по BcB_c строк (это тайлингтайлинг (разбиение на блоки)EN tilingПриём быстрых ядер: данные режутся на тайлы, которые помещаются в быструю память, и каждый тайл используется многократно. Тайловый матмул с тайлом t читает из медленной памяти в t раз меньше, чем наивный. из прошлого урока). Для блока запросов QiQ_i заводим в SRAM векторы mm и ℓ\ell (по числу на строку) и накопитель AA размером Br×dB_r \times d. Затем идём по блокам ключей:

S=QiKj⊤/dоценки блока Br×Bcm′=max⁡(m, rowmax(S))бегущий максимум каждой строкиP~=exp⁡(S−m′)ненормированные вероятности блокаℓ←ℓ⋅em−m′+rowsum(P~)A←A⋅em−m′+P~ Vj,m←m′\begin{aligned} S &= Q_i K_j^\top / \sqrt{d} && \text{оценки блока } B_r \times B_c \\ m' &= \max\big(m,\ \mathrm{rowmax}(S)\big) && \text{бегущий максимум каждой строки} \\ \tilde P &= \exp(S - m') && \text{ненормированные вероятности блока} \\ \ell &\leftarrow \ell \cdot e^{m - m'} + \mathrm{rowsum}(\tilde P) \\ A &\leftarrow A \cdot e^{m - m'} + \tilde P \, V_j, \qquad m \leftarrow m' \end{aligned}

После последнего блока Oi=A/ℓO_i = A / \ell — один раз записываем в видеопамять. Это ровно формулы online softmax, только xx стал строкой блока SS, а vv — блоком VjV_j.

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
Прямой проход FlashAttention для одного блока запросов. Матрица оценок существует только кусками в SRAM

Сколько это занимает в SRAM. При Br=Bc=64B_r = B_c = 64 и d=64d = 64 в BF16: блоки QiQ_i, KjK_j, VjV_j — по 8 КиБ, блок SS во FP32 — 16 КиБ, накопитель AA во FP32 — 16 КиБ. Итого около 56 КиБ — помещается в 100 КБ shared memory SM у Ada. (В реальных ядрах SS и AA живут прямо в регистрах.)

Параллелизм. Блоки запросов независимы друг от друга, поэтому каждый обрабатывает свой блок потоковблок потоковEN thread block (CTA)До 1024 потоков, которые выполняются на одном SM и делят его shared memory. Ядро запускается сеткой (grid) из многих блоков. на своём SM. При батче 8, 12 головах и T=4096T = 4096 с Br=128B_r = 128 это 8⋅12⋅32=30728 \cdot 12 \cdot 32 = 3072 блока — работы хватит всем 76 SM.

Причинная маска. Если весь блок KjK_j лежит правее последней строки блока QiQ_i, все его оценки замаскированы, и блок можно не считать вовсе. Маска нужна только на диагональных блоках. При T=4096T = 4096 и блоках по 128 из 32⋅32=102432 \cdot 32 = 1024 пар блоков считаются 32⋅33/2=52832 \cdot 33 / 2 = 528 — почти вдвое меньше работы.

FlashAttention — не приближение вроде разреженного или линейного внимания (модуль 5.5). Это та же формула softmax(QK⊤/d) V\mathrm{softmax}(QK^\top/\sqrt{d})\,V, переписанная в другом порядке. Online softmax — тождество: на каждом шаге ℓ\ell и AA в точности равны суммам по уже просмотренным столбцам, приведённым к текущему максимуму. Отличия от наивной версии возникают только из-за другого порядка сложения чисел с плавающей точкой. В FP64 это 10−1610^{-16}, в FP32 — около 10−710^{-7}, в BF16 — около 10−310^{-3}. В практике ниже вы проверите это тестом.

Обратный проход: пересчитать дешевле, чем хранить

Заголовок раздела «Обратный проход: пересчитать дешевле, чем хранить»

Для градиентов нужны вероятности PP. Обычная реализация хранит их с прямого прохода — те самые 38.7 ГБ из примера выше. FlashAttention сохраняет только выход OO и одно число на строку:

Li=mi+log⁡ℓi=log⁡∑jeSijL_i = m_i + \log \ell_i = \log \sum_j e^{S_{ij}}

— logsumexp строки (урок 1.3.2). В обратном проходе блоки S=QiKj⊤/dS = Q_i K_j^\top / \sqrt{d} считаются заново, и вероятности восстанавливаются без всякой нормировки: Pij=eSij−LiP_{ij} = e^{S_{ij} - L_i}. Дальше градиенты по формулам (их вывод — то же цепное правило, что в уроке 1.3.2 для p−yp - y):

dV=P⊤dO,dP=dO V⊤,dSij=Pij (dPij−Di),Di=∑jPij dPij=dOi⋅Oi,dQ=dS K/d,dK=dS⊤Q/d.\begin{aligned} dV &= P^\top dO, \qquad dP = dO\, V^\top, \\ dS_{ij} &= P_{ij}\,(dP_{ij} - D_i), \quad D_i = \sum_j P_{ij}\, dP_{ij} = dO_i \cdot O_i, \\ dQ &= dS\, K / \sqrt{d}, \qquad dK = dS^\top Q / \sqrt{d}. \end{aligned}

Читаем вторую строку: градиент по оценке — это вероятность, умноженная на разницу «градиент по этой вероятности минус его среднее по строке». Среднее DiD_i считается без матрицы: ∑jPij(dOi⋅Vj)=dOi⋅∑jPijVj=dOi⋅Oi\sum_j P_{ij} (dO_i \cdot V_j) = dO_i \cdot \sum_j P_{ij} V_j = dO_i \cdot O_i.

Пересчёт добавляет 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 math
import torch
torch.manual_seed(0)
T, d = 64, 16
Q, 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) @ V
dO = torch.randn_like(O)
O.backward(dO)
# «как во FlashAttention»: из прямого прохода сохранены только O и L
with 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-15
dK 8.9e-16
dV 6.7e-16

Здесь матрица T×TT \times T строится целиком ради простоты; в настоящем ядре те же формулы применяются к блокам.

FlashAttention-2 (Tri Dao, 2023) не меняет математику, только раскладку работы по железу. Ненормированный накопитель делится на ℓ\ell один раз в конце, а не на каждом блоке, — меньше операций вне тензорных ядер. Блоки запросов распределяются по SM (параллелизм по длине последовательности, а не только по батчу и головам) — длинные последовательности с маленьким батчем загружают всю карту. Внутри блока работа делится между варпами по строкам QQ, и им не нужно обмениваться частичными суммами через 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 с причинной маской и пропуском блоков из будущего. Тесты сравнят результат с наивным вниманием до 10−610^{-6} на длинах, которые не делятся на размер блока, проверят logsumexp и посчитают экономию памяти.

Online softmax и блочное causal attentionnumpy · нужен интернет8 тестов
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 на T=2000T = 2000 через time.perf_counter. На NumPy блочная версия медленнее — питоновские циклы и маленькие матмулы. Выигрыш FlashAttention — от иерархии памяти GPU, а не от алгоритма как такового. В следующем уроке вы перенесёте этот код на Triton, и тогда блоки окажутся в настоящей SRAM.

Проверь себя6 вопросов
1 Online softmax прочитал строку (2,5)(2, 5). Чему равна бегущая сумма ℓ\ell (при m=5m = 5)? Округлите до двух знаков.
2 Чем результат FlashAttention отличается от обычного внимания?
3 Что FlashAttention хранит после прямого прохода для обратного? Выберите все.Можно выбрать несколько ответов
4 Сколько ГБ занимает матрица вероятностей PP одного слоя при батче 8, 12 головах, T=4096T = 4096 в BF16? Округлите до десятых (1 ГБ = 10910^9 байт).
5 Причинное внимание, T=2048T = 2048, блоки QQ и KK по 128. Сколько пар блоков вычисляет FlashAttention с пропуском блоков из будущего?
6 Обратный проход FlashAttention делает больше FLOPs, чем обычный (пересчитывает SS). Почему он всё равно быстрее?
  • Обычное внимание memory-bound: матрицы T×TT \times T несколько раз ходят через видеопамять, а для обратного прохода PP хранится целиком — гигабайты на слой.
  • Online softmax копит максимум mm, сумму ℓ\ell и ненормированный выход за один проход, пересчитывая старое множителем em−m′e^{m - m'}.
  • FlashAttention применяет это к блокам QQ, KK, VV в SRAM: матрица оценок существует только кусками, память — O(T)O(T), причинная маска позволяет пропустить почти половину блоков. Результат точный.
  • В обратном проходе блоки пересчитываются из QQ, KK и сохранённого logsumexp LL: больше FLOPs, но в разы меньше трафика. FlashAttention-2 лучше раскладывает работу по SM и варпам, FlashAttention-3 использует асинхронность и FP8 на Hopper.

Дальше: Пишем ядра на Triton — от сложения векторов до тайлового матмула и forward FlashAttention на настоящей SRAM вашей видеокарты.