Skip to content

Qwen3.5-9B prompt rows in one product in Zig engine - #1897

Open
grantoverton wants to merge 11 commits into
ashhart:mainfrom
grantoverton:qwen35-9b-prompt-rows
Open

grantoverton wants to merge 11 commits into
ashhart:mainfrom
grantoverton:qwen35-9b-prompt-rows

Conversation

@grantoverton

@grantoverton grantoverton commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Stacks on #1896 (grow-as-used K/V, branch qwen35-9b-kv-grow). On an oQ4e checkpoint (4-bit in groups of 64, with 5-bit matrices and mixed-width fused stacks) the 27B engine's prompt pass runs two kinds of projection one row a launch: a mixed stack's lane member, and every 5-bit g64 matrix on tf_lane, a 1-16 row kernel. An M5 prompt pass is up to 2,048 rows, so those matrices are read hundreds of times a pass. This puts every prompt row of a lane member through one product, and, with TF_Q27_FIVE_BYTES=1 on chips with tensor units, runs 5-bit matrices from byte codes with every 16-row block in one dispatch. Outputs are bit-identical: both keep tf_lane's arithmetic, and the replies below equal before's on both chips.

Please review the last two commits here (from "qwen27: a mixed stack's lane member takes all its prompt rows in one product" on); the rest is #1896 (grow-as-used K/V) and its stack.

What this changes

  1. A mixed stack's lane member takes all its prompt rows in one product (quant_gpu.zig, core/gpu_profile.zig, core/contract/qwen27_records.zig). quantRuns launched each lane member once a row. Prompt rows (k.prompt and more than one row) now go through one quantOne call into a stage buffer, the call it already makes for a single matrix of that width, and a copy kernel (q27_stack) places them at the stack's stride. Decoded rows keep the per-row launch. The GPU profile also prints its per-shape dense totals. quant_rows_test's 300-row pass through a 4/5-bit stack, which equals its rows decoded one at a time, now runs this path on every chip. The copy kernel's launch is registered with the kernel contract (gate 5).
  2. 5-bit matrices take every prompt row in one byte-code dispatch, opt-in (projection.zig, quant_gpu.zig, gpu_weights.zig, quant_rows_test.zig, qwen27_records.zig). With TF_Q27_FIVE_BYTES=1 on a chip with tensor units, a 5-bit g64 matrix stores its codes one byte a weight ([n][k], scales and biases laid out as prepareLanes lays them) and runs tf_byte: tf_lane's arithmetic on the same codes (the uint8 tensor op, the same scale and bias FMAs, its K split and reduction order, input sums in tf_lane's order from one tf_byte_sums pass), every 16-row block in one dispatch. Decoded rows use it too. The byte copy holds 8 bits a weight instead of 5. Unset, or on a chip without tensor units, nothing changes. Test (tensor units): a 300-row prompt pass through byte codes equals the lane kernel's rows decoded one at a time, and byte codes' decoded rows equal the lane kernel's prompt pass, byte for byte. Both kernels' launches are registered for gate 5.

Exactness

Target: a local oQ4e quantization of ornith-ai/Ornith-1.5-9B (not published; the 9B shape #1826 serves): 4-bit in groups of 64, with 52 matrices at 5 bits (DeltaNet out_proj in all 24 linear layers, in_proj_a in 10, in_proj_b in 9, in_proj_z in 3, down_proj in 4 and k_proj in 2), so both changed paths load. Drafter ornith-ai/Ornith-1.5-9B-DFlash @21be344 at --drafter-bits 4. Before is #1896's tip; after is this tip, unset and with TF_Q27_FIVE_BYTES=1. Greedy, thinking off. The 4-stream runs (--parallel 4 --context 65536 --prompt-cache-gib 0) send #1896's sequence: the 12 drafted-versus-plain prompts, bench_openai.py, bench_concurrent.py --levels 1,4 --alone --serial, a 30,000-token exact-id prompt and bench_concurrent.py --levels 4 --alone; the digest is over every reply's prompt length, token count and token hash, sorted.

Chip Run (in order) Replies Digest Drafted == plain Alone and serial cells
M5 Ultra before 1 81 6944e5041f25 12/12 42 equal, 0 unequal
M5 Ultra after 1 81 6944e5041f25 12/12 42 equal, 0 unequal
M5 Ultra after 1, TF_Q27_FIVE_BYTES=1 81 6944e5041f25 12/12 42 equal, 0 unequal
M5 Ultra after 2, TF_Q27_FIVE_BYTES=1 81 6944e5041f25 12/12 42 equal, 0 unequal
M5 Ultra before 2 81 6944e5041f25 12/12 42 equal, 0 unequal
M3 Ultra before 1 81 9dab160716a4 12/12 42 equal, 0 unequal
M3 Ultra after 1 81 9dab160716a4 12/12 42 equal, 0 unequal
M3 Ultra after 2 81 9dab160716a4 12/12 42 equal, 0 unequal
M3 Ultra before 2 81 9dab160716a4 12/12 42 equal, 0 unequal
M5 Ultra final tip, TF_Q27_FIVE_BYTES=1 81 6944e5041f25 12/12 42 equal, 0 unequal

Every cold-prefill run below also gives equal replies on its chip (25 each, digest 6ffc6af68772).

  • Drafted equals "draft": false, 12/12 in every run; concurrent equals solo, every alone and serial cell equal.
  • Rows at seams. A prompt row's bits don't depend on how many rows share its call: the stack path is quantOne's own call for that width, and the byte kernel's 16-row blocks each run the same group loop. The host tests above check this at 300 rows (two full blocks plus a partial one at every 128- and 16-row edge); the 2,048-row M5 chunks and 128-row M3 chunks of the 30,000- and 65,536-token prompts give before's replies.
  • Precision. Neither change alters a reduction order or an accumulation type, so there is no FP64 or teacher-forced rerun: bf16 activations, fp32 sums and tf_lane's K split, in tf_lane's order.
  • Cancellation, reset and resume: neither change touches state, caches or scheduling; they were not rerun.

The 27B. Both changed paths serve any qwen27 checkpoint with mixed stacks or 5-bit matrices, so the 27B was run before and after: a local oQ4e quantization of Qwen3.8-27B (not published; the layout #1822 describes: 4-bit in groups of 64, 5-bit out_proj on all 48 DeltaNet layers, in_proj_a and in_proj_b on 23, in_proj_z on one, a 5-bit embedding and a 6-bit lm_head), serve <27B> --parallel 1 --context 131072 --prompt-cache-gib 0, no drafter: the 12 prompts above, bench_openai.py, and cold prefill of the same prompts as text.

Chip Run (in order) Replies Digest Drafted == plain bench_openai code / chat Prefill 2,048 8,192 32,768 65,536 Peak footprint
M5 Ultra before 1 49 832fc648f137 12/12 36.66 / 36.67 721.0 737.5 703.3 644.1 44.37 GB
M5 Ultra after 1 49 832fc648f137 12/12 36.70 / 34.88 857.0 847.7 749.8 672.9 44.38 GB
M5 Ultra after 1, TF_Q27_FIVE_BYTES=1 49 832fc648f137 12/12 40.74 / 38.98 1,079.4 1,056.2 958.6 871.6 44.95 GB
M5 Ultra before 2 49 832fc648f137 12/12 36.70 / 36.69 756.4 740.6 695.9 647.7 44.37 GB

(Prefill here is the text of the same paragraph, 2 rounds, tok/s; the 27B's tokenizer gives the same token counts.)

Performance

  • M3 Ultra: Mac Studio (Mac15,14), 80-core GPU, 256 GB, macOS 26.4.1 (25E253), Xcode 26.6, Python 3.14.
  • M5 Ultra: Mac Studio (Mac17,15), 64-core GPU, 96 GB, macOS 27.2 (26B123), Xcode 27.2, Python 3.15.

Zig 0.17.0, zig build native -Dcpu=apple_m1 -Dversion=1.0.0 on each Mac. One model process of mine at a time; both Macs were also serving other models, and the M5's other work moved single runs by up to 30% (each table names the spread), so runs alternate before and after and the tables give every run.

Cold prefill: the exact token arrays from #1826 (upstream's public lighthouse paragraph, tokenized by Qwen3.5-9B's tokenizer.json, sent as ids), POST /v1/completions with the ids, max_tokens 1, temperature 0, ignore_eos, server --drafter <drafter> --parallel 1 --context 131072 --prompt-cache-gib 0, one request at a time with a short unrelated request between measurements; every record has cached_tokens 0 and prompt_tokens equal to N. One 2,048-token warm-up, then 3 rounds, medians, tok/s.

M5 Ultra, 9B oQ4e (runs in order; the M5's other work moved whole runs by up to 30%: before 2 is 22% below before 1 at 2,048)

Run 2,048 8,192 32,768 65,536 Peak footprint
before 1 2,802.8 2,762.1 2,588.6 2,359.5 25.99 GB
after 1 3,393.5 3,324.1 3,017.3 2,706.9 26.00 GB
after 1, TF_Q27_FIVE_BYTES=1 4,258.3 3,939.1 3,678.1 3,092.6 26.23 GB
after 2, TF_Q27_FIVE_BYTES=1 3,573.2 3,371.7 3,052.8 2,739.1 26.23 GB
after 2 2,260.5 2,605.8 2,463.0 2,148.8 25.99 GB
before 2 2,188.1 2,287.7 2,129.4 1,920.2 26.00 GB
final tip, TF_Q27_FIVE_BYTES=1 4,529.8 4,420.8 3,712.0 3,137.1 26.23 GB
before 3 2,210.6 2,136.8 2,017.3 1,823.6 26.01 GB
before 4 2,803.2 2,761.6 2,588.4 2,332.1 25.99 GB
after 4 3,115.7 2,870.1 2,755.6 2,356.2 25.98 GB
after 4, TF_Q27_FIVE_BYTES=1 3,584.3 3,517.3 3,017.1 2,672.3 26.25 GB
after 5, TF_Q27_FIVE_BYTES=1 3,768.8 3,442.0 3,021.1 2,712.6 26.23 GB
after 5 2,668.4 2,577.5 2,394.4 2,153.3 26.00 GB
before 5 2,222.0 2,192.2 2,020.1 1,822.4 26.00 GB
before 6 2,172.1 2,196.5 2,000.2 1,867.7 26.00 GB
after 6 2,808.3 2,739.5 2,533.9 2,223.9 26.00 GB
after 6, TF_Q27_FIVE_BYTES=1 3,492.9 3,308.0 2,886.8 2,619.0 26.25 GB

In every cycle after is above the before run beside it, and the byte path is above after. Against the before run next to it, after gains 15% to 21% (cycle 1), 3% to 16% (2), 1% to 11% (4), 18% to 20% (5) and 19% to 29% (6); the byte path adds 10% to 58% over the after run next to it. The M5's absolute level drifts by 30% between cycles, so compare within a cycle.

M3 Ultra, 9B oQ4e (runs in order; the byte path needs tensor units and does not run here, and an M3 prompt pass is at most 128 rows)

Run 2,048 8,192 32,768 65,536 Peak footprint
before 1 1,001.0 940.3 836.8 733.5 24.00 GB
after 1 1,024.3 943.5 848.9 748.2 24.00 GB
after 2 1,088.7 1,008.6 900.3 788.2 24.00 GB
before 2 1,082.7 1,012.2 901.9 786.0 24.00 GB

On the M3, after equals before within the runs' spread.

Decode (bench_openai.py … --tokens 256 --reps 3 --temperatures 0, medians; bench_concurrent.py aggregates), from the 4-stream runs:

M5 Ultra (tok/s)

Run bench_openai code chat concurrent code ×1 code ×4 chat ×1 chat ×4
before 1 377.14 214.89 384.1 651.2 214.8 488.8
after 1 375.19 218.18 382.2 637.3 218.5 500.9
after 1, TF_Q27_FIVE_BYTES=1 401.04 242.57 413.5 670.5 242.7 541.9
after 2, TF_Q27_FIVE_BYTES=1 371.05 225.49 395.3 673.4 230.6 543.2
before 2 377.17 214.70 357.9 652.0 213.8 492.7

Decoded rows don't take the stack path, and the after runs decode as before does. Decoded rows do take byte codes when they are on.

M3 Ultra (tok/s)

Run bench_openai code chat concurrent code ×1 code ×4 chat ×1 chat ×4
before 1 228.86 149.47 226.8 359.6 151.4 266.0
after 1 225.18 147.73 221.0 360.5 148.3 286.9
after 2 235.99 165.96 238.3 383.8 166.1 303.7
before 2 237.30 167.35 237.4 385.3 160.3 278.6

Memory: with TF_Q27_FIVE_BYTES=1 the 9B loads at 18.45 GB instead of 18.33 GB (M5, /memory after load) and peaks at 28.35 GB against 27.91 to 28.11 GB unset (4-stream runs); the 27B peaks at 44.95 GB instead of 44.37 GB; unset, the footprint equals before's.

Build and host tests

At the tip f4e7d6b68: this series rebased onto #1896's current tip 8e2463c7b, which adds a third commit (a fresh pass takes its prompt's K/V rows at once) that runs only with TF_Q27_KV_GROW set; every run above has it unset, so they ran this code. On the M5: zig build native -Dcpu=apple_m1 -Dversion=1.0.0 exit 0; zig build test test-golden -Dcpu=apple_m1 --summary all exit 0, 594/594 steps, 9,231/9,289 tests passed and 58 skipped; goldens 55/55 and 322/335 equal, 0 unexpected differences; zig build dev-tools -Dcpu=apple_m1 exit 0. On the M3: native and dev-tools exit 0; test + test-golden 590/594 steps, 9,226/9,289 passed, 57 skipped and 6 failed, goldens 55/55 and 322/335 with 0 unexpected. All six failures are in the server's benchmark-client tests (benchmark_cli_test, benchmark_stream_test, benchmark_upload_test, benchmark_run_test), each a local fake server counting one request more than its test sent; this series doesn't touch those files, they pass on the M5 at the same tip and on the M3 at aa20a833a, and the M3 was under other jobs' load at the time (#1896's tip shows the same tests failing, a different one or two each run). python3 tools/zig/lean_check.py: 424 problems at 5c5667b95 and at each commit (0 added). Before this rebase, at aa20a833a: on the M5, native exit 0, test + test-golden exit 0 (594/594 steps; 1,281/1,305 tests rerun, 24 skipped, the rest cached from the same tree's full run at 2af064e9d: 9,229/9,287 passed, 58 skipped), goldens 55/55 and 322/335 with 0 unexpected, dev-tools exit 0; on the M3, native, test + test-golden (594/594 steps, goldens 55/55 and 322/335 with 0 unexpected; most tests cached from the same tree's full run) and dev-tools all exit 0. At the previous base (ce9c264ce) the M3 gave: native exit 0, 594/594 steps, 9,230/9,287 passed, 57 skipped (the byte-code test skips there), goldens equal, dev-tools exit 0. Main shares them: main 3ce4a0480's own zig build test test-golden on the same M3 failed benchmark_run_test (9,361/9,417 passed), with no qwen27 change in it.

Before the rebase, at the same qwen27 code:

  • M3: zig build native -Dcpu=apple_m1 -Dversion=1.0.0 exit 0; zig build test test-golden -Dcpu=apple_m1 --summary all exit 0, 587/587 steps, 9,079/9,133 tests passed and 54 skipped (the byte-code test skips without tensor units); goldens 55/55 and 322/335 equal, 0 unexpected differences; zig build dev-tools -Dcpu=apple_m1 exit 0.
  • M5: zig build test test-golden -Dcpu=apple_m1 --summary all exit 0, 587/587 steps, 9,078/9,133 passed and 55 skipped (the byte-code test runs here); goldens 55/55 and 322/335 equal, 0 unexpected differences.
  • python3 tools/zig/lean_check.py: 424 problems at the base and at each commit (0 added), before and after the rebase.
  • Kernel contract gate 5 (part of zig build test): the three new launch records pass. zig build kernel-contract -Dcpu=apple_m1 was not run (see Not run).

Not run

A DFlashDraftModel config now parses as variant v1: its taps (up to eight),
block, mask token and layer kinds come from the config, with no conv or
selector keys. Its graph is the plain qwen3 body (eleven tensors a layer,
fc, hidden_norm, norm), each layer adding attention and MLP to its residual
through a new add operator on the target's residual kernel. With no
codebooks (rank 0) the selector's edge term is zero, so trees come from
each depth's marginals.

The target's tap planes follow the drafter's tap count (eight here), and
the keep, gather and glue paths pass that count instead of five. DFlash2
parsing, its schedule and its tests are unchanged.
…nd fusion rows

runnerBytes kept five raw planes a prompt row. tapRowBytes now prices a
drafter's planes, and for a fusing (v1) drafter the row-major copy, fc's
rows and the fused row: 18 x hidden x 2 bytes for the 9B drafter's eight
taps. Fit carries it as tap_row; 0 keeps the five-plane figure, so DFlash2
and drafter-less serves plan exactly as before.
…the whole context

The target's taps pass now runs the drafter's fc and hidden_norm as a v1
drafter's taps complete (Taps.setFuser), so every gather reads one plane
of fused rows: a ring row and a prompt-cache state's drafter part are
8 KiB a token instead of 64 KiB. The drafter's context append starts from
those rows (k and v alone). DFlash2 keeps its raw planes.

The ring and the context now keep the served span (context plus the lane
window) instead of the 4096-token window. Sliding layers attend their own
window of it; the full-attention layer attends all of it, with its own
cache and attention sized to the span.
…tap slots

Commit "read and run the 9B's DFlash drafter beside DFlash2" made the tap
ids and a draft config's taps eight-slot arrays, and three tf-qwen27 tools
still set five: zig build dev-tools failed on dflash_graph.zig,
dflash_runtime.zig and forward_diff.zig. They now name the same five taps
with three empty slots (the plane count stays five).
The serving line already named a v1 drafter "DFlash", but the startup log
line still said "DFlash2 drafts for one of them" for either. Config.kind()
gives the name, DFlash for z-lab's v1 layout and DFlash2 for DFlash2, and
both lines take it from there. Test: a v1 config is named DFlash and the
stock DFlash2 config DFlash2.
Every slot took K/V rows for the whole --context on its first use, so
four live 9B streams at --context 32768 held 4 GiB of K/V while each
stream used a few thousand tokens. With TF_Q27_KV_GROW=<rows> a slot
starts at that many rows and, when a round, a restored state or a moved
slot needs more, grows to the need rounded up to the step or a quarter
more than it holds, at most the context.

Growth goes one attention layer at a time: a wider buffer pair, one
waited blit of every old byte to its place (each K/V head from
h*old*dim to h*new*dim), the old pair out of the residency set and
freed, so growing holds at most one layer's old pair beside the new
rows. It runs where moveSlot and reset already touch a slot
(settleSlot, no pending keep, no failure).

The attention kernels already take each stream's head stride from its
cache, so the plan now passes the slot's held rows; arithmetic and key
order depend only on positions. Unset, every slot holds the context as
before, and the startup fit and slot admission still charge the full
context.
With TF_Q27_KV_GROW a slot kept its high-water rows after its stream
ended, and every parked stream's drafter taps ring was sized to the
whole span, so one long request left its rows behind in every slot it
touched.

Give-back: when a stream leaves slot k, the slot goes back to its first
rows (fresh small buffers, no copy) as soon as no blit reads it. The
check is a non-blocking status read of the kept-state save, retried at
each backend call, so no stream waits on it. Slot 0 keeps its rows
while it stands for a next turn's resume; the next fresh pass there
gives them back, as does any fresh pass in any slot.

Rings: a parked stream's taps ring starts at the same first rows and
grows by the K/V rule when taps are absorbed or a kept state is
restored or moved into it (Ring.grow, Ring.hold); the old rows move to
their places on the CPU after the slot's pending reads land. Drafter
frames wait for their ring reads before closing, so a ring only changes
between them. A context's reset gives its ring back. The growth helpers
move to kv_slots.zig to keep decode_round.zig under 600 lines.
…ROW)

With K/V grown as used, a fresh prompt pass gave its slot back to the first rows and then grew it
step by step through the prompt: ten growths for a 65,536-token prompt, each a waited blit of every
layer into new buffers. The slot is empty at that point, so it now takes the rows the prompt fills
(rounded up to the step) in new buffers with no copy, and keeps them when it already holds exactly
that many. Decode and restores still grow by the same rule.
…product

A mixed-width fused stack (an oQ4e checkpoint's 4/5-bit q|k|v or qkv|z)
ran each lane member one row a launch, so a 2,048-row prompt pass read
that member's weights 2,048 times. Prompt rows now go through one lane
product into a stage buffer, and a copy kernel places them at the
stack's stride; decoded rows keep the per-row launch. The lane product
is the call quantOne already makes for a single matrix of that width,
and quant_rows_test's 300-row pass through a 4/5-bit stack still equals
its rows run one at a time. The GPU profile also reports its per-shape
dense totals.
…h (opt-in TF_Q27_FIVE_BYTES)

An oQ4e checkpoint's 5-bit g64 matrices (on the 9B: DeltaNet out_proj
in all 24 linear layers, some down, z, a/b and q/k/o) run on tf_lane,
a 1-16 row kernel: a 2,048-row prompt pass is 128 launches a matrix,
each unpacking the 5-bit stream and summing its input groups again.

With TF_Q27_FIVE_BYTES=1 on chips with tensor units, those matrices
store their codes one byte a weight ([n][k], scales and biases as
prepareLanes lays them) and run tf_byte: tf_lane's arithmetic on the
same codes (the uint8 tensor op, the same scale and bias FMAs, its K
split and reduction order, input sums in tf_lane's order from one sums
pass), with every 16-row block in one dispatch. Unset, nothing changes.
The byte copy holds 8 bits a weight instead of 5.

Test: on a chip with tensor units, a 300-row prompt pass through byte
codes equals the lane kernel's rows one at a time, and byte codes'
decoded rows equal the lane kernel's prompt pass.
@grantoverton
grantoverton force-pushed the qwen35-9b-prompt-rows branch from f4e7d6b to 37d8b13 Compare November 2, 2026 13:16
@ashhart

ashhart commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @grantoverton. The bit-identity claim against tf_lane holds: the same codes into the same uint8 matmul2d, the same metadata layout, in-order F32 group sums, the same fma chain, SK and BF16 store. Unset is unchanged, and uniform 4/64 checkpoints are unchanged. Two changes:

  1. The byte path skips the lane path's metadata checks. prepare returns prepareBytes before the BF16 scale/bias, shape and rows % 4 checks, and reads scales with host(u16), so F32 or misshaped metadata would be misread instead of refused. Please run the same checks first.
  2. The contract has launch records only. Please add tf_byte as a byte-exact replacement of tf_lane at 5-bit g64, over the 9B and 27B shapes and K splits, with a planted control. And add q27_stack as a raw copy, with a stride control.

Two small points: say how the start-up fit counts the byte copy (+0.23 GB on the 9B, +0.57 GB on the 27B). And replaced scratch buffers are released mid-pass, which is safe because qwen27's command buffers retain bound resources, but please confirm that on #1826's base.

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