Skip to content

Commit 5666d0d

Browse files
Merge pull request #129 from KempnerInstitute/fix/save-on-train-end
Fix clean runs not always saving on last step
2 parents 32accd1 + a88270e commit 5666d0d

4 files changed

Lines changed: 64 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9191
- `docs/checkpointing/dcp-model.md`: updated the save/load snippets and the "shape to fill" explanation to the DCP-aware helpers.
9292
- Tests (fail on the pre-fix code, pass after): `tests/integration/test_checkpoint_roundtrip.py::test_manager_restores_optimizer_moments_single_gpu` and `tests/distributed/test_checkpoint.py::test_resume_restores_optimizer_moments` assert `exp_avg` / `exp_avg_sq` are restored bit-exactly into a *fresh* optimizer (single-GPU + distributed); `tests/e2e/test_training_e2e.py::test_resume_determinism_single_gpu` / `test_resume_determinism_2gpu_fsdp` assert end-to-end bit-exact loss across an interrupt-and-resume on a learnable dataset.
9393
- **On-disk format note:** optimizer state is now keyed by parameter fully-qualified name rather than positional index. Checkpoints written before this fix will not restore optimizer state on resume (training continues with a fresh optimizer); model state is unaffected.
94+
- **Always save a final checkpoint on clean completion.** A run that finished cleanly previously wrote a final checkpoint only when `max_steps` happened to land on the save schedule; otherwise the fully-trained model (including the entire WSD decay tail) was never persisted and `latest` resolved to the last scheduled step. The training-loop teardown now writes one unconditional checkpoint at `max_steps` on normal completion when that step is off-schedule, matching the emergency-checkpoint guarantee on the preemption path. Purely additive: the schedule/retention knobs (`interval`, `keep_last_n`, `dyn_ckpt_window`) are untouched.
95+
- `scripts/train.py`: a `completed_normally` marker set on the final iteration, plus an off-schedule final `ckpt_mgr.save(...)` (and `on_checkpoint_save` hook) before `ckpt_mgr.wait()`.
96+
- Tests: `tests/e2e/test_training_e2e.py` — a clean off-schedule completion writes `step_{max_steps}` and points `latest` at it.
9497

9598
## [0.1.0] — 2026-04-16
9699

docs/training/training-loop.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,22 @@ After the loop:
185185

186186
```python
187187
prof.stop()
188+
# Clean off-schedule finish: persist the fully-trained final step
189+
if completed_normally and not config.checkpoint.should_save(step):
190+
ckpt_mgr.save(step, ...) # final checkpoint; `latest` committed after wait
188191
ckpt_mgr.wait() # drain last async save
189192
hook_runner.on_train_end(step, tokens_seen)
190193
tracker.close()
191194
destroy_distributed()
192195
```
193196

197+
On a clean finish, an unconditional checkpoint is written at `max_steps`
198+
when that step is not already on the save schedule — so a completed run's
199+
fully-trained model (including the WSD decay tail) is always recoverable
200+
and `latest` points at it. This mirrors the emergency checkpoint the
201+
preemption path writes on shutdown. The `should_save` guard avoids a
202+
duplicate when `max_steps` already coincided with the schedule.
203+
194204
`ckpt_mgr.wait()` is load-bearing — without it, a rank can exit before
195205
its async DCP write completes, corrupting the checkpoint for
196206
everyone else on the same save. See

scripts/train.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,8 @@ def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
672672
)
673673
hook_runner.on_checkpoint_save(0, config.checkpoint.dir)
674674

675+
completed_normally = False
676+
675677
while step < tc.max_steps:
676678
# Refresh data iterator at start / epoch boundary
677679
if dataloader is not None and data_iter is None:
@@ -1043,11 +1045,29 @@ def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
10431045
shutdown_handler.finish()
10441046
break
10451047

1048+
# Clean-completion marker for the unconditional final save after the
1049+
# loop. Only reached when training completes without any errors, e.g.,
1050+
# no NaN/NCCL/shutdown breaks. If a run encounters a NaN, the last step
1051+
# is intentionally *not* saved because the actual model state would be
1052+
# `max_steps - 1`, not `max_steps`.
1053+
if step >= tc.max_steps:
1054+
completed_normally = True
1055+
10461056
if prof is not None:
10471057
prof.stop()
10481058
if rank == 0:
10491059
print_profiler_summary(prof, trace_dir=config.profiling.trace_dir)
10501060

1061+
if completed_normally and not config.checkpoint.should_save(step):
1062+
ckpt_mgr.save(
1063+
step=step,
1064+
tokens_seen=tokens_seen,
1065+
scheduler=scheduler,
1066+
dataloader=dataloader,
1067+
extra=ckpt_extra,
1068+
)
1069+
hook_runner.on_checkpoint_save(step, config.checkpoint.dir)
1070+
10511071
# Flush any pending async checkpoint before tearing down process group
10521072
ckpt_mgr.wait()
10531073

tests/e2e/test_training_e2e.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,37 @@ def test_sigterm_triggers_emergency_checkpoint(tmp_path):
606606
assert "Checkpoint saved" in output, f"Emergency checkpoint was not saved:\n{output[-2000:]}"
607607

608608

609+
@pytest.mark.e2e
610+
def test_final_checkpoint_on_clean_completion(tmp_path):
611+
"""A clean finish at an off-schedule step must still persist a final
612+
checkpoint, with `latest` pointing at it."""
613+
ckpt_dir = tmp_path / "final_ckpt"
614+
615+
# max_steps=10 with interval=1000 => no scheduled save lands (debug.toml
616+
# configures no dyn_ckpt_window), so the only checkpoint must be the
617+
# unconditional final save at step 10.
618+
result = _run_training(
619+
[
620+
DEBUG_CONFIG,
621+
"--train.max_steps=10",
622+
"--metrics.log_interval=5",
623+
f"--checkpoint.dir={ckpt_dir}",
624+
"--checkpoint.interval=1000", # no scheduled checkpoint
625+
],
626+
nproc=1,
627+
)
628+
_assert_training_complete(result, expected_steps=10)
629+
630+
output = result.stdout + result.stderr
631+
final = ckpt_dir / "step_10"
632+
latest = ckpt_dir / "latest"
633+
assert final.is_dir(), f"final checkpoint step_10 was not written:\n{output[-2000:]}"
634+
assert latest.exists(), f"`latest` pointer is missing:\n{output[-2000:]}"
635+
assert latest.resolve().name == "step_10", (
636+
f"`latest` should resolve to step_10, got {latest.resolve().name!r}"
637+
)
638+
639+
609640
# ============================================================================
610641
# MoE Training
611642
# ============================================================================

0 commit comments

Comments
 (0)