Conversation
|
Pushed This is the same |
|
While hardening the batched-sampler path I hit a second lane-alignment issue in the same family as the
Gating on truthiness of the list itself re-indexes whenever per-lane info exists while still leaving a genuinely empty - if any(self.samplers):
+ # Re-index whenever a per-lane list exists (len == old uids), even if
+ # every entry is falsy (all-None samplers / all-[] processors). any()
+ # skips the all-falsy case, leaving the list longer than uids so a later
+ # extend() binds lanes to the wrong request (silent greedy/wrong-sampler).
+ if self.samplers:
self.samplers = [self.samplers[idx] for idx in keep]
- if any(self.logits_processors):
+ if self.logits_processors:
self.logits_processors = [self.logits_processors[idx] for idx in keep]Plus the sibling aliasing bug in - self.logits_processors = [[]] * len(keep)
+ self.logits_processors = [[] for _ in keep]Repro: build a #1513 already covers the |
|
Pushed the two |
|
Revalidated 2026-07-22 on the current tree: grouped/memoized vs per-row 1.05 / 1.07 / 1.07 / 1.10 / 1.12x at B=2/4/8/16/32 (Qwen3-0.6B-4bit, 256-token prompts, 128 generated, 4-rep medians) — consistent with the original table; the state-budget admission layer downstream of this was also re-exercised under a deliberately tight budget (flood 8 -> 4 active + 4 queued, clean second-wave drain, no over-admission). |
GenerationBatch._step groups rows by sampler identity and runs each unique sampler once over its rows instead of once per row. The server memoizes _make_sampler on the sampling parameters so concurrent requests with identical settings share one sampler object and hit the grouped path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A sampler shared across rows that ignores its input width (e.g. a constant closure) would silently mis-broadcast under grouping. Check the returned row count and fall back to per-row calls for that group. Tests: shared make_sampler object matches the fallback path bit-exact under a fixed seed; a shared constant sampler keeps per-row semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grouping by object identity broke two contracts (found in adversarial review): a stateful callable that happens to return one token per row was called once per group instead of once per row, and XTC's scalar random gate became shared across grouped rows, correlating requests. make_sampler outputs now carry batch_groupable (False when XTC is active, whose gate is not row-independent); the engine groups only marked samplers and keeps the original one-call-per-row contract for everything else. The server sampler cache now keys on token-id values rather than recyclable id(tokenizer). Regression tests for both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Measured: Apple M5 Max (128 GB), mlx 0.32.0.dev; frozen trees A=a790972 (pristine) / B=a51c2b4 (integrated); bench harness driving BatchGenerator directly; Qwen3-0.6B rows are counterbalanced 4-rep medians, Qwen3-Coder-30B rows 3-rep; generation t/s from the engine's BatchStats (prompt time excluded by the scheduler itself). Within-process sampler-mode order counterbalanced; tree order fixed A-then-B (cross-tree ratios corroborated by tree-order-immune within-tree ratios). Control (pristine tree): a sampler object shared across rows gains nothing without grouping — memoized/per-row 1.001x (B=8), 0.997x (B=32), Qwen3-0.6B-4bit at 128-token prompts. With this change, same memoized workload (Qwen3-0.6B-4bit): - 128-token prompts: 1746 -> 1893 t/s (1.084x) at B=8; 2156 -> 2475 t/s (1.148x) at B=32. - 4096-token prompts: 1.038x / 1.039x (B=8/32) — the saved per-step dispatch is fixed, so the relative win dilutes as attention time grows with context. - Qwen3-Coder-30B-A3B-8bit: within-tree grouping ratios 0.993-1.021x; cross-tree comparison inconclusive (session variance exceeded the effect size; tree order not interleaved). No 30B gain is claimed. Arm B is the integrated F1+F2+F3 tree; F2/F3 opt-in controls disabled for this workload (window=1, no budget); attribution rests on the sampler-mode controls and within-tree ratios. Sampling params identical across rows (temp 0.7 / top_p 0.95). XTC and arbitrary custom samplers keep the one-call-per-row contract and are not accelerated (batch_groupable opt-in).
PromptProcessingBatch.extend planted None placeholders for requests without logits processors, while filter() uses []. A mixed batch [None, [proc]] then crashes _step (for processor in logits_processors[e] => TypeError: NoneType is not iterable). Same invariant violation upstream PR ml-explore#1513 fixes. Use [] placeholders (list-comprehension form to avoid shared-reference aliasing). Sampler placeholders correctly stay None (they fall back to fallback_sampler, never iterated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… filter GenerationBatch.filter gated the re-index on any(self.samplers) / any(self.logits_processors). When a per-lane list is non-empty but all entries are falsy (all-None samplers / all-[] processors), any() is False so the list is not re-indexed and stays longer than uids; the next extend() then binds lanes to the wrong request -> silent wrong-sampler/greedy output, no error. Gate on the list itself so it re-indexes whenever per-lane info exists (an empty [] is still left untouched). Also switch PromptProcessingBatch.filter's else-branch from [[]] * n (aliases one list object) to independent per-lane lists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upstream ml-explore#1176 computes xtc_special_tokens from tokenizer.eos_token_ids (plural); the memoization key still read the singular attribute, which the TokenizerWrapper contract does not guarantee (and the new TestMakeSampler fake exercises exactly that). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
94c841e to
4711407
Compare
|
Rebased onto current The rebase picks up #1176/#1575/#1372 (XTC special tokens, per-row thresholds, the 0.1 default), which this PR's sampler memoization now composes with: Re-verified locally on 🤖 Generated with Claude Code |
Vectorize batched sampling for rows that share a sampler
Problem
GenerationBatch._stephas two sampling paths: a vectorized one thatruns the sampler once over the whole
[batch, vocab]logprob tensor,and a per-row fallback that loops in Python, calling the sampler
Bseparate times on
[1, vocab]slices and concatenating the results.The vectorized path only triggers when no row carries a custom
sampler — but the server attaches a freshly-constructed sampler closure
to every request (
server.pybuilds one per request via_make_sampler). So in real server operation the vectorized path isunreachable: every decode step, for every generated token, pays
BPython-level sampler dispatches plus a concatenate. At
--decode-concurrency 32that is 32 dispatches per token, all doingidentical math for the overwhelmingly common case of requests that
share sampling settings.
Change
Two small pieces that compose:
A
batch_groupablecontract on samplers.make_sampler'soutputs now carry an attribute declaring themselves row-independent:
applying them to
kstacked rows at once is semantically identicalto
kseparate calls. Temperature scaling, top-p, min-p, and top-kall have this property (per-row transforms over the vocab axis).
XTC does not — it draws one scalar random gate, which would
become correlated across requests if grouped — so XTC-bearing
samplers are never marked. Arbitrary third-party callables are also
unmarked and keep the exact historical one-call-per-row contract
(a stateful callable, or one drawing shared randomness, cannot be
silently regrouped); the feature is invisible to them.
Identity grouping in the decode step.
_stepgroups rows bysampler object; each marked group runs one vectorized call over
its rows' logprobs. To make groups actually form in the server,
_make_sampleris memoized on the sampling parameters plus theactual special-token id values (small clear-on-overflow cache), so
concurrent requests with identical settings share one sampler
object and collapse into a single
[B, vocab]call. Mixed-configbatches degrade gracefully to one call per unique config — never
worse than the previous per-row loop.
No public API changes. Samplers produced by
make_sampleralreadyvectorize over a leading batch axis (the fallback path has always
required this of the default sampler).
Correctness
test_batch_shared_sampler_matches_fallback: rows sharing onemake_samplerobject produce bit-identical tokens to afallback-path (
sampler=) run under a fixed seed — the groupedsingle-group call consumes RNG identically to the fallback path.
test_batch_shared_stateful_sampler_keeps_per_row_calls: an unmarkedstateful callable is still called exactly once per row, with per-row
widths asserted.
test_xtc_sampler_not_batch_groupable: XTC samplers are excludedfrom grouping (their scalar random gate is not row-independent).
test_batch_shared_nonvectorizing_sampler: a shared constant closurekeeps per-row semantics.
unchanged. (
test_batch_matches_single,test_batch_sliding_window,test_many_batches, and thetest_batch_continued_generation*triofail identically on pristine
mainin my environment — pre-existingnumeric baselines, unrelated to this change.)
Measured
Apple M5 Max (128 GB), mlx 0.32.0.dev; a harness driving
BatchGeneratordirectly; counterbalanced 4-rep medians; generationt/s from the engine's own
BatchStats(prompt time excluded by thescheduler itself, not by wall-clock subtraction). "per-row" = distinct
sampler objects per request (current server behavior); "grouped" = one
shared object (server behavior with the memoization).
Baseline control (pristine
main): a sampler object shared across rowsgains nothing without the grouping engine — memoized/per-row =
1.001x (B=8), 0.997x (B=32) — confirming any delta below is the engine
change alone.
Qwen3-0.6B-4bit (150k vocab), 128-token prompts, 64 generated:
The grouped path matches the vectorized fallback path within noise at
every batch size — it recovers all recoverable overhead. The saved cost
is a roughly fixed 1-2 ms of Python dispatch + concatenate per decode
step, so the relative win shrinks as per-step forward time grows:
dilution is mechanical (attention time grows with context; the saved
dispatch does not).
below this session's measurement floor. No 30B gain is claimed;
the win is concentrated where batching is used most aggressively
(small/mid models at high concurrency), which is stated as the
honest scope.
It is a strict improvement in all measured configurations: the grouped
path never does more work than the per-row loop.
Notes / possible follow-ups
A batched sampler taking per-row parameter arrays (
temp: [B], ...)would collapse those too; left out to keep this change small — happy
to explore if there's interest. The same follow-up is the natural
place to thread per-row RNG keys, which would let seeded requests
(currently excluded from batching by
args.seed) batchdeterministically.
🤖 Generated with Claude Code