QR code linking to samf.sh/talks/2026/08/03samf.sh/talks/2026/08/03

Pre-Training LLMs on a Supercomputer

The most model per GPU-hour: what works, what breaks, and what it costs.

Sam Foreman, Argonne National Laboratory

2026-08-03 · ATPESC 2026

Roadmap

One goal, two halves: get the most model per GPU-hour. Part 1 spends the compute well; Part 2 keeps a crash from wasting it.

Part 1: getting a run off the ground (now, ~30 min)

  • Data preparation
  • Python environments + Lustre at scale
  • Parallelism: enough to launch

Part 2: keeping it alive to the end (after the break, ~60 min)

  • Critical batch size + second-order optimizers
  • Failures, shared filesystems, checkpointing
  • Fault-tolerant training + automatic restarts

Who this is for

One goal runs through all of it: the best model you can get per GPU-hour. Every choice ahead trades compute for model quality; the job is to spend it well.

  • You know HPC. You may be new to training LLMs at scale.
  • This is lessons-learned, not a survey: do-this-not-that from real runs on Aurora / Polaris.
  • Builds on today’s earlier talks: Jane just did LLM basics (tokenization, training objectives); Bethany covered the parallelisms; Nathan, profiling. We assume those and focus on making it survive at full-machine scale.

The stack

Three repos, one job each. No cuda in user code.

Hardware agnostic

Write it once on Aurora’s XPUs, run it anywhere.

Data prep is the first wall

Not a download. A distributed-systems problem, before the first GPU step.

  • Curate · blend · shard 2T+ tokens, many corpora
  • Reproducible every rank, every run
  • Survivable flaky FS · node failures · noisy neighbors
The three steps, in order

tokenize (text → shards) → blend (weighted mix) → pin (make it reproducible)

From raw corpus to tokens

Raw text → tokenized, sharded binaries before you can blend.

  • Pin the tokenizer (SentencePiece, vocab 32k) → or every shard is stale
  • Tokenize in parallel → EOD per doc, fixed-size .bin shards + file list
  • Decide doc packing up front → it changes the loss
  • Bottleneck is the filesystem, not the tokenizer
Tokenize once, reuse forever

Re-tokenizing 2T tokens is a whole-allocation tax.

Blending data, efficiently

Sample every batch to a fixed mixture. Do not concatenate.

  • Concatenation forgets: order becomes a hyperparameter
  • Domain weights honored per batch, deterministically
  • BlendCorpus: aggregate → sample → index
Time to prepare 2T tokens: serial ~1 hr vs distributed ~2 min, a 30x speedup
Index-build time for 2T tokens: serial ~1 hr vs distributed ~2 min (30x).

Reproducibility and the fork tax

Same seed + shards + weights → same batch, every rank, every run.

Universal:

  • data churn: re-scraped shards break determinism
  • pin everything: SHAs · tokenizer · shard lists · weights

If you fork upstream (we fork torchtitan for XPU):

  • upstream churn: 46 syncs in 7 weeks, any can shift RNG

Every fork sync runs a deterministic smoke test first:

flowchart TB A["<i>start</i><br/><span style='font-weight:700'>upstream @ HEAD</span>"] --> B["<i>resync · 50 steps · deterministic</i><br/><span style='font-weight:700'>smoke test</span>"] B --> D{"bit-exact?"} D -->|✅| C["<i>diff = 0</i><br/><span style='font-weight:700'>✓ ship</span>"] D -->|❌️| F["<i>diff &ne; 0</i><br/><span style='font-weight:700'>bisect + fix</span>"] F -.->|retry| B classDef source fill:#7c4ed508,stroke:#7c4ed5,color:#7c4ed5,stroke-width:1.5px classDef stage fill:#118cc208,stroke:#118cc2,color:#118cc2,stroke-width:1.5px classDef ship fill:#1da81108,stroke:#1da811,color:#1da811,stroke-width:1.5px classDef gate fill:#ee8f2410,stroke:#ee8f24,color:#c9750f,stroke-width:1.5px classDef bad fill:#e0556008,stroke:#e05560,color:#e05560,stroke-width:1.5px class A source class B stage class D gate class C ship class F bad

Bit-exact = loss + grad-norm + peak memory match baseline.

A Python environment that works

Don’t build from scratch. Layer on the site module: it already has torch, MPI, and the GPU stack built for this machine.

# 1. the site's prebuilt module (torch / MPI / GPU)
module load frameworks

# 2. a venv on top -- your packages, inheriting the module's torch
uv venv --system-site-packages --python python3 .venv
source .venv/bin/activate
uv pip install "git+https://github.com/saforem2/ezpz"   # resolves in seconds
The rule:

venv first, clone last

  • --system-site-packages is what lets the venv see the module’s torch (skip it and you pull a fresh CPU-only wheel)
  • never pip install into base; clone conda base only if a package hard-requires conda (slow, huge)

Why import melts Lustre at scale

One import torch = thousands of stat() / open(). Now × 50,000 ranks.

  • Metadata server chokes, not bandwidth
  • Laptop-seconds → cluster-minutes before step 1
  • Per-file rsync past 256 nodes: 1-2 hours
