Skip to content

Commit bc902da

Browse files
authored
feat(pt_expt): align the training runtime with pt (#5958)
## Summary - move checkpoint layout, retention, training timing, and sharding policy into backend-independent training utilities shared by `pt` and `pt_expt` - add `pt_expt` support for checkpoint directories and retention ratios, EMA training/checkpoints, EMA full validation, consistent training reports, and restart-safe state restoration - support the same `zero_stage` strategies as `pt`, including DDP, ZeRO-1, and FSDP2, with collective checkpoint assembly and optimizer-state restoration - reuse overflow-safe gradient norm reduction and defer the non-finite verdict to checkpoint boundaries so a diverged model is not saved - make EMA checkpoint retention inherit `max_ckpt_keep` by default while preserving an explicit `ema_ckpt_keep` override ## Motivation The `pt_expt` trainer currently lacks several operational guarantees available in `pt`: equivalent checkpoint retention and restart behavior, EMA support, distributed state sharding, stable gradient checks, and consistent progress reporting. Implementing these separately would leave two training runtimes with duplicated policies that can drift. This PR keeps backend-specific serialization and execution in each trainer, while centralizing the policies that are independent of a backend. It also relocates the EMA, validation, and gradient helpers under `pt_expt`, which is their continuing owner as the legacy `pt` trainer is retired. ## Notable fixes - rerunning in a directory from a longer run no longer lets stale future checkpoints evict the newly written checkpoint - `max_ckpt_keep < 1` retains all checkpoints instead of deleting the current checkpoint - `ckpt_keep_ratio` works when periodic saving is disabled and overrides both regular and EMA windows - restarting from a checkpoint without optimizer state resumes the learning-rate schedule from the recorded step - sharded checkpoints are assembled collectively, avoiding rank desynchronization at the next barrier - regular and EMA checkpoint families are pruned independently; absent `ema_ckpt_keep` now gives both families the `max_ckpt_keep` window ## Validation - `OMP_NUM_THREADS=1 DP_INTER_OP_PARALLELISM_THREADS=0 DP_INTRA_OP_PARALLELISM_THREADS=0 /Users/outisli/Software/miniforge3/envs/dpmd/bin/python -m pytest source/tests/common/dpmodel/test_train_checkpoint.py source/tests/common/test_argcheck_training.py -q` (`14 passed`) - a five-step `pt_expt` training run with EMA, `save_freq=1`, and only `max_ckpt_keep=2` retained regular steps `4, 5` and EMA steps `4, 5` - repository pre-commit checks passed <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added configurable checkpoint retention, restart-safe cleanup, and separate EMA checkpoints. * Added distributed training with multiple sharding strategies and improved checkpoint restoration. * Added EMA full-validation workflows with independent schedules and best-checkpoint tracking. * Added parameter-count reporting and improved training progress timing, averages, and ETA estimates. * Added safer handling of non-finite gradients. * Added support for checkpoint retention and EMA features across supported PyTorch backends. * **Documentation** * Expanded guidance for checkpoint retention, EMA, distributed training, and inference options. * **Bug Fixes** * Improved validation compatibility and handling of shared or relocated save directories. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a3195b0 commit bc902da

28 files changed

Lines changed: 2729 additions & 965 deletions

deepmd/dpmodel/train/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
22
"""Backend-independent training abstractions."""
33

4+
from .checkpoint import (
5+
CheckpointStore,
6+
build_checkpoint_stores,
7+
resolve_keep_ckpt_count,
8+
)
49
from .data import (
510
TrainingTaskConfig,
611
iter_training_task_configs,
@@ -15,6 +20,13 @@
1520
StepSchedule,
1621
resolve_step_schedule,
1722
)
23+
from .sharding import (
24+
ShardingPolicy,
25+
)
26+
from .timing import (
27+
DisplayInterval,
28+
TrainingTimer,
29+
)
1830
from .trainer import (
1931
DEFAULT_TASK_KEY,
2032
AbstractTrainer,
@@ -32,19 +44,25 @@
3244
"DEFAULT_TASK_KEY",
3345
"AbstractTrainEntrypoint",
3446
"AbstractTrainer",
47+
"CheckpointStore",
48+
"DisplayInterval",
3549
"LearningCurveWriter",
3650
"RankContext",
51+
"ShardingPolicy",
3752
"StepSchedule",
3853
"TrainEntrypointOptions",
3954
"TrainStepResult",
4055
"TrainerConfig",
4156
"TrainingTask",
4257
"TrainingTaskCollection",
4358
"TrainingTaskConfig",
59+
"TrainingTimer",
60+
"build_checkpoint_stores",
4461
"change_model_out_bias",
4562
"change_model_out_bias_by_task",
4663
"iter_training_task_configs",
4764
"make_task_maps",
4865
"print_data_summaries",
66+
"resolve_keep_ckpt_count",
4967
"resolve_step_schedule",
5068
]

deepmd/dpmodel/train/checkpoint.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""On-disk layout and retention policy of periodic training checkpoints.
3+
4+
A run writes one numbered file per checkpoint and keeps a fixed-size window of
5+
the most recent ones. Both the naming and the pruning are pure filesystem
6+
concerns, independent of how a backend serializes its state, so they are
7+
described once here and shared by every backend.
8+
"""
9+
10+
from __future__ import (
11+
annotations,
12+
)
13+
14+
import logging
15+
from math import (
16+
ceil,
17+
)
18+
from pathlib import (
19+
Path,
20+
)
21+
from typing import (
22+
TYPE_CHECKING,
23+
Any,
24+
)
25+
26+
from deepmd.common import (
27+
symlink_prefix_files,
28+
)
29+
30+
if TYPE_CHECKING:
31+
from collections.abc import (
32+
Mapping,
33+
)
34+
35+
log = logging.getLogger(__name__)
36+
37+
__all__ = ["CheckpointStore", "build_checkpoint_stores", "resolve_keep_ckpt_count"]
38+
39+
40+
class CheckpointStore:
41+
"""Naming, publication and retention of a family of checkpoints.
42+
43+
Numbered checkpoints are written as ``<directory>/<name>-<step><suffix>``,
44+
where ``<name>`` is the file name of ``prefix`` and ``<directory>`` is
45+
``save_dir`` when given and the directory of ``prefix`` otherwise.
46+
Publishing a checkpoint points the prefix-named files at it, so a consumer
47+
that only knows the prefix always reaches the newest checkpoint.
48+
49+
Parameters
50+
----------
51+
prefix : str or Path
52+
The checkpoint prefix, such as ``model.ckpt``. Its directory receives
53+
the prefix-named symlinks, and its file name seeds the numbered files.
54+
save_dir : Path, optional
55+
Directory holding the numbered checkpoints. Defaults to the directory
56+
of ``prefix``.
57+
max_keep : int, optional
58+
Number of most recent numbered checkpoints to retain. Values below one
59+
retain every checkpoint.
60+
suffix : str, optional
61+
File suffix of a checkpoint, including the leading dot.
62+
pointer_file : str or Path, optional
63+
File recording the path of the most recently published checkpoint.
64+
``None`` publishes symlinks only, which is what a secondary family of
65+
checkpoints, such as the EMA one, requires so that it does not claim
66+
the pointer of the primary family.
67+
"""
68+
69+
def __init__(
70+
self,
71+
prefix: str | Path,
72+
*,
73+
save_dir: Path | None = None,
74+
max_keep: int = 5,
75+
suffix: str = ".pt",
76+
pointer_file: str | Path | None = None,
77+
) -> None:
78+
self.prefix = Path(prefix)
79+
self.directory = Path(save_dir) if save_dir is not None else self.prefix.parent
80+
self.max_keep = int(max_keep)
81+
self.suffix = suffix
82+
self.pointer_file = Path(pointer_file) if pointer_file is not None else None
83+
84+
def prepare(self) -> None:
85+
"""Create the directories receiving the checkpoints and the symlinks."""
86+
self.directory.mkdir(parents=True, exist_ok=True)
87+
self.prefix.parent.mkdir(parents=True, exist_ok=True)
88+
89+
def path_for(self, step: int) -> Path:
90+
"""Return the path of the checkpoint recorded at a step.
91+
92+
Parameters
93+
----------
94+
step : int
95+
Training step encoded into the file name.
96+
97+
Returns
98+
-------
99+
Path
100+
Path of the numbered checkpoint of this store.
101+
"""
102+
return self.directory / f"{self.prefix.name}-{step}{self.suffix}"
103+
104+
def step_of(self, path: Path) -> int | None:
105+
"""Return the step encoded in a checkpoint name, or ``None``.
106+
107+
Only the file name is inspected; see :meth:`holds` for membership of
108+
this store.
109+
110+
Parameters
111+
----------
112+
path : Path
113+
Candidate checkpoint path.
114+
115+
Returns
116+
-------
117+
int or None
118+
The step of a numbered checkpoint of this store, or ``None`` when
119+
the name does not follow ``<name>-<step><suffix>``.
120+
"""
121+
stem_prefix = f"{self.prefix.name}-"
122+
if path.suffix != self.suffix or not path.name.startswith(stem_prefix):
123+
return None
124+
step_text = path.name[len(stem_prefix) : -len(self.suffix)]
125+
if not step_text.isdigit():
126+
return None
127+
return int(step_text)
128+
129+
def holds(self, path: Path) -> bool:
130+
"""Whether a path is a numbered checkpoint of this store.
131+
132+
Parameters
133+
----------
134+
path : Path
135+
Candidate checkpoint path.
136+
137+
Returns
138+
-------
139+
bool
140+
``True`` when the path lies in this store's directory and its name
141+
encodes a step.
142+
"""
143+
return (
144+
self.step_of(path) is not None
145+
and path.parent.resolve() == self.directory.resolve()
146+
)
147+
148+
def publish(self, path: Path) -> None:
149+
"""Point the prefix-named files and the pointer file at a checkpoint.
150+
151+
Parameters
152+
----------
153+
path : Path
154+
Checkpoint the prefix-named files resolve to from now on.
155+
"""
156+
self.prefix.parent.mkdir(parents=True, exist_ok=True)
157+
symlink_prefix_files(str(path.with_suffix("")), str(self.prefix))
158+
if self.pointer_file is not None:
159+
self.pointer_file.write_text(str(path))
160+
161+
def prune(self, current: Path) -> None:
162+
"""Drop the checkpoints made obsolete by a fresh one.
163+
164+
Checkpoints numbered above the current step are remnants of a longer
165+
earlier run over the same directory. They are removed first: leaving
166+
them in place would let the retention window discard the freshly
167+
written checkpoint instead, so a rerun in a finished directory would
168+
keep no result at all. The window then retains the newest ``max_keep``
169+
checkpoints. The checkpoint just written is never removed.
170+
171+
Parameters
172+
----------
173+
current : Path
174+
Path of the checkpoint that was just written. A path this store
175+
does not hold, such as a checkpoint selected by validation, dates
176+
nothing and claims no slot of the window.
177+
"""
178+
if self.max_keep < 1:
179+
return
180+
current_step = self.step_of(current) if self.holds(current) else None
181+
retained: list[tuple[int, Path]] = []
182+
for path in self.directory.glob(f"*{self.suffix}"):
183+
step = self.step_of(path)
184+
if step is None or path.is_symlink():
185+
continue
186+
if current_step is not None and path.name == current.name:
187+
continue
188+
if current_step is not None and step > current_step:
189+
path.unlink(missing_ok=True)
190+
else:
191+
retained.append((step, path))
192+
retained.sort(key=lambda item: (item[0], item[1].name))
193+
# The current checkpoint occupies one slot of the window when this
194+
# store holds it.
195+
occupied = 1 if current_step is not None else 0
196+
excess = max(0, len(retained) + occupied - self.max_keep)
197+
for _, path in retained[:excess]:
198+
path.unlink(missing_ok=True)
199+
200+
201+
def resolve_keep_ckpt_count(
202+
ckpt_keep_ratio: float | None, num_steps: int, save_freq: int
203+
) -> int | None:
204+
"""Convert a checkpoint-retention ratio into a sliding-window keep count.
205+
206+
A checkpoint is written every ``save_freq`` steps and once more at the
207+
final step, so a run of ``num_steps`` produces ``ceil(num_steps /
208+
save_freq)`` of them in total (the terminal checkpoint is off-cadence when
209+
``num_steps`` is not a multiple of ``save_freq``). Keeping the most recent
210+
``ceil(ratio * total)`` is equivalent to retaining the final ``ratio``
211+
fraction of the run by step, without the caller computing the count by
212+
hand.
213+
214+
Parameters
215+
----------
216+
ckpt_keep_ratio : float or None
217+
The fraction of the training run, by step, whose periodic checkpoints
218+
are retained. ``None`` leaves the keep count unchanged.
219+
num_steps : int
220+
The total number of training steps, already resolved (including when
221+
derived from ``numb_epoch``).
222+
save_freq : int
223+
The checkpoint saving frequency in steps. Values below one disable
224+
periodic saving, leaving the final checkpoint as the only one.
225+
226+
Returns
227+
-------
228+
int or None
229+
The number of most recent checkpoints to keep (at least one), or
230+
``None`` when ``ckpt_keep_ratio`` is not set.
231+
"""
232+
if ckpt_keep_ratio is None:
233+
return None
234+
total_ckpts = max(1, ceil(num_steps / save_freq)) if save_freq > 0 else 1
235+
return max(1, ceil(ckpt_keep_ratio * total_ckpts))
236+
237+
238+
def build_checkpoint_stores(
239+
training_params: Mapping[str, Any],
240+
*,
241+
num_steps: int,
242+
ema_prefix: str | Path,
243+
rank: int = 0,
244+
) -> tuple[CheckpointStore, CheckpointStore]:
245+
"""Build the checkpoint stores of a training run.
246+
247+
A run keeps two families of checkpoints: the periodic ones, which carry
248+
the live weights and the state needed to resume, and the EMA ones, which
249+
carry smoothed weights only. They share a directory and differ in prefix,
250+
retention and whether they own the pointer file.
251+
252+
Parameters
253+
----------
254+
training_params : Mapping[str, Any]
255+
The normalized ``training`` section. ``save_ckpt``, ``save_dir``,
256+
``save_freq``, ``max_ckpt_keep``, ``ckpt_keep_ratio`` and
257+
``ema_ckpt_keep`` are read from it. When ``ema_ckpt_keep`` is unset,
258+
the EMA family inherits ``max_ckpt_keep``.
259+
num_steps : int
260+
The resolved run length, needed to turn ``ckpt_keep_ratio`` into a
261+
keep count.
262+
ema_prefix : str or Path
263+
Checkpoint prefix of the EMA family, derived by the backend from
264+
``save_ckpt``.
265+
rank : int, optional
266+
Process rank. Only the chief creates directories and reports the
267+
resolved retention.
268+
269+
Returns
270+
-------
271+
tuple[CheckpointStore, CheckpointStore]
272+
The periodic store and the EMA store.
273+
"""
274+
save_dir = training_params.get("save_dir")
275+
save_freq = int(training_params.get("save_freq", 1000))
276+
max_keep = int(training_params.get("max_ckpt_keep", 5))
277+
configured_ema_max_keep = training_params.get("ema_ckpt_keep")
278+
ema_max_keep = (
279+
max_keep if configured_ema_max_keep is None else int(configured_ema_max_keep)
280+
)
281+
ckpt_keep_ratio = training_params.get("ckpt_keep_ratio")
282+
283+
keep_ckpt_count = resolve_keep_ckpt_count(ckpt_keep_ratio, num_steps, save_freq)
284+
if keep_ckpt_count is not None:
285+
max_keep = keep_ckpt_count
286+
ema_max_keep = keep_ckpt_count
287+
if rank == 0:
288+
log.info(
289+
"Resolved checkpoint retention to %d from ckpt_keep_ratio=%s "
290+
"(num_steps=%d, save_freq=%d).",
291+
keep_ckpt_count,
292+
ckpt_keep_ratio,
293+
num_steps,
294+
save_freq,
295+
)
296+
297+
directory = Path(save_dir) if save_dir else None
298+
store = CheckpointStore(
299+
training_params.get("save_ckpt", "model.ckpt"),
300+
save_dir=directory,
301+
max_keep=max_keep,
302+
pointer_file="checkpoint",
303+
)
304+
ema_store = CheckpointStore(
305+
ema_prefix,
306+
save_dir=directory,
307+
max_keep=ema_max_keep,
308+
)
309+
if rank == 0:
310+
store.prepare()
311+
return store, ema_store

0 commit comments

Comments
 (0)