Skip to content

ModelCheckpoint overwrites the checkpoint it resumed from when using gradient accumulation #22033

Open

Description

@MissieKahsay

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

  1. wpyaoxueros commented on Oct 19, 2026

    @wpyaoxueros

    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 @wpyaoxueros is 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.

  2. wpyaoxueros commented on Oct 19, 2026

    @wpyaoxueros

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions