Skip to content

rl: add durable rollout bank for completed groups - #6352

Open
lauradang wants to merge 4 commits into
NVIDIA:mainfrom
lauradang:laurad/rollout-bank-complete-groups
Open

rl: add durable rollout bank for completed groups#6352
lauradang wants to merge 4 commits into
NVIDIA:mainfrom
lauradang:laurad/rollout-bank-complete-groups

Conversation

@lauradang

Copy link
Copy Markdown
Contributor

Persist completed RolloutGroups to a write-through, per-group-fsync'd ledger the instant they assemble, so completed work survives a SIGKILL (the SLURM time limit) and is restored at resume instead of regenerated.

  • types/: extract the rollout data models (Rollout, TokenRollout, RolloutGroup, Rollouts, GroupedRollouts, AgentBaseModel, and the EnvId/GroupsPerEnv/GroupQueuesPerEnv aliases) out of agent/api.py into a megatron.rl.types package; api.py re-exports them so existing imports keep working. RolloutGroup gains a uid; TokenRollout gains advantage_override; Rollout.reward widens to float | None.
  • rollout_bank.py: the durable bank (append/restore/mark_consumed/ checkpoint), offset-indexed binary sidecars for token/logprob/mask arrays, per-record checksums, torn-write recovery, and compaction.
  • api.py: the pipeline writes each completed group through the bank on assembly (injected, never a global).
  • rl_utils.py: bank singleton + restore/injection in get_environment_rollouts, weight-balanced per env across steps.
  • weighted_multi_task.py: env_group_targets + num_groups_per_env override so restored groups keep the per-env weight split.
  • checkpointing.py: compact at the checkpoint boundary (sync path and async durability callback) so compacted-through T tracks the model.
  • arguments.py: --rl-durable-rollout-bank / --rl-rollout-bank-dir / --rl-rollout-bank-max-bytes. No-op when unset.

Ported from jalbericiola#10. That PR was authored on a fork base whose in-flight tracker and speculative-rollout scaffolding are not on NVIDIA main, so the bank was reapplied against main's episode-based pipeline rather than cherry-picked.

Tests: unit + pipeline coverage for the bank round-trip, durability, compaction, restore balancing, and the type re-export (tests/unit_tests/ rl/test_rollout_bank.py, test_grouped_rollouts.py). 101 pass locally.

  • I, the PR author, have personally reviewed every line of this PR.

What does this PR do?

⚠️ For major changes (either in lines of code or in its impact), please make sure to first share a design doc with the team. If you're unsure what's the best way to do so, contact @NVIDIA/mcore-oncall.

Issue tracking

For PRs from open-source community contributors:

  • New features: a linked issue is required. Please open a feature request and reference it here before submitting the PR.
  • Small updates (bug fixes, minor improvements): a linked issue is recommended and will accelerate the PR review process.

Linked issue:

Contribution process

Pre-checks

  • I have added relevant unit tests
  • I have added relevant functional tests
  • I have added proper typing to my code Typing guidelines
  • I have added relevant documentation
  • I have run the autoformatter.sh on my PR

Persist completed RolloutGroups to a write-through, per-group-fsync'd
ledger the instant they assemble, so completed work survives a SIGKILL
(the SLURM time limit) and is restored at resume instead of regenerated.

- types/: extract the rollout data models (Rollout, TokenRollout,
  RolloutGroup, Rollouts, GroupedRollouts, AgentBaseModel, and the
  EnvId/GroupsPerEnv/GroupQueuesPerEnv aliases) out of agent/api.py into
  a megatron.rl.types package; api.py re-exports them so existing
  imports keep working. RolloutGroup gains a uid; TokenRollout gains
  advantage_override; Rollout.reward widens to float | None.
- rollout_bank.py: the durable bank (append/restore/mark_consumed/
  checkpoint), offset-indexed binary sidecars for token/logprob/mask
  arrays, per-record checksums, torn-write recovery, and compaction.
- api.py: the pipeline writes each completed group through the bank on
  assembly (injected, never a global).
- rl_utils.py: bank singleton + restore/injection in
  get_environment_rollouts, weight-balanced per env across steps.
- weighted_multi_task.py: env_group_targets + num_groups_per_env
  override so restored groups keep the per-env weight split.
- checkpointing.py: compact at the checkpoint boundary (sync path and
  async durability callback) so compacted-through T tracks the model.
- arguments.py: --rl-durable-rollout-bank / --rl-rollout-bank-dir /
  --rl-rollout-bank-max-bytes. No-op when unset.

Ported from jalbericiola#10. That PR was authored on a fork
base whose in-flight tracker and speculative-rollout scaffolding are not
on NVIDIA main, so the bank was reapplied against main's episode-based
pipeline rather than cherry-picked.

Tests: unit + pipeline coverage for the bank round-trip, durability,
compaction, restore balancing, and the type re-export (tests/unit_tests/
rl/test_rollout_bank.py, test_grouped_rollouts.py). 101 pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lauradang
lauradang requested review from a team as code owners August 7, 2026 18:35
@svcnvidia-nemo-ci
svcnvidia-nemo-ci marked this pull request as draft August 7, 2026 18:36
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR has been automatically converted to draft because all PRs must start as drafts.

When you are ready for review, click Ready for Review to begin the review process. This will:

  1. Add the oncall reviewer (optional reviewer)
  2. Add required review teams based on your changes

See the contribution guide for more details.

@copy-pr-bot

copy-pr-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