Python is pathologically small-file

The fix is not a faster FS. It is not hitting the FS from every rank.

Deep dive: Running 50k Python processes on Aurora with ezpz yeet

ezpz yeet: broadcast the environment

Copy the env once to node-local /tmp, fan out node-to-node. Imports hit local SSD.

ezpz yeet --compress          # 1 tarball off Lustre
source /tmp/.venv/bin/activate
ezpz launch python3 -m your_app.train
  • Greedy O(log N) fan-out tree, not a star
  • Per-node cost 8.7s → 0.18s (48x)
  • Full Aurora: pre-launch under 13 min
yeet broadcast wall-clock vs node count, 8 to 4096 nodes

Why it stays O(log N): greedy fan-out

A single source saturates its NIC at ~8 outbound copies. So each node that finishes becomes a source for the next batch: the tree grows recursively.

flowchart LR S(["source<br/>(shared FS)"]) --> L["node00<br/>/tmp"] L --> A1["node01"] L --> A2["node02"] L --> Ad["…"] L --> A8["node08"] A1 --> B1["node09"] A1 --> Bd["…"] A1 --> B8["node16"] A2 --> C1["node17"] A2 --> Cd["…"] A2 --> C8["node24"] classDef src fill:#ee8f2410,stroke:#ee8f24,color:#ee8f24,stroke-width:1.5px classDef g1 fill:#118cc210,stroke:#118cc2,color:#118cc2,stroke-width:1.5px classDef g2 fill:#1da81110,stroke:#1da811,color:#1da811,stroke-width:1.5px classDef dots fill:#83838308,stroke:#83838388,color:#838383,stroke-width:1px class S,L src class A1,A2,A8 g1 class B1,B8,C1,C8 g2 class Ad,Bd,Cd dots
  • Cap 8 outbound / source; a thread pool load-balances to the least-busy one
  • Faster nodes don’t wait: finish early → serve early
  • Result: ~O(log N) wall-clock, not the O(N) of a single-source star

The parallelism menu

Bethany covered these in depth. Fast recap as a decision menu: five axes, mix and match (“4D parallelism” = several stacked). The only question: what each splits.

StrategyAbbr.What it splitsYou reach for it when…
Data ParallelDPthe batch: every GPU holds a full model copy, sees different datathe model fits on one GPU
Tensor ParallelTPindividual weight matrices across GPUs (within a layer)a single layer is too big; needs fast interconnect
Pipeline ParallelPPthe model by layer (stages), streaming micro-batches throughthe model is deep and will not fit vertically
Sequence / ContextSPthe sequence dimension (long context) across GPUscontext length blows up activation memory
Expert ParallelEPexperts of an MoE layer across GPUsyou are training a Mixture-of-Experts
Read it as a hierarchy of cost

DP is nearly free (no model changes). TP is chatty (all-reduce inside every layer, keep it on-node). PP needs careful micro-batch scheduling. Add them in that order of pain.

In pictures (1/2): replicate, shard, split a layer

Data Parallel — model replicated, batch sliced, grads averaged
flowchart LR x0["x0"] --> N0["NN → Loss"] x1["x1"] --> N1["NN → Loss"] N0 --> A["avg<br/>grads"] N1 --> A classDef d fill:#f2b8b508,stroke:#e05560,color:#e05560,stroke-width:1.5px classDef m fill:#f9d79508,stroke:#ee8f24,color:#c9750f,stroke-width:1.5px classDef g fill:#83838310,stroke:#838383,color:#838383,stroke-width:1.5px class x0,x1 d class N0,N1 m class A g
FSDP / ZeRO — params sharded, all-gathered just-in-time per layer
flowchart LR G0["GPU0<br/>shard A"] <-->|"all-gather"| G1["GPU1<br/>shard B"] G1 <-->|"all-gather"| G2["GPU2<br/>shard C"] classDef s fill:#f9d79508,stroke:#ee8f24,color:#c9750f,stroke-width:1.5px class G0,G1,G2 s
Tensor Parallel — each layer’s matmul sliced; all-reduce every layer
flowchart LR subgraph GPU0 A0["Layer k · half A"] end subgraph GPU1 A1["Layer k · half B"] end A0 <-->|"all-reduce (every layer)"| A1 classDef t fill:#118cc208,stroke:#118cc2,color:#118cc2,stroke-width:1.5px class A0,A1 t
  • DP is the default (nothing changes). FSDP trades comms for memory. TP is chatty (all-reduce every layer): keep it on-node.

In pictures (2/2): split the depth, split the sequence

