Towards Resilient Training and Fault-Tolerant Software at ALCF

Reliability as a first-order design problem for AI-for-Science at scale

Sam Foreman · Argonne National Laboratory
DEPEND Annual Retreat · October 30, 2026 — UIUC
Interactive version: live diagrams + linksQR code linking to samf.sh/talks/2026/10/30samf.sh/talks/2026/10/30

ALCF and Aurora

  • Aurora — 10,624 nodes · 127,488 XPUs (12 tiles / node in FLAT mode) · HPE Cray EX · Slingshot-11 · system paper
  • ALCF — DOE open science · Aurora + Polaris (560× A100)
  • My corner — AI-for-Science campaigns (AuroraGPT, MProt-DPO, AERIS) and the glue between PyTorch and the machine: ezpz / torchtitan/ezpz
XPU is one GPU tile exposed as torch.xpu in FLAT mode. 12 / node. Polaris runs the same software at smaller N — fewer surprises, same bugs.

Outline

  • I · Inevitable — Why does scale force dependability into the design?

    • The MTBF math at 2k nodes and what Llama 3 / OPT / BLOOM ate.
  • II · Observed — What really fails on Aurora?

  • III · Alive — How do we keep a multi-day job running?

    • A 3-scope recovery cascade, its measured cost, and the open problems.

Training is a dependability workload

Training is a dependability problem now:

  • Long, tightly coupled, all-reduce bound. One dead rank stalls everyone.
  • State: TBs of model + optimizer. Checkpoint is the only safe harbor.
  • Naïve restart cost: hours × thousands of nodes, wasted.

The tension: failure rate scales with N. Recovery cost scales with state. As models grow both get worse together.

Why Aurora is a good lens:

  • New hardware + new torch.xpu. Every layer can break, so we saw everything.
  • Real production jobs at 512–3,200 nodes, not just benchmarks
  • Live failure data: hangs, bad nodes, Lustre stalls, loss spikes
  • Portable stack (ezpz) → compare Intel / NVIDIA / AMD profiles

At scale, failure is math

Even with a generous MTBF of 3 years per node (~26k h), a 2,048-node run (~25k torch.xpus — ~20% of Aurora) has a

system MTBF ≈ 13 hours

About once a day on a multi-day run.

CampaignScaleWhat they reported
Llama 3 405B (Meta, 2024)116K H100 · 54 days419 interruptions — ~1 / 3 h, 78% hardware, mostly HBM/NIC
OPT-175B (Meta, 2022)2~1K A100 · 2 months35 manual restarts + 100+ cycled hosts
BLOOM-176B (BigScience, 2022)3384 A100 · 3.5 monthsFrequent loss spikes; embedding-norm + checkpoint cadence as mitigation
GLM-130B (Zeng et al., 2023)496 → 392 DGX-A100Spikes “increasingly frequent” at scale; some recover, some go to NaN

And the scheduler makes it worse

We get big, short, shared allocations. Training wants long, tightly-coupled, stateful.

  • Too big. INCITE is ≥ 20% of Aurora (~2k nodes)5. That forces a huge batch and worse optimization6.
  • Too short. Walltime is hours, runs are weeks. Every restart boundary can silently fail.
  • Too shared. Lustre is communal. Import storms stall you and your neighbors.
  • Too stuck. No preemption or migration. One bad node holds 10k hostage unless you route around it.

Part II — What actually fails

What we’ve actually seen break

Five scopes, 2,048 nodes, ~25k XPU. What we saw.

What actually fails: a taxonomy

ScopeExamples we’ve seenTypical symptomWho recovers
HardwareHBM ECC, GPU hang, NIC reset, node crashRank disappears · NCCL timeout · SIGSEGV/SIGABRTScheduler + spare nodes
FabricSlingshot transient, oneCCL/MPI transport wedgeALLGATHER hangs · collective timeout (default 600s)Watchdog kill → retry
StorageLustre import stall, metadata stormRanks block on ckpt / data load · stdout goes quietWatchdog; backoff; resubmit
Software stacktorch.xpu kernel gap, XCCL collective bug, DeepSpeed fragility at scaleDeterministic crash at same step · CheckpointError (routing nondeterminism)Pin / patch; exclude op
Training dynamicsLoss spike → NaN, silent bad step (no crash, wrong answer)Diverging norm · eval regression with no errorSpike detector; rollback to last good ckpt

