QR code linking to samf.sh/talks/2026/10/15samf.sh/talks/2026/10/15

Profiling and Optimization of AI Training Workloads

Find the bottleneck before you buy more nodes.

Sam Foreman, Nathan Nichols · Argonne National Laboratory

2026-10-15 · Genesis Mission Hackathon · Advanced A3

Roadmap

Measure → localize → fix. The order is the content.

1Baselinetokens/sec, MFU, and the ceiling — 15 min
2Instrumenttorch.profiler on a real run — 25 min
3Read the tracecompute vs comms vs data — 25 min
4Fixone measurement per fix — 25 min

Then the self-progress block, where you run it on your own model.

Who this is for

Your run works. It is slower than it should be. You do not know whether that is code, config, or nodes.

  • Assumed: a run that launches, an allocation, willingness to read a trace
  • Not covered: getting off the ground, failures, checkpointing
If your run does not launch yet

Wrong session. The self-progress block is a better use of the next 90 minutes, and ATPESC 2026 covers getting started and staying alive.

More nodes is not a fix

Scaling an inefficient run scales the inefficiency.

The job we profile later this hour is idle 90.7% of every step. Another 512 nodes buys 90.7% idle on 512 more nodes. Same fraction, bigger bill.

That idle is inside the step — the GPU sitting between kernels while one rank waits. It is not queue time, not a dead node, not walltime you lost to the scheduler. Nodes do not touch it.

First question, before any change

Is the GPU busy?

Pick the number first

MetricWhat it tells youUse it for
tokens/sec/GPUwhat you are payingthe headline
step timedecomposes into phasesdebugging
MFUhow much machine is leftknowing when to stop

The trap: 2× on a kernel that is 15% of the step is a 7% win. Speed up the thing that dominates, or do not bother.

🍋 ezpz

Distributed PyTorch setup and launch. The same script runs on a laptop, one GPU, or a thousand-node supercomputer, with no code changes.

import ezpz
rank = ezpz.setup_torch()        # CUDA/NCCL, XPU/CCL, MPS, or CPU/Gloo
model = MyModel().to(ezpz.get_torch_device())
model = ezpz.wrap_model(model)   # FSDP by default
ezpz launch python3 train.py     # PBS or SLURM: hostfile and ranks found for you

The FLOP counter, timers, and profiler context in the rest of this deck all come from it.

MFU: how much of the machine are you using?

MFU=model FLOPspeak FLOP/s×step time\text{MFU} = \frac{\text{model FLOPs}}{\text{peak FLOP/s} \times \text{step time}}

Both per-device, so world_size drops out. Every rank does the same work.

Do not hand-derive the numerator. ezpz counts it with FlopCounterMode (real ops, not 6ND6ND) and looks up the peak, computing Intel PVC dynamically:

from ezpz.flops import estimate_model_flops, compute_mfu
flops = estimate_model_flops(model, input_shape)   # counted, not estimated
mfu = compute_mfu(flops, step_duration)            # peak auto-detected

AuroraGPT production chains: 20B at 17-22%, 2B at 9-12%. Bigger model, better utilization, same machine.

The job we profile today runs at 0.33% MFU

125M params, seq 1024, tp=2 across 24 ranks. Where a 440 ms step goes:

flowchart LR S["<span style='font-weight:700'>one step</span><br/><i>440.3 ms</i>"] --> I["<span style='font-weight:700'>idle</span><br/><i>398.7 ms · 90.6%</i>"] S --> B["<span style='font-weight:700'>device busy</span><br/><i>41.5 ms · 9.4%</i>"] B --> C["compute<br/><i>31.9 ms · 7.2%</i>"] B --> M["collectives<br/><i>9.7 ms · 2.2%</i>"] I --> G["<i>3,748 gaps</i><br/>median <b>46 µs</b>"] C --> K["<i>3,749 kernels</i><br/>median <b>4.6 µs</b>"] classDef step fill:#118cc208,stroke:#118cc2,color:#118cc2,stroke-width:1.5px classDef bad fill:#e0556008,stroke:#e05560,color:#e05560,stroke-width:1.5px classDef ok fill:#1da81108,stroke:#1da811,color:#1da811,stroke-width:1.5px classDef note fill:#83838308,stroke:#838383,color:#838383,stroke-width:1.5px class S step class I,G bad class B,C,M ok class K note

