Skip to content

Commit a9bcbc5

Browse files
authored
feat(pt): add custom save behaviors (deepmodeling#5589)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `training.save_dir` for periodic checkpoints and `validating.save_best_dir` for best-validation checkpoints. * Added `training.ckpt_keep_ratio` for ratio-based sliding-window checkpoint retention. * **Bug Fixes** * Improved checkpoint filename/“latest” aliasing and symlink/pointer behavior for periodic and EMA checkpoints when `save_dir` is set. * Ensured “best” checkpoints are written only to the configured best-checkpoint directory. * Eagerly creates the validator checkpoint directory during initialization. * **Documentation** * Documented `save_dir` and `ckpt_keep_ratio`; updated the training advanced guide and example config. * **Tests** * Added unit tests for retention rounding/edge cases and filesystem tests for redirecting checkpoints and custom best-checkpoint locations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 1f0ca6c commit a9bcbc5

9 files changed

Lines changed: 355 additions & 19 deletions

File tree

deepmd/pt/train/training.py

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@
7676
from deepmd.pt.train.utils import (
7777
NonFiniteGradGuard,
7878
clip_grad_norm_,
79+
latest_checkpoint_path,
80+
resolve_best_checkpoint_dir,
81+
resolve_keep_ckpt_count,
7982
scoped_env_defaults,
8083
)
8184
from deepmd.pt.train.validation import (
@@ -212,8 +215,13 @@ def __init__(
212215
self.disp_freq = training_params.get("disp_freq", 1000)
213216
self.disp_avg = training_params.get("disp_avg", False)
214217
self.save_ckpt = training_params.get("save_ckpt", "model.ckpt")
218+
save_dir = training_params.get("save_dir")
219+
self.save_dir = Path(save_dir) if save_dir else None
220+
if self.save_dir is not None and self.rank == 0:
221+
self.save_dir.mkdir(parents=True, exist_ok=True)
215222
self.save_freq = training_params.get("save_freq", 1000)
216223
self.max_ckpt_keep = training_params.get("max_ckpt_keep", 5)
224+
self.ckpt_keep_ratio = training_params.get("ckpt_keep_ratio")
217225
self.enable_ema = bool(training_params.get("enable_ema", False))
218226
self.ema_decay = float(training_params.get("ema_decay", 0.999))
219227
self.ema_ckpt_keep = int(training_params.get("ema_ckpt_keep", 3))
@@ -727,6 +735,24 @@ def get_lr(lr_params: dict[str, Any]) -> BaseLR:
727735
rank=self.rank,
728736
)
729737

738+
# === Derive checkpoint retention from ckpt_keep_ratio ===
739+
# num_steps is final here (including when derived from num_epoch), so the
740+
# ratio can be converted into an absolute keep count once.
741+
keep_ckpt_count = resolve_keep_ckpt_count(
742+
self.ckpt_keep_ratio, self.num_steps, self.save_freq
743+
)
744+
if keep_ckpt_count is not None:
745+
self.max_ckpt_keep = keep_ckpt_count
746+
self.ema_ckpt_keep = keep_ckpt_count
747+
log.info(
748+
"Resolved checkpoint retention to %d from ckpt_keep_ratio=%s "
749+
"(num_steps=%d, save_freq=%d).",
750+
keep_ckpt_count,
751+
self.ckpt_keep_ratio,
752+
self.num_steps,
753+
self.save_freq,
754+
)
755+
730756
# Learning rate
731757
self.gradient_max_norm = training_params.get("gradient_max_norm", 0.0)
732758
self.nonfinite_grad_guard = NonFiniteGradGuard()
@@ -1194,7 +1220,9 @@ def _create_full_validator(
11941220
rank=self.rank,
11951221
zero_stage=self.zero_stage,
11961222
restart_training=self.restart_training,
1197-
checkpoint_dir=Path(self.save_ckpt).parent,
1223+
checkpoint_dir=resolve_best_checkpoint_dir(
1224+
validating_params, self.save_ckpt
1225+
),
11981226
)
11991227

12001228
def _create_ema_full_validator(
@@ -1228,7 +1256,9 @@ def _create_ema_full_validator(
12281256
rank=self.rank,
12291257
zero_stage=self.zero_stage,
12301258
restart_training=self.restart_training,
1231-
checkpoint_dir=Path(self.save_ckpt).parent,
1259+
checkpoint_dir=resolve_best_checkpoint_dir(
1260+
validating_params, self.save_ckpt
1261+
),
12321262
full_val_file=get_ema_validation_log_path(
12331263
validating_params.get("full_val_file", "val.log")
12341264
),
@@ -1817,21 +1847,25 @@ def log_loss_valid(_task_key: str = "Default") -> dict:
18171847
self.zero_stage > 0 or self.rank == 0 or dist.get_rank() == 0
18181848
):
18191849
# Handle the case if rank 0 aborted and re-assigned
1820-
self.latest_model = Path(self.save_ckpt + f"-{display_step_id}.pt")
1850+
self.latest_model = latest_checkpoint_path(
1851+
self.save_ckpt, display_step_id, self.save_dir
1852+
)
18211853
self.save_model(self.latest_model, lr=cur_lr, step=_step_id)
18221854
if self.rank == 0 or dist.get_rank() == 0:
18231855
log.info(f"Saved model to {self.latest_model}")
1824-
symlink_prefix_files(self.latest_model.stem, self.save_ckpt)
1856+
symlink_prefix_files(
1857+
str(self.latest_model.with_suffix("")), self.save_ckpt
1858+
)
18251859
with open("checkpoint", "w") as f:
18261860
f.write(str(self.latest_model))
18271861
if self.model_ema is not None:
1828-
self.latest_ema_model = Path(
1829-
self.ema_save_ckpt + f"-{display_step_id}.pt"
1862+
self.latest_ema_model = latest_checkpoint_path(
1863+
self.ema_save_ckpt, display_step_id, self.save_dir
18301864
)
18311865
self.save_ema_model(self.latest_ema_model, lr=cur_lr, step=_step_id)
18321866
if self.rank == 0 or dist.get_rank() == 0:
18331867
symlink_prefix_files(
1834-
self.latest_ema_model.stem,
1868+
str(self.latest_ema_model.with_suffix("")),
18351869
self.ema_save_ckpt,
18361870
)
18371871

@@ -1910,30 +1944,36 @@ def log_loss_valid(_task_key: str = "Default") -> dict:
19101944
self.get_sample_func[model_key],
19111945
_bias_adjust_mode="change-by-statistic",
19121946
)
1913-
self.latest_model = Path(self.save_ckpt + f"-{self.num_steps}.pt")
1947+
self.latest_model = latest_checkpoint_path(
1948+
self.save_ckpt, self.num_steps, self.save_dir
1949+
)
19141950
cur_lr = self.lr_schedule.value(self.num_steps - 1)
19151951
self.save_model(self.latest_model, lr=cur_lr, step=self.num_steps - 1)
19161952
log.info(f"Saved model to {self.latest_model}")
1917-
symlink_prefix_files(self.latest_model.stem, self.save_ckpt)
1953+
symlink_prefix_files(str(self.latest_model.with_suffix("")), self.save_ckpt)
19181954
with open("checkpoint", "w") as f:
19191955
f.write(str(self.latest_model))
19201956
if self.model_ema is not None:
1921-
self.latest_ema_model = Path(
1922-
self.ema_save_ckpt + f"-{self.num_steps}.pt"
1957+
self.latest_ema_model = latest_checkpoint_path(
1958+
self.ema_save_ckpt, self.num_steps, self.save_dir
19231959
)
19241960
self.save_ema_model(
19251961
self.latest_ema_model,
19261962
lr=cur_lr,
19271963
step=self.num_steps - 1,
19281964
)
1929-
symlink_prefix_files(self.latest_ema_model.stem, self.ema_save_ckpt)
1965+
symlink_prefix_files(
1966+
str(self.latest_ema_model.with_suffix("")), self.ema_save_ckpt
1967+
)
19301968

19311969
if self.num_steps == 0 and self.zero_stage > 0:
19321970
# ZeRO-1 / FSDP: all ranks participate in save_model (collective op)
1933-
self.latest_model = Path(self.save_ckpt + "-0.pt")
1971+
self.latest_model = latest_checkpoint_path(self.save_ckpt, 0, self.save_dir)
19341972
self.save_model(self.latest_model, lr=0, step=0)
19351973
if self.model_ema is not None:
1936-
self.latest_ema_model = Path(self.ema_save_ckpt + "-0.pt")
1974+
self.latest_ema_model = latest_checkpoint_path(
1975+
self.ema_save_ckpt, 0, self.save_dir
1976+
)
19371977
self.save_ema_model(self.latest_ema_model, lr=0, step=0)
19381978

19391979
if (
@@ -1942,17 +1982,25 @@ def log_loss_valid(_task_key: str = "Default") -> dict:
19421982
if self.num_steps == 0:
19431983
if self.zero_stage == 0:
19441984
# When num_steps is 0, the checkpoint is never saved in the loop
1945-
self.latest_model = Path(self.save_ckpt + "-0.pt")
1985+
self.latest_model = latest_checkpoint_path(
1986+
self.save_ckpt, 0, self.save_dir
1987+
)
19461988
self.save_model(self.latest_model, lr=0, step=0)
19471989
if self.model_ema is not None:
1948-
self.latest_ema_model = Path(self.ema_save_ckpt + "-0.pt")
1990+
self.latest_ema_model = latest_checkpoint_path(
1991+
self.ema_save_ckpt, 0, self.save_dir
1992+
)
19491993
self.save_ema_model(self.latest_ema_model, lr=0, step=0)
19501994
log.info(f"Saved model to {self.latest_model}")
1951-
symlink_prefix_files(self.latest_model.stem, self.save_ckpt)
1995+
symlink_prefix_files(
1996+
str(self.latest_model.with_suffix("")), self.save_ckpt
1997+
)
19521998
with open("checkpoint", "w") as f:
19531999
f.write(str(self.latest_model))
19542000
if self.model_ema is not None:
1955-
symlink_prefix_files(self.latest_ema_model.stem, self.ema_save_ckpt)
2001+
symlink_prefix_files(
2002+
str(self.latest_ema_model.with_suffix("")), self.ema_save_ckpt
2003+
)
19562004

19572005
if self.timing_in_training and self.timed_steps:
19582006
msg = f"average training time: {self.total_train_time / self.timed_steps:.4f} s/batch"

deepmd/pt/train/utils.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@
99
from contextlib import (
1010
contextmanager,
1111
)
12+
from math import (
13+
ceil,
14+
)
15+
from pathlib import (
16+
Path,
17+
)
1218
from typing import (
1319
TYPE_CHECKING,
20+
Any,
1421
)
1522

1623
import torch
@@ -191,3 +198,88 @@ def scoped_env_defaults(defaults: dict[str, str]) -> Generator[None, None, None]
191198
os.environ.pop(key, None)
192199
else:
193200
os.environ[key] = value
201+
202+
203+
def latest_checkpoint_path(prefix: str, step_label: int, save_dir: Path | None) -> Path:
204+
"""
205+
Resolve the on-disk path of a periodic checkpoint file.
206+
207+
Parameters
208+
----------
209+
prefix : str
210+
The checkpoint prefix, e.g. ``model.ckpt`` or its EMA counterpart.
211+
step_label : int
212+
The training step encoded into the filename.
213+
save_dir : Path or None
214+
The configured checkpoint directory. When ``None`` the file follows
215+
``prefix`` relative to the working directory.
216+
217+
Returns
218+
-------
219+
Path
220+
``save_dir/<prefix name>-<step>.pt`` when ``save_dir`` is set, otherwise
221+
``<prefix>-<step>.pt`` relative to the working directory.
222+
"""
223+
directory = save_dir if save_dir is not None else Path(prefix).parent
224+
return directory / f"{Path(prefix).name}-{step_label}.pt"
225+
226+
227+
def resolve_best_checkpoint_dir(
228+
validating_params: dict[str, Any], save_ckpt: str
229+
) -> Path:
230+
"""
231+
Resolve the directory for full-validation best checkpoints.
232+
233+
Parameters
234+
----------
235+
validating_params : dict
236+
The ``validating`` section of the training configuration.
237+
save_ckpt : str
238+
The regular checkpoint prefix from ``training.save_ckpt``.
239+
240+
Returns
241+
-------
242+
Path
243+
``validating.save_best_dir`` when set, otherwise the directory derived
244+
from ``save_ckpt``.
245+
"""
246+
save_best_dir = validating_params.get("save_best_dir")
247+
if save_best_dir:
248+
return Path(save_best_dir)
249+
return Path(save_ckpt).parent
250+
251+
252+
def resolve_keep_ckpt_count(
253+
ckpt_keep_ratio: float | None, num_steps: int, save_freq: int
254+
) -> int | None:
255+
"""
256+
Convert a checkpoint-retention ratio into a sliding-window keep count.
257+
258+
A checkpoint is written every ``save_freq`` steps and once more at the final
259+
step, so a run of ``num_steps`` produces ``ceil(num_steps / save_freq)`` of
260+
them in total (the terminal checkpoint is off-cadence when ``num_steps`` is
261+
not a multiple of ``save_freq``). Keeping the most recent
262+
``ceil(ratio * total)`` is equivalent to retaining the final ``ratio``
263+
fraction of the run by step, without the caller computing the count by hand.
264+
265+
Parameters
266+
----------
267+
ckpt_keep_ratio : float or None
268+
The fraction of the training run, by step, whose periodic checkpoints
269+
are retained. ``None`` leaves the keep count unchanged.
270+
num_steps : int
271+
The total number of training steps, already resolved (including when
272+
derived from ``numb_epoch``).
273+
save_freq : int
274+
The checkpoint saving frequency in steps.
275+
276+
Returns
277+
-------
278+
int or None
279+
The number of most recent checkpoints to keep (at least one), or
280+
``None`` when ``ckpt_keep_ratio`` is not set.
281+
"""
282+
if ckpt_keep_ratio is None:
283+
return None
284+
total_ckpts = max(1, ceil(num_steps / save_freq))
285+
return max(1, ceil(ckpt_keep_ratio * total_ckpts))

deepmd/pt/train/validation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ def __init__(
273273
self.topk_records = self._load_topk_records()
274274
self._sync_state_store()
275275
if self.rank == 0:
276+
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
276277
self._initialize_best_checkpoints(restart_training=restart_training)
277278

278279
# Lazily-populated full test snapshot for LMDB validation. Mixed-nloc

deepmd/utils/argcheck.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5122,11 +5122,26 @@ def training_args(
51225122
doc_disp_freq = "The frequency of printing learning curve."
51235123
doc_save_freq = "The frequency of saving check point."
51245124
doc_save_ckpt = "The path prefix of saving check point files."
5125+
doc_save_dir = (
5126+
"The directory in which periodic checkpoint files are written, "
5127+
"including the regular checkpoints (the `save_ckpt` prefix) and, when "
5128+
"EMA is enabled, the EMA checkpoints. It is created recursively if it "
5129+
"does not exist. The latest-checkpoint symlinks (such as "
5130+
"`model.ckpt.pt`) and the `checkpoint` pointer file remain in the "
5131+
"working directory and reference the files in this directory. If not "
5132+
"set, checkpoints are written to the working directory."
5133+
)
51255134
doc_max_ckpt_keep = (
51265135
"The maximum number of checkpoints to keep. "
51275136
"The oldest checkpoints will be deleted once the number of checkpoints exceeds max_ckpt_keep. "
51285137
"Defaults to 5."
51295138
)
5139+
doc_ckpt_keep_ratio = (
5140+
"An alternative to `max_ckpt_keep` that sets the number of retained "
5141+
"checkpoints as a fraction in (0, 1) of the run: the most recent "
5142+
"`ceil(ckpt_keep_ratio * ceil(numb_steps / save_freq))` checkpoints are kept. "
5143+
"When set, it overrides `max_ckpt_keep` and `ema_ckpt_keep`."
5144+
)
51305145
doc_enable_ema = (
51315146
"Whether to maintain an exponential moving average (EMA) of model "
51325147
"parameters during training and save periodic EMA checkpoints with an "
@@ -5249,10 +5264,26 @@ def training_args(
52495264
),
52505265
Argument("disp_freq", int, optional=True, default=1000, doc=doc_disp_freq),
52515266
Argument("save_freq", int, optional=True, default=1000, doc=doc_save_freq),
5267+
Argument(
5268+
"save_dir",
5269+
[str, None],
5270+
optional=True,
5271+
default=None,
5272+
doc=doc_only_pt_supported + doc_save_dir,
5273+
),
52525274
Argument(
52535275
"save_ckpt", str, optional=True, default="model.ckpt", doc=doc_save_ckpt
52545276
),
52555277
Argument("max_ckpt_keep", int, optional=True, default=5, doc=doc_max_ckpt_keep),
5278+
Argument(
5279+
"ckpt_keep_ratio",
5280+
[float, None],
5281+
optional=True,
5282+
default=None,
5283+
doc=doc_only_pt_supported + doc_ckpt_keep_ratio,
5284+
extra_check=lambda x: x is None or 0.0 < x < 1.0,
5285+
extra_check_errmsg="must be a fraction in the open interval (0, 1)",
5286+
),
52565287
Argument(
52575288
"enable_ema",
52585289
bool,
@@ -5475,6 +5506,14 @@ def validating_args() -> Argument:
54755506
"The frequency, in training steps, of running the full validation pass."
54765507
)
54775508
doc_save_best = "Whether to save an extra checkpoint when the selected full validation metric reaches a new best value."
5509+
doc_save_best_dir = (
5510+
"The directory in which the best checkpoints selected by full "
5511+
"validation are written (the `best.ckpt` prefix, and the "
5512+
"`best_ema.ckpt` prefix when EMA full validation is enabled). It is "
5513+
"created recursively if it does not exist. If not set, the best "
5514+
"checkpoints are written to the directory determined by "
5515+
"`training.save_ckpt`."
5516+
)
54785517
doc_ema_full_validation = (
54795518
"Whether to additionally run the same full validation flow on the "
54805519
"EMA-smoothed model when `validating.full_validation=true`. This reuses "
@@ -5552,6 +5591,13 @@ def validating_args() -> Argument:
55525591
default=True,
55535592
doc=doc_only_pt_supported + doc_save_best,
55545593
),
5594+
Argument(
5595+
"save_best_dir",
5596+
[str, None],
5597+
optional=True,
5598+
default=None,
5599+
doc=doc_only_pt_supported + doc_save_best_dir,
5600+
),
55555601
Argument(
55565602
"max_best_ckpt",
55575603
int,

doc/train/training-advanced.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ Other keys in the {ref}`training <training>` section are explained below:
103103
- {ref}`disp_file <training/disp_file>` The file for printing learning curve.
104104
- {ref}`disp_freq <training/disp_freq>` The frequency of printing learning curve. Set in the unit of training steps
105105
- {ref}`save_freq <training/save_freq>` The frequency of saving checkpoint.
106+
- {ref}`save_dir <training/save_dir>` The directory where periodic checkpoints are written (PyTorch backend). It is created recursively if missing, while the `model.ckpt.pt` symlinks and the `checkpoint` pointer file stay in the working directory. Defaults to the working directory.
107+
- {ref}`ckpt_keep_ratio <training/ckpt_keep_ratio>` An alternative to `max_ckpt_keep` (PyTorch backend) that keeps a sliding window of `ceil(ckpt_keep_ratio * ceil(numb_steps / save_freq))` most recent checkpoints, i.e. the final `ckpt_keep_ratio` fraction of the run by step. It overrides `max_ckpt_keep` (and `ema_ckpt_keep`) when set, and works the same whether the run length is given by `numb_steps` or `numb_epoch`.
106108

107109
## Options and environment variables
108110

examples/water/dpa4/input.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"numb_steps": 2000000,
100100
"gradient_max_norm": 5.0,
101101
"save_freq": 2000,
102+
"save_dir": "ckpt",
102103
"max_ckpt_keep": 3,
103104
"enable_ema": true,
104105
"ema_decay": 0.999,
@@ -119,6 +120,7 @@
119120
},
120121
"validating": {
121122
"compiled_infer": false,
122-
"tf32_infer": false
123+
"tf32_infer": false,
124+
"save_best_dir": "ckpt_best"
123125
}
124126
}

0 commit comments

Comments
 (0)