Skip to content

fix(mlxrunner): grammar-aware speculation — stages 2b/3, and the fix that makes them correct - #191

Merged
glennneuber merged 5 commits into
mainfrom
feat/grammar-aware-speculation-stage1
Aug 18, 2026
Merged

glennneuber merged 5 commits into
mainfrom
feat/grammar-aware-speculation-stage1

Conversation

@glennneuber

Copy link
Copy Markdown

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:

engine format:"json" unconstrained penalty
MLX 21.13 36.53 −42%
GGUF 57.23 58.17 −1.6%

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

draftPrefix answers 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.Mask admits EOS only where the grammar CanComplete, 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:

  • the caller's matcher is unchanged after both an accepted and a rejected draft
  • the returned matcher is advanced over exactly the accepted tokens (verified against a hand-walk)

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 { " } a expecting truncation at 2, reasoning that } can't close an object while a string is open. draftPrefix accepted all four — and was right: after {" the parser is inside a string, where } and a are 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

  1. Per-row masked verification in 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.
  2. Measure on the GPU host: acceptance under grammar, tok/s against the 21.13 baseline, and that output still parses as JSON every time.
  3. Flip the default only if 2 and 3 hold.

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.

gofmt clean · go vet clean · go test ./x/mlxrunner ./x/structured: ok

🤖 Generated with Claude Code

@glennneuber

Copy link
Copy Markdown
Author

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: Vocab.Mask only admits EOS where the grammar CanComplete, so one arriving here is legal by construction — the walk stops because nothing follows it, not because it might be illegal.

And the corrected test is the best thing in the PR. { " } a truncating at 2 is what a reader reasons their way to; that after {" the parser is inside a string where } is ordinary content is what the grammar actually does. Recording the reasoning error in the comment rather than silently fixing the expectation is what stops the next person re-deriving it — and it is a genuine near-miss, since a test asserting an over-strict prefix would have made a correct implementation look broken.

One thing I would ask before stage 4 claims the win

The 42% is measured on scene_hd.png, one image. The suite's heaviest cells are three-image (multi_3img, and six of the eight bbox_contract_* probes), and there is direct evidence that cell shape changes this kind of cost sharply: #185's allocator sweep moved scene_single by 1% and bbox_contract_multi by 54%.

So when the device half lands, I would want the before/after on at least one three-image cell as well as scene_single. Two reasons it might differ: a longer prompt changes the draft-acceptance rate (unconstrained measured 0.76 with avg_draft 2–3, on a single-image prompt), and the JSON these probes emit is more deeply nested, which changes how often the grammar truncates a draft early. A 42% headline that turns out to be 15% on the cells people actually run would be worth knowing before it is quoted.

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.

@glennneuber glennneuber changed the title feat(mlxrunner): grammar-aware speculation, stage 1 — host-side draft legality wip(mlxrunner): grammar-aware speculation — stages 1-3, DOES NOT WORK, gated off Aug 17, 2026
@glennneuber
glennneuber marked this pull request as draft August 17, 2026 22:32
@glennneuber

Copy link
Copy Markdown
Author

Stage 3: it runs, it is 40% faster, and it breaks the format

Do not enable. OLLAMA_MLX_GRAMMAR_SPECULATION stays off.

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 earlier, 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. 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 the residual comes from

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

# defect symptom
1 truncation after scheduleSpeculation cache: offset 34 exceeds 33 stored keys
2 reassigned candidates.tokens to an unpinned slice Ints requires DTypeInt32, got BOOL — use-after-free
3 same reassignment broke the caller's defer Unpin leaks the original, unpins the wrong array

2 and 3 were one decision: mutating a field whose pinning the caller owns. Verification now runs through local verifyTokens/verifyDist views. A draftCount of 0 returns errNoLegalDraft and falls back to a serial step.

What this settles about reviewing device code

Stage 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 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 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.

@glennneuber

Copy link
Copy Markdown
Author

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 feat/grammar-draft-host-logic and its base is retargeted to #193, so it now carries only the two commits that do not work: stage 2b (device bias, gated off) and stage 3 (the wiring that produces invalid JSON). The diff shown here is the untrusted half alone.

Blocked on the same thing as before: targetDist.SliceRows(0, draftCount).ResidualAgainst(draftDist) subtracts an unmasked draft distribution from a masked target, so the residual token need not respect the mask. Until that is fixed, the gate-ON speedup is just the grammar being skipped.

@glennneuber
glennneuber changed the base branch from main to feat/grammar-draft-host-logic August 17, 2026 22:42
@glennneuber
glennneuber force-pushed the feat/grammar-draft-host-logic branch from d465b2d to b794e9c Compare August 17, 2026 22:48
@glennneuber
glennneuber force-pushed the feat/grammar-aware-speculation-stage1 branch from e2f6369 to 366dffa Compare August 17, 2026 22:48
@glennneuber
glennneuber changed the base branch from feat/grammar-draft-host-logic to main August 17, 2026 23:08
glennneuber and others added 2 commits August 18, 2026 09:10
…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>
@glennneuber
glennneuber force-pushed the feat/grammar-aware-speculation-stage1 branch from 366dffa to 6fa913d Compare August 17, 2026 23:10
glennneuber and others added 2 commits August 18, 2026 09:58
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>
@glennneuber

Copy link
Copy Markdown
Author

Root cause found, and it was not the residual

The note I left on 6fa913d blamed ResidualAgainst subtracting an unmasked draft from a masked target. That claim is false and no code changed for it. The -Inf bias makes the masked target's probability for a forbidden token exactly zero, so max(target − draft, 0) is zero there too, in both the sparse and dense layouts — and min(1, p_masked/q_unmasked) is the correct acceptance rule for sampling from the masked target with an arbitrary proposal. Masking the draft side would have been a pure throughput regression bought for nothing.

The actual defect was that the gated path never applied the grammar to a single emitted token. eval_count=88 matching the no-format arm exactly was the clue: not slightly-wrong output, entirely unconstrained output.

Three emission paths were unmasked or unadopted, and they compound:

  1. park() built a plain pipelinedDecoder — raw logits, no mask, no matcher advance. 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 advanced the matcher over it either.
  3. accept() adopted commitIDs[:keep] but built that slice before appending nextID — the residual-or-bonus token every round returns. Even a fully working round left the matcher one token behind.

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 acceptance=0.00 and avg_draft=0.49: propose → errNoLegalDraft → park → resume, forever.

The fix

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. Sharing rather than cloning is load-bearing — park and draft alternate within one request.
  • resume() adopts the token it drains, before close() unpins it.
  • accept() adopts nextID as well as the committed drafts.
  • adoptGrammar returns an error instead of discarding Advance's bool. The serial decoder has treated this as fatal since it was written; the gated path swallowing it is why the drift survived a whole campaign.

Also fixed, found while tracing: an admitted drafted EOS ends the mask sequence a row early, which indexed sampleTokenAt past the distribution and handed Sampler.Distribution as many rows as draft tokens — which it reads as a proposal-shaped call and silently shifts every row's repeat-penalty history by one. verifyPlan drops the drafted EOS instead, restoring the bonus row universally at a cost of one unaccepted draft per request.

maskedTargetLogits was dead code that disagreed with the block accept() actually ran, and pipeline.go's comment pointed readers at it. Replaced with grammarRows/verifyPlan/maskRows, all live.

Verification

isEOS is carried on the session rather than reached for through spec.r.Tokenizer, which makes the grammar bookkeeping constructible without a model. 15 host-side tests, no MLX — the bonus-row contract over every shape draftMasks can return, adoption, the illegal-token guard, and the invariant that the matcher tracks every emitted token. Full ./x/... suite green, gofumpt and vet clean.

Still gated off. The on-GPU arm has not been re-run yet — measurement to follow.

Not changed, deliberately

Sampler.Distribution's guard is one-directional (it panics only when rows exceed len(draftTokens)+1, and is silent when short because the proposal path relies on that shape). Tightening it to == would need a validation-only variant in shared sampler code; accept() now asserts the row contract on its own side instead.

@glennneuber glennneuber changed the title wip(mlxrunner): grammar-aware speculation — stages 1-3, DOES NOT WORK, gated off fix(mlxrunner): grammar-aware speculation — stages 2b/3, and the fix that makes them correct Aug 18, 2026
@glennneuber

Copy link
Copy Markdown
Author

Measurement status: not run — build environment, not the code

Six attempts to build a CUDA image on the 10.8.0.6 workstation, none completed. Recording this so nobody assumes the fix was measured.

  • / is at 99% (26 GB free). The image build needs more transient space than that: two attempts were killed at the 3 GB mark to protect the other containers on the host.
  • Routing buildkit to /mnt/8TB_SN850X_RAID1_BTRFS via a bind-mounted buildx volume works — root held at 26–28 GB for the whole build, and 74 GB of compile cache accumulated on the array. The pre-existing bigdisk builder does not help; its state volume is on / despite the name.
  • The two runs that got furthest both died when the buildkit daemon container stopped mid-build — once SIGKILL (137), once a graceful goaway. No memory limit, 632 GB RAM free, no docker daemon restarts, no prune timer. Cause unidentified; something external to this session is stopping that container.
  • Also worth knowing: with a genuinely cold cache the NVIDIA dnf install step failed once (it succeeded on retry). The layer cache may be load-bearing for building this repo from scratch.

A retry is cheap now — the builder gramfix and its 74 GB cache are still in place:

docker start buildx_buildkit_gramfix0 && docker buildx build --builder gramfix --load -t maxusai/ollama:gramfix /opt/github/MaxusAI/ollama

The measurement harness is written and ready: gate-off vs gate-ON, gemma4:31b-nvfp4, one 1920×1080 image, format:"json", n=3, reporting eval_count / tok/s / JSON validity / acceptance.

Acceptance criterion when it does run: eval_count must diverge from the no-format arm's 88 (it should land near the gate-off arm's 83), JSON must parse in all three repeats, and grammar_no_legal_draft must be well below the round count — an acceptance of 0.00 alongside a high grammar_no_legal_draft would mean the matcher is still drifting. Those two counters were added precisely so that reading is unambiguous.

Until then this stays draft.

@glennneuber

Copy link
Copy Markdown
Author

Measured on CUDA. The format bug is fixed; the feature does not speed anything up.

RTX PRO 6000 Blackwell, gemma4:31b-nvfp4, visimgs/scene_hd.png (1920×1080), same
prompt in both arms, think:false, num_ctx:8192, warmed, n=3.

binary format:"json" unconstrained
published baseline (eb0ad43, ADR note) 21.13 36.53
pristine eb0ad43, re-probed (n=1) 22.35 —
main @ 640a349c (n=1) 22.98 —
this branch, gate OFF 23.63 (23.81 / 23.09 / 23.98) 37.47 (36.92 / 38.22 / 37.27)
this branch, gate ON 22.25 (21.96 / 22.48 / 22.32) 34.00 (34.64 / 33.08 / 34.29)

What is fixed

Every constrained response on every arm came back as bare, parseable JSON —
bare_json=True parses=True, no markdown fence. Stage 3 shipped output that ignored
the grammar entirely; f1d3d223 and 99dc5c88 close that. The correctness claim in
the writeup above holds on device, not just in the host tests.

What does not work

Gate ON is not faster — it is marginally slower, and the reason is that it never
drafts.
The stats, taken from the same requests as the table:

iterations=126 drafted=0 accepted=0 acceptance=0.00 avg_draft=0.00 max_draft=0 \
  grammar_truncated=0 grammar_no_legal_draft=0 depth_over_time="0.0/0 0.0/0 ... 0.0/0"
iterations=121 drafted=1 accepted=0 acceptance=0.00 avg_draft=0.01 max_draft=1 ...

drafted=0 on nearly every round, and not because the grammar rejected the
proposals — grammar_no_legal_draft=0 and grammar_truncated=0, so the
errNoLegalDraft fallback never fired either. Nothing was ever proposed.
depth_over_time is flat zero for the whole request.

The reading the code supports: speculativeDecoder only proposes when
s.limit > 0 (speculate.go:232), and s.limit is refreshed solely in
endRound from depth.next(). The acceptance model only learns when
observed > 0 (speculate.go:167), and a parked round reports observed=0.
Depth starts at zero, so round 1 parks, which teaches the controller nothing,
so depth stays zero — a cold-start deadlock. The one round that did draft
(max_draft=1) was a cadence probe, and its single token was rejected, which
is evidence against raising depth.

For contrast, the same binary unconstrained climbs normally —
depth_over_time="3.6/5 5.1/6 5.0/5 ... 5.9/7", acceptance 0.72–0.93.

So the feature as it stands is correct and inert. The gap it was built to close
(−37% here, −42% published) is untouched.

Caveat on the unconstrained column

Gate ON reads 34.00 vs 37.47 unconstrained. The gate should not touch that path
at all. This host is shared and its load moved a great deal across the session,
so I would not call that a real regression on n=3 without re-running both arms
back to back on a quiet box. Flagging it rather than explaining it away.

Recommendation

Keep the gate off and keep the branch — the correctness work is worth landing
on its own, and the harness now exists. The next change is the depth controller:
it needs a way to enter a grammar-constrained request at depth ≥ 1, or to learn
from parked rounds, or speculation under a grammar can never start.

Method note: measured with a Go-only binary swap over
maxusai/ollama:0.32.14-rc0-dynres-mlxfix. The branch changes only x/mlxrunner/*.go;
git diff eb0ad43...HEAD over x/mlxrunner/mlx, MLX_VERSION, MLX_C_VERSION,
CMakeLists.txt, cmake/, llama/ and ml/ is empty apart from two Go files and a
README, so the native payload is identical and the swap is sound. Verified by sha256
inside the image and by main reproducing the published baseline through the same path.

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>
@glennneuber
glennneuber marked this pull request as ready for review August 18, 2026 08:27
@glennneuber
glennneuber merged commit c4f67f9 into main Aug 18, 2026
11 checks passed
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.

1 participant