Skip to content

feat(sc): add checkpoint save/restore to SingleController - #3429

Open
haitian-nvidia wants to merge 11 commits into
NVIDIA-NeMo:mainfrom
haitian-nvidia:haitianj/sc-main-ckpt
Open

feat(sc): add checkpoint save/restore to SingleController#3429
haitian-nvidia wants to merge 11 commits into
NVIDIA-NeMo:mainfrom
haitian-nvidia:haitianj/sc-main-ckpt

Conversation

@haitian-nvidia

Copy link
Copy Markdown

What does this PR do ?

Adds checkpoint save/resume to the SingleController path, closing the
NotImplementedError placeholder left in setup_single_controller.

Save (in the train pump, after each weight sync):

  • Policy weights via policy.save_checkpoint, and optimizer state when
    checkpointing.save_optimizer is set. Megatron async_save is
    supported: the tmp→step rename is deferred with
    begin_finalization(wait_fn=policy.finalize_async_save) and flushed at
    the next save or on exit, so training/rollouts continue while the weight
    write completes in the background.
  • Training state (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).
  • Triggers: save_period boundary, last step, and
    checkpoint_must_save_by (timeout save + early stop).
    latest_checkpoint_status.json is refreshed after each save.

Resume:

  • setup_single_controller resolves the latest checkpoint, loads
    training_info.json, feeds weights_path/optimizer_path to the
    trainer, and restores the dataloader position (with the existing
    dataset-swap guard).
  • SingleControllerActor restores its counters, seeds the sampler
    dispatch cursor via the new resume_from_step argument, and reloads
    replay-buffer groups (re-acquiring one buffer-capacity permit per
    group) before the pumps start.
  • New sampler surface: create_sampler(..., resume_from_step=...) and a
    supports_buffer_checkpoint property on PromptGroupSampler (True for
    the 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:

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests?
  • Did you run the unit tests and functional tests locally? Visit our Testing Guide for how to run tests
  • Did you add or update any necessary documentation? Visit our Document Development Guide for how to write, build and test the docs.

Additional Information

  • Unit tests: tests/unit/single_controller/ — 108 passed, including 32
    new tests in test_sc_checkpointing.py (save triggers, async-save
    finalization ordering and failure propagation, metric_name handling,
    dataloader round-trip, replay-buffer persistence, setup resume wiring).
  • E2E on GB200 (Qwen3-0.6B, megatron backend, async_save: true):
    a 4-step run saves complete step_2/step_4 checkpoints with no
    tmp_step_* leftovers; a checkpoint_must_save_by run saves and stops
    early; resuming restores the dataloader + replay buffer and continues
    to step 4.
  • No documentation changes needed (config surface reuses the existing
    checkpointing block).

haitian-nvidia and others added 6 commits July 29, 2026 16:14
…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>
@haitian-nvidia
haitian-nvidia requested review from a team as code owners July 30, 2026 18:03
@copy-pr-bot

copy-pr-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@yuki-97 yuki-97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cbbbe2btest_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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cbbbe2btest_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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cbbbe2btest_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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread nemo_rl/algorithms/single_controller.py Outdated
Comment on lines +237 to +251
# 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
# 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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 26c0f92 — flattened to self._logger.finish() + a bare await asyncio.to_thread(self._checkpointer.shutdown), relying on exception chaining.

Comment thread nemo_rl/algorithms/single_controller.py Outdated
# 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread nemo_rl/algorithms/single_controller.py Outdated
Comment on lines +276 to +280
if not (
self._sampler.supports_buffer_checkpoint
and self._last_checkpoint_path is not None
):
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of supports_buffer_checkpoint, I think maybe better to

  1. save sampler_name in ckpt.
  2. if sampler_name equal with current, load buffer
  3. if sampler_name not equal, warning and return.

something like below:

Suggested change
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add functional tests like tests/functional/grpo_async_replay_buffer_checkpoint.sh and tests/functional/sft_resume_diamond.sh?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@haitian-nvidia
haitian-nvidia requested a review from yuki-97 August 4, 2026 22:49
@haitian-nvidia

Copy link
Copy Markdown
Author

@mehraakash

@mehraakash
mehraakash self-requested a review August 5, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants