NVIDIA-NeMo/RL PR #3480 โ the fix for
the snapshot-skew bug from the last round. Links pinned to bca9a721.
Part 2 of 3 ยท the ledger ยท the proof
Last round's bug: the save read the sampler's dispatch cursor, then did ~15 awaits
of model and optimizer writing, then snapshotted the replay buffer โ and the rollout pump
kept running in the gap, so the two reads could disagree by one step. On resume the
anti-duplication guard then silently dropped a full batch of prompts. The fix is not a
patch on that one pair โ the author made every restart-facing read happen inside one
exclusive window, and split sampler admission so its cursor advance takes part in the same
locking. Nothing that moves a prompt can run while the checkpoint looks.
The lock is a shared DataPlaneCheckpointBarrier
(replay_buffer.py:196)
with two sides. mutation() โ the "ticket" โ just counts you in as an
in-progress writer: it blocks only while a checkpoint is open, and writers never block
each other. checkpoint() is the exclusive side: it stops new writers from
entering, waits for the counted ones to finish, and only then reads. Note the inversion โ
the code that writes state takes the shared side; the snapshot reader takes
the exclusive one. Exclusivity is between the group of writers and the save, not among
writers.
The subtle part. Sampler admission does two things: it waits at a gate until the
trainer catches up, then it advances the dispatch cursor and stamps the batch's
step. Wrapping both in a mutation slot would be wrong: the gate wait can only end when the
trainer advances, and the trainer's own save is what waits for mutation slots to drain โ
lock held across the wait means the two sides wait for each other. So the PR splits the
old admit() into a two-step protocol,
TransactionalAdmissionSampler:
# single_controller.py:816-822 โ the dispatch path
await sampler.wait_until_admissible(...) # the gate wait โ OUTSIDE the barrier,
# a checkpoint can run while this polls
async with barrier.mutation(): # then the short part takes a slot:
target_step = sampler.commit_admission() # cursor += 1, stamp the step
ledger.mark_group_admitted(...) # RESERVED โ ADMITTED, same cut
# already-buffered duplicate check + dispatch bookkeeping, same cut
Between the wait ending and the slot being taken, nothing can regress the gate: the
rollout pump is the only admitter and runs these two lines back to back, and the trainer
version only grows. All four built-in samplers implement the split. A custom sampler that
does not gets its whole admit() wrapped in a slot as a
fallback
โ correct, at the cost of the checkpoint waiting out that sampler's gate.
Same tiny numbers as last round's bug page โ gate window 1, 4 prompts per step, trainer at step 7, cursor at 6:
| last round (bug) | this round (fixed) | |
|---|---|---|
| cursor read | before the save's ~15 awaits | inside the exclusive cut |
| pump admits during the save? | yes โ stamps step 7 mid-save | cannot โ commit_admission needs a slot, slots wait |
| what disk says | cursor 6, buffer already has step 7 | cursor and buffer agree by construction of the cut |
| resume | guard sees step 7 covered โ silently drops a fresh batch of 4 | nothing to reconcile |