Repository navigation
qwen27: an Ultra's prompt chunk takes one reg dispatch a projection (same bits) - #1738
Conversation
|
Thanks @Nipale-ai. Sweeping each weight matrix once per chunk instead of once per 128 rows is a clean idea, and gating it to Ultras with tensor units keeps other chips untouched. We've rebased it onto main (clean) with a changelog line, and it's running now on an M5 Ultra: Qwen3.8-27B MLX 4-bit with DFlash2, main against this, alternating fresh servers. That checks prompt tokens, cold prefill from 2k to 64k, decode after each prompt, and the stream sweep at 1, 4 and 16 streams. We'll post the numbers here. It would land after 1.2.0, which is being cut today. |
|
It holds up, @Nipale-ai. M5 Ultra, Qwen3.8-27B MLX 4-bit with DFlash2, main against this, alternating fresh servers (main, this, this, main):
It's ready, and lands right after 1.2.0. Thank you. |
|
A correction to my last comment, @Nipale-ai: I called this ready too soon. Reading every cell rather than the summary, one cell is slower in both runs of this PR and level in both runs of main. M5 Ultra, 27B + DFlash2, four greedy chat streams: 309.0 and 305.7 tok/s on main, 288.1 and 286.3 here (-6%). The other three cells at four streams are level or up, and the prefill gain stands (+9.5 to +10.4%). So we're holding it rather than landing it now. We see the same -6% in that one cell from another change that alters how the 27B's Metal pipelines are keyed, and not from changes that don't. So our first guess is a pipeline built on first use inside that cell, perhaps the new |
|
Update on the four-stream cell I flagged: it isn't this PR. We ran main against main with only a comment changed, on the same M5 Ultra with the same harness. Greedy chat at four streams read 309.2/312.3 tok/s on the base binary and 286.2/287.7 on the new one, the same split as this PR's 309.0/305.7 against 288.1/286.3. So that cell measures our harness's second binary, not your change. Everything else on this PR is clean: tokens equal on all 20 prompt rows, prompt speed +9.5% to +10.4% from 2k to 64k, decode level, 1 and 16 streams level. It's queued to land now. Thank you @Nipale-ai, and sorry for the false alarm. |
…same bits) On an Ultra with tensor units, a prompt chunk of more than 128 rows runs each 4-bit reg projection in one dispatch of up to lane.max_reg_rows (2,048) rows instead of 128-row dispatches, so the chunk reads each weight matrix once. Those chunks take their own lane key (quant_gpu.longKey) with a 2,048-row sums scratch. Decoded and verify rows, prompt chunks of 128 rows or fewer, and other chips keep their current keys, layouts and dispatches. lane_projection.Layout.rows sizes the sums scratch and the encode bound; the kernel source is unchanged. tf-core-lane-reg-check requires one long dispatch to equal 128-row dispatches byte for byte at the 27B's prompt shapes and K splits, and at the two partial layouts of the Neural Engine share.
032cdef to
8ff6d10
Compare
|
Thank you @Nipale-ai. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes. |
|
Thank you for checking every cell and landing it, @ashhart. |
|
Thanks @Nipale-ai! One dispatch per 4-bit projection for Qwen3.8-27B prompt chunks on M5 Ultra is in 1.2.1: https://github.com/ashhart/TensorFold/releases/tag/v1.2.1 |
What this changes
On an Ultra with tensor units (M5 Ultra), a Qwen3.8-27B prompt chunk of more than 128 rows runs each 4-bit projection through the reg lane kernel (
tf_lane_reg) in one dispatch instead of 128-row dispatches, so the chunk sweeps each weight matrix once instead of once per 128 rows. Replies are token-identical; cold prefill throughput is about 10% to 12% higher from 2k to 64k tokens (time to first token 9% to 11% lower); decoded rows, verify rows and other chips are unchanged.lane.reg_rows). Each dispatch sweeps the whole weight matrix, so a 2,048-row chunk reads gate/up's 89.1 MB of codes and 11.1 MB of scales and biases 16 times.lane_projection.Layout.rows(defaultreg_rows) sets the rows one reg encode may take, fromreg_rowstomax_reg_rows(2,048) in steps of 16. It sizes the sums scratch and the encode bound only; the kernel source is the same text (unit test).quant_gpu.longPromptis the gate: the Metal device name ends in " Ultra" andtensorUnits()holds.model.zigsetsKernels.long_promptonce at load.quant_gpu.longKey: on such a chip, a prompt chunk of more than 128 rows takes its own lane key (Key.long), built withrows = max_reg_rows. Like every lane key, it compiles its pipelines on first use. Decoded rows, verify rows and prompt chunks of 128 rows or fewer keep their current keys and 128-row sums scratch (unit test).max_rows: 2,048 for a long key, and 128 for other reg keys and 16 for non-reg keys, as before.Every threadgroup computes the same output tile with the same K split, the same tensor-op calls, the same epilogue and the same reduction as before. Only the number of row blocks on a dispatch's y changes, so every output word is the same.
Other chips (M5 Max, M5 Pro, and chips without tensor units) build the same keys and layouts, take the same row steps and encode the same dispatches as
main. I measured only on an M5 Ultra and have no M5 Max, so the gate keeps them on 128-row dispatches.Receipt
Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision:
main94b90c9; after is this branch, one commit on 94b90c9. Both built withzig build native -Dcpu=apple_m1 -Dversion=1.0.0(Zig 0.17.0). No MLX or PyTorch in the served path; the clients ran on Python 3.13.14.TensorFold/Qwen3.8-27B-MLX-4bit(its model card names that repo). I did not record its revision; shard SHA-2566cc1508e96fb,83f2a20ca805,31b8c91ef899;tokenizer.json06b9509352d2.tensorfold-native serve MODEL --name bench --context 66560 --parallel 1 --prompt-cache-gib 0 --no-update-check.Exactness, from
tools/bench_concurrent.py --alone --serialortoken_shaagainst"draft": false:tools/bench_concurrent.py --tokens 256 --reps 3 --temperatures 0 --levels 1,4 --alone --serial, server--context 16384 --parallel 4 --prompt-cache-gib 0, on this branch and onmain: every concurrent reply'stoken_sha256equals its request alone, 3/3 at one stream and 12/12 at four streams in each cell, and every alone run equals"draft": false. The cells arebench_openai's code and chat prompts, and a--promptscell with two docs prompts of 1,885 and 8,004 tokens that take the long dispatches. The server accepted drafted tokens in the four-stream runs.token_sha(64 greedy tokens) is the same before and after at every size: 2kf4a149d4ab4a, 8k5ca261bef56b, 32ke7beb06f7788, 64kd69cb44b4b30.tf-core-lane-reg-checkgains a long check. It packs random 4-bit codes, scales and biases at the 27B's seven prompt shapes with their prompt K splits (N x K, split: 34816 x 5120, 1; 5120 x 17408, 2; 10240 x 5120, 1; 12288 x 5120, 2; 6240 x 5120, 4; 5120 x 6144, 2; 2048 x 5120, 8) and at the two partial layouts of the opt-in Neural Engine share withTF_ANE_COLUMNS=3072(gate/up without the first 96 tiles of each half;downover input groups 48 to 271, into fp32). It runs 129, 1,065 and 2,048 rows with own and with producer sums, and requires every output byte of one long dispatch to equal the 128-row dispatches:LONG CHECK PASS cases=54. The existing check still printsREG CHECK PASS cases=352.Decode speed before and after, from
tools/bench_openai.py:tools/bench_openai.py --tokens 256 --reps 3 --temperatures 0, a fresh server each, alternated A B B A (A ismain, B is this branch), median decode in tok/s:fibonacci-raw: 49.90, 49.62 before; 49.71, 49.84 after;token_shaa10c9b848ed9in every run.gpu-chat-no-think: 49.69, 49.79 before; 49.63, 49.80 after;token_shaa06fab348fbein every run.Prompt speed before and after:
ttft_sfromtools/bench_cache.py --prompt-file ids-N.json --expected-prompt-tokens N --tokens 64 --repeats 2 --streams 1 --require-cold, four cold requests abench_cacherun (with the prompt cache off, the repeats and the concurrent request are cold too).cached_tokens = 0, and everybench_cacherun qualified.docs/*.mdat 94b90c9, joined in case-insensitive name order (WINDOWS.mdlast), each file followed by two empty lines, tokenized by the checkpoint through/v1/tokenizewithadd_special_tokens: false. The arrays, the request body and the rawbench_cacheJSON are attached: receipts-v3.zip.main), with 60 s idle between servers, giving 16 cold samples per cell. Medians with [min-max]; the change and tok/s (N / medianttft_s) follow from the rounded medians:ttft_sover branchttft_s, per-session medians): 1.101-1.108 at 2k, 1.100-1.106 at 8k, 1.094-1.103 at 32k and 1.111-1.181 at 64k. At 64k the first request of each run was 1.6-4.4 s faster than the three after it on both sides, so its samples and pairs spread more.docs/recipes/qwen3.8-27b.md: 1325, 1269 and 1171 tok/s at 2k, 8k and 32k) come from its own method (100 greedy tokens, its own docs prompts);mainmeasures higher here with the method above, and this change leaves the recipe alone.Memory:
footprint,phys_footprint_peak), read after each session's 64k run: 30.910-30.912 GiB onmainand 30.899-30.904 GiB on this branch, four sessions each; after load, before any request, 30.535-30.536 GiB on both. 256 GB RAM,--context 66560.down(K 17,408), 0.75 MiB at K 6,144 and 0.63 MiB at K 5,120. Decoded rows keep their 128-row keys. Nothing changes on other chips.main's, although the long keys add scratch; I did not trace why.Tests run, with pass, fail and skip counts:
zig build test test-golden -Dcpu=apple_m1 --summary all: this branch 8713/8766 passed, 53 skipped, 0 failed (from an empty local cache);main8707/8760 passed, 53 skipped, 0 failed. Three new tests (the layout-rows test, thelongPrompttest and thelongKeytest), each counted in two test binaries (+6).zig build tf-core-lane-reg-check -Dcpu=apple_m1and its run: both checks pass (above).zig build test-server-security test-pull-security26/26 passed;zig build fuzz-cortheon17/17 passed;zig build check-release-paths59/59 steps succeeded;zig build test-kernel-contract141/141 passed; Pythonflashnext_batch_serve_test.py8/8 passed andbuild_options_test.py5/5 passed (withZIGset);zig build dev-tools -Dcpu=apple_m1andzig build dev-tools -Doptimize=Debug -Dcpu=apple_m1: exit 0.tools/zig/lean_check.py: 424 problems onmainand on this branch;tools/zig/lean_new.py: 0 new.zig fmt --checkon the five changed files andgit diff --check: clean.zig build kernel-contract -Dcpu=apple_m1stops on this Mac before the gates withUnrecordedRotaryCompiler(its rotary probes need atf-rotary-sweeprecord for this Metal compiler), onmainand on this branch alike.tf-kernel-contract --only lanegives the same result on both:SUMMARY 6 kernels: 6 pass gates 1-4, 0 fail, 0 known findings; 0 controls: 0 red as planted, 0 missed; 0 limit problems.Not run, and why:
mlx_lm): not measured. There is no 1.1.9 build on this Mac, and the before ismain94b90c9, which already carries the changes since 1.1.9.--prompt-cache-gib 8, an 8k request and then its 32k extension reportedcached_tokens = 0, and the server logged no cache hit, onmainand on this branch alike. So I have no resume receipt; the replies'token_shaequal the cold runs'.longPrompttest). CUDA: not run; this change touches no CUDA code.check-release-pathsran on macOS, while pull requests check it on Linux x86_64.Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. Drafted output and concurrent: yes,bench_concurrent --alone --serialabove; a resumed prompt: no receipt (see "Not run, and why").SUMMARYline is in the receipt. No kernel source changes; the Metal laneSUMMARYline is above.mainat every size measured; not measured against 1.1.9 (see "Not run, and why").main, and they need no GPU.tf-core-lane-reg-checkis a device tool and, as before, stops withTensorUnitsRequiredwithout tensor units.Related
lane.Projection.initcall inquant_gpu.zigintolaneEntry; this change addslongto that key androwsto that call, so the two branches conflict there. If qwen27 DFlash: compile the drafter head's lane pipeline at load #1734 lands first, I will rebase this branch onto it.Suggested changelog line