Skip to content

Gemma 4 12B on the native Metal engine: text, images and its MTP assistant (stacked on #879) - #975

Open
BobClawblaw wants to merge 55 commits into
ashhart:mainfrom
BobClawblaw:gemma4-12b
Open

BobClawblaw wants to merge 55 commits into
ashhart:mainfrom
BobClawblaw:gemma4-12b

Conversation

@BobClawblaw

@BobClawblaw BobClawblaw commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Gemma 4 12B (gemma4_unified) on the native Gemma 4 family: text, images, and Google's MTP assistant as its drafter. #322 step 2.

Stacked on #879. The first commit here is #879's head (21e8040e). Until #879 lands, only the commits after it are this PR.

What changes

  • Runtime geometry (families/gemma4/config.zig): the family reads the 31B's or the 12B's sizes from one Geometry, each admitted exactly. MoE, per-layer inputs, shared KV and per-module widths stay refused by name. The kernels already take each size as a define, so the 12B runs the 31B's kernels unchanged.
  • Registry and CLI: gemma4_unified (mlx-q4g64) is served by the same host, titled "Gemma 4 12B".
  • Checkpoint: tools/zig/gemma4_12b_convert.py makes it from Google's release (revision 707f0a3b). It quantizes the text weights with mlx-lm's converter to affine 4-bit g64, keeps the image embedder's ten tensors as stored (bf16, 100 MB) in model-vision.safetensors, drops audio, and writes back the checkpoint's own gemma4_unified config. Run twice, it gives the same tensors bit for bit.
  • MTP assistant (assistant.zig, assistant_weights.zig): google/gemma-4-12B-it-assistant at 46d4c6f1, made 4-bit at load. It has four q-only layers over the target's own last sliding and last full caches, from the last kept row, and chains of up to six drafts, every stream's in one command buffer. --drafter picks DFlash or the assistant by the drafter's model_type, each refused by name on the wrong target. Ordered embeddings are refused.
  • The embedder (families/gemma4/vision_unified.zig): the 12B has no image encoder. Following transformers' Gemma4UnifiedVisionEmbedder, each 48-px patch goes through LayerNorm, the 6,912 to 3,840 Linear, LayerNorm, the x and y position rows and LayerNorm, then the unscaled RMS norm and the 3,840 x 3,840 projection. It runs in bf16 at torch's rounding on the 31B tower's gemm_nax GEMMs, with one new kernel, g4u_norm (a LayerNorm that can add the Linear's bias or the position rows on the way in). About 2 ms an image.
  • The processor (image.zig): sized as for the 31B (PIL's bicubic resize, 256 to 280 tokens), then cut into 48-px patches, which is the order patches_merge gives. The 31B's 16-px path is unchanged.
  • The model holds a Tower, either the 31B's encoder or the 12B's embedder. The prompt, backend and host call it, and the language model's side (image rows over their tokens, both-way attention on sliding layers) is the 31B's.
  • gemma4_vision_fixtures.py and tf-gemma4-vision check either model, choosing by the checkpoint's config.

Checks (M5 Ultra, macOS 27)

  • The 31B, on the commits before the 12B's images (74005ebe): decode, long-prompt (8k-252k), lanes, DFlash and image receipts are byte-identical to 21e8040e's. After them, its tower check is below.
  • 12B decode against @gilby's gemma4_unified at 6b0ead7a: three of four prompts equal over 128 tokens. The fourth parts at its second token on a near-tie. Its last rows sit as close to mlx-lm's as the reference's do, as for the 31B.
  • 12B long prompts against mlx-lm: 8k and 128k equal. 32k, 64k and 252k part at near-ties (an exact tie at 252k).
  • tf-gemma4-lanes with the assistant: equal to plain greedy at one, four and eight streams, and seeded sampled at one and four.
  • Served: drafted replies equal undrafted ones, including 32k-token thinking replies. Structured output obeys 18/18. Prompt reuse resumes an 11.5k-token conversation in 0.09 s. Speeds are in the recipe.
  • 12B images, tf-gemma4-vision on five drawn images (20 x 15 to 1200 x 800 and 180 x 1400), against transformers 5.17 and torch:
    • The patches equal the PIL processor's patches through patches_merge, bit for bit.
    • Each stage is as close to the fp32 run as torch's own bf16 run: the projected rows 0.30% relative error averaged over the images, torch's 0.31%.
    • The checkpoint's own processor (torchvision's resize) is 0 to 2 levels of 255 from PIL's.
  • 12B images served with the MTP assistant: tools/zig/vision_served.py passes 19/19 checks (drafted equal to "draft": false, concurrent equal to alone, a later turn resuming past its image, the refusals, JPEG). Questions about the images answer right, and mlx-vlm 0.7.2 on the same checkpoint gives the same pixels and the same five short answers. An 80-token description parts from mlx-vlm's at its third token, on a near-tie in mlx-vlm's own logits ("design" -1.625, "illustration" -1.75).
  • The 31B's tower, on the head: tf-gemma4-vision on its five images: patches bit for bit, the tower 7.6% relative error against torch bf16's 7.8%, as its recipe states.
  • zig build test, test-kernel-contract, test-golden (0 unexpected), fuzz-cortheon, fmt and the lean check (0 new) pass.

