fix(server): row-level per-request sampling in the continuous batch (supersedes #1353) - #1647
fix(server): row-level per-request sampling in the continuous batch (supersedes #1353)#1647mmmugh wants to merge 11 commits into
Conversation
|
hey @mmmugh , the CI fails b/c of pre-commit, run |
| raise ValueError( | ||
| "thinking_budget is not supported with speculative decoding in the server." | ||
| ) | ||
| vocab_size = self._model_vocab_size() |
There was a problem hiding this comment.
Is this just a drive-by fix? I don't object to it, but it seems only tangentially related.
There was a problem hiding this comment.
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.)
| # 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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.)
|
@mmmugh Thanks, see the comments for some questions. Please make sure to run |
|
Thanks @Lazarus-931—good catch. Ran |
…s; unify the two copies
…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.
b61d180 to
a343cd6
Compare
|
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. 🙏 |
|
@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). |
@Lazarus-931 Can you take a look? |
|
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! |
…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.
|
Merged current main; the branch is no longer conflicting. Per @Lazarus-931's call above, Conflicts: Two bugs a pre-push audit turned up, both in main's own features, fixed and regression-tested:
Four smaller ones in the new code: upstream's "drop if x < t" phrasing, so a single Your call. Combined with Masks are checked token-for-token against @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).
|
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:
No rush—just flagging that it's ready and only needs a CI trigger to move. |
Supersedes #1353. That PR added
top_k/min_pto 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 owntemperature / top_p / top_k / min_p / seed.The framing
mlx-vlm's
GenerationBatchwas forked from mlx-lm's, and mlx-lm already carries per-row samplers (self.samplers: List[Callable], threaded throughextend/filter). The fork collapsed that list into one shared sampler built from the first request — which is the bug. Onmaintoday,GenerationBatch.extend()/filter()never touchself.sampler, so a merging batch's sampler is dropped ("request #1 wins");_make_samplerdropstop_k/min_p; andgreedy_samplingis a batch-wide bool from request #1. This PR restores per-row sampling.Approach
SamplingConfig(temperature, top_p, top_k, min_p, seed)carried as a per-rowList[SamplingConfig]throughinsert → extend → filter, mirroring the existinglogits_processorslist exactly._PositionedTargetSampler(configs)that builds[B]mx.arrays and applies temperature → top_k → top_p → min_p in one sorted-space pass, with fused greedywhere(temp < eps, argmax, sampled).top_kis a rank-mask (exactly-k), becauseapply_top_k'sargpartition(kth=int)can't take a per-rowk. The vendoredsample_utils.pyis untouched._PositionedTargetSamplercopies (which had drifted on seed handling) are unified._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.top_k/min_pare 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 distinctrow_idsand distincttemperature/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 onmaintoday. 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.top_k=1 @ temp=2.0tracks greedy (top_k binds)temp=2.0diverges from greedy (incoherent)temp=1.3, top_k=60, min_p=0.02genuinely samplestop_k=1row stays greedy-coherent every time[120,120,120,120,120]temp=2.0row stays incoherent every time[17,17,17,17,17]The discriminator (the bar #1353 could not pass): a
top_k=1(greedy) request and an unconstrainedtemperature=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
BatchGenerator; its walks callsample_targeton the full batch, so per-row composes (one gate keeps positioned-sampler MTP batches at full width, since_mtp_rounds_batchotherwise compacts mid-round)._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 aTODO(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 ownsampler_is_greedysurface + 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 thesample_targetgrouping-invariance that mlx-vlm's positioned sampler adds over mlx-lm's global-RNG closures (andspeculative/mtp.pyduck-types onsample_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)
top_k >= vocab_sizecheck raisesHTTPException(422)but some pre-existing broadexcept Exceptionhandlers inopenai.py/anthropic.pydowngrade 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
samplerspattern from mlx-lm. Vectorized-sampler shape (rank-mask top_k, fused greedy, per-row[B]params) follows vLLM, SGLang, and TGI. The vendored-in-treesample_utils/BatchKVCachethis builds on came from @lucasnewman's #1494.