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

Как устроена видеокарта и куда уходит время

Что внутри GPU — потоковые мультипроцессоры, варпы, иерархия памяти и тензорные ядра, — как по roofline на числах RTX 4080 понять, во что упирается операция, и как профайлер и torch.compile показывают, куда на самом деле уходит время шага обучения.

УРОКstage-5/5-7-gpu-internals/01-gpu-architecture-profiling.mdx2 чУрок 1 из 4
После урока вы сможете
  • Описать устройство GPU (SM, варпы, иерархию памяти, тензорные ядра) и назвать числа своей RTX 4080 по документации NVIDIA
  • Посчитать арифметическую интенсивность операции и по roofline определить, упирается она в память или в вычисления
  • Объяснить, почему поэлементные операции съедают заметную долю времени при ничтожной доле FLOPs и что с этим делает слияние операций
  • Снять профиль шага обучения через torch.profiler, прочитать таблицу и трассу и сравнить eager с torch.compile

В уроке 3.3.1 вы узнали, что GPU — это «тысячи простых ядер», и научились честно замерять время с torch.cuda.synchronize(). В уроке 5.1.1 посчитали бюджет обучения через паспортные 97.5 TFLOPS RTX 4080 и MFUутилизация FLOPs модели (MFU)EN model FLOPs utilization (MFU)Доля пиковой производительности железа, которая уходит на полезные вычисления модели. Для больших распределённых обучений типичны 30–50 %.. Если вы запускали «baby GPT» из урока 4.6.2 на видеокарте, то, скорее всего, MFU вышел заметно ниже 50%. Куда делась остальная мощность?

Ответ почти всегда один из трёх: карта ждёт память, карта ждёт процессор или считает не на тех блоках. Чтобы отличить одно от другого, нужна модель того, что происходит внутри GPU, и инструмент, который это показывает. Этот урок даёт и то и другое. Он же — фундамент модуля: FlashAttention, ядра на Triton и обучение на четырёх картах — это всё ответы на вопрос «где теряется время».

Процессор вашей машины — 48 мощных ядер, каждое умеет всё: ветвления, предсказания переходов, сложные инструкции. GPU устроен наоборот. В RTX 4080 76 одинаковых «цехов» — потоковых мультипроцессоровпотоковый мультипроцессор (SM)EN streaming multiprocessor (SM)Основной «цех» GPU NVIDIA: свои CUDA-ядра, тензорные ядра, регистры и shared memory. У RTX 4080 их 76, у H100 и H800 — 132. Блок потоков целиком выполняется на одном SM. (SM). В каждом SM 128 простых арифметических блоков для FP32 (NVIDIA называет их CUDA-ядрами), 4 тензорных ядра, свой файл регистров и своя быстрая память. Итого 76⋅128=972876 \cdot 128 = 9728 CUDA-ядер и 76⋅4=30476 \cdot 4 = 304 тензорных ядра — так в whitepaper NVIDIA по архитектуре Ada.

Отсюда паспортный пик FP32. Каждое CUDA-ядро за такт делает одно умножение-сложение (FMA, a⋅b+ca \cdot b + c) — это 2 операции. При частоте 2.505 ГГц (boost по паспорту):

9728⋅2⋅2.505⋅109≈48.7⋅1012 FLOP/с.9728 \cdot 2 \cdot 2.505 \cdot 10^9 \approx 48.7 \cdot 10^{12}\ \text{FLOP/с}.

Читаем: «число ядер × 2 операции за такт × тактов в секунду». Это те самые 48.7 TFLOPS из урока 3.3.1.

Как GPU выполняет программу. Программа для GPU называется ядромядро GPU (kernel)EN GPU kernelФункция, которая запускается на GPU сразу во множестве потоков. Каждая операция PyTorch на видеокарте — запуск одного или нескольких ядер. Не путать с ядрами процессора и с CUDA-ядрами (арифметическими блоками). (kernel) — не путайте с ядрами процессора. Каждая операция PyTorch на GPU — это запуск одного или нескольких ядер: a + b — одно ядро, a @ b — другое. Ядро запускается на сетке (grid) из множества блоков потоковблок потоковEN thread block (CTA)До 1024 потоков, которые выполняются на одном SM и делят его shared memory. Ядро запускается сеткой (grid) из многих блоков.. Каждый блок целиком попадает на один SM и делится на варпыварпEN warpГруппа из 32 потоков GPU NVIDIA, которые выполняют одну и ту же инструкцию одновременно, каждый над своими данными. Если потоки варпа расходятся по разным веткам if, ветки выполняются по очереди. — группы по 32 потока, которые выполняют одну и ту же инструкцию одновременно, каждый над своими числами. Если внутри варпа потоки пошли по разным веткам if, ветки выполняются по очереди, и половина варпа простаивает.

Уровень программыЧто этоНа каком «железе»
сетка (grid)все блоки одного запуска ядравесь GPU
блок потоковдо 1024 потоков, общая shared memoryодин SM
варп32 потока, одна инструкция на всехпланировщик внутри SM
потокодно «скалярное» вычислениеодно CUDA-ядро

На одном SM одновременно «живут» до 48 варпов (1536 потоков) — так в Ada Tuning Guide для compute capability 8.9. Зачем так много, если CUDA-ядер 128? Чтобы прятать ожидание памяти: пока один варп ждёт данные из видеопамяти (сотни тактов), планировщик переключается на другой, у которого данные уже пришли. GPU не ускоряет доступ к памяти — он прячет его за огромным числом параллельных задач.

Самое важное в этом уроке — память. Вот что есть у RTX 4080 (по whitepaper и Ada Tuning Guide):

flowchart TB
R["Регистры<br/>256 КБ на SM · всего 19 МБ<br/>доступ за такт"]
S["L1 / shared memory (SRAM)<br/>128 КБ на SM, из них до 100 КБ shared<br/>всего 9.5 МБ"]
L2["L2-кэш<br/>64 МБ на весь чип"]
V["Видеопамять GDDR6X<br/>16 ГБ у штатной карты · 716.8 ГБ/с"]
C["Оперативная память CPU<br/>через PCIe 4.0 x16 · около 32 ГБ/с"]
R --> S --> L2 --> V --> C
Иерархия памяти RTX 4080: чем ниже, тем больше объём и тем медленнее доступ
  • Регистры — личная память каждого потока, самая быстрая. 64 тысячи 32-битных регистров на SM.
  • Shared memoryshared memory (SRAM)EN shared memoryБыстрая память внутри SM, общая для потоков одного блока; физически та же SRAM, что и кэш L1. На Ada — до 100 КБ на SM. В статьях о FlashAttention её называют просто SRAM. — быстрая память внутри SM, общая для потоков одного блока. Физически это та же SRAM, что и кэш L1; сколько отдать под shared, решает программа (до 100 КБ на SM). В статьях про FlashAttention её называют просто SRAM.
  • L2 — общий кэш на весь чип. У Ada он огромный, 64 МБ (у предыдущей RTX 3090 Ti было 6 МБ).
  • Видеопамять — те самые гигабайты из nvidia-smi. У GeForce это GDDR6X, у серверных карт — HBMHBM, видеопамятьEN HBM (high-bandwidth memory)Основная память серверных GPU (A100, H100, H800) — микросхемы в одном корпусе с чипом. У GeForce вместо неё GDDR6X с той же ролью: большой объём, но в десятки раз медленнее SRAM. В литературе «HBM» часто означает просто видеопамять. (многослойная память в одном корпусе с чипом). Роль у них одинаковая, поэтому дальше «HBM» и «видеопамять» — синонимы.

Насколько SRAM быстрее видеопамяти? Для RTX 4080 NVIDIA этого числа не публикует, но порядок даёт статья FlashAttention (рис. 1) для A100: SRAM около 19 ТБ/с при объёме 20 МБ на весь чип, HBM — 1.5 ТБ/с при 40 ГБ. Разница больше чем на порядок, а объём меньше в тысячи раз. Отсюда главный принцип быстрых ядер: загрузить кусок данных в SRAM один раз и сделать с ним как можно больше работы, прежде чем писать результат обратно.

Тензорное ядротензорное ядроEN Tensor CoreБлок GPU, который за одну инструкцию умножает маленькие матрицы и прибавляет результат: D = AB + C. Работает в форматах TF32, BF16, FP16, FP8, INT8. У RTX 4080 их 304 (по 4 на SM), пик в BF16 — 97.5 TFLOPS против 48.7 у FP32 на CUDA-ядрах. за одну инструкцию умножает две маленькие матрицы и прибавляет результат к третьей: D=AB+CD = A B + C. Обычное CUDA-ядро за такт делает одно умножение-сложение, тензорное — десятки сразу. Но умеет оно только матричное умножение и только в «узких» форматах. Пиковая производительность RTX 4080 по whitepaper (плотные матрицы, без «маркетинговой» разреженности 2:4, которая удваивает числа):

Формат и блокиПик, TFLOPSГде используется
FP32 на CUDA-ядрах48.7матмул по умолчанию в PyTorch, все поэлементные операции
TF32 на тензорных ядрах48.7torch.set_float32_matmul_precision("high")
BF16 / FP16 на тензорных ядрах, накопление в FP3297.5torch.autocast(dtype=torch.bfloat16), урок 5.4.1
FP16 на тензорных ядрах, накопление в FP16194.9инференс-движки; точность сумматора хуже
FP8 на тензорных ядрах, накопление в FP32194.9torch._scaled_mm, урок 5.4.1

Две причины, почему матмул в BF16BF16 (bfloat16)EN bfloat16 (brain floating point)16-битный формат: 1 бит знака, 8 бит порядка (как у FP32), 7 бит мантиссы. Диапазон как у FP32, точность — 2–3 значащие цифры. Стандарт обучения LLM. быстрее FP32: вдвое больше пик (97.5 против 48.7) и вдвое меньше байт на каждое число, то есть вдвое меньше нагрузка на память. Любопытная деталь GeForce: TF32 здесь не даёт выигрыша в паспорте — его пик равен FP32 на CUDA-ядрах (у серверных A100 и H100 TF32 в разы быстрее FP32). Проверьте на своей карте, что получится на практике.

У любой операции есть две цены: сколько операций FF (FLOP) она делает и сколько байт MM читает и пишет в видеопамять. У GPU — два предела: пик PP (FLOP/с) и пропускная способность памятипропускная способность памятиEN memory bandwidthСколько байт в секунду память отдаёт вычислителю. RTX 4080 — около 717 ГБ/с, H100 — 3.35 ТБ/с, двухканальная DDR5 — около 90 ГБ/с. Главное число для скорости decode. WW (байт/с). Операция не может закончиться быстрее F/PF / P (не хватит вычислителя) и быстрее M/WM / W (не хватит памяти):

t≥max⁡(FP, MW).t \ge \max\left(\frac{F}{P},\ \frac{M}{W}\right).

Что больше, то и узкое место. Отношение I=F/MI = F / M называют арифметической интенсивностьюарифметическая интенсивностьEN arithmetic intensityСколько операций приходится на один прочитанный из памяти байт (FLOP/байт). Decode с батчем 1 в BF16 — около 1, prefill на 1000 токенов — около 1000. — «сколько операций приходится на каждый байт из памяти». Сравните её с точкой перегиба P/WP / W. У RTX 4080 в BF16:

PW=97.5⋅1012716.8⋅109≈136 FLOP/байт.\frac{P}{W} = \frac{97.5 \cdot 10^{12}}{716.8 \cdot 10^9} \approx 136\ \text{FLOP/байт}.

Операция с I<136I < 136 — ограничено памятьюограничено памятью (memory-bound)EN memory-boundОперация, которая дольше ждёт данные из памяти, чем считает. Ускоряется, если читать меньше байт (квантование, GQA, MLA) или делать больше работы на каждый прочитанный байт (батчинг).: тензорные ядра простаивают, ожидая данных. С I>136I > 136 — ограничено вычислениямиограничено вычислениями (compute-bound)EN compute-boundОперация, в которой вычислитель загружен полностью, а память успевает подвозить данные. Ускоряется только более быстрым железом или меньшим числом операций.: память успевает, вычислитель загружен. Достижимая скорость равна min⁡(P, I⋅W)\min(P,\ I \cdot W); график этой функции похож на крышу (наклонный скат и ровный конёк), отсюда название модель rooflineмодель roofline («крыша»)EN roofline modelДостижимая производительность = min(пик FLOP/с, интенсивность × пропускная способность памяти). Точка перегиба (ridge point) = пик / пропускная способность; у H100 около 295 FLOP/байт..