Pipeline Parallel — layer stack split into stages; micro-batches pipelined
flowchart LR subgraph GPU0 direction LR L0["L0"] --> L1["L1"] end subgraph GPU1 direction LR L2["L2"] --> L3["L3"] end L1 -->|"stage hand-off"| L2 classDef p fill:#1da81108,stroke:#1da811,color:#1da811,stroke-width:1.5px class L0,L1,L2,L3 p
Sequence / Context — sequence sliced per rank; ring-attention rotates KV
flowchart LR S0["GPU0<br/>seq 0 · KV 0"] -->|"KV"| S1["GPU1<br/>seq 1 · KV 1"] S1 -->|"KV"| S2["GPU2<br/>seq 2 · KV 2"] S2 -->|"KV rotates the ring"| S0 classDef c fill:#7c4ed508,stroke:#7c4ed5,color:#7c4ed5,stroke-width:1.5px class S0,S1,S2 c
  • PP splits the layer stack into stages across nodes; SP/CP splits the sequence for long context (ring-attention over KV). EP (not shown) splits MoE experts.

ZeRO / FSDP: the memory vs comms trade

Still data parallel: shard the replicated state, gather on demand. Each stage shards one more thing, so per-GPU memory drops.1

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.
Each stage shards one more component into a 1/N per-GPU slice; the greyed track is memory freed on that GPU.
The trade

Buy memory with comms: an all-gather per step. More stages = less memory, more comms.

  • ✅ model almost fits, or you want a bigger batch
  • ❌ a single layer won’t fit → that’s TP / PP

What you’ll actually pick

Climb only when memory forces you: DP → FSDP/ZeRO → TP → PP.

  • Fits on one GPUDP, scale batch
  • Almost fitsFSDP/ZeRO (stage 1, then 3)
  • A layer won’t fitTP on-node, PP across
  • Long contextSP · MoEEP
Tip

ezpz launch Same script, every machine. No mpiexec / srun / bind wrappers.

%%{init: {'themeVariables': {'fontSize': '22px'}, 'flowchart': {'nodeSpacing': 55, 'rankSpacing': 45, 'padding': 14}}}%% flowchart LR EZ(["<span style='font-weight:700'>ezpz launch</span>"]) EZ --> AURORA["<i>PBS · Intel</i><br/><span style='font-weight:700'>Aurora</span>"] EZ --> POLARIS["<i>PBS · NVIDIA</i><br/><span style='font-weight:700'>Polaris</span>"] EZ --> PM["<i>SLURM · NVIDIA</i><br/><span style='font-weight:700'>Perlmutter</span>"] EZ --> FRONTIER["<i>SLURM · AMD</i><br/><span style='font-weight:700'>Frontier</span>"] EZ --> MAC["<span style='font-weight:700'>Multi-CPU</span>"] classDef hub fill:#ee8f2408,stroke:#ee8f24,color:#ee8f24,stroke-width:1.5px classDef aurora fill:#3b82f608,stroke:#3b82f6,color:#3b82f6,stroke-width:1.5px classDef polaris fill:#10b98108,stroke:#10b981,color:#10b981,stroke-width:1.5px classDef perlmutter fill:#06b6d408,stroke:#06b6d4,color:#06b6d4,stroke-width:1.5px classDef frontier fill:#ef444408,stroke:#ef4444,color:#ef4444,stroke-width:1.5px classDef mac fill:#a78bfa08,stroke:#a78bfa,color:#a78bfa,stroke-width:1.5px class EZ hub class AURORA aurora class POLARIS polaris class PM perlmutter class FRONTIER frontier class MAC mac

Before you commit: find the learning rate

The last knob before a full-machine run. Sweep the LR on a short run and read the curve, don’t guess a number and burn 2k nodes finding out it was wrong.

  • flat/high at small LR: trains, just slowly
  • basin: loss drops into the usable band
  • cliff: one step too big → grads to infNaN
  • pick just below the cliff: the fastest rate that still has safety margin as the run heats up
Cheap insurance

A few hundred steps to sweep saves a full-scale run that NaNs at step 7.

Conceptual LR-finder: loss vs learning rate on a log axis, flat at small LR, a basin (sweet spot, circled) in the middle, then a steep cliff diverging to NaN at large LR

You’ve launched. Now everything tries to kill it.

Part 1 spent the compute well. Part 2 is about not wasting what you’ve spent.

See you after the break.

Part 2: now keep it alive

Part 1 got a run started. That was the easy part.

  • Not a program you start: one you babysit for weeks
  • Every crash you don’t recover is compute you already paid for, thrown away
  • So the question becomes: how cheaply do you recover when (not if) it breaks?
The rest of this talk

Good news: the loss curve. Bad news: everything that can interrupt it.

Where we’re headed: AuroraGPT-2B to 7.77T tokens

One continuous run, 154K steps, three data-mix stages. Everything in Part 2 is what it takes to keep this curve going.

AuroraGPT-2B loss vs tokens over one continuous 7.77T-token run: train and val loss track together, plateau, then step down sharply at each of two data-mix stage transitions (4.673T and 7.064T tokens), ending at loss 2.03.
Each data-mix transition (dashed) steps the loss down; train and val stay locked together to a final 2.03.

And the full picture: every run, one axis

Every canonical AuroraGPT pretraining run, loss vs tokens. Bigger models fall faster per token; the 2B reference rides three data-mix stages the furthest.