The gaps are bigger than the kernels. Python cannot launch work fast enough to keep the device fed.

The ceiling: roofline

Arithmetic intensity (FLOP per byte) against achievable FLOP/s. Two regimes, two different fixes:

  • Memory-bound — left of the ridge. Fusion, precision, layout.
  • Compute-bound — right of it. Bigger matmuls, better kernels.

Knowing which side you are on tells you which half of this talk applies.

Log-log roofline: arithmetic intensity in FLOP per byte on the x axis against attainable bf16 TFLOP/s on the y. A slope-one memory diagonal rises to meet a flat compute ceiling. Intel Max 1550 per tile peaks at 340.8 TFLOP/s over 1638.4 GB/s HBM2e, ridge at 208 FLOP/byte; NVIDIA A100 40GB peaks at 312 TFLOP/s over 1555 GB/s HBM2, ridge at 201 FLOP/byte. Left of the ridge is memory-bound, right is compute-bound.

Both machines ask for roughly the same thing: ~200 FLOP per byte before compute becomes the limit.

Instrument it

Four decisions, four flags:

DecisionWhyFlag
skip the warmupstep 1 is allocator + autotune--pytorch-profiler-warmup
capture a few steps1 is noise, 100 is gigabytes--pytorch-profiler-active
one rank2048 ranks × traces → Lustre--rank-zero-only
pay for what you readshapes/stack/memory cost--record-shapes, …

Defaults: wait=1 warmup=2 active=3 repeat=5.

Profiling the warmup is the classic mistake

Step 1 is allocator churn and autotuning. It is not your training step.

Profiling is not free: step time +57%, and writing a 101 MiB trace stalls the step ~65×.

Before you reach for the profiler

A profiler perturbs what it measures and hands you gigabytes to interpret. A timer around each phase answers which quarter of the step in five minutes.

Often that is the whole answer.

import ezpz
from ezpz.history import StopWatch
with StopWatch("fwd", iter=step):
    loss = model(batch)
    ezpz.synchronize()  # GPU work is async: wait for it

ezpz also has timeitlogit (decorator, logs per-rank) and History.update(), which is where the dt= dtf= dtb= sps= tps= mfu= log lines come from.

Vendor tools

torch.profiler says which op. The vendor tool says why that op is slow — occupancy, bandwidth, which collective algorithm got picked.

Most people never need this. Go here when the trace blames a kernel and you cannot see why.

Aurora · Intel Max 1550

module load frameworks/2026.1.0
unitrace -d -h \
  --chrome-itt-logging \
  --chrome-kernel-logging \
  -o utrace python step.py

Polaris · Perlmutter · NVIDIA

module load pytorch/2.13.0
nsys profile -t cuda,nvtx,osrt \
  -o step_trace \
  --force-overwrite true \
  python step.py
Do not module load pti-gpu

frameworks/2026.1.0 already loads pti-gpu/0.17.0 and puts unitrace 2.3.0 on PATH. Loading pti-gpu/1.0.1-rc1 on top breaks torch outright: ImportError: libpti_view.so.0: cannot open shared object file.

Name your own phases

A vendor timeline shows kernels. Annotation makes it show your phases.

NVIDIAIntel
APItorch.cuda.nvtxtorch.profiler.itt
range_push / popyesyes
range_start / endyesno
on the other vendorraisesno-ops
nvtx = torch.cuda.is_available()
push = torch.cuda.nvtx.range_push if nvtx else torch.profiler.itt.range_push
pop  = torch.cuda.nvtx.range_pop  if nvtx else torch.profiler.itt.range_pop

Only one direction needs guarding: NVTX raises on XPU, ITT no-ops on CUDA. ezpz has the wrapper but no annotation yet (#270).

One setup function, three schedulers

MachineDeviceActivitySorts by
AuroraMax 1550XPUself_xpu_time_total
PolarisA100CUDAself_cuda_time_total
PerlmutterA100CUDAself_cuda_time_total

ezpz fills in that right-hand column for you. You write one script and it runs on all three — no if xpu: anywhere in your code.

Profiling is the part that travels badly

Aurora needs --no-with-stack for FSDP+TP.1 Perlmutter hangs outright. Demo from Polaris or Aurora.

In your own training loop

from ezpz.profile import get_profiling_context

with get_profiling_context(
    profiler_type="torch",
    wait=1, warmup=2, active=3, repeat=1,
    rank_zero_only=True,
    with_stack=False,  # True hits the RecursionError on Aurora
    outdir="./traces",
) as prof:
    for step, batch in enumerate(dataloader):
        train_step(batch)
        if prof is not None:
            prof.step()

Three lines around a loop you already have. It picks the activity set, the sort column, and the rank gating.

The launch is not identical

export HTTPS_PROXY=http://proxy.alcf.anl.gov:3128  # ALCF compute: no internet
source <(curl -fsSL https://ezpz.cool/utils.sh)
ezpz_setup_env && ezpz_install                     # install is a SECOND step
ezpz launch python3 -m ezpz.examples.profiler --profile --rank-zero-only

Verified on Polaris. Note examples.profiler — the small one. Swap in fsdp_tp --tp 2 and you need --no-with-stack on Aurora, and it hangs outright on Perlmutter.

Four things that bite

  1. ALCF compute has no route out. Set HTTPS_PROXY=http://proxy.alcf.anl.gov:3128
  2. The venv is named after cwd. Don’t cd between setup and launch.
  3. It tokenizes 5.9 GB before step 0. Pass --hf-limit 512.
  4. PBS jobs need a login shell. #!/bin/bash -l, not #!/bin/bash.

 

None of these fail where the mistake is. That is what costs the afternoon.

Reading a trace

--profile drops torch-profiler-rank0-step{N}-{timestamp}.json. Open it in Perfetto or chrome://tracing.

CPU rows on top (what Python asked for), device rows below (what ran). A gap in the device row is the GPU waiting.

From the job behind this talk. The GPU is idle 90.6% of every step.

Perfetto timeline zoomed to a 17 ms window inside ProfilerStep#10 on Aurora. The CPU main-thread row is packed with nested operator frames including FSDP::pre_forward, FSDP::all_gather, c10d::_allgather_base_ and xccl::_all_gather. The three XPU device rows, Stream 64, 65 and 66, are blank across the middle of the window: the GPU is idle while the CPU works.
17 ms inside one step. CPU packed, Stream 64/65/66 blank. That whitespace is the GPU waiting on all_gather.

Three shapes, three fixes

Trace showsDiagnosisFix section
idle at the start of every stepdata stalldataloader, FS
idle mid-step, regularcommunicationcollective, overlap
busy but throughput lowkernel efficiencyprecision, fusion, shapes

Everything after this slide is one of three branches.

Take a photo of this one

It is the table you will want in front of you at 2am.

Fix: data stalls

Usually the cheapest big win, and the one teams skip because it is not the interesting part.

  • Workers, prefetch, pinned memory — the boring three, in that order
  • Many small files is a metadata problem, not a bandwidth problem
  • Lustre does not like you — one stat() storm × 50k ranks
Same failure as import torch at scale

The fix is not a faster filesystem. It is not hitting the filesystem from every rank. See ezpz yeet.

Measured on Aurora, 8 → 4096 nodes: per-node env cost 8.7 s → 0.18 s (48×). Full-scale pre-launch 12.5 min, first step at 16 min.

Fix: communication

  • Overlap — the framework probably already does it; confirm in the trace before tuning anything
  • TP across nodes is usually the bug, not a tuning problem

Keep TP on-node. DP is nearly free; TP is chatty.

Turn the logs on before you tune anything:

AuroraPolaris · Perlmutter
verbosityCCL_LOG_LEVEL=infoNCCL_DEBUG=INFO
narrow it—NCCL_DEBUG_SUBSYS=INIT,COLL,NET
one file per rank—NCCL_DEBUG_FILE=nccl.%h.%p.log
who hungTORCH_NCCL_DESYNC_DEBUG=1same
Aurora ships quieter than the library default

oneCCL defaults to warn; the setup lowers it to ERROR. You are getting less diagnostic output than the library would give you.

Fix: kernel efficiency

  • Mixed precision — verify you got it; bf16 in the config is not proof
  • torch.compile — helps often; on AuroraGPT MoE it cost 26% of throughput
  • Fused attention / optimizer — usually free, check availability per device
  • Shapes — pad to the tensor-core multiple
torch.compile is not free

Compile time is real, and it does not always pay back. Measure it like anything else.

Measured: moe_500m, Aurora, 2 nodes / 24 tiles, 2026-04-13. TPS 11,200 → 8,316; TFLOPS 25.22 → 18.70; MFU 8.46% → 6.27%. All three agree on -26%, which is the same thing as +35% step time — quote one, not both.

Compile also used 2.2× less memory (8.44 → 3.78 GiB). A real trade.

As of April 2026, torch 2.10 — not re-measured since.

When the constraint is memory

Activation checkpointing, ZeRO stages, offload. Each buys memory with compute.

ZeRO / FSDP memory partitioning: horizontal memory-proportional bars per GPU across four stages (Baseline/DDP, ZeRO-1 shard optimizer, ZeRO-2 +gradients, ZeRO-3/FULL_SHARD shard everything). Each stage replaces a full-width component bar with a 1/N per-GPU slice over a greyed freed-memory track, so per-GPU memory drops from 16 GB toward 16/N GB.

Adam, per parameter: 2 (bf16 param) + 2 (grad) + 12 (fp32 master

  • two moments) = 16 bytes. Sharding takes that toward 16/N.
These are slowdowns you accept to fit

Not speedups. Teams reach for them expecting speedups.

Hands-on

No new code. The flags already exist.

# 0 · setup, then install -- setup_env does NOT install ezpz
export HTTPS_PROXY=http://proxy.alcf.anl.gov:3128  # ALCF compute only
source <(curl -fsSL https://ezpz.cool/utils.sh) && ezpz_setup_env
uv pip install --no-cache --link-mode=copy "git+https://github.com/saforem2/ezpz"

# 1 · baseline: how fast is it now?
ezpz launch python3 -m ezpz.examples.fsdp_tp \
    --model small --dataset random --train-iters 12

# 2 · same run, instrumented
ezpz launch python3 -m ezpz.examples.fsdp_tp \
    --model small --dataset random --train-iters 12 \
    --profile --rank-zero-only --no-with-stack

144 s, two traces, full table. Open the JSON in Perfetto and you are looking at your own run.

--no-with-stack is not optional for FSDP+TP

Without it the callback dies in key_averages() — which runs before export_chrome_trace, so you lose the table and the trace.

The other way you lose GPU-hours

Everything so far assumed the run finishes. At scale it does not.

A crash at step 1,900 of 2,000 with a 100-step save interval costs you the recompute, the restart, and the queue wait. None of it shows up in a profile.

Throughput you never used is the same as throughput you never had

An 18% MFU run that dies at 90% is a 0% MFU run.

ezpz launch --auto-retry

On a non-zero exit, scrape the log for known bad-node signatures, retire the named host, rotate in a spare, relaunch. No babysitting.

ezpz launch --auto-retry --nhosts 8 --spare-nodes 2 -- \
  python3 -m ezpz.examples.fsdp_tp --model debug \
    --ckpt-dir ./ckpts --save-interval 100 --train-iters 3000

Catches: watchdog kill (124), walltime-racing crashes (143), Aurora PALS shepherd-9, gloo peer-closed, and zero-progress hangs.

No spare, no retry

Every retryable verdict is gated on has_spares. If --np eats the whole allocation there is nothing to rotate in and the run ends EXHAUSTED. Size with --nhosts and leave --spare-nodes.

--auto-retry also flips the idle timeout on: it defaults to 30 min, so a silent xccl hang gets killed and retried instead of burning the whole walltime.

Checkpointing without paying for it

Sync save blocks training. Async stages to CPU, fans out in a background thread, stamps .complete last.

ModelCkptSync stallAsync stall
agpt-2b23 GB3.75 s1.05 s3.6×
agpt-20b232 GB23.57 s5.4 s4.4×
python3 -m ezpz.examples.fsdp_tp \
    --ckpt-dir /shared/ckpts --async-ckpt \
    --ckpt-stage-dir /tmp/ezpz-ckpt --save-interval 100
Two dead ends got there

A 5.18 s blocking drain that no metric recorded, then a marker stamped a full interval late — async was durable but not resumable. The fix probes on MPI’s own communicator, not the training group, so it cannot deadlock against the gradient all-reduce.

ezpz.cool/guides/checkpoint-restart

Now do it: one lever at a time

Same job, same 2 nodes, same profiling recipe. Each row is a run.

changeidleMFUtok/s/GPU
Abaseline, bs=1 sl=102490.7%0.33%1,392
B--batch-size 876.4%2.51%10,484
C--batch-size 1663.8%4.54%18,947
D+ --seq-len 409619.1%9.77%27,648
E+ --tp 110.1%12.9%36,461

One mechanism explains all of it: give each kernel launch more work. Comm share falls 25% → 2.7% as a consequence, not a separate fix.

The same 440 ms

Perfetto timeline of a 440 ms window in the baseline run. Stream 64 is a row of thin coloured ticks with visible white gaps between nearly every one; the CPU thread above is densely packed. Twenty-odd one-shot XCCL init streams each occupy a row with a single mark.
A — 90.7% idle
Perfetto timeline of the same 440 ms window after batching and sequence-length changes. Stream 64 is nearly continuous, with labelled gemm_kernel and collective blocks wide enough to read, and the CPU thread shows repeating FSDP::pre_forward and FSDP::all_gather spans instead of undifferentiated texture.
E — 10.1% idle

Same trace viewer, same zoom, same 440 ms. The whitespace is the win.

Where it stops, and why

90.7% → 10.1% idle. MFU 0.33% → 12.9%, a 39× gain.

Also still bad — and the trace says why. The residual 188 ms of idle is 3,735 gaps at a 23 µs median. Launch overhead never disappears; it only gets amortized.

The honest ceiling is memory, not cleverness

E peaks at 55.7 of 64 GiB. Both bigger-model configs OOMed outright. To go further you need a model big enough to justify 24 tiles, which is a different talk.

Takeaways

  1. Baseline and ceiling before any change.
  2. The trace names the problem. Ours said launch overhead, not comms — and the first guess was wrong.
  3. One lever per measurement. 90.7% → 10.1% idle, five runs.
  4. Stop when the measurement says stop. Ours stopped at memory.

Slides: samf.sh/talks/2026/10/15

export HTTPS_PROXY=http://proxy.alcf.anl.gov:3128  # ALCF compute only
source <(curl -fsSL https://ezpz.cool/utils.sh) && ezpz_setup_env
uv pip install --no-cache --link-mode=copy "git+https://github.com/saforem2/ezpz"
ezpz launch python3 -m ezpz.examples.fsdp_tp \
    --profile --rank-zero-only --no-with-stack

Find me in the self-progress block.

Acknowledgements

This research used resources of the Argonne Leadership Computing Facility at Argonne National Laboratory, which is supported by the Office of Science of the U.S. Department of Energy, Office of Science, under contract number DE-AC02-06CH11357.

Footnotes

  1. torch.profiler records a Python stack per op when with_stack=True (the default), and p.key_averages() then walks those stacks recursively in torch/autograd/profiler_util.py to build the event tree — FSDP2 + TP call stacks are deep enough to exceed Python’s default 1000-frame limit, and the run exits 143 having written zero traces. Aurora job 8881403 is a one-variable pair (same allocation, same fsdp_tp --model small --tp 2): the default writes 0 traces at rc=143, --no-with-stack writes 5 at rc=0. Nothing in ezpz is implicated; its frames in the traceback are just the outer call path. This is a depth threshold, not an Aurora property — Polaris (job 7670657) profiled the same example with stacks and wrote 5 traces. Aurora crosses the limit first. ↩

1
1
 Slides
Theme
▣ Deck
 Font
 Code Font
 Table of Contents
    Keybinds
    j / →Next slide / fragment k / ←Previous slide / fragment SpaceAdvance (fragments first) g / GFirst / last slide Ctrl+.Open speaker notes window EscToggle overview / close dialog fFullscreen pPrint / export to PDF ? / : / mThis dialog