Repository navigation
feat(speculative): opt-in acceptance-optimal rejection sampling, and a premise correction - #992
Merged
Merged
Conversation
…ture > 0 The speculative verify loop accepted a drafted token only when an independent draw from the target sampler landed on the same token. That rule is lossless but its acceptance rate is `sum_x p(x) q(x)`, far below the `sum_x min(p(x), q(x))` optimum available for the same draft/target pair, so high-probability drafts that simply were not re-drawn got thrown away. Replace it at `temperature > 0` with modified rejection sampling (Leviathan et al. 2023 Algorithm 1; Chen et al. 2023): accept the drafted token `t` iff `u * q(t) <= p(t)` for a fresh `u ~ U[0,1)`, and on the first rejection emit one token drawn from the normalized residual `relu(p - q)` and end the chain. The emitted stream is distributed exactly as target-only sampling because `min(q, p) + relu(p - q) == p` pointwise, and that identity holds for any proposal `q`, which is what keeps greedy drafters and stale-history penalties safe. `fused_sample_probs` in the C++ bridge returns the categorical distribution `fused_sample` draws from, reusing the sampler's own filter chain (extracted as `fused_sample_filter_logits`) rather than reimplementing it, so the accept test's `p` and `q` cannot drift from what the sampler actually does. A greedy configuration returns the one-hot argmax indicator, which is the correct degenerate `q` for a greedily-proposing drafter. The softmax runs in float32 so a small acceptance ratio cannot underflow into an unconditional accept. `sampling::sample_token_with_distribution` returns a token together with the distribution it was drawn from in one pre-step, which is what guarantees `q` is the proposal distribution rather than a later reconstruction. Temperature 0 is untouched: `acceptance_rule` returns `Argmax` whenever the target sampler is greedy (`temperature == 0` or `top_k == 1`), and the caller runs the pre-existing comparison verbatim with no distribution tensors built and no randomness drawn. `MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT=0` restores argmax acceptance at every temperature. Every distinct rule and per-token outcome kind is logged once per process at info level, so a server log proves which rule a run used. Validated by 17 unit tests: the accept rate matches `min(1, p/q)` to 4 binomial standard errors over 4000 trials, the residual draw passes a chi-square against `normalize(relu(p - q))` at alpha=1e-4, tokens outside the target's filtered support are rejected with zero tolerance over 400 trials, and the measured accept rate reaches the `sum min(p, q)` optimum and beats the old `sum p*q` rate. Refs #902
Adds the regression guard the acceptance change needs to be believable. The test models make the null hypothesis checkable in closed form: the target's logits are independent of its input, so target-only sampling emits i.i.d. draws from one known categorical and a Pearson chi-square against it is a direct test of "the speculative stream is distributed as target-only sampling". H0: the temperature-0.7 speculative stream is distributed as target-only sampling at 0.7. Statistic: chi-square over a 6-entry vocabulary, dof 5. Sample count: 12000 emitted tokens by default, overridable with MLXCEL_SPEC_DIST_SAMPLES. Threshold: 25.7448, the alpha=1e-4 upper-tail quantile, so a correct implementation fails one run in ten thousand. A goodness-of-fit test proves nothing without demonstrated power, so the power is measured in-tree rather than asserted. `chi_square_rejects_known_wrong_acceptance_rules` replays the same statistic against two deliberately wrong rules and requires rejection: drawing the replacement from `p` instead of from the residual (measured 128.6 at n=4000, five times the threshold) and comparing against the target's argmax instead of its sampler (measured 1825.1). The correct rule scores 5.1 on the same run. Chi-square is blind to a single wrong token, so two zero-tolerance assertions sit beside it. `filtered_target_support_is_never_violated` runs a top-k=2 target against an unfiltered drafter and fails on the first emitted token outside the target's two-token support, which is the exact failure a missing `p(t) > 0` conjunct produces. `temperature_zero_stream_is_byte_identical_to_greedy_target_only` walks a deterministic 6-cycle target against a drafter that agrees on one token in six, and fails on one differing token. Cache rewind is pinned in both deterministic termination regimes: a never-agreeing drafter, where every round rejects at position 0 and the last emitted token stays pending, and a drafter that walks the target's own orbit, where the run stops inside the accept branch and the last token is already forwarded. A third test asserts every cache layer agrees with layer 0 across randomized mixed accept/reject rounds, since a per-layer trim mismatch corrupts attention with no other symptom. Refs #902
…accounting `SpeculativeGenerator` now counts rounds, proposed draft tokens and accepted draft tokens per `generate` call, exposes them through `acceptance_stats()`, and logs a summary line at info level that names the rule the run used. Without that line an acceptance-rate A/B has no way to prove which arm it measured, which is how a benchmark ends up comparing the fallback against itself. `docs/speculative-acceptance.md` is new. It states the three acceptance rules and what each guarantees, gives the losslessness proof for modified rejection sampling and the reason it holds for any proposal distribution, and carries a table of which rule each code path actually runs today. That table records a finding the issue text did not have: the classic `SpeculativeGenerator` compared against a fresh draw from the target *sampler*, not its argmax, so it was already distribution-preserving and what it lost was acceptance rate. The argmax-against-argmax bias the issue describes is real on the Gemma 4 MTP and DFlash round loops, which select the target token with `argmax_per_position` / `argmax_logits_to_array` regardless of temperature and whose drafters return token ids without the distribution they proposed from. The doc also spells out the RNG dependency (the accept test and the residual draw consume MLX's key sequence, so at an equal seed the emitted tokens differ from a pre-change run even though the distribution is identical), how to read the active rule off a log, and the exact `mlxcel generate --draft-model` A/B commands. It says explicitly not to measure this change with `speculative_bench`, whose pairings are all MTP or DFlash and therefore unchanged. `MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT` is documented in the environment-variable table, and the new doc is linked from `README.md`, `docs/README.md` and `docs/architecture.md`. Two tests cover the accounting: it is exact in both deterministic regimes (a never-agreeing drafter accepts nothing, a drafter walking the target's own orbit accepts everything), and a stochastic run must both report `rule=Stochastic` and reach the mean accepted draft length predicted by `sum_x min(p, q)` while clearing twice the pre-change prediction. Refs #902
…he CLI The `mlxcel` CLI installs no `tracing` subscriber, so every info-level line added for issue #902 was discarded on the only binary that runs `SpeculativeGenerator`. The instrumentation was correct and the transport was missing, which left an acceptance-rate A/B unable to state which rule it measured. Three fixes, plus one metric defect the new visibility exposed. `SpeculativeAcceptanceStats::summary_line` is now printed on stdout unconditionally after every speculative run, carrying the acceptance rule that actually ran and the mean accepted draft length. `AcceptanceRule::id` gives each rule a stable, whitespace-free, greppable token (`stochastic`, `argmax-greedy`, `argmax-kill-switch`, `argmax-no-proposal-distribution`) so a benchmark log can prove its arm. Setting `RUST_LOG` now installs a subscriber for the offline CLI commands, writing to stderr so piped stdout stays clean. Nothing is installed when `RUST_LOG` is unset, so default output is unchanged byte for byte, and `Serve` is skipped because `server::startup` installs its own and would panic on the double `init()`. This also makes the pre-existing drafter auto-detection line reachable. The drafter-kind line was actively misleading: with no explicit `--draft-kind` the CLI printed the kind it auto-detected from the drafter's `config.json`, usually `dflash`, and then ignored it and ran the classic `SpeculativeGenerator` anyway. A benchmark run was therefore indistinguishable from a DFlash round-loop run. The path actually taken is now printed on its own line. The metric defect: `acceptance_rate` is `accepted / proposed`, but a chain stops at its first rejection and never tests the block positions behind it, so `proposed` is not the denominator theory predicts against. Comparing it across two arms with different chain-length profiles made a correct implementation look like a 2x regression on a real Llama 8B/1B pair. `positions_tested` is now counted identically on both acceptance rules and `per_position_acceptance` is reported beside the old figure, with the difference documented. `MLXCEL_SPECULATIVE_ACCEPT_DIAG=1` adds `closed_form_sum_min` (`sum_x min(p, q)`) and `closed_form_sum_prod` (`sum_x p(x) q(x)`) averaged over the tested positions, and makes the argmax arm capture `q` so both halves of an A/B report the same quantity. Since `min(a, b) >= a * b` on `[0, 1]`, the pair turns an acceptance anomaly into an arithmetic statement: the measured per-position rate must sit at `sum_min`, and `sum_min` can never fall below `sum_prod`. Off by default; it costs two full-vocabulary reductions per tested position. Measured on Llama-3.1-8B-Instruct-4bit with a Llama-3.2-1B-Instruct drafter, 200 tokens, block 4: at temperature 0.7 `sum_min` 0.2808 against `sum_prod` 0.2740 (1.02x) with measured 0.2902; at 1.0, 0.1738 against 0.1613 (1.08x) with measured 0.1735; at 1.5, 0.6809 against 0.0494 (13.8x) with measured 0.6966. The measured value sits on `sum_min` at every temperature, which is the correctness result. The ratio is the throughput headroom and it collapses when the drafter is near-deterministic, because `q(t*) ~ 1` makes `min(p, q)` and `p * q` coincide. Documented, along with the warning that between-trajectory variance at 0.7 on this pair exceeds the available gain, so a single short run there measures noise. Tests: the summary line's shape is pinned as an observable contract (stable rule ids, distinct and whitespace-free, `per_position_acceptance` distinct from `acceptance_rate`); `positions_tested` is asserted exactly in both deterministic regimes; the primary acceptance assertion is restated in the quantity theory predicts (per-position acceptance within 5 binomial sigma of `sum_x min(p, q)`); and `closed_form_acceptance` is checked against hand-computed values including the `sum_min >= sum_prod` invariant. Refs #902
…opt-in Two corrections, both to claims this branch made rather than to the algorithm. The rule the kill switch restored was named `argmax-kill-switch`, which is wrong and actively misled a benchmark. The code it restores is the original two statements verbatim, `sample_token_optimized(pos_logits, target_sampling, history)` followed by an equality test, which is a fresh draw from the target *sampler* and is `argmax` only at temperature 0. Call it sampler-match. The variants are now `greedy-argmax`, `sampler-match`, `stochastic` and `sampler-match-no-proposal-distribution`, and the module header no longer repeats the issue's claim that the pre-existing rule was biased at temperature > 0. It was not: it emits the target's own draw on both branches, so every emitted token is a fresh conditional draw from the target and the stream was already a target-only sample. `is_distribution_preserving` now returns true for every selectable variant, which reads oddly and is exactly the finding; `is_acceptance_optimal` carries the distinction that actually differs. The header also now states the acceptance ceiling and its consequence. For a `q`-distributed proposal and a `p`-distributed emission the largest probability the two can coincide is `sum_x min(p, q)`, the maximal-coupling bound. Every distribution-preserving rule is capped there: sampler-match reaches `sum_x p(x) q(x)`, modified rejection sampling attains the bound. Argmax-against-argmax is not capped because it is not correct, accepting at `q(argmax p)`, which for a confident drafter agreeing with the target's mode far exceeds the ceiling. So no implementation can make a correct rule strictly improve mean accepted length against argmax acceptance, and the issue's required outcome has to be read against sampler-match, where the improvement is real and provable. Given that, modified rejection sampling is now opt-in: `MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT=1` enables it, default off, with `SpeculativeGenerator::with_stochastic_acceptance(bool)` as the programmatic override. On the only path it reaches, the rule it replaces was already lossless, so it buys acceptance rate alone, and the available gain is `sum_x min(p, q) / sum_x p(x) q(x)`, which collapses toward 1 whenever the drafter is confident. Measured at about 1.02 on a Llama-3.1-8B / Llama-3.2-1B pair at temperature 0.7. Two extra full-vocabulary passes and a host sync per verified position do not pay for that. Same pattern as `MLXCEL_FUSED_QK_NORM` (#326) and the #905 fusions: the machinery, proof and tests land, the default does not move until a measurement justifies it. The programmatic override is not a convenience. With the env default off, every distributional test would have silently stopped exercising this module, which is the failure mode that nearly shipped in #899. The tests now force the rule explicitly, and a new test asserts the default sampler-match rule passes the same chi-square, which is the empirical half of the premise correction. Also pins a pre-existing draft-cache defect that predates this issue and affects both rules identically: the rewind trims `min(rejected + 1, n)` where the draft cache should retain `current_token, d_0..d_{a-1}`, making the correct trim `rejected - 1`. At `accepted == 0` the draft cache does not advance at all, so the drafter proposes from a frozen prefix while the target advances. `draft_cache_falls_behind_the_main_cache_every_round_pre_existing_defect` documents the current wrong behavior so a fix produces a failing test rather than silence. Validated: 95 speculative tests pass; full mlxcel-core lib run is 1285 passed, 1 failed (the pre-existing #964). End-to-end on a real Llama 8B/1B pair the three arms report `rule=sampler-match` (default), `rule=stochastic` (opt-in) and `rule=greedy-argmax` (temperature 0). Refs #902
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
Adds modified rejection sampling as an opt-in acceptance rule for the classic speculative path (
MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT=1, default off), together with the proof, the regression guards, the closed-form diagnostic, and a correction to the issue's premise that changes what this issue should be understood to be asking for.The premise correction, and why it changes the ask
Issue #902 states that the verify loop "accepts a draft token iff it equals the target model's argmax", and that this biases the output at
temperature > 0. For the path the issue's line numbers point at, that is not true.SpeculativeGeneratorselects the target token withsample_token_optimized(pos_logits, target_sampling, history), a fresh draw from the target sampler, which isargmaxonly attemperature == 0. It emits that draw on both branches: the drafted token on acceptance (equal to the draw by the accept test) and the draw itself on rejection. Every emitted token is therefore a fresh conditional draw from the target, and the stream was already exactly a target-only sample. Call this rule sampler-match. It was never biased; what it loses is acceptance rate,sum_x p(x) q(x).This is measured, not argued:
the_default_sampler_match_rule_also_matches_the_target_distributionpasses the same chi-square that rejects argmax-against-argmax at ~1825.The biased rule the issue describes is real, but it lives in
Gemma4MtpTargetAdapter::verify_forward(argmax_per_position,src/models/gemma4_mtp_target.rs:655) and the DFlash round loop (argmax_logits_to_array,drafter/dflash/round_loop.rs:728), both regardless ofsampler.temperature. Neither is changed here.The acceptance ceiling: why "strictly improves vs argmax" is unachievable
For a
q-distributed proposal and ap-distributed emission, the largest probability the two can coincide issum_x min(p(x), q(x))— the maximal-coupling bound, equivalently1 - TV(p, q). Every distribution-preserving rule is capped there.sum_x p(x) q(x)sum_x min(p(x), q(x))— the ceilingq(argmax p)— not bounded by the ceilingSince
min(a, b) >= a * bon[0, 1], rejection sampling always accepts at least as often as sampler-match. Butq(argmax p)is under no such constraint: withp(a) = 0.3andq(a) = 0.95the argmax rule accepts at 0.95 where the ceiling for any correct rule is about 0.30.So the reviewer's reading is correct and I agree with it: no implementation can make a distribution-preserving rule strictly improve mean accepted length against argmax acceptance. That is not a defect to fix, it is the price of unbiasedness, and it means the issue's required outcome is unachievable as literally written. Read against sampler-match — the rule this generator actually ran — the improvement is real and provable, and that is what the tests assert.
Recommendation: opt-in, not default
Modified rejection sampling is off by default in this PR.
MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT=1enables it;SpeculativeGenerator::with_stochastic_acceptance(bool)overrides programmatically.The reasoning: on the only path this rule reaches, the rule it replaces was already distribution-preserving, so it buys acceptance rate and nothing else. The available gain is exactly
sum_x min(p, q) / sum_x p(x) q(x), and that ratio collapses toward 1 whenever the drafter is confident, becauseq(t*) ~ 1makesmin(p, q)andp * qcoincide. Measured at ~1.02 on a Llama-3.1-8B / Llama-3.2-1B pair at temperature 0.7. Two extra full-vocabulary passes and an extra host sync per verified position do not pay for two percent of theoretical acceptance.This follows
MLXCEL_FUSED_QK_NORM(#326) and the #905 fusions in this same epic: the machinery, the proof and the tests land and are available, and the default does not move until a measurement justifies it. The place this rule earns its default is a verify path whose current rule is genuinely biased — MTP and DFlash — where it would buy correctness rather than only acceptance rate, and where trading acceptance for unbiasedness is a real decision.With the env default off, every distributional test would have silently stopped exercising this module, which is exactly how #899 nearly shipped a dead path. The programmatic override exists so the tests force the rule explicitly, and
stochastic_runs_report_the_stochastic_rule_and_a_longer_accepted_runfails if a run reports anything butrule=stochastic.A separate bug this work uncovered: #994
The draft KV cache rewind trims
min(rejected + 1, n)where the correct trim isrejected - 1. Ataccepted == 0the draft cache does not advance at all, so the drafter proposes from a frozen prefix while the target advances, in a self-reinforcing loop. It predates #902, affects every acceptance rule identically, and is the most likely explanation for measured acceptance of 0.12-0.46 on an 8B/1B pair against the 0.6-0.8 reported upstream. It also makes any speculative A/B on this path noisy: two 200-token runs producedclosed_form_sum_minof 0.221 and 0.396 on the same model pair.Filed as #994 with the derivation.
draft_cache_falls_behind_the_main_cache_every_round_pre_existing_defectpins the current wrong behavior so a fix produces a failing test rather than silence. Not fixed here: it changes token streams at every temperature and needs its own parity story.What changed
src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp— the filter chain offused_sample_impl(temperature scale, top-k, top-p, min-p) is extracted intofused_sample_filter_logits, and a newfused_sample_probsruns that same chain and returnssoftmax(...)in float32 instead of drawing. The effective distribution is therefore derived from the sampler's own code rather than reimplemented next to it, so it cannot drift. A greedy configuration (temperature == 0ortop_k == 1) returns the one-hot argmax indicator, which is the degenerateqa greedily-proposing drafter needs. Float32 is deliberate: the caller reads single entries back to the host and compares them against a uniform draw, and an f16 probability underflows below ~6e-8, turning a legitimately small acceptance ratio into an unconditional accept.src/lib/mlxcel-core/src/sampling.rs— the pre-sampler steps (last-position slice, token bias, repetition / DRY / frequency-presence penalties, XTC) are split out ofsample_token_optimized_coreintopreprocess_logits_for_sampling, with no behavior change. Two new public functions build on it:effective_token_distributionreturnsporqfor a given logits/config/history triple and consumes no randomness, andsample_token_with_distributionreturns a token together with the distribution it was drawn from from a single pre-step, which is what guaranteesqis the actual proposal distribution rather than a later reconstruction.src/lib/mlxcel-core/src/speculative/stochastic_accept.rs(new) —acceptance_rule,accept_draft_token,residual_resample,verify_draft_token, the kill switch and the one-shot info logging. The module header carries the losslessness proof and the reason it holds for any proposalq. The accept test isp(t) > 0 && u * q(t) <= p(t); the first conjunct is load-bearing, not cosmetic, because aq(t)that has underflowed float32 to zero would otherwise make the product test read0 <= 0and accept a token the target's own filter excluded. The residual draw goes throughffi::fused_sample(log(relu(p - q)), 1.0, 0, 1.0, 0.0), sosoftmax(log r) == r / sum(r)is the normalized residual by construction and the draw reuses the #900 Gumbel-max kernel where the backend supports it.src/lib/mlxcel-core/src/speculative/mod.rs— the verify loop dispatches on the rule. On the stochastic arm the draft loop capturesqper position (left unevaluated, so a chain rejected early never materializes the tail) and the verify loop buildspat each position from the history including the tokens accepted so far in the round. Both arms produce the same(accept, replacement)pair, so all the downstream bookkeeping, EOS handling and cache rewind is shared and unchanged. NewSpeculativeAcceptanceStatscounts rounds, proposals and acceptances.Docs:
docs/speculative-acceptance.md(new),MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPTindocs/environment-variables.md, links fromREADME.md,docs/README.md,docs/architecture.md.The distributional argument, and how the test verifies it
Null hypothesis. The token stream emitted by
SpeculativeGenerator::generateattemperature > 0is distributed identically to sampling the target model alone at the same temperature.Why the test can check it directly. The test target model's logits do not depend on its input, so target-only sampling emits i.i.d. draws from one known categorical
p = softmax(target_logits / 0.7). Under H0 every token the speculative generator emits, whatever mixture of accepted drafts, residual resamples and bonus tokens produced it, is also an i.i.d. draw from that samep, so a Pearson goodness-of-fit test againstptests H0 directly rather than by proxy.Statistic, samples, threshold. Pearson chi-square over a 6-entry vocabulary,
dof = 5. The committed default draws 12000 emitted tokens (SAMPLE_TOKENS, overridable withMLXCEL_SPEC_DIST_SAMPLES); thenum_draft = 1companion test draws half that. Rejected above 25.7448, the upper-tail quantile atalpha = 1e-4, so a correct implementation fails one run in ten thousand.Demonstrated power, not assumed power.
chi_square_rejects_known_wrong_acceptance_rulesreplays the same statistic against two deliberately wrong rules and requires rejection. Measured on this branch at n = 4000 verify positions: correct rule 4.88, "resample frompinstead of the residual" 136.48, argmax-against-argmax 1825.13, threshold 25.7448. The middle arm is the specific mistake a naive implementation makes, and it is detected with a five-fold margin at a quarter of the end-to-end sample count.Where chi-square is blind, zero-tolerance assertions cover. A goodness-of-fit test cannot see a single wrong token, so:
filtered_target_support_is_never_violatedruns atop_k = 2target against an unfiltered drafter over 1500 emitted tokens and fails on the first token outside the target's two-token support (exactly what a missingp(t) > 0conjunct produces);tokens_outside_the_target_support_are_never_acceptedandunderflowed_proposal_mass_does_not_bypass_the_target_supportassert the same property on the primitive over 400 and 200 trials with no tolerance;residual_draw_follows_the_normalized_relu_differencefails if the residual ever emits a token withp <= q; and the temperature-0 test fails on one differing token.Temperature 0
acceptance_rulereturnsArgmaxwhenever the target sampler is greedy (temperature == 0.0ortop_k == 1, matching the C++ sampler's own short-circuit, pinned bysampler_is_greedy_matches_fused_sampler_short_circuit). On that arm no distribution tensor is built, no randomness is drawn, and the pre-existing comparison runs verbatim.temperature_zero_stream_is_byte_identical_to_greedy_target_onlywalks a deterministic 6-cycle target against two drafters (one agreeing on one token in six, one never agreeing) atnum_draft1, 2 and 4, and asserts the emitted vector equals the target's own greedy continuation exactly.Proving from a log which rule ran
Both lines are at info. The first time a process runs each distinct rule it logs
speculative acceptance rule active rule="stochastic (modified rejection sampling)" distribution_preserving=true; the latch is per rule kind, not one global flag, so a process that serves a greedy request and later atemperature > 0one logs both lines. The other three labels areargmax (greedy target sampler),argmax (stochastic acceptance disabled by env)andargmax (drafter reported no proposal distribution). The first occurrence of each per-token outcome is logged the same way (accept, residual resample, and the degenerate residual fallback). Everygeneratecall then logsspeculative decode finished rule=Stochastic rounds=.. proposed_draft_tokens=.. accepted_draft_tokens=.. acceptance_rate=.. mean_accepted_len=.. generated_tokens=.., which is both the A/B measurement and the proof of which arm produced it.stochastic_runs_report_the_stochastic_rule_and_a_longer_accepted_runfails if atemperature > 0run reports anything other thanStochastic.Kill switch
MLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT=0(alsofalse,no,off, case- and whitespace-insensitive) restores the previous rule at every temperature, read once per process.RNG
The accept test draws one
U[0,1)per verified position and the residual draws one categorical per rejection, both through MLX's default key sequence, so--seedstill reproduces a run exactly. But the stream is consumed differently than before, so at an equal seed the emitted tokens differ from a pre-change run even though the distribution is identical (the same relationshipMLXCEL_SAMPLING_GUMBELhas). Set the kill switch to reproduce a stream recorded before this landed. Documented indocs/speculative-acceptance.mdand the env-var table.Observability: how to prove which rule ran
The
mlxcelCLI installs notracingsubscriber, so info-level instrumentation is discarded on the only binary that runsSpeculativeGenerator. Every speculative run therefore prints an unconditional stdout summary:rule=is a stable, whitespace-free, greppable id:greedy-argmax,sampler-match,stochastic,sampler-match-no-proposal-distribution. Its shape is pinned bycli_summary_line_carries_the_rule_id_and_mean_accepted_len. SettingRUST_LOGadditionally installs a subscriber for the offline CLI commands (stderr, so piped stdout stays clean); default output is unchanged when it is unset, andServeis skipped because the server installs its own.Two metric corrections found by making it visible
acceptance_rateis the wrong metric for an A/B. It isaccepted / proposed, but a chain stops at its first rejection and never tests the block positions behind it, soproposedis not the denominator theory predicts against. On a real Llama 8B/1B pair this made a correct implementation read as a 2x regression (0.059 stochastic vs 0.119 kill-switch).positions_testedis now counted identically on both rules andper_position_acceptanceis reported beside it. On the same runs that reads 0.2359 vs its own closed form 0.2213: correct.The drafter-kind line was misleading. With no explicit
--draft-kind, the CLI printed the kind auto-detected from the drafter'sconfig.json(usuallydflash) and then ignored it and ran the classicSpeculativeGeneratoranyway, so a benchmark run was indistinguishable from a DFlash round-loop run. The path actually taken is now printed:Passing
--draft-kindexplicitly does not give you a DFlash round loop here; it returns an error saying the offline path does not construct one.Verifying the gain before trusting a throughput number
MLXCEL_SPECULATIVE_ACCEPT_DIAG=1addsclosed_form_sum_min(sum_x min(p, q), what modified rejection sampling accepts) andclosed_form_sum_prod(sum_x p(x) q(x), what the old rule accepts), averaged over the positions the run actually tested, and makes the argmax arm captureqso both halves of an A/B report the same quantity. Becausemin(a, b) >= a * bon[0, 1],sum_min >= sum_prodholds for every pair of distributions, so the two numbers convert an acceptance anomaly into an arithmetic statement: the measured per-position rate must sit atsum_min, andsum_mincan never fall belowsum_prod. Off by default (two full-vocabulary reductions per tested position).Measured on Llama-3.1-8B-Instruct-4bit with a Llama-3.2-1B-Instruct drafter, 200 tokens,
--num-draft-tokens 4, one trajectory per row:closed_form_sum_minclosed_form_sum_prodper_position_acceptanceThe measured value sits on
sum_minat every temperature: that is the correctness result, and it is the strongest evidence in this PR that the accept test is right on real models rather than only on synthetic ones. The ratio column is the throughput headroom, and on this pair at 0.7 there is essentially none, becausesum_x min(p, q)collapses ontosum_x p(x) q(x)whenever the drafter is near-deterministic (q(t*) ~ 1makesmin(p(t*), q(t*)) = p(t*)andp(t*) q(t*) ~ p(t*)). The gain appears whenqis diffuse. Between-trajectory variance is also large: two 200-token runs at 0.7 differed by nearly 2x insum_min, so a single short run there measures noise, not the change. Readsum_min / sum_prodfirst; it bounds what any throughput measurement can show. The 1.5 row is a real measurement of the mechanism but comes from a degenerate high-temperature trajectory where both models agree on repetitive text, so it is not a production number.How to run the throughput benchmark
Throughput timings were not run here, by instruction. The classic
SpeculativeGeneratoris reachable only from the offline CLI (src/server/speculative_dispatch.rs:41records that the server does not use it), so the A/B ismlxcel generate --draft-model, with the kill switch selecting the arm:MLXCEL_SPECULATIVE_ACCEPT_DIAG=1 ./target/release/mlxcel generate -m models/<target> --draft-model models/<draft> --num-draft-tokens 4 --temp 0.7 --seed 1234 -n 256 -p "<prompt>"for the default (sampler-match) arm, and the same command withMLXCEL_SPECULATIVE_STOCHASTIC_ACCEPT=1for the opt-in arm. Confirmrule=argmax-kill-switchvsrule=stochastic, then compareper_position_acceptanceandmean_accepted_len. At--temp 0.0both arms must produce identical output and identicalrule=greedy-argmax.Do not measure this change with
speculative_bench. Every pairing it benches is MTP or DFlash, which this PR does not touch, so a before/after would compare the unchanged fallback against itself and produce a clean-looking null.docs/speculative-acceptance.mdsays this explicitly so the next person does not repeat it.What this PR does not do
Task 1 of the issue (expose draft probabilities on
Drafter,speculative/mtp/,drafter/dflash,drafter/gemma4_assistant) and the MTP/DFlash half of task 3 are not implemented.Drafter::draft_blockreturnsVec<i32>andMtpTarget::verify_forwardreturnsVerifyForwardOutput { target_tokens: Vec<i32>, .. }; the target logits are a local insideGemma4MtpTargetAdapter::verify_forwardthat is dropped at the end of the function, andMtpBatchedVerifyForwardOutputhas no logprobs field at all. Wiring stochastic acceptance there means widening both traits and every implementor, on the perf-critical deferred-greedy path, with no Gemma 4 or DFlash checkpoint available in this environment to validate against. Adding that surface without a consumer that can be exercised is how #899 shipped a path that never activated, so it is deliberately left out and recorded indocs/speculative-acceptance.md's per-path table instead. TheArgmaxNoProposalDistributionrule variant exists so those paths have a correct, logged answer when they are wired.The
mtp_policyhalf of task 3 needs no change:MtpBurstProfile::from_summaryconsumesMtpAcceptanceSummary, whichMtpRoundDiagnostics::record_roundfills from the MTP walk's own accepted counts. That plumbing is untouched and keeps working; had the MTP path been converted, its higher acceptance profile would flow through the same counters with no code change.The issue's
docs/benchmark_results/chain-speculative-acceptance-<hw>-<date>.mdis not written, because writing a results file without having run the benchmark would be fabrication.CUDA is unvalidated. No CUDA hardware and no
nvccin this environment.fused_sample_probsuses only backend-agnostic MLX ops and inheritsfused_sample's existingsingle_dtype_scalars(non-Metal) branch through the shared filter chain, but nothing on the CUDA path was compiled or run.Test plan
cargo test --release -p mlxcel-core --lib --features metal,accelerate speculative::— 95 passed, 0 failed.cargo test --release --test speculative_parity --features metal,accelerate— passed.cargo check --release --lib --tests --features metal,accelerate— clean.cargo test --release -p mlxcel-core --lib --features metal,accelerate— 1285 passed, 1 failed (pre-existing fix(tests): fused MoE GeGLU parity threshold is tighter than the gather_qmm reference's own jitter #964; baseline was 1250+1).cargo clippy --release -p mlxcel-core --lib --tests --features metal,accelerate -- -D warnings— clean.cargo clippy --release --bin mlxcel --features metal,accelerate -- -D warnings— clean.rule=ids (stochastic,sampler-match,argmax-greedy) and the diagnostic confirms measured per-position acceptance sits atsum_minat temperatures 0.7, 1.0 and 1.5.cargo fmt --all -- --check— clean.python3 scripts/ci/check_cross_repo_refs.py— exit 0.Closes #902