There is a cliff in your training run’s GPU-utilization graph. You probably can’t see it, because the dashboard averages it away — but it is there, every few hundred steps, and it is costing you more GPU-hours than any kernel you have spent the last month optimising.
The cliff is the checkpoint. Every few hundred steps the run serialises model and optimiser state — tens of gigabytes — and writes it to disk. If that write is synchronous, the training step that triggered it blocks until the bytes are durable. The most expensive silicon in the building spends those seconds doing nothing but waiting for an NVMe drive.
The averaged dashboard is lying to you
This is the same trap as coordinated omission: a one-minute utilization average cannot show you a four-second stall. You see 94% and call it healthy. Sample at per-step resolution and the sawtooth appears — a flat line at the top, punctuated by vertical drops toward zero exactly on the checkpoint interval. The average is arithmetically true and operationally useless.
A checkpoint is an I/O job wearing a training job’s clothes
The training loop is a compute job; the checkpoint is an I/O job; and the moment you run them on the same thread, the slower one sets the pace. A synchronous save does three expensive things in the critical path: it serialises state, it pushes tens of gigabytes through the filesystem, and it calls fsync to force durability. Worse, a large buffered write floods the page cache and can evict the very data your loader was about to read — so the stall does not end cleanly when the write does; the next few steps limp while the cache refills.
Measure the save path, not the spec sheet
The spec sheet sells you small-random-read IOPS. A checkpoint is none of those things — it is a large, sequential, fsync’d burst, often from every rank at once. So benchmark that, not the marketing number. The companion code has an fio job that models the real pattern (O_DIRECT, 1 MB blocks, fsync per write) and a dependency-free Python harness that times a stand-in step against three save strategies and prints the utilization gap on your hardware.
Get the write off the critical path
The fix is not a faster disk. It is refusing to let the disk pace the GPU. Capture the checkpoint to pinned host memory on the training thread — cheap, microseconds — then hand it to a background writer that fsyncs while training continues. Use O_DIRECT for the big sequential dump so it bypasses the page cache instead of evicting your data. The one rule that makes this correct rather than merely fast: snapshot before you enqueue, so the next step cannot mutate weights mid-write.
# Before: the step waits for the disk
save_checkpoint(state) # serialise + write + fsync, all inline
# After: the step pays only for an in-memory snapshot
pinned = snapshot_to_pinned(state) # microseconds, on the training thread
writer.enqueue(pinned) # fsync happens on a background thread
The reframe
A stall at every save is not a disk problem; it is an accounting one. You are paying frontier-GPU rates to watch a progress bar fill. Measure the save path, move it off the critical path, and the cliffs flatten — same hardware, more of it actually working. The GPUs were never the bottleneck. The decision to save synchronously was — and that is an operating-model choice, not a model one.
