Skip to content

Commit 372507e

Browse files
committed
fix(ckpt): honor persistent retain intervals
Signed-off-by: Yu Yao <yaoyu.094@gmail.com>
1 parent 125695a commit 372507e

4 files changed

Lines changed: 241 additions & 5 deletions

File tree

src/megatron/bridge/training/checkpointing.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,20 @@ def train_state_finalize_fn():
14751475
train_state_dict = train_state.state_dict()
14761476

14771477
def train_state_finalize_fn() -> None:
1478+
previous_step = 0
1479+
if (
1480+
ckpt_cfg.save_retain_interval is not None
1481+
and not is_global_non_persistent_ckpt
1482+
and file_exists(tracker_filename)
1483+
):
1484+
if MultiStorageClientFeature.is_enabled():
1485+
msc = MultiStorageClientFeature.import_package()
1486+
open_file = msc.open
1487+
else:
1488+
open_file = open
1489+
with open_file(tracker_filename, "r") as f:
1490+
previous_step = int(f.read().strip())
1491+
14781492
train_state_dict["floating_point_operations_so_far"] = torch.tensor(
14791493
num_floating_point_operations_so_far, dtype=torch.float32
14801494
)
@@ -1504,6 +1518,35 @@ def train_state_finalize_fn() -> None:
15041518
with open(tracker_filename, "w") as f:
15051519
f.write(str(step))
15061520

1521+
if (
1522+
ckpt_cfg.save_retain_interval is not None
1523+
and not is_global_non_persistent_ckpt
1524+
and previous_step > 0
1525+
and previous_step != step
1526+
and previous_step % ckpt_cfg.save_retain_interval != 0
1527+
):
1528+
previous_checkpoint = get_checkpoint_name(save_dir, previous_step)
1529+
if os.path.islink(previous_checkpoint):
1530+
print_rank_0(
1531+
f" skipping deleting checkpoint from iteration {previous_step:7d} "
1532+
f"at {ckpt_cfg.save} since it is a symbolic link"
1533+
)
1534+
else:
1535+
1536+
def remove_previous_checkpoint() -> None:
1537+
with _CHECKPOINT_CLEANUP_LOCK:
1538+
if MultiStorageClientFeature.is_enabled():
1539+
msc = MultiStorageClientFeature.import_package()
1540+
if msc.os.path.exists(previous_checkpoint):
1541+
msc.delete(previous_checkpoint, recursive=True)
1542+
elif os.path.isdir(previous_checkpoint):
1543+
shutil.rmtree(previous_checkpoint)
1544+
1545+
if ckpt_cfg.async_save:
1546+
threading.Thread(target=remove_previous_checkpoint).start()
1547+
else:
1548+
remove_previous_checkpoint()
1549+
15071550
tp_rank = (tensor_rank if tensor_rank is not None else pg_collection.tp.rank()) + 1
15081551
tp_world_size = pg_collection.tp.size()
15091552
pp_rank = (pipeline_rank if pipeline_rank is not None else pg_collection.pp.rank()) + 1

src/megatron/bridge/training/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,16 @@ def finalize(self) -> None:
615615
assert self.save is not None, "async_save is enabled, but save is not set. Set save to a valid path."
616616
assert self.use_persistent_ckpt_worker, "async_save requires use_persistent_ckpt_worker=True."
617617

