Skip to content

TASK: merge upstream v0.34.0 into main (MLX ce916dbb; drafting under a grammar kept, with a knob) - #297

Merged
glennneuber merged 42 commits into
mainfrom
task/upstream-sync-0.34.0
Sep 13, 2026
Merged

glennneuber merged 42 commits into
mainfrom
task/upstream-sync-0.34.0

Conversation

@glennneuber

@glennneuber glennneuber commented Sep 13, 2026 •

Copy link
Copy Markdown

The fold of upstream v0.34.0 (d8ab4b4f, 21 commits across 88 files), cut from main at 7d230fdc. The full record, with every gate and its evidence, is docs/maxusai/tasks/upstream-sync-0.34.0.md.

Merge and pins. Merge commit ca3ff1db. MLX moves 37c26e57 → ce916dbb (ml-explore/mlx#4452, the idle runner that held a CPU core at 100 %), which is why this fold needed a real rebuild rather than a Go-only swap. llama.cpp and MLX-C pins are unchanged. The pin deliberately stops short of MLX main: ollama#4458 inserts global_scale into gather_qmm ahead of sorted_indices, and MLX-C at c74db530 passes positionally.

Three conflicts.

Gates. Gate 2 green in the golang container. Gate 3 built maxusai/ollama:sync-0.34.0. Gate 4 preflight PASS, and 21/7 once the MLX pin was made assertable. Gate 5 GGUF: every quality row identical to main across eight models. Gate 5 MLX ran twice — on the payload-swap image and, at the end, on the shipping image itself: contract matrix identical to main, 50 of 50 cells, with 2 of 70 T1 quality cells differing (a 0.003 IoU move and one 7 px OCR code). Renders in preflight-runs/.

The MLX differences, attributed. About thirty suite runs across four images — the fold, the fold with drafting gated off, main, and main carrying only the fold's MLX libraries — show that drafting under a grammar is the only change in this fold that moves MLX outputs, and what it moves are knife-edge cells that flip between runs on main too. Without it the fold reproduces main's answers test for test.

Decision (maintainer, 2026-09-12): align with upstream. Drafting under a grammar stays on by default; OLLAMA_MLX_DRAFT_UNDER_GRAMMAR=0 restores the gate this fork carried before. ADR 0033 is amended here to record the divergence, and the knob is documented beside the other serving knobs.

A known issue this fold widens but does not introduce. Drafting leaves MLX memory that no tracked array accounts for, growing across requests on the qwen3.5 family, which the admission headroom cannot see. Main's own drafting does the same on think-on and format-less requests; drafting under a grammar extends the reach to structured output. The knob limits that reach without fixing it. The fix belongs upstream in the speculation path, and nothing is being filed there until we can propose one. ADR 0034 is amended in #296.

Merge order with the open PRs.

Not in this PR. The deploy stays held. The built candidate predates the knob commit, so shipping the knob needs the next build, or the -knob swap image the task doc describes. The Metal half is the maintainer's.

🤖 Generated with Claude Code

dhiltgen and others added 30 commits September 3, 2026 14:17
…18219)

* ci: rebuild MLX macOS test payloads the release can't supply

The MLX unit test dependency payload can go stale when a PR changes the
xgrammar wrapper (release dylib lacks the new symbols) or bumps MLX pins (no
matching release, so MLX tests silently skipped). Compare the matched tag's
build rules and wrapper sources against the checkout, rebuilding only
libollama_xgrammar.dylib on drift; with no matching release, build one Metal
variant at the platform default.

* address comments
A structured-output request could not use a model's draft head: it
decoded one token at a time, at roughly half the speculative throughput
on a dense 27B MTP model.

The grammar is enforced during verification instead: each draft
position's logits are masked before rejection sampling, so an invalid
draft is never accepted and every emitted token obeys the grammar.
Drafts stay unconstrained; constraining the draft chain would stall its
pipelined forwards.

Speculative steps also now dispatch the drafts before the host builds
the verification graph, worth 4-8% end to end at a fixed draft depth on
MTP models, with or without a grammar.
The runner compiled a format as a JSON Schema, the one grammar kind its
xgrammar binding exposed. A structural tag holds a schema as one node of
a larger tree and also expresses what a schema cannot: free text around
constrained spans, a thinking region that closes before constrained
content, tool calls pinned to their schemas.

The runner now compiles structural tags only; its client wraps the API's
formats into one, which compiles to the same grammar as before. The JSON
token and vocabulary caps go with it: neither bounds compile cost, which
follows the grammar's state count. The byte and nesting caps stay.
Avoid unbounded parallel builds on small runners, and cache the payloads for repeated runs.
progress: join the render loop in stop and do the final writes after the
goroutine exits, so Stop/StopAndClear cannot race an in-flight render on
the shared bufio.Writer.

sched: read the unload-mutable fields in runnerRef.LogValue only under a
successful refMu.TryLock and omit them when contended, since slog resolves
it on goroutines that may already hold refMu.
Upstream v0.34.0 is a Go- and app-side release: the native payload
(MLX, MLX-C, llama.cpp) is unchanged, and none of the MLX binding's C
or CMake files moved. Three files conflicted:

- server/sched.go: keep the fork's runnerRef.LogValue (a leaf logMu,
  #289 and #291) over upstream's refMu.TryLock(), which drops name,
  inference, pid and num_ctx at the eleven log sites that already hold
  refMu. Adopt upstream's slices.Clone of the GPU list inside the lock.
- x/mlxrunner/client.go: take upstream's requestGrammar (structured
  output compiled as an xgrammar structural tag), in line with ADR 0033.
- x/mlxrunner/speculate.go: take upstream's accept(), which lets a
  constrained session draft. Keep the fork's comments, rewriting the
  park() note that said a constrained session never drafts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…est to structural tags

The v0.34.0 merge took upstream's removal of pid from LogValue's shared
attributes (upstream moved it into its TryLock block), so the fork's
resolved function stopped logging pid.
TestRunnerRefLogValueKeepsFieldsUnderRefMu caught it; put it back.

client_format_test.go asserted the pre-0.34.0 wire format and called
parseGrammar with a raw "json". Since v0.34.0 the client wraps every
non-empty format in an xgrammar structural tag, and parseGrammar accepts
only such tags. Re-assert ADR 0009 on that path: empty formats stay
unconstrained, every non-empty format survives as a constraint, and a
malformed or unwrapped format is an error. Rejecting a format the
compiler cannot honour now happens in the native compile, checked live
by the fold's GPU gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the fold's task doc: the three conflict resolutions, what Gate 2
caught (a silent pid regression, the format test's port to structural
tags, a flaky upstream test with its cause measured), and why the MLX
half needs a native rebuild. Upstream changed the xgrammar shim that
ships in the MLX payload, so a swapped binary cannot load the 0.33.3
library and MLX structured output answers 501. Main's live baseline
refuses "yaml" with a 400; the fold's answer awaits a rebuilt payload.

Corrects the first-look claim in the sync doc that 0.34.0 needed no
native rebuild.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…op fix

ml-explore/mlx#4452 stops the CUDA completion worker from spinning after
its first batch: an idle runner held one core at 100 % with the GPU idle
(measured 2026-09-10, claude-scratch/probe-idle-cpu.sh).

The pin stops at that commit (10 past the old pin), not at MLX main. The
next CUDA-relevant commit, ollama#4458, inserts a global_scale parameter into
gather_qmm's C++ signature ahead of sorted_indices. MLX-C at our pin
(c74db530) passes sorted_indices and the stream positionally, so it
would bind them to the wrong parameters or fail to compile, and MLX-C
has not adapted yet. The commits before ollama#4452 are Metal, CPU and Python
fixes plus additive core APIs, so MLX_C_VERSION stays unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…llama#4452

Records Glenn's go-ahead for the rebuild with the MLX bump, the pin at
ml-explore/mlx#4452 rather than MLX main (ollama#4458 changes gather_qmm's
C++ signature, which MLX-C at our pin calls positionally), why the
build misses every llama-server cache, and the MLX gates that follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…f identical on quality

On the swap image, which measures the GGUF path validly: the preflight
passes 20 and skips 8, the same as main, and the eight-model think-off
has no OOM or error. Rendered with the ADR 0012 generators against
main, every head-to-head quality row matches (56 of 56), the contract
matrices are identical, and only throughput and latency move, with the
host's load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 0.34.0 loader looks up ten symbols in libollama_xgrammar.so, and the
0.33.3 build lacks two: ollama_xgrammar_matcher_rollback and
ollama_xgrammar_matcher_is_terminated. The three dynamic_matcher names
the record listed are wrappers defined in dynamic.c itself. The rebuilt
payload has all ten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d; the idle core is fixed

On a test image with the fold's Go binary and its freshly built MLX
payload (MLX 0.32.2-37-gce916db), "json" and a schema return valid JSON
and "" stays unconstrained. "yaml" is still refused with a 400, but with
xgrammar's internal compile error instead of main's API-level message.
The idle runner sits at 0.0-0.1 % CPU where main held a core at 100 %
(ml-explore/mlx#4452). One xgrammar warning, a token offered to an
already-terminated matcher, is noted for the campaign's full runner log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er a grammar

The five-model MLX think-off on the payload-swap image finished with no
OOM and no error. A mechanical diff of main's generator tables against
the fold's leaves one contract flip (gemma4:12b bcadvnorm1) and small
moves on qwen3.6 and gemma4:31b.

Every suite request carries a grammar. Upstream 4986e92 lets a
constrained request draft, and main never did, so the fold drafts in
every completion. Record that mechanism, why the xgrammar
terminated-matcher warning is benign, the throughput context, the
knife-edge history of name_bbox, and the attribution A/B now running
(fold, nodraft, main).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
glennneuber and others added 12 commits September 12, 2026 06:28
…hat moves outputs

Gates 034e to 034i attribute the fold's MLX think-off differences from
main, using four images: the fold, nodraft (the fold with main's
one-line gate restored), main, and mainmlx (main with only the fold's
MLX libraries).

Cold, every image gives the same answers apart from knife-edge cells
that already flip on main. In the full suite, nodraft and mainmlx
reproduce main's answers and answer lengths on all 27 tests, while the
warm-drafting fold flips bbox_contract_adv_norm1 in two of three runs.

On qwen3.8 the fold's drafting also leaves MLX memory that no tracked
array accounts for: +6.8 GiB by request 28, not bounded. Admission
cannot see it. A decision is needed on keeping upstream's drafting
under a grammar; the recommendation is to restore main's gate until
the memory growth is fixed. MLX think-off cells are not bit-
reproducible, whatever ADR 0012 section 4 says.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
maxusai/ollama:sync-0.34.0 (0.33.3-dynres-28-gfbedf50) carries the payload-swap
image's MLX payload byte for byte and the same Go code, so the MLX gates measured
there hold for it. On the real image the format checks, the idle-core check and
the preflight cuda-dynres-903 pass. The deploy stays held, pending the decision
on drafting under a grammar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gate-sync034h's preflight passed with mlx_payload_pin SKIPPED, because
cuda-dynres-903 recorded no mlx_build although these images ship
mlx_cuda_v13. So the fold's own MLX bump went unasserted.

Pin it to this fold's MLX_VERSION, ce916dbb, and widen the profile test
to enforce a pin on any profile that serves the MLX payload whatever its
platform says. PR #294 adds the same key on main with a523d60's
37c26e57; the merge keeps this value, since the payload here is the
bumped one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
probe-mainleak.sh sent the same 18 text requests to main-a523d60b
twice on qwen3.8: without a format, where main drafts (18 of 18), and
with one, where it does not (0 of 18). The untracked gap grows only in
the drafting arm, to +0.72 GiB by request 17, while the other holds
-0.11 to -0.14 GiB.

So the leak is in the speculation path main already ships, and
production meets it today on think-on and format-less requests. The
fold widens its reach to structured output rather than introducing it,
and restoring main's gate limits the leak without removing it. Record
that in the decision, since it changes what either choice buys.

Also correct the peak-memory median to the committed generator's
definition (2.7, not 2.9: statistics.median averages the two middle
values) and cite the promoted generators from #295 instead of the
scratch scripts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fold's own preflight is the run that exposed this: pinning
mlx_build on cuda-dynres-903 made gate-sync034h's preflight FAIL with
"no MLX engine-init line in the log window", because a CUDA preflight
loads llama.cpp models and no MLX runner starts.

Take the fix from #294: read the version string out of the shipped
libmlx.so when no engine-init line appears, keeping the live line as
the first source. The pin here stays ce916dbb, this fold's MLX.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Re-ran the preflight on maxusai/ollama:sync-0.34.0 with the pin and the
payload fallback in place: 21 pass, 7 skip, and mlx_payload_pin reports
"MLX build ce916db matches the measured payload (0.32.2-37-gce916db),
from the shipped libmlx.so". The 06:40 run was 20/8 with that check
skipped.

Record why the second source was needed: a CUDA preflight loads
llama.cpp models, so the engine-init line the check reads never
appears, and pinning alone failed a correct payload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… knob

Glenn: align with upstream, keep ours as an option, post nothing to
ollama/ollama.

speculate.go now decides through draftingEnabled, and
OLLAMA_MLX_DRAFT_UNDER_GRAMMAR=0 restores main's gate. An unrecognised
value keeps upstream's default and warns: a typo that silently disabled
drafting would surface later as "the fold is slow" with nothing pointing
back here.

ADR 0033 carries the divergence, since its own consequence section said
grammar-aware speculation would have to be rebuilt on upstream's engine
if the depth controller were ever fixed. The fold's decision section
records what the choice buys and costs, including that the memory leak
is main's too and the knob is a mitigation rather than a fix.

Tests: the env parser table, and the gate across grammar, logprobs and
top-logprobs requests with the knob both ways. go vet and the full
x/mlxrunner suite pass in the golang:1.26.0 container.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
OLLAMA_MLX_DRAFT_UNDER_GRAMMAR sits beside OLLAMA_GPU_OVERHEAD and
OLLAMA_MLX_MEMORY_LIMIT in the vision-suite README, with when to reach
for it: a long-running qwen3.5-family server serving structured output,
where drafting leaves memory admission cannot price.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
probe-knob.sh on maxusai/ollama:sync-0.34.0-knob (the candidate with
this branch's binary layered on, payload byte-identical): with the knob
unset, four format requests and two without one all draft; with it 0,
the format requests draft nothing and the others still do; with an
unrecognised value drafting stays on and the runner warns. All 200.

So the switch gates structured output only, which is what the decision
above claims. Note that the clean candidate image predates the knob:
the switch needs the next full build, or this swap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The real image had been covered by P1 and P2 — payload byte-identical,
Go code equal — rather than by a campaign of its own. This is the
campaign: five models, think-off, no OOM, no error, nothing left
unconverged.

Against main its contract matrix is identical, 50 of 50 cells,
bbox_contract_adv_norm1 included: the knife-edge cell that came back ❌
on the swap image's run is ✅ here, which is what a knife-edge does. Two
of 70 T1 quality cells differ from either baseline, a 0.003 IoU move and
one 7 px code, and answer lengths sit inside the spread two runs of one
image already show.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PR #297 against main, opened 2026-09-13. Not merged, and the deploy
stays held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-reading the four existing trace logs — no new runs, no GPU — turns "memory
grows" into a unit and a cadence. The gap moves by whole 144 MiB units (the 48
layers' F32 [1 48 128 128] delta state at 3 MiB each) or not at all: 15 steps of
3 units and one of 4 over the fold's 28 requests, 6 steps of exactly 1 unit over
main's 17 drafting requests, and 16 zeros over main's gated arm.

It is not proportional to the drafting work, so it is a per-request exit path
rather than the round loop. The trie is exonerated more firmly — the tracked
total, snapshot bytes, paged-out bytes and snapshot count agree request for
request across the arms, and the gap keeps growing after the trie caps and the
tracked total goes flat. It is not driver-side either: against the pinned MLX
ce916dbb the CUDA backend makes no raw allocation outside event.cu, and its
graph cache is an LRU capped at 400.

That leaves a full recurrent state whose Go handle is released while MLX still
holds a reference. The next probe needs a GPU, so the upstream report stays held.

Also fix the gofumpt violation golangci-lint caught in the gate test: one table
element per line. The macOS "[setup failed]" alongside it was a cascade — the
matrix's fail-fast cancelled the Darwin payload build mid-step, and go test then
ran anyway under if: always() against an unbuilt app/ui embed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

6 participants