Training loss vs tokens for five overlaid AuroraGPT chains: 2B-MDS reference (blue dashed) descending through two data-mix stage transitions to 2.03 at 7.77T tokens; 2B torchtitan n256 (salmon) and n512 (dark red) tracking near 2.65-2.71; and the two 20B chains (n256 orange dashed, n512 green) dropping steeply per token to about 2.44.
The 20B chains (green, orange) drop fastest per token; the 2B-MDS reference (blue) goes furthest, stepping down at each data-mix transition to 2.03.

Batch size: the ceiling, and the knobs

BcritB_{\text{crit}}: the largest batch where more data-parallel workers still buy near-linear speedup.2

Speedup vs global batch on log-log axes. An actual (blue) speedup curve tracks the ideal linear reference closely at small batch (green 'near-linear' region, labelled '2x batch is about half the steps'), then bends away and flattens past a red dashed B_crit marker (grey 'speedup saturates' region).

The ceiling

  • below: 2x batch ≈ half the steps, more nodes nearly free
  • above: speedup saturates, training gets unstable34
  • HPC hits it first: INCITE jobs want ~2k nodes, past BcritB_{\text{crit}}
  • fix is TP/PP/EP + a batch you chose, not “more DP”

The knobs (and where they break)

  • LR scaling (linear / B\sqrt{B})2 + longer warmup + grad clip
  • linear LR backfires: 80B NaN’d earlier (step 7 vs 29)
  • LRs don’t transfer: mano@48 loses to AdamW@384 (unless you use μP5)
Tip

Scheduling (grab the machine) and the math (stay under BcritB_{\text{crit}}) pull opposite ways. Re-tune at the target batch and node count, don’t extrapolate a small-batch LR up a 100x jump and hope.

mano: Muon quality at AdamW speed

mano6 normalizes updates on a rotating Oblique manifold with O(dim)O(\text{dim}) vector-norm ops (no Newton-Schulz iterations). So it matches Muon’s loss without Muon’s throughput tax.

OptimizerLossTPS
Muon3.5574,556
mano3.6317,048
AdamW3.8017,245
SophiaG4.7197,208
  • Muon and mano tie on loss (~3.6); mano runs at AdamW speed (~7,000 vs ~4,600 TPS), so it wins on wall-clock.
  • Caveat: at large batch (GBS=384) AdamW still wins (2.71 vs 2.88); mano’s LR was tuned at GBS=48 and needs re-tuning.
Training loss vs step for Muon, mano, AdamW, SophiaG at 2B / GBS=48 over 1000 steps: mano and Muon converge lowest and nearly overlap, AdamW above, SophiaG highest

Second-order optimizers: the landscape

AdamW is the baseline. Everything else buys curvature at some compute price.

OptimizerCore idea
AdamW7diagonal 2nd moment + decoupled weight decay
SophiaG8clipped Hessian-diagonal: light curvature, cheap
Shampoo9Kronecker-factored preconditioner per layer
SOAP10Adam in Shampoo’s eigenbasis: stabler, fewer knobs
Muon11Newton-Schulz orthogonalize: a cheap Shampoo-like step

Shampoo → SOAP → Muon is one family: same second-moment structure, cheaper each step. SophiaG takes the other cheap route: a clipped diagonal Hessian.12

AuroraGPT-2B optimizer comparison at 50M tokens/batch on 256 nodes. Left: training loss (log) vs consumed tokens for AdamW, Lamb, Muon, SophiaG; SophiaG reaches the lowest loss. Right: gradient norm (log); Muon's grad norm spikes ~six orders of magnitude at ~1.1T tokens (divergence) while SophiaG stays lowest and bounded.
Real 2B runs at GBS 6,144: SophiaG reaches the lowest loss with bounded grad norm; Muon diverges at ~1.1T tokens.

80B: one bf16 cliff, three dead optimizers

At 80B (dim=9216), every constant-finder LR NaN-ed in the first ~dozen steps: grad_norm went to inf one step before the loss, while loss was still flat (~12.9).

OptimizerDied atSignature
manostep 5grad NaN (diverged first)
AdamWstep 9grad NaN
SophiaGstep 14Hessian-term overflow, ~6,100 node-h
  • mano died first (step 5), despite the safest-looking LR band
  • shared failure across 3 → a bf16-at-dim=9216 corner, not tuning
  • fix: long warmup + grad clip; stable at TP=4 · LBS=1
80B LR-finder at GBS=6144: mano, SophiaG, AdamW, muon loss-vs-LR. AdamW and muon cliff straight to NaN with no minimum; mano and SophiaG reach a real U-minimum first

80B LR-finder (GBS=6,144): AdamW and muon cliff straight to NaN; mano and SophiaG reach a minimum first, then blow up past it.

At scale, failure is the default

You are running one job across thousands of nodes in lockstep. The job dies when any node dies, so cluster MTBF is roughly single-node MTBF divided by N.

  • one node fails ~once a year; put 16,000 in one job → ~44 failures/day
RunScaleFailures
Llama 3 405B1316K H100s · 54 d419 (1 every 3h); 99% auto-recovered
OPT-175B14~1K A100s · 2 mo35 manual restarts + 100+ hosts cycled
BLOOM-176B15384 A100s · 3.5 mofrequent loss spikes
GLM-130B16768 A100s · 2 mospikes worsen; some NaN

