In async GRPO a loader hands out prompts a few at a time, the model answers them in the
background, and training picks the answers up later from a holding area (the replay buffer) that only
keeps finished ones. A checkpoint saves the loader's place โ but that place is already past
prompts that are still being answered, and those prompts are in no buffer either. Restart, and they
are gone: on one long run, 675 of 736 skipped prompts came from this. The fix numbers every prompt as
the loader hands it out
(trajectory_collector.py:448),
and saves the loader's place from before the unfinished prompts instead of its place now
(:544).
On restart the loader hands out that stretch again, and each prompt that is already trained or still
sitting in the buffer is thrown away as it arrives
(:486);
the rest get answered again.
One checkpoint, two ways to restart
The fix, in code
1. Number each prompt, and write down where the loader was. Before every
handout the code copies the loader's place, then writes a number on each prompt it hands out. The
last 512 of those places are kept.
place = dataloader.state_dict() # where the loader is: 1 handout done so far
batch = next(loader) # the next 4 prompts
n = self._next_nemo_gym_task_index # 4 โ the number the first of them gets
stamp_task_indices(batch) # the 4 prompts become 4, 5, 6, 7
_dataloader_snapshots.append((n, place)) # "before prompt 4, the loader was here"
2. When saving, pick the place that matches what training has finished. How far
training got is read off the numbers on the prompts it just used. A step counter would not do: when a
prompt is allowed to fail, the counter and the real position drift apart.
done_through = max(done_through,
max(p["_ng_task_index"] for p in just_trained) + 1) # 4
saved = collector.get_checkpoint_state(done_through) # one call, so nothing moves in betweenโโ newest remembered place at or before prompt 4 โ "before prompt 4"
save(saved place, "train_dataloader.pt") # 1 handout done โ not the 3 it has really done
rollouts["frontier_ordinal"] = 4 # prompts under 4 are trained
rollouts["resume_base_ordinal"] = 4 # this file starts handing out again at prompt 4
3. On restart, hand that stretch out again and drop what you already have. The
loader repeats prompts the run has already dealt with, so each one is thrown away the moment it
arrives. Only the gap reaches the model โ and once the repeated stretch is past, the checking stops.
done_through = 4
still_in_buffer = [4, 6, 7, 10] # groups that survived in replay_buffer.pt
last_to_check = max(still_in_buffer + [done_through - 1]) # 10# the loader starts at prompt 4 again: 4,5,6,7 | 8,9,10,11 | 12,13,14,15
for batch in loader:
if lowest number in batch > 10: # 12,13,14,15 โ past everything we know about
stop checking; use the batch # normal from here to the end of the run
keep = numbers that are >= 4 and not in still_in_buffer
# 4,5,6,7 โ keep [5] (4, 6 and 7 are in the buffer)# 8,9,10,11 โ keep [8,9,11] (10 is in the buffer)
Set checkpointing.load_replay_buffer: false
(grpo_math_1B.yaml:124)
and the buffer starts empty, so still_in_buffer is [] and 4, 6, 7 and 10 get
answered again too. Same code either way.
Does it work?
Nobody has posted a run. There is no W&B link, no reward curve, no
before-and-after prompt-coverage count on the PR โ so there is no evidence yet that a resumed run
learns better, or that the ~30% of prompts skipped on the long run that started this actually goes to
zero. What exists is that first measurement (675 of 736 skipped prompts, traced to two causes,
before the fix) and one test that runs a save like the one above both ways (with its own
numbers, fed straight to the filter): the old way skips exactly
[5, 8, 9, 11], the new way skips nothing and repeats nothing
(test_async_utils.py:1530).
The right thing to ask for before merging is one resumed long run with the skip count reported.
Two things to know before you restart a run. The prompts come back, but the answers do
not: a prompt that is answered again gets a fresh answer from the newer weights, because a
half-finished answer cannot be saved. You get the same prompts in the same order, not the same bytes.
And if you restart from a checkpoint written before this PR, that run keeps the old, lossy
behaviour for good โ and so does every run started from the checkpoints it writes
(grpo.py:4346
logs a warning). Only runs started fresh after this PR are protected from their first save.