Skip to content

fix(server): row-level per-request sampling in the continuous batch (supersedes #1353) - #1647

Open
mmmugh wants to merge 11 commits into
Blaizzy:mainfrom
mmmugh:fix/row-level-sampling
Open

fix(server): row-level per-request sampling in the continuous batch (supersedes #1353)#1647
mmmugh wants to merge 11 commits into
Blaizzy:mainfrom
mmmugh:fix/row-level-sampling

Conversation

@mmmugh

@mmmugh mmmugh commented Jul 21, 2026

Copy link
Copy Markdown

Supersedes #1353. That PR added top_k/min_p to the single shared batch sampler; @lucasnewman closed it because a continuous batch needs row-level sampling when requests have different generation parameters. This does that: every row in the batch is sampled with its own temperature / top_p / top_k / min_p / seed.

The framing

mlx-vlm's GenerationBatch was forked from mlx-lm's, and mlx-lm already carries per-row samplers (self.samplers: List[Callable], threaded through extend/filter). The fork collapsed that list into one shared sampler built from the first request — which is the bug. On main today, GenerationBatch.extend()/filter() never touch self.sampler, so a merging batch's sampler is dropped ("request #1 wins"); _make_sampler drops top_k/min_p; and greedy_sampling is a batch-wide bool from request #1. This PR restores per-row sampling.

Approach

  • A frozen SamplingConfig(temperature, top_p, top_k, min_p, seed) carried as a per-row List[SamplingConfig] through insert → extend → filter, mirroring the existing logits_processors list exactly.
  • A row-aware _PositionedTargetSampler(configs) that builds [B] mx.arrays and applies temperature → top_k → top_p → min_p in one sorted-space pass, with fused greedy where(temp < eps, argmax, sampled). top_k is a rank-mask (exactly-k), because apply_top_k's argpartition(kth=int) can't take a per-row k. The vendored sample_utils.py is untouched.
  • The two duplicated _PositionedTargetSampler copies (which had drifted on seed handling) are unified.
  • Per-request seeds stay stateless: _position_seed(seed, position)mx.random.key, so a seeded request is reproducible and invariant to batch composition (vLLM/SGLang semantics: same seed = same stream). Per-row differentiation comes from each row's seed.
  • Out-of-domain top_k/min_p are rejected at admission (422) via Pydantic field constraints.

The proof

The load-bearing test is a strengthened test_positioned_target_sampler_is_batch_grouping_invariant: two rows with distinct row_ids and distinct temperature/top_p/top_k/min_p/seed, asserting the batched draw equals each row drawn alone. That property is what #1353 lacked, and it fails on main today. Plus regression tests for the batch lifecycle (extend/filter alignment, greedy reconciliation, first-token per-row), MTP staggered completion, and speculative no-drop. 499 tests green.

Live verification

Verified end-to-end against a real mlx_vlm.server (Qwen3-VL-8B-Instruct-8bit, MLX 0.32.0, Apple Silicon), on the continuous-batching path.

Check Result Detail
Greedy (temp=0) deterministic solo identical across runs
top_k=1 @ temp=2.0 tracks greedy (top_k binds) shares a 305-char prefix with the greedy answer
Unconstrained temp=2.0 diverges from greedy (incoherent) shares only 17 chars with greedy
Stochastic temp=1.3, top_k=60, min_p=0.02 genuinely samples ≠ greedy
Seeded stochastic config reproducible (same seed twice, solo) identical
Co-batched top_k=1 row stays greedy-coherent every time prefix-match with greedy across 5 co-batched pairs: [120,120,120,120,120]
Co-batched temp=2.0 row stays incoherent every time prefix-match with greedy across the same pairs: [17,17,17,17,17]
The two co-batched rows never collapse to one mode greedy row ≠ hot row in every pair

The discriminator (the bar #1353 could not pass): a top_k=1 (greedy) request and an unconstrained temperature=2.0 (incoherent) request were co-batched. The greedy row tracked the greedy answer every time while the hot row stayed wild every time, in the same batches — they retained distinct per-config behavior. Under the old shared-sampler behavior the whole batch uses one parameter set, so the greedy row would have gone wild whenever the hot request opened the batch; it never did.

Why behavioral, not byte-identity: the batched model forward on this GPU is floating-point non-deterministic across batch composition — two identical greedy requests, co-batched, share a long common prefix then diverge (reduction order depends on batch position). That is a pre-existing model property on the untouched greedy path, unrelated to sampling. So byte-exact per-row isolation is proven by the deterministic unit test test_positioned_target_sampler_is_batch_grouping_invariant (identical logprobs in → identical token out); the live test proves param adherence, which is robust to that FP noise.

Scope

  • This PR: plain AR + MTP — full per-row. MTP rides the same BatchGenerator; its walks call sample_target on the full batch, so per-row composes (one gate keeps positioned-sampler MTP batches at full width, since _mtp_rounds_batch otherwise compacts mid-round).
  • dflash/eagle3 (_run_speculative): interim explicit degradation, full per-row in a follow-up PR. A heterogeneous batch is serialized-by-config (never the silent last-wins), with a TODO(PR2) marker. Full per-row there is tractable (no accept-math change — verification is exact-match-to-target-sample, not q/p rejection) but has its own sampler_is_greedy surface + test matrix, so it's a separate reviewable PR.

An open question for you

The plumbing (per-row config through insert/extend/filter) is the same whether the sampler applies params vectorized (this PR) or as mlx-lm's per-row loop-of-closures. I went vectorized because it preserves the sample_target grouping-invariance that mlx-vlm's positioned sampler adds over mlx-lm's global-RNG closures (and speculative/mtp.py duck-types on sample_target). Measured ~1.6–3.1× faster than the loop at B=4–16, though that's a couple percent of a decode step at small B — happy to switch to the loop shape if you'd prefer parity with mlx-lm; it drops into the same seam.

Known limitations (follow-ups)

  • The model-dependent top_k >= vocab_size check raises HTTPException(422) but some pre-existing broad except Exception handlers in openai.py/anthropic.py downgrade it to 400/500. No crash either way (the sampler is robust to out-of-domain params); it's a status-code nicety I left rather than reworking unrelated error handling.
  • _run_speculative's serialize-by-config can defer a lone off-config request under sustained majority-config load (interim path; resolved by the PR-2 per-row work).

Credits

Restores the per-row samplers pattern from mlx-lm. Vectorized-sampler shape (rank-mask top_k, fused greedy, per-row [B] params) follows vLLM, SGLang, and TGI. The vendored-in-tree sample_utils/BatchKVCache this builds on came from @lucasnewman's #1494.

@Lazarus-931
Lazarus-931 requested a review from lucasnewman July 21, 2026 20:28
@Lazarus-931

Copy link
Copy Markdown
Collaborator

hey @mmmugh , the CI fails b/c of pre-commit, run pre-commit run --all-files and you should be good to go

Comment thread mlx_vlm/server/generation.py Outdated
raise ValueError(
"thinking_budget is not supported with speculative decoding in the server."
)
vocab_size = self._model_vocab_size()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just a drive-by fix? I don't object to it, but it seems only tangentially related.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right—removed it. It was tangential, and batched_row_sample already degrades an
out-of-range top_k safely (it just acts as "disabled"), so it wasn't guarding against a crash
anyway. Kept the top_k/min_p/temperature Pydantic field constraints, since those validate the
new per-row params directly. (Pushed in the latest commit.)

Comment thread mlx_vlm/speculative/mtp.py Outdated
# finished rows until the whole batch drains; correctness > that.
cache_filterable = all(
hasattr(c, "filter") for c in prompt_cache
) and not _sampler_supports_positioned_target(sampler)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a non-trivial change from a performance perspective, since we won't be dropping finished sequences. Can you benchmark batched inference before and after?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call—I reworked it so we do keep dropping finished sequences. Instead of disabling
mid-round compaction for positioned samplers, the sampler now compacts its per-row configs in
lockstep with the batch via _PositionedTargetSampler.select(keep_slots), so active_idx shrinks
as before and the configs stay aligned to the shrunk logprobs. The regression test now asserts
compaction happens (cache.filter_calls == [[1]]) rather than being suppressed, and reverting the
one sampler.select(...) line reproduces the width-mismatch it guards against.

Benchmarked it end-to-end. I split Qwen/Qwen3.5-4B into a native qwen3_5_mtp drafter
(python -m mlx_vlm.speculative.drafters.qwen3_5_mtp.split) and served with
--draft-kind mtp --draft-block-size 4 on an M-series Mac Studio. Workload: 8 concurrent chat
requests with max_tokens spread 48→256 so rows finish at staggered rounds and mid-round compaction
fires repeatedly, temperature=0.7 so the batch runs the vectorized per-row sampler. I instrumented
_mtp_rounds_batch to confirm the change is actually exercised—it runs at B=8 with the positioned
sampler, and compaction fires on every drop (n_active 8→7→…→1, select() each time). 3-trial
median aggregate decode throughput (per-run variance <1%):

shared sampling config distinct per-row config
main (c9e27b0) 135.6 tok/s 134.6 tok/s
this branch 135.2 tok/s 119.1 tok/s

So the compaction rework itself is free: with a shared config the branch matches main (135.2 vs
135.6, within noise) while dropping finished rows exactly like main (verified firing 8→1).

Being upfront about the other cell—when every row carries a different sampling config, per-row
MTP decode runs ~12% slower than the uniform batch (119 vs 135). It's the same _mtp_rounds_batch
path (not the serialize_speculative_batch_by_config gate, which is dflash/eagle3-only): the cost of
actually honoring distinct seeds/params across a speculative batch, which main doesn't do at all.
It only appears when configs genuinely differ; shared-config batches are unaffected. I can chase that
down in the per-row-speculative follow-up if it's worth it. (Batched AR per-row overhead, for
reference, was ~1%: branch 272 vs main 275 tok/s.)

@lucasnewman

Copy link
Copy Markdown
Collaborator

@mmmugh Thanks, see the comments for some questions. Please make sure to run pre-commit run --all to clear the formatter so we can run tests.

@mmmugh

mmmugh commented Jul 22, 2026

Copy link
Copy Markdown
Author

Thanks @Lazarus-931—good catch. Ran pre-commit run --all (black/isort/autoflake) and pushed the formatting fix in 21d3002; it's on the current head. 🙏

@lucasnewman

Copy link
Copy Markdown
Collaborator

@mmmugh Can you sign your commits and force push so we can clear the merge requirements?

mmmugh and others added 8 commits July 24, 2026 00:08
…er; derive greedy per-row

Threads List[SamplingConfig] through the GenerationBatch lifecycle
(__init__/empty/extend/filter), PromptProcessingBatch, and
BatchGenerator.insert, mirroring the existing logits_processors
per-row handling exactly. GenerationBatch._step now builds a fresh
_PositionedTargetSampler(self.sampling) with real row_ids instead of
a shared sampler + row_ids=[0]*B. greedy_sampling is now derived per
physical batch from the actual mix of sampling_configs
(all(c.temperature == 0 ...)) instead of a stale generator-level
default. Also drops the dead _uniform line from
_PositionedTargetSampler.__init__ (computed but never read; would
have been a per-step cost once _step() started constructing this
sampler every decode step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9yV25mKtcznxvu3wX1sj
SpeculativeGenerationBatch now builds its sampler from a per-row
List[SamplingConfig] (self.sampling, aligned to _all_uids) via a new
_init_sampling helper, instead of taking a prebuilt shared sampler +
greedy_sampling flag. The MTP deferred-greedy walk already samples via
sample_target(row_ids=..., positions=...) on the full batch every
position, so this makes MTP decoding per-row with no walk changes.

PromptProcessingBatch.generate()'s SpeculativeGenerationBatch(...) call
now passes sampling=list(self.sampling) - the same per-row config list
its GenerationBatch sibling branch already forwards.

Also fixes test_generate.py's direct SpeculativeGenerationBatch
construction, which used the now-removed sampler= kwarg (that test
monkeypatches run_speculative_server_rounds entirely, so the sampler
value was never exercised).
…on; explicit speculative degradation

Closes the crash where _make_sampler passed old scalar kwargs to the
now-per-row _PositionedTargetSampler ctor, so any server request with
temperature != 0 raised a TypeError. _run()'s admission now passes
sampling_configs=[_config_from_args(args)] to batch_gen.insert(); the
BatchGenerator-level sampler= is dropped (it's inert on the AR path,
which samples per-row from self.sampling since Task 3) rather than
rebuilt, keeping the all-greedy path byte-identical.

Pydantic ge/le constraints on top_p/top_k/min_p (VLMRequest,
OpenAIRequest) turn out-of-domain values into a 422 at request
validation, before anything reaches the GPU thread. Also adds a
best-effort top_k-vs-model-vocab_size check in
ResponseGenerator.generate() (documented caveats on exact HTTP status
in the task report, since some endpoints' exception handling
downgrades HTTPException before it reaches the client).

_run_speculative (dflash/eagle3) previously overwrote a single shared
`sampler` once per request in a loop -- the last request's params
silently governed sampling for every row, including other requests'.
Replaced with _serialize_speculative_batch_by_config: co-batch only
requests sharing one SamplingConfig, requeue the rest for the next
round. Full per-row dflash/eagle3 sampling is TODO(PR2); this never
silently blends configs in the meantime.

Renegotiated test_idle_batch_generator_is_recreated_for_new_sampler
(encoded the rejected one-sampler-per-batch design) into
test_run_co_batches_mixed_temperature_requests_into_one_batch_generator,
which asserts two differently-tempered requests admitted together
share one BatchGenerator and carry distinct per-row configs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9yV25mKtcznxvu3wX1sj
…enable, validation parity, formatting)

- Revert _step()/PromptProcessingBatch.generate() row_ids to constant [0]*B
  so a seeded row's RNG stream is invariant to filter()-induced batch
  composition changes (matches the existing MTP path); per-row seed still
  differentiates rows. Add a real-GenerationBatch regression test proving
  batch-composition invariance, verified to fail under the old positional
  keying.
- Recompute GenerationBatch.greedy_sampling after filter() so a batch that
  drops its last temperature>0 row regains the fused greedy fast path.
- Add ge=0 to AnthropicRequest.top_k and ge=0.0 to VLMRequest/OpenAIRequest
  temperature, for parity with existing request-model constraints.
- Drop the now-dead greedy_sampling kwarg from the BatchGenerator(...) call
  in server/generation.py (sampler=None always wins in that constructor).
- black-format the branch's own new/changed code in ar.py, generation.py,
  and test_row_sampling.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9yV25mKtcznxvu3wX1sj
… in lockstep

- Remove the model-dependent top_k>=vocab_size 422 check (tangential to
  row-level sampling; batched_row_sample degrades out-of-domain safely). Keep
  the top_k/min_p/temperature Pydantic field constraints.
- MTP: instead of disabling mid-round cache compaction for positioned samplers,
  compact the sampler's per-row configs in lockstep via
  _PositionedTargetSampler.select(keep_slots) — finished sequences are dropped
  again (no throughput regression) while the configs stay aligned to the shrunk
  logprobs. Regression test now asserts compaction happens + no width crash.
@mmmugh
mmmugh force-pushed the fix/row-level-sampling branch from b61d180 to a343cd6 Compare July 24, 2026 04:19
@mmmugh

mmmugh commented Jul 24, 2026

Copy link
Copy Markdown
Author

Done—signed all 8 commits and force-pushed. The branch head is now a343cd6 and GitHub shows every commit as Verified, so that should clear the signed-commits requirement. 🙏

@mmmugh

mmmugh commented Jul 24, 2026

Copy link
Copy Markdown
Author

@lucasnewman before I finish the merge, I'd like your steer on one integration point, since main has moved a lot in the sampler area since I opened this (the top-nσ / p-less / typical-p modes, plus _PositionedTargetSampler itself).

This PR generalizes _PositionedTargetSampler to carry a per-row SamplingConfig, which meant replacing the batch generator's per-request sampler= slot with per-row sampling_configs threaded through insert/extend/filter. Your new modes route through that same sampler= slot (_make_sampler(args)) and aren't expressible in the current SamplingConfig (temperature/top_p/top_k/min_p/seed)—so they collide in server/generation.py. (The other two conflicts are trivial: schema field constraints and a co-located test.)

How would you like the two to coexist?

Extend SamplingConfig + the per-row sampler to honor top_n_sigma/p_less/typical_p per-row (architecturally consistent, a bit more surface).
Keep the new modes on a batch-level sampler path alongside per-row (applied batch-global, not per-row).
Happy to implement whichever fits where you're taking this.

@lucasnewman

Copy link
Copy Markdown
Collaborator

@lucasnewman before I finish the merge, I'd like your steer on one integration point, since main has moved a lot in the sampler area since I opened this (the top-nσ / p-less / typical-p modes, plus _PositionedTargetSampler itself).

This PR generalizes _PositionedTargetSampler to carry a per-row SamplingConfig, which meant replacing the batch generator's per-request sampler= slot with per-row sampling_configs threaded through insert/extend/filter. Your new modes route through that same sampler= slot (_make_sampler(args)) and aren't expressible in the current SamplingConfig (temperature/top_p/top_k/min_p/seed)—so they collide in server/generation.py. (The other two conflicts are trivial: schema field constraints and a co-located test.)

How would you like the two to coexist?

Extend SamplingConfig + the per-row sampler to honor top_n_sigma/p_less/typical_p per-row (architecturally consistent, a bit more surface). Keep the new modes on a batch-level sampler path alongside per-row (applied batch-global, not per-row). Happy to implement whichever fits where you're taking this.

@Lazarus-931 Can you take a look?

@Lazarus-931

Copy link
Copy Markdown
Collaborator

hi @mmmugh, yes great point, I think we'll merge this first with extended SamplingConfig, we'll edit those prs need be. Feel free to make changes here however for that!

mmmugh added 2 commits July 24, 2026 15:04
…r row

main advanced 48 commits during review and grew three new sampling modes
(top_n_sigma/p_less/typical_p) in the code this branch rewrites. Per
@Lazarus-931's steer, extend SamplingConfig to carry them and honor them
per row rather than keeping a batch-level sampler path.

Conflicts: keep this branch's schema field constraints alongside main's new
fields; keep both co-located speculative tests; drop main's _make_sampler,
which has no callers left once admission builds per-row configs.

Fixes found auditing the merge before pushing:
- _apc_pick_for unpacked a 6-tuple while insert() now appends a 7th element
  (the SamplingConfig), so every APC-enabled prefill would raise ValueError.
  Both sides merged cleanly on their own; the conflict was semantic.
- top-nσ computed its statistics in the input dtype; mx.std overflows float16
  for any real vocab, silently disabling the mode. Compute them in float32,
  as upstream's _top_n_sigma does.
- Phrase the top-nσ test as "drop if x < threshold" like upstream so a NaN
  threshold (from -inf logprobs) keeps tokens instead of emptying the row.
- Chain the new modes before the rank filters, matching make_sampler's order,
  instead of ANDing masks built from the full distribution: typical_p could
  reject an entire top_k window and collapse the row to greedy.
- Skip the extra masks entirely when no row enables a mode.
- Gate typical_p on 0 < typical_p < 1 and coerce p_less to bool.

Sampling with no mode active is unchanged and still matches apply_top_p and
apply_min_p exactly. Combining a mode with top_p renormalizes over the
survivors instead of reproducing make_sampler's chain, which can reject every
token once a mode has removed mass.
Picks up the pre-commit black bump to 26.3.1 so CI's formatter check runs the
same version used here; the tree is already clean under it.
@mmmugh

mmmugh commented Jul 24, 2026

Copy link
Copy Markdown
Author

Merged current main; the branch is no longer conflicting. Per @Lazarus-931's call above, SamplingConfig now carries top_n_sigma / p_less / typical_p, honored per row: _new_modes_keep() builds the per-row keep mask with each mode gated per row, so a row that doesn't use one is untouched. One open question for you at the end.

Conflicts: schemas.py keeps this PR's field constraints plus main's three new fields; both adjacent test_speculative.py tests survive; _make_sampler is deleted (per-row admission leaves it with no callers, and make_sampler is no longer imported, so keeping it would NameError). A second merge picks up Mage-Flow and the black 26.3.1 bump, so the formatter check here runs the same version CI will.

Two bugs a pre-push audit turned up, both in main's own features, fixed and regression-tested:

  • APC. _apc_pick_for destructures the queued-sequence tuple as 6 elements; insert() now appends a 7th (the config). Separate hunks, so git merged both sides cleanly—a semantic-only conflict that would have raised ValueError on every APC-enabled prefill. The new test feeds real insert() output to the real consumer so the two can't drift; one upstream test that hand-built a 6-tuple is updated to match.
  • float16. top_n_sigma took max/std in the raw dtype, and mx.std over an fp16 logprob vector overflows to inf at any real vocab size, silently no-opping the mode. Upstream's _top_n_sigma casts to fp32 unconditionally and the deleted _make_sampler routed through it. Now fp32: 0 mismatches vs apply_top_n_sigma at V=8192/32000 in fp16, bf16 and fp32.

Four smaller ones in the new code: upstream's "drop if x < t" phrasing, so a single -inf logprob (grammar masking, logit_bias) can't NaN the threshold and pin a row to argmax; modes chained ahead of the rank filters per make_sampler's order, or typical_p can empty a Qwen-default top_k=20 window and collapse the row to greedy; the extra mask work skipped unless a row opts in; typical_p range-gated, and a non-bool p_less coerced rather than raising in the GPU thread and tearing down the batch.

Your call. Combined with top_p I renormalize over the survivors instead of reproducing make_sampler's chain. apply_top_p compares cumulative mass against 1 - top_p on a distribution that no longer sums to 1 once a mode has masked it, so it can reject every token: top_n_sigma=1.0 with top_p=0.9 leaves a row of all -inf upstream that then gets sampled from. Looks like a genuine upstream bug worth its own look, but happy to match upstream exactly here instead. With no mode active, behaviour is unchanged and still matches apply_top_p / apply_min_p exactly.

Masks are checked token-for-token against sample_utils.apply_*, plus per-row activation, grouping invariance, fp16 parity, NaN tolerance, typical_p+top_k and server wiring; every fix is mutation-tested (8/8 fail when reverted). 1826 passed, 4 skipped, plus one unrelated pre-existing failure (test_qwen_target_verify_quantized_linear_matches_singleton_batch_path, an exact bf16 quantized-matmul equality check) that fails identically on pristine origin/main in a clean worktree, so not caused by this PR. pre-commit clean under 26.3.1.

@lucasnewman your approve predates the merge and these fixes. The composition order is the part worth re-reading.

Only conflict was test_generate.py, where main's expanded cache imports and
new prefix-cache-reuse tests landed beside this branch's SamplingConfig import
and APC tuple-arity test; both sides kept.

main's changes to the files this branch touches are unrelated to sampling (an
_apc_media_token_ids guard for text-only models, a should_add_special_tokens
helper, and a 500 -> 400 status on model-load failure).
@mmmugh

mmmugh commented Jul 31, 2026

Copy link
Copy Markdown
Author

Rebased onto current main—back to mergeable, commits signed, suite green.

Whenever one of you has a moment: CI has never run on this branch (fork PRs need a maintainer to approve the workflow), so it's sitting at "blocked" on a check that hasn't been triggered rather than on anything failing. A run would tell us if it's actually good on your infra.

Still open from the last update, whenever you get to it:

  • top_p combined with one of the new modes: I renormalize over the survivors rather than reproducing make_sampler's chain, because that chain can reject every token once a mode has masked mass away (top_n_sigma=1.0 with top_p=0.9 leaves an all -inf row). Happy to match upstream exactly instead if you'd rather keep them identical.
  • @lucasnewman your approve predates the merge and the fixes that came with it, so it'll need a fresh look.

No rush—just flagging that it's ready and only needs a CI trigger to move.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants