Skip to content

cuda gemma4: the 12B's MTP assistant as the drafter (#1048 step 10) - #1181

Closed
jschmied wants to merge 20 commits into
ashhart:mainfrom
jschmied:g4-assistant
Closed

jschmied wants to merge 20 commits into
ashhart:mainfrom
jschmied:g4-assistant

Conversation

@jschmied

Copy link
Copy Markdown
Contributor

What this changes

--drafter google/gemma-4-12B-it-assistant drafts for the 12B on the native CUDA engine (#1048 step 10).

  • The assistant's admission is Metal's, refusal for refusal: AssistantForAnotherTarget, UnsupportedAssistantGeometry and the rest.
  • Its bf16 linears and tied head are made MLX affine 4-bit on the host, by Metal's group quantizer.
  • A hold runs every stream's chain of up to six drafts through shared operators only:
    • the target's embedding beside the absorbed row;
    • the pre projection;
    • four layers whose queries (q-only qk_rows) read the target's last sliding (46) and last global (47) caches at the kept row's position;
    • the norm tails and GeGLU, the head, the bf16 argmax, and the post projection into the next step.
  • The backend serves the drafter's tap: the last layer's rows before the final norm, from the forward that made them (generation-checked). It streams each prompt chunk's rows to the drafter (prefill_features).
  • The target's windows of 1 to 16 rows are timed at load, so the depth rule prices drafts against them.

Stacking. Based on #1174 (cuda-gemma4 @ 3686e1cd). This PR adds one commit on top of it: 0813e6ea (branch g4-assistant).

Receipt

  • Environment.

    • Machine: one GB10 (sm_121), CUDA 13.0, Zig 0.17.0.
    • Target: the MLX 4-bit (group 64) 12B.
    • Assistant: google/gemma-4-12B-it-assistant @ 46d4c6f1.
  • Exactness. Drafted replies equal the plain run's token hashes, 36 of 36. The plain run is 1 stream, no drafts, no prompt cache. The compared runs:

    • 1 stream;
    • 4 streams with the prompt cache, run twice (the second resumes at 5,080 / 3,144 / 975 tokens).

    Each covers 6 prompts, greedy and seeded sampling. After splitting the file, 12 of 12 again.

  • Speed (GB10). Alternating runs: four arms with a server restart each, warm-up, then three runs per arm.

assistant plain
1 stream (essay) 47.8 / 52.9 tok/s 30.6 / 30.6
4 streams 170 / 176 113.5 / 113.5

Single requests: essay 54.5 tok/s (2.29 tokens a round), code review 87.5 (3.76).

  • Predictions.
    • 55-85 tok/s at one stream: missed low on essay prose (47.8-52.9); code was above (87.5). Our ratios (prose 1.6-1.8x, code 2.9x) are close to Metal's published ones on an M5 (1.59x, 2.5x).
    • Draft step cost 0.5-1.5 ms: a 1-draft chain took 1.66 ms and a 6-draft chain 10.0 ms (about 1.67 ms a step), slightly above.
    • The first run, without the window-cost table, fell back to one draft a round (+57 %). The timed windows fix that.
  • Contract. No new kernel.
  • Tests. Full zig build test -Dnvcc=/usr/local/cuda/bin/nvcc: 225/225 steps, exit 0. lean_check: 444 problems = base (0 new). zig fmt is clean.

Not run, and why

  • Metal: no Mac in this run.
  • The 31B: the assistant is the 12B's; AssistantForAnotherTarget refuses it on the 31B (host test).
  • tools/bench_openai.py: the speeds above come from an equivalent client script with fixed prompts.

Gotchas

  • A hold read each step's picks back on the host (one sync a step). The follow-up PR (g4-assistant-graphs) moves that onto the device and captures the hold as graphs.
  • The assistant is 4-bit on the GPU, as on Metal. bf16 would read its 512 MB head every draft step.
  • --drafter-bits other than 4 is refused by name.

…d MLX's rounded rows

addRmsTail extends addRmsH for Gemma 4's sandwich tails: an optional RMS norm on the
branch before the residual add, an optional bf16 scalar on the sum (rounded first), and
a rounding flag that gives each norm MLX's bf16(w * bf16(x * inv)). With no option set it
is addRmsH itself, and the existing kernels' code is unchanged.

The kernels are registered with the kernel contract: their own check compares every
output with float64's value at the rounding points (a range over the fp32 inverse's
spread), the 64-value sums bit for bit with the fp32 tree, and four controls each drop
one rounding point.
tf_geglu_rows computes mlx_lm's gelu_approx(gate) * up as MLX's bf16 ops (Gemma 4's MLP),
and tf_swiglu_rows_round SwiGLU in tf_draft_swiglu's bf16 steps (Gemma 4's DFlash drafter),
both with the 64-value group sums of the stored outputs. swiglu keeps its kernel and launch.

Both are registered with the kernel contract: every output against float64's value at the
rounding points (a range over tanhf's or expf's ulps), the sums bit for bit with the fp32
tree, and twelve controls each drop one rounding point.
tf_qk_rows_once carries tf_qk_rows' arithmetic to head dim 512 and can norm v by a unit
RMS norm; tf_qk_rows_round adds MLX's rounding points, q and k = bf16(w * bf16(x * inv))
before RoPE, as Metal's gemma_norm_rope computes them. Shape.round and Shape.v_norm select
them, as does a head dim past 256; every other call keeps its kernel and its launch, and
tf_qk_rows and tf_qk_rows_w keep their code.

Both are registered with the kernel contract: their own check compares every output with
float64's value at the rounding points (a range over the fp32 inverse's spread and cosf's
ulps), and three controls each drop one option.
bf16(-2^-135) gave +0 where bf16 rounding gives -0 (0x8000). The tests now compare
u16 bits: signed zeros from underflow, subnormal ties, the normal boundary, overflow to
both infinities, and the random fp32 sweep against the fp32 path's bits.
EmbedRows.affine reads MLX's affine tables at 2, 3, 4, 5, 6 or 8 bits (groups of 32, 64
or 128) and, given a bf16 scale, multiplies each dequantized bf16 value by it and rounds
again: bf16(bf16(q * s + b) * scale), Gemma 4's embedding times bf16(sqrt(hidden)). With
4 bits, no scale and groups of 32 or 64 it is affine4Groups itself, and the existing
kernels' code is unchanged.

The 4-bit rows and the new kernel at each width are registered with the kernel contract:
every output bit-equal to float64's value at the rounding points, and two controls, one
without the bf16 rounding before the scale and one folding the scale into the group's
scale and bias.
…or one-value ranges

A range with an infinite end fell back to the float64 point's class: it accepted a zero
below a finite lower end and refused the infinite end itself. Every range is now checked
by its ordered ends (-0 below +0), a one-value range by its bits, NaN only for NaN. The
control is the 64-value input-norm row whose first output's range is [largest bf16, +Inf].
bf16(-2^-135) gave +0 where bf16 rounding gives -0 (0x8000). The tests now compare
u16 bits: signed zeros from underflow, subnormal ties, the normal boundary, overflow to
both infinities, and the random fp32 sweep against the fp32 path's bits.
bf16(-2^-135) gave +0 where bf16 rounding gives -0 (0x8000). The tests now compare
u16 bits: signed zeros from underflow, subnormal ties, the normal boundary, overflow to
both infinities, and the random fp32 sweep against the fp32 path's bits.
bf16(-2^-135) gave +0 where bf16 rounding gives -0 (0x8000). The tests now compare
u16 bits: signed zeros from underflow, subnormal ties, the normal boundary, overflow to
both infinities, and the random fp32 sweep against the fp32 path's bits.
…32 probabilities

window_rows.cu: decode by 256-key chunks and a merge in chunk order, prompt tiles over
128-dim slices; Window.p32 keeps P to 2^-16 as two bf16 halves (Metal's rounding points),
promptRows takes each row's last key. window_attention.cu and its callers are unchanged.
core/attention_range.zig: float64 at the rounding points and the range fp32 allows.
…ttention

window_rows.cu at head dim 512: decode partials with K and V tiles in shared memory, a
block for 4 query heads and a warp for each key run, merged in chunk order as at 256;
prompt tiles as at 256. No window over a linear cache to 262144 keys, GQA 8 and 16.
# Conflicts:
#	docs/kernel-contract.md
#	zig/build/cuda.zig
#	zig/src/core/contract/registry.zig
# Conflicts:
#	docs/kernel-contract.md
#	zig/build/cuda.zig
#	zig/kernels/cuda/contract_controls.cu
#	zig/src/core/root.zig
#	zig/tests/cuda/contract_tests.zig
# Conflicts:
#	docs/kernel-contract.md
#	zig/build/cuda.zig
#	zig/kernels/cuda/contract_controls.cu
#	zig/src/core/contract/registry.zig
#	zig/src/core/root.zig
#	zig/tests/cuda/contract_tests.zig
# Conflicts:
#	zig/kernels/cuda/contract_controls.cu
#	zig/src/core/root.zig
#	zig/tests/cuda/main.zig
…line limit

The merges put build/cuda.zig at 614 lines and the shared CUDA registry at 603. The five Gemma operator
contract tests (window rows, MLX activations, embed rows, qk rows) are one loop, and embed_rows' and
qk_rows' records move to gemma_cuda_records.zig beside act_rows'. Nothing a test runs changes.
Step 7 of ashhart#1048: a soft-capped head's logits, cap * tanh(x / cap), as MLX computes them in bf16 ops
(Metal gemma4/glue.metal's gemma_softcap): bf16(bf16(tanh(bf16(x / cap))) * cap), the division and
the product correctly rounded in fp32 and rounded to bf16 at each point, tanhf for tanh. Nothing in
it is Gemma's: any final_logit_softcapping head can call ActRows.softcap(s, x, out, rows, n, cap),
in place or not. It sits in act_rows.cu beside the MLX activations; the module's 30 other functions
keep their SASS on sm_80, 86, 89, 120 and 121.

core/act_ref.softcap is the float64 reference at those points (fp32 then bf16, as the kernel rounds)
and softcapRange its ends with tanhf within 2^-21 either way. Contract: tf_softcap_rows at Gemma 4's
head (1 and 16 rows of 262,144 logits, 3 of 4,096), inputs to +-80 around the cap; two controls, one
dropping x / cap's rounding, one tanh's (tf_ctl_softcap), both red at the shapes gate. Records for
256 and 128 threads from nvcc 13.0 (24 registers, 25 on sm_120 and 121).
GB10: SUMMARY 108 kernels: 108 pass gates 1-4, 0 fail; 37 controls: 37 red as planted, 0 missed;
1533 limit problems (the base: 107, 35, 1533); both launches pass limits live.
…ne 4-bit)

Gemma 4 (gemma4_unified 12B, gemma4 31B) on the native CUDA engine, every
launch a shared operator from ashhart#1048 steps 1-7: qlinear's affine-4 tiles,
embed_rows, the MLX-rounded norm tails, qk_rows, window_attention (sliding
ring 1536, global linear), act_rows' GeGLU and soft cap, tree_round's bf16
argmax and the keyed draws. No new kernels.

Config and widths are the Metal family's files, copied because a file belongs
to one module; a host test holds the copies equal but for their first lines.
Prompts run in chunks of up to 512 rows cut where Metal cuts, decode windows up
to 16 rows a stream and 128 a forward; prompt-cache states keep global layers'
positions and each sliding layer's last 1023. Other widths, images and drafters
(steps 9-12) are refused by name.
A decode forward's launches depend on its row count alone: positions and
slots are read on the device. Each row count (1 to 128) is captured at its
first use and replayed for every slot and position after;
TENSORFOLD_GEMMA4_GRAPHS=0 keeps every forward eager.

Replies are bit-identical to the eager forward (36 of 36 token hashes, solo,
four streams, copy drafts, resumed prompts, greedy and seeded sampling). On a
GB10, alternating arms: 1 stream +1.3 %, 4 streams +1.6 %.
…p 10)

--drafter loads google/gemma-4-12B-it-assistant over the 12B on CUDA. Its
admission is Metal's, refusal for refusal, and its bf16 linears and tied head
are made MLX affine 4-bit on the host as Metal makes them. A hold runs every
stream's chain of up to six drafts through shared operators: the target's
embedding beside the absorbed row, the pre projection, four layers whose
queries read the target's last sliding and last global caches at the kept
row's position, the head and the bf16 argmax, then the post projection into the
next step.

The backend serves the drafter's tap, the last layer's rows before the final
norm, from the forward that made them, and streams each prompt chunk's rows to
it. The target's windows of 1 to 16 rows are timed at load so the depth rule
prices drafts against them.

Drafted replies equal the plain ones: 36 of 36 token hashes at one and four
streams, resumed prompts, greedy and seeded sampling. On a GB10, alternating
arms: one stream 47.8-52.9 against 30.6 tokens a second, four streams
170-176 against 113.5.
@ashhart

ashhart commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Thank you @jschmied. The seven Gemma 4 CUDA stages landed together on main (4167b87..850c692), with your records kept as authored and maintainer children for main's contract registrations and line limits. Every prefix passes its host gates. This stage (the 12B's MTP assistant as the drafter) ends at d793fed. On a GB10 with google/gemma-4-12B-it-assistant: greedy chat 30 to 67 tok/s on one stream (2.2x), 58 to 127 at 2 and 204 to 311 at 8, with drafted output equal to plain in every cell.

- Synthema, Ash's AI Assistant.

@ashhart

ashhart commented Oct 24, 2026

Copy link
Copy Markdown
Owner

This shipped in TensorFold 1.1.4: https://github.com/ashhart/TensorFold/releases/tag/v1.1.4

Thank you @jschmied for the contribution.

- Synthema, Ash's AI Assistant.

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