Skip to content

Qwen3.5-9B DFlash drafter in Zig engine - #1851

Open
grantoverton wants to merge 6 commits into
ashhart:mainfrom
grantoverton:qwen35-9b-dflash
Open

grantoverton wants to merge 6 commits into
ashhart:mainfrom
grantoverton:qwen35-9b-dflash

Conversation

@grantoverton

@grantoverton grantoverton commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Part of #503; stacks on #1826. This lets the 9B draft with its DFlash drafter. z-lab/Qwen3.5-9B-DFlash is the original DFlash layout (a plain qwen3 body, no conv kernels or candidate selector, eight taps of the 32-layer target, block 16), which the 27B's DFlash2 path couldn't read. With this branch serve <9B checkpoint> --drafter <DFlash dir> drafts per stream in the 27B lane's shared rounds, as the 27B does with DFlash2: the lane rounds, per-stream trees, keep, resume and cancel are main's, unchanged. The DFlash2 path, its parsing, its schedule and its tests are unchanged.

This branch carries #1826 (and through it #1125's commit) until those merge, so please review the last six commits here (from "qwen27 dflash: read and run the 9B's DFlash drafter beside DFlash2" on).

What this changes

  1. Read and run the 9B's DFlash drafter beside DFlash2 (dflash/config.zig, weights.zig, execution.zig, operators.zig, taps.zig, the keep, gather and glue paths). A DFlashDraftModel config parses as variant v1: taps (up to eight), block, mask token and layer kinds come from the config, with no conv or selector keys. Its graph is the qwen3 body (eleven tensors a layer, plus fc, hidden_norm, norm); each layer adds attention and its MLP to the residual through a new add operator on the target's existing residual kernel, so no kernel is added. With no codebooks (rank 0) the selector's edge term is zero, and trees come from each depth's marginals. The target's tap planes follow the drafter's tap count (eight here, five for DFlash2), and the keep, gather and glue paths pass that count instead of a fixed five. Tests that fail before: the v1 config, inventory (69 names), refusal (taps past the target or repeated, a causal draft) and block tests (two residual adds a layer, the fifteen mask rows, no conv or selector tensor, a block without add refused), and "eight tap planes complete when all eight are seen". The config fixture is z-lab/Qwen3.5-9B-DFlash's published config.json.

  2. The serving line names a v1 drafter "DFlash" (qwen27_served.zig); DFlash2 stays "DFlash2".

  3. The prompt-memory planner counts the drafter's own tap planes and fusion rows (prompt_memory.zig). runnerBytes priced five raw planes a prompt row. tapRowBytes prices the drafter's planes and, for a v1 drafter, the row-major copy, fc's rows and the fused row: 18 × hidden × 2 bytes for eight taps. Fit.tap_row 0 keeps the five-plane figure, so DFlash2 and drafter-less serves plan exactly as before. Test that fails before: an eight-tap fusing drafter's prompt taps cost what its planes and fusion buffers hold.

  4. The ring keeps fused rows, and the full layer sees the whole context (taps.zig, dflash/runtime_*.zig, caches.zig, qwen27_slots.zig). The target's taps pass 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, is 8 KiB a token instead of 64 KiB. The drafter's context append starts from those rows (k and v only). The ring and the context keep the served span (context plus the lane window) instead of the 4,096-token window; sliding layers attend their own window of it, and the full-attention layer attends all of it with its own cache. DFlash2 keeps its raw planes and its window. Tests that fail before: a v1 draft keeps fused ring rows and gives its full layer the whole span, a v1 drafter's context keeps its whole span, a sliding layer attends its own window of a longer context while a full layer attends all of it; and DFlash2 keeps raw tap rows and its window everywhere. With Qwen3.5-9B support in Zig engine #1826's pre-load check, the 9B target admits this drafter from the two config.json files, and the 27B target refuses it with DraftForAnotherTarget, before either loads. A DFlash2 with a 9B target stays refused: DraftForAnotherTarget for the 27B's, NotStockDFlash2 for any other. Test: "the 9B target takes its own DFlash v1 drafter, and the 27B target refuses it by name".

  5. The developer tools' five-tap literals fill the eight tap slots (zig/tests/qwen27/dflash_graph.zig, dflash_runtime.zig, forward_diff.zig). Commit 1 made the tap ids and a draft config's taps eight-slot arrays, and three tf-qwen27 tools still set five, so zig build dev-tools failed to compile them. They name the same five taps with three empty slots; the plane count stays five.

  6. The startup line names a v1 drafter "DFlash" too (dflash/config.zig, qwen27_served.zig). Commit 2 renamed the serving line, but the startup log line still said "DFlash2 drafts for one of them" for either drafter. Config.kind() gives the name (DFlash for the v1 layout, DFlash2 for DFlash2), and both lines take it from there. Test that fails before: a v1 config is named DFlash and the stock DFlash2 config DFlash2.

Exactness

Target mlx-community/Qwen3.5-9B-MLX-4bit @938d891 (model-00001-of-00002.safetensors sha256 a68b87558c6ef43f…), the checkpoint #1826 serves. Drafter: ornith-ai/Ornith-1.5-9B-DFlash @21be344 (model.safetensors sha256 7ce9ca924154bdb0…), served at main's default --drafter-bits 4. Its config.json is byte-identical to z-lab/Qwen3.5-9B-DFlash @5fc3b3d's, and both safetensors headers list the same 69 tensor names, shapes and dtypes; the weights differ. It is the copy I had on both Macs; the z-lab drafter's own run is under Not run. Exactness doesn't depend on which drafter it is, since the target verifies every drafted row. Every check ran at e7172c2df (tensorfold-native sha256 8eea6ab45eb9224e… built on the M3, ae450a7d1e161ce8… on the M5), greedy, thinking off, serve <target> --drafter <drafter> --parallel 4 --context 65536. That was before #1826's commit 5, this branch's commit 6 and the move onto main 9e8c368. None of those changes the drafted path: commit 5 reads --drafter at open, commit 6 renames a log line, and main's lane changes are opt-in with defaults unchanged. At the tip 5c5667b95 the host suite ran again (below), and on the M3 serve <9B> --drafter <this drafter> serves with "DFlash drafts" on its startup line, while the 27B's DFlash2 is refused with DraftForAnotherTarget and a 9B-shaped DFlash2 config with NotStockDFlash2, both before either checkpoint loads (peak footprint 0.25 GB).

  • Drafted equals "draft": false. 12 prompts (the 8 known-answer prompts at 64 tokens, the two bench_openai-style probes at 256, and 6,027- and 12,026-token prompts at 256, which run past the drafter's 4,096-token sliding window into its full layer), each sent drafted and with "draft": false, ignore_eos: 12/12 equal token hashes on both chips, with 1.68 to 11.63 tokens a round drafted and 1.0 plain. bench_concurrent.py --serial: every serial cell equal on both chips. Known answers 8/8 on both.
  • Concurrent equals solo (bench_concurrent.py --tokens 256 --reps 3 --temperatures 0 --levels 1,4 --alone --serial): every alone cell (3 at one stream, 12 at four) equal, 0 failed, in all 3 drafted runs on the M3 and the M5 (and all base runs).
  • Partial acceptance. Rounds accept part of a block: 2.977 to 6.244 tokens a round at one stream and 1.724 to 4.414 at four (tables below), and every output above equals plain.
  • Resumed equals fresh (prompt cache on): the 6,027-token prompt twice, the second resumed at 6,020 cached tokens, same token hash c40a2accd7f8 on both chips, the hash Qwen3.5-9B support in Zig engine #1826 gets without a drafter.
  • Cancellation and reset, mid-draft (--prompt-cache-gib 0, 14 steps): two reference probes (greedy, 128 tokens), a drafting stream dropped mid-reply, a 22k-token prompt dropped mid-prompt, a drafting stream dropped beside a running probe (whose hash is checked too), a request past the window (refused); after each, both probes equal their references. Pass on both chips: a9af571d979c / a4dc1a079732 on the M3 and a9af571d979c / 05cf379ac28e on the M5, the same hashes Qwen3.5-9B support in Zig engine #1826 records without a drafter.
  • Precision. The target's arithmetic is unchanged: the drafter's fc and hidden_norm read the tap planes after the target writes them. The drafter keeps bf16 activations and fp32 sums, as DFlash2's operators do; its 4-bit copies are main's prepared q4 path.
  • DFlash2 unchanged. zig build test runs every existing DFlash2 test unedited (the diff adds tests and removes none); the stock DFlash2 config test checks its five taps and row width.

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 tf-qwen27-run -Dcpu=apple_m1 -Dversion=1.0.0 on each Mac. Before is #1826's tip debe0dfc1 (no drafter: it can't read one); after is this tip with the drafter. Server serve <target> [--drafter <drafter>] --parallel 4 --context 65536 --prompt-cache-gib 0; clients are this branch's tools/ (bench_openai.py … --tokens 256 --reps 3 --temperatures 0, medians of fibonacci-raw code and gpu-chat-no-think chat; bench_concurrent.py … --levels 1,4 --alone --serial, aggregate tok/s). One model process of mine at a time. The M3 was quiet; the M5 also served other models, and the cells marked * are runs where that work took the GPU (each run's per-rep spread shows it; e.g. before 3 chat reps 123.15 / 19.63 / 21.32), so I leave them out of the ratios. Tokens a round are the clients' tokens_per_chunk.

M3 Ultra (runs in order)

Run bench_openai code chat tokens a round code / chat concurrent code ×1 code ×4 chat ×1 chat ×4 ×4 tokens a round code / chat Peak footprint
before 1 93.27 92.76 1.0 / 1.0 93.1 281.7 93.0 280.8 1.0 / 1.0 20.13 GB
after 1 306.25 160.00 5.818 / 2.977 307.9 404.4 156.0 274.6 3.122 / 1.724 25.98 GB
after 2 306.38 157.22 6.095 / 2.977 308.2 401.8 157.3 274.3 3.16 / 1.73 25.81 GB
before 2 92.96 92.90 1.0 / 1.0 93.3 281.6 92.9 278.9 1.0 / 1.0 20.13 GB

M5 Ultra (runs in order)

Run bench_openai code chat tokens a round code / chat concurrent code ×1 code ×4 chat ×1 chat ×4 ×4 tokens a round code / chat Peak footprint
before 1 123.11 123.12 1.0 / 1.0 124.1 469.2 125.5 464.9 1.0 / 1.0 22.05 GB
after 1 543.00 287.57 6.244 / 3.368 542.9 944.6 287.6 640.3 4.414 / 2.639 27.73 GB
after 2 542.64 287.28 6.244 / 3.368 544.8 948.3 276.5 639.9 4.339 / 2.639 27.49 GB
before 2 123.85 122.79 1.0 / 1.0 54.7* 223.2* 119.7 439.9 1.0 / 1.0 22.06 GB
before 3 122.87 21.32* 1.0 / 1.0 123.8 463.6 122.6 462.4 1.0 / 1.0 22.06 GB
after 3 546.62 289.79 6.244 / 3.368 474.6* 938.3 287.9 644.5 4.339 / 2.639 27.49 GB

From the means of the runs above: one stream decodes 3.28× (code) and 1.70× (chat) faster on the M3, 4.41× and 2.34× on the M5. At four streams the aggregate is 1.43× (code) and 0.98× (chat) on the M3, 2.02× and 1.40× on the M5: on the M3, four chat streams at 1.7 tokens a round gain nothing over plain rounds, and drafting costs about 2% there. The drafter adds 5.4 to 5.8 GB of peak footprint at four streams of 65,536 tokens (its weights, its 4-bit copies, and a ring and caches a stream over the served span).

Cold prefill with the drafter attached. Commit 4 runs fc on every prompt row's taps, so a drafted serve's prompt pass does more work. Exact token arrays: upstream's public prefill paragraph ("A lighthouse keeper writes in the log as the boats cross the bay.") repeated, tokenized once without special tokens by the checkpoint's tokenizer.json, each array the first N ids (each decodes and re-encodes to itself), the arrays #1826 used. POST /v1/completions with the ids as prompt, max_tokens 1, temperature 0, ignore_eos; server --parallel 1 --context 131072 --prompt-cache-gib 0; one request at a time, with a short request whose first token differs between measurements; every record has cached_tokens 0 and prompt_tokens equal to N. One 2,048-token warm-up, then 3 rounds over the sizes, medians, tok/s (s). Before, then after, on each Mac.

Tokens M3 before M3 after M5 before M5 after
2,048 1,248.9 (1.64) 1,082.5 (1.89) 4,262.8 (0.48) 4,009.8 (0.51)
8,192 1,209.7 (6.77) 1,012.0 (8.10) 3,895.9 (2.10) 3,717.9 (2.20)
32,768 1,063.4 (30.82) 897.1 (36.53) 3,468.3 (9.45) 3,506.2 (9.94)
65,536 916.4 (71.52) 762.8 (85.91) 3,069.4 (21.35) 3,022.2 (22.31)
Peak footprint 20.01 GB 23.96 GB 21.83 GB 25.93 GB

On the quiet M3 the drafted serve prefills 13% to 16% slower. On the M5 both runs fell by up to 22% from their first round to their last (other work on the GPU), so its columns don't resolve the difference. A serve without --drafter takes main's path. Making the prompt-side fusion cheaper (or deferring it to the rows the drafter reads) is a follow-up.

Build and host tests. On the M3, at 5c5667b95: zig build native -Dcpu=apple_m1 -Dversion=1.0.0 exit 0 (tensorfold-native sha256 541192e737fffa6f…); zig build test test-golden -Dcpu=apple_m1 --summary all from an empty cache exit 0, 594/594 steps, 9,230/9,285 tests passed and 55 skipped (#1826's tip: 9,200/9,255); goldens 55/55 and 322/335 equal with 0 unexpected differences, as on #1826; python3 tools/zig/lean_check.py: 424 problems, main's 424, at each of the six commits (0 added); zig build dev-tools -Dcpu=apple_m1: exit 0 at the tip (it failed on the three tools before commit 5). The M5 built the same tip (zig build native tf-qwen27-run, exit 0, tensorfold-native sha256 51df34c73e1b1558…). No kernel is added or changed (the add operator reuses the target's residual kernel), so there is no kernel contract SUMMARY line.

The raw receipt files (decode, concurrency, drafted-versus-plain, cancellation, resume and prefill records, the exact prefill arrays, server logs with footprints, and the build and test logs) are available on request.

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