Repository navigation
fix(server): reseed RNG per row at batched-prefill first-token sample - #356
Merged
Merged
Conversation
A batched prefill cohort runs every row's begin_prefill (which seeds the global MLX RNG when the request carries a seed) up front, before any row reaches finish_prefill (which samples the first token). The fused first-token sampler draws from the process-global RNG with no per-call key, so the last seeded row's seed governed first-token sampling for the whole cohort ("last-seed-wins"): a seeded row's first token depended on its siblings' seeds rather than its own. PR #346 (#332 cohort splitting) widened this, since cold rows that previously fell back to sequential prefill now run inside a BatchedCold cohort.
Reseed the global RNG to the row's own seed inside finish_prefill, immediately before the first-token sample, so each row's first token depends only on its own seed. begin_prefill keeps its seeding (harmless once overwritten here, and still reached from the disaggregated handoff path). Greedy / temperature == 0 / top_k == 1 rows take the argmax path and consume no RNG, so the reseed is a no-op for them. finish_prefill is the single per-row first-token chokepoint for every prefill path, so this one insertion covers both the pre-existing all-cold case and the #332 cohort-split case.
The batched fused decode path samples all rows in one fused_sample draw over [B, vocab] from the shared global RNG and is left unchanged: a seeded row's multi-token decode output across batch sizes is the pre-existing, documented batched-vs-B=1 jitter class, and making it byte-identical would require threading a per-row RNG key into fused_sample (follow-up).
Add a real-model regression test (qwen3-0.6b-4bit, ignored, soft-skip when absent) that holds cohort composition constant and varies only seed values to isolate the RNG variable: a seeded row's first token is invariant to sibling seeds (the bug-discriminating check), depends on its own seed (positive control, non-vacuous), greedy is unaffected, and a cohort-split mixed window matches the all-cold window of the same cold composition.
Member
Author
Implementation Review SummaryIntentReseed the global MLX RNG to each row's own seed at the batched-prefill first-token sample point, eliminating "last-seed-wins" for seeded sampling in a batched cohort (#347). FindingsNo CRITICAL, HIGH, or MEDIUM correctness findings. The one-line reseed and its regression test are correct as written; no changes were made by this review. Verification
Verdict: correct and complete for its stated scope. |
inureyes
added a commit
that referenced
this pull request
Jun 20, 2026
Audio I/O on the OpenAI /v1/audio/* surface (Whisper speech-to-text #371, Kokoro-82M text-to-speech #374, endpoint plumbing #368), reasoning_content on non-streaming chat (#359), pre-M5 decode +8-12% via a hardware-gated op cap (#360), Turbo4Asym decode rerouted through dequant-SDPA (#369), batched sampling fixes (#339, #340, #344, #356), and the Turbo KV advisor/docs aligned to the measured sweep with ADR 0002 (#343, #376). README: consolidate the v0.3.0 and v0.3.1 highlights under a single v0.3 entry and add v0.3.2.
be-free-snowboy
pushed a commit
to freestylejs/snowbrain-mlxcel
that referenced
this pull request
Oct 27, 2026
…lablup#356) A batched prefill cohort runs every row's begin_prefill (which seeds the global MLX RNG when the request carries a seed) up front, before any row reaches finish_prefill (which samples the first token). The fused first-token sampler draws from the process-global RNG with no per-call key, so the last seeded row's seed governed first-token sampling for the whole cohort ("last-seed-wins"): a seeded row's first token depended on its siblings' seeds rather than its own. PR lablup#346 (lablup#332 cohort splitting) widened this, since cold rows that previously fell back to sequential prefill now run inside a BatchedCold cohort. Reseed the global RNG to the row's own seed inside finish_prefill, immediately before the first-token sample, so each row's first token depends only on its own seed. begin_prefill keeps its seeding (harmless once overwritten here, and still reached from the disaggregated handoff path). Greedy / temperature == 0 / top_k == 1 rows take the argmax path and consume no RNG, so the reseed is a no-op for them. finish_prefill is the single per-row first-token chokepoint for every prefill path, so this one insertion covers both the pre-existing all-cold case and the lablup#332 cohort-split case. The batched fused decode path samples all rows in one fused_sample draw over [B, vocab] from the shared global RNG and is left unchanged: a seeded row's multi-token decode output across batch sizes is the pre-existing, documented batched-vs-B=1 jitter class, and making it byte-identical would require threading a per-row RNG key into fused_sample (follow-up). Add a real-model regression test (qwen3-0.6b-4bit, ignored, soft-skip when absent) that holds cohort composition constant and varies only seed values to isolate the RNG variable: a seeded row's first token is invariant to sibling seeds (the bug-discriminating check), depends on its own seed (positive control, non-vacuous), greedy is unaffected, and a cohort-split mixed window matches the all-cold window of the same cold composition.
be-free-snowboy
pushed a commit
to freestylejs/snowbrain-mlxcel
that referenced
this pull request
Oct 27, 2026
Audio I/O on the OpenAI /v1/audio/* surface (Whisper speech-to-text lablup#371, Kokoro-82M text-to-speech lablup#374, endpoint plumbing lablup#368), reasoning_content on non-streaming chat (lablup#359), pre-M5 decode +8-12% via a hardware-gated op cap (lablup#360), Turbo4Asym decode rerouted through dequant-SDPA (lablup#369), batched sampling fixes (lablup#339, lablup#340, lablup#344, lablup#356), and the Turbo KV advisor/docs aligned to the measured sweep with ADR 0002 (lablup#343, lablup#376). README: consolidate the v0.3.0 and v0.3.1 highlights under a single v0.3 entry and add v0.3.2.
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
Fixes the "last-seed-wins" determinism bug in batched prefill so a seeded request's first token no longer depends on the seeds of the other rows it happens to be batched with. The change is a one-line reseed of the global MLX RNG at the exact first-token sample point, plus a real-model regression test.
Root cause
seed_rng_if_neededseeds the process-global MLX RNG (ffi::random_seed) and is called frombegin_prefill. Inrun_padded_batched_prefill, every row in a cohort runsbegin_prefillup front (one loop) before any row runsfinish_prefill(a later per-row loop that samples the first token). The first-token sampler isfused_sample, which takes only(logits, temperature, top_k, top_p, min_p)and draws from the global RNG with no per-call key. So by the time row 0 samples, the global RNG holds the LAST cohort row's seed, and every seeded row's first token is governed by whichever seed was applied last ("last-seed-wins"). PR #346 (#332 cohort splitting) widened the exposure: a cold row that previously fell back to sequential prefill now runs batched inside aBatchedColdcohort and newly inherits this behavior.Fix
Insert
seed_rng_if_needed(&seq.sampling)infinish_prefill, immediately before the first-tokensample_token_optimizedcall, so the global RNG is reseeded to THIS row's own seed at the exact point it samples.begin_prefill's existing seeding is intentionally left in place (it is harmless once overwritten here, and is still reached from the disaggregated handoff path).finish_prefillis the single per-row first-token sampling chokepoint for every prefill path (batched cohort, sequential, full, chunked-final), so this one insertion fixes both the pre-existing all-cold case and the #332 cohort-split case holistically rather than patching each path.Greedy is a no-op
Greedy /
temperature == 0/top_k == 1rows take thefused_sampleargmax path, which consumes no RNG, so the reseed has no effect on them. The regression test pins this.Scope: decode is out of scope (honest AC #1 re-framing)
This fix makes a seeded row's FIRST token independent of its siblings' seeds, and greedy output is fully identical regardless of batch composition. It does NOT make a seeded request's full multi-token output byte-identical between "run alone" (B=1) and "batched" (B>1). Two pre-existing, already-documented effects remain and are not this seed bug: (1) padded batched prefill is not bitwise-identical to single-sequence prefill on Metal (the #203 / #325 / #326 forward-pass jitter class), and (2) the batched fused DECODE path (
execute_batched_decode->batched_fused_sample) samples all rows in ONEfused_sampledraw over[B, vocab]from the shared global RNG, so a seeded row's decode trajectory depends on batch composition (the documented batched-vs-B=1 jitter class noted insampling.rs). Full per-sequence multi-token determinism across batch sizes would require threading a per-row RNG key intofused_sample(an FFI / MLX-side change), which is disproportionate for this priority:low bug and is left as a follow-up. AC #1 is therefore satisfied at the first-token / batched-prefill scope of this bug; the residual is the existing jitter class, not last-seed-wins.Regression test (invariant choice)
src/server/batch/scheduler_seed_determinism_tests.rs(real qwen3-0.6b-4bit,#[ignore], soft-skips when the model dir is absent), mirroring thescheduler_cohort_parity_tests.rsharness. It deliberately does NOT compare a batched (B>1) row against a single-prefill (B=1) row, which would conflate the forward-pass jitter class. Instead it holds the cohort COMPOSITION constant (same prompts in the same positions, so the measured row's logits are byte-identical) and varies ONLY seed values, isolating the RNG variable. The measured row is always at index 0 (sampled first) and the seeded sibling at index 1 (seeded last), which is exactly where "last-seed-wins" would leak. Checks:temperature == 0row's first token is independent of any sibling's seed.[cold A, cold B, adopted C], which the planner splits into BatchedCold{A, B}+ Sequential{C}) matches the same row in an all-cold batched window of the same cold composition ([cold A, cold B]).Test plan
cargo check --lib --tests --features metal,acceleratecargo clippy --lib --tests --features metal,accelerate -- -D warningscargo test -p mlxcel --lib --release --features metal,accelerate batched_prefill_seed_determinism -- --ignored --nocapture(observed: sibling seeds [1,2,3] -> first token [4792, 4792, 4792]; own seeds [10..60] -> 4 distinct first tokens; greedy [334, 334, 334]; cohort-split all-cold == mixed == 334)cargo fmt --checkCloses #347