@@ -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
0 commit comments