Пример 1: сложение двух тензоров BF16 по NN чисел. Операций NN (одно сложение на число). Байт: прочитать два тензора и записать один, 3⋅2N=6N3 \cdot 2N = 6N. Интенсивность 1/6≈0.171/6 \approx 0.17 — в 800 раз ниже точки перегиба. Для N=64⋅220N = 64 \cdot 2^{20} (67 млн чисел) память займёт 6⋅67.1⋅106/716.8⋅109≈0.566 \cdot 67.1 \cdot 10^6 / 716.8 \cdot 10^9 \approx 0.56 мс, а вычисления — около 0.001 мс (поэлементное сложение идёт на CUDA-ядрах, а не на тензорных: 67.1⋅106/48.7⋅1012≈0.001467.1 \cdot 10^6 / 48.7 \cdot 10^{12} \approx 0.0014 мс).

Пример 2: матмул (M×K)⋅(K×N)(M \times K) \cdot (K \times N) в BF16. Операций 2MKN2MKN, байт 2(MK+KN+MN)2(MK + KN + MN), если каждая матрица читается и пишется один раз. Для M=K=N=4096M = K = N = 4096: I=4096/3≈1365I = 4096 / 3 \approx 1365 — далеко правее точки перегиба.

Пример 3: слои вашей «baby GPT» (батч 64 × 256 токенов = 16 384 строки, d=384d = 384):

ОперацияФормаИнтенсивность, FLOP/байтУпор
c_attn (Q, K, V одной матрицей)16384×384⋅384×115216384 \times 384 \cdot 384 \times 1152283вычисления, с небольшим запасом
c_fc в MLP16384×384⋅384×153616384 \times 384 \cdot 384 \times 1536302вычисления
lm_head, словарь 137 символов16384×384⋅384×13716384 \times 384 \cdot 384 \times 137100память
LayerNorm, GELU, остаточное сложениепоэлементнооколо 1 и меньшепамять, всегда

Проверим таблицу кодом — тем же, что вы напишете в практике:

def matmul_intensity(M, K, N, b=2): # b — байт на число (BF16 = 2)
return 2 * M * K * N / (b * (M * K + K * N + M * N))
ridge = 97.5e12 / 716.8e9 # точка перегиба RTX 4080, BF16
for name, shape in [("c_attn", (16384, 384, 1152)), ("c_fc", (16384, 384, 1536)), ("lm_head", (16384, 384, 137))]:
I = matmul_intensity(*shape)
print(f"{name:8s} I = {I:5.0f} {'вычисления' if I > ridge else 'память'}")
c_attn I = 283 вычисления
c_fc I = 302 вычисления
lm_head I = 100 память

Модель маленькая, поэтому даже её матмулы стоят недалеко от точки перегиба. У GPT-2 small (d=768d = 768) или Qwen-0.5B (d=896d = 896) интенсивность матмулов выше, и MFU растёт сам собой.

Куда уходит время шага: поэлементные операции

Заголовок раздела «Куда уходит время шага: поэлементные операции»

Посчитаем бюджет одного блока baby GPT в прямом проходе, если бы каждая операция шла на пределе своего ресурса.

  • Матмулы и внимание: 5858 GFLOP в четырёх линейных слоях плюс 3.23.2 GFLOP во внимании (с причинной маской считается половина матрицы). На пике 97.5 TFLOPS — 0.63 мс.
  • Поэлементные операции: два LayerNorm, два остаточных сложения, GELU на тензоре 16384×153616384 \times 1536, dropout. Каждая читает вход и пишет выход: всего около 258 МБ. На 716.8 ГБ/с — 0.36 мс.

Поэлементные операции делают 0.6% всех FLOPs, а занимают около трети времени — и это оценка снизу. Под torch.autocast LayerNorm и остаточный поток считаются в FP32 (4 байта на число), а между форматами вставляются ядра-преобразования. Добавьте накладные расходы на запуск каждого ядра (единицы микросекунд, урок 3.3.1), и станет понятно, почему MFU маленьких моделей редко поднимается выше 30–40%.

Лекарство — слияние операцийслияние операций (kernel fusion)EN kernel fusionВыполнение нескольких операций одним ядром: промежуточные результаты остаются в регистрах или SRAM, в видеопамять пишется только итог. Главный способ ускорить memory-bound операции. Автоматически это делает torch.compile. (kernel fusion): несколько поэлементных шагов делаются одним ядром, промежуточные результаты остаются в регистрах, а в видеопамять уходит только финальный ответ.

