Настоящий материал представляет собой обзор седьмой главы книги Chris Fregly, «AI Systems Performance Engineering: Optimizing Model Training and Inference Workloads with GPUs, CUDA, and PyTorch», O'Reilly Media, 2026 (Copyright © 2026 Flux Capacitor, LLC).
Прямые цитаты из книги выделены отдельными блоками с пометкой «Цитата из книги». Остальной текст — систематизированное изложение содержания главы, сопоставление с актуальными данными о платформе, проверка прогнозов по открытым источникам, а также дополнительные замечания, выходящие за пределы текста главы. Цитаты приводятся в ограниченном объёме в целях обзора и критического анализа.
Современные графические процессоры NVIDIA — от Hopper до Blackwell — способны выполнять десятки терафлопс в формате FP32 и ещё больше с использованием Tensor Core. Но между теоретическим пиком и реальной производительностью стоит один барьер: подсистема памяти. Данные должны поступать в вычислительные блоки достаточно быстро, чтобы процессор не простаивал. Именно здесь большинство ядер теряют производительность — не из-за нехватки арифметики, а из-за неэффективного обращения к памяти.
«По мере роста и усложнения AI-моделей память GPU часто становится узким местом, стоящим между теоретической вычислительной способностью и реальной производительностью».
«На современном GPU, таком как Blackwell, пропускная способность HBM3e достигает 8 ТБ/с на устройство. В конфигурации Grace Blackwell GB200 и GB300 эта цифра удваивается до 16 ТБ/с на оба GPU. Необъединённые обращения к памяти оставят большую часть этой пропускной способности неиспользованной из-за избыточных транзакций и простоев».
В главе подчёркивается, что оптимизация памяти — это не одна техника, а последовательность шагов, каждый из которых снимает очередной барьер. Сначала объединение загрузок (coalescing) решает проблему фрагментированных транзакций. Затем векторизация сокращает число инструкций. Тайлинг устраняет повторные загрузки. Устранение конфликтов банков убирает сериализацию разделяемой памяти. Warp shuffle заменяет обмен через память на обмен через регистры. Кэш только для чтения снижает нагрузку на DRAM. И, наконец, TMA с асинхронными конвейерами скрывает задержку HBM за вычислениями.
Каждый шаг подтверждается конкретными метриками Nsight Compute: Global Memory Load Efficiency, Average Sectors per Request, DRAM throughput, SM Active %, bank conflicts, warp stall reasons. Глава последовательно показывает, как эти метрики меняются при переходе от наивного кода к оптимизированному.
Ключевая идея главы: производительность GPU-ядра определяется не количеством арифметических инструкций, а тем, насколько хорошо оно использует иерархию памяти. Ядро, которое при каждом умножении обращается к HBM, будет тратить 800 циклов на ожидание. Ядро, которое загружает тайл один раз, переиспользует его через shared memory, обменивается значениями через регистры и асинхронно предзагружает следующий тайл через TMA, превращает эти 800 циклов ожидания в 800 циклов полезной работы.
GPU располагает многоуровневой иерархией памяти, и цена доступа к каждому уровню различается на порядки. Регистры — самый быстрый уровень: задержка составляет один цикл, но их объём ограничен (до 65 536 32-битовых регистров на SM у Blackwell). Разделяемая память (shared memory) обеспечивает задержку около 30 циклов и объём до 228 КБ на SM. Кэш L2, общий для всех SM, добавляет примерно 200 циклов задержки. И, наконец, глобальная память — HBM3e на Blackwell — обеспечивает задержку около 800 циклов, но при этом обладает наибольшим объёмом: до 192 ГБ на устройство и пропускной способностью до 8 ТБ/с (16 ТБ/с в двухчиповых конфигурациях Grace Blackwell).
Ключевое наблюдение: задержка доступа к HBM примерно в 800 раз больше, чем к регистру. Если каждый warp будет ждать 800 циклов на каждую загрузку, производительность упадёт кратно. Поэтому вся оптимизация сводится к одному принципу: меньше обращаться к HBM, больше — к ближним уровням иерархии, и не давать GPU простаивать в ожидании данных.
Каждый приём, рассмотренный ниже, иллюстрируется кодом на CUDA C++ и PyTorch, а также метриками профилировщика Nsight Compute, чтобы было видно не только «что изменить», но и «почему это помогает».
Память GPU — основное узкое место современных AI-нагрузок. Задержка HBM (~800 циклов) на порядки превышает задержку регистров (1 цикл).
HBM3e на Blackwell: до 8 ТБ/с на GPU, 192 (180 полезных) ГБ на B200, 16 ТБ/с в двухчиповых конфигурациях. Пропускная способность памяти растёт медленнее, чем вычислительная способность.
Задача оптимизации — сместить баланс от HBM к регистрам, shared memory и L2. Каждый приём из главы — шаг в этом направлении.
«Шаблон доступа к памяти вашего кода может сильно влиять на производительность. Обращения к глобальной памяти работают быстрее всего, когда потоки в warp'е обращаются к непрерывным адресам памяти, которые аппаратура может объединить в меньшее число более крупных транзакций. Если потоки обращаются к разрозненным или невыровненным адресам, устройство не может объединить запросы в минимальное число транзакций кэш-линий, которые на современных GPU составляют 128-байтовые линии, состоящие из четырёх 32-байтовых секторов. Это приводит к множеству дополнительных транзакций памяти, извлекающих неиспользуемые данные, что быстро съедает пропускную способность памяти».
Обращения к глобальной памяти работают быстрее всего, когда 32 потока warp'а читают непрерывный участок памяти. Аппаратура GPU способна объединить 32 отдельных запроса в минимальное число 128-байтовых транзакций — при идеальном выравнивании это одна транзакция на warp. Каждая 128-байтовая кэш-линия состоит из четырёх 32-байтовых секторов, и оптимальный доступ укладывается ровно в четыре сектора на запрос.
Если же потоки разбегаются по разрозненным адресам — шаговая индексация, нерегулярный доступ, невыровненный старт, — аппаратура не может объединить запросы. Вместо одной широкой транзакции warp выдаёт множество мелких, каждая из которых тянет из HBM 32-байтовый сектор, но полезные байты в нём — лишь малая часть. В худшем случае число секторов на запрос может приближаться к 32.
32 lane-запроса сливаются в одну 128-байтовую транзакцию — или взрываются во множество
В Nsight Compute раздел Memory Workload Analysis покажет пониженную эффективность загрузки глобальной памяти (Global Memory Load Efficiency), повышенное число чтений секторов DRAM и среднее количество секторов на запрос выше 4,0. Процент пропускной способности DRAM останется значительно ниже пикового, подтверждая, что warp тратит циклы в ожидании памяти вместо нагрузки на ALU.
Шаг индексации напрямую определяет число транзакций на warp
Чтобы выйти из режима ограниченности памятью, можно реорганизовать данные так, чтобы каждый из 32 потоков warp'а загружал непрерывные элементы. Либо индексируйте массив как input[idx], где idx = blockIdx.x * blockDim.x + threadIdx.x, либо перейдите к компоновке «структура массивов» (SoA) вместо «массива структур» (AoS), чтобы поток i всегда обращался к элементу i.
Массив структур даёт stride 3, структура массивов — stride 1
x y z | x y z | … — чтение только x даёт stride 3 и scatter-обращения. SoA раскладывает поля в отдельные массивы — доступ становится непрерывным, coalescing срабатывает идеально.#include <cuda_runtime.h>
#include <iostream>
__global__ void uncoalescedCopy(const float* __restrict__ in,
float* __restrict__ out,
int N, int stride) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
out[idx] = in[idx * stride];
}
}
import torch
def uncoalesced_copy(input_tensor, stride):
flat = input_tensor.contiguous().view(-1)
idx = torch.arange(0, flat.numel(), stride,
device=flat.device, dtype=torch.long)
return torch.index_select(flat, 0, idx)
В необъединённой версии запрос памяти каждого warp'а разбивается в среднем на 8 отдельных 32-байтовых секторов. Шаблон доступа охватывает две кэш-линии на warp, раздувая количество транзакций и истощая конвейер памяти. Nsight Compute сообщает лишь около 25% от устойчивого пикового значения пропускной способности DRAM.
__global__ void coalescedCopy(const float* __restrict__ in,
float* __restrict__ out,
int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
out[idx] = in[idx];
}
}
Объединённая версия на PyTorch — это просто out = inp.clone(), поскольку clone() на непрерывном тензоре использует векторизованное копирование памяти под капотом. Можно также применить torch.compile с mode="max-autotune", чтобы TorchInductor выбрал объединённые и векторизованные расписания.
| Метрика | До (необъединённый) | После (объединённый) |
|---|---|---|
| Пропускная способность DRAM (% от пика) | 25% | 90% (в 3,6 раза) |
| Эффективность загрузки глобальной памяти | 23% | 99% |
| Среднее число секторов на запрос | 8,0 | 4,0 (оптимально) |
| Активность SM % | 62% | 99% |
| Время выполнения ядра | 4,8 мс | 1,3 мс (в 3,7 раза) |
Объединение загрузок — базовый уровень оптимизации памяти. Аппаратура GPU способна объединить 32 запроса warp'а в одну 128-байтовую транзакцию, но только если потоки обращаются к непрерывным адресам.
Необъединённый доступ: 25% DRAM throughput, 8 секторов на запрос. Объединённый: 90% DRAM throughput, 4 сектора на запрос. Ускорение ядра — 3,7×.
Проверяйте Global Memory Load Efficiency в Nsight Compute. Если она ниже 90% — reorganize данные (SoA вместо AoS, непрерывная индексация).
«Хотя объединение памяти — это аппаратная оптимизация времени выполнения на GPU NVIDIA, векторизованный доступ к памяти — это стратегия времени компиляции, в которой каждая инструкция загрузки или сохранения явно извлекает несколько смежных элементов (например, float4 или 16 байт) на поток. Это сокращает количество инструкций и устраняет накладные расходы на сшивание».
Объединение — это аппаратная оптимизация времени выполнения: GPU сам «склеивает» 32 запроса по 4 байта в широкую транзакцию. Но под капотом остаются 32 отдельные инструкции загрузки, по одной на поток. Векторизация устраняет этот избыток: каждый поток читает не один float, а сразу четыре — типом float4 (16 байт).
Когда все 32 потока warp'а выдают загрузку float4, они вместе извлекают 32 × 16 = 512 байт непрерывных данных, которые контроллер памяти разбивает ровно на четыре 128-байтовые транзакции. Каждая транзакция состоит из четырёх 32-байтовых секторов, каждый полностью используемый — идеальные 4,0 сектора на транзакцию. По сравнению со скалярным случаем число инструкций загрузки на поток падает в 4 раза.
Для использования векторизованных загрузок указатели данных должны быть выровнены по ширине вектора. Функции выделения памяти CUDA, такие как cudaMalloc, возвращают указатели, выровненные как минимум по 256 байт. Однако добавление смещения элемента может нарушить выравнивание:
auto ptr4 = reinterpret_cast<const float4*>(ptr);
__global__ void copyVector16B(const float4* __restrict__ in,
float4* __restrict__ out,
int N4) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N4) {
out[idx] = in[idx];
}
}
На архитектуре Blackwell доступен ещё более широкий вектор — 32 байта на поток (8 float). CUDA не предоставляет встроенного типа float8, поэтому нужно определить собственную структуру с 32-байтовым выравниванием:
struct alignas(32) float8 { float v[8]; };
__global__ void copyVector32B(const float8* __restrict__ in,
float8* __restrict__ out, int N8) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N8) {
out[idx] = in[idx];
}
}
При 32-байтовых поточных загрузках warp перемещает 1024 байта (32 потока × 32 байта), что отображается на 8 × 128-байтовых линий. На Hopper та же операция потребует двух 16-байтовых загрузок на поток, поскольку Hopper не поддерживает 32-байтовые векторные инструкции.
| Метрика | До (скалярный) | После (векторизованный) |
|---|---|---|
| Эффективность загрузки глобальной памяти | 28% | 97% |
| Среднее число секторов на запрос | 31,8 | 4,0 |
| Пропускная способность DRAM (% от пика) | 25% | 90% (в 3,6 раза) |
| Время выполнения ядра | 4,2 мс | 1,2 мс (в 3,5 раза) |
Векторизация — сокращение числа инструкций за счёт ширины вектора. float4 (16 байт) на Hopper, 32-байтовые векторы на Blackwell.
Скалярный доступ: 28% load efficiency, 31,8 сектора на запрос. Векторизованный: 97% load efficiency, 4,0 сектора. Ускорение ядра — 3,5×.
Проверяйте выравнивание указателей. cudaMalloc возвращает выравнивание не менее 256 байт, но смещения могут его нарушить. На Blackwell используйте 32-байтовые векторы с alignas(32).
«Распространённая ловушка производительности — повторное чтение одних и тех же данных из глобальной памяти. Тайлинг — это техника, позволяющая избежать этого, загружая фрагменты данных в более быструю on-chip разделяемую память и переиспользуя эти фрагменты многими потоками».
Объединение и векторизация ускоряют саму передачу данных, но не решают проблему повторных загрузок. Наивное умножение матриц N × N загружает каждый элемент матрицы A из HBM N раз — по разу для каждой строки B, с которой он умножается. Это приводит к N–1 избыточным загрузкам на элемент.
Тайлинг решает проблему: блок потоков загружает подматрицу (тайл) 32 × 32 из A и B в разделяемую память ровно один раз, а затем все потоки блока используют эти данные для 32 × 32 = 1024 умножений-накоплений. Каждый элемент A и B теперь загружается из HBM один раз на тайл вместо 32 раз.
#define TILE_SIZE 32
__global__ void tiledMatMul(const float* A, const float* B,
float* C, int N) {
__shared__ float sA[TILE_SIZE][TILE_SIZE];
__shared__ float sB[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
float sum = 0.0f;
for (int t = 0; t < N; t += TILE_SIZE) {
if (row < N && (t + threadIdx.x) < N)
sA[threadIdx.y][threadIdx.x] = A[row * N + t + threadIdx.x];
else sA[threadIdx.y][threadIdx.x] = 0.0f;
if ((t + threadIdx.y) < N && col < N)
sB[threadIdx.y][threadIdx.x] = B[(t + threadIdx.y) * N + col];
else sB[threadIdx.y][threadIdx.x] = 0.0f;
__syncthreads();
for (int k = 0; k < TILE_SIZE; ++k)
sum += sA[threadIdx.y][k] * sB[k][threadIdx.x];
__syncthreads();
}
if (row < N && col < N) C[row * N + col] = sum;
}
| Метрика | До (наивное) | После (тайловое) | Примечания |
|---|---|---|---|
| Пропускная способность DRAM (% от пика) | 90% | 25% | Тайловое ядро эффективнее — байты переиспользуются |
| Достигнутая занятость (%) | 42% | 89% | Больше резидентных warp'ов |
| Пропускная способность FP | 15 GFLOPS | 170 GFLOPS | Переиспользование повышает устойчивые вычисления |
| Сектора загрузки глобальной памяти | 9 800 | 1 200 | Каждый элемент извлекается один раз на тайл |
| Пропускная способность shared memory (% от пика) | 52% | 99% | Шаблон доступа избегает конфликтов банков |
Падение пропускной способности DRAM с 90% до 25% — это не ухудшение, а улучшение. Наивное ядро жадно пожирает полосу, но большая часть байтов бесполезна. Тайловое ядро потребляет меньше полосы, потому что каждый байт используется многократно. Арифметическая интенсивность возрастает с 1,5 FLOP на байт до 8 FLOP на байт, и ядро переходит из режима «ограничено памятью» в режим «ограничено вычислениями».
Может возникнуть соблазн увеличить размер тайла до 64 × 64 или 128 × 128. Однако большие тайлы потребляют больше регистров и shared memory. Тайл 64 × 64 требует около 22 регистров на поток и ~16 КБ shared memory на входной тайл. Всегда проверяйте баланс между повторным использованием и занятостью через cudaOccupancyMaxPotentialBlockSize или отчёт Nsight Compute.
Тайлинг — переиспользование данных в shared memory. Каждый элемент загружается один раз и используется многократно.
Наивное ядро: 15 GFLOPS, 9 800 секторов. Тайловое: 170 GFLOPS, 1 200 секторов. Арифметическая интенсивность растёт с 1,5 до 8 FLOP/байт.
Балансируйте размер тайла с бюджетом регистров и shared memory. Проверяйте занятость (occupancy) через CUDA Occupancy API.
«На современных GPU NVIDIA, включая Blackwell, разделяемая память имеет 32 банка с шириной банка 4 байта (адреса отображаются по модулю 32). Как таковой, warp, шагающий на 128 байт (32 float × 4 байта), отображает все потоки в один банк. Если несколько потоков в warp'е обращаются к одному банку, возникает конфликт банков. Это заставляет обращения к памяти сериализоваться, что сводит на нет преимущество в скорости разделяемой памяти».
Разделяемая память на GPU разбита на 32 банка с шириной 4 байта (адреса отображаются по модулю 128 байт). Если несколько потоков warp'а обращаются к одному банку, возникает конфликт — обращения сериализуются. 32-сторонний конфликт означает, что 32 параллельных чтения превращаются в 32 последовательных, уничтожая преимущество разделяемой памяти в скорости.
32 lane, 32 банка, каждый поток бьёт в свой банк — 1 цикл
Классический сценарий конфликта — транспозиция матрицы с тайлом 32 × 32. Запись в разделяемую память идёт по строкам (tile[ty][tx]) и объединена. Но чтение — по столбцам (tile[tx][ty]): при фиксированном threadIdx.x и варьируемом threadIdx.y все 32 потока попадают в один банк, потому что строки отстоят на 128 байт. Это полный 32-сторонний конфликт.
Чем больше потоков в одном банке — тем сильнее сериализация
Аналогичная проблема возникает при шаге, равном числу банков:
__shared__ float arr[32 * warpCount];
float x = arr[threadIdx.x * 32]; // 32-way conflict: все в банке 0
Одна лишняя колонка сдвигает строки в разные банки
tile[i][0], попадает в банк 0 вместе с остальными. Добавляем одну padding-колонку (32 × 33) — шаг становится 132 B, банки сдвигаются, конфликты исчезают.#define TILE_DIM 32
__global__ void transposeNaive(const float *idata,
float *odata, int width) {
__shared__ float tile[TILE_DIM][TILE_DIM];
int x = blockIdx.x * TILE_DIM + threadIdx.x;
int y = blockIdx.y * TILE_DIM + threadIdx.y;
tile[threadIdx.y][threadIdx.x] = idata[y * width + x];
__syncthreads();
odata[x * width + y] = tile[threadIdx.x][threadIdx.y];
}
#define TILE_DIM 32
#define PAD 1
__global__ void transposePadded(const float *idata,
float *odata, int width) {
__shared__ float tile[TILE_DIM][TILE_DIM + PAD];
int x = blockIdx.x * TILE_DIM + threadIdx.x;
int y = blockIdx.y * TILE_DIM + threadIdx.y;
tile[threadIdx.y][threadIdx.x] = idata[y * width + x];
__syncthreads();
odata[x * width + y] = tile[threadIdx.x][threadIdx.y];
}
| Метрика | До (без дополнения) | После (с дополнением) |
|---|---|---|
| Конфликты банков при загрузке shared memory | 4,8 млн | 0 |
| Использование shared memory (% от пика) | 52% | 100% |
| Доля простоев warp'ов (память) | 38% | 0,5% |
| Время выполнения ядра | 4 мс | 1,3 мс (в 3 раза) |
Альтернатива дополнению — swizzling, преобразование индексов, которое «перемешивает» потоки по банкам без увеличения расхода памяти:
int bank_idx = threadIdx.x ^ (threadIdx.y & 0x1F);
Библиотека CUTLASS от NVIDIA активно использует swizzling в своих итераторах тайлов. Дополнение проще, swizzling экономнее по памяти — выбор зависит от контекста.
Если все 32 потока в warp'е обращаются к точно одному и тому же адресу в одном банке, аппаратура выполнит широковещательную рассылку значения всем потокам за один цикл. Любой другой сценарий с двумя и более различными адресами в одном банке вызовет конфликт.
Конфликты банков — скрытая стоимость shared memory. Padding (33 вместо 32) или swizzling устраняют конфликты.
Без дополнения: 4,8 млн конфликтов, 52% shared memory throughput. С дополнением: 0 конфликтов, 100% throughput. Ускорение ядра — 3×.
Проверяйте bank conflicts в Nsight Compute. Если они есть — добавьте padding или используйте swizzling. Стоимость дополнения — ~3% памяти, выигрыш — до 3× по времени.
«Наиболее распространёнными являются интринсики __shfl_sync (shuffle). __shfl_sync позволяет транслировать значение от одного потока всем остальным потокам в warp'е. Вы также можете выполнять warp-уровневые сокращения без записи в разделяемую память».
Если потоки одного warp'а должны обменяться данными, разделяемая память — не единственный путь. Интринсики __shfl_sync позволяют передавать значения напрямую между регистрами потоков, минуя разделяемую память. Конфликты банков при этом невозможны — банки памяти не задействованы.
unsigned mask = __activemask();
float val = threadVal;
for (int offset = 16; offset > 0; offset >>= 1) {
float other = __shfl_down_sync(mask, val, offset);
val += other;
}
Каждый __shfl_down_sync напрямую читает регистр другой полосы, уменьшая число активных потоков вдвое на каждом шаге, пока полоса 0 не накопит полную сумму. За 5 итераций (16 → 8 → 4 → 2 → 1) все 32 потока получают общую сумму. Поскольку вся коммуникация остаётся в регистрах, нет трафика разделяемой памяти, нет конфликтов банков и нет лишних синхронизаций.
Ограничение: shuffle работает только внутри одного warp'а (32 потока). Для обмена между warp'ами по-прежнему нужна разделяемая или глобальная память с надлежащей синхронизацией.
Warp shuffle — обмен данными через регистры, а не через shared memory. Конфликты банков невозможны.
Shuffle работает только внутри одного warp'а. Для межwarp-обмена нужна shared memory или глобальная память.
Для редукций, префиксных сумм и обмена значениями внутри warp'а используйте __shfl_sync. Это быстрее и не создаёт конфликтов.
«Когда все потоки читают одни и те же значения или поток перечитывает данные, которые не меняются, неиспользование механизмов кэширования GPU может стать узким местом… Отметьте данные, доступные только для чтения, как const __restrict__, чтобы задействовать низколатентный кэш только для чтения, сокращая трафик DRAM и повышая активность SM».
На современных GPU загрузки из глобальной памяти автоматически кэшируются в L2 и часто в L1. Но есть специализированный путь данных только для чтения, который имеет более низкую задержку для широковещательных обращений и не вытесняет другие кэшированные данные. Это не то же самое, что 64-килобайтный кэш константной памяти — кэш только для чтения значительно больше и предназначен для больших массивов неизменяемых данных, таких как векторы внедрения (embedding) в NLP-моделях.
__global__ void lookup(const float* __restrict__ table,
float* out, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
int t = idx % T;
out[idx] = table[t];
}
}
Без __restrict__ компилятор не может доказать, что table не алиасит с out, и использует обычный путь глобальной памяти. С __restrict__ загрузки идут через кэш только для чтения, который поддерживает широковещание: если все 32 потока читают один и тот же адрес, значение доставляется за один цикл вместо 32 отдельных обращений.
| Метрика | До (без __restrict__) | После (с __restrict__) |
|---|---|---|
| Эффективность загрузки глобальной памяти | 52% | 97% |
| Пропускная способность DRAM | 600 ГБ/с | 200 ГБ/с |
| Пропускная способность чтения L2 | 1 500 ГБ/с | 1 800 ГБ/с |
| Активность SM % | 45% | 93% |
| Время выполнения ядра | 2,5 мс | 1,0 мс |
Кэш только для чтения — специализированный путь данных с более низкой задержкой для широковещательных обращений. Активируется через const __restrict__.
Без __restrict__: 52% load efficiency, 600 ГБ/с DRAM. С __restrict__: 97% load efficiency, 200 ГБ/с DRAM (снижение трафика). Ускорение ядра — 2,5×.
Помечайте read-only данные как const __restrict__. Это бесплатно и даёт до 2,5× ускорения для lookup-ядер.
«CUDA Pipeline API вместе с аппаратным движком Tensor Memory Accelerator (TMA) поднимают эту идею на уровень thread-block'а. Вместо того чтобы каждый warp использовал блоки загрузки/сохранения (LD/ST) SM для извлечения данных из глобальной памяти, вы можете вызвать движок TMA для асинхронного извлечения целого тайла из глобальной памяти в разделяемую память».
«TMA способна на массовые копии 1D–5D с произвольными шагами между глобальной и разделяемой памятью без блокировки конвейера инструкций SM».
Самый передовой приём — асинхронная передача данных с помощью Tensor Memory Accelerator (TMA). TMA — это аппаратный DMA-движок, который перемещает данные между глобальной памятью и разделяемой памятью без участия вычислительных блоков SM. Пока TMA загружает следующий тайл, SM вычисляет на предыдущем. TMA способна на массовые копии 1D–5D с произвольными шагами, включая объединение и многомерные передачи.
SM считает, TMA параллельно тянет следующий тайл
cuda::memcpy_async, и TMA асинхронно тащит тайлы HBM → SMEM. Пока TMA качает тайл N+1, SM считает над тайлом N (double buffering). Освобождаются LD/ST-юниты, скрывается ~800-цикловая latency HBM.Чтобы запустить передачу TMA, используется cuda::memcpy_async() вместе с cuda::pipeline:
cuda::memcpy_async(sharedBuf,
globalPtr + offset,
cuda::aligned_size_t<16>(bytes),
pipe);
pipe.producer_commit();
__shared__ float tile0[TILE_SIZE];
__shared__ float tile1[TILE_SIZE];
float* tiles[2] = { tile0, tile1 };
__shared__ cuda::pipeline_shared_state<
cuda::thread_scope_block, 2> state;
auto pipe = cuda::make_pipeline(
cuda::this_thread_block(), &state);
pipe.producer_acquire();
cuda::memcpy_async(tiles[0], global_ptr,
cuda::aligned_size_t<32>{bytes}, pipe);
pipe.producer_commit();
for (int t = 1; t < nTiles; ++t) {
pipe.consumer_wait();
processTile(tiles[(t - 1) & 1]);
pipe.consumer_release();
pipe.producer_acquire();
cuda::memcpy_async(tiles[t & 1],
global_ptr + t * TILE_SIZE,
cuda::aligned_size_t<32>{bytes}, pipe);
pipe.producer_commit();
}
pipe.consumer_wait();
processTile(tiles[(nTiles - 1) & 1]);
pipe.consumer_release();
| Метрика | До (без предвыборки) | После (асинхронная предвыборка) |
|---|---|---|
| Эффективность загрузки глобальной памяти | 23% | 99% |
| Среднее число секторов на запрос | 6,4 | 4,0 |
| Пропускная способность DRAM (% от пика) | 25% | 90% |
| Активность SM % | 62% | 100% |
| Время выполнения ядра | 18 мс | 7 мс |
TMA — аппаратный DMA-движок, освобождающий SM от перемещения данных. Double buffering скрывает задержку HBM за вычислениями.
Без TMA: 23% load efficiency, 18 мс. С TMA: 99% load efficiency, 7 мс. Ускорение ядра — 2,6×.
Для memory-bound ядер с тайловой обработкой используйте cuda::memcpy_async с TMA. Это освобождает LD/ST-юниты для вычислений.
Все приёмы можно свести к нескольким принципам:
float4 (16 байт на поток) на Hopper или 32-байтовые векторы на Blackwell (CUDA 13). Проверяйте выравнивание указателей — cudaMalloc возвращает выравнивание не менее 256 байт, но смещения могут его нарушить.__shfl_sync, __shfl_down_sync, __shfl_xor_sync. Конфликты банков невозможны, синхронизация не нужна.const __restrict__ — это включает кэш только для чтения и сокращает DRAM-трафик в 3 раза.cuda::memcpy_async с TMA и двойной буферизацией скрывает задержку HBM за вычислениями.Чек-лист из восьми пунктов: coalescing → vectorization → tiling → bank conflicts → shuffle → read-only cache → TMA → profiling.
Проходите по чек-листу последовательно. Каждый пункт подтверждается метриками Nsight Compute.
Глава 7 книги Fregly была написана в 2025 году. С тех пор появились новые данные, которые позволяют проверить прогнозы и уточнить цифры.
В главе TMA рекомендуется для асинхронной загрузки тайлов. Открытые данные подтверждают: на Blackwell TMA стала стандартом для высокопроизводительных ядер. CUTLASS, Triton и TorchInductor генерируют код с использованием TMA. NVIDIA продолжает развивать TMA в Rubin — с ещё более широкими возможностями.
В главе упоминается поддержка 32-байтовых векторных загрузок на Blackwell (CUDA 13). Это подтверждено: sm_100 поддерживает ld.global.v8.f32 и st.global.v8.f32. На Hopper (sm_90) максимальная ширина — 16 байт.
В главе указано 8 ТБ/с на B200. Открытые данные: B200 — 8 ТБ/с HBM3e, B300 — 8 ТБ/с HBM3e, Rubin — 22 ТБ/с HBM4. Пропускная способность памяти растёт, но всё ещё отстаёт от роста вычислительной способности.
В главе TMA даёт 2,6× ускорения в примере. Открытые данные: документация CUTLASS указывает на «nearly optimal utilization of peak theoretical throughput» для тюнингованных GEMM-ядер. Внимание к TMA растёт с каждым поколением GPU.
Глава не упоминает FP4 + микрошкалирование (NVFP4) для памяти. В 2025 году Blackwell добавил аппаратную поддержку NVFP4 с блочным микрошкалированием. Это позволяет хранить веса в 4-битном формате с сохранением точности, что вдвое сокращает трафик памяти. Также появилась аппаратная декомпрессия — она подробно рассмотрена в обзоре главы 5.
Производительность GPU-ядра определяется не количеством арифметических инструкций, а тем, насколько хорошо оно использует иерархию памяти. Ядро, которое при каждом умножении обращается к HBM, будет тратить 800 циклов на ожидание — независимо от того, насколько быстр его вычислительный блок. Ядро, которое загружает тайл один раз, переиспользует его через shared memory, обменивается значениями через регистры и асинхронно предзагружает следующий тайл через TMA, превращает эти 800 циклов ожидания в 800 циклов полезной работы.
Переход от «ограничено памятью» к «ограничено вычислениями» — это и есть цель оптимизации. Когда узкое место смещается с памяти на арифметику, производительность ядра начинает приближаться к теоретическому пику GPU. Каждый из описанных приёмов — объединение, векторизация, тайлинг, устранение конфликтов банков, shuffle, кэш только для чтения, TMA-конвейеры — это один шаг на этом пути. И каждый шаг подтверждается конкретными метриками профилировщика.
Память — не «просто ещё один ресурс». На GPU она определяет, будет ли вычислительный блок простаивать или работать на пике. Объединяйте, векторизуйте, тайлируйте, устраняйте конфликты, используйте TMA — и ваши ядра перестанут ждать данные, начав считать.
Цель оптимизации памяти — сместить узкое место с HBM на арифметику. Все приёмы главы служат этой цели.
Профилируйте каждое изменение. Nsight Compute покажет, сдвинулось ли узкое место.
Источники: Chris Fregly, «AI Systems Performance Engineering» (O'Reilly, 2026), Глава 7 — цитаты приводятся в ограниченном объёме в целях обзора и критического анализа. Документация NVIDIA CUDA C++ Programming Guide; NVIDIA Nsight Compute documentation; NVIDIA Blackwell architecture whitepaper; CUTLASS documentation; Triton documentation.
This material is a review of Chapter 7 of Chris Fregly, «AI Systems Performance Engineering: Optimizing Model Training and Inference Workloads with GPUs, CUDA, and PyTorch», O'Reilly Media, 2026 (Copyright © 2026 Flux Capacitor, LLC).
Direct quotes from the book are marked with separate blocks labeled «Quote from the book». The rest of the text is a systematized exposition of the chapter's content, a comparison with current data about the platform, verification of forecasts against open sources, and additional observations that go beyond the chapter's text. Quotes are provided in limited volume for review and critical analysis purposes.
Modern NVIDIA GPUs — from Hopper to Blackwell — deliver tens of teraflops in FP32 and even more with Tensor Cores. But between the theoretical peak and real performance there's one barrier: the memory subsystem. Data must reach compute units fast enough to keep the processor busy. And this is exactly where most kernels lose performance — not from a shortage of arithmetic, but from inefficient memory access.
«As AI models grow in size and complexity, a GPU's memory system often becomes the bottleneck that stands between theoretical compute capability and real-world performance».
«On a Blackwell GPU, per-device HBM3e bandwidth is up to 8 TB/s. Within the Grace Blackwell GB200 and GB300 (two GPU superchips), this increases to 16 TB/s across both GPUs. Using uncoalesced memory accesses will leave most of this bandwidth unused due to excess memory transactions and stalls».
In the chapter, it is emphasized that memory optimization is not one technique but a sequence of steps, each removing the next barrier. First, load coalescing solves the problem of fragmented transactions. Then vectorization reduces instruction count. Tiling eliminates redundant loads. Eliminating bank conflicts removes serialization of shared memory. Warp shuffle replaces memory exchange with register exchange. The read-only cache reduces DRAM load. And finally, TMA with async pipelines hides HBM latency behind computation.
Each step is confirmed by concrete Nsight Compute metrics: Global Memory Load Efficiency, Average Sectors per Request, DRAM throughput, SM Active %, bank conflicts, warp stall reasons. The chapter consistently shows how these metrics change when moving from naive to optimized code.
The key idea of the chapter: GPU kernel performance is determined not by the number of arithmetic instructions but by how well the kernel uses the memory hierarchy. A kernel that hits HBM on every multiply will spend 800 cycles waiting. A kernel that loads a tile once, reuses it through shared memory, exchanges values through registers, and asynchronously prefetches the next tile via TMA turns those 800 waiting cycles into 800 cycles of useful work.
The GPU has a multi-level memory hierarchy, and the cost of accessing each level differs by orders of magnitude. Registers are the fastest level: latency is one cycle, but capacity is limited (up to 65,536 32-bit registers per SM on Blackwell). Shared memory offers ~30 cycles of latency and up to 228 KB per SM. L2 cache, shared across all SMs, adds roughly 200 cycles. Finally, global memory — HBM3e on Blackwell — has ~800 cycles of latency, but the largest capacity: up to 192 GB per device and up to 8 TB/s bandwidth (16 TB/s in dual-chip Grace Blackwell configurations).
The key observation: HBM access latency is roughly 800× that of a register. If every warp waits 800 cycles per load, throughput collapses. All optimization therefore reduces to one principle: touch HBM less, touch the near levels of the hierarchy more, and don't let the GPU sit idle waiting for data.
Every technique below is illustrated with CUDA C++ and PyTorch code, plus Nsight Compute profiler metrics — so you see not only what to change, but why it helps.
GPU memory is the main bottleneck of modern AI workloads. HBM latency (~800 cycles) is orders of magnitude higher than register latency (1 cycle).
HBM3e on Blackwell: up to 8 TB/s per GPU, 192 (180 usable) GB on B200, 16 TB/s in dual-chip configurations. Memory bandwidth grows slower than compute capability.
The optimization task is to shift the balance from HBM to registers, shared memory, and L2. Every technique in the chapter is a step in this direction.
«The memory access pattern of your code can greatly impact performance. Global memory accesses are fastest when threads in a warp access contiguous memory addresses that the hardware can combine into fewer, larger transactions. If threads access scattered or misaligned addresses, the device cannot coalesce requests into the minimal number of cache line transactions, which on modern GPUs are 128-byte lines composed of four 32-byte sectors. This results in many more memory transactions retrieving unused data, which quickly eats up memory bandwidth».
Global memory access is fastest when the 32 threads of a warp read a contiguous memory region. The GPU hardware can merge 32 separate requests into the minimum number of 128-byte transactions — with perfect alignment this becomes one transaction per warp. Each 128-byte cache line consists of four 32-byte sectors, and optimal access fits exactly four sectors per request.
If threads scatter across non-contiguous addresses — strided indexing, irregular access, unaligned start — the hardware can't merge requests. Instead of one wide transaction, the warp issues many small ones, each pulling a 32-byte sector from HBM while only a fraction of those bytes are useful. In the worst case, sectors per request can approach 32.
32 lane requests merge into one 128-byte transaction — or explode into many
In Nsight Compute's Memory Workload Analysis you'll see lower Global Memory Load Efficiency, more DRAM sector reads, and average sectors per request above 4.0. DRAM throughput percentage stays well below peak, confirming that warps are burning cycles waiting on memory instead of feeding the ALUs.
The stride directly determines the transaction count per warp
To break out of the memory-bound regime, reorganize data so that each of the warp's 32 threads loads contiguous elements. Either index as input[idx], where idx = blockIdx.x * blockDim.x + threadIdx.x, or switch from array-of-structures (AoS) to structure-of-arrays (SoA) layout so thread i always touches element i.
Array of structures gives stride 3, structure of arrays gives stride 1
x y z | x y z | … — reading only x gives stride 3 and scattered access. SoA lays fields out in separate arrays — access becomes contiguous and coalescing works perfectly.#include <cuda_runtime.h>
#include <iostream>
__global__ void uncoalescedCopy(const float* __restrict__ in,
float* __restrict__ out,
int N, int stride) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
out[idx] = in[idx * stride];
}
}
import torch
def uncoalesced_copy(input_tensor, stride):
flat = input_tensor.contiguous().view(-1)
idx = torch.arange(0, flat.numel(), stride,
device=flat.device, dtype=torch.long)
return torch.index_select(flat, 0, idx)
In the uncoalesced version each warp's memory request fragments into an average of 8 separate 32-byte sectors. The access pattern spans two cache lines per warp, inflating transaction count and starving the memory pipeline. Nsight Compute reports only about 25% of steady-state peak DRAM throughput.
__global__ void coalescedCopy(const float* __restrict__ in,
float* __restrict__ out,
int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < n) {
out[idx] = in[idx];
}
}
The coalesced PyTorch version is simply out = inp.clone(), since clone() on a contiguous tensor uses vectorized memory copy under the hood. You can also apply torch.compile with mode="max-autotune" to let TorchInductor pick coalesced and vectorized schedules.
| Metric | Before (uncoalesced) | After (coalesced) |
|---|---|---|
| DRAM throughput (% of peak) | 25% | 90% (3.6×) |
| Global memory load efficiency | 23% | 99% |
| Average sectors per request | 8.0 | 4.0 (optimal) |
| SM activity % | 62% | 99% |
| Kernel execution time | 4.8 ms | 1.3 ms (3.7×) |
Load coalescing is the basic level of memory optimization. GPU hardware can merge 32 warp requests into one 128-byte transaction, but only if threads access contiguous addresses.
Uncoalesced: 25% DRAM throughput, 8 sectors per request. Coalesced: 90% DRAM throughput, 4 sectors per request. Kernel speedup — 3.7×.
Check Global Memory Load Efficiency in Nsight Compute. If it's below 90% — reorganize data (SoA instead of AoS, contiguous indexing).
«While memory coalescing is a runtime hardware optimization on NVIDIA GPUs, vectorized memory access is a compile-time strategy in which each load or store instruction explicitly fetches multiple contiguous elements (e.g., float4, or 16 bytes) per thread. This reduces instruction count and eliminates stitching overhead».
Coalescing is a runtime hardware optimization: the GPU itself fuses 32 four-byte requests into one wide transaction. But underneath there are still 32 separate load instructions, one per thread. Vectorization removes this overhead: each thread reads not one float but four at once — via the float4 type (16 bytes).
When all 32 warp threads issue a float4 load, together they fetch 32 × 16 = 512 bytes of contiguous data, which the memory controller splits into exactly four 128-byte transactions. Each transaction consists of four fully-used 32-byte sectors — perfect 4.0 sectors per transaction. Compared to the scalar case, the number of load instructions per thread drops 4×.
To use vectorized loads, data pointers must be aligned to the vector width. CUDA allocators like cudaMalloc return pointers aligned to at least 256 bytes. However, adding an offset may break alignment:
auto ptr4 = reinterpret_cast<const float4*>(ptr);
__global__ void copyVector16B(const float4* __restrict__ in,
float4* __restrict__ out,
int N4) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N4) {
out[idx] = in[idx];
}
}
On Blackwell a wider vector is available — 32 bytes per thread (8 floats). CUDA doesn't provide a built-in float8 type, so define your own struct with 32-byte alignment:
struct alignas(32) float8 { float v[8]; };
__global__ void copyVector32B(const float8* __restrict__ in,
float8* __restrict__ out, int N8) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N8) {
out[idx] = in[idx];
}
}
With 32-byte per-thread loads, a warp moves 1024 bytes (32 threads × 32 bytes), mapping to 8 × 128-byte lines. On Hopper the same operation requires two 16-byte loads per thread, since Hopper doesn't support 32-byte vector instructions.
| Metric | Before (scalar) | After (vectorized) |
|---|---|---|
| Global memory load efficiency | 28% | 97% |
| Average sectors per request | 31.8 | 4.0 |
| DRAM throughput (% of peak) | 25% | 90% (3.6×) |
| Kernel execution time | 4.2 ms | 1.2 ms (3.5×) |
Vectorization is reducing instruction count by widening the vector. float4 (16 bytes) on Hopper, 32-byte vectors on Blackwell.
Scalar: 28% load efficiency, 31.8 sectors per request. Vectorized: 97% load efficiency, 4.0 sectors. Kernel speedup — 3.5×.
Check pointer alignment. cudaMalloc returns at least 256-byte alignment, but offsets can break it. On Blackwell use 32-byte vectors with alignas(32).
«A common performance pitfall is repeatedly reading the same data from global memory. Tiling is a technique to avoid this by loading chunks of data into faster on-chip shared memory—and reusing those chunks across many threads».
Coalescing and vectorization speed up the transfer itself but don't solve the re-loading problem. Naive N × N matrix multiplication loads every element of A from HBM N times — once for each row of B it's multiplied against. That's N–1 redundant loads per element.
Tiling solves it: a block of threads loads a 32 × 32 sub-matrix (tile) of A and B into shared memory exactly once, then all block threads use that data for 32 × 32 = 1024 multiply-accumulates. Each A and B element is now loaded from HBM once per tile instead of 32 times.
#define TILE_SIZE 32
__global__ void tiledMatMul(const float* A, const float* B,
float* C, int N) {
__shared__ float sA[TILE_SIZE][TILE_SIZE];
__shared__ float sB[TILE_SIZE][TILE_SIZE];
int row = blockIdx.y * TILE_SIZE + threadIdx.y;
int col = blockIdx.x * TILE_SIZE + threadIdx.x;
float sum = 0.0f;
for (int t = 0; t < N; t += TILE_SIZE) {
if (row < N && (t + threadIdx.x) < N)
sA[threadIdx.y][threadIdx.x] = A[row * N + t + threadIdx.x];
else sA[threadIdx.y][threadIdx.x] = 0.0f;
if ((t + threadIdx.y) < N && col < N)
sB[threadIdx.y][threadIdx.x] = B[(t + threadIdx.y) * N + col];
else sB[threadIdx.y][threadIdx.x] = 0.0f;
__syncthreads();
for (int k = 0; k < TILE_SIZE; ++k)
sum += sA[threadIdx.y][k] * sB[k][threadIdx.x];
__syncthreads();
}
if (row < N && col < N) C[row * N + col] = sum;
}
| Metric | Before (naive) | After (tiled) | Notes |
|---|---|---|---|
| DRAM throughput (% of peak) | 90% | 25% | Tiled kernel is efficient — bytes reused |
| Achieved occupancy (%) | 42% | 89% | More resident warps |
| FP throughput | 15 GFLOPS | 170 GFLOPS | Reuse boosts sustained compute |
| Global memory load sectors | 9,800 | 1,200 | Each element fetched once per tile |
| Shared memory throughput (% of peak) | 52% | 99% | Access pattern avoids bank conflicts |
The drop in DRAM throughput from 90% to 25% is not a regression — it's the win. The naive kernel greedily consumes bandwidth but most bytes are useless. The tiled kernel consumes less bandwidth because each byte is used many times. Arithmetic intensity rises from 1.5 FLOP/byte to 8 FLOP/byte, and the kernel shifts from memory-bound to compute-bound.
It's tempting to grow tiles to 64 × 64 or 128 × 128 for even more reuse. But larger tiles consume more registers and shared memory. A 64 × 64 tile needs ~22 registers per thread and ~16 KB shared memory per input tile. Always balance reuse against occupancy using cudaOccupancyMaxPotentialBlockSize or Nsight Compute reports.
Tiling is reusing data in shared memory. Each element is loaded once and used many times.
Naive: 15 GFLOPS, 9,800 sectors. Tiled: 170 GFLOPS, 1,200 sectors. Arithmetic intensity rises from 1.5 to 8 FLOP/byte.
Balance tile size with register and shared memory budgets. Check occupancy via CUDA Occupancy API.
«On modern NVIDIA GPUs, including Blackwell, shared memory has 32 banks with a 4-byte bank width (i.e., addresses map mod 32). As such, a warp that strides by 128 B (32 floats × 4 B) maps all threads to the same bank. If multiple threads in a warp access the same bank, a bank conflict occurs. This forces the memory accesses to serialize, which negates the speed advantage of shared memory».
Shared memory on GPUs is split into 32 banks, each 4 bytes wide (addresses map modulo 128 bytes). If multiple warp threads access the same bank, a conflict occurs and accesses serialize. A 32-way conflict turns 32 parallel reads into 32 sequential ones, destroying shared memory's latency advantage.
32 lanes, 32 banks, each thread hits its own bank — 1 cycle
The classic conflict scenario is a 32 × 32 matrix transpose tile. Writes to shared memory go by rows (tile[ty][tx]) and coalesce. Reads go by columns (tile[tx][ty]): with threadIdx.x fixed and threadIdx.y varying, all 32 threads land in the same bank, because rows are 128 bytes apart. That's a full 32-way conflict.
The more threads hit one bank, the worse the serialization
A similar problem arises when the stride equals the bank count:
__shared__ float arr[32 * warpCount];
float x = arr[threadIdx.x * 32]; // 32-way conflict: all in bank 0
One extra column shifts each row into a different bank
tile[i][0] lands in bank 0 together with everyone else. Add one padding column (32 × 33) — the stride becomes 132 B, banks shift, conflicts vanish.#define TILE_DIM 32
__global__ void transposeNaive(const float *idata,
float *odata, int width) {
__shared__ float tile[TILE_DIM][TILE_DIM];
int x = blockIdx.x * TILE_DIM + threadIdx.x;
int y = blockIdx.y * TILE_DIM + threadIdx.y;
tile[threadIdx.y][threadIdx.x] = idata[y * width + x];
__syncthreads();
odata[x * width + y] = tile[threadIdx.x][threadIdx.y];
}
#define TILE_DIM 32
#define PAD 1
__global__ void transposePadded(const float *idata,
float *odata, int width) {
__shared__ float tile[TILE_DIM][TILE_DIM + PAD];
int x = blockIdx.x * TILE_DIM + threadIdx.x;
int y = blockIdx.y * TILE_DIM + threadIdx.y;
tile[threadIdx.y][threadIdx.x] = idata[y * width + x];
__syncthreads();
odata[x * width + y] = tile[threadIdx.x][threadIdx.y];
}
| Metric | Before (no pad) | After (padded) |
|---|---|---|
| Shared memory bank conflicts | 4.8 M | 0 |
| Shared memory throughput (% of peak) | 52% | 100% |
| Warp stall (memory) | 38% | 0.5% |
| Kernel execution time | 4 ms | 1.3 ms (3×) |
An alternative to padding is swizzling — an index transformation that shuffles threads across banks without spending extra memory:
int bank_idx = threadIdx.x ^ (threadIdx.y & 0x1F);
NVIDIA's CUTLASS library uses swizzling heavily in its tile iterators. Padding is simpler, swizzling saves memory — the choice depends on context.
If all 32 warp threads read exactly the same address in one bank, the hardware broadcasts the value to all threads in a single cycle. Any other pattern with two or more distinct addresses in the same bank triggers a conflict.
Bank conflicts are the hidden cost of shared memory. Padding (33 instead of 32) or swizzling eliminates conflicts.
Without padding: 4.8M conflicts, 52% shared memory throughput. With padding: 0 conflicts, 100% throughput. Kernel speedup — 3×.
Check bank conflicts in Nsight Compute. If present — add padding or use swizzling. Padding costs ~3% memory, reward up to 3× in time.
«The most common are the __shfl_sync intrinsics (shuffle). __shfl_sync lets you broadcast a value from one thread to all other threads in a warp. You can also perform warp-level reductions without ever writing to shared memory».
If threads within a warp need to exchange data, shared memory isn't the only path. The __shfl_sync intrinsics let values pass directly between thread registers, bypassing shared memory. Bank conflicts are impossible — no memory banks are involved.
unsigned mask = __activemask();
float val = threadVal;
for (int offset = 16; offset > 0; offset >>= 1) {
float other = __shfl_down_sync(mask, val, offset);
val += other;
}
Each __shfl_down_sync directly reads another lane's register, halving the active lanes each step until lane 0 accumulates the full sum. In 5 iterations (16 → 8 → 4 → 2 → 1) all 32 threads receive the total. Because all communication stays in registers, there's no shared memory traffic, no bank conflicts, and no extra synchronization.
Restriction: shuffle works only within one warp (32 threads). Inter-warp communication still requires shared or global memory with proper synchronization.
Warp shuffle is register-level communication. Bank conflicts are impossible.
Shuffle works only within one warp. Inter-warp communication requires shared or global memory.
For reductions, prefix sums, and value exchange within a warp, use __shfl_sync. It's faster and creates no conflicts.
«When all threads read the same values or a thread rereads data that does not change, failing to use the GPU's caching mechanisms can bottleneck performance… Mark read-only data as const __restrict__ to tap into the low-latency read-only cache, cutting DRAM traffic and lifting SM activity».
On modern GPUs, global memory loads are automatically cached in L2 and often in L1. But there's a dedicated read-only data path with lower latency for broadcast access, and it doesn't evict other cached data. This is not the same as the 64 KB constant cache — the read-only cache is much larger and meant for big immutable arrays like embedding tables in NLP models.
__global__ void lookup(const float* __restrict__ table,
float* out, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < N) {
int t = idx % T;
out[idx] = table[t];
}
}
Without __restrict__ the compiler can't prove table doesn't alias out, so it uses the ordinary global memory path. With __restrict__ loads go through the read-only cache, which supports broadcast: if all 32 threads read the same address, the value arrives in one cycle instead of 32 separate accesses.
| Metric | Before (no __restrict__) | After (with __restrict__) |
|---|---|---|
| Global memory load efficiency | 52% | 97% |
| DRAM throughput | 600 GB/s | 200 GB/s |
| L2 read throughput | 1,500 GB/s | 1,800 GB/s |
| SM activity % | 45% | 93% |
| Kernel execution time | 2.5 ms | 1.0 ms |
The read-only cache is a specialized data path with lower latency for broadcast access. Activated via const __restrict__.
Without __restrict__: 52% load efficiency, 600 GB/s DRAM. With __restrict__: 97% load efficiency, 200 GB/s DRAM (traffic reduction). Kernel speedup — 2.5×.
Mark read-only data as const __restrict__. It's free and delivers up to 2.5× speedup for lookup kernels.
«CUDA's Pipeline API together with the Tensor Memory Accelerator (TMA) hardware engine take this idea to the thread-block level. Instead of having each warp use the SM's load and store (LD/ST) units to fetch data from global memory, you can invoke the TMA engine to asynchronously fetch an entire tile from global memory into shared memory».
«TMA is capable of 1D–5D bulk copies and arbitrary strides between global and shared memory without blocking the SM instruction pipeline».
The most advanced technique is asynchronous data transfer with the Tensor Memory Accelerator (TMA). TMA is a hardware DMA engine that moves data between global and shared memory without SM compute blocks. While TMA loads the next tile, the SM computes on the previous one. TMA can handle bulk 1D–5D copies with arbitrary strides, including coalescing and multidimensional transfers.
SM computes, TMA pulls the next tile in parallel
cuda::memcpy_async and TMA asynchronously pulls tiles HBM → SMEM. While TMA fetches tile N+1, the SM computes on tile N (double buffering). LD/ST units are freed, the ~800-cycle HBM latency is hidden.To launch a TMA transfer, use cuda::memcpy_async() with cuda::pipeline:
cuda::memcpy_async(sharedBuf,
globalPtr + offset,
cuda::aligned_size_t<16>(bytes),
pipe);
pipe.producer_commit();
__shared__ float tile0[TILE_SIZE];
__shared__ float tile1[TILE_SIZE];
float* tiles[2] = { tile0, tile1 };
__shared__ cuda::pipeline_shared_state<
cuda::thread_scope_block, 2> state;
auto pipe = cuda::make_pipeline(
cuda::this_thread_block(), &state);
pipe.producer_acquire();
cuda::memcpy_async(tiles[0], global_ptr,
cuda::aligned_size_t<32>{bytes}, pipe);
pipe.producer_commit();
for (int t = 1; t < nTiles; ++t) {
pipe.consumer_wait();
processTile(tiles[(t - 1) & 1]);
pipe.consumer_release();
pipe.producer_acquire();
cuda::memcpy_async(tiles[t & 1],
global_ptr + t * TILE_SIZE,
cuda::aligned_size_t<32>{bytes}, pipe);
pipe.producer_commit();
}
pipe.consumer_wait();
processTile(tiles[(nTiles - 1) & 1]);
pipe.consumer_release();
| Metric | Before (no prefetch) | After (async prefetch) |
|---|---|---|
| Global memory load efficiency | 23% | 99% |
| Average sectors per request | 6.4 | 4.0 |
| DRAM throughput (% of peak) | 25% | 90% |
| SM activity % | 62% | 100% |
| Kernel execution time | 18 ms | 7 ms |
TMA is a hardware DMA engine that frees the SM from moving data. Double buffering hides HBM latency behind computation.
Without TMA: 23% load efficiency, 18 ms. With TMA: 99% load efficiency, 7 ms. Kernel speedup — 2.6×.
For memory-bound tiled kernels, use cuda::memcpy_async with TMA. It frees LD/ST units for compute.
All techniques reduce to a few principles:
float4 (16 bytes per thread) on Hopper, or 32-byte vectors on Blackwell (CUDA 13). Check pointer alignment — cudaMalloc returns at least 256-byte alignment but offsets can break it.__shfl_sync, __shfl_down_sync, __shfl_xor_sync. No bank conflicts, no synchronization needed.const __restrict__ — enables the read-only cache and cuts DRAM traffic 3×.cuda::memcpy_async with TMA and double buffering hides HBM latency behind compute.The eight-point checklist: coalescing → vectorization → tiling → bank conflicts → shuffle → read-only cache → TMA → profiling.
Go through the checklist sequentially. Each point is confirmed by Nsight Compute metrics.
Chapter 7 of Fregly's book was written in 2025. Since then, new data has emerged that allows verification of forecasts and clarification of figures.
The chapter recommends TMA for asynchronous tile loading. Open data confirms: on Blackwell, TMA has become the standard for high-performance kernels. CUTLASS, Triton, and TorchInductor generate code using TMA. NVIDIA continues to develop TMA in Rubin — with even wider capabilities.
The chapter mentions support for 32-byte vector loads on Blackwell (CUDA 13). This is confirmed: sm_100 supports ld.global.v8.f32 and st.global.v8.f32. On Hopper (sm_90) the maximum width is 16 bytes.
The chapter cites 8 TB/s on B200. Open data: B200 — 8 TB/s HBM3e, B300 — 8 TB/s HBM3e, Rubin — 22 TB/s HBM4. Memory bandwidth is growing, but still lags behind compute capability growth.
The chapter shows TMA giving 2.6× speedup in an example. Open data: CUTLASS documentation states «nearly optimal utilization of peak theoretical throughput» for tuned GEMM kernels. Attention to TMA grows with each GPU generation.
The chapter does not mention FP4 + microscaling (NVFP4) for memory. In 2025, Blackwell added hardware support for NVFP4 with block-wise microscaling. This allows storing weights in a 4-bit format while preserving accuracy, halving memory traffic. Hardware decompression also emerged — it is covered in detail in the review of Chapter 5.
GPU kernel performance is determined not by the number of arithmetic instructions but by how well the kernel uses the memory hierarchy. A kernel that hits HBM on every multiply will spend 800 cycles waiting — no matter how fast its compute block is. A kernel that loads a tile once, reuses it through shared memory, exchanges values through registers, and asynchronously prefetches the next tile via TMA turns those 800 waiting cycles into 800 cycles of useful work.
Moving from memory-bound to compute-bound is the goal of optimization. When the bottleneck shifts from memory to arithmetic, kernel performance approaches the theoretical GPU peak. Each of the techniques described — coalescing, vectorization, tiling, eliminating bank conflicts, shuffle, read-only cache, TMA pipelines — is a step on that path. And each step is backed by concrete profiler metrics.
Memory is not "just another resource". On the GPU it determines whether the compute block sits idle or runs at peak. Coalesce, vectorize, tile, eliminate conflicts, use TMA — and your kernels will stop waiting for data and start computing.
The goal of memory optimization is to shift the bottleneck from HBM to arithmetic. All techniques in the chapter serve this goal.
Profile every change. Nsight Compute will show whether the bottleneck has shifted.
Sources: Chris Fregly, «AI Systems Performance Engineering» (O'Reilly, 2026), Chapter 7 — quotes provided in limited volume for review and critical analysis purposes. NVIDIA CUDA C++ Programming Guide; NVIDIA Nsight Compute documentation; NVIDIA Blackwell architecture whitepaper; CUTLASS documentation; Triton documentation.