Repository navigation
ModelCheckpoint overwrites the checkpoint it resumed from when using gradient accumulation #22033
Description
Bug description
With accumulate_grad_batches > 1 and ModelCheckpoint(every_n_train_steps=N), resuming from a step checkpoint makes the callback save that same checkpoint again on the first batch after the resume. That batch doesn't do an optimizer step, so global_step is still the step the file was saved at, and the file we resumed from gets overwritten.
The new file has the same weights and optimizer state, but its loop progress counts one more completed batch. That batch's gradient was only accumulated in memory, so if the job is interrupted again before the next checkpoint and resumed from this file, the batch is silently skipped and training no longer matches an uninterrupted run.
In an uninterrupted run this can't happen because _last_global_step_saved stops a second save at the same global_step. That attribute isn't part of ModelCheckpoint.state_dict(), so after resuming it is back to 0 and the check doesn't apply.
Expected: no save on the first batch after resuming, and the checkpoint we resumed from is left as it was.
What version are you seeing the problem on?
v2.5, v2.6, master
How to reproduce the bug
import hashlib, logging, shutil, tempfile, warnings
import torch
import lightning.pytorch as L
from lightning.pytorch.callbacks import ModelCheckpoint
from torch.utils.data import DataLoader
warnings.filterwarnings("ignore"); logging.getLogger("lightning.pytorch").setLevel(logging.ERROR)
root = tempfile.mkdtemp()
class Model(L.LightningModule):
def __init__(self, crash_at=None):
super().__init__(); torch.manual_seed(0); self.layer = torch.nn.Linear(4, 1); self.crash_at = crash_at
def training_step(self, batch, batch_idx):
if batch_idx == self.crash_at:
raise RuntimeError("simulated crash")
return (self.layer(batch) - 3).pow(2).mean()
def configure_optimizers(self): return torch.optim.SGD(self.parameters(), lr=0.05)
def train_dataloader(self): return DataLoader(torch.ones(8, 4), batch_size=1) # 8 identical batches
def fit(name, crash_at=None, ckpt_path=None):
model = Model(crash_at)
trainer = L.Trainer(accelerator="cpu", max_epochs=1, accumulate_grad_batches=2, logger=False,
enable_progress_bar=False, enable_model_summary=False,
callbacks=[ModelCheckpoint(dirpath=f"{root}/{name}", every_n_train_steps=2, save_top_k=-1)])
try:
trainer.fit(model, ckpt_path=ckpt_path)
except RuntimeError:
pass
return model.layer.weight.detach().clone()
def describe(path):
ckpt = torch.load(path, weights_only=False)
done = ckpt["loops"]["fit_loop"]["epoch_loop.batch_progress"]["current"]["completed"]
return f"sha={hashlib.sha256(open(path, 'rb').read()).hexdigest()[:10]} global_step={ckpt['global_step']} batches_completed={done}"
print("lightning", L.__version__, "torch", torch.__version__)
w_full = fit("full") # reference: uninterrupted run
fit("run", crash_at=5) # crashes after the step-2 checkpoint was written
ckpt = f"{root}/run/epoch=0-step=2.ckpt"
shutil.copy(ckpt, f"{root}/original.ckpt")
print("before resume:", describe(ckpt))
fit("run", crash_at=5, ckpt_path=ckpt) # resume, crashes again one batch later
print("after resume: ", describe(ckpt))
print("checkpoint we resumed from was overwritten:", open(ckpt, "rb").read() != open(f"{root}/original.ckpt", "rb").read())
w_orig = fit("a", ckpt_path=f"{root}/original.ckpt")
w_over = fit("b", ckpt_path=ckpt)
print("resume from original step-2 file == uninterrupted:", torch.equal(w_orig, w_full))
print("resume from overwritten step-2 file == uninterrupted:", torch.equal(w_over, w_full))
shutil.rmtree(root)Error messages and logs
lightning 2.6.6 torch 2.14.1
before resume: sha=02f5b8d9a7 global_step=2 batches_completed=3
after resume: sha=bfb3c5fa3f global_step=2 batches_completed=4
checkpoint we resumed from was overwritten: True
resume from original step-2 file == uninterrupted: True
resume from overwritten step-2 file == uninterrupted: False
Same result on master (84df182) and on 2.5.0. On 2.4.0 the file isn't rewritten.
Environment
Current environment
#- PyTorch Lightning Version: 2.6.6 (pip) and master 84df182 (pip install -e)
#- PyTorch Version: 2.14.1 (CPU)
#- Python version: 3.13.15
#- OS: macOS 26.5 (arm64)
#- CUDA/cuDNN version: none
#- GPU models and configuration: none
#- How you installed Lightning: pip
More info
It shows up from 2.5.0, which is when resuming mid-epoch started at the right batch; before that the checkpointed batch was replayed instead (#18060), which hid it. #21772 doesn't change this; I tried the repro with it applied and the file is still rewritten.
Adding _last_global_step_saved to state_dict() on its own doesn't fix it, because the callback state is collected inside trainer.save_checkpoint before _save_checkpoint updates the attribute, so the checkpoint stores the previous save's step. Setting self._last_global_step_saved = trainer.global_step when fitting resumes from a checkpoint (e.g. in on_train_start when trainer.ckpt_path is set) makes the repro pass.
Activity
wpyaoxueros commentedon Oct 19, 2026
Hi, I’d like to investigate this issue and prepare a focused regression test and fix. The issue includes a CPU-only reproducer, but I haven’t yet verified it against current
master. I can’t self-assign because GitHub reports@wpyaoxuerosis not assignable. Could a maintainer assign it to me or confirm I should proceed? I’m using an AI coding assistant and will disclose that in any PR.wpyaoxueros commentedon Oct 19, 2026
I have prepared the fix and regression test in draft PR #22034: #22034. On the base revision, resuming reproduced the step-2 checkpoint advancing from 3 to 4 completed batches without an optimizer update; with the fix, it stays at 3, and the full CPU reproduction matches uninterrupted training. The PR includes AI disclosure and the changelog entry.