NVIDIA-NeMo/RL PR #3480 β
recovering the replay buffer from native TransferQueue checkpoints.
All links pinned to 1c33a9a1.
This PR teaches a paused run to come back with its finished-but-not-yet-trained rollouts intact. To do that it now saves two new things: the replay buffer index (which groups are waiting, and which training step each is stamped for) and the sampler cursor (how many batches have been handed out so far). On resume both are restored exactly.
They have to agree. The cursor decides what step the next batch is stamped for, and
the buffer says which steps are already covered. The problem is that the two are
read about a hundred lines apart, with roughly fifteen await points between
them β and the rollout pump keeps running through every one of those awaits.
The cursor is snapshotted carefully β it sits in an await-free window with the
dataloader position and the spare-prompt pool, and there is a comment saying exactly
that. The buffer is snapshotted carefully too, inside the exclusive checkpoint barrier so
no commit can tear it. Each half is correct on its own. The two were just never made
atomic with each other, and admit() takes no barrier, so it is free to
run in the gap.
Config for the walkthrough β every number distinct so you can trace each one:
| quantity | value | |
|---|---|---|
max_lookahead_versions (the gate) | 1 | generation may run one step ahead |
num_prompts_per_step | 4 | groups needed to close a step |
| trainer version at save | 7 | the step about to be trained |
| saved cursor | 6 | read at t0, before the pump moved |
| stamp the pump assigned at t1 | 7 | collides with the trainer version β that is the bug |
On restart the controller restores the cursor to 6 and the buffer with its 4 groups stamped for step 7. Then the rollout pump starts:
rollout pump trainer
ββββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββ
reads a fresh batch of 4 prompts
from the dataloader waiting for step 7
admit() β cursor 6 becomes 7
stamps target_step = 7 β already covered!
count_for_target_step(7) = 4 select() finds the 4
β dispatches 0 of 4 prompts RESTORED groups
β "dropping the rest" step 7 closes normally
β training looks healthy
The guard at single_controller.py:819-831 notices that step 7 is already covered and refuses to double-generate. That part is right. But the four fresh prompts it declined to dispatch were already pulled off the dataloader, and the dataloader does not rewind. They are gone β never generated, never trained, not in any checkpoint.
print saying βdropping the restβ β which reads like the deliberate
anti-duplication message it also is. One batch per affected resume, silently missing from
the training stream.
If the pump admits at t1 but its rollouts are still generating at t3, the buffer
saves nothing for step 7 while the cursor says 6. On restart the pump re-stamps step 7,
sees count_for_target_step(7) == 0, and dispatches a full fresh batch. It
self-heals. The in-flight rollouts are still lost, but that is the already-known,
deliberately-deferred in-flight drop β not this bug.
The invariant to restore is one sentence: the cursor, the dataloader position and the buffer index must all describe the same instant. Two changes, both using the barrier that already exists.
admit() participate in the barrierThe gate poll must stay outside the barrier β it waits for the trainer to advance, which cannot happen mid-checkpoint, so holding a slot across it would deadlock. Only the increment and the stamp need protecting:
# staleness_sampler.py:381 _GatedSampler.admit
async def admit(self, *, trainer_version_fn):
while self._dispatch_index >= trainer_version_fn() + self._gate_window:
await asyncio.sleep(_GATE_POLL_SECONDS) # stays outside
+ async with self._buffer.checkpoint_barrier.mutation():
+ self._dispatch_index += 1
+ return self._stamp()
- self._dispatch_index += 1
- return self._stamp()
mutation() is the shared side of the same
DataPlaneCheckpointBarrier that commit() already takes, so a save
in progress blocks the increment instead of racing it.
The dataloader and spare pool must move with it β the batch at the saved dataloader position is the one the next stamp applies to, so all three have to be captured together:
# single_controller.py:1872 _save_checkpoint
- save_state.sampler_dispatch_index = self._sampler.dispatch_index
- dataloader_state = self._dataloader.state_dict()
- reserve_state = list(self._replacement_reserve)
...
# single_controller.py:1979 β the exclusive window that already exists
async with self._data_plane_checkpoint_barrier.checkpoint():
+ # No admit, no commit, no dataloader read can interleave here,
+ # so these four describe one instant.
+ save_state.sampler_dispatch_index = self._sampler.dispatch_index
+ dataloader_state = self._dataloader.state_dict()
+ reserve_state = list(self._replacement_reserve)
if self._sampler.supports_buffer_checkpoint:
replay_metadata = self._buffer.metadata_state_dict(...)
max(saved_cursor, max(target_step in restored groups)). That cannot fix a
torn dataloader position, but it does close the drop shown here, and it is a three-line
change in __init__.
Not on hardware β nobody has run this. The functional test that would exercise it
(grpo_dp_single_controller_tq_recovery.sh) checkpoints on a one-second timeout
at step 1, where the pipeline is not yet warm, so it does not reach the state above.
What does exist is an executable model of the two functions involved. It reimplements
_GatedSampler.admit and the ready-only filter in
metadata_state_dict line-for-line from the source, and drives them through
seven warm steps and a checkpoint. It is a model, not the real classes β importing those
pulls in torch and Ray:
steady state: trainer_version=7 dispatch_index=6
[t0] save_state.sampler_dispatch_index = 6 (single_controller.py:1872)
[t1] rollout pump admits during the save -> dispatch_index=7, stamps target_step=7
[t2] its 4 rollouts COMMIT before the snapshot
[t3] metadata_state_dict() -> 4 group(s), target_step(s)=[7]
--- restart ---
restored: trainer_version=7, dispatch_index=6, buffer target_step(s)=[7]
rollout pump: admits, stamps target_step=7; count_for_target_step(7) = 4
-> single_controller.py:822 drops 4 of 4 prompts (0 dispatched)
trainer at step 7: select() finds 4 of 4 group(s) needed
The honest ask before merging: a test that saves with a warm pipeline and a group already stamped for the upcoming step, restores, and asserts the resumed run dispatches a full batch rather than dropping one. No such test exists today β the drop branch of that guard is never exercised, and the sampler cursor and the buffer are never checked against each other.