Plan for the failure, not for the happy path.

The Lustre tax comes back, now on the write path

In Part 1 the shared-FS tax hit reads: staging the env off Lustre with ezpz yeet. Checkpointing hits the same wall from the other side.

  • reads (Part 1): every rank stat()s the same env → stage node-local
  • writes (now): every rank flushes a 232 GB checkpoint to the same MDS
  • same fix, same fan-out: coordinate the writes, don’t stampede
Why it’s worse for checkpoints

A stale env costs you a slow startup. A checkpoint write that contends with training collectives can take the whole job down (next slide).

Checkpointing: what, when, how big

Your only insurance policy. Resume from the last one, everything since is wasted.

What goes in

  • model weights (fp32 master)
  • optimizer state (~2x the model)
  • LR sched, step, RNG, dataloader pos

How big (measured)

  • 20B → 232 GB. Sync save stalls training ~23.6 s; reload is ~55-63 s (dominated by dcp.load).

When: the cadence tradeoff

  • too frequent: write overhead eats throughput
  • too rare: a crash throws away hours

EV rule: interval where wasted work on a crashwrite overhead. At a failure every few hours, and a ~24 s blocking save, that lands in the tens-of-minutes range.

The ~24 s stall is why you push it off the critical path → async, next.

Asynchronous checkpointing, and how it melted the cluster

Done right, async hides the 232 GB write behind the next few hundred steps: snapshot to host memory, flush on a background thread, bound the fan-out.

Per-save training-thread stall, sync vs async, at 2B (23 GB) and 20B (232 GB). Sync blocks on the full write (3.75s / 23.6s); async pays only a short stage + drain (1.05s / 5.4s), 3.6x and 4.4x less stall.
Sync blocks the training thread on the whole write; async pays only stage + drain while the GPUs keep going. Measured on Aurora (ezpz guide).
  • snapshot to host memory, then flush on a background thread
  • bound concurrency: cap ranks flushing at once
  • reload is ~55-63 s at 20B (dcp.load-bound)
The naive version took the job down

Unbounded async at 20B / 512N+ contends with training collectives on the same fabric. Bound the fan-out; write a .complete marker last.

Restarting quickly

The recovery clock runs crash → first-step-back-training, not crash → “file exists.”

Reload fast (reload is ~55-63 s at 20B, dcp.load-bound)

  • stage node-local: ezpz yeet checkpoint-729/tmp, not 512 ranks pulling 232 GB off Lustre at once
  • same fan-out as the venv, any dir or tarball

Reload anywhere: converters

  • Megatron ⇄ 🤗 HF ⇄ ZeRO ⇄ Universal
  • Universal decouples from the parallelism layout → resume at a different TP/PP/DP than you saved at
  • the converted HF checkpoint is what you evaluate, serve, or fine-tune from

”The restarts”: three layers of recovery

Bad-node failover, hang-watchdog, and PBS resubmit each operate at a different scope.

flowchart TB subgraph job["📋 JOB scope: PBS · hours"] JOB_TXT["<i>crash / walltime</i> →<br/><span style='font-weight:700'>chained resubmit</span>"] subgraph node["🖥 NODE scope: failover wrapper · minutes"] NODE_TXT["<i>bad host detected</i> →<br/><span style='font-weight:700'>swap from spare pool</span>"] subgraph proc["⏱ PROCESS scope: ezpz launch · seconds"] PROC_TXT["<i>stdout idle ≥ N s</i> →<br/><span style='font-weight:700'>kill + backoff</span>"] end end end classDef jobC fill:#118cc208,stroke:#118cc2,color:#118cc2,stroke-width:1.5px classDef nodeC fill:#ee8f2408,stroke:#ee8f24,color:#ee8f24,stroke-width:1.5px classDef procC fill:#1da81108,stroke:#1da811,color:#1da811,stroke-width:1.5px classDef jobTxt fill:transparent,stroke:none,color:#118cc2 classDef nodeTxt fill:transparent,stroke:none,color:#ee8f24 classDef procTxt fill:transparent,stroke:none,color:#1da811 class JOB_TXT jobTxt class NODE_TXT nodeTxt class PROC_TXT procTxt class job jobC class node nodeC class proc procC
flowchart TB subgraph proc["⏱ PROCESS · seconds"] PROC_TXT["<i>stdout idle ≥ N s</i> →<br/><span style='font-weight:700'>kill + backoff</span>"] end subgraph node["🖥 NODE · minutes"] NODE_TXT["<i>bad host detected</i> →<br/><span style='font-weight:700'>swap from spare pool</span>"] end subgraph job["📋 JOB · hours"] JOB_TXT["<i>crash / walltime</i> →<br/><span style='font-weight:700'>chained resubmit</span>"] end proc -- "process exit" --> node node -- "node exhaustion" --> job classDef jobC fill:#118cc208,stroke:#118cc2,color:#118cc2,stroke-width:1.5px classDef nodeC fill:#ee8f2408,stroke:#ee8f24,color:#ee8f24,stroke-width:1.5px classDef procC fill:#1da81108,stroke:#1da811,color:#1da811,stroke-width:1.5px classDef jobTxt fill:transparent,stroke:none,color:#118cc2 classDef nodeTxt fill:transparent,stroke:none,color:#ee8f24 classDef procTxt fill:transparent,stroke:none,color:#1da811 class JOB_TXT jobTxt class NODE_TXT nodeTxt class PROC_TXT procTxt class job jobC class node nodeC class proc procC

Inner loops catch most failures; outer loops catch the rest.

--auto-retry: bad-node failover, on tap

The NODE layer: allocate spares up front, swap them in on failure. Out of bash, into ezpz.

# 522 nodes allocated; train on 512, 10 auto-reserved as spare.
ezpz launch --auto-retry --nhosts 512 \
  -- python -m torchtitan.train
  • bad-node → scrape host, swap spare, re-exec
  • config-bug guard: 2 attempts with zero step= → stop

Broke the 20B/512N stall: a stale placeholder → walltime-blocked weeks; relaunch drove it 4,400 → 5,400. Ships in ezpz#144.

flowchart TB START(( )) -->|"qsub / sbatch"| LAUNCH["<span style='font-weight:700'>ezpz launch</span><br/><span style='font-weight:700'>--auto-retry</span>"] LAUNCH -->|"run + idle watchdog<br/><i>exit 124 if 30 min silent</i>"| CLASSIFY["<span style='font-weight:700'>classify exit</span><br/><i>parse trailer · crash<br/>patterns · SIGTERM</i>"] CLASSIFY -->|"success<br/><i>rc = 0, no crash</i>"| DONE["<span style='font-weight:700'>DONE</span><br/>rc = 0"] CLASSIFY -->|"walltime / stuck /<br/>no spares left"| STOP["<span style='font-weight:700'>STOP</span><br/><i>spares exhausted ·<br/>stuck pre-training</i>"] CLASSIFY -.->|"bad node <i>(crash / hang)</i><br/>swap in spare, retry"| LAUNCH classDef launchC fill:#7c4ed508,stroke:#7c4ed5,color:#7c4ed5,stroke-width:1.5px classDef classifyC fill:#ee8f2408,stroke:#ee8f24,color:#ee8f24,stroke-width:1.5px classDef doneC fill:#1da81108,stroke:#1da811,color:#1da811,stroke-width:1.5px classDef stopC fill:#e0556008,stroke:#e05560,color:#e05560,stroke-width:1.5px classDef startC fill:#838383,stroke:#838383,color:#838383 class START startC class LAUNCH launchC class CLASSIFY classifyC class DONE doneC class STOP stopC linkStyle 4 stroke:#e05560,color:#e05560

The detector under it all: the idle-timeout watchdog

--auto-retry (previous slide) needs to know a job hung. A hang quiets stdout, so idle stdout is the signal, and this watchdog is what fires on it.

# --auto-retry turns the watchdog on for you (idle default = FAILOVER_IDLE_TIMEOUT).
# Standalone, without node failover: --timeout is the detector, --retries re-execs.
ezpz launch --timeout 600 --retries 3 \
    python -m torchtitan.train --config-file ./config.toml
  • --timeout SECONDS: kill if stdout goes idle (not walltime); exit 124
  • --retries N: bounded re-exec, backoff 5/10/20/40/60s. Mutually exclusive with --auto-retry (bounded per-process vs unbounded node-level)
stateDiagram-v2 direction LR [*] --> Running Running --> Hung: no stdout for N seconds Hung --> Killed: SIGTERM → SIGKILL Killed --> Running: backoff, re-exec

Fires on absence of progress, not a heartbeat ping (a hung job is “alive” by kill -0).

It caught a real silent hang in production

Job 8505298, 2026-05-23. Trains steps 1-37, then the log goes silent. No traceback, no rank dying. Just dead.

Time (CT)Event
21:06:41step 37 logged · loss 11.80 · tps 3,919
21:36:4130 min dead air · ezpz launch --timeout=1800 SIGTERMs
21:36:43wrapper classifies exit 124 to silent-hang (not walltime)
21:36:43no traceback to scrape, so blind swap of rank-0 host
21:36:45attempt 2 launches on swapped node set
21:57:49walltime hit · step 296 · loss 5.68 · ckpts persisted

Three new pieces fired in sequence on a real hang: the --timeout=1800 watchdog, the exit 124 classification, and failover_swap_one_blind(). Unattended, 9:36 PM on a Friday. Full writeup.

Where production stands

What the machinery above actually produced. The next few slides zoom in on these.

RunNodesStepsTokensLossStatus
2B base25692,8594.674T (100%)2.65complete
2B-MDS ref256154,3917.770T (166%)2.03✅ 3-stage reference
20B/512N5126,010605B (13%)2.44advancing (auto-retry)
20B/256N2566,301159B (3%)2.44advancing
80B51214(NaN’d)NaN❌ optimizer cliff
  • Two 2B runs complete; both 20B chains advancing; the 80B hit the bf16 optimizer cliff (the wall from Part 2).

The flagship run: AuroraGPT-2B to 7.77T tokens

One continuous run, 154K steps, three data-mix stages. It rode the whole Part 2 machinery: async checkpoints, layered auto-restart, silent-hang recovery.

AuroraGPT-2B loss vs tokens over one continuous 7.77T-token run: train and val loss track together, plateau, then step down sharply at each of two data-mix stage transitions (4.673T and 7.064T tokens), ending at loss 2.03.
Each data-mix transition (dashed) steps the loss down; train and val stay locked together to a final 2.03.

The whole program, one axis

Every canonical AuroraGPT pretraining run, loss vs tokens. Bigger models fall faster per token; the 2B reference rides three data-mix stages the furthest.

Training loss vs tokens for five overlaid AuroraGPT chains: 2B-MDS reference (blue dashed) descending through two data-mix stage transitions to 2.03 at 7.77T tokens; 2B torchtitan n256 (salmon) and n512 (dark red) tracking near 2.65-2.71; and the two 20B chains (n256 orange dashed, n512 green) dropping steeply per token to about 2.44.
The 20B chains (green, orange) drop fastest per token; the 2B-MDS reference (blue) goes furthest, stepping down at each data-mix transition to 2.03.

What it costs: throughput and MFU

Same chains, the compute side: sustained per-GPU throughput and how much of the hardware’s FLOPs we actually use.

Two panels vs tokens for the five chains. Left, tokens/sec/GPU: 2B-MDS sustains ~4,700, the 2B torchtitan chains ~2,500-3,200, the 20B chains ~350-450 (a 10x-bigger model does fewer tokens/sec/GPU). Right, model FLOPs utilization: the 20B chains run higher (~17-22%) than the 2B chains (~9-12%).
Bigger model = fewer tokens/sec/GPU but higher MFU (20B ~17-22% vs 2B ~9-12%): the 20B keeps the XPUs busier per step.

It learns: eval vs tokens, and the 20B is most efficient

Loss falling is necessary, not sufficient. The eval gate is what says the model learned, and per token the 20B pulls ahead of both 2B chains.

lm-eval accuracy vs tokens consumed on four benchmarks (HellaSwag, ARC-Easy, ARC-Challenge, Winogrande) for the 2B v2 256N and 512N chains, the 20B v2 512N chain, and the 2B-MDS reference. On every benchmark the 20B (green) rises far steeper per token, reaching ARC-Easy ~0.69 and HellaSwag ~0.63 by ~440B tokens, beating both 2B chains at matched token counts.
Four benchmarks vs tokens. The 20B (green) climbs steepest per token: ARC-Easy ~0.69 by ~440B tokens, already past both 2B chains. More model per GPU-hour, made literal.

Putting it together: one resilient launch

Everything in Part 2 collapses into a startup ritual and a single launch line. Nothing here is aspirational: each flag is one we saw earn its keep.

# 1. stage the env node-local (off Lustre) · Part 1
ezpz yeet --compress
source /tmp/.venv/bin/activate

# 2. launch with all three recovery layers armed · Part 2
ezpz launch \
    --auto-retry \                 # NODE:    swap bad hosts from the spare pool
    --np 512 \                     #          (allocate 522, keep 10 spare)
    --timeout 1800 \               # PROCESS: kill on 30 min of stdout silence
    --retries 3 \                  #          re-exec with exponential backoff
    -- python3 -m torchtitan.train --config-file ./config.toml
# JOB layer: a chained PBS resubmit wraps the whole thing (hours scope)
  • Data blended + pinned, env broadcast off Lustre, checkpoints async and node-local-stageable, failures caught at process / node / job scope.
  • The person who launches this can close the laptop. The 9:36-PM-Friday silent hang recovered itself.
The whole talk in one line

It all serves one number: model quality per GPU-hour. Spend the compute well (Part 1), and make recovery so cheap that a failure wastes almost none of it (Part 2).

What generalizes, what doesn’t

Generalizes (vendor / site / model)

  • Bit-exact deterministic smoke gate after every upstream sync
  • lm-eval as ground truth for “is it actually learning?”
  • Spare-node failover wrapper: same idea on Slurm
  • Launcher / env autodetect: push vendor-shaped assumptions out of training code

Doesn’t (re-tune per config / hardware / version)

  • torch.compile decisions (helps dense, can hurt)
  • Collective tuning (CCL vs gloo fallbacks, NCCL/CCL env)
  • Optimizer stability at the bf16 corner: LR-finder rank ≠ sustained stability

What to take home

Five ways to get more model per GPU-hour:

The five lessons

  1. Data prep is a distributed-systems problem. Tokenize once, blend by weight, pin everything for reproducibility.
  2. Do not hit Lustre from every rank. Stage node-local; broadcast the env.
  3. Add parallelism in order of pain: DP → FSDP/ZeRO → TP → PP, and stay under the critical batch size.
  4. Loss is not ground truth. Gate on evals; a bit-exact smoke test catches silent corruption.
  5. Do not prevent failures, recover cheaply. Async checkpoints + layered auto-restart.

Start here

Deeper write-ups

Try it yourself: FSDP + TP in four lines