Not in this PR: audio (the server takes none) and mixed oQ widths (#322 step 3).

Recipe: docs/recipes/gemma-4-12b-native.md.

…by's gemma4-dense Python engine

config.zig pins the 31B 4-bit geometry and names each layout it refuses (MoE, the 12B unified, per-layer inputs,
shared KV, keys apart from values, per-module widths); tools/zig/gemma4_gate.py records greedy ids and top-two
margins from gilby's RowDecode (lane kernels) through either prompt path, and compares a native run with them.
…ane projection bit-equal on every linear

tools/zig/gemma4_dump.py writes each decode kernel's inputs and outputs for a sliding and a global layer at 1, 2
and 16 rows; tf-gemma4-kernels runs core lane_projection with lane_qmm's K split on the 31B's stacked q/k/v, o,
gate/up, down and tied head: every output's bits equal the Python engine's.
…de kernels and the MLX ops around them

Ports gilby's qkv_prep, attend (partials and merge), attn_tail and dense_tail (ashhart#360) with their rounding points,
plus MLX's looped RMS norm, the scaled 4-bit embedding, mlx_lm's geglu and logit soft-cap as bf16 ops, and the
global layers' proportional RoPE table from MLX's precise pow. tf-gemma4-glue checks every output against the
Python engine's at 1, 2 and 16 rows, sliding and global layers: all bits equal.
…it for logit with gilby's RowDecode

weights.zig packs each layer's q/k/v, o, gate/up and down stacks and the tied head once for the core lane
projection (seven shapes); state.zig keeps a ring per sliding layer and a growing cache per global one; forward.zig
runs a window through the 60 layers in one command buffer. tf-gemma4-run on four chat prompts, 128 tokens each:
every token and every top-two logit equals the Python engine's (gemma4_gate.py check).
…every width, and 8k/32k receipts

tf-gemma4-run takes a prompt window width: windows of 16 give the one-row path's tokens and margins on every chat
prompt. Against the oracle's mlx_lm prompt forward, the first token is equal at 8k and 32k and 49 tokens are equal
at 32k; at 8k the two diverge at token 32 on a 1-2 bf16-step tie, where the Python engine's own decode-path prompt
diverges from its mlx path too, and the native run equals that decode path's tokens and last-row logits bit for bit.
gemma4_gate.py gains a prompts command (8k/32k chat prompts from the repo's text) and saves last-row logits.
…stream equal to its plain reply

forward.run takes several streams' windows in one forward (q/k/v prep, attention and the cache write per stream at
offsets, every other kernel over all rows); backend.zig puts it behind the lanes Backend, windows up to 16 rows,
keep rolling a cache back by its length. tf-gemma4-lanes: plain and copy-drafted single streams and 4- and 8-stream
shared rounds equal the oracle's 128 tokens; on copy-heavy prompts (11.1 and 3.3 tokens a round, drafts landing and
rolled back) one stream and 8 mixed streams equal the engine's own plain replies.
…eedy replies equal to the Python reference

native/gemma4.zig serves the dense 31B on the lane host (context 32,768 by default); metal.zig registers gemma4 in
mlx-q4g64 and refuses --speed-up and --learn for it by name; the tests that took gemma4 as an unserved type use
gemma4_unified, which still is. gemma4_gate.py served: on an M5 Ultra the four prompts' greedy token SHAs equal the
oracle's alone, with drafts and four at once; thinking stays in reasoning_content and a tool call parses.
docs/recipes/gemma-4-native.md is the recipe; docs/credits.md credits gilby's ashhart#360 and cshintov's #10.
…s at 821 GB/s

The core lane projection's reg variant (4-bit groups of 64 in tensor-op registers, group sums precomputed) gives
lane_qmm's bits on every linear and width (tf-gemma4-kernels --reg) and reads the 31B's 17.3 GB a step at 821 GB/s,
against 662 for the plain lane kernel. One-row decode goes from 35 to 42 tokens a second (tf-gemma4-bench); the
four prompts' tokens and top-two logits still equal the oracle's.
…ion, the same bits

A window's keys and values go into the cache before attention, and attention runs over it in 16-row calls reading
every key from the cache (the kernel takes its first new key from the meta), so a prompt window can take the reg
projection's 128 rows in one weight read. Tokens, top-two logits and last-row logits equal the 16-row and one-row
runs; 8k prompts go from 340 to 576 tokens a second and 32k from 237 to 330.
…ned copies of the MLX tensors

prompt.zig runs a chunk's projections on affine_mm (the decode glue for the rest, the head on its last row only);
affine_mm reads words as u32, and checkpoint tensors can sit at any byte offset in a shard, so weights.zig copies
each linear's tensors to aligned storage. The sliding ring holds the window plus a chunk. Against the oracle's mlx-lm
prompt receipts: first tokens equal at 8k and 32k, 49 tokens at 32k, the short prompts as the Python engine's own
decode path; prompt attention is still the per-row decode kernel (637 tokens a second at 8k).
…128-wide slices across simdgroups

gemma_prompt_attention takes 16 query rows of one head a threadgroup: each simdgroup scores keys over its 128 columns
of the head (two on sliding layers, four on global ones), the slices' partial scores add in slice order through
threadgroup memory, every simdgroup runs the same online softmax under the causal and sliding-window masks, and each
keeps its slice of the output. Prompts go from 637 to 1,184 tokens a second at 8k and from 351 to 1,008 at 32k
(mlx-lm: 1,217 and 1,233); first tokens equal the oracle's mlx-lm receipts at 8k and 32k, 49 tokens at 32k.
…it prompt path

backend.zig's prefill runs prompt.zig's chunks; the lanes check and the served gate now take the engine's own replies
as the reference (its prompt path follows mlx-lm's arithmetic, not the oracle's decode path). Copy-drafted, 4- and
8-stream rounds equal the plain replies; served on an M5 Ultra, greedy SHAs equal tf-gemma4-run's alone, drafted
and four at once, with thinking and tool calls parsed.
… key head's query heads as one tile

gemma_decode_attention packs a key head's eight query heads (two decode rows) into one 16-row tile, so each key and
value is read once a token, splits the keys into 256-key chunks across threadgroups and leaves partials for
gemma_attention_merge. Decode goes from 36 to 38 tokens a second at 8k and from 30 to 35 at 32k (mlx-lm: 42, 38);
16-row windows at 32k run 2.5 times faster. Every row keeps its bits at any window width, and drafted, 4- and
8-stream replies equal the plain ones; the oracle's decode now differs only at near-ties (one bf16 step).
…xt prompts up to 252k in the gate

The global layers' decode chunks come from prepare (256 keys a chunk), so the partials' scratch is sized by them and
by the window on sliding layers: about 1 GiB at the 262,144-token context, not 4. gemma4_gate.py prompts builds 8k,
32k, 64k, 128k and 252k chat prompts from the repo's docs and source, no passage repeated.
… cache, the same bits

The norm, merge, attn_tail, geglu and dense_tail kernels write the next projection's group sums in tf_lane_sums'
order, so the reg lane kernel runs on them without its own sums dispatch, and qkv_prep writes keys and values straight
into the cache. GeGLU runs 64 threads a group (4.9 us a call at one row, against 23.9 for a thread a group). One-row
decode goes from 42 to 43 tokens a second; tokens, margins and long-prompt logits are unchanged.
…s as it makes them

Backend.prefill_features runs the prompt pass with a FeatureSink: the Drafted wrapper opens the drafter first and
absorbs each chunk's rows but the prompt's last (that one goes with the first token, as before), so a long prompt's
taps are never held whole. Targets without it keep the one absorb after the pass.
… one stream, the same tokens

families/gemma4/dflash.zig runs z-lab/gemma-4-31B-it-DFlash (DFlash v1: five Qwen3-style layers on six tapped target
layers, blocks of the pending token and up to 15 mask tokens, the target's embedding and tied head) behind the lane
core's Drafted wrapper. dflash_weights.zig admits that one geometry and quantizes the bf16 linears at load to 4-bit
groups of 64 by MLX's affine rule (a plain min/max rule lost half the acceptance on the taps' outlier channels); the
linears run on the lane projection, the norms on the target's fused norm kernel with the projections' group sums, the
SwiGLU on a sums-writing copy of core/draft_ops'. Each stream keeps context keys and values in a 2,047-slot ring for
the sliding layers and a growing buffer for the full one; an absorb's command buffer is committed and settled by the
hold that follows.

The target writes its tapped layers' rows from dense_tail, hands prompt chunks' rows through prefill_features and
verify rows through features, reports its window forward's cost by rows (timed at load) so the depth rule prices
drafts, takes 128 rows of streams a forward, and draws greedy tokens with the core's GPU argmax.

tensorfold-native serve takes --drafter for gemma4; tf-gemma4-lanes runs the drafted cases with GEMMA_DRAFTER (and
GEMMA_DRAFT_TRACE prints drafts and timings). On the M5 Ultra one stream decodes 6.4, 6.4, 2.1 and 6.7 tokens a round
on the four prompts (150-180 tok/s; plain 43) and 10.2 and 7.8 on copying replies (190 tok/s), equal to plain; eight
streams 180-195 tok/s together. The served gate passes against the engine's receipts (the decode attention's 256-key
order ties one near-tie the oracle splits). Credit to z-lab for the drafter and its MLX reference.
…b pass; wide forwards' costs timed at load

holdFn concatenates the streams' blocks (up to 128 rows) and runs the drafter once, attention a block at its rows:
eight streams' blocks take 9 ms instead of 27. absorbFn packs the streams' rows into passes the same way, fc a piece
and the rest together. The drafter reports batched head costs. The backend times forwards at 32, 48, 64, 96 and 128
rows as shared_costs, since the rule's extension of the window table priced them at twice the truth; GEMMA_DRAFT_TRACE
also prints each verify's rows and time and the test each case's engine steps. A two-blocks-a-threadgroup variant of
the reg lane kernel was measured and dropped: wide forwards are bound by tensor-op compute (0.8 ms a row), not weight
reads. Eight streams: 203-208 tok/s (from 180-195); drafted replies still equal plain, the served gate passes.
…ong-context table to 252k

One forward of a 252k-token prompt through mlx_lm builds 252k x 252k masks and took the Mac down; the oracle's
mlx prompt path now feeds 2048-token chunks as mlx_lm.generate does, and the 252k receipt peaks at a quarter of
the RAM. tf-gemma4-run reads ids files up to 4 MiB (the 252k prompt is 1.7 MB of JSON).

Native equals mlx-lm for 49 tokens at 32k, 64k, 128k and 252k; at 8k they part at token 25 on an exact tie in
mlx-lm. The recipe tabulates prompt and decode speed for both at each size.
…ead; maxes and probabilities cross once a block

The old kernel sliced the head across simdgroups and summed their partial scores through threadgroup memory
every 32 keys, with two barriers each time: a third of the attention time at 32k (234 ms of a 512-row chunk's
620; the GEMMs are 376 and the glue 13). Now each simdgroup scores its own 64 keys of a block over the whole
head, reading the tile's Q from L1, and the tile exchanges only its row maxes and fp32 probabilities once a block;
each simdgroup then accumulates its own 128 output columns. The attention stage at 32k is 185-191 ms, what mlx-lm's
fused attention costs at that shape. Fully unrolling the 32 fragment steps ran 5x slower (the code outgrew the
instruction cache); bf16 probabilities were 3% faster but put an 8k prompt's logits 0.5 apart at an mlx-lm tie.

Value reads stop at the tile's last written key: a value row never written multiplies a zero probability into
NaN for every row of the tile, which the served gate hit once the block grew past 32 keys.

tf-gemma4-bench GEMMA_STAGES[=depth] times a chunk's GEMMs, attention and glue apart (prompt.layer takes a
Stages mask). The recipe's long-context table is remeasured: 8k, 32k and 64k equal mlx-lm for 49 tokens; 128k
and 252k each part once where mlx-lm's top two logits tie.
…cale and bias fetched a step ahead

The same multiplies in the same order, so every caller's bits stay: Gemma 4's last-row logits at 8k to 252k are
identical before and after. A 512-row chunk's 60 Gemma 4 layers go from 382 to 344 ms (mlx-lm's quantized matmuls
take 302): the tile's 16 x 16 fragments load as two 8-byte vectors a lane instead of eight scalars (6%), and each
step's 16 bytes of codes with their scale and bias are fetched during the step before, so the barriers no longer wait
on memory (4%). Both keep their scalar form under TF_SIMD_FRAGS, where a lane's columns are not contiguous.

Measured and set aside: taller tiles of 128 and 256 rows (every simdgroup waits on the dequantizing ones: 414 and
582 ms), a 64 x 128 tile with 16 x 128 a simdgroup (registers spill: 752), eight simdgroups on a 64 x 128 tile (410),
and a double-buffered tile (occupancy: 397). The exact-codes epilogue's scale and bias FMAs bound at about 75 ms.
…oring 32 keys and owning 64 output columns

The kernel streams keys and values, so each load serving twice the rows is what moves it: a 512-row chunk's 60
layers at 32k go 194 -> 138 ms, at 128k 810 -> 527 (mlx-lm's fused attention about 161 at 32k). The probabilities
cross threadgroup memory 128 keys at a time, fp32 as before. Vector fragment loads, fetching a step ahead, and
sparing interior blocks the mask and the rescale all measured as noise on the 16-row kernel; a prefetched set of
fragments cost occupancy and ran up to twice slower.

Only the row sums regroup. Long prompts: 8k 1142 -> 1187 tok/s, 32k 996 -> 1054, 64k 844 -> 949, 128k 648 -> 806,
252k 390 -> 527 (mlx-lm 1305/1139/944/651/440). The two tie-breaks against mlx-lm's equal top logits move from
128k and 252k to 8k and 128k; decode receipts, lanes with and without the drafter, and the served gate are unchanged.
…nd the row sums go

The tile held exact codes (4-bit ones plus 128) and every 64-deep group's partial product was scaled and bias-corrected
in a scalar epilogue, the bias times fp32 row sums from a kernel of its own. Now each thread writes its 32 weights as
bf16(fma(scale, code, bias)), one rounding, as mlx-lm's quantized matmul dequantizes, and the tensor unit accumulates
over the whole depth with nothing after it: a 512-row chunk's 60 Gemma 4 layers take 284 ms on the GEMMs where they
took 344 (mlx-lm's 302). tf_affine_row_sums, rowSums/rowSumsAt, the sums strides and every caller's sums buffer are
removed (Gemma 4, GLM, the tests and mm_bench).

The prompt path's weights now round as mlx-lm's do while the lane kernels keep the codes exact, so prompt receipts
move: affine_ref checks affine_mm against the rounded weights (its own reference stays exact for simd_qmm) with the
tensor unit's 16-deep chain as the bound, and glm_rows_classes' fp64 check takes the rounding. Gemma 4 long prompts:
8k 1362, 32k 1202, 64k 1047, 128k 892, 252k 564 tok/s (mlx-lm 1305/1139/944/651/440); equal at 32k and 64k, parting
at 8k, 128k and 252k where mlx-lm's top two logits tie. The four decode receipts all equal the oracle now; lanes with
and without the drafter and the served gate are unchanged.
…jections' prologues on one-row windows

A plain token at short context goes from 23.7 to 20.3 ms on the M5 Ultra (42 to 49 tokens a second; mlx-lm 46), 45 at
8k and 40 at 32k. The lane projection's K split is now the one each shape streams fastest at on one row (o and down 4,
the gate/up stack and the head 8), another fp32 summation order than the reference's: tf-gemma4-kernels keeps the
reference split bit for bit and reports the decode split, where a handful of outputs a linear move by one bf16 step.

The core reg lane kernel takes a family prologue and epilogue (Fused): Gemma's dense tail runs as the next layer's qkv
prologue, the attention tail as the gate/up projection's with geglu its epilogue (the stack's rows interleaved in 16-row
blocks), and the down projection sums its own input; seven dispatches a layer rather than ten, the same bits as apart,
worth 0.2 ms. tf-gemma4-bench times the decode stages, each shape plain and fused, and the glue kernels alone.