Pattern: inner layers catch most events; silent data / training faults are the hardest because nothing crashes.

What it looks like in practice

① SFT that wouldn’t die — 32N, 3 crashes, 4 tries

32-node SFT (tulu_math_uc_mix, 4.5B tokens). OneCCL fault at 32N, three hosts. Each crash → watchdog kill, spare swap, resume from last checkpoint. Finished at 0.77. GRPO reused checkpoint-729-hf the next day. No human.

② One line fixed a hang — CCL_OP_SYNC=1

FSDP2 + TP>1 on XPU hung forever on async collectives. NCCL default doesn’t hold on XPU. Fix was one line: ezpz now forces CCL_OP_SYNC=1 (#172). Documented, not optional.

③ 20B beats 2B — recovery that pays in science

Same data, bigger model. 512N (20B) beats 256N (2B) per token. HellaSwag 0.635 vs 0.555. Only possible because the run finished. Better models, not just better uptime.

One training program across vendors

Hello world:

# example.py
import torch
import ezpz
rank = ezpz.setup_torch()
world_size = ezpz.get_world_size()
dev = ezpz.get_torch_device()      # {xpu, cuda, cpu, ...}
# FSDP2 by default — accepts DeviceMesh
model = ezpz.wrap_model(torch.nn.Linear(128, 10).to(dev))
print(f"Hello from {rank}/{world_size}!")
$ ezpz launch python3 example.py
Hello from rank 0/24!
Hello from rank 1/24!
# ... etc
Other examples
# your own script — no changes needed
ezpz launch python3 your_training_script.py
# full HF model + dataset via ezpz examples
ezpz launch python3 -m ezpz.examples.fsdp_tp \
    --dataset=allenai/olmo-mix-1124
    --model=meta-models/Muse-Spark-Open-816B-A42B \
    --local-batch-size=1
    # ...etc

Same command on every machine: ezpz picks the backend.

%%{init: {'themeVariables': {'fontSize': '22px'}, 'flowchart': {'nodeSpacing': 40, 'rankSpacing': 36, 'padding': 10}}}%% 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>"] EZ --> MPS["<i>MPS · Apple</i><br/><span style='font-weight:700'>Multi-MPS*</span>"] classDef hub fill:none,stroke:#ee4c2c,color:#181716,stroke-width:1.8px linkStyle default stroke:#ee4c2c,stroke-width:1.8px 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 classDef mps fill:#f59e0b08,stroke:#f59e0b,color:#d97706,stroke-width:1.5px class EZ hub class AURORA aurora class POLARIS polaris class PM perlmutter class FRONTIER frontier class MAC mac class MPS mps

*mccl (mps-ddp/mccl): MPS on Apple silicon, so ezpz launch works even on a laptop.

Recovery matters: 20B beats 2B per token

Same data, larger model. 512N (20B) wouldn’t have finished without this stack.

AuroraGPT-2B training loss over cumulative tokens: MDS reference, stages 1 and 2 at 256 and 512 nodes

Left: training loss vs tokens — 512N stage 2 to 6.927T (solid 256N, dashed 512N; mixes differ).
2B MDS ref 2B v2 256N 2B v2 512N 20B v2 512N
HellaSwag accuracy vs tokensARC-Easy accuracy vs tokensARC-Challenge accuracy vs tokensWinogrande accuracy vs tokens
Right: lm-eval accuracy vs tokens — 20B leads at matched tokens (HellaSwag 0.635 vs 0.555).

Part III — How we keep jobs alive

Three scopes, three loops — and what they still miss

Process, node, and allocation restarts

LayerScopeTriggerCost of being wrong
Process1 rankCrash / OOMTokens since last ckpt
Node12 XPUsHang on one host1 node’s work + re-shard
Allocation2k nodesBad FS / schedulerWhole-job walltime

ezpz handles node + allocation restarts: checkpoint-restart + node exclude + scheduler resubmit. DCP writes the checkpoints.

%%{init: {'flowchart': {'nodeSpacing': 22, 'rankSpacing': 70, 'padding': 14}}}%% flowchart LR LAUNCH["ezpz launch<br/>--auto-retry"] -->|run + monitor| CLASSIFY{"Classify exit"} CLASSIFY -->|rc = 0| DONE["Done"] CLASSIFY -->|bad node + spare available| SWAP["Swap spare<br/>+ retry"] SWAP -.-> LAUNCH CLASSIFY -->|no retry possible| STOP["Stop<br/>spares exhausted /<br/>stuck before training"] class DONE retry-done class STOP retry-stop
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 42, 'padding': 14}}}%% flowchart TB LAUNCH["ezpz launch<br/>--auto-retry"] -->|run + monitor| CLASSIFY{"Classify exit"} CLASSIFY -->|rc = 0| DONE["Done"] CLASSIFY -->|bad node + spare available| SWAP["Swap spare<br/>+ retry"] SWAP -.-> LAUNCH CLASSIFY -->|no retry possible| STOP["Stop<br/>spares exhausted /<br/>stuck before training"] class DONE retry-done class STOP retry-stop
Exit trailer + crash patterns + idle watchdog (exit 124) → next action. Failover writeup.

Layer 1 — Process scope (seconds)

NCCL’s 600s timeout fires but the job just idles and burns allocation. Hang without a crash.

Fix. Stdout-idle watchdog (ezpz launch). No output for N seconds (30 min with --auto-retry). Kill mpiexec (124), backoff, retry in place.

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
  • Catches hangs that silence logging — fabric wedge, Lustre stall
  • Costs one interval + backoff (seconds to minutes, not hours)

Layer 2 — Node scope (minutes)

One bad host poisons every retry. Same allocation hits the same HBM fault or flaky NIC.

Fix — bad-node failover. Job 8505298: step 37, then silence. No traceback. Watchdog at 30 min (124), wrapper swapped a node, relaunched — attempt 2 reached step 296.

flowchart LR TRAIN["step 37"] -->|"stdout idle 30 min"| WATCH["watchdog<br/>exit 124"] WATCH --> TRACE{"bad host in log?"} TRACE -->|yes| EXCLUDE["exclude host"] TRACE -->|no| SWAP["blind swap<br/>1 spare"] EXCLUDE --> RETRY["retry from ckpt"] SWAP --> RETRY RETRY -->|"step 296"| DONE["recovered"]
Step 37Training output stops for 30 minutes.
WatchdogExits with code 124.
Check the logBad host found → exclude it. No bad host → swap in one spare.
RetryResume from the checkpoint.
RecoveredAttempt 2 reaches step 296.

_set_pg_timeout has no xpu branch: timeout is stored, never enforced.7

  • Host mapping from rank → node via PBS_NODEFILE / hostfile; CCL/MPI error often names the offender
  • Spare pool sized as ~1–2% of allocation (e.g., 20 spares for a 1k-node run) — cheap vs. the cost of a full resubmit
  • Exclusion list persists across retries within the same PBS job

Layer 3 — Job scope (hours)

Walltime expired. Allocation ended. Spares exhausted. Job’s over.

Fix — checkpoint-restart + chained resubmit.

  • Async writes model + optimizer + RNG + dataloader — under 5% step cost
  • Each checkpoint is a durable asset (checkpoint-729-hf) that SFT/GRPO can branch from
  • Job N submits N+1 at launch — if N dies, N+1 resumes from the latest one, no human needed

Optimal interval? Young’s / Daly’s formula assumes known MTBF — at Aurora it drifts. See appendix.

Checkpointing: what it really costs

Per-save stall on Sunspot. 3.75s sync → 1.05s async at 23 GB. 23.6s → 5.4s at 232 GB.

Checkpoint stall: sync vs async. Per-save training-thread stall. Stage + drain, not stage alone.

async-ckpt stages to /tmp, fans out to Lustre. DCP is sharded, .complete gates resume. Hard part is correctness. NaN 100 steps ago poisons later ones. Guide.
  • First cut hid 5s: stage alone looked 12× cheaper. Real win is stage + drain vs save.
  • try_finalize_if_ready stamps .complete when all ranks report done. Stale fsync is what hurts Lustre.

Evidence: a job that survived

AuroraGPT-2B SFT on tulu_math_uc_mix — 32 nodes, GBS 6,144, 3 epochs (~4.5B tokens)

AttemptWhat happenedOutcome
1SIGABRT in oneCCL transport (scale-only, 32N)Watchdog kill → node swap → resume
2SIGABRT again, different hostSame path
3SIGABRT, third hostSame path
4Clean run to completionFinal loss 0.77 · checkpoint-729-hf as durable asset
  • No human intervention between attempts — all three scopes handled it
  • Downstream GRPO branched from checkpoint-729-hf the next day

SFT loss over steps: 32N, 3 SIGABRTs recovered — final 0.77 (crashes marked)

Loss vs step — SIGABRT crashes marked (dashed). Watchdog + swap + resume each time.

The determinism tax

Speed, reproducibility, and reliability pull in three directions.

ModeThroughputResumeDebug
Non-deterministic (default)Fastest — async reductionsLoss may wobbleSame seed ≠ same path
Deterministic (fixed reductions, CUBLAS_WORKSPACE_CONFIG, …)10–30% slowerBit-identical, invisibleBisect to the bad step

We train fast, checkpoint smart. Non-deterministic for speed. RNG + reduction order saved, so rollback is reproducible.

Open: selective determinism — only on the recovery path, or only on the suspect collective — without paying full price?

What still breaks

What the three layers don’t handle — and why each is a research problem.

GapWhy it’s hardDone looks like
Silent bad steps — wrong grad, slow drift, no crashNo oracle; NaN is late, eval is costly/noisyCheap online anomaly detector
Loss-spike oracle — rollback or ride it out?Spikes can self-heal or go terminal; threshold is workload-specificLearned classifier: rollback vs. continue
Checkpoint interval at scaleYoung’s optimum assumes known MTBF; ours drifts with scale, code, LustreAdaptive interval from observed failures
Is this checkpoint good?Eval on every ckpt costs tokens/timeLightweight probe, high recall for poison
Heterogeneous reliability — XPU vs. H100 vs. MI300 differPortable stack surfaces it, policy doesn’t adaptPer-platform model tuning watchdog/spare/ckpt

Open questions

  1. Honest heartbeats. Stdout + NCCL timeouts are crude and late. Right in-band signal for a wedged collective at 10k?

  2. Cheap correctness oracles. Bad grad or poisoned checkpoint without a full eval. Checksums, invariants, shadow passes.

  3. Adaptive checkpointing. MTBF drifts with scale, code, Lustre weather. Learn the schedule online.

  1. Selective determinism. Deterministic resume without deterministic training. Record just enough to replay the failure.

  2. Cross-platform reliability. Same workload, three accelerators, three failure modes. One model to predict and adapt?

  3. Resilience-aware scheduling. If the scheduler knew the job was long and tightly coupled, what would it do?

Even our Claude Muse deleted prod jobs

Even the AI that wrote this stack deleted prod jobs once.

Takeaways

  1. At 2k nodes failure is the default. Even heroic per-node MTBF collapses system wide. About 1 per day. About 2.5 h at 10k. Design for it.

  2. Most failures are mundane. The hard ones are silent. Hardware, NIC, fabric are noisy. Bad grads and slow drift are not.

  3. Recovery wants layers. Process in seconds. Node in minutes. Job in hours. Each catches what the inner one cannot.

  4. Checkpoint is the only safe harbor. Interval, validation, cost dominate reliability. Async helps. Provenance does not yet exist.

  5. The open problems are dependability problems. Detection, oracles, adaptive policies, determinism, cross-platform models, scheduling.

Thanks — and let’s talk

This work used resources of the Argonne Leadership Computing Facility, a DOE Office of Science user facility at Argonne National Laboratory, supported under contract DE-AC02-06CH11357.

Aurora was built with HPE and Intel. Thanks to the ALCF operations and support teams, the Intel torch.xpu / oneCCL teams, and the science crews behind GenSLM, MProt-DPO, AERIS, and AuroraGPT.

Sam Foremanforemans@anl.gov · samf.sh · github.com/saforem2
ALCFalcf.anl.gov · docs.alcf.anl.gov

Appendix — Inside an Aurora node

CPU socket 0Xeon CPU Max · 3 GPUs · 4 Slingshot NICs
CPU socket 1Xeon CPU Max · 3 GPUs · 4 Slingshot NICs

Each GPU has 2 tiles and 128 GiB HBM. Six GPUs connect through Xe Link; CPUs connect through UPI.

%%{init: {'flowchart': {'nodeSpacing': 20, 'rankSpacing': 28, 'padding': 12}}}%% flowchart TB subgraph S0["CPU socket 0"] C0["Xeon CPU Max · 52 cores<br/>64 GiB HBM + 512 GiB DDR5"] G0["GPU 0 · 128 GiB HBM<br/>Tile A | Tile B"] G1["GPU 1 · 128 GiB HBM<br/>Tile A | Tile B"] G2["GPU 2 · 128 GiB HBM<br/>Tile A | Tile B"] C0 ---|PCIe 5| G0 C0 --- G1 C0 --- G2 P0["PCIe switch"] N0["4 Slingshot 11 NICs"] C0 --- P0 P0 ---|PCIe 4| N0 end subgraph S1["CPU socket 1"] C1["Xeon CPU Max · 52 cores<br/>64 GiB HBM + 512 GiB DDR5"] G3["GPU 3 · 128 GiB HBM<br/>Tile A | Tile B"] G4["GPU 4 · 128 GiB HBM<br/>Tile A | Tile B"] G5["GPU 5 · 128 GiB HBM<br/>Tile A | Tile B"] C1 ---|PCIe 5| G3 C1 --- G4 C1 --- G5 P1["PCIe switch"] N1["4 Slingshot 11 NICs"] C1 --- P1 P1 ---|PCIe 4| N1 end UPI["UPI · CPU interconnect"] --- C0 UPI --- C1 G0 -.- XE["Xe Link · all-to-all among 6 GPUs"] G1 -.- XE G2 -.- XE G3 -.- XE G4 -.- XE G5 -.- XE N0 --- NET["Slingshot network · Dragonfly topology"] N1 --- NET
Logical schematic, not a wiring diagram. ALCF node topology · Aurora system paper · Tile-as-device config

Appendix — Checkpoint-interval math (Young / Daly)

Classic result: optimum checkpoint interval

T ≈ √(2 · C · MTBF)* (Young, 1974) · refined by Daly (2006) for failure-during-checkpoint

where C = checkpoint write time, MTBF = mean time between failures.

AssumptionHolds at Aurora scale?
MTBF is known and stationaryNo — varies with N, code version, Lustre load, even time of day
Failures are independent, memoryless (exponential)Roughly for hardware; no for software regressions (correlated, bursty)
Detection is instantNo — NCCL timeout is 600s; watchdog is 30 min by default
Checkpoint cost C is constantNo — shared Lustre BW is contended; async hides most but not fsync

Implication: the closed-form optimum is a starting guess, not a policy. We need an adaptive interval learned from the observed failure process.

Footnotes

  1. Llama 3 herd of models §3.3.2 Training reliability. ↩

  2. OPT-175B chronicles (Zhang et al., 2022). ↩

  3. BLOOM: 176B open-access multilingual LM (BigScience, 2022). ↩

  4. GLM-130B (Zeng et al., ICLR 2023). ↩

  5. ALCF INCITE program. ↩

  6. Zhang et al., critical batch size — see ezpz LR-finder notes; McCandlish et al. (2018). ↩

  7. Failover incident: 8505298 ↩

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