Everything in this talk, runnable now. Copy-paste onto any of these machines.

# 1. bootstrap the ezpz environment (auto-detects the machine)
source <(curl -fsSL https://bit.ly/ezpz-utils) && ezpz_setup_env
uv pip install --no-cache --link-mode=copy "git+https://github.com/saforem2/ezpz"

# 2. smoke test first (always)
ezpz launch python3 -m ezpz.examples.test

# 3. full FSDP + TP pre-training
ezpz launch python3 -m ezpz.examples.fsdp_tp
Same script, every machine

No mpiexec / srun / bind wrappers: ezpz launch auto-detects the scheduler and device. Smoke-test before the real run, every time.

QR code linking to samf.sh/talks/2026/08/03samf.sh/talks/2026/08/03

Thanks

AuroraGPT team: Venkat Vishwanath, the AI/ML Group at ALCF, collaborators across ANL.

Argonne Leadership Computing Facility: Aurora time, Sunspot staging.

Intel: Intel Max 1550 XPU + oneAPI / XCCL / IPEX support throughout.

Code & docs

This research used resources of the Argonne Leadership Computing Facility, which is a DOE Office of Science User Facility supported under Contract DE-AC02-06CH11357.

Questions?

Footnotes

Appendix: backup slides

Material that didn’t make the main path but is here for Q&A. Pull the full slides from the AuroraGPT-at-scale deck as needed:

  • Post-training: CPT · SFT · GRPO curves (covered in Jane’s talk)
  • MoE on Intel XPU: throughput and where it stands
  • AERIS: a production case study on the same stack
  • Failover engineering: the full silent-hang recovery write-up
  • Open questions we’d like this community’s help on

A field guide to what breaks

The cost is telling transient (retry, fine) from systemic (retry reproduces it).

Failure taxonomy grid: rows are where it broke (Hardware, Software, Network, System), columns are transient (retry and you're fine) vs systemic (retrying just reproduces it). Silent failures with no traceback (corrupt shard, bad upstream commit, collective hang) are marked as the dangerous ones.

War story: the fabric that wouldn’t sit still

At full-machine scale the interconnect is never fully healthy. It shows up two ways:

  • Loud: gloo Connection closed by peer (job 8470102). You get an exit code.
  • Quiet: a collective just stops. Every rank blocks in the same all_reduce.

The quiet one is dangerous:

  • alive by kill -0, nothing crashed
  • xccl ignores train_timeout_seconds → eats full PBS walltime
  • job 8479579 hung at step 803, heartbeat still ticking

The one signal you can trust: a hang quiets stdout. Absence of progress is the detector (→ watchdog).

Footnotes

  1. ZeRO: Memory Optimizations Toward Training Trillion Parameter Models (Rajbhandari et al. 2020), Fig. 7. Per-param Adam mixed-precision budget: 2 (fp16 params) + 2 (fp16 grads) + 12 (fp32 master + momentum + variance) = 16 bytes; sharding across N ranks drives the sharded piece toward 16/N.

  2. An Empirical Model of Large-Batch Training (McCandlish et al. 2018): LR and warmup both need to grow with batch, and the payoff flattens at the critical batch size. 2

  3. How Does Critical Batch Size Scale in Pre-training? (Zhang et al. 2025).

  4. How to Set the Batch Size for Large-Scale Pre-training? (Zhou et al. 2025).

  5. μP (maximal-update parametrization): Tensor Programs V (Yang et al. 2022). Reparametrize so the optimal LR is invariant to width, tune on a small proxy, then transfer to the full model (“μTransfer”).

  6. mano: manifold-normalized optimization (2026). Implemented in our fork alongside SPAM (2501.06842).

  7. Decoupled Weight Decay Regularization (Loshchilov & Hutter 2019).

  8. Sophia: A Scalable Stochastic Second-order Optimizer (Liu et al. 2023). A clipped diagonal-Hessian preconditioner; SophiaG estimates the diagonal with the Gauss-Newton-Bartlett trick.

  9. Shampoo: Preconditioned Stochastic Tensor Optimization (Gupta et al. 2018). Kronecker factors of GGGG^\top.

  10. SOAP: Improving and Stabilizing Shampoo using Adam (Vyas et al. 2024). Runs Adam in Shampoo’s eigenbasis, refreshed every kk steps.

  11. Muon: MomentUm Orthogonalized by Newton-Schulz (Jordan et al. 2024/2025).

  12. Also common: LAMB (You et al. 2020), layer-wise trust ratio for huge batches; Adopt (Taniguchi et al. 2024), converges for any β2\beta_2; Sophia (Liu et al. 2023), clipped Hessian-diagonal.

  13. Llama 3 herd of models (Meta AI, 2024), §3.3.2 (Training reliability).

  14. OPT-175B chronicles + dev log (Zhang et al., 2022).

  15. BLOOM: A 176B-Parameter Open-Access Multilingual Language Model (BigScience, 2022).

  16. GLM-130B: An Open Bilingual Pre-Trained Model (Zeng et al., ICLR 2023).

samf.sh/talks/2026/08/03/ 1
 Slides
Theme
 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 ?This dialog