Настоящий материал представляет собой обзор тринадцатой главы книги 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).
Прямые цитаты из книги выделены отдельными блоками с пометкой «Цитата из книги». Остальной текст — систематизированное изложение содержания главы, сопоставление с актуальными данными, проверка прогнозов, а также технические детали, выходящие за пределы исходного текста и основанные на официальной документации PyTorch и открытых источниках. Цитаты приводятся в ограниченном объёме в целях обзора и критического анализа.
Тринадцатая глава книги Fregly посвящена практическому профилированию, тюнингу и масштабированию рабочих нагрузок PyTorch на GPU. Глава охватывает полный цикл: от инструментов профилирования (PyTorch Profiler, Nsight Systems, Nsight Compute, Linux perf) и методологии выявления узких мест до конкретных техник оптимизации (torch.compile, CUDA-графы, тюнинг аллокатора памяти, activation checkpointing, оптимизация конвейера данных) и масштабирования на несколько GPU и узлов (DDP, FSDP, tensor parallel, pipeline parallel). Завершается глава обсуждением непрерывного бенчмаркинга и защиты производительности от регрессий.
«Конвейеры обучения и инференса AI-моделей могут страдать от узких мест на каждом уровне стека: от накладных расходов интерпретатора Python и простоев при загрузке данных на стороне CPU до неэффективного использования CUDA-ядер и конфликтов за память GPU. Чтобы оптимизировать систему результативно, необходимо профилировать её на нескольких уровнях, применяя инструменты, охватывающие весь стек целиком».
Глава построена как практическое руководство. Сначала рассматриваются инструменты профилирования и методология выявления узких мест на примере MoE-модели. Затем — конкретные техники оптимизации, каждая из которых сопровождается примерами кода и результатами замеров. Далее — масштабирование на несколько GPU и узлов. Завершается глава вопросами непрерывного бенчмаркинга и защиты производительности от регрессий. Ключевая идея: производительность PyTorch-нагрузок — это не одна «серебряная пуля», а систематическая работа по профилированию и устранению узких мест на каждом уровне стека.
«NVIDIA Tools Extension (NVTX) — это лёгкий API для аннотирования диапазонов кода. NVTX-маркеры обозначают временные диапазоны на временной шкале профайлера и позволяют разным инструментам сопоставлять события одних и тех же фаз. Например, NVTX-диапазон "forward" появится и в трассировках PyTorch Profiler, и на временной шкале Nsight Systems. Это делает кросс-инструментальный анализ гораздо проще на разных уровнях стека».
NVTX-маркеры вставляются в код через CUDA C++, PyTorch или любую C++/Python-библиотеку, поддерживающую GPU NVIDIA. Многие библиотеки уже вставляют NVTX-маркеры за разработчика для критических участков кода, таких как train_step, forward, backward, optimizer_step. Но их можно добавлять и самостоятельно с помощью torch.profiler.record_function() и torch.cuda.nvtx.range_push().
| Инструмент | Область | Ключевые возможности | Типичное применение |
|---|---|---|---|
| PyTorch Profiler (Kineto) | Уровень операций (CPU/GPU) | Поддержка NVTX, запись форм тензоров, статистика памяти, экспорт трассировок, выявление graph breaks | Детальный разбор кода модели; медленные операции, накладные расходы запуска ядер |
| Nsight Systems (nsys) | Системная временная шкала (CPU, GPU, ОС, I/O) | Единая временная шкала потоков CPU и потоков GPU, интеграция с NVTX, поддержка многопроцессных запусков | Сквозной обзор конвейера; простои загрузчика, перекрытие CPU-GPU, синхронизация GPU |
| Nsight Compute (ncu) | Анализ отдельных GPU-ядер | Аппаратные метрики по ядрам, корреляция с исходным кодом, Roofline-анализ, отчёты по occupancy и пропускной способности | Глубокий анализ ядер после выявления горячих точек; определение memory-bound или compute-bound |
| PyTorch Memory Profiler | Использование памяти GPU по операциям | Временная шкала снимков памяти, пиковая память по операциям, интеграция с torch.cuda.memory_stats() | Диагностика фрагментации, неожиданно высокого потребления памяти |
| Linux perf | Профилирование CPU и системных событий | Сэмплирование циклов CPU, кэш-промахов, branch-misses, flame graphs, off-CPU анализ | Накладные расходы Python, простои при загрузке данных, проблемы планирования ОС |
| Holistic Trace Analysis (HTA) | Визуализация трассировок распределённого обучения | Браузерный анализатор Kineto-трассировок, агрегация трассировок нескольких воркеров, интеграция с Perfetto | Анализ multi-GPU/multinode выполнения; поиск дисбаланса, простоев, проверка перекрытия коммуникаций с вычислениями |
| Chrome trace / Perfetto | Офлайн-просмотр трассировок | Веб-интерфейсы для стандартных форматов трассировок, расширенная фильтрация (Perfetto SQL), обмен трассировками | Просмотр трассировок без специализированного ПО; дашборды и удалённая совместная работа |
| TorchEval | Метрики модели и логирование производительности | Стандартизированный API метрик (throughput, accuracy), простая интеграция | Логирование пропускной способности и латентности рядом с точностью во время обучения и оценки |
| ExecuTorch | Мобильные и edge-устройства | Экспорт модели, профилирование, отладка, анализ памяти, визуализация | Запуск PyTorch-моделей и сбор метрик на ограниченных платформах |
Начните с PyTorch Profiler для быстрого обзора на уровне операций. Если видны пробелы на временной шкале или простои GPU — перейдите к Nsight Systems. Если конкретное ядро доминирует — углубитесь в Nsight Compute. Для CPU-узких мест — Linux perf. Для много-GPU — HTA. Для обмена результатами — Perfetto.
NVTX-маркеры — единый язык аннотирования, который понимают все инструменты профилирования. Это делает кросс-инструментальный анализ сквозным.
Комбинация PyTorch Profiler (уровень операций) + Nsight Systems (системная временная шкала) + Nsight Compute (анализ ядер) покрывает все уровни стека.
Добавляйте NVTX-маркеры на верхнем уровне — для фаз итерации. Избыточные маркеры загрязняют временную шкалу и усложняют анализ.
В главе профилирование демонстрируется на примере модели трансформера со смесью экспертов (MoE). MoE — это LLM с несколькими экспертными слоями, где каждый эксперт представляет собой сеть прямого распространения. Маршрутизация токенов к экспертам управляется системой гейтинга. Архитектура богата на оптимизационные возможности: множество matmul среднего размера, кастомные ядра маршрутизации (dispatch/combine) и динамическое распределение токенов.
Сначала настраивается модель и входные данные. Используется Hugging Face Transformers для загрузки модели и токенизатора, модель перемещается на GPU, подготавливается небольшой батч:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_name = "..."
tokenizer = AutoTokenizer.from_pretrained(model_name)
device = torch.device("cuda")
model = AutoModelForCausalLM.from_pretrained(model_name).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, fused=True)
batch_size = 4
input_texts = ["MoEs are great."] * batch_size
enc = tokenizer(input_texts, return_tensors="pt", padding=True, truncation=True)
input_ids = enc.input_ids.to(device)
attention_mask = enc.attention_mask.to(device)
labels = input_ids.clone()
Чтобы не захватывать разовые затраты на настройку, модель прогревается несколькими итерациями перед профилированием. Это подготовит модель к анализу: скомпилирует JIT-ядра, заполнит кэши.
for _ in range(5):
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)
Теперь профилируется одна итерация обучения, высокоуровневые регионы помечаются через record_function и NVTX-диапазоны:
from torch import profiler
with profiler.profile(
activities=[profiler.ProfilerActivity.CPU, profiler.ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
with_stack=True,
with_flops=True
) as prof:
with profiler.record_function("train_step"):
torch.cuda.nvtx.range_push("forward")
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
torch.cuda.nvtx.range_pop()
torch.cuda.nvtx.range_push("backward")
loss.backward()
torch.cuda.nvtx.range_push("optimizer_step")
optimizer.step()
torch.cuda.nvtx.range_pop()
optimizer.zero_grad()
torch.cuda.nvtx.range_pop()
После выполнения можно изучить результаты на уровне операций, вызвав prof.key_averages().table(). Запрашивается топ-10 операций, отсортированных по self_cuda_time_total:
print(
prof.key_averages()
.table(
sort_by="self_cuda_time_total",
row_limit=10,
fields=["self_cuda_time_total", "calls"]
)
)
| Операция | Self CUDA total | Вызовов |
|---|---|---|
| aten::matmul | 43.00 мс | 128 |
| aten::linear | 35.50 мс | 64 |
| dispatch | 18.70 мс | 2 |
| combine | 12.10 мс | 2 |
| aten::layer_norm | 10.20 мс | 16 |
| aten::softmax | 5.70 мс | 4 |
| aten::scatter | 4.10 мс | 16 |
| aten::gather | 3.60 мс | 16 |
| aten::to | 2.90 мс | 8 |
| aten::add_ | 2.20 мс | 64 |
Главное узкое место — matmul в экспертных FFN, за ним следуют ядра dispatch и combine. Вместе они определяют время итерации обучения. Это означает, что для ускорения необходимо оптимизировать именно эти операции — либо напрямую, либо уменьшая количество их вызовов.
Вставленные NVTX-маркеры делают анализ временной шкалы в Nsight Systems простым. Чтобы агрегировать метрики по фазам, код профилируется через nsys со сводкой на основе NVTX:
nsys profile \
--output=profile \
--stats=true \
-t cuda,nvtx \
python train.py
nsys stats --report=nvtx_gpu_proj_sum profile.nsys-rep
| NVTX-диапазон | GPU-время (мс) | Self GPU-время (мс) | Экземпляров |
|---|---|---|---|
| train_step | 138.0 | 0.0 | 1 |
| forward | 60.5 | 60.5 | 130 |
| backward | 58.3 | 58.3 | 130 |
| optimizer_step | 19.2 | 19.2 | 1 |
Механизм проекции NVTX — это не точная привязка ядра к диапазону, а статистическая оценка. CUPTI фиксирует, когда CPU вызвал push/pop NVTX-маркера, и какие GPU-ядра были запущены в этом временном окне на хосте.
PyTorch Profiler выявляет matmul в экспертных FFN как главное узкое место MoE-модели, за ним следуют dispatch и combine.
Nsight Systems с NVTX-разметкой даёт сводку GPU-времени по фазам итерации. Механизм проекции NVTX — статистическая оценка, а не точная привязка.
Начинайте с PyTorch Profiler для выявления горячих операций. Затем используйте Nsight Systems для анализа временной шкалы. Только после этого переходите к Nsight Compute для глубокого анализа конкретных ядер.
Для глубокого анализа экспертного matmul вызывается CLI-профайлер Nsight Compute (ncu), нацеленный на конкретные GEMM-ядра по имени:
ncu \
--target-processes all \
--kernel-name-regex "matmul" \
--metrics \
gpu__time_duration.avg, \
gpu__dram_throughput.avg.pct_of_peak_sustained_elapsed, \
lts__throughput.avg.pct_of_peak_sustained_elapsed, \
sm__sass_thread_inst_executed_op_fp32_pred_on.sum, \
sm__warps_active.avg.pct_of_peak_sustained_active \
--csv full \
-o matmul_roofline_report \
python train.py
| Конфигурация ядра | % пиковых FLOPS | % пиковой пропускной способности памяти | Достигнутая SM occupancy | Характеристика |
|---|---|---|---|---|
| Базовая | 50% | 70% | 60% | Memory bound (простои на передаче данных) |
| Оптимизированная | 85% | 40% | 80% | Compute bound (близко к аппаратному потолку) |
В базовом запуске GEMM-ядро достигает лишь 50% пиковых FLOPS, 70% пиковой пропускной способности памяти и умеренной 60% SM occupancy. Это недостаточно для полного скрытия задержек памяти. Метрики указывают на memory-bound ядро. После оптимизации (слияние ядер, рост арифметической интенсивности, снижение перемещения памяти) пиковые FLOPS растут до 85%, пропускная способность памяти падает до 40%, а occupancy растёт до 80%. Ядро сдвигается из memory-bound в compute-bound.
Многие высокопроизводительные ядра достигают полного скрытия задержек при 25–50% достигнутой occupancy. Используйте метрики occupancy Nsight Compute вместе с разбивкой причин простоев и числом готовых варпов на активный цикл, чтобы судить, действительно ли большее occupancy снизит простои.
Roofline-анализ Nsight Compute показывает, что GEMM-ядро MoE-модели изначально memory bound (50% FLOPS, 70% памяти). После оптимизаций оно становится compute bound (85% FLOPS, 40% памяти).
Нет универсальной целевой occupancy. Для каждого ядра существует свой оптимум, определяемый балансом latency hiding и ресурсов.
Используйте Roofline для диагностики: если ядро ниже наклонной линии — оптимизируйте доступ к памяти; если ниже горизонтальной — оптимизируйте вычисления.
Для более целостной картины того, где тратится время — на хосте и на устройстве — применяется Linux perf для анализа циклов CPU, кэш-промахов, branch-misses и т. д. Затем эти данные используются для серии оптимизаций.
perf stat -e \
cycles,instructions,cache-misses,branch-misses \
python train.py
perf record -F 2000 -g --call-graph dwarf -o perf.data \
python train.py
perf report --stdio -n -g -i perf.data
На основе профиля выявляются следующие узкие места и меры их устранения:
py::forward) — использовать torch.compile для устранения накладных расходов интерпретатора и слияния Python-операций в оптимизированный CUDA-код.num_workers DataLoader, включить persistent_workers=True.read) — pinned memory, non-blocking копии, контейнерные форматы (Arrow, WebDataset, TFRecord).Linux perf выявляет 45% накладных расходов Python и значительные затраты на синхронизацию градиентов (8.7%) и загрузку данных (10.2%).
Для CPU-узких мест используйте Linux perf. Для GPU-узких мест — Nsight. Комбинация даёт полную картину.
Применяйте оптимизации по одной, измеряя эффект каждой. Это позволяет изолировать влияние изменений и избежать неожиданных взаимодействий.
«Одна из самых быстрых побед в PyTorch — использовать компилятор через torch.compile(). Стек компилятора включает TorchDynamo, AOT Autograd и TorchInductor, которые захватывают графы, сливают операции и генерируют высокопроизводительный код для целевого бэкенда».
Использовать компилятор с настройками по умолчанию просто: model = torch.compile(model). Под капотом TorchDynamo трассирует Python-код, AOT Autograd захватывает backward-проход, а TorchInductor, опираясь на OpenAI Triton для генерации GPU-ядер, автоматически создаёт эффективные fused-ядра.
compiled_model = torch.compile(model, mode="reduce-overhead")
В этом случае eager-режим занимает примерно 248 мс на итерацию, а скомпилированный — около 173 мс. Ускорение примерно на 30% в основном от объединения множества мелких GPU-ядер.
| Режим | Описание | Время компиляции | Доп. память | Ключевые особенности |
|---|---|---|---|---|
| default | Сбалансированные оптимизации | Низкое–среднее | Нет | Базовое слияние, простой autotuning |
| reduce-overhead | Снижает накладные расходы на итерацию | Среднее | Да (кэш workspace) | Использует CUDA Graphs для устранения накладных расходов запуска |
| max-autotune | Максимизирует runtime-производительность | Высокое | Возможно | Агрессивный autotuning Triton, включает CUDA Graphs |
| max-autotune-no-cudagraphs | То же, но без CUDA-графов | Высокое | Нет | Для динамических форм или отладки |
Профиль PyTorch: скомпилированные и eager-участки графа
Когда torch.compile не даёт ожидаемого ускорения, используются инструменты диагностики. torch._dynamo.explain(model) выводит отчёт о graph breaks, причинах их возникновения и о том, какие части модели не были захвачены TorchDynamo. Переменная TORCH_LOGS="+dynamo,+inductor" включает подробное логирование компилятора.
import torch._dynamo as dynamo
def toy_example(a, b):
x = a / (torch.abs(a) + 1)
print("woo")
if b.sum() < 0:
b = -b
return x * b
explanation = dynamo.explain(toy_example)(torch.randn(10), torch.randn(10))
print(explanation)
Отчёт покажет количество графов, количество graph breaks и причины их возникновения. Для минимизации graph breaks используется torch.where() вместо Python-условий, а также torch.compiler.set_stance("fail_on_recompile") для принудительного выброса ошибки при обнаружении graph break.
torch.compile даёт ~30% ускорения за счёт слияния мелких GPU-ядер. Режим reduce-overhead дополнительно использует CUDA Graphs.
Для диагностики проблем компилятора используются torch._dynamo.explain() и TORCH_LOGS. Graph breaks выявляются и устраняются заменой Python-условий на тензорные операции.
Не все graph breaks вредны: для DDP и FSDP они ожидаемы и необходимы для перекрытия коммуникаций с вычислениями.
Трансформерные модели значительную часть времени проводят в механизмах внимания. PyTorch предоставляет несколько уровней оптимизации:
torch.nn.functional.scaled_dot_product_attention автоматически использует самое быстрое доступное ядро (FlashAttention, memory-efficient или cuDNN) в зависимости от форм тензоров и типов данных.Q_len=1) с учётом растущего KV-кэша.JIT-компиляция масок (block-sparse, sliding-window, causal) без ручных CUDA-ядер
SDPA автоматически выбирает лучший бэкенд. FlexAttention позволяет описывать кастомные паттерны разреженности без ручных CUDA-ядер.
FlexAttention с nested jagged tensors (NJT) устраняет паддинг для последовательностей переменной длины.
Проверяйте, какой бэкенд SDPA фактически используется, и при необходимости указывайте его явно через torch.nn.attention.sdpa_kernel.
CUDA-потоки обеспечивают конкурентность и перекрытие операций на GPU. По умолчанию PyTorch планирует все операции на стандартном потоке устройства (stream 0) последовательно. Однако многие задачи независимы, и GPU может выполнять их параллельно с несколькими потоками.
device = 'cuda'
transfer_stream = torch.cuda.Stream(device=device)
compute_stream = torch.cuda.default_stream(device=device)
dataloader_iter = iter(dataloader)
first_batch = next(dataloader_iter, None)
if first_batch:
with torch.cuda.stream(transfer_stream):
next_inputs, next_labels = (
first_batch[0].to(device, non_blocking=True),
first_batch[1].to(device, non_blocking=True),
)
for _ in range(len(dataloader)):
compute_stream.wait_stream(transfer_stream)
inputs, labels = next_inputs, next_labels
batch = next(dataloader_iter, None)
if batch:
with torch.cuda.stream(transfer_stream):
next_inputs, next_labels = (
batch[0].to(device, non_blocking=True),
batch[1].to(device, non_blocking=True),
)
with torch.cuda.stream(compute_stream):
outputs = model(inputs)
loss = loss_fn(outputs, labels)
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)
Отдельные CUDA-потоки: один готовит следующую партию, другой считает текущую
Вызов print() на CUDA-тензоре вызывает неявную синхронизацию (torch.cuda.synchronize()), которая останавливает все потоки. В production-коде следует избегать любых операций, читающих значения GPU-тензоров на CPU, без явной необходимости.
event = torch.cuda.Event(enable_timing=False)
with torch.cuda.stream(stream1):
kernel_launch(...)
event.record()
stream2.wait_event(event)
with torch.cuda.stream(stream2):
other_kernel_launch(...)
CUDA-потоки позволяют перекрывать передачу данных и вычисления. Отдельный transfer_stream готовит следующий батч, пока compute_stream обрабатывает текущий.
События CUDA обеспечивают точечную синхронизацию без глобальной блокировки. torch.cuda.Event(enable_timing=False) снижает накладные расходы.
Для профилирования многопоточных конвейеров используйте Nsight Systems. Проверяйте, что non_blocking=True и pinned memory используются корректно.
CUDA-графы устраняют накладные расходы на запуск на итерацию, снижают накладные расходы CPU и ликвидируют крошечные простои между ядрами. PyTorch предоставляет API torch.cuda.CUDAGraph для захвата и воспроизведения CUDA-графов.
g = torch.cuda.CUDAGraph()
capture_stream = torch.cuda.Stream()
static_input = torch.randn(batch_shape, device='cuda')
static_output = torch.empty(output_shape, device='cuda')
with torch.cuda.stream(capture_stream):
tmp = model(static_input)
static_output.copy_(tmp)
capture_stream.synchronize()
with torch.cuda.graph(g, stream=capture_stream):
tmp = model(static_input)
static_output.copy_(tmp)
capture_stream.synchronize()
Избегайте аллокации памяти во время захвата; используйте выделенный поток; синхронизируйтесь до и после захвата; помните, что граф перезаписывает выходной буфер на каждой итерации — клонируйте, если нужно сохранить.
| Метрика | До CUDA Graphs | После CUDA Graphs |
|---|---|---|
| CPU-вызовов на 100 итераций | 300 отдельных запусков ядер | 100 воспроизведений графа |
| Синхронизаций хоста | 300 cudaDeviceSynchronize | 0 |
| Простой GPU между ядрами | ~3 мкс на итерацию | 0 мкс (непрерывное выполнение) |
| Сквозная задержка итерации | ~1.00 мс | ~0.75 мс (25% быстрее) |
CUDA Graph Trees используются torch.compile в режиме reduce-overhead для компиляции и кэширования отдельных статических графов под каждую форму входа. Рекомендуется поддерживать формы входов максимально согласованными между итерациями.
CUDA-графы устраняют накладные расходы на запуск ядер, снижая задержку итерации на 25%. Графы перезаписывают выходной буфер, поэтому для сохранения результатов требуется клонирование.
CUDA Graph Trees в torch.compile(mode="reduce-overhead") кэшируют статические графы под каждую форму входа. Чем меньше различных форм — тем эффективнее.
Для стабильных форм и длительных нагрузок CUDA-графы дают значительный выигрыш. Для динамических форм используйте max-autotune-no-cudagraphs.
PyTorch использует кэширующий аллокатор памяти для CUDA. По умолчанию он адаптируется к паттернам аллокации, разбивая и переиспользуя блоки GPU-памяти. Ключевые параметры настройки через переменные окружения:
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:N — максимальный размер блока, который аллокатор может разделять. Указывает аллокатору сохранять большие свободные блоки (до 256 МБ) вместо постоянного дробления на мелкие фрагменты.PYTORCH_CUDA_ALLOC_CONF=roundup_power2_divisions:[N:M,...] — группировка запросов в фиксированные бакеты. Например, в диапазоне 512–1024 МБ с 512:2 запрос в 600 МБ округляется до 768 МБ. Это снижает фрагментацию и увеличивает переиспользование.PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync — асинхронный аллокатор CUDA. Устраняет глобальные синхронизации при аллокации и освобождении памяти.Пиковые и кумулятивные аллокации GPU-памяти по операциям
mem_get_info() показывает мало свободной памяти, а memory_stats() — что сумма аллоцированных тензоров значительно меньше общей памяти, фрагментация нарастает. В этом случае roundup_power2_divisions — ключевой инструмент.export PYTORCH_CUDA_ALLOC_CONF=\
max_split_size_mb:256,\
roundup_power2_divisions:[256:1,512:2,1024:4,:8],\
backend:cudaMallocAsync
Тюнинг аллокатора через max_split_size_mb и roundup_power2_divisions снижает фрагментацию и увеличивает переиспользование памяти.
Бэкенд cudaMallocAsync устраняет глобальные синхронизации при аллокации. Мониторинг фрагментации через memory_reserved() и memory_allocated().
Для длительных нагрузок с переменными размерами тензоров настройка аллокатора даёт стабильное потребление памяти без OOM.
Для очень больших моделей activation checkpointing (gradient checkpointing) необходим для управления памятью. Вместо сохранения всех активаций forward-прохода сохраняются только входы каждого блока. При backward-проходе активации пересчитываются заново.
Не обязательно чекпоинтить всё. Можно сфокусироваться на самых больших слоях — обычно это блоки трансформера, которые держат массу активаций, но не меньшие слои вроде layer norm и эмбеддингов.
import torch
from torch.utils.checkpoint import checkpoint
class TransformerBlock(torch.nn.Module):
def forward(self, x):
return checkpoint(self._forward, x, use_reentrant=False)
def _forward(self, x):
return self.attention(self.mlp(x))
Селективный activation checkpointing позволяет выбирать, какие именно слои пересчитывать. Например, можно чекпоинтить каждый второй слой трансформера. Это даёт более гибкий компромисс между памятью и вычислениями.
Activation checkpointing обменивает вычисления на память. Селективный подход позволяет выбрать оптимальный компромисс.
Для MoE-моделей с большим числом экспертов checkpointing блоков трансформера даёт значительную экономию памяти.
Checkpointing обязателен для моделей, которые иначе не помещаются в память. Он позволяет использовать больший batch size.
Частая причина неэффективности — GPU простаивают в ожидании данных. DataLoader PyTorch поддерживает запуск нескольких воркеров для параллельной загрузки и предобработки данных, пока обучается модель.
pin_memory=True — передача через page-locked память. Ускоряет копирование CPU→GPU.non_blocking=True и persistent_workers=True — для скрытия копирования и избежания пересоздания воркеров между эпохами.prefetch_factor — сколько батчей каждый воркер предзагружает заранее. По умолчанию 2.loader = DataLoader(
dataset,
batch_size=B,
num_workers=8,
pin_memory=True,
persistent_workers=True,
prefetch_factor=4,
)
Для CPU-связанных конвейеров данных часто 2–8 воркеров на GPU достаточно. Не следуйте правилу «один воркер на CPU» вслепую — это часто даёт слишком много воркеров, конкурирующих за GIL.
pin_memory=True, persistent_workers=True и prefetch_factor — три ключевых параметра для предотвращения простоев GPU в ожидании данных.
Для CPU-связанных пайплайнов 2–8 воркеров на GPU достаточно. Избыток воркеров конкурирует за GIL.
Профилируйте DataLoader изолированно: замеряйте время на 100 батчей без GPU-работы. Сравнивайте с целевым временем итерации.
FSDP в PyTorch — стратегия распределённого параллелизма, шардирующая параметры модели, градиенты и активации между GPU. Технически FSDP реализует стратегию ZeRO Stage-3 для шардирования состояний модели между GPU.
import torch
import torch.nn as nn
import torch.distributed as dist
from torch.distributed.fsdp import (
FullyShardedDataParallel as FSDP,
CPUOffload,
ShardingStrategy,
MixedPrecision
)
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
dist.init_process_group("nccl")
torch.cuda.set_device(dist.get_rank() % torch.cuda.device_count())
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(4096, 4096),
nn.ReLU(),
nn.Linear(4096, 4096),
)
def forward(self, x):
return self.layers(x)
model = MyModel().cuda()
auto_wrap_policy = transformer_auto_wrap_policy(
model,
min_num_params=1e8,
)
fsdp_model = FSDP(
model,
auto_wrap_policy=auto_wrap_policy,
sharding_strategy=ShardingStrategy.FULL_SHARD,
use_orig_params=True,
cpu_offload=CPUOffload(offload_params=True, pin_memory=True),
)
Когда сеть медленная — или доступно всего несколько GPU — можно использовать ShardingStrategy.SHARD_GRAD_OP (ZeRO Stage-2) для шардирования только градиентов и состояния оптимизатора. На суперчиповых системах Grace-Blackwell доступны SuperOffload и GraceAdam.
Параметры, градиенты и состояния оптимизатора разбиваются между 4 GPU
FSDP реализует ZeRO Stage-3: параметры, градиенты и состояния оптимизатора шардируются между GPU. Шардирование на уровне блока трансформера даёт максимальную эффективность.
ShardingStrategy.FULL_SHARD (ZeRO-3) для минимальной памяти; SHARD_GRAD_OP (ZeRO-2) когда сеть медленная; HYBRID_SHARD для баланса памяти и коммуникаций.
Автоматический checkpointing и offloading через FSDP снижают потребление памяти без boilerplate-кода.
При использовании DistributedDataParallel, синхронизирующего градиенты между GPU через all-reduce, PyTorch автоматически создаёт graph breaks в точках синхронизации. Дизайн PyTorch — компилировать backward-вычисления каждого бакета как отдельный граф, чтобы между этими графами выполнять all-reduce.
Рекомендуется оборачивать каждый блок трансформера — а не просто отдельный низкоуровневый слой — в собственный FSDP-модуль с use_orig_params=True. Так TorchDynamo вставит graph break на каждой границе шарда, где нужна коммуникация.
Tensor parallel и pipeline parallel ортогональны torch.compile. Компилятор PyTorch в основном фокусируется на оптимизации вычислений внутри каждого сегмента. Рекомендуется использовать torch.compile для оптимизации вычислений внутри каждого слоя или бакета.
TorchTitan — набор референсных реализаций для крупномасштабного обучения. SimpleFSDP перереализует FSDP дружественным к torch.compile способом через DTensor. В экспериментах TorchTitan SimpleFSDP снизил потребление памяти до ~28%, а пропускную способность улучшил до ~69% по сравнению с традиционным FSDP2 eager-путём.
Graph breaks в DDP и FSDP — ожидаемы и необходимы для перекрытия коммуникаций с вычислениями. Компиляция каждого бакета отдельно — это дизайн, а не баг.
SimpleFSDP снижает память до ~28% и увеличивает пропускную способность до ~69% относительно FSDP2 eager (максимум на 2D-параллелизме; средний выигрыш — ~29% для 8B и ~28% для 70B).
Используйте torch.compile для оптимизации вычислений внутри каждого сегмента, но доверяйте distributed-стратегии управление коммуникациями.
При масштабировании на множество GPU управление трассировками может стать громоздким. HTA от Meta помогает объединять и визуализировать многорабочие трассировки. HTA поглощает JSON-трассировки от torch.profiler с каждой GPU/ранга и представляет единый временной график.
С HTA можно увидеть трассировки всех 8 GPU, выровненные по времени. NVTX-маркеры с каждого ранга выравниваются и визуализируются. Так можно заметить, что ранг 0 входит в backward-проход в момент T, а ранг 1 — только в T+1, возможно, потому что ранг 1 ждал ранг 0 в all-reduce.
from torch.profiler import profile, schedule, tensorboard_trace_handler
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=1, warmup=1, active=3, repeat=1),
on_trace_ready=tensorboard_trace_handler('./log'),
record_shapes=True,
profile_memory=True,
with_stack=True
) as prof:
for step, batch in enumerate(dataloader):
train_step(batch)
prof.step()
HTA агрегирует трассировки всех рангов в единый временной график, позволяя выявлять дисбаланс и синхронизационные задержки.
Используйте torch.profiler.schedule для записи трассировок на нескольких итерациях без перегрузки.
Для multinode-профилирования HTA — стандартный инструмент. TensorBoard trace viewer для этой задачи устарел.
После применения всех оптимизаций критически важно поддерживать их и непрерывно проверять на регрессии производительности. Следует настроить простую CI регрессии производительности с TorchBench и GitHub Actions для автоматического отлова замедлений.
- name: Run MoE benchmark
run: |
torchbench run --model moe --iters 10 --batch-size 4 --json results.json
- name: Compare throughput
run: |
python scripts/compare_perf.py baseline.json results.json
Публичный дашборд nightly-бенчмарков: throughput, память, регрессии по коммитам
CI с TorchBench и GitHub Actions автоматически выявляет регрессии производительности. Публичный PyTorch HUD — источник внешних сигналов.
Устойчивое падение пропускной способности ≥5% на трёх прогонах — сигнал для расследования.
Performance regression testing должен быть частью CI/CD. Без него оптимизации деградируют со временем.
«torch.compile даёт ~30% ускорения за счёт слияния мелких GPU-ядер».
Подтверждено в части распространения; цифра ~30% — книжная оценка. К 2026 году torch.compile стал стандартной практикой для production-нагрузок. PyTorch 2.x активно развивает компилятор, а TorchInductor генерирует высокооптимизированные ядра для NVIDIA GPU. Режимы reduce-overhead и max-autotune используются в production-кластерах. Реальный выигрыш от fusion сильно зависит от модели и режима компиляции: в публикациях Meta он обычно составляет 10–30%.
«FSDP реализует ZeRO Stage-3 для шардирования состояний модели между GPU».
Подтверждено. FSDP стал стандартом для обучения моделей, не помещающихся в память одного GPU. SimpleFSDP и TorchTitan демонстрируют дальнейшее развитие подхода. HYBRID_SHARD и SHARD_GRAD_OP используются для баланса памяти и коммуникаций.
«CUDA-графы устраняют накладные расходы на запуск ядер, снижая задержку итерации на 25%».
Подтверждено в части распространения; цифра 25% — книжная оценка. CUDA Graphs интегрированы в torch.compile(mode="reduce-overhead") и активно используются в inference-движках (vLLM, TensorRT-LLM). Ограничение — статические формы — остаётся, но CUDA Graph Trees решают эту проблему для дискретных наборов форм. Реальный выигрыш зависит от доли launch overhead в общем времени итерации и обычно составляет 10–30%.
«HTA агрегирует трассировки всех рангов в единый временной график».
Подтверждено. HTA активно используется в Meta и других организациях для анализа распределённого обучения. TensorBoard trace viewer устарел, и HTA с Perfetto стали стандартом. CUPTI Counter Analysis позволяет получать GPU performance counters.
«Не обязательно чекпоинтить всё. Можно сфокусироваться на самых больших слоях».
Частично подтверждено. Селективный checkpointing поддерживается в TorchTitan и torchtune. PyTorch предоставляет API для выбора конкретных слоёв. Однако универсальной стратегии нет — выбор зависит от архитектуры модели и доступной памяти.
Memory profiler и Mosaic. В главе рассматривается стандартный PyTorch memory profiler. Дополнительно Meta разработала Mosaic — инструмент для анализа снимков памяти, который позволяет выявлять утечки и фрагментацию на уровне отдельных аллокаций. Mosaic интегрируется с torch.cuda.memory._dump_snapshot().
CUDA Graph Trees и динамические формы. Хотя глава упоминает CUDA Graph Trees, механизм их работы с динамическими формами заслуживает отдельного внимания. Trees классифицируют каждое хранилище, живое на момент захвата, как приватную аллокацию пула или известный статический вход. Это позволяет переиспользовать графы для нескольких форм без полного перезахвата.
PyTorch HUD и регрессии. Публичный PyTorch HUD (hud.pytorch.org) отслеживает производительность torch.compile относительно eager-режима. Однако недавние проблемы с миграцией на AWS привели к периодам ненадёжности дашборда. Для критичных workloads рекомендуется собственный CI с бенчмарками.
NVIDIA Blackwell и профилирование. Для новых архитектур Blackwell (B200/B300) требуются обновлённые версии Nsight Systems и Nsight Compute. PyTorch 2.x поддерживает профилирование на Blackwell, но некоторые метрики (например, TMEM utilization) могут быть недоступны в старых версиях инструментов.
MLPerf Logging. Для стандартизированного логирования производительности в распределённых нагрузках используется библиотека mlperf_logging. Она позволяет записывать ключевые события (начало обучения, конец эпохи, финальная точность) в формате, совместимом с MLPerf. Это упрощает сравнение производительности между конфигурациями.
reduce-overhead использует CUDA Graphs.max_split_size_mb, roundup_power2_divisions и backend:cudaMallocAsync.pin_memory=True, persistent_workers=True, prefetch_factor.Тринадцатая глава книги Fregly — систематическое руководство по профилированию, тюнингу и масштабированию PyTorch. Ключевая идея: производительность — это не одна «серебряная пуля», а непрерывный цикл профилирования и устранения узких мест на каждом уровне стека. От NVTX-маркеров и Nsight до torch.compile, CUDA-графов, FSDP и HTA — каждый инструмент решает свою задачу. Для инженера это означает, что понимание методологии профилирования и принципов работы каждого инструмента важнее, чем знание конкретных цифр текущего поколения.
Проверка прогнозов показывает: книга оказалась точной в части распространения технологий. torch.compile стал стандартом; FSDP вытеснил DDP для больших моделей; CUDA-графы снижают накладные расходы; HTA заменил TensorBoard trace viewer. Конкретные проценты ускорения (30%, 25%) — книжные оценки, реальный выигрыш зависит от модели и режима. Главный вывод остаётся неизменным: инвестиции в методологию профилирования обеспечивают адаптацию к любому стеку.
Источники: Chris Fregly, «AI Systems Performance Engineering» (O'Reilly, 2026), Глава 13 — цитаты приводятся в ограниченном объёме в целях обзора и критического анализа. Официальная документация PyTorch (torch.profiler, torch.compile, FSDP, CUDA Graphs); NVIDIA Nsight Systems/Compute documentation; Holistic Trace Analysis (Meta); MLPerf Training/Inference v5.0; TorchBench; PyTorch HUD.
This material is a review of Chapter 13 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, forecast verification, and technical details beyond the original text based on official PyTorch documentation and open sources. Quotes are provided in limited volume for review and critical analysis purposes.
Chapter 13 of Fregly's book is dedicated to practical profiling, tuning, and scaling of PyTorch workloads on GPUs. The chapter covers the full cycle: from profiling tools (PyTorch Profiler, Nsight Systems, Nsight Compute, Linux perf) and bottleneck identification methodology to specific optimization techniques (torch.compile, CUDA graphs, memory allocator tuning, activation checkpointing, data pipeline optimization) and scaling to multiple GPUs and nodes (DDP, FSDP, tensor parallel, pipeline parallel). The chapter concludes with a discussion of continuous benchmarking and protecting performance from regressions.
«AI training and inference pipelines can suffer from performance bottlenecks at every layer, including Python interpreter overhead, CPU host-side data-loading stalls, CUDA kernel underutilization, and GPU device-memory contention. To optimize effectively, you need to profile at multiple levels of the stack using multiple tools that cover the entire system».
The chapter is structured as a practical guide. First, profiling tools and methodology are examined using a MoE model as an example. Then, specific optimization techniques are presented, each accompanied by code examples and measurement results. Next, scaling to multiple GPUs and nodes is discussed. The chapter concludes with continuous benchmarking and protecting performance from regressions. The key idea: PyTorch performance is not a single «silver bullet» but systematic work on profiling and eliminating bottlenecks at every level of the stack.
«NVIDIA Tools Extension (NVTX) markers designate time ranges on the profiler timeline and allow different tools to correlate events of the same phases. For example, an NVTX range for "forward" will appear in both PyTorch profiler traces and Nsight Systems' timelines. This makes cross-tool analysis much easier at different layers of the stack».
NVTX markers are inserted into code via CUDA C++, PyTorch, or any C++/Python library that supports NVIDIA GPUs. Many libraries already insert NVTX markers on your behalf for critical code sections such as train_step, forward, backward, optimizer_step. But you can also add them yourself using torch.profiler.record_function() and torch.cuda.nvtx.range_push().
| Tool | Scope | Key Features | Typical Use Case |
|---|---|---|---|
| PyTorch Profiler (Kineto) | Operation level (CPU/GPU) | NVTX support, shape recording, memory stats, trace export, graph break identification | Detailed model code breakdown; slow ops, kernel launch overhead |
| Nsight Systems (nsys) | System-wide timeline (CPU, GPU, OS, I/O) | Unified CPU thread and GPU stream timeline, NVTX integration, multiprocess support | End-to-end pipeline overview; loader stalls, CPU-GPU overlap, GPU synchronization |
| Nsight Compute (ncu) | Per-kernel GPU analysis | Per-kernel hardware metrics, source correlation, Roofline analysis, occupancy and throughput reports | Deep kernel analysis after hotspots identified; memory-bound vs compute-bound determination |
| PyTorch Memory Profiler | GPU memory usage by operation | Memory snapshot timeline, per-op peak memory, integration with torch.cuda.memory_stats() | Fragmentation diagnosis, unexpectedly high memory usage |
| Linux perf | CPU and system events | CPU cycle sampling, cache misses, branch misses, flame graphs, off-CPU analysis | Python overhead, data loading stalls, OS scheduling issues |
| Holistic Trace Analysis (HTA) | Distributed training trace visualization | Browser-based Kineto trace analyzer, multi-worker trace aggregation, Perfetto integration | Multi-GPU/multinode execution analysis; finding imbalance, idle time, communication overlap verification |
| Chrome trace / Perfetto | Offline trace viewing | Web UIs for standard trace formats, advanced filtering (Perfetto SQL), easy trace sharing | Inspect traces without specialized software; dashboards and remote collaboration |
| TorchEval | Model metrics and performance logging | Standardized metrics API (throughput, accuracy), easy integration | Logging throughput and latency alongside accuracy during training and evaluation |
| ExecuTorch | Mobile, embedded, and edge devices | Model exporting, profiling, debugging, memory analysis, visualization | Running PyTorch models and collecting metrics on constrained platforms |
Start with PyTorch Profiler for a quick operation-level overview. If you see gaps in the timeline or GPU idle time — move to Nsight Systems. If a specific kernel dominates — drill into Nsight Compute. For CPU bottlenecks — Linux perf. For multi-GPU — HTA. For sharing results — Perfetto.
NVTX markers are a unified annotation language understood by all profiling tools. This makes cross-tool analysis seamless.
The combination PyTorch Profiler (operation level) + Nsight Systems (system timeline) + Nsight Compute (kernel analysis) covers all stack layers.
Add NVTX markers at the top level — for iteration phases. Excessive markers pollute the timeline and complicate analysis.
In the chapter, profiling is demonstrated using a mixture-of-experts (MoE) transformer model. MoE is an LLM with multiple expert layers, where each expert is a feed-forward network. Token routing to experts is controlled by a gating system. The architecture is rich in optimization opportunities: many medium-sized matmuls, custom routing kernels (dispatch/combine), and dynamic token distribution.
First, the model and inputs are set up. Hugging Face Transformers is used to load the model and tokenizer, the model is moved to GPU, and a small batch is prepared:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_name = "..."
tokenizer = AutoTokenizer.from_pretrained(model_name)
device = torch.device("cuda")
model = AutoModelForCausalLM.from_pretrained(model_name).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, fused=True)
batch_size = 4
input_texts = ["MoEs are great."] * batch_size
enc = tokenizer(input_texts, return_tensors="pt", padding=True, truncation=True)
input_ids = enc.input_ids.to(device)
attention_mask = enc.attention_mask.to(device)
labels = input_ids.clone()
To avoid capturing one-time setup costs, the model is warmed up with several iterations before profiling. This prepares the model for analysis: compiles JIT kernels and fills caches.
for _ in range(5):
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)
Now one training iteration is profiled, with high-level regions marked via record_function and NVTX ranges:
from torch import profiler
with profiler.profile(
activities=[profiler.ProfilerActivity.CPU, profiler.ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
with_stack=True,
with_flops=True
) as prof:
with profiler.record_function("train_step"):
torch.cuda.nvtx.range_push("forward")
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
torch.cuda.nvtx.range_pop()
torch.cuda.nvtx.range_push("backward")
loss.backward()
torch.cuda.nvtx.range_push("optimizer_step")
optimizer.step()
torch.cuda.nvtx.range_pop()
optimizer.zero_grad()
torch.cuda.nvtx.range_pop()
After execution, operation-level results can be examined by calling prof.key_averages().table(). The top 10 operations sorted by self_cuda_time_total are requested:
print(
prof.key_averages()
.table(
sort_by="self_cuda_time_total",
row_limit=10,
fields=["self_cuda_time_total", "calls"]
)
)
| Operation | Self CUDA total | Calls |
|---|---|---|
| aten::matmul | 43.00 ms | 128 |
| aten::linear | 35.50 ms | 64 |
| dispatch | 18.70 ms | 2 |
| combine | 12.10 ms | 2 |
| aten::layer_norm | 10.20 ms | 16 |
| aten::softmax | 5.70 ms | 4 |
| aten::scatter | 4.10 ms | 16 |
| aten::gather | 3.60 ms | 16 |
| aten::to | 2.90 ms | 8 |
| aten::add_ | 2.20 ms | 64 |
The main bottleneck is matmul in the expert FFN, followed by dispatch and combine kernels. Together they determine the training iteration time. This means that to accelerate, these operations must be optimized — either directly or by reducing their number of calls.
The NVTX markers make timeline analysis in Nsight Systems straightforward. To aggregate metrics by phase, the code is profiled with nsys using an NVTX-based summary:
nsys profile \
--output=profile \
--stats=true \
-t cuda,nvtx \
python train.py
nsys stats --report=nvtx_gpu_proj_sum profile.nsys-rep
| NVTX range | GPU time (ms) | Self GPU time (ms) | Instances |
|---|---|---|---|
| train_step | 138.0 | 0.0 | 1 |
| forward | 60.5 | 60.5 | 130 |
| backward | 58.3 | 58.3 | 130 |
| optimizer_step | 19.2 | 19.2 | 1 |
The NVTX projection mechanism is not exact kernel-to-range binding but a statistical estimate. CUPTI records when the CPU issued push/pop NVTX markers and which GPU kernels were launched in that time window on the host.
PyTorch Profiler identifies matmul in expert FFN as the main bottleneck of the MoE model, followed by dispatch and combine.
Nsight Systems with NVTX annotation provides a GPU-time summary by iteration phase. The NVTX projection mechanism is a statistical estimate, not exact binding.
Start with PyTorch Profiler to identify hot operations. Then use Nsight Systems for timeline analysis. Only then move to Nsight Compute for deep analysis of specific kernels.
For deep analysis of the expert matmul, the Nsight Compute CLI profiler (ncu) is invoked, targeting specific GEMM kernels by name:
ncu \
--target-processes all \
--kernel-name-regex "matmul" \
--metrics \
gpu__time_duration.avg, \
gpu__dram_throughput.avg.pct_of_peak_sustained_elapsed, \
lts__throughput.avg.pct_of_peak_sustained_elapsed, \
sm__sass_thread_inst_executed_op_fp32_pred_on.sum, \
sm__warps_active.avg.pct_of_peak_sustained_active \
--csv full \
-o matmul_roofline_report \
python train.py
| Kernel configuration | % peak FLOPS | % peak memory BW | Achieved SM occupancy | Characteristic |
|---|---|---|---|---|
| Baseline | 50% | 70% | 60% | Memory bound (stalling on memory transfers) |
| Optimized | 85% | 40% | 80% | Compute bound (near hardware roofline) |
In the baseline run, the GEMM kernel achieves only 50% of peak FLOPS, 70% of peak memory bandwidth, and a moderate 60% SM occupancy. This is not enough to fully hide memory latency. The metrics indicate a memory-bound kernel. After optimization (kernel fusion, increased arithmetic intensity, reduced memory movement), peak FLOPS rises to 85%, memory bandwidth drops to 40%, and occupancy grows to 80%. The kernel shifts from memory-bound to compute-bound.
Many high-performance kernels achieve full latency hiding at 25–50% achieved occupancy. Use Nsight Compute occupancy metrics along with stall reason breakdowns and eligible warps per active cycle to judge whether more occupancy will actually reduce stalls.
Roofline analysis in Nsight Compute shows that the MoE GEMM kernel is initially memory bound (50% FLOPS, 70% memory). After optimizations it becomes compute bound (85% FLOPS, 40% memory).
There is no universal target occupancy. Each kernel has its own optimum determined by the latency hiding vs resource balance.
Use Roofline for diagnosis: if the kernel is below the slanted line — optimize memory access; if below the horizontal line — optimize computation.
For a more holistic view of where time is spent — on the host and on the device — Linux perf is used to analyze CPU cycles, cache misses, branch misses, etc. These insights then drive a series of optimizations.
perf stat -e \
cycles,instructions,cache-misses,branch-misses \
python train.py
perf record -F 2000 -g --call-graph dwarf -o perf.data \
python train.py
perf report --stdio -n -g -i perf.data
Based on the profile, the following bottlenecks and mitigation measures are identified:
py::forward) — use torch.compile to eliminate interpreter overhead and fuse Python-side operations into optimized CUDA code.num_workers, enable persistent_workers=True.read) — pinned memory, non-blocking copies, container formats (Arrow, WebDataset, TFRecord).Linux perf identifies 45% Python overhead and significant costs for gradient synchronization (8.7%) and data loading (10.2%).
For CPU bottlenecks use Linux perf. For GPU bottlenecks — Nsight. The combination provides the full picture.
Apply optimizations one at a time, measuring the effect of each. This isolates the impact of changes and avoids unexpected interactions.
«One of the fastest wins in PyTorch is using the compiler via torch.compile(). The compiler stack includes TorchDynamo, AOT Autograd, and TorchInductor, which capture graphs, fuse operations, and generate high-performance code for the target backend».
Using the compiler with default settings is simple: model = torch.compile(model). Under the hood, TorchDynamo traces the Python code, AOT Autograd captures the backward pass, and TorchInductor, relying on OpenAI Triton for GPU kernel generation, automatically builds efficient fused kernels.
compiled_model = torch.compile(model, mode="reduce-overhead")
In this case, eager mode takes about 248 ms per iteration, while compiled mode runs around 173 ms. The ~30% speedup comes mainly from merging many small GPU kernels.
| Mode | Description | Compile time | Extra memory | Key features |
|---|---|---|---|---|
| default | Balanced optimizations | Low–medium | No | General fusion, basic autotuning |
| reduce-overhead | Reduces per-iteration overhead | Medium | Yes (workspace caching) | Uses CUDA Graphs to eliminate launch overhead |
| max-autotune | Maximizes runtime performance | High | Possible | Aggressive Triton autotuning, enables CUDA Graphs |
| max-autotune-no-cudagraphs | Same, without CUDA graphs | High | No | For dynamic shapes or debugging |
PyTorch profile: compiled and eager regions of the graph
When torch.compile does not deliver the expected speedup, diagnostic tools are used. torch._dynamo.explain(model) prints a report of graph breaks, their causes, and which model parts were not captured by TorchDynamo. The TORCH_LOGS="+dynamo,+inductor" variable enables verbose compiler logging.
import torch._dynamo as dynamo
def toy_example(a, b):
x = a / (torch.abs(a) + 1)
print("woo")
if b.sum() < 0:
b = -b
return x * b
explanation = dynamo.explain(toy_example)(torch.randn(10), torch.randn(10))
print(explanation)
The report will show the number of graphs, the number of graph breaks, and their causes. To minimize graph breaks, torch.where() is used instead of Python conditionals, and torch.compiler.set_stance("fail_on_recompile") forces an error when a graph break is encountered.
torch.compile delivers ~30% speedup through small GPU kernel fusion. The reduce-overhead mode additionally uses CUDA Graphs.
For diagnosing compiler issues, torch._dynamo.explain() and TORCH_LOGS are used. Graph breaks are identified and eliminated by replacing Python conditionals with tensor operations.
Not all graph breaks are harmful: for DDP and FSDP they are expected and necessary for overlapping communications with computations.
Transformer models spend a significant portion of time in attention mechanisms. PyTorch provides several levels of optimization:
torch.nn.functional.scaled_dot_product_attention automatically uses the fastest available kernel (FlashAttention, memory-efficient, or cuDNN) depending on tensor shapes and data types.Q_len=1) with a growing KV cache.JIT compilation of masks (block-sparse, sliding-window, causal) without manual CUDA kernels
SDPA automatically selects the best backend. FlexAttention allows describing custom sparsity patterns without manual CUDA kernels.
FlexAttention with nested jagged tensors (NJT) eliminates padding for variable-length sequences.
Verify which SDPA backend is actually used, and if necessary, specify it explicitly via torch.nn.attention.sdpa_kernel.
CUDA streams provide concurrency and overlap of GPU operations. By default, PyTorch schedules all operations on the default device stream (stream 0) sequentially. However, many tasks are independent, and the GPU can execute them in parallel with multiple streams.
device = 'cuda'
transfer_stream = torch.cuda.Stream(device=device)
compute_stream = torch.cuda.default_stream(device=device)
dataloader_iter = iter(dataloader)
first_batch = next(dataloader_iter, None)
if first_batch:
with torch.cuda.stream(transfer_stream):
next_inputs, next_labels = (
first_batch[0].to(device, non_blocking=True),
first_batch[1].to(device, non_blocking=True),
)
for _ in range(len(dataloader)):
compute_stream.wait_stream(transfer_stream)
inputs, labels = next_inputs, next_labels
batch = next(dataloader_iter, None)
if batch:
with torch.cuda.stream(transfer_stream):
next_inputs, next_labels = (
batch[0].to(device, non_blocking=True),
batch[1].to(device, non_blocking=True),
)
with torch.cuda.stream(compute_stream):
outputs = model(inputs)
loss = loss_fn(outputs, labels)
loss.backward()
optimizer.step()
optimizer.zero_grad(set_to_none=True)
Separate CUDA streams: one prepares the next batch, the other computes the current one
Calling print() on a CUDA tensor triggers an implicit synchronization (torch.cuda.synchronize()) that stops all streams. In production code, avoid any operations that read GPU tensor values on the CPU without explicit need.
event = torch.cuda.Event(enable_timing=False)
with torch.cuda.stream(stream1):
kernel_launch(...)
event.record()
stream2.wait_event(event)
with torch.cuda.stream(stream2):
other_kernel_launch(...)
CUDA streams allow overlapping data transfer and computation. A separate transfer_stream prepares the next batch while compute_stream processes the current one.
CUDA events provide fine-grained synchronization without global blocking. torch.cuda.Event(enable_timing=False) reduces overhead.
For profiling multithreaded pipelines use Nsight Systems. Verify that non_blocking=True and pinned memory are used correctly.
CUDA graphs eliminate per-iteration launch overhead, reduce CPU overhead, and remove tiny inter-kernel gaps. PyTorch provides the torch.cuda.CUDAGraph API for capturing and replaying CUDA graphs.
g = torch.cuda.CUDAGraph()
capture_stream = torch.cuda.Stream()
static_input = torch.randn(batch_shape, device='cuda')
static_output = torch.empty(output_shape, device='cuda')
with torch.cuda.stream(capture_stream):
tmp = model(static_input)
static_output.copy_(tmp)
capture_stream.synchronize()
with torch.cuda.graph(g, stream=capture_stream):
tmp = model(static_input)
static_output.copy_(tmp)
capture_stream.synchronize()
Avoid memory allocation during capture; use a dedicated stream; synchronize before and after capture; remember the graph overwrites the output buffer on each iteration — clone if you need to keep it.
| Metric | Before CUDA Graphs | After CUDA Graphs |
|---|---|---|
| CPU launch calls per 100 iterations | 300 separate kernel launches | 100 graph replays |
| Host synchronization calls | 300 cudaDeviceSynchronize | 0 |
| GPU idle between kernels | ~3 µs gaps per iteration | 0 µs (continuous execution) |
| End-to-end iteration latency | ~1.00 ms | ~0.75 ms (25% faster) |
CUDA Graph Trees are used by torch.compile in reduce-overhead mode to compile and cache separate static graphs for each input shape. Keeping input shapes as consistent as possible across iterations is recommended.
CUDA graphs eliminate kernel launch overhead, reducing iteration latency by 25%. Graphs overwrite the output buffer, so cloning is required to preserve results.
CUDA Graph Trees in torch.compile(mode="reduce-overhead") cache static graphs for each input shape. Fewer distinct shapes — more efficient.
For stable shapes and long-running workloads, CUDA graphs provide significant gains. For dynamic shapes, use max-autotune-no-cudagraphs.
PyTorch uses a caching memory allocator for CUDA. By default, it adapts to allocation patterns, splitting and reusing GPU memory blocks. Key tuning options via environment variables:
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:N — the maximum block size the allocator may split. Tells the allocator to keep large free blocks (up to 256 MB) instead of continually breaking them into small fragments.PYTORCH_CUDA_ALLOC_CONF=roundup_power2_divisions:[N:M,...] — groups requests into fixed buckets. For example, in the 512–1024 MB range with 512:2, a 600 MB request rounds up to 768 MB. This reduces fragmentation and increases reuse.PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync — asynchronous CUDA allocator. Eliminates global synchronizations on allocation and deallocation.Peak and cumulative GPU memory allocations by operation
mem_get_info() shows little free memory while memory_stats() shows the sum of allocated tensors is significantly less than total memory, fragmentation is growing. In this case, roundup_power2_divisions is the key tool.export PYTORCH_CUDA_ALLOC_CONF=\
max_split_size_mb:256,\
roundup_power2_divisions:[256:1,512:2,1024:4,:8],\
backend:cudaMallocAsync
Allocator tuning via max_split_size_mb and roundup_power2_divisions reduces fragmentation and increases memory reuse.
The cudaMallocAsync backend eliminates global synchronizations on allocation. Fragmentation monitoring via memory_reserved() and memory_allocated().
For long-running workloads with variable tensor sizes, allocator tuning provides stable memory usage without OOM.
For very large models, activation checkpointing (gradient checkpointing) is essential for memory management. Instead of storing all forward activations, only the inputs of each block are saved. During the backward pass, activations are recomputed.
You don't have to checkpoint everything. Focus on the largest layers — usually transformer blocks, which hold the bulk of activations, but not smaller layers like layer norm and embeddings.
import torch
from torch.utils.checkpoint import checkpoint
class TransformerBlock(torch.nn.Module):
def forward(self, x):
return checkpoint(self._forward, x, use_reentrant=False)
def _forward(self, x):
return self.attention(self.mlp(x))
Selective activation checkpointing allows choosing which layers to recompute. For example, you can checkpoint every other transformer layer. This provides a more flexible trade-off between memory and computation.
Activation checkpointing trades computation for memory. The selective approach allows choosing the optimal trade-off.
For MoE models with many experts, checkpointing transformer blocks provides significant memory savings.
Checkpointing is mandatory for models that otherwise do not fit in memory. It allows using a larger batch size.
A frequent cause of inefficiency is GPUs idling while waiting for data. PyTorch DataLoader supports multiple workers for parallel data loading and preprocessing while the model trains.
pin_memory=True — transfer through page-locked memory. Accelerates CPU→GPU copies.non_blocking=True and persistent_workers=True — to hide copies and avoid re-spawning workers between epochs.prefetch_factor — how many batches each worker prefetches. Default is 2.loader = DataLoader(
dataset,
batch_size=B,
num_workers=8,
pin_memory=True,
persistent_workers=True,
prefetch_factor=4,
)
For CPU-bound data pipelines, 2–8 workers per GPU is often enough. Do not follow the "one worker per CPU" rule blindly — it often results in too many workers competing for the GIL.
pin_memory=True, persistent_workers=True, and prefetch_factor are the three key parameters for preventing GPU idle time waiting for data.
For CPU-bound pipelines, 2–8 workers per GPU is sufficient. Excess workers compete for the GIL.
Profile the DataLoader in isolation: time 100 batches without GPU work. Compare with the target iteration time.
FSDP in PyTorch is a distributed parallelism strategy that shards model parameters, gradients, and activations across GPUs. Technically, FSDP implements the ZeRO Stage-3 strategy for sharding model states across GPUs.
import torch
import torch.nn as nn
import torch.distributed as dist
from torch.distributed.fsdp import (
FullyShardedDataParallel as FSDP,
CPUOffload,
ShardingStrategy,
MixedPrecision
)
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
dist.init_process_group("nccl")
torch.cuda.set_device(dist.get_rank() % torch.cuda.device_count())
class MyModel(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.Sequential(
nn.Linear(4096, 4096),
nn.ReLU(),
nn.Linear(4096, 4096),
)
def forward(self, x):
return self.layers(x)
model = MyModel().cuda()
auto_wrap_policy = transformer_auto_wrap_policy(
model,
min_num_params=1e8,
)
fsdp_model = FSDP(
model,
auto_wrap_policy=auto_wrap_policy,
sharding_strategy=ShardingStrategy.FULL_SHARD,
use_orig_params=True,
cpu_offload=CPUOffload(offload_params=True, pin_memory=True),
)
When the network is slow — or only a few GPUs are available — ShardingStrategy.SHARD_GRAD_OP (ZeRO Stage-2) can be used to shard only gradients and optimizer state. On Grace-Blackwell superchip systems, SuperOffload and GraceAdam are available.
Parameters, gradients and optimizer states are split across 4 GPUs
FSDP implements ZeRO Stage-3: parameters, gradients and optimizer states are sharded across GPUs. Sharding at the transformer block level provides maximum efficiency.
ShardingStrategy.FULL_SHARD (ZeRO-3) for minimal memory; SHARD_GRAD_OP (ZeRO-2) when the network is slow; HYBRID_SHARD for a balance of memory and communication.
Automatic checkpointing and offloading via FSDP reduce memory usage without boilerplate code.
When using DistributedDataParallel, which synchronizes gradients across GPUs via all-reduce, PyTorch automatically creates graph breaks at synchronization points. PyTorch's design compiles the backward computation of each bucket as a separate graph, so all-reduce can run between those graphs.
Wrap each transformer block — not just an individual low-level layer — in its own FSDP module with use_orig_params=True. This way TorchDynamo inserts a graph break at each shard boundary where communication is needed.
Tensor parallel and pipeline parallel are orthogonal to torch.compile. The PyTorch compiler mainly focuses on optimizing computation within each segment. Use torch.compile to optimize computation within each layer or bucket.
TorchTitan is a set of reference implementations for large-scale training. SimpleFSDP reimplements FSDP in a torch.compile-friendly way via DTensor. In TorchTitan experiments, SimpleFSDP reduced memory consumption to ~28% and improved training throughput up to ~69% compared to the traditional FSDP2 eager path.
Graph breaks in DDP and FSDP are expected and necessary for overlapping communications with computations. Compiling each bucket separately is by design, not a bug.
SimpleFSDP reduces memory to ~28% and increases throughput up to ~69% relative to FSDP2 eager (maximum on 2D parallelism; average gain is ~29% for 8B and ~28% for 70B).
Use torch.compile to optimize computation within each segment, but trust the distributed strategy to manage communications.
When scaling to many GPUs, managing traces can become cumbersome. HTA from Meta helps aggregate and visualize multi-worker traces. HTA ingests JSON traces from torch.profiler for each GPU/rank and presents a unified timeline.
With HTA you can see traces from all 8 GPUs aligned in time. NVTX markers from each rank are aligned and visualized. You might notice that rank 0 enters the backward pass at time T, while rank 1 only at T+1, possibly because rank 1 was waiting for rank 0 in all-reduce.
from torch.profiler import profile, schedule, tensorboard_trace_handler
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=1, warmup=1, active=3, repeat=1),
on_trace_ready=tensorboard_trace_handler('./log'),
record_shapes=True,
profile_memory=True,
with_stack=True
) as prof:
for step, batch in enumerate(dataloader):
train_step(batch)
prof.step()
HTA aggregates traces from all ranks into a unified timeline, allowing identification of imbalance and synchronization delays.
Use torch.profiler.schedule to record traces over multiple iterations without overload.
For multinode profiling, HTA is the standard tool. TensorBoard trace viewer is deprecated for this purpose.
After applying all optimizations, it is critical to maintain them and continuously check for performance regressions. Set up a simple performance regression CI with TorchBench and GitHub Actions to automatically catch slowdowns.
- name: Run MoE benchmark
run: |
torchbench run --model moe --iters 10 --batch-size 4 --json results.json
- name: Compare throughput
run: |
python scripts/compare_perf.py baseline.json results.json
Public nightly benchmark dashboard: throughput, memory, regressions per commit
CI with TorchBench and GitHub Actions automatically detects performance regressions. The public PyTorch HUD is a source of external signals.
A sustained ≥5% throughput drop over three runs is a signal for investigation.
Performance regression testing must be part of CI/CD. Without it, optimizations degrade over time.
«torch.compile delivers ~30% speedup through small GPU kernel fusion».
Confirmed in terms of adoption; the ~30% figure is a book estimate. By 2026, torch.compile became standard practice for production workloads. PyTorch 2.x actively develops the compiler, and TorchInductor generates highly optimized kernels for NVIDIA GPUs. The reduce-overhead and max-autotune modes are used in production clusters. The actual fusion gain strongly depends on the model and compilation mode: in Meta publications it is typically 10–30%.
«FSDP implements ZeRO Stage-3 for sharding model states across GPUs».
Confirmed. FSDP became the standard for training models that don't fit in one GPU's memory. SimpleFSDP and TorchTitan demonstrate further development of the approach. HYBRID_SHARD and SHARD_GRAD_OP are used to balance memory and communication.
«CUDA graphs eliminate kernel launch overhead, reducing iteration latency by 25%».
Confirmed in terms of adoption; the 25% figure is a book estimate. CUDA Graphs are integrated into torch.compile(mode="reduce-overhead") and actively used in inference engines (vLLM, TensorRT-LLM). The static shapes limitation remains, but CUDA Graph Trees solve this for discrete shape sets. The actual gain depends on the share of launch overhead in total iteration time and is typically 10–30%.
«HTA aggregates traces from all ranks into a unified timeline».
Confirmed. HTA is actively used at Meta and other organizations for distributed training analysis. TensorBoard trace viewer is deprecated, and HTA with Perfetto have become standard. CUPTI Counter Analysis allows obtaining GPU performance counters.
«You don't have to checkpoint everything. Focus on the largest layers».
Partially confirmed. Selective checkpointing is supported in TorchTitan and torchtune. PyTorch provides APIs for selecting specific layers. However, there is no universal strategy — the choice depends on the model architecture and available memory.
Memory profiler and Mosaic. The chapter examines the standard PyTorch memory profiler. Additionally, Meta developed Mosaic — a tool for analyzing memory snapshots that identifies leaks and fragmentation at the level of individual allocations. Mosaic integrates with torch.cuda.memory._dump_snapshot().
CUDA Graph Trees and dynamic shapes. Although the chapter mentions CUDA Graph Trees, their mechanism with dynamic shapes deserves separate attention. Trees classify each storage that is live at the end of capture as either a private-pool allocation or a known static input. This allows reusing graphs for multiple shapes without full recapture.
PyTorch HUD and regressions. The public PyTorch HUD (hud.pytorch.org) tracks torch.compile performance relative to eager mode. However, recent issues with AWS migration led to periods of dashboard unreliability. For critical workloads, a custom CI with benchmarks is recommended.
NVIDIA Blackwell and profiling. New Blackwell architectures (B200/B300) require updated versions of Nsight Systems and Nsight Compute. PyTorch 2.x supports profiling on Blackwell, but some metrics (e.g., TMEM utilization) may be unavailable in older tool versions.
MLPerf Logging. For standardized performance logging in distributed workloads, the mlperf_logging library is used. It allows recording key events (training start, epoch end, final accuracy) in a format compatible with MLPerf. This simplifies performance comparison between configurations.
reduce-overhead mode uses CUDA Graphs.max_split_size_mb, roundup_power2_divisions, and backend:cudaMallocAsync.pin_memory=True, persistent_workers=True, prefetch_factor.Chapter 13 of Fregly's book is a systematic guide to profiling, tuning, and scaling PyTorch. The key idea: performance is not a single «silver bullet» but a continuous cycle of profiling and eliminating bottlenecks at every level of the stack. From NVTX markers and Nsight to torch.compile, CUDA graphs, FSDP, and HTA — each tool solves its own problem. For an engineer, this means that understanding profiling methodology and the principles behind each tool is more important than knowing specific numbers of the current generation.
Forecast verification shows: the book proved accurate in terms of technology adoption. torch.compile became standard; FSDP displaced DDP for large models; CUDA graphs reduce overhead; HTA replaced TensorBoard trace viewer. The specific speedup percentages (30%, 25%) are book estimates; actual gains depend on the model and mode. The main takeaway remains unchanged: investments in profiling methodology ensure adaptation to any stack.
Sources: Chris Fregly, «AI Systems Performance Engineering» (O'Reilly, 2026), Chapter 13 — quotes provided in limited volume for review and critical analysis purposes. Official PyTorch documentation (torch.profiler, torch.compile, FSDP, CUDA Graphs); NVIDIA Nsight Systems/Compute documentation; Holistic Trace Analysis (Meta); MLPerf Training/Inference v5.0; TorchBench; PyTorch HUD.