Классический пример из видео Карпатого — GELU в приближении через тангенс, записанная формулой:

import math
import torch
def gelu_manual(x):
return 0.5 * x * (1.0 + torch.tanh(math.sqrt(2.0 / math.pi) * (x + 0.044715 * torch.pow(x, 3.0))))

В обычном (eager) режиме PyTorch выполняет эту строку как 8 отдельных ядер: pow, умножение на 0.044715, сложение с x, умножение на константу, tanh, прибавление 1, 0.5 * x и итоговое умножение. Каждое читает свои входы из видеопамяти и пишет выход обратно: всего 10 чтений и 8 записей на каждое число. Слитое ядро читает x один раз и пишет ответ один раз — в 9 раз меньше байт. Для тензора 16384×153616384 \times 1536 в BF16 это 1.26 мс против 0.14 мс по roofline. Для сравнения: матмул c_fc, который этот тензор породил, на пике занимает 0.20 мс. Без слияния «мелкая» GELU была бы в шесть раз дольше самого тяжёлого умножения в блоке.

(В нашей model.py стоит F.gelu — это одно готовое ядро PyTorch, так что эта конкретная беда нам не грозит. Но LayerNorm, остаточные сложения, dropout и преобразования форматов по-прежнему идут отдельными ядрами.)

torch.compiletorch.compileEN torch.compileКомпилятор PyTorch 2: TorchDynamo перехватывает граф операций модели, TorchInductor генерирует слитые ядра (на GPU — на Triton). Ускоряет обучение и инференс без изменения кода модели; первый вызов долгий из-за компиляции. делает слияние автоматически. Он перехватывает граф операций вашей модели (компонент TorchDynamo), а генератор кода TorchInductor пишет для GPU новые ядра на Triton:

  • сливает цепочки поэлементных операций и редукций (LayerNorm, softmax, сложения, GELU, dropout) в одно ядро;
  • оставляет большие матмулы библиотеке cuBLAS (в режиме mode="max-autotune" перебирает и свои Triton-шаблоны);
  • убирает накладные расходы Python между операциями, а в режиме mode="reduce-overhead" записывает весь шаг в CUDA Graph — один запуск вместо сотен.

Посмотреть, какие ядра он сгенерировал, можно переменной окружения TORCH_LOGS=output_code: в консоль выведется код на Triton с именами вроде triton_poi_fused_add_mul_tanh_... (poi — pointwise, поэлементное) или triton_red_fused_native_layer_norm_... (red — reduction). В уроке 5.7.3 вы будете писать такие ядра сами.

Профилирование: как увидеть, куда ушло время

Заголовок раздела «Профилирование: как увидеть, куда ушло время»

Модель выше — это гипотеза. Проверяют её инструментами.

nvidia-smi показывает память и GPU-Util. Осторожно: GPU-Util — это доля времени, когда на карте выполнялось хотя бы одно ядро. Одно крошечное ядро на одном SM из 76 даёт те же 100%. Если GPU-Util низкий (меньше 90%), карта ждёт процессор — данные, Python, запуск ядер. Если высокий, это ещё ничего не говорит об эффективности.

torch.profilerпрофайлерEN profilerИнструмент, который записывает, сколько времени заняла каждая операция и каждое ядро. Для PyTorch — torch.profiler (таблица и трасса для Perfetto), для всей программы — NVIDIA Nsight Systems (nsys). записывает каждую операцию PyTorch и каждое ядро GPU с их временем. Минимальный пример — профиль шага обучения из полного скрипта ниже:

from torch.profiler import ProfilerActivity, profile, record_function, schedule
acts = [ProfilerActivity.CPU, ProfilerActivity.CUDA] # на CPU — только ProfilerActivity.CPU
with profile(activities=acts, schedule=schedule(wait=1, warmup=1, active=3)) as prof:
for _ in range(5): # 1 шаг пропустить, 1 — прогрев профайлера, 3 — записать
with record_function("train_step"): # своя метка на трассе
train_step()
prof.step() # сообщить профайлеру, что шаг закончился
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=10))
prof.export_chrome_trace("trace.json") # открыть в https://ui.perfetto.dev

Таблица — это «кто сколько съел». На CPU (маленькая модель, 4 слоя, d=128d = 128) у нас она выглядит так (сокращено, числа на вашей машине будут другими):