Reformat tests/unit_tests/rl/test_rollout_bank.py to satisfy the CI
linting job's black check (--skip-magic-trailing-comma).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Laura Dang <laurad@nvidia.com>
@janEbert

Copy link
Copy Markdown
Contributor

Hey, your first commit (8c9a310) needs to be signed with the DCO still.

@tdene tdene 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.

Left a couple of important comments.

Another one in the comments here: you are also recovering rollouts that were trained on since the last saved checkpoint.

The generator should not be allowed to run until we catch up. If we saved on ckpt 20, and crashed on ckpt 38, then ckpts 21 -> 37 should train without the generator ever engaging.

Look at #4125 as well. Perhaps this PR should be rebased on top of 4125 so that it can easily load data into the iterator without turning on the generator.

Comment thread megatron/rl/rl_utils.py
Comment on lines +934 to +935
for group in rollouts:
bank.mark_consumed(group.uid, args.curr_iteration)

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.

This repeatedly opens and flushes the same file in a loop. This is going to be very slow compared to just opening the file once at the head of the loop and flushing + closing at the end.

"next checkpoint. Consider a shorter checkpoint interval or lower --rl-generation-lag.",
self._bytes_written, self.max_bytes,
)
self._warned_over_cap = True

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.

This never gets reset after compaction.

self._ledger_f, self._tok_f, self._lp_f, self._mask_f) = saved

def _maybe_warn_over_cap(self) -> None:
if self.max_bytes <= 0 or self._bytes_written <= self.max_bytes:

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.

_bytes_written is also never changed by compaction, even though compaction will change its measure.

_FORMAT_VERSION = 1
_MANIFEST = "MANIFEST.json"
_LEDGER = "ledger.log"
_CONSUMED = "consumed.log"

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.

Should this also be compacted so that it does not grow without bound?

Comment on lines +198 to +202
if not env_id and not config.evaluation_only:
raise ValueError(
f"Active agent {i} ({type(a).__name__}) has no env_id; it is "
f"required to weight-balance restored rollout-bank groups by env. "
f"Set env_id for every non-evaluation agent in the environment config."

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.

Do single-environment setups currently set env_id? If so, they would fail this check, when they shouldn't have to because single-env routing is trivial.

Comment on lines +399 to +402
token_typed = member_type == "TokenRollout" and all(
"trajectory" in m and m["trajectory"] and isinstance(m["trajectory"][0], list)
for m in members
)

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.

You can do isinstance(group.rollouts[0], TokenRollout) directly.

Comment thread megatron/rl/rl_utils.py
Comment on lines +907 to +918
# Streaming with rebalance: quota-route the balanced stream to the
# per-env residual, buffering over-quota groups for a later step.
# Otherwise pull n_fresh groups in completion order.
if args.rl_partial_rollouts and residual_per_env is not None:
fresh = _pull_fresh_balanced(
loop, rollout_generator, residual_per_env,
runtime_state.fresh_overflow,
)
else:
fresh = _pull_fresh_inorder(loop, rollout_generator, n_fresh)
# Restored groups first, then freshly generated ones.
rollouts = list(inject) + fresh

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.

There's a problem with this logic.

Let's say you're in a 2-env situation. You want 50% from env A and 50% from env B. You bank rollouts, and you end up with 100 env A rollouts and 2 env B rollouts.

This logic ensures that you consume a balanced amount, but on the production side you will continue to produce env A despite having way too many rollouts from it.

I don't think this should be fixed in this PR. I think this is a pervasive prior issue that can happen even without banking. But I think it should be documented.

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.

Actually, I'm having second thoughts.

This logic will reliably result in the rollout bank never getting drained. The generator continues to generate rollouts in exactly the same proportion and amount as this logic drains.

I am pretty sure that any overflow is permanent. That is a problem that should be fixed in this PR.

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.

I also don't think this env-balancing logic should exist on the consumer side.

It already exists on the producer side. The rollout bank is a producer as well. It should plug into WeightedMultiTask.

That lets you cut out _pull_fresh_balanced, fresh_overflow, _plan_restore_injection, _empty_rollout_generator, _env_targets, and env_group_targets, and num_groups_per_env.

Something like

correct_agent_groups = list(agent_groups)
for i, (eid, total) in enumerate(zip(env_ids, agent_groups)):
    cached = min(total, len(restored.get(eid, [])))
    correct_agent_groups[i] = total - cached
    for _ in range(cached):
        yield restored[eid].popleft()

try:
with open(self._manifest_path) as f:
manifest = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):

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.

FileNotFoundError and json.JSONDecodeError should not be treated the same. If the file is not found, the ledger doesn't exist. If we have a json.JSONDecodeError, the user should be told that there's a malformatted ledger and an error should be raised so that the user can try to manually recover it.

members = [cls.model_validate(m) for m in group_dict["rollouts"]]
group = RolloutGroup(
rollouts=members,
batch_id=group_dict.get("batch_id", 0),

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.

This batch_id will be stale and will collide.

Each new run starts counting from 0, even if it's a continuation run.

I think the correct fix is to fix this behavior, and make runs start counting from current_iteration instead.

This will affect B/B.

Comment on lines +718 to +720
self._collection_iter = None
self._seg_dir = None
self.set_collection(iteration)

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.

I'm pretty sure this will be off-by-1 for sync saves, and may be very off for async saves.

But also, I don't think these 3 lines do anything? Right after this happens, get_environment_rollouts runs bank.set_collection(args.curr_iteration).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants