feat(sc): add checkpoint save/restore to SingleController - #3429
feat(sc): add checkpoint save/restore to SingleController#3429haitian-nvidia wants to merge 11 commits into
Conversation
…ting Port the replay-buffer checkpoint state capture from PR NVIDIA-NeMo#3138 onto the split single-controller path. state_dict snapshots ready slots on the event loop, then fetches each group's DataPlane rows; unready reservations (in-flight rollouts) are dropped. load_state_dict validates the envelope (partition, group size, sample_id uniqueness) before any DataPlane write, truncates to the current capacity keeping the freshest groups, and re-puts rows while rebuilding the parallel slot lists. Staleness filtering is intentionally left to the sampler's first evict. Covered by 9 new unit tests (round-trip, preflight rejection, capacity truncation); 20/20 pass in tests/unit/single_controller/ test_tq_replay_buffer.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
Two additions the SC checkpointing path needs from the sampler layer: - resume_from_step: BaseSampler (and the three built-in policies + create_sampler) now accept the trainer step the run starts from — 0 for a fresh run, the restored current_step on resume. It seeds the dispatch cursor to preserve the fresh-start invariant _dispatch_index == trainer_version - 1. Without it a restored InOrderSampler stamps target_steps from 0 and every dispatched batch is instantly evicted (target < trainer_version), livelocking the train pump. create_sampler forwards the kwarg to custom samplers only on resume, so fresh starts don't constrain their constructors and an unsupported class fails loudly instead of silently running with an unseeded cursor. - supports_buffer_checkpoint: new PromptGroupSampler property gating replay-buffer save/restore. Only the ungated WindowedSampler returns True — gated policies dispatch a fixed quota per trainer step, so restored groups could never complete an already-consumed window. Covered by 8 new unit tests in test_sampler_interface.py (cursor seeding, gate behavior after resume, factory forwarding, custom fail-loud, checkpoint-support matrix). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
Replace the checkpointing NotImplementedError guard with the actual driver-side resume wiring, following the grpo.py setup pattern: - Build a CheckpointManager unconditionally and resolve the latest checkpoint: load_training_info() populates save_state (default GRPOSaveState when starting fresh) and get_resume_paths() yields the weights/optimizer paths. - _build_trainer takes kw-only weights_path/optimizer_path and forwards them to TQPolicy (previously hardcoded to None) on both the colocated and non-colocated build paths. - Restore the dataloader position from train_dataloader.pt when present (load_dataloader_state, with its dataset-swap guard); warn and start fresh otherwise. Runs before _clamp_max_num_steps as before. - Forward checkpointing.pretrained_checkpoint into the policy config. - SingleControllerActorArgs carries two new fields, save_state and last_checkpoint_path, for the actor-side restore (next step). Saving itself is not wired yet — that lands in the SingleControllerActor train pump next. Existing tests updated for the new surface: the setup tests' hand-built checkpointing block now carries the keys CheckpointManager indexes, and the pump tests pass the two new ActorArgs fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
Wire the actor side of SC checkpointing (the setup/resume half landed in the previous commit), with Megatron async_save supported end to end: - Restore: __init__ rebuilds counters (train_steps/trainer_version/ current_epoch/consumed_samples/total_valid_tokens) from the save_state loaded by setup, seeds the sampler with resume_from_step, and run() reloads replay-buffer groups (ungated samplers only, one capacity permit per restored group) before the pumps start. - Save: after each weight sync, _save_checkpoint mirrors async_grpo_train's block — finalize_pending flushes the previous background finalization, save_checkpoint returns after D2H staging under async_save, aux state (training info, dataloader position, replay buffer when the sampler supports it) is written synchronously, then begin_finalization defers the tmp->step rename until the async weight writes complete. run() flushes the last checkpoint via checkpointer.shutdown() on every exit path. - TimeoutChecker drives checkpoint_must_save_by: a timeout save also stops training early, matching the legacy loops. - latest_checkpoint_status.json is refreshed after each save for external watchdogs (reuses grpo's _write_latest_checkpoint_status). The pump tests' hand-built configs gain the checkpointing block the actor now reads (enabled=false keeps them write-free). Validated end to end on GB200 (Qwen3-0.6B, megatron async_save=true): 4-step run saves step_2/step_4 with no tmp_step_* leftovers; a checkpoint_must_save_by run stops early with a complete checkpoint; the resume run restores dataloader + 4 replay groups and continues from step 2 to step 4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
Port the checkpointing test suite from PR NVIDIA-NeMo#3138 onto the split SC architecture (actor_args, the PromptGroupSampler protocol, the split trainer step API) and extend it for the async-save path: - counter/sampler-cursor restore, save triggers (period boundary, last step, checkpoint_must_save_by timeout, disabled, save_optimizer), metric_name handling, dataloader state round-trip with the dataset-swap guard, and setup resume wiring (get_resume_paths forwarded to the trainer factory, training_info.json loaded). - replay-buffer persistence is asserted against sampler.supports_buffer_checkpoint (windowed saves/restores with one capacity permit per group; gated samplers skip both sides). - new async-save coverage: the tmp->step rename stays deferred until finalize_async_save completes and is flushed by shutdown; a failed background finalization re-raises at the next save; _save_checkpoint records val_metrics into val_reward and a val:* metric_name. 32 tests, in-process actor with fakes (ray.cluster_resources patched); 108 passed together with the existing single_controller suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
On exit, run() now propagates a failed checkpoint finalization only on the clean path; when an exception is already propagating the flush is best-effort (warning), so the original training failure stays the raised exception — matching async_grpo_train's guarded cleanup shutdown. logger.finish() moves into its own finally so it runs either way. Also drop the stale "SC does not support checkpointing yet." comment from the SC exemplar config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
yuki-97
left a comment
There was a problem hiding this comment.
@haitian-nvidia thanks for supporting this! left some comments.
| "payload_for": list(metas[0].sample_ids) | ||
| } | ||
|
|
||
| def test_round_trip_restores_lists_and_rows(self): |
There was a problem hiding this comment.
Round-trip only exercises start_weight == end_weight and target_step is None. _add_group(weight=w) defaults end_weight=weight, and commit never sets target_step — so a load bug that swapped start_weight / end_weight at replay_buffer.py:1002-1003 or silently dropped target_step (the InOrderSampler's primary key at staleness_sampler.py:431) would still pass.
Suggest fix: parameterize with start=1, end=2 on one group and target_step=7 on another.
There was a problem hiding this comment.
Done in cbbbe2b — test_round_trip_preserves_end_weight_and_target_step parameterizes exactly as suggested: one group with start=1, end=2 and one with target_step=7, asserting all three lists survive the round-trip. Built via the real reserve(target_step=...) → commit path (rather than a hand-built envelope) so the save side of target_step serialization is exercised too.
| for sid in ids: | ||
| self._rows.pop(sid, None) | ||
|
|
||
| def get_samples( |
There was a problem hiding this comment.
FakeDataPlaneClient.get_samples returns a hand-crafted {"payload_for": sample_ids} dict and load_state_dict re-puts it verbatim — but the production replay-buffer file goes through torch.save(buffer_state, replay_buffer.pt) on TensorDict-valued fields_data (see single_controller.py:290,766). Any bug in that real serialization path (dtype/device promotion, non-contiguous strides, custom-Tensor pickle) is uncaught here.
Suggest fix: one round-trip that stores an actual TensorDict payload through an in-memory BytesIO torch.save/torch.load cycle.
There was a problem hiding this comment.
Done in cbbbe2b — test_round_trip_tensordict_payload_through_torch_save stores a real TensorDict as fields_data (mixed int64/float32 dtypes + a non-contiguous [:, ::2] view) through an in-memory BytesIO torch.save/torch.load(weights_only=False) cycle (matching the production load), then asserts per-key torch.equal after load_state_dict re-puts.
| "payload_for": list(metas[0].sample_ids) | ||
| } | ||
|
|
||
| def test_round_trip_restores_lists_and_rows(self): |
There was a problem hiding this comment.
Three round-trip shapes aren't covered: (a) empty buffer (common resume shape when no group was committed before the checkpoint); (b) middle-unready [ready, unready, ready] (state_dict skips unready by index; the trailing-unready test wouldn't catch an off-by-one in that skip); (c) target_step != None — the InOrderSampler resume path, which needs _make_group_entry(target_step=7) to construct.
There was a problem hiding this comment.
Done in cbbbe2b — (a) test_round_trip_empty_buffer; (b) test_state_dict_skips_middle_unready ([ready, unready, ready], asserting the neighbouring groups' fields don't shift); (c) covered by test_round_trip_preserves_end_weight_and_target_step (see the thread above) via the real reserve(target_step=7) path.
| return asyncio.run(_main()) | ||
|
|
||
|
|
||
| def _run_actor_run(mc: MasterConfig, actor_args: SingleControllerActorArgs): |
There was a problem hiding this comment.
All four _run_actor_run call sites use max_num_steps=0, so _train_pump never runs post-restore. TestCounterRestore covers constructor seeding and TestSaveTrigger covers the fresh pump, but nothing composes the two to prove that resuming with save_state["current_step"]=N and running the pump yields _train_steps = N + k (not k, not 2N + k).
Suggest fix: one test that primes save_state["current_step"]=2, runs with max_num_steps=4, and asserts final _train_steps == 4.
There was a problem hiding this comment.
Done in cbbbe2b — test_resumed_pump_continues_to_max_steps primes save_state["current_step"]=2, runs the live pump with max_num_steps=4, and asserts _train_steps == 4 (not 2, not 6), plus that only the post-resume step_4 boundary is checkpointed and consumed_samples accumulates on top of the restored value.
| dataloader_state_path = os.path.join( | ||
| last_checkpoint_path, "train_dataloader.pt" | ||
| ) | ||
| if os.path.exists(dataloader_state_path): |
There was a problem hiding this comment.
SC guards load_dataloader_state with if os.path.exists(dataloader_state_path) and prints a warning on miss, while GRPO's init_train_dataloader (grpo.py:451-452) calls it unconditionally and lets a missing file raise. SC always writes train_dataloader.pt on save (line 759), so absence means externally-tampered / partial ckpt — silent-fresh-start then replays already-seen samples with no signal to the user.
Suggest fix: drop the exists guard and let load_dataloader_state raise, matching GRPO's contract.
There was a problem hiding this comment.
Done in 26c0f92 — dropped the os.path.exists guard; a resume checkpoint missing train_dataloader.pt now raises (matching GRPO's contract). The unit test now expects FileNotFoundError.
| self._dispatch_index: int = -1 | ||
| # Pre-incremented before each admitted batch, so the cursor trails | ||
| # the run's starting step by one. | ||
| self._dispatch_index: int = resume_from_step - 1 |
There was a problem hiding this comment.
resume_from_step is threaded through 6 signatures + a special-case != 0 gate for CustomSamplerConfig at line 561-562 that silently overrides YAML model_extra on resume but silently loses to it on fresh runs.
Since only _dispatch_index needs to be restored, wdyt replacing the kwarg with a BaseSampler.set_dispatch_index(resume_from_step) post-construction hook, called once from SingleControllerActor.__init__ after create_sampler? Removes the 5 sub-constructor signature changes, the factory kwarg, and the CustomSampler asymmetry.
There was a problem hiding this comment.
Done in 26c0f92 — replaced the resume_from_step kwarg threading with a BaseSampler.set_dispatch_index(resume_from_step) post-construction hook (also on the PromptGroupSampler protocol), called once from SingleControllerActor.__init__. The factory kwarg and the custom-sampler fresh-vs-resume asymmetry are gone.
| # Flush the last checkpoint's background finalization; a failure | ||
| # raises on a clean exit but never masks a propagating exception. | ||
| propagating = sys.exc_info()[0] is not None | ||
| try: | ||
| await asyncio.to_thread(self._checkpointer.shutdown) | ||
| except Exception: | ||
| if not propagating: | ||
| raise | ||
| warnings.warn( | ||
| "Checkpoint finalization failed while handling an " | ||
| "exception; the original exception will be re-raised.", | ||
| stacklevel=2, | ||
| ) | ||
| finally: | ||
| self._logger.finish() |
There was a problem hiding this comment.
sys.exc_info()[0] is not None + try/except buys "shutdown errors don't mask training errors" — but Python's built-in exception chaining already preserves the original via __context__, and the shape reads against skills/error-handling's fail-loud policy. The whole block flattens to:
| # Flush the last checkpoint's background finalization; a failure | |
| # raises on a clean exit but never masks a propagating exception. | |
| propagating = sys.exc_info()[0] is not None | |
| try: | |
| await asyncio.to_thread(self._checkpointer.shutdown) | |
| except Exception: | |
| if not propagating: | |
| raise | |
| warnings.warn( | |
| "Checkpoint finalization failed while handling an " | |
| "exception; the original exception will be re-raised.", | |
| stacklevel=2, | |
| ) | |
| finally: | |
| self._logger.finish() | |
| self._logger.finish() | |
| await asyncio.to_thread(self._checkpointer.shutdown) |
There was a problem hiding this comment.
Done in 26c0f92 — flattened to self._logger.finish() + a bare await asyncio.to_thread(self._checkpointer.shutdown), relying on exception chaining.
| # Snapshot before any await so it can't interleave with | ||
| # _rollout_pump iterating this same dataloader. | ||
| dataloader_state = self._dataloader.state_dict() | ||
| if val_metrics is not None: |
There was a problem hiding this comment.
for the val part, is it make sense to you to remove it and add back when supporting val?
I'm also ok with keeping it here since val should be supported soon, so just a notice to add some related tests when supporting val.
There was a problem hiding this comment.
Done in 60de5aa — removed the val placeholder from _save_checkpoint (the val_metrics parameter and its branch); we'll add it back with the validation loop, along with tests. The stale val_reward sentinel from _default_grpo_save_state is still dropped so it never lands in training_info.json.
| if not ( | ||
| self._sampler.supports_buffer_checkpoint | ||
| and self._last_checkpoint_path is not None | ||
| ): | ||
| return |
There was a problem hiding this comment.
instead of supports_buffer_checkpoint, I think maybe better to
- save
sampler_namein ckpt. - if
sampler_nameequal with current, load buffer - if
sampler_namenot equal, warning and return.
something like below:
| if not ( | |
| self._sampler.supports_buffer_checkpoint | |
| and self._last_checkpoint_path is not None | |
| ): | |
| return | |
| if not self._last_checkpoint_path is not None: | |
| return | |
| if sampler_name != current_sampler_name: | |
| print(...) | |
| return | |
There was a problem hiding this comment.
Done in 60de5aa, with one refinement on where the name lives: supports_buffer_checkpoint is gone — every save writes replay_buffer.pt and records async_rl.sampler.name into training_info.json (run metadata, alongside current_step), rather than into the buffer envelope. On resume the saved name is compared against the current sampler before the torch.load, so a mismatched (or pre-this-change) checkpoint warns and skips without reading the buffer file. TQReplayBuffer stays sampler-agnostic.
| # Enable per-rollout diagnostic prints (prompt content / completion previews). | ||
| diagnostics: false | ||
|
|
||
| # SC does not support checkpointing yet. |
There was a problem hiding this comment.
can we add functional tests like tests/functional/grpo_async_replay_buffer_checkpoint.sh and tests/functional/sft_resume_diamond.sh?
There was a problem hiding this comment.
Done in 7c1d6da — added tests/functional/grpo_checkpoint_single_controller.sh (registered in L1_Functional_Tests_SingleController.sh, full tier). Same two-run shape as grpo_async_replay_buffer_checkpoint.sh: run 1 uses a 1-second checkpoint_must_save_by so the timeout save fires deterministically at the first step boundary and stops early (also covering the timeout-save path); run 2 resumes from step_1, asserting the dataloader + replay-buffer restore lines and reaching step_4. Verified end to end on 2 GPUs (~8 min runtime).
Three changes from the PR review: - A resume checkpoint missing train_dataloader.pt now raises instead of warning and starting from a fresh dataloader position. SC always writes the file on save, so absence means a corrupted checkpoint; this matches GRPO's load contract. - The sampler dispatch cursor is now seeded via a post-construction BaseSampler.set_dispatch_index(resume_from_step) hook (also on the PromptGroupSampler protocol) instead of threading a resume_from_step kwarg through five constructors and the factory — and it removes the fresh-vs-resume asymmetry for custom FQN samplers. - run()'s exit path flattens to logger.finish() + a bare checkpointer.shutdown(): Python's exception chaining already keeps an original training failure visible if the flush also fails, so the explicit guard read against the fail-loud policy. Tests updated accordingly (setter-based seeding cases, the missing-file test now expects FileNotFoundError, resume-wiring fixtures write dataloader state); 107 passed. Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
- Round-trip now covers start_weight != end_weight and a non-None target_step (the InOrderSampler selection key), an empty buffer, and an unready slot sandwiched between ready ones (guards the by-index skip in state_dict). - One round-trip drives a real TensorDict payload (mixed dtypes and a non-contiguous view) through torch.save/torch.load, exercising the serialization path the production replay_buffer.pt actually uses instead of the opaque fake payload. - New composed resume test: an actor restored at current_step=2 running a live pump to max_num_steps=4 ends at exactly 4 steps and only checkpoints the post-resume boundary. 112 passed with the existing single_controller suite. Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
Replace the sampler-level supports_buffer_checkpoint capability with a recorded-identity check, per review: - Every save now writes replay_buffer.pt regardless of sampler, and records async_rl.sampler.name into training_info.json. - On resume, the buffer is restored only when the saved sampler name matches the current one; a mismatch warns and skips (checked before the torch.load, so a mismatched buffer file is never read). This also covers resuming an old checkpoint that predates the recorded name. - supports_buffer_checkpoint is dropped from PromptGroupSampler, BaseSampler and WindowedSampler; TQReplayBuffer is untouched (the sampler identity is run metadata, so it lives in training_info, not the buffer envelope). Also remove the val_metrics placeholder from _save_checkpoint (val lands with the future validation loop); the stale val_reward sentinel is still dropped from training_info. 108 passed with the existing single_controller suite. Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
Two runs of examples/run_grpo_single_controller.py sharing one checkpoint_dir and one config (same max_num_steps, so Megatron train_iters stays consistent across the restart): - Run 1 sets checkpointing.checkpoint_must_save_by=00:00:00:01 so the timeout save fires deterministically at the first step boundary and training stops early, leaving a complete step_1 checkpoint (weights, dataloader position, replay buffer; no tmp_step_* leftovers, i.e. the async finalization was flushed) with current_step=1 and the saving sampler_name recorded in training_info.json. - Run 2 drops the timeout and must restore the dataloader position and replay buffer, then continue from step 2 to step 4. Registered in L1_Functional_Tests_SingleController.sh (full tier; the fast tier keeps the two existing quick scripts). Verified end to end on 2 GPUs (Qwen3-0.6B, megatron async_save=true), ~8 minutes of runtime. Signed-off-by: haitian-nvidia <haitianj@nvidia.com>
# Conflicts: # nemo_rl/algorithms/single_controller.py # nemo_rl/algorithms/single_controller_utils/setup.py # tests/unit/single_controller/test_rollout_pump.py # tests/unit/single_controller/test_train_pump.py
What does this PR do ?
Adds checkpoint save/resume to the SingleController path, closing the
NotImplementedErrorplaceholder left insetup_single_controller.Save (in the train pump, after each weight sync):
policy.save_checkpoint, and optimizer state whencheckpointing.save_optimizeris set. Megatronasync_saveissupported: the tmp→step rename is deferred with
begin_finalization(wait_fn=policy.finalize_async_save)and flushed atthe next save or on exit, so training/rollouts continue while the weight
write completes in the background.
training_info.json), dataloader position(
train_dataloader.pt), and — when the sampler supports it(windowed/over-sampled) — the TQ replay buffer's committed prompt groups
(
replay_buffer.pt, meta + DataPlane payloads).save_periodboundary, last step, andcheckpoint_must_save_by(timeout save + early stop).latest_checkpoint_status.jsonis refreshed after each save.Resume:
setup_single_controllerresolves the latest checkpoint, loadstraining_info.json, feedsweights_path/optimizer_pathto thetrainer, and restores the dataloader position (with the existing
dataset-swap guard).
SingleControllerActorrestores its counters, seeds the samplerdispatch cursor via the new
resume_from_stepargument, and reloadsreplay-buffer groups (re-acquiring one buffer-capacity permit per
group) before the pumps start.
create_sampler(..., resume_from_step=...)and asupports_buffer_checkpointproperty onPromptGroupSampler(True forthe windowed sampler; gated samplers skip buffer save/restore).
Usage
uv run examples/run_grpo_single_controller.py \ checkpointing.enabled=true \ checkpointing.checkpoint_dir=results/my-run \ checkpointing.save_period=10 # Re-running the same command resumes from the latest step_N checkpoint.Before your PR is "Ready for review"
Pre checks:
Additional Information
tests/unit/single_controller/— 108 passed, including 32new tests in
test_sc_checkpointing.py(save triggers, async-savefinalization ordering and failure propagation, metric_name handling,
dataloader round-trip, replay-buffer persistence, setup resume wiring).
async_save: true):a 4-step run saves complete
step_2/step_4checkpoints with notmp_step_*leftovers; acheckpoint_must_save_byrun saves and stopsearly; resuming restores the dataloader + replay buffer and continues
to step 4.
checkpointingblock).