Problem
A large training run lasts days or weeks on many machines, and something will stop it: a hardware fault, a preempted spot instance, a crashed process, a bug fix you need to deploy. Without a checkpoint all of that compute is lost. With a checkpoint that holds only the weights, training restarts, but as a different run: the optimizer forgets its momentum, the learning-rate schedule starts again from warmup, and the data order changes. Losses spike, results stop being reproducible, and a bug introduced by the resume is hard to tell apart from a real training problem.
Checkpoints also leave the machine that made them. People download weights, and PyTorch's default file format is a Python pickle, which can run code when it is loaded. And at scale a checkpoint is many gigabytes split across hundreds of ranks, so writing it takes real time, and the job that reads it back may not have the same number of GPUs.
The same word also names an unrelated memory technique. Activation checkpointing keeps only some of the activations from the forward pass and recomputes the rest during backward, so a deeper model or a bigger batch fits in memory. It is covered at the end of Mechanics.
Intuition
A checkpoint is a save file for a game with randomness in it. To continue exactly where you stopped, the save must hold the map and your inventory (the weights), but also how fast you are moving and in which direction (the optimizer's moments), where you are on the level's timer (the schedule), the state of the dice (the random number generators) and which cards you have already drawn from the deck (the position in the data). Leave any one out and you can still keep playing, but not the same game.
Everything on the left changes every step. The worked example below leaves out the optimizer, scheduler, RNG and data position in turn and measures what changes; the toy run has no grad scaler.
Mechanics
State dicts
PyTorch saves state as plain dictionaries, never as live objects. Module.state_dict() maps each parameter's and buffer's dotted name (layers.0.attn.q_proj.weight) to its tensor; load_state_dict(sd, strict=True) copies tensors back by name and, with strict=False, returns the missing and unexpected keys instead of raising. Because the file holds names and tensors, not code, the model class must be rebuilt by your code before loading.
Optimizer.state_dict() has two entries: state, the per-parameter tensors (for Adam: exp_avg, exp_avg_sq and step), and param_groups, the hyperparameters of each group with parameters replaced by integer ids. Its docstring is explicit that "the parameter itself is NOT saved"; load_state_dict matches ids to the new optimizer's parameters by order, so the parameters must be passed in the same order as when the checkpoint was made. A learning-rate scheduler's state_dict is its __dict__ minus the optimizer; restoring it (load_state_dict) restores how many steps have been taken. See optimizers and learning-rate schedules for what those states mean.
Random state and data position
Shuffling, dropout, data augmentation and random cropping all draw from generators. torch.get_rng_state() returns the CPU generator's state as a byte tensor and set_rng_state restores it; torch.cuda.get_rng_state_all() and set_rng_state_all do the same for every GPU. Python's random and NumPy have their own generators, saved separately.
The position in the data is the piece most often lost. torch.utils.data.DataLoader at this pin has no state_dict, so the epoch and the number of batches already consumed must be saved by your loop. The order itself can be made a pure function of saved numbers: DistributedSampler.__iter__ shuffles with a fresh generator seeded with self.seed + self.epoch, so after set_epoch(epoch) the order is reproduced exactly, and resuming mid-epoch means skipping the batches already seen. The plain RandomSampler, given no generator, draws a fresh seed from the global generator on every iter(), so its order depends on the RNG state at the start of the epoch. The worked example uses the seed + epoch scheme for the order and a separate stateful generator for input noise, so both pieces have to be restored.
Saving and loading files
torch.save(obj, f) pickles the object with protocol DEFAULT_PROTOCOL = 2 and writes tensor storages as separate entries in a zip archive (_use_new_zipfile_serialization=True by default). Write to a temporary file and rename it over the old one, so a crash mid-write never leaves a half-written file under the name your resume logic will read; keep the last few checkpoints rather than one, in case the newest is bad.
torch.load takes map_location (load GPU tensors onto the CPU or another device), mmap (map the file instead of reading it into memory) and weights_only. Unpickling can call any importable function, because a pickle can say "call this global with these arguments" (Python's __reduce__). Since 2.6 weights_only defaults to True; the error message says setting it to False "can result in arbitrary code execution". With weights_only=True a restricted unpickler resolves each GLOBAL opcode against an allowlist (tensor rebuild functions, dtypes, torch.Size, nn.Parameter, collections.OrderedDict, collections.Counter, and others), refuses the modules sys, os, posix and nt outright, and raises "Unsupported global" for anything else. Your own trusted classes can be added with torch.serialization.add_safe_globals. Formats such as safetensors avoid pickle entirely by storing only a header and raw tensor bytes.
How big, and how often
With mixed precision and Adam, each parameter carries a bf16 copy for compute (2 bytes), an fp32 master copy (4) and two fp32 moments (4 + 4): 14 bytes to resume, against 2 bytes for an inference export. The ZeRO paper counts the same 12 bytes of fp32 optimizer state plus 2 bytes each of half-precision weights and gradients, 16 in all, in memory; gradients are recomputed every step and are not saved.
Saving often wastes time writing; saving rarely wastes time redoing lost work. If writing a checkpoint stops training for C seconds and failures arrive on average every M seconds, saving every T seconds loses about C/T of the time to writing and, on a failure, half an interval of work on average. Young (1974) minimized that first-order waste:
waste(T) ≈ C / T + T / (2M) ⇒ T* = √(2 C M)
At T* the two terms are equal. Making writes cheaper shortens the best interval and lowers the waste, which is why large jobs save asynchronously: dcp.async_save first copies the state to staging storage (CPU memory by default) and then runs the save in a separate thread, so training is blocked only for the copy. The formula assumes failures are independent and C is much smaller than M; a job on N machines has roughly N times the failure rate of one.
Distributed checkpoints
When FSDP or tensor parallelism shards parameters and optimizer state across ranks, gathering everything onto rank 0 to call torch.save is slow and may not fit in its memory. torch.distributed.checkpoint (DCP) instead has every rank write its own shards in parallel: dcp.save "handles ShardedTensor, and DTensor by having each rank only save their local shards", and a .metadata file records which piece of which tensor lives where. On dcp.load "each rank will try to read the least amount of data necessary"; the default load planner "handles resharding by issuing multiple read requests against storage", so a checkpoint saved on 64 GPUs can be loaded on 48. get_state_dict(model, optimizers) returns model and optimizer state dicts keyed by canonical names that "can be resharded with a different number of trainers and/or different parallelisms", and set_state_dict puts them back. DCP loads in place: tensors in the state dict you pass must already be allocated with their destination layout.
Activation checkpointing
Backpropagation needs the activations of every layer, so by default the forward pass keeps all of them, and activation memory grows with depth × batch × sequence length. torch.utils.checkpoint.checkpoint(fn, *args) runs fn without saving its intermediate tensors, keeps only its inputs, and runs fn again during backward to rebuild them; its docstring describes this as trading compute for memory. With preserve_rng_state=True (the default) it stashes and restores the RNG state so dropout masks in the recomputation match the original forward. Pass use_reentrant=False; the docstring recommends it, and it works with torch.autograd.grad and keyword arguments.
checkpoint_sequential(functions, segments, input) splits a sequence of n layers into k segments of ⌊n/k⌋ layers; all segments except the last are checkpointed and the last absorbs the remainder. At the peak, memory holds the k − 1 saved segment inputs plus one segment's activations, about k + n/k layers' worth, which is smallest near k = √n: memory grows with √n instead of n, for about one extra forward pass. That is the sublinear-memory result of Chen et al. (2016). Transformer libraries usually checkpoint each block, saving one tensor per block and recomputing attention and MLP intermediates.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
Module.state_dict |
→ {dotted name: tensor} | Parameters and persistent buffers by name; no code, no class. | module.py 2203-2291 |
Module.load_state_dict |
sd, strict=True, assign=False |
Copies tensors in by name; returns missing and unexpected keys. | module.py 2539-2652 |
Optimizer.state_dict |
{"state": {id: {...}}, "param_groups": [...]} | Per-parameter moments and step; hyperparameters per group, parameters as ids. | optimizer.py 718-809 |
Optimizer.load_state_dict |
state dict from the same parameter order | Maps saved ids to the current parameters and casts state to their device. | optimizer.py 918-1064 |
LRScheduler.state_dict |
__dict__ minus the optimizer | How far along the schedule is (last_epoch) and its settings. |
lr_scheduler.py 182-200 |
torch.get_rng_state / cuda.get_rng_state_all |
ByteTensor / list of ByteTensor | CPU and per-GPU generator states, restored with the matching setters. | random.py 27-48, cuda/random.py 45-88 |
DistributedSampler |
seed, epoch, rank, num_replicas | Order from seed + epoch, so set_epoch reproduces it. |
distributed.py 107-157 |
torch.save / torch.load |
obj, f; map_location, weights_only, mmap |
Zip archive of a protocol-2 pickle plus raw storages; restricted unpickling by default. | serialization.py 945-1021, 1316-1650 |
add_safe_globals |
list of classes or functions | Extends the weights_only allowlist with globals you trust. |
serialization.py 283-318 |
dcp.save / dcp.async_save / dcp.load |
state_dict, checkpoint_id |
Each rank writes or reads only its shards; load reshards to the current layout. | state_dict_saver.py 89-397, state_dict_loader.py 60-202 |
get_state_dict / set_state_dict |
model, optimizers | Canonical-name state dicts for FSDP, DDP and tensor-parallel models. | state_dict.py 1271-1361, 1481-1544 |
checkpoint / checkpoint_sequential |
fn, *args, use_reentrant=False; segments |
Activation checkpointing: keep inputs, recompute the rest in backward. | checkpoint.py 423-617, 701-792 |
A collaborator's checkpoint fails to load with "Unsupported global: GLOBAL mylib.config.TrainConfig". Is setting weights_only=False the fix?
Only if you trust the file completely, because weights_only=False lets the pickle call any importable function. The narrower fix is torch.serialization.add_safe_globals([TrainConfig]) after checking that the class is yours and harmless to construct, or better, asking for a checkpoint that stores the config as a plain dict. The error is the allowlist doing its job: the file asks to build an object the loader does not know is safe.
Worked example
A run that is interrupted
Fit y ≈ w·x + b to 8 points with Adam (β = 0.9, 0.999), batches of 2 for 3 epochs (12 steps). The learning rate warms up linearly over 3 steps to 0.5, then follows a cosine to zero. Each epoch's order is Random(seed + epoch), as in DistributedSampler, and each input gets Gaussian jitter (σ = 0.1) from one stateful generator, standing in for dropout or augmentation. The run is checkpointed after step 6, in the middle of epoch 2.
| Step | Epoch | Batch (point ids) | Learning rate | w | b | Loss on all points |
|---|---|---|---|---|---|---|
| 1 | 1 | 6, 7 | 0.1667 | 0.1667 | 0.1667 | 6.2282 |
| 2 | 1 | 2, 4 | 0.3333 | 0.4007 | 0.4338 | 4.4022 |
| 3 | 1 | 0, 3 | 0.5000 | 0.7257 | 0.6987 | 2.5902 |
| 4 | 1 | 1, 5 | 0.5000 | 1.0337 | 0.9426 | 1.3554 |
| 5 | 2 | 5, 0 | 0.4849 | 1.3348 | 1.1309 | 0.5953 |
| 6 (save) | 2 | 6, 4 | 0.4415 | 1.5859 | 1.2977 | 0.2520 |
| 7 | 2 | 1, 7 | 0.3750 | 1.7863 | 1.4264 | 0.1697 |
| 8 | 2 | 2, 3 | 0.2934 | 1.9259 | 1.5018 | 0.2002 |
| 9 | 3 | 3, 6 | 0.2066 | 2.0121 | 1.5464 | 0.2548 |
| 10 | 3 | 0, 1 | 0.1250 | 2.0619 | 1.5659 | 0.2928 |
| 11 | 3 | 5, 2 | 0.0585 | 2.0823 | 1.5717 | 0.3084 |
| 12 | 3 | 4, 7 | 0.0151 | 2.0864 | 1.5720 | 0.3108 |
The checkpoint after step 6 holds w = 1.5859, b = 1.2977; Adam's step count t = 6 and moments m = (−1.8582, −0.6741), v = (0.2910, 0.0868); the schedule's step 6; the jitter generator's state; and the data position: epoch 2, 2 of 4 batches done.
Resume five ways
Restoring everything replays steps 7 to 12 bit for bit: every w, b, learning rate and batch equals the uninterrupted run's exactly, not approximately. Each other row restores everything except one piece, which it resets to its value at the start of training (the position resets to the start of the current epoch, the common bug when only the epoch is saved).
| Resume | Step 7 batch | Step 7 learning rate | Identical? | Final w | Final b | Final loss | Largest gap in w or b |
|---|---|---|---|---|---|---|---|
| Full checkpoint | 1, 7 | 0.3750 | yes | 2.0864 | 1.5720 | 0.3108 | 0.0000 |
| Forgot optimizer state | 1, 7 | 0.3750 | no, from step 7 | 2.2428 | 1.2677 | 0.1700 | 0.3043 |
| Forgot scheduler state | 1, 7 | 0.1667 | no, from step 7 | 2.5090 | 1.6810 | 0.9231 | 0.4226 |
| Forgot RNG state | 1, 7 | 0.3750 | no, from step 7 | 2.0908 | 1.5580 | 0.2984 | 0.0140 |
| Forgot position in epoch | 5, 0 | 0.3750 | no, from step 7 | 2.0885 | 1.5396 | 0.2785 | 0.0325 |
- Optimizer. With t reset to 1, Adam's bias correction makes the first step's size about the full learning rate in the gradient's sign direction, whatever the gradient's size; the saved moments would have damped it. The gap peaks at 0.304.
- Scheduler. The warmup starts again, so step 7 uses 0.1667 instead of 0.3750, and the next steps climb back to 0.5 where the schedule should be decaying. This is the largest gap of the four (0.423).
- RNG. The batches and learning rates are right, but the jitter replays the noise from step 1. The gap is small here (0.014), but the run is no longer reproducible, and with dropout in a real network every mask changes.
- Position. Epoch 2 starts over, so its first 2 batches are seen twice and the last 2 batches of the final epoch are never reached before the step budget ends.
3 of the four broken resumes end with a lower loss than the uninterrupted run (0.3108). A broken resume is not necessarily worse on one run; it is a different, unrepeatable run, which is what makes these bugs hard to find from the loss curve.
Curves leave the uninterrupted run at the checkpoint. The scheduler bug is the most visible because it re-applies a large learning rate late in training; the RNG and position bugs are nearly invisible in w, and that is why they survive.
How big is a resumable checkpoint?
For a 7-billion-parameter model trained in bf16 with Adam:
| Piece | Bytes per parameter | Size | Why |
|---|---|---|---|
| bf16 weights | 2 | 14 GB | the copy the forward pass uses; also the whole inference export |
| fp32 master weights | 4 | 28 GB | what the optimizer updates under mixed precision |
| Adam first moment m | 4 | 28 GB | one fp32 value per parameter |
| Adam second moment v | 4 | 28 GB | one fp32 value per parameter |
| Resumable checkpoint | 14 | 98 GB | 7× the 14 GB inference export |
The optimizer state alone is six times the bf16 weights, which is why sharding it (ZeRO, FSDP) and writing shards in parallel matter more than compressing the weights.
How often to save
Suppose the whole job fails on average every 6 hours (M = 21600 s), and a blocking save stops training for C = 60 s. Young's interval is T* = √(2 · 60 · 21600) = 1610 s ≈ 26.8 min, and both waste terms equal 3.73% there, 7.45% in all. If an asynchronous save blocks for only C = 5 s, T* drops to 7.7 min and the waste to 2.15%.
| Blocking time C | T = 5.0 min | T = 15.0 min | T* | T = 60.0 min | T = 120.0 min |
|---|---|---|---|---|---|
| 60 s | 20.69% | 8.75% | 26.8 min: 7.45% | 10.00% | 17.50% |
| 5 s | 2.36% | 2.64% | 7.7 min: 2.15% | 8.47% | 16.74% |
The minimum is flat: saving at half or twice T* costs little extra. Being off by ten times costs a lot in either direction.
Loading 4 shards on 3 ranks
A 12-row parameter was saved row-sharded across 4 ranks and is loaded on 3. Each new rank owns a contiguous block of rows and reads, from each saved shard that overlaps it, only the overlapping rows. This is the kind of read plan DCP's metadata lets the load planner make.
| New rank | Owns rows | Reads |
|---|---|---|
| 0 | 0–3 | rows 0–2 from old rank 0 (offset 0 in its shard); rows 3–3 from old rank 1 (offset 0 in its shard) |
| 1 | 4–7 | rows 4–5 from old rank 1 (offset 1 in its shard); rows 6–7 from old rank 2 (offset 0 in its shard) |
| 2 | 8–11 | rows 8–8 from old rank 2 (offset 2 in its shard); rows 9–11 from old rank 3 (offset 0 in its shard) |
No rank reads the whole tensor. With real FSDP or tensor-parallel layouts the shards are multi-dimensional, but the plan is the same: intersect what each rank needs with what each saved chunk holds.
Activation checkpointing a 16-layer stack
Count memory in units of one layer's saved activations and treat a segment's input as one unit. checkpoint_sequential with k segments keeps k − 1 segment inputs and, at the peak, one segment's activations; every checkpointed layer is run forward a second time.
| Segments k | Layers per segment | Saved inputs | Peak memory (units) | Layers recomputed |
|---|---|---|---|---|
| 1 | 16 | 0 | 16 | 0 of 16 |
| 2 | 8 | 1 | 9 | 8 of 16 |
| 4 | 4 | 3 | 7 | 12 of 16 |
| 8 | 2 | 7 | 9 | 14 of 16 |
| 16 | 1 | 15 | 16 | 15 of 16 |
The peak is lowest at k = 4 = √16: 7 units instead of 16. More segments only add saved inputs; with one layer per segment everything is saved again as inputs and nothing is gained. If backward costs about twice the forward, recomputing 12 of 16 layers adds 12/16 of a forward to 3 forward-equivalents of work, about 25% more compute.
What weights_only sees in a pickle
Each object is pickled with protocol 2, as torch.save does, and its opcodes are listed with pickletools without unpickling anything. The verdict uses an allowlist of two entries copied from PyTorch's (collections.OrderedDict and collections.Counter); the real list adds the tensor and dtype globals.
| Object pickled | Bytes | GLOBAL opcodes | Restricted load |
|---|---|---|---|
| plain state dict | 42 | none | loads |
| OrderedDict state dict | 70 | collections.OrderedDict |
loads |
| object with __reduce__ | 93 | builtins.print |
refused: builtins.print is not allowed |
The third object's __reduce__ tells the unpickler to call print with a string. A plain pickle.load would call it; a real attack names os.system instead. The restricted loader refuses before calling anything, because the decision is made when the GLOBAL opcode is read.
Implementation
import os, random
import torch
import torch.distributed.checkpoint as dcp
from torch.distributed.checkpoint.state_dict import get_state_dict, set_state_dict
from torch.utils.checkpoint import checkpoint
def save(path, step, model, opt, sched, scaler, epoch, batches_done):
state = {
"model": model.state_dict(),
"optimizer": opt.state_dict(),
"scheduler": sched.state_dict(),
"scaler": scaler.state_dict(),
"rng": {"torch": torch.get_rng_state(),
"cuda": torch.cuda.get_rng_state_all(),
"python": random.getstate()},
"data": {"epoch": epoch, "batches_done": batches_done},
"step": step,
}
tmp = path + ".tmp"
torch.save(state, tmp)
os.replace(tmp, path) # atomic: never a half-written file at path
def resume(path, model, opt, sched, scaler, sampler):
state = torch.load(path, map_location="cpu", weights_only=True)
model.load_state_dict(state["model"]) # model already built and on its device
opt.load_state_dict(state["optimizer"]) # same parameter order as when saved
sched.load_state_dict(state["scheduler"])
scaler.load_state_dict(state["scaler"])
torch.set_rng_state(state["rng"]["torch"])
torch.cuda.set_rng_state_all(state["rng"]["cuda"])
random.setstate(state["rng"]["python"])
sampler.set_epoch(state["data"]["epoch"]) # DistributedSampler: order = seed + epoch
return state["step"], state["data"]["epoch"], state["data"]["batches_done"]
# Sharded models (FSDP, tensor parallel): every rank calls these.
def save_sharded(ckpt_dir, model, opt):
model_sd, optim_sd = get_state_dict(model, opt)
dcp.save({"model": model_sd, "optim": optim_sd}, checkpoint_id=ckpt_dir)
def load_sharded(ckpt_dir, model, opt): # world size may differ from the save
model_sd, optim_sd = get_state_dict(model, opt)
state = {"model": model_sd, "optim": optim_sd}
dcp.load(state, checkpoint_id=ckpt_dir) # fills the tensors in place
set_state_dict(model, opt, model_state_dict=state["model"], optim_state_dict=state["optim"])
# Activation checkpointing: keep each block's input, recompute its insides in backward.
class Stack(torch.nn.Module):
def __init__(self, blocks):
super().__init__()
self.blocks = torch.nn.ModuleList(blocks)
def forward(self, x):
for block in self.blocks:
x = checkpoint(block, x, use_reentrant=False)
return x
In the loop, skip batches_done batches of the resumed epoch before training, and call save every T* seconds of wall time rather than every fixed number of steps if step times vary. The skip costs reading those batches again; a loader that saves its own position avoids that.
Tradeoffs
| Choice | Gains | Costs |
|---|---|---|
| Full training state vs weights only | Exact, reproducible resume | 7× the bytes with bf16 + Adam; longer writes |
| Save more often | Less work lost per failure | More time blocked writing; the optimum is T* = √(2CM) |
| Asynchronous save | Blocking time drops to the copy into CPU memory | Extra host memory for the staged copy; a failure during the background write loses that checkpoint |
One torch.save file vs DCP | Simple, portable, easy to inspect | Must gather to one rank; DCP writes in parallel and reshards on load but needs a matching state-dict structure |
weights_only=True | Loading cannot run arbitrary code | Custom classes need add_safe_globals; store configs as plain dicts instead |
| Activation checkpointing | Memory from ∝ n toward ∝ √n; bigger batches or longer sequences fit | About one extra forward pass (≈ a third more compute); more segments than √n waste memory again |
| Skip-ahead resume vs a stateful loader | No extra machinery | Reads and discards the batches already used; slow if an epoch is huge |
Your saves now block for 5 s instead of 60 s thanks to async_save. Should you save less often, the same, or more often?
More often. T* = √(2CM) shrinks with √C: here from 26.8 min to 7.7 min. Cheaper writes make each save cost less than the work it protects, and the total waste falls from 7.5% to 2.2%. Keeping the old interval with the cheaper save would leave the work-lost term where it was.
A resumed run's loss is fine, but rerunning the same resume gives slightly different results from the original job. Which piece of state is the first suspect?
The random state. Batches and the learning rate would show up as a visible bump; a reset generator changes dropout masks, augmentation and possibly data-worker seeds while the loss looks normal, as in the RNG row above. Save the CPU, GPU and Python generator states (and seed workers from saved values), and check that the resumed run reproduces a known batch and loss at the first step after the checkpoint.
Connections
Read first
- The training loop intro
Read next
No page builds on this one yet.
The state being saved is defined on other pages: the loop that produces it on training loop, Adam's moments on optimizers, warmup and cosine on learning-rate schedules, the grad scaler and master weights on mixed precision, and DistributedSampler on data loading and batching. Sharded checkpoints follow the layouts of distributed training. Activation checkpointing trades against the memory that backpropagation keeps in the computation graph, and competes with gradient accumulation as the way to fit a bigger effective batch.
References
- Young. A First Order Approximation to the Optimum Checkpoint Interval. Communications of the ACM 17(9), 1974. doi:10.1145/361147.361115
- Chen, Xu, Zhang and Guestrin. Training Deep Nets with Sublinear Memory Cost. 2016. arXiv:1604.06174
- Rajbhandari, Rasley, Ruwase and He. ZeRO: Memory Optimizations Toward Training Trillion Parameter Models. SC 2020. arXiv:1910.02054
- Kingma and Ba. Adam: A Method for Stochastic Optimization. ICLR 2015. arXiv:1412.6980
- Python documentation:
pickle(including its warning that unpickling can execute arbitrary code) andpickletools. - PyTorch v2.15.0:
torch.save,torch.load, theweights_onlyallowlist,Module.state_dict,Optimizer.state_dict,DistributedSampler,dcp.save,dcp.load,get_state_dict,checkpoint,checkpoint_sequential.