Checkpointing and Resuming TrainJobs
A fine-tune that runs for six hours will eventually meet a node reboot, an evicted pod, a preemption, or an OOM at hour five. Without checkpoints, every one of those events costs you the whole run.
Kubeflow Trainer v2 has no checkpoint API of its own. Nothing in the TrainJob or TrainingRuntime spec writes, finds, or restores a checkpoint. The split is:
Get either column wrong and the failure mode is the same and silent: the job restarts, finds nothing, and cheerfully trains from step 0 again.
This guide covers the mechanics. For the specific case of deliberately preempting training to give an InferenceService its GPU back, see Preemptible TrainJobs with Kueue, which builds on everything here.
TOC
PrerequisitesWhat a checkpoint has to containPick storage that outlives the podMake the runtime checkpoint-awareSubmit a resumable TrainJobHow each interruption resumesA crash or a node failureA preemptionA deliberate pausePer-framework knobsMulti-node runsVerify it actually resumesTroubleshootingPrerequisites
What a checkpoint has to contain
"Resume" is not "reload the weights". A checkpoint that only holds model weights restarts your optimizer from zero — momentum buffers, the learning-rate schedule position, and the step counter are all lost, and the loss curve visibly kinks where it resumed.
A resumable checkpoint holds:
- Model weights
- Optimizer state — Adam's moment estimates. Typically ~2x the model size in fp32, and usually the largest part of the checkpoint.
- LR scheduler state — so the schedule continues rather than re-warming.
- Step / epoch counter — where to pick up.
- RNG state and data-loader position — so the resumed run doesn't re-see the same samples in the same order.
HuggingFace Trainer's checkpoint-N/ directories already contain all of this. If you are hand-rolling a loop, saving only model.state_dict() is the most common way to get a "resume" that quietly damages the run.
Pick storage that outlives the pod
The checkpoint directory must be on a PVC. An emptyDir dies with the pod, and a hostPath or local-storage class strands the checkpoint on a node the restarted pod may never land on again.
The access mode depends only on how many trainer pods write at once:
ReadWriteOnce means "one node at a time", not "one node forever". A network-attached RWO volume (Ceph RBD, EBS, and similar) detaches from the old node and re-attaches wherever the restarted pod is scheduled, so a single-node run resumes correctly even when it comes back on a different node. Only concurrent multi-pod access needs RWX.
This does not hold for local-storage / topolvm / hostPath, which are physically pinned to one node.
Apply the PVC (checkpoint-pvc.yaml):
Size it for checkpoint size x save_total_limit, plus headroom. Full-weight checkpoints are bigger than people expect — a 7B model with optimizer state is roughly 80 GiB per checkpoint. LoRA/QLoRA adapters are a few MB, so the same PVC can keep far more of them.
Make the runtime checkpoint-aware
checkpoint-trainingruntime.yaml is a runnable TrainingRuntime with the whole pattern wired up. The load-bearing parts:
And the two lines in the script that actually do the resuming:
That auto-detect is what makes one manifest work for both the first submission and every restart after it. Hardcoding resume_from_checkpoint=True fails the first run (no checkpoint exists yet); hardcoding a path pins you to one checkpoint forever.
The trainer.kubeflow.org/trainjob-ancestor-step: trainer label on the trainer replicatedJob is required. Without it the controller does not recognise that replicatedJob as the trainer step, and every spec.trainer.* field on your TrainJob — env, resourcesPerNode, image, command, numNodes — is dropped silently: no error, no event, no warning. The TrainJob runs with the runtime's defaults instead, so a job you thought you gave a GPU and a CKPT_DIR runs without either.
Verify the overrides landed rather than trusting them:
Apply the runtime:
Submit a resumable TrainJob
trainjob-resume.yaml mounts the checkpoint PVC through podTemplateOverrides, so one shared runtime can serve many runs that each checkpoint somewhere different:
podTemplateOverrides can add volumes, volumeMounts, nodeSelector, tolerations, and affinity, but it may not set env on the trainer or initializer containers — the validating webhook rejects the TrainJob outright:
Environment variables go in spec.trainer.env instead.
How each interruption resumes
A crash or a node failure
Two independent layers restart a failed trainer, and a checkpoint-aware script resumes correctly under both:
Either way the new process runs the same auto-detect and picks up the newest checkpoint-N. Watch restarts climb on the JobSet:
Once maxRestarts is exhausted the TrainJob goes Failed — so set it high enough to absorb transient node churn, but not so high that a genuinely broken script loops forever.
A preemption
Kueue evicts the Workload and Trainer v2 recreates the JobSet once quota frees up; the trainer resumes from the PVC. The cohort/quota setup is a topic of its own — see Preemptible TrainJobs with Kueue.
A deliberate pause
spec.suspend stops a run without losing it — useful to hand a GPU to something urgent for an hour:
The resumed pod is a new pod with a new name, and may land on a different node. Its first log line should be your [checkpoint] resuming from ....
activeDeadlineSeconds restarts its countdown when a suspended TrainJob resumes — the job gets the full deadline again after each pause, rather than being measured from creation.
Per-framework knobs
Every HuggingFace-Trainer-based stack exposes the same three knobs, so the pattern above transfers unchanged:
Pick save_steps from the interruption you can tolerate: the work at risk is at most save_steps x seconds_per_step. At 5 s/step, save_steps: 100 risks ~8 minutes. Saving too often is its own tax — a full-weight checkpoint can take tens of seconds of stalled GPU, so on a stable cluster err large and on a preemptible queue err small. Always pair it with save_total_limit so the PVC does not grow without bound.
Multi-node runs
With numNodes: > 1 the PVC must be RWX, and two things change:
- Only rank 0 should write the checkpoint for standard data-parallel training, or every rank races to write the same path. HF Trainer already handles this; a hand-rolled loop must guard with
if rank == 0. - All ranks must resume from the same checkpoint. Ranks that disagree diverge silently rather than crashing.
For sharded strategies (FSDP, DeepSpeed ZeRO), each rank owns a slice of the weights and optimizer state, and gathering it all onto rank 0 is often impossible for large models. That is what torch.distributed.checkpoint is for: every rank writes its own shard in parallel, and the result can be re-loaded at a different world size — so a run checkpointed on 8 GPUs can resume on 4. A checkpoint saved as one gathered file cannot be resharded this way.
Verify it actually resumes
Do not wait for a real outage to find out. Run this drill once per new runtime:
If step 3 instead prints no prior checkpoint, starting fresh, the resume is broken — and it would have been broken during a real preemption too.
Grepping the trainer's logs for HF Trainer's own "Saving model checkpoint to" line is unreliable: the tqdm progress bar overwrites it. Check for checkpoint-* directories on the PVC instead, which is what the log line above does.