NVIDIA-NeMo/RL #4507, part 6 of 11 in the partial-checkpointing stack.
All permalinks pin the PR head f6d6aa5.
Three pages on this PR. This one: how much of the current head the test needs.
The test reads the rows each run trained on from the trainer's own dump
(load_training_records),
then checks each restored call: it finished once, it starts its row's last call, and its first tokens hash to what the restore logged
(_assert_prefix_boundary).
Every other row must match the uninterrupted run exactly. Those checks are worth keeping.
The extra lines are checks that cannot fail, the same lookups built several times, and unit tests that repeat each other.
Where the fix lands: RL only, test files only.
_gym_prefix_recovery_parity.py and the matching unit tests.
No product change.
prefix + tail = total. The vLLM worker computes the logged total as exactly that sum
(vllm_worker_async.py:1914-1931).
The author removed this check in round 1, and the last commit brought it back.
Advantages within tolerance. GRPO computes each row's advantage from its prompt group's rewards and masks
(advantage_estimator.py:106-145),
and the test already compares rewards, masks and group membership exactly. With top_p=1e-6 every advantage here is 0 anyway.
@@ _gym_prefix_recovery_parity.py L330-L336 ยท _assert_prefix_boundary if tail_tokens <= 0: raise AssertionError("restored generation produced no suffix tokens") - if prefix_tokens + tail_tokens != total_tokens: - raise AssertionError( - "restored generation token accounting has a gap or overlap: " - f"prefix={prefix_tokens}, tail={tail_tokens}, total={total_tokens}" - ) @@ _gym_prefix_recovery_parity.py L36-L38, L166-L195, L267-L274 ยท approximate compare -_APPROXIMATE_FIELDS = ("advantages",) -def _assert_nested_close(baseline, recovery, *, path, rtol, atol): ... # 30 lines - for field in _APPROXIMATE_FIELDS: - _assert_nested_close(left.get(field), right.get(field), ...) # plus the rtol/atol parameters and flags (L205-206, L561-562, L595-596)
What still catches a dropped or extra token: counting back from the row's end lands the restored call at the wrong place, and its hash no longer matches (parity.py:352-364). That stays.
C17 โ parity.py:332 C18 โ parity.py:36
The rows are re-indexed 7 times
(_rows_by_identity),
and the two functions that check each cut build their own row maps and look up the restored hash twice. Merging them into one ~45-line function keeps every check:
| Check | Today | After |
|---|---|---|
| each prefix restored once and finished once | two list scans | one _once() lookup each |
| restored call sits at the row's end, after an untrained token | L352-359 | same lines |
| its first tokens hash to the restore log | L360-364 | same |
| a set-aside row matches before its last call | L444-450 | same, plus the C16 near-tie check |
| not every cut is set aside | L452-456 | same |
C16 โ parity.py:451 C17 โ parity.py:332 C18 โ parity.py:36 C19 โ parity.py:138 C20 โ calendar_rows.jq:12 C21 โ snapshot.py:83 C22 โ unit test:25