Receipts: decode prompts equal the oracle but the story at a tie it takes the other way; long prompts equal at 32k, 64k
and 252k, apart at 8k and 128k on mlx-lm's own ties; lanes and DFlash streams equal; served gate passes with its
thinking budget at 800 tokens (the shifted reasoning ran past 400).
…s and, with one key chunk, merges itself

On one-row windows gemma_attention_partial takes gemma_qkv_prep's work (FUSE.x): its simdgroups norm and rope the
threadgroup's query heads from the stacked row, and the last chunk's threadgroup writes the row's key and value to the
cache; with one chunk (FUSE.y) it writes gemma_attention_merge's output and sums itself, exp(top - top) being 1. The
prep's lane layout and reductions are the prep kernel's, so the bits are the same, and the dispatches a sliding layer
go from seven to five or six: a plain token at short context is 19.7 ms (51 tokens a second; mlx-lm 46), 45 at 8k
and 40 at 32k. Global layers keep their prep and merge. Receipts as before: decode, long, lanes, drafter, served.
…ing and first-fetch order

The fused hook moved the first weight fetch ahead of the tile setup and indexed tiles by simdgroup for every reg
pipeline. Unfused pipelines now compile main's code path again (one tile a threadgroup, the first group's codes, pairs
and sums fetched together after the setup), so models without a prologue see no change from it; Gemma 4's fused
one-row decode keeps its bits (tf-gemma4-bench) and its fixtures (tf-gemma4-kernels), and its rate is unchanged.
BobClawblaw and others added 23 commits October 15, 2026 17:58
…d an image may be pieces with tokens around them

The server and engine_api half of step5-native's aa221b1, the same text, so the branches merge clean: Info.vision's
part_type is the content part type the chat template reads ("image" for most HF templates, Gemma 4's among them), and a
PreparedImage may carry pieces, each its tower input and the tokens its placeholder expands to around its rows. Gemma
4's template writes <|image|> alone, and its processor puts the image's rows between boi and eoi.
…e blocks on sliding layers, and prompt reuse

serve --vision loads the checkpoint's image tower (vision_tower., embed_vision.: 1.1 GB, packed apart from the language
model) and reads PNG and JPEG data URLs. families/gemma4/image.zig prepares an image as transformers'
Gemma4ImageProcessorPil does: the largest aspect-preserving size, sides multiples of 48, whose 16-pixel patches fit 280
tokens, PIL's bicubic resize, then np_rescale's float64 1/255. families/gemma4/vision.zig runs Gemma4VisionModel on
gemm_nax's tensor-unit GEMMs at torch's per-op bf16 rounding (kernels/metal/gemma4/vision.metal): the patch projection
and the x and y position rows, fp32 RMS norms times their weights, per-head q, k and v norms, the axial 2D RoPE (theta
100, x on dims 0-35, y on 36-71), attention at scale 1 as batched GEMMs and a softmax, GELU-tanh gating, the 3 x 3
pooler and standardization, then the 4-bit projection on core affine_mm: about 90 ms an image.

In the language model an image's rows replace its tokens' embeddings unscaled, and on the 50 sliding layers they see
each other both ways (use_bidirectional_attention "vision"): gemma_prompt_attention takes each row's last key (HI, its
own position for text, so text prompts keep their bits) and global layers stay causal. A prompt chunk never cuts an
image: a cut that would moves before it, or past it when the chunk starts there.

Prompts are reused between requests (snapshot.zig): a kept state is each sliding ring's last 1,024 keys and values,
the global layers' so far, and the DFlash drafter's context for the stream (dflash.zig keepLane, resumed by its first
absorb), kept and resumed only at the request's planned chunk starts. A drafting stream does not resume a state kept
without the drafter's context, and none is resumed inside an image. The budget is cache_fit's beside every stream's
caches, 16 GiB by default.

Checked: tools/zig/gemma4_vision_fixtures.py (transformers 5.17 and torch) and tf-gemma4-vision on five drawn images,
20 x 15 to 1200 x 800 and 180 x 1400: patches equal the PIL processor's bit for bit; the tower's rows are as close to an
fp32 run as torch's own bf16 run (7.6% relative error averaged over the images, torch's 7.8%). Served with the drafter
on an M5 Ultra, an image described in 80 greedy tokens equals mlx-vlm 0.7.2's reply character for character from the
same 287-token prompt, drafts on and off; tools/zig/vision_served.py passes its 18 checks on a fresh server (drafted
equals plain, concurrent equals alone, a later turn resumes past its image, another image resumes only the text before
it, the refusals); a 3,336-token text conversation resumed 3,329 tokens and one with an image 3,584 of 3,607, each
answering as its first pass did. tf-gemma4-run's decode and 8k-252k receipts are byte-identical to 64f1895's.
zig build test, fmt and the lean check pass.
…, MLX's and mlx-lm's ops, transformers' tower, z-lab's DFlash

glue.metal ports gilby's gemma4-dense reference (a6ff795: RowDecode, _EMIT, qkv_prep, the tails and attention) and,
where it calls MLX or mlx-lm, their kernels' arithmetic: gemma_rms_looped is MLX's rms_looped (rms_norm.metal, the
kernel mx.fast.rms_norm runs past 4,096), gemma_embed MLX's dequantize, gemma_geglu and gemma_softcap mlx-lm's
gemma4_text ops, gemma_rope_freqs its ProportionalRoPE frequencies (MIT, Apple). The image tower and image.zig follow
transformers' modeling_gemma4 and Gemma4ImageProcessorPil (Apache 2.0, the HuggingFace Team); the drafter z-lab's MLX
DFlash model (MIT, Z Lab).
…rafter's held drafts to the grammar

Every response_format request on Gemma 4 with drafts on failed with HeldDraftsUnreadable: the lane core reads a grammar
stream's held drafts through the backend's held hook, which the wrapper did not set. It now reads them from the drafter
(fillers: the pending token, as verify writes them). A drafted_test case runs a grammar stream through the wrapper:
drafted equals plain, every token inside the grammar; it fails without the hook.
…holds

The checkpoint, the packed weights, both scratches, the drafter's weights and buffers and the image tower's join one
set; a stream's caches come and go with it. Served on an M5 Ultra, a 1-token request after 2/6/30/120 s idle took
0.48/0.47/0.42/0.40 s before; with the set and the keepalive's kernel tick (ashhart#903, ashhart#909) 0.11/0.11/0.11/0.12 s, where the
tick alone gives 0.35-0.61 s and the set alone 0.11-0.66 s.
…d, prompt reuse and image timings, the idle first token

Structured output is enforced with drafts on (the harness's check: 18 of 18), so the API doc lists Gemma 4 among the
native engines that enforce it; tool_choice required or a named function gets HTTP 400, as Gemma's template writes its
own call markup. The recipe adds served first-token times: 7.7 s fresh and 0.22 s resumed for an 11,481-token
conversation, 8.1 s and 0.12 s with an image, an image's 270 ms. Known issues: the first request after six or more idle
seconds takes 0.4-0.7 s until the keepalive's tick does GPU work.
…see the pending token

The drafts wrapper's held hook cleared a stream's filler count when a grammar read it, so a verify of the
same held window would read the head's chain instead. The count now ends only at the verify or the next
request. The grammar tests move to drafted_grammar_test.zig with a case that fails without the fix, and the
two whole-restore cases share one fixture.
…ales and biases per group of 64

The tower took embed_vision.embedding_projection's scales and biases without checking their dtype, rank,
shape or byte count; a malformed checkpoint is now refused by name before a kernel reads them. A unit test
covers f32 scales, groups of 32, short bytes, a flat tensor and wrong words.
ashhart#598 has landed with main's own image path, so Gemma 4 takes it: the server's wrap writes boi and eoi around an
image's rows (the pieces this branch added are gone), images decode through core's picture decoder, and the residency
set is Resident.hold's, whose keepalive tick runs a copy on the GPU. Info.vision keeps part_type, now carried by the
messages' Media, since Gemma 4's template writes <|image|> only for "image" parts. LFM2's prompt GEMMs take the core
affine_mm without row sums, as every other caller does since its weights are dequantized to bf16 in the tile. Both
sides load the tile's fragments in two 8-byte reads; this branch's am_tile_frag stays, since main's frag_get_tg form
of the same loads made Gemma 4's prompt GEMMs 11% slower (315 against 283 ms for a 512-row chunk at depth 8k).
…utes: each weight bf16(fma(s, q, b)) in the tile

The contract's affine family gains that form (MLX's quantized matmul loader), and affine_mm's specs take it without
row sums: its launch records are the 9,216 threadgroup bytes it now uses, and the row-sums kernel's entry goes. Its
known finding, NaN for an infinite input from the +128 code offset, passes now and is dropped. tf-tile-check holds
the tensor rows to the float64 bound alone: they round each weight as MLX does, so they no longer beat the exact-code
tiles (worst 2.46e-3 against 2.21e-3 of the terms' magnitude at 4 bits). A server test covers a template that reads
"image" parts: normalized twice, the part keeps that type, and the server's wrap lands around the image's rows.
…set and main's keepalive, HTTPS image URLs, Pillow's pixels, timings measured again
…12B's unified text model admitted exactly

config.zig names two layouts (dense31b for gemma4, unified12b for gemma4_unified with gemma4_unified_text) and
matches every size against the one its model type picks; the family, its tests and the tower's copy into the
prompt read the Model's geometry, per-layer arrays hold max_layers, and DFlash refuses a drafter for another target.
…ts log and refusals naming the geometry

The registry admits gemma4_unified beside gemma4 on gemma4.zig's host and the hub titles it Gemma 4 12B; the
tests that took it for an unserved type now use gemma3n. --vision on a checkpoint without a tower is refused by
name, and a DFlash drafter for another target is refused as the 31B's.
…it g64) and the gate's oracle for it: gilby's gemma4_unified
…h q alone, the assistant's input rows kernel, the 4-bit quantizer apart from DFlash's bf16 check
…de 4-bit at load, chains over the target's last sliding and full caches from the kept row
…pe, each refused by name on the wrong target, and tf-gemma4-lanes checks either, greedy and seeded
…served checks and speeds; the 31B recipe and notices name it
…f16 in model-vision.safetensors; the language model's shards unchanged
… 48-px merged patches, the model's Tower either input; tf-gemma4-vision checks both
@BobClawblaw BobClawblaw changed the title Gemma 4 12B on the native Metal engine, with its MTP assistant (stacked on #879) Gemma 4 12B on the native Metal engine: text, images and its MTP assistant (stacked on #879) Oct 16, 2026
… not returned as a slice of a stack temporary (the 31B with --vision trapped at startup)
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

A fix on top, 393dccae: with --vision, the 31B stopped at startup with a trap (exit 133). The image input's working buffers came back from Tower.buffers() as a slice of a stack temporary, which the residency list then read after it was gone. They are now appended to the list directly. The 12B's image runs passed by chance.

Checked on 393dccae, M5 Ultra: the 4-bit 31B (DFlash) and 4-bit 12B (MTP assistant) each start with --vision, and tools/zig/vision_served.py passes on both, drafted replies equal to "draft": false. zig build test (204/204 steps), fmt and the lean check (0 new) pass.

@ashhart

ashhart commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw, the 12B embedder and assistant reuse the same Gemma family and shared drafter path.

We checked 393dcca. config.zig:163-170/209-213 casts EOS and context values before range validation; negative or oversized metadata can trap. We can fold named refusals and boundary regressions in at landing.

This follows #879 after its shared-arithmetic issue is resolved, then the 12B geometry, assistant and owned-image checks. Tensor-unit admission stays explicit. Please retain the divergent reference prompts and margins alongside actual drafted/plain, seeded shared-stream and image/cache comparisons on the final head. Three agreeing short prompts and tolerance-level image features are useful evidence, with the remaining fidelity cases still visible.

Thanks too for 393dcca and the M5 Ultra rerun: both models starting with --vision and vision_served.py passing with drafted equal to "draft": false closes the stack-slice lifetime at model.zig:40 and native/gemma4.zig:156.

Posted by Ash's AI Assistant Synthena

@ashhart ashhart mentioned this pull request Oct 17, 2026
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.

2 participants