Name Self CPU % Self CPU ... # of Calls
aten::mm 29.10% 99.049ms ... 105
aten::addmm 14.69% 50.016ms ... 48
aten::_scaled_dot_product_flash_atten... 7.22% 24.565ms ... 12
aten::sum 3.79% 12.918ms ... 51
aten::add 3.39% 11.524ms ... 30
aten::gelu_backward 3.33% 11.353ms ... 12

На GPU в таблице появятся колонки Self CUDA и строки с именами ядер: матмулы — что-то вроде sm80_xmma_gemm_bf16... или cutlass_..., внимание — flash_fwd_kernel, поэлементные — vectorized_elementwise_kernel, LayerNorm — layer_norm_kernel. Имена зависят от версий CUDA и PyTorch; узнавать их надо по смыслу.

Трасса (trace.json в Perfetto) — это время по горизонтали. Сверху строка потока CPU: Python ставит операции в очередь. Ниже строка «stream 7» (номер может отличаться): ядра на GPU. Читайте её так:

  • ядра идут плотно, без щелей — GPU занят; дальше смотрите, какие ядра длинные;
  • между ядрами щели — GPU ждёт CPU. Лечится крупным батчем, torch.compile, CUDA Graphs;
  • много коротких ядер подряд (по 5–20 мкс) между длинными матмулами — поэлементные операции, кандидаты на слияние.

Nsight Systems (nsys) — профайлер от NVIDIA для всей программы сразу: CPU, GPU, копирования по PCIe, NCCL. Он нужен, когда торч-профайлер не видит проблему (например, задержки загрузки данных или обмен между картами): nsys profile -t cuda,nvtx,osrt -o gpt python train.py, затем nsys stats gpt.nsys-rep или графический Nsight Systems.

Напишите тайловый матмул со счётчиком обращений к медленной памяти и roofline-калькулятор. Вы увидите, во сколько раз тайлы сокращают трафик, какой тайл помещается в 100 КБ shared memory и почему одной shared memory не хватает, чтобы матмул стал compute-bound.

Тайловый матмул и roofline RTX 4080numpy · нужен интернет6 тестов
import numpy as np

# Характеристики RTX 4080 (whitepaper NVIDIA по архитектуре Ada, плотные операции без разреженности)
PEAK_BF16 = 97.5e12      # FLOP/с: тензорные ядра, BF16 с накоплением в FP32
PEAK_FP32 = 48.7e12      # FLOP/с: обычные ядра CUDA, FP32
BANDWIDTH = 716.8e9      # байт/с: видеопамять GDDR6X
SMEM_BYTES = 100 * 1024  # байт: максимум shared memory на один SM (Ada, compute capability 8.9)


# ── Готовый помощник: наивный матмул со счётчиком ─────────────────────────────
def naive_matmul(A, B):
    """C = A @ B «в лоб». Каждое C[i, j] читает строку A и столбец B из медленной памяти.

    Возвращает (C, loads), где loads — сколько чисел прочитано из медленной памяти.
    Кэша нет: одно и то же число читается заново каждый раз, когда нужно.
    """
    n = A.shape[0]
    C = np.zeros((n, n))
    loads = 0
    for i in range(n):
        for j in range(n):
            C[i, j] = A[i, :] @ B[:, j]   # строка A (n чисел) и столбец B (n чисел)
            loads += 2 * n
    return C, loads


# ── Ваша часть ───────────────────────────────────────────────────────────────
def tiled_matmul(A, B, t):
    """Тайловый матмул: C = A @ B блоками t×t.

    Для каждого тайла C[bi, bj] (t×t) идём по k-тайлам: загружаем в «быструю память»
    тайл A[bi, bk] и тайл B[bk, bj] (каждый — t·t чисел), перемножаем и прибавляем к C.
    Возвращает (C, loads), loads — сколько чисел прочитано из медленной памяти.
    n делится на t.
    """
    n = A.shape[0]
    assert n % t == 0, "n должно делиться на t"
    C = np.zeros((n, n))
    loads = 0
    # TODO: три вложенных цикла по i, j, k с шагом t; срезы A[i:i+t, k:k+t] и B[k:k+t, j:j+t]
    ...
    return C, loads


def tiled_loads(n, t):
    """Формула: сколько чисел читает тайловый матмул n×n с тайлом t. Тайлов C: (n/t)²,
    у каждого n/t шагов по k, на шаге — 2t² чисел."""
    # TODO
    ...


def max_tile(smem_bytes, bytes_per_elem):
    """Самый большой тайл t — степень двойки, — при котором три тайла t×t
    (A, B и накопитель C) помещаются в smem_bytes: 3·t²·bytes_per_elem ≤ smem_bytes."""
    # TODO: начните с t = 1 и удваивайте, пока следующий размер помещается
    ...


def matmul_intensity(M, K, N, bytes_per_elem):
    """Арифметическая интенсивность (FLOP на байт) умножения (M×K)·(K×N), если каждая
    матрица читается/пишется ровно один раз: 2MKN / (bytes·(MK + KN + MN))."""
    # TODO
    ...


def roofline(intensity, peak, bandwidth):
    """Модель roofline. Возвращает (достижимые FLOP/с, "memory" или "compute").

    Достижимая производительность = min(peak, intensity · bandwidth).
    Если intensity меньше точки перегиба peak / bandwidth — упор в память ("memory").
    """
    # TODO
    ...


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    A, B = rng.standard_normal((32, 32)), rng.standard_normal((32, 32))
    _, naive = naive_matmul(A, B)
    print("наивный матмул 32×32 читает", naive, "чисел")
    C, loads = tiled_matmul(A, B, 8)
    print("тайловый, t = 8:", loads)

После решения посмотрите на вывод solution: тайл BF16, который помещается в shared memory, — 128, и он даёт интенсивность t/2=64t / 2 = 64 FLOP/байт относительно видеопамяти. Это ниже точки перегиба 136. Как же cuBLAS выжимает из карты почти пик? Помогает следующий уровень иерархии: тайлы A и B, которые одновременно нужны десяткам блоков на разных SM, приходят из L2 (64 МБ), а не из видеопамяти. Поэтому хорошие ядра заботятся о порядке обхода тайлов. В уроке 5.7.3 вы увидите это в матмуле на Triton как параметр GROUP_SIZE_M.

Проверь себя6 вопросов
1 Пик RTX 4080 в BF16 — 97.5 TFLOPS, пропускная способность памяти — 716.8 ГБ/с. Чему равна точка перегиба roofline (FLOP на байт)? Округлите до целого.
2 Какие операции шага обучения baby GPT упираются в пропускную способность памяти? Выберите все.Можно выбрать несколько ответов
3 Сложение двух BF16-тензоров по 64⋅22064 \cdot 2^{20} чисел на RTX 4080. Сколько миллисекунд займёт оно по roofline? Округлите до двух знаков.
4 Ручная GELU из 8 операций в eager делает 10 чтений и 8 записей на каждое число, слитое ядро — 1 чтение и 1 запись. Во сколько раз слияние уменьшает трафик памяти?
5 nvidia-smi показывает GPU-Util 100%. Что это гарантирует?
6 Почему матмул в BF16 на RTX 4080 быстрее, чем в FP32? Выберите все верные причины.Можно выбрать несколько ответов
  • GPU — это 76 SM (у RTX 4080), в каждом 128 CUDA-ядер и 4 тензорных ядра. Программа-ядро запускается сеткой блоков; блок живёт на одном SM и выполняется варпами по 32 потока. Десятки варпов на SM нужны, чтобы прятать ожидание памяти.
  • Память — иерархия: регистры → shared memory/SRAM (до 100 КБ на SM) → L2 (64 МБ) → видеопамять (716.8 ГБ/с). Быстрое ядро загружает данные в SRAM один раз и делает с ними как можно больше работы.
  • Roofline: время ≥ max⁡(F/P,M/W)\max(F/P, M/W); точка перегиба RTX 4080 в BF16 — 136 FLOP/байт. Большие матмулы упираются в вычисления, поэлементные операции — всегда в память.
  • Поэлементные операции — доли процента FLOPs, но треть времени и больше. Слияние операций (вручную или через torch.compile) убирает лишние чтения и записи. Проверяйте гипотезы профайлером: таблица — кто сколько съел, трасса — где GPU простаивает.

Дальше: FlashAttention: внимание без матрицы n×n — самый знаменитый пример того, как тайлы в SRAM и слияние операций превращают memory-bound внимание в быстрое и точное.