618+
if self.save_retain_interval is not None:
619+
if self.save_retain_interval <= 0:
620+
raise ValueError("save_retain_interval must be positive.")
621+
if self.save_interval is None or self.save_interval <= 0:
622+
raise ValueError("save_retain_interval requires a positive save_interval.")
623+
if self.save_retain_interval % self.save_interval != 0:
624+
raise ValueError("save_retain_interval must be divisible by save_interval.")
625+
if self.most_recent_k != -1:
626+
raise ValueError("save_retain_interval and most_recent_k cannot be enabled together.")
627+
618628
if self.also_save_hf_checkpoint:
619629
if self.ckpt_format == "fsdp_dtensor":
620630
raise ValueError(

tests/unit_tests/training/test_checkpointing.py

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,151 @@ def run_cleanup_immediately(*, target, args):
891891
assert future_incomplete_checkpoint.is_dir()
892892
assert torch.load(latest_train_state, weights_only=True)["step"].item() == 1000
893893

894+
@pytest.mark.parametrize("previous_step, previous_checkpoint_remains", [(10, False), (20, True)])
895+
def test_sync_persistent_save_honors_retain_interval(
896+
self, tmp_path, save_checkpoint_fixtures, previous_step, previous_checkpoint_remains
897+
):
898+
"""Persistent saves retain only interval checkpoints and the latest checkpoint."""
899+
previous_checkpoint = tmp_path / f"iter_{previous_step:07d}"
900+
previous_checkpoint.mkdir()
901+
(tmp_path / "latest_checkpointed_iteration.txt").write_text(str(previous_step))
902+
current_checkpoint = tmp_path / "iter_0000030"
903+
904+
state = save_checkpoint_fixtures["mock_state"]
905+
state.train_state.step = 30
906+
state.train_state.state_dict.return_value = {"step": torch.tensor(30)}
907+
state.cfg.checkpoint.save = str(tmp_path)
908+
state.cfg.checkpoint.async_save = False
909+
state.cfg.checkpoint.save_retain_interval = 20
910+
state.cfg.checkpoint.most_recent_k = -1
911+
state.wandb_logger = Mock()
912+
913+
pg_collection = Mock()
914+
pg_collection.expt_dp.rank.return_value = 0
915+
pg_collection.tp.rank.return_value = 0
916+
pg_collection.tp.size.return_value = 1
917+
pg_collection.pp.rank.return_value = 0
918+
pg_collection.pp.size.return_value = 1
919+
920+
with (
921+
patch(
922+
"megatron.bridge.training.checkpointing.dist_checkpointing.save",
923+
return_value=None,
924+
),
925+
patch("megatron.bridge.training.checkpointing.TorchDistSaveShardedStrategy", return_value=Mock()),
926+
patch("megatron.bridge.training.checkpointing.get_pg_collection", return_value=pg_collection),
927+
patch("megatron.bridge.training.checkpointing.get_rng_state", return_value=Mock()),
928+
patch("megatron.bridge.training.checkpointing.get_rerun_state_machine") as mock_rerun,
929+
patch("megatron.bridge.training.checkpointing._get_model_glu_interleave_sizes", return_value=(None, None)),
930+
patch(
931+
"megatron.bridge.training.checkpointing.generate_state_dict",
932+
return_value={"model": {"weight": Mock()}},
933+
),
934+
patch(
935+
"megatron.bridge.training.checkpointing.unwrap_model",
936+
return_value=save_checkpoint_fixtures["mock_model"],
937+
),
938+
patch("megatron.bridge.training.checkpointing.save_sharded_modelopt_state"),
939+
patch("megatron.bridge.training.checkpointing.maybe_save_dataloader_state"),
940+
patch("megatron.bridge.training.checkpointing.fault_tolerance"),
941+
patch("megatron.bridge.training.checkpointing.is_empty_async_queue", return_value=True),
942+
patch("megatron.bridge.training.checkpointing.get_rank_safe", return_value=0),
943+
patch("megatron.bridge.training.checkpointing.is_last_rank", return_value=False),
944+
patch("torch.distributed.is_initialized", return_value=False),
945+
):
946+
mock_rerun.return_value.state_dict.return_value = {}
947+
save_checkpoint(
948+
state,
949+
save_checkpoint_fixtures["mock_model"],
950+
save_checkpoint_fixtures["mock_optimizer"],
951+
save_checkpoint_fixtures["mock_scheduler"],
952+
1000000,
953+
checkpointing_context={},
954+
pg_collection=pg_collection,
955+
)
956+
957+
assert previous_checkpoint.exists() is previous_checkpoint_remains
958+
assert current_checkpoint.is_dir()
959+
960+
def test_async_persistent_save_defers_retain_interval_cleanup(self, tmp_path, save_checkpoint_fixtures):
961+
"""The previous checkpoint remains available until its async replacement is durable."""
962+
previous_checkpoint = tmp_path / "iter_0000010"
963+
previous_checkpoint.mkdir()
964+
latest_train_state = tmp_path / "latest_train_state.pt"
965+
torch.save({"step": torch.tensor(10)}, latest_train_state)
966+
(tmp_path / "latest_checkpointed_iteration.txt").write_text("10")
967+
current_checkpoint = tmp_path / "iter_0000030"
968+
969+
state = save_checkpoint_fixtures["mock_state"]
970+
state.train_state.step = 30
971+
state.train_state.state_dict.return_value = {"step": torch.tensor(30)}
972+
state.cfg.checkpoint.save = str(tmp_path)
973+
state.cfg.checkpoint.async_save = True
974+
state.cfg.checkpoint.save_retain_interval = 20
975+
state.cfg.checkpoint.most_recent_k = -1
976+
state.wandb_logger = Mock()
977+
978+
pg_collection = Mock()
979+
pg_collection.expt_dp.rank.return_value = 0
980+
pg_collection.tp.rank.return_value = 0
981+
pg_collection.tp.size.return_value = 1
982+
pg_collection.pp.rank.return_value = 0
983+
pg_collection.pp.size.return_value = 1
984+
985+
finalize_fns = []
986+
async_request = Mock()
987+
async_request.add_finalize_fn.side_effect = finalize_fns.append
988+
989+
def run_cleanup_immediately(*, target):
990+
thread = Mock()
991+
thread.start.side_effect = target
992+
return thread
993+
994+
with (
995+
patch("megatron.bridge.training.checkpointing.dist_checkpointing.save", return_value=async_request),
996+
patch("megatron.bridge.training.checkpointing.TorchDistSaveShardedStrategy", return_value=Mock()),
997+
patch("megatron.bridge.training.checkpointing.get_pg_collection", return_value=pg_collection),
998+
patch("megatron.bridge.training.checkpointing.get_rng_state", return_value=Mock()),
999+
patch("megatron.bridge.training.checkpointing.get_rerun_state_machine") as mock_rerun,
1000+
patch("megatron.bridge.training.checkpointing._get_model_glu_interleave_sizes", return_value=(None, None)),
1001+
patch(
1002+
"megatron.bridge.training.checkpointing.generate_state_dict",
1003+
return_value={"model": {"weight": Mock()}},
1004+
),
1005+
patch(
1006+
"megatron.bridge.training.checkpointing.unwrap_model",
1007+
return_value=save_checkpoint_fixtures["mock_model"],
1008+
),
1009+
patch("megatron.bridge.training.checkpointing.save_sharded_modelopt_state"),
1010+
patch("megatron.bridge.training.checkpointing.maybe_save_dataloader_state"),
1011+
patch("megatron.bridge.training.checkpointing.schedule_async_save"),
1012+
patch("megatron.bridge.training.checkpointing.fault_tolerance"),
1013+
patch("megatron.bridge.training.checkpointing.is_empty_async_queue", return_value=True),
1014+
patch("megatron.bridge.training.checkpointing.get_rank_safe", return_value=0),
1015+
patch("megatron.bridge.training.checkpointing.is_last_rank", return_value=False),
1016+
patch("megatron.bridge.training.checkpointing.threading.Thread", side_effect=run_cleanup_immediately),
1017+
patch("torch.distributed.is_initialized", return_value=False),
1018+
):
1019+
mock_rerun.return_value.state_dict.return_value = {}
1020+
save_checkpoint(
1021+
state,
1022+
save_checkpoint_fixtures["mock_model"],
1023+
save_checkpoint_fixtures["mock_optimizer"],
1024+
save_checkpoint_fixtures["mock_scheduler"],
1025+
1000000,
1026+
checkpointing_context={},
1027+
pg_collection=pg_collection,
1028+
)
1029+
1030+
assert previous_checkpoint.is_dir()
1031+
assert torch.load(latest_train_state, weights_only=True)["step"].item() == 10
1032+
for finalize_fn in finalize_fns:
1033+
finalize_fn()
1034+
1035+
assert not previous_checkpoint.exists()
1036+
assert current_checkpoint.is_dir()
1037+
assert torch.load(latest_train_state, weights_only=True)["step"].item() == 30
1038+
8941039
def test_async_checkpoint_loggers_use_scheduled_step(self, save_checkpoint_fixtures):
8951040
"""Delayed logger finalizers must identify the checkpoint they belong to."""
8961041
state = save_checkpoint_fixtures["mock_state"]
@@ -1079,7 +1224,13 @@ def run_cleanup_immediately(*, target, args):
10791224
assert future_incomplete_checkpoint.is_dir()
10801225
assert torch.load(latest_train_state, weights_only=True)["step"].item() == 30
10811226

1082-
def test_sync_global_non_persistent_honors_configured_retention(self, tmp_path, save_checkpoint_fixtures):
1227+
@pytest.mark.parametrize(
1228+
"most_recent_k, save_retain_interval, expected_steps",
1229+
[(5, None, (20, 30, 40, 50, 60)), (-1, 20, (50, 60))],
1230+
)
1231+
def test_sync_global_non_persistent_honors_configured_retention(
1232+
self, tmp_path, save_checkpoint_fixtures, most_recent_k, save_retain_interval, expected_steps
1233+
):
10831234
"""Synchronous global non-persistent cleanup must retain the configured checkpoint count."""
10841235
save_dir = tmp_path / "persistent"
10851236
non_persistent_dir = tmp_path / "non_persistent"
@@ -1097,8 +1248,10 @@ def test_sync_global_non_persistent_honors_configured_retention(self, tmp_path,
10971248
state.cfg.checkpoint.non_persistent_ckpt_type = "global"
10981249
state.cfg.checkpoint.non_persistent_global_ckpt_dir = str(non_persistent_dir)
10991250
state.cfg.checkpoint.async_save = False
1100-
state.cfg.checkpoint.most_recent_k = 5
1251+
state.cfg.checkpoint.most_recent_k = most_recent_k
1252+
state.cfg.checkpoint.save_retain_interval = save_retain_interval
11011253
state.wandb_logger = Mock()
1254+
(non_persistent_dir / "latest_checkpointed_iteration.txt").write_text("50")
11021255

11031256
pg_collection = Mock()
11041257
pg_collection.expt_dp.rank.return_value = 0
@@ -1143,9 +1296,8 @@ def test_sync_global_non_persistent_honors_configured_retention(self, tmp_path,
11431296
non_persistent_ckpt=True,
11441297
)
11451298

1146-
assert not (non_persistent_dir / "iter_0000010").exists()
1147-
for step in (20, 30, 40, 50):
1148-
assert (non_persistent_dir / f"iter_{step:07d}").is_dir()
1299+
actual_steps = {int(checkpoint.name.removeprefix("iter_")) for checkpoint in non_persistent_dir.glob("iter_*")}
1300+
assert actual_steps == {*expected_steps, 70}
11491301
assert current_checkpoint.is_dir()
11501302
assert future_incomplete_checkpoint.is_dir()
11511303

tests/unit_tests/training/test_config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3057,6 +3057,37 @@ def check_container_state_matches(cfg1, cfg2):
30573057
class TestCheckpointConfig:
30583058
"""Tests for CheckpointConfig class."""
30593059

3060+
@pytest.mark.parametrize(
3061+
"config_overrides, error_message",
3062+
[
3063+
({"save_interval": 10, "save_retain_interval": 0}, "save_retain_interval must be positive"),
3064+
(
3065+
{"save_interval": None, "save_retain_interval": 20},
3066+
"save_retain_interval requires a positive save_interval",
3067+
),
3068+
(
3069+
{"save_interval": 10, "save_retain_interval": 15},
3070+
"save_retain_interval must be divisible by save_interval",
3071+
),
3072+
(
3073+
{"save_interval": 10, "save_retain_interval": 20, "most_recent_k": 1},
3074+
"save_retain_interval and most_recent_k cannot be enabled together",
3075+
),
3076+
],
3077+
)
3078+
def test_save_retain_interval_validation(self, config_overrides, error_message):
3079+
"""Retain intervals require one valid, unambiguous persistent retention policy."""
3080+
ckpt_cfg = create_test_checkpoint_config(**config_overrides)
3081+
3082+
with pytest.raises(ValueError, match=error_message):
3083+
ckpt_cfg.finalize()
3084+
3085+
def test_save_retain_interval_accepts_multiple_of_save_interval(self):
3086+
"""A positive retain interval divisible by the save interval is valid."""
3087+
ckpt_cfg = create_test_checkpoint_config(save_interval=10, save_retain_interval=20)
3088+
3089+
ckpt_cfg.finalize()
3090+
30603091
def test_precision_aware_optimizer_cpu_staging_defaults_off(self):
30613092
ckpt_cfg = create_test_checkpoint_config()
30623093

0 commit comments

Comments
 (0)