Repository navigation
Qwen3.5-9B DFlash drafter in Zig engine - #1851
Open
grantoverton wants to merge 6 commits into
Open
grantoverton wants to merge 6 commits into
grantoverton wants to merge 6 commits into
Conversation
This was referenced Nov 1, 2026
grantoverton
force-pushed
the
qwen35-9b-dflash
branch
from
November 1, 2026 22:53
54a72a5 to
5c5667b
Compare
This was referenced Nov 1, 2026
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.
grantoverton
force-pushed
the
qwen35-9b-dflash
branch
from
November 2, 2026 13:16
5c5667b to
ae0d759
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #503; stacks on #1826. This lets the 9B draft with its DFlash drafter.
z-lab/Qwen3.5-9B-DFlashis 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 branchserve <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
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). ADFlashDraftModelconfig 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, plusfc,hidden_norm,norm); each layer adds attention and its MLP to the residual through a newaddoperator 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 withoutaddrefused), and "eight tap planes complete when all eight are seen". The config fixture isz-lab/Qwen3.5-9B-DFlash's publishedconfig.json.The serving line names a v1 drafter "DFlash" (
qwen27_served.zig); DFlash2 stays "DFlash2".The prompt-memory planner counts the drafter's own tap planes and fusion rows (
prompt_memory.zig).runnerBytespriced five raw planes a prompt row.tapRowBytesprices 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_row0 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.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'sfcandhidden_normas 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 twoconfig.jsonfiles, and the 27B target refuses it withDraftForAnotherTarget, before either loads. A DFlash2 with a 9B target stays refused:DraftForAnotherTargetfor the 27B's,NotStockDFlash2for any other. Test: "the 9B target takes its own DFlash v1 drafter, and the 27B target refuses it by name".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 threetf-qwen27tools still set five, sozig build dev-toolsfailed to compile them. They name the same five taps with three empty slots; the plane count stays five.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.safetensorssha256a68b87558c6ef43f…), the checkpoint #1826 serves. Drafter:ornith-ai/Ornith-1.5-9B-DFlash@21be344 (model.safetensorssha2567ce9ca924154bdb0…), served at main's default--drafter-bits 4. Itsconfig.jsonis byte-identical toz-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 ate7172c2df(tensorfold-nativesha2568eea6ab45eb9224e…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--drafterat open, commit 6 renames a log line, and main's lane changes are opt-in with defaults unchanged. At the tip5c5667b95the host suite ran again (below), and on the M3serve <9B> --drafter <this drafter>serves with "DFlash drafts" on its startup line, while the 27B's DFlash2 is refused withDraftForAnotherTargetand a 9B-shaped DFlash2 config withNotStockDFlash2, both before either checkpoint loads (peak footprint 0.25 GB)."draft": false. 12 prompts (the 8 known-answer prompts at 64 tokens, the twobench_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.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).c40a2accd7f8on both chips, the hash Qwen3.5-9B support in Zig engine #1826 gets without a drafter.--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/a4dc1a079732on the M3 anda9af571d979c/05cf379ac28eon the M5, the same hashes Qwen3.5-9B support in Zig engine #1826 records without a drafter.fcandhidden_normread 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.zig build testruns 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
Zig 0.17.0,
zig build native tf-qwen27-run -Dcpu=apple_m1 -Dversion=1.0.0on each Mac. Before is #1826's tipdebe0dfc1(no drafter: it can't read one); after is this tip with the drafter. Serverserve <target> [--drafter <drafter>] --parallel 4 --context 65536 --prompt-cache-gib 0; clients are this branch'stools/(bench_openai.py … --tokens 256 --reps 3 --temperatures 0, medians offibonacci-rawcode andgpu-chat-no-thinkchat;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)
bench_openaicodeM5 Ultra (runs in order)
bench_openaicodeFrom 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
fcon 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'stokenizer.json, each array the first N ids (each decodes and re-encodes to itself), the arrays #1826 used.POST /v1/completionswith the ids asprompt,max_tokens1, 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 hascached_tokens0 andprompt_tokensequal to N. One 2,048-token warm-up, then 3 rounds over the sizes, medians, tok/s (s). Before, then after, on each Mac.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
--draftertakes 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.0exit 0 (tensorfold-nativesha256541192e737fffa6f…);zig build test test-golden -Dcpu=apple_m1 --summary allfrom 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-nativesha25651df34c73e1b1558…). No kernel is added or changed (theaddoperator reuses the target's residual kernel), so there is no kernel contractSUMMARYline.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
z-lab/Qwen3.5-9B-DFlash@5fc3b3d on the stock target (acceptance, decode and the same exactness checks):tensorfold-native serve mlx-community/Qwen3.5-9B-MLX-4bit --drafter z-lab/Qwen3.5-9B-DFlash --parallel 4 --context 65536 --prompt-cache-gib 0, then the two client commands above.--drafter-bits 0): the same serve with--drafter-bits 0.serve <Qwen3.8-27B checkpoint> --drafter <DFlash2 dir> --parallel 4, then the clients above, on main and on this tip.mlx_lmwith a DFlash drafter. Qwen3.5-9B support in Zig engine #1826 hasmlx_lm.server's plain decode and prefill on the same checkpoint and Macs.tf-qwen27-run --seams): the target's prompt and decode arithmetic is unchanged, and the runner has no drafter option.atc overlap --json(ATC isn't installed here). By hand, withgit merge-treeagainst every open pull request that touches these files: qwen3_5_moe: the experts' prompt sort as one threadgroup (stacked on #1125) #1799 (stacked on qwen27: serve Qwen3.5-MoE (Ornith-1.5-122B) on Metal: routed experts, its DFlash2 drafter and the publisher's 4-bit weights #1125) also changes the drafter's config, generation and session and conflicts in 12 files here (4 of them already through Qwen3.5-9B support in Zig engine #1826); the qwen27: serve the 27B pair, refuse what a pair cannot keep, and die with the peer #1434 to qwen27: document two-Mac 27B serving and the row split's exactness #1437 pair series conflicts through Qwen3.5-9B support in Zig engine #1826's attention and loader changes; Metal: the dense Qwen3.5 loader takes its shape from the config #1817 you've compared. I'll rebase onto whichever lands first.