Fix off-by-one in GRPO rollout chunk-slicing that produces empty chunks - #20
Open
Anilreddy2309 wants to merge 1 commit into
Open
Anilreddy2309 wants to merge 1 commit into
Anilreddy2309 wants to merge 1 commit into
Conversation
_build_chunk_slice_segment() computed chunk boundaries with ceiling
division:
CHUNK_SIZE = ceil(EFFECTIVE / NUM_CHUNKS)
START_LINE = CHUNK_ID * CHUNK_SIZE + 1
END_LINE = (CHUNK_ID + 1) * CHUNK_SIZE, clamped to EFFECTIVE
Whenever EFFECTIVE doesn't divide evenly by NUM_CHUNKS,
NUM_CHUNKS * CHUNK_SIZE overshoots EFFECTIVE, so the last chunk (or
several, when NUM_CHUNKS > EFFECTIVE) gets START_LINE > END_LINE --
a 0-byte chunk input, and therefore a 0-byte chunk output. E.g.
EFFECTIVE=4, NUM_CHUNKS=3: CHUNK_SIZE=2, chunk 2 gets START_LINE=5,
END_LINE=4.
That empty-but-legitimate chunk then trips the merge step's own
safety check (added specifically to catch real upstream failures --
see the "silent-success cascade" comment a few lines below in the
same file), which reports "chunk N .done exists but file
missing/empty -- aborting" and hard-fails the entire seed's merge,
even though nothing actually went wrong upstream. This is reachable
any time a rollout config chunks a sample count that isn't a clean
multiple of num_chunks -- a common shape for smoke tests and small
eval sets.
Replace with balanced-remainder division: the first
(EFFECTIVE % NUM_CHUNKS) chunks get one extra line, the rest get
EFFECTIVE // NUM_CHUNKS. This covers exactly 1..EFFECTIVE with no
gaps, overlaps, or empty chunks for any CHUNK_ID in [0, NUM_CHUNKS)
whenever NUM_CHUNKS <= EFFECTIVE (proof: REM*(BASE+1) +
(NUM_CHUNKS-REM)*BASE = REM + NUM_CHUNKS*BASE = EFFECTIVE exactly,
by definition of floor division/modulo). A chunk is only empty when
CHUNK_ID >= EFFECTIVE (more chunks requested than samples exist),
which the merge check should still legitimately catch.
Testing: this is generated bash, not directly importable Python
logic, so tests/test_rollout.py's new tests actually execute the
rendered segment via subprocess against real input files rather than
only snapshotting the text -- a snapshot alone would not catch a
wrong split point. Covers: full coverage with no gaps/overlaps across
total_lines 2-12 x num_chunks 2..total_lines, no chunk ever empty in
that range (the direct regression test), the exact previously-buggy
4-lines/3-chunks repro, the NUM_CHUNKS<=1 no-op path, and the
max_num_samples clamp. Verified the new tests actually fail against
the old ceiling-division formula before confirming they pass against
the fix.
_build_client_cmd() composes this segment into its own rendered
output, so its five byte-exact fixture snapshots under
tests/fixtures/rollout/ needed regenerating via
scripts/_dump_rollout_fixtures.py; the diff in each is identical and
scoped to exactly this arithmetic block.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Anil Balireddy <anilbalireddi@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_build_chunk_slice_segment()(nvflow/lib/rl/rollout.py) computes each Slurm job'shead|tailchunk boundaries with ceiling division:Whenever
EFFECTIVEdoesn't divide evenly byNUM_CHUNKS,NUM_CHUNKS * CHUNK_SIZEovershootsEFFECTIVE, so the last chunk (or several, ifNUM_CHUNKS > EFFECTIVE) getsSTART_LINE > END_LINE— a 0-byte chunk. Concretely:EFFECTIVE=4, NUM_CHUNKS=3→CHUNK_SIZE=2, chunk 2 getsSTART_LINE=5, END_LINE=4.That empty-but-legitimate chunk then trips the merge step's own safety check a few lines later in the same file (added specifically to catch real upstream failures — see its "silent-success cascade" comment), which reports
"chunk N .done exists but file missing/empty -- aborting"and hard-fails the entire seed's merge, even though nothing actually went wrong upstream. This is reachable any time a rollout config chunks a sample count that isn't a clean multiple ofnum_chunks— a common shape for smoke tests and small eval sets.Fix
Balanced-remainder division: the first
EFFECTIVE % NUM_CHUNKSchunks get one extra line, the rest getEFFECTIVE // NUM_CHUNKS. This covers exactly1..EFFECTIVEwith no gaps, overlaps, or empty chunks for anyCHUNK_IDwheneverNUM_CHUNKS <= EFFECTIVE— by construction,REMAINDER*(BASE_SIZE+1) + (NUM_CHUNKS-REMAINDER)*BASE_SIZE = REMAINDER + NUM_CHUNKS*BASE_SIZE = EFFECTIVEexactly. A chunk is only empty whenCHUNK_ID >= EFFECTIVE(more chunks requested than samples exist), which is a real misconfiguration the merge check should still catch — that behavior is unchanged.Testing
This is generated bash, not directly-callable Python logic, so I didn't rely on a text snapshot alone (a snapshot wouldn't catch a wrong split point, only a wrong render).
tests/test_rollout.py's new tests actually execute the rendered segment viasubprocessagainst real input files:total_lines2–12 ×num_chunks2..total_linesNUM_CHUNKS <= 1no-op path (unchanged)max_num_samplesclamp (unchanged)I verified the new tests actually fail against the old ceiling-division formula (reproducing the exact reported bug:
chunk 2/3 was empty for total_lines=4) before confirming they pass against the fix — see commit message for the exact repro._build_client_cmd()composes this segment into its own rendered output, so its five byte-exact fixture snapshots undertests/fixtures/rollout/needed regenerating viascripts/_dump_rollout_fixtures.py; the diff in each file is identical and scoped to exactly this arithmetic block (verified with a byte-for-byte diff comparison across all 5).ruff check/ruff format --checkpassmypypassespytest tests/suite passes, including the previously-skippedtest_rollout.py(398 passed, 1 skipped, run against a minimalnemo_skillsstub since the real package needs the full NeMo-RL/torch stack); the lightweight CI path (test_rollout.pyskipped viapytest.importorskip, as designed) is unaffected — 339 passed, 3 skipped, matching the pre-change baseline exactly🤖 Generated with Claude Code