Repository navigation
fix(mlxrunner): grammar-aware speculation — stages 2b/3, and the fix that makes them correct - #191
Conversation
|
Reviewing as consolidator. The staging and the inertness are right, and the load-bearing property is correctly identified. Not mutating the caller's matcher is exactly the thing that would have been easy to get wrong and expensive to debug: a draft is a guess, grammar state is a stack machine, and advancing the live matcher over a rejected guess leaves the request generating against a state its own output never reached. Returning the clone alongside the count — so the caller adopts it over precisely the committed tokens — is the correct shape. Those two tests (caller's matcher unchanged after accept and after reject; returned matcher advanced over exactly the accepted tokens, hand-walked) are the ones I would have asked for. The EOS reasoning is right for a subtle reason worth keeping in the comment: And the corrected test is the best thing in the PR. One thing I would ask before stage 4 claims the winThe 42% is measured on So when the device half lands, I would want the before/after on at least one three-image cell as well as Happy to run that comparison here once the stage that changes behaviour exists — the harness, the fixtures and a clean CUDA host are all in place. No objection to this stage. Inert, well-tested, and the one property that must hold is the one the tests pin. |
Stage 3: it runs, it is 40% faster, and it breaks the formatDo not enable. It is faster because it stopped doing the job. The remaining defect is the math, not the plumbing
targetDist.SliceRows(0, draftCount).ResidualAgainst(draftDist)a masked target minus an unmasked draft distribution. That difference isn't a distribution over the masked support, and a token drawn from it need not respect the mask. Masked speculative decoding needs the draft side masked too, or the residual re-masked before sampling. I masked one side of a two-sided comparison — a design error, not a wiring one. Three plumbing defects were found and fixed first
2 and 3 were one decision: mutating a field whose pinning the caller owns. Verification now runs through local What this settles about reviewing device codeStage 2b shipped with the claim that reviewing it is not the same as running it. Four defects later that is a measurement, not a caution — every one compiled, passed The JSON parse in the harness is the only reason this isn't being reported as a 40% win. Stages 1 and 2a stand on their own — pure host logic, 15 tests, no device dependency. If this PR is too large to carry, they are worth splitting out and landing; stages 2b/3 need the residual masking before they mean anything. |
5f7642b to
e2f6369
Compare
|
Stages 1 and 2a are split out into #193 as requested — pure host logic, 15 tests, no device dependency, green. This branch is rebased onto Blocked on the same thing as before: |
d465b2d to
b794e9c
Compare
e2f6369 to
366dffa
Compare
…gated OFF
THE GATE IS THE POINT. OLLAMA_MLX_GRAMMAR_SPECULATION is unset by default and
nothing here runs unless it is explicitly "1" or "true". This mechanism has
never executed against a real model, and it belongs to the path whose own
comments warn that every exit between schedule and commit must drain the
snapshot schedule -- where a mistake is silent KV corruption, not a crash.
Shipped off so it can be measured, not used.
WHAT IS TESTED, and it is the half where an off-by-one would hide:
fillMaskBias lays out [rows, vocabDim] row-major, 0 where a row's mask admits
the token and -Inf where it does not. Four tests cover the layout against every
id of every row, row independence, buffer reuse across a longer round followed
by a shorter one, and the gate refusing everything except an explicit opt-in
("0", "no", "off", "yes", "2", "TRUE1" all stay off).
WHAT IS NOT TESTED: everything below the fill. constraintBiasRows,
maskedTargetLogits and adoptGrammar are written from the shape contract of
Sampler.Distribution and the row semantics of the fused hidden. They compile
and are unreachable with the gate off. Reviewing them is not the same as
running them, and they must not be trusted until stage 3 measures them.
WHY MASKED VERIFICATION IS REQUIRED, rather than an optimisation: verification
compares a draft against the target's distribution. Unmasked, the target's
argmax can be a token the format forbids, so every legal draft is rejected
against a token that could never have been emitted -- speculation accepts
nothing while appearing to work. That failure reads as "speculation did not
help" rather than as a bug, which is why it is written down.
ROWS PAST THE FIRST ILLEGAL DRAFT ARE FICTION. maskedTargetLogits returns the
admitted count alongside the biased logits, and the caller must verify only
that many: a row after an illegal token describes a grammar state that does not
exist, so accepting from it would be corruption rather than a wrong answer.
adoptGrammar advances the matcher only over committed tokens, never over a
draft, for the reason stage 1 documents: a matcher advanced over tokens that
were then rolled back leaves the request generating against a state its own
output never reached.
Routing is untouched -- constrained requests still take the serial path. Wiring
accept to call this, and measuring acceptance and tok/s against the 21.13
baseline, is stage 3.
gofmt clean; go vet clean; ./x/mlxrunner ./x/structured ok.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…S THE FORMAT
DO NOT ENABLE. OLLAMA_MLX_GRAMMAR_SPECULATION stays off and must stay off: with
the gate on, output is no longer valid JSON. Committed because the measurement
and the four defects behind it are worth more than the code is, not because the
feature works.
MEASURED, gemma4:31b-nvfp4, one 1920x1080 image, format:"json", n=3:
gate-off 22.41 / 21.98 / 22.37 tok/s eval=83 valid-json
gate-ON 29.28 / 31.41 / 32.83 tok/s eval=88 INVALID JSON x3
acceptance=0.00 avg_draft=0.49 max_draft=1
IT IS FASTER BECAUSE IT STOPPED DOING THE JOB. eval=88 is exactly what the
UNCONSTRAINED arm produced in the earlier comparison, against the constrained
path's 83: the gated path is generating unconstrained output. The ~40% is the
grammar penalty being avoided by skipping the grammar, not by verifying drafts
against it. Reporting throughput alone would have called this a win.
THE REMAINING DEFECT IS THE MATH, NOT THE PLUMBING. acceptance=0.00 with drafts
being produced means every step falls through to the residual or bonus token.
targetDist is masked, but residualTokens comes from
targetDist.SliceRows(0, draftCount).ResidualAgainst(draftDist)
-- a masked target minus an UNMASKED draft distribution. That difference is not
a distribution over the masked support and the token drawn from it need not
respect the mask at all. Masked speculative decoding needs the draft side
masked too, or the residual re-masked before sampling. One side of a two-sided
comparison was masked, which is a design error rather than a wiring one.
THREE PLUMBING DEFECTS WERE FOUND AND FIXED FIRST, all invisible to review,
vet and the host tests:
1. Truncating the draft AFTER scheduleSpeculation left the snapshot and the
commit disagreeing on the count: "cache: offset 34 exceeds 33 stored
keys". The truncation now precedes the snapshot.
2. Reassigning candidates.tokens to a truncated slice broke accept's contract
that the CALLER pins that array. The Sweep before the Eval freed the
unpinned replacement and the read returned a recycled buffer -- surfacing
as "Ints requires DTypeInt32, got BOOL", a use-after-free wearing a type
error's clothes.
3. The same reassignment made the caller's deferred Unpin release the
replacement while the original stayed pinned for good.
2 and 3 were one decision: mutating a field whose pinning the caller owns.
Verification now runs through local verifyTokens / verifyDist views and
candidates is left untouched. A draftCount of 0 -- the grammar rejecting the
first draft -- returns errNoLegalDraft and the caller falls back to a serial
step rather than scheduling a zero-length snapshot.
WHAT THIS SETTLES ABOUT REVIEWING DEVICE CODE. Stage 2b was committed with the
claim that reviewing it is not the same as running it. Four defects later that
is not a caution, it is a measurement: every one compiled, passed vet, passed
the host tests and read correctly. The second was invisible at the Go level
entirely -- a pointer swap that only misbehaves once Sweep runs.
The JSON parse in the measurement harness is the only reason this is not being
reported as a 40% win.
gofmt clean; go vet clean; ./x/mlxrunner ok (the gated path has no host tests --
that is the point).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366dffa to
6fa913d
Compare
Stage 3 measured as 40% faster and produced invalid JSON, with an eval_count
identical to the same request run with no format at all. That last fact is the
whole diagnosis: the gated path was not generating slightly-wrong output, it
was generating completely unconstrained output.
Three emission paths were unmasked or unadopted, and they compound:
1. park() built a plain pipelinedDecoder. A parked round's tokens are emitted
output, but nothing masked them and nothing advanced the matcher. The
depth controller starts at zero, so the FIRST round of every request took
this path.
2. resume() drains a token the inner decoder sampled but never fed through its
own next, so nothing had advanced the matcher over it either.
3. accept() adopted commitIDs[:keep] -- the accepted drafts -- but built that
slice before appending nextID, the residual-or-bonus token every round
returns. So even a fully working round left the matcher one token behind.
Any one of these desynchronises the matcher from the emitted text. Then mask
row 0 is the state after some earlier prefix, legal drafts look illegal, the
round parks, and the parked token is emitted unadopted too. It does not
converge, it compounds -- which is why the output ended up with no constraint
on it at all rather than a few bad tokens.
The fix, in one sentence: every token the request emits is drawn from a masked
distribution and advances the one matcher the session owns.
- park() under a grammar builds a constrained step over the SESSION'S
matcher (constrainedStep), so a parked round is masked and stays level
with the drafting rounds it alternates with.
- resume() adopts the token it drains.
- accept() adopts nextID as well as the committed drafts.
- adoptGrammar returns an error instead of discarding Advance's failure.
Every token reaching it came from a masked distribution, so a failure means
drift, and generating past drift ships malformed output under a format
promise. The serial decoder has made this call since it was written; the
gated path silently swallowing it is why the drift above survived a whole
measurement campaign.
Also fixed, found while tracing the above: an admitted drafted EOS ends the
mask sequence a row early, which made sampleTokenAt index past the
distribution AND handed Sampler.Distribution as many rows as draft tokens,
shifting every row's penalty history by one. accept now drops the drafted EOS
instead, which restores the bonus row universally -- the row before an EOS
still admits EOS, so the target can sample it there, at a cost of one
unaccepted draft per request.
NOT a defect, contrary to the note left on the stage 3 commit: the residual.
targetDist.ResidualAgainst(draftDist) cannot sample an illegal token in either
distribution layout, because the -Inf bias makes the masked target's
probability for a forbidden token exactly zero, so max(target-draft, 0) is
zero there too. min(1, p_masked/q_unmasked) is also the correct acceptance
rule for sampling from the masked target with an arbitrary proposal. The
earlier diagnosis was wrong and no code was changed for it.
maskedTargetLogits was dead code that disagreed with the block accept()
actually ran, and pipeline.go's comment pointed readers at it. Replaced with
grammarRows and maskRows, both on the live path.
isEOS is now carried on the session rather than reached for through
spec.r.Tokenizer, which makes the grammar bookkeeping constructible without a
model: constrain_session_test.go covers the bonus-row contract, adoption,
the illegal-token guard, and the invariant that the matcher tracks every
emitted token -- 7 tests, no MLX.
Still gated off; the on-GPU measurement is next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-on to the previous commit, from an adversarial audit of the same path. None of these caused the invalid JSON; all of them are things that would have made it visible, or that would break next. - The round plan moves out of accept() into verifyPlan, which carries the guarantee accept depends on: whenever a round verifies anything, there is exactly one mask per verified draft PLUS the bonus row. That is now host-testable over every shape draftMasks can return -- all-legal, illegal mid-draft, illegal at once, empty, lone EOS, EOS after a legal token. - accept() asserts that contract before scheduleSpeculation, where an error return is still cheap; after the schedule it would leave the snapshot undrained and panic the next PrepareSnapshots. The assert is not belt-and-braces: NOTHING downstream can catch a short mask sequence. Sampler.Distribution panics only when given MORE rows than draft tokens plus one and is deliberately silent when given fewer, because the proposal path relies on that shape -- so a short sequence is read as a proposal-shaped call and silently shifts every row's repeat-penalty history by one. - resume() adopts the drained token BEFORE inner.close(), which unpins the very array the adopt loop reads. Nothing sweeps in that window today, so the other order happens to work; depending on that is how stage 3 produced two use-after-frees. - An errNoLegalDraft round no longer counts as a drafted round. accept returns before scheduleSpeculation and before any target forward, so the round decoded plainly -- charging it as depth-N priced a plain decode as a speculative one, and made an acceptance of 0.00 indistinguishable from the target rejecting every draft. Two counters now separate those: how many draft positions the grammar refused, and how many rounds it refused the first draft outright. That ambiguity is exactly what let the broken gated path read as "speculation did not help" for a whole campaign. - The gated route requires spec.enabled. A format request that also asks for logprobs cannot draft, so it parked permanently -- correct after the previous commit, but reached through two extra layers. It goes to the serial constrained decoder now, and the gated branch means what it says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause found, and it was not the residualThe note I left on 6fa913d blamed The actual defect was that the gated path never applied the grammar to a single emitted token. Three emission paths were unmasked or unadopted, and they compound:
Any one desynchronises the matcher; then mask row 0 is the state after some earlier prefix, legal drafts look illegal, the round parks, and the parked token is emitted unadopted too. It does not converge, it compounds — which is why The fixEvery token the request emits is drawn from a masked distribution and advances the one matcher the session owns.
Also fixed, found while tracing: an admitted drafted EOS ends the mask sequence a row early, which indexed
Verification
Still gated off. The on-GPU arm has not been re-run yet — measurement to follow. Not changed, deliberately
|
Measurement status: not run — build environment, not the codeSix attempts to build a CUDA image on the 10.8.0.6 workstation, none completed. Recording this so nobody assumes the fix was measured.
A retry is cheap now — the builder docker start buildx_buildkit_gramfix0 && docker buildx build --builder gramfix --load -t maxusai/ollama:gramfix /opt/github/MaxusAI/ollamaThe measurement harness is written and ready: gate-off vs gate-ON, Acceptance criterion when it does run: Until then this stays draft. |
Measured on CUDA. The format bug is fixed; the feature does not speed anything up.RTX PRO 6000 Blackwell,
What is fixedEvery constrained response on every arm came back as bare, parseable JSON — What does not workGate ON is not faster — it is marginally slower, and the reason is that it never
The reading the code supports: For contrast, the same binary unconstrained climbs normally — So the feature as it stands is correct and inert. The gap it was built to close Caveat on the unconstrained columnGate ON reads 34.00 vs 37.47 unconstrained. The gate should not touch that path RecommendationKeep the gate off and keep the branch — the correctness work is worth landing Method note: measured with a Go-only binary swap over |
golangci-lint's intrange linter failed CI on the three counted loops the new tests added. Go 1.22 range-over-int is equivalent here -- size is int32, so `range size` still yields int32 -- and the assertions are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First of four stages toward speculative decoding under a grammar. Nothing calls this yet — the serial constrained path is untouched and remains the only behaviour, so this is inert by construction.
Why
Constrained decoding runs one token per forward pass (
constrainedDecoder.next) while unconstrained decoding is speculative. That is the whole of the cost. Measured on CUDA,gemma4:31b-nvfp4, one 1920×1080 image,n=3:format:"json"llama-server pays almost nothing because it verifies drafts against the grammar instead of giving up on drafting. Background:
mlx-constrained-decode-disables-speculation.md(#187).What this stage does
draftPrefixanswers one question: given a draft, how much of it could the grammar possibly accept? Verification then never has to consider a token the format forbids.It does not decide acceptance — the target model's masked distribution does, and that's the device half, not in this commit.
The load-bearing property
The caller's matcher is not mutated. Grammar state is a stack machine and a draft is a guess; advancing the live matcher over a guess that verification then rejects would leave the request generating against a state its own output never reached. So it walks a
Clone()and returns it, for the caller to adopt over exactly the tokens that commit — which is why the count and the matcher come back together.EOS ends the walk rather than extending it:
Vocab.Maskadmits EOS only where the grammarCanComplete, so one reaching this point is legal by construction, but nothing follows it and advancing past it would ask the matcher to consume sentinel text.Tests
Nine, including the two that actually matter:
plus truncation at the first illegal token, truncation mid-draft, immediate rejection, EOS stopping the walk, empty draft, and an out-of-range id ending the walk rather than panicking.
One test was wrong before the code was
It drafted
{ " } aexpecting truncation at 2, reasoning that}can't close an object while a string is open.draftPrefixaccepted all four — and was right: after{"the parser is inside a string, where}andaare ordinary content.The premise is now
:after{, which no open string can absorb. The reasoning error is recorded in the test comment, because the next reader will make it too.Remaining stages
speculationSession.accept, behind a flag. Each drafted position sits in a different grammar state, so this needs a[k+1, V]bias — ~4 MiB/step at k=3 on a 262,144 vocabulary. Affordable against a 42% penalty, but to be measured, not assumed.Stage 2 touches
accept, whose own comments warn that every exit between schedule and commit must drain the snapshot schedule — a bug there is silent KV corruption, not a crash. It wants its own review.🤖 Generated with Claude Code