Skip to content

qwen27: rank-slice the 27B family and gather the whole embedding table - #1433

Open
akol1 wants to merge 3 commits into
ashhart:mainfrom
akol1:qwen27/rank-slice
Open

akol1 wants to merge 3 commits into
ashhart:mainfrom
akol1:qwen27/rank-slice

Conversation

@akol1

@akol1 akol1 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Step 2 of 6 of the two-Mac Qwen3.8-27B series, rebased on main (db3fd5ab), stacked on step 1 (qwen27/pair-seam). Step 3 stacks on this commit.

The 27B family becomes rank-aware behind one interface: forward.Reduce (zig/src/families/qwen27/forward.zig:23) carries a seam's part, stage, gather and failed, plus an optional argmax that defaults to null; a one-Mac model therefore compiles the head seam out and executes exactly the layer program it executed before this series. projection.prepare derives a rank's row slice from the checkpoint matrix (gn), so the slice keeps the checkpoint's K split, tile and prompt tables (zig/src/families/qwen27/projection.zig:61); prompt rows take promptSplit of the whole matrix, so the pair's prompt reduction is the reduction tree one Mac uses rather than a half-width tree. The width the seam carries is the tensor's own: the fused gate-up keeps the member split instead of recomputing it from the stacked width, a local qkv block keys neither m5Split nor promptSplit, and simd_qmm_bits.call(..., whole) keeps the split rank's chunk count through twinExact as well.

The split is by output rows with the full K on the rank that owns them: 12 of 24 attention heads, 2 of 4 KV heads, 8 of 16 linear-attention q/k heads, 24 of 48 value channels and 3,072 of 6,144 z rows, 8,704 of the MLP's 17,408 rows, 2,560 of the 5,120 output rows for o_proj/down_proj at full K, and 124,160 of the 248,320-row lm_head. o_proj and down_proj compute a rank's own output rows at full K, and the other rank's rows come over the seam as whole bf16 rows: stage copies this rank's columns of a frame row and gather copies each rank's half to its own offset, so no two partial sums meet and no row is computed twice. Embeddings and norms stay whole on every rank, so a token id means the same thing on both, and the embedding gather's bound stays the whole 248,320-row table (config.embedBound). The head seam is argmax over each rank's 124,160-logit half and pick over the pair: no rank computes or gathers the whole 248,320-row logit vector, the pick is the better of the two half-vocab winners, and a non-finite logit on either half fails closed through the sentinel instead of letting the other half's winner stand in for the whole vocabulary's. session.zig keeps the merged top two for its cross-block tie test and reads the pick's maxInt(u32) sentinel as error.PairFailed, never as a logit.

Two host-path details the maintainer asked for: the prompt attention merge strides the rank's own head count — q27_attn_merge takes constant uint& heads (zig/kernels/metal/qwen27/attention_mpp.metal:270) and indexes ((int64_t)node * (int64_t)heads + h) * D, with attention.zig:413 setting it from the rank's geometry; the prompt merge writes the rank's KV node rows (kv_heads in Q27PromptArgs) instead of assuming the model's four (attention_prompt.metal:171). The in_proj_qkv 2,048-row trap is keyed on the tensor's rows, not on a split table, so a whole 6,144-row qkv block stays whole; projection.zig's tests pin it: "every one-Mac stack keeps main's K split", "a rank's row slice keeps the checkpoint matrix's K split", and "stacked gate-up keeps the member K split rather than recomputing it from stacked width". config_test pins the rank spans against the checkpoint's real shapes — 12 heads, 2 KV heads, 8,704 intermediate rows, 124,160 vocab rows, 2,560 output rows, q rows from 6,144 — while rank 0's spans start at 0, and synthetic_graph runs the same graph on both ranks. The qkv blocks start at 1,024 rather than 2,048, which is the trap the maintainer's comment names: a block boundary keyed on the stacked tensor's rows instead of the tensor's own.

Receipt

  • Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: this commit (b3d9b7e74ad63c700b098a4f00b5f18752ec1bdf) on main (db3fd5abae34e85a3df7b38467ef118022b24908) plus step 1; no MLX or PyTorch; macOS 27.0.1 on an Apple M4 Pro (Mac16,11), Metal only (applegpu_g16, no tensor units), Apple metal version 32023.921. No checkpoint in this commit's gates: config_test and synthetic_graph build the graph from a synthetic config, and the model gates arrive in step 4.
  • Exactness, from tools/bench_concurrent.py --alone --serial or token_sha against "draft": false: n/a at this commit, nothing serves yet; the split is exact by construction and pinned by the host tests above — a rank's slice keeps the checkpoint matrix's K split, the fused gate-up keeps its member split, and every one-Mac stack keeps main's split (0 new problems in the lean run is the same diff's shape check, not exactness). PASS limits: 313 registered Metal launches against M1-M5, 0 problems and the filtered contract runs below cover the kernels the split talks to.
  • Decode speed before and after, from tools/bench_openai.py: n/a, no serving path at this commit; the pair's decode rate needs two Macs and arrives with step 4's receipts.
  • Prompt speed before and after: n/a; prompt projections keep the checkpoint matrix's split, so their reduction tree is unchanged, and a throughput number without a second Mac would be a loopback artifact.
  • Tests run, with pass, fail and skip counts: zig build test: 557/557 steps succeeded; 1153/1172 tests passed (19 skipped) (Zig does not re-count cached test steps, so these are the counts of what this checkout re-ran; every step succeeded), exit 0. zig build test-kernel-contract: 43/43 steps succeeded, exit 0. zig build dev-tools: 215/215 steps succeeded. zig build tf-qwen27-tp-check: no such step at this commit (step 4 adds it). zig fmt --check build.zig zig: clean. The filtered contract runs: SUMMARY 15 kernels: 15 pass gates 1-4, 0 fail, 0 known findings; 35 controls: 35 red as planted, 0 missed; 0 limit problems; gates 300 ms, limits 146 ms and SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 0 controls: 0 red as planted, 0 missed; 0 limit problems; gates 34032 ms, limits 121 ms. Lean rule: lean: 428 problems, 428 on the base, 0 new (tools/zig/lean_new.py, exit 0).
  • Not run, and why: the model gates (they need a checkpoint and tf-qwen27-tp-check, which step 4 adds); a two-Mac run (the serving link is step 3 and the scripts step 5); the unfiltered kernel corpus (it stops at UNSUPPORTED metal moss image: the family runs on tensor units (M5) alone; its kernels unchecked here, so the runs above use --only); CUDA, HIP/RDNA, M1-M3, M5 and Windows are not in this room.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. — the split rides the lane rounds the family already executes; no serving path is added in this commit.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. — no serving path at this commit; step 3 refuses drafts and step 4's fixtures are greedy, so there is nothing drafted to compare yet.
  • No precision traded for speed. If the bits change, the description says which sums change and why. — the split moves bf16 rows and adds in fp32 once per seam; no bf16 partial sum is created, and a one-Mac model's Reduce is null, so its arithmetic is main's.
  • A new or changed GPU kernel is registered with the kernel contract (docs/kernel-contract.md); each backend's SUMMARY line is in the receipt. — the two qwen27 attention kernels this commit edits have no contract record on main either; the edit is their head-stride argument, and it is pinned by the one-Mac split tests and by step 4's stage-3 fixtures rather than by a registration this series does not add. The pair's four records are step 1.
  • Prompt processing is no slower than the last release. — n/a, prompt projections keep the checkpoint matrix's split, so their reduction tree is unchanged; a pair throughput comparison belongs with step 4.
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not. — Metal on an M4 Pro (applegpu_g16); no CUDA, HIP, M1-M3, M5 or Windows.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. — config_test's shard spans and projection.zig's split tests fail on the parent commit (no Shard, no whole argument), and the model-requiring paths skip without a checkpoint as before.
  • Comments and docstrings are one line. No measurements or history in the source. — lean: 428 problems, 428 on the base, 0 new.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines. — no user path, hostname or address is in the diff.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

The embedBound fix is a real bug fix with a test. Thank you.

Two places change the arithmetic, so the pair can't match the single Mac yet:

  • The row-parallel split of o_proj/down_proj reduces across ranks. See core: add the row-split pair seam and split simd_qmm over K #1432 for an exact layout that keeps each sum on one rank.
  • The rank-local K-split. projection.prepare picks the K-split from split(weight.shape[0], k), and rowView sets shape[0] to the rank's rows. So a rank's q/k/v, gate/up and z/b/a get a different split from the single Mac's. For example, gate goes from 17,408 to 8,704 rows and sk from 2 to 4; q goes from 12,288 to 6,144 and sk from 4 to 8. On M5, the m5Split/promptSplit tables are keyed on the global shapes, so a rank misses them. A different sk is a different fp32 order. Please carry the global matrix's split into the rank slice.

Stage 1 compares loadShard(ranks=1) against load on the same new code. It needs a comparison against main's binary too.

@akol1
akol1 force-pushed the qwen27/rank-slice branch 3 times, most recently from 5fcfe81 to 7d57101 Compare October 27, 2026 22:38
@akol1

akol1 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Refreshed head 7d57101a carries the global split into the rank slice, and the arithmetic now matches one Mac's:

  • No cross-rank reduction. Each output projection is split by output rows; rank r owns 2,560 of the 5,120 hidden columns (8,704 of 17,408 for down_proj) and reads the full K. Every sum stays on one rank, and pair_gather copies whole bf16 rows with no arithmetic between them, so no cross-rank partial sum and no tie policy exists.
  • The K split comes from the checkpoint matrix. projection.prepare and Linear.gn carry the global width into quant_gpu, so a rank's q/k/v, gate/up and z/b/a keep the split one Mac uses instead of recomputing it from the slice's rows: gate-up stays at 2 for 8,704 of 17,408 (not 4), q stays 4 and 8 for 6,144 of 12,288 (not 8 and 16), and a local 2,560-row down slice is keyed on neither the m5Split nor the promptSplit table. projection.zig and config_test.zig pin both directions.
  • Stage 1 against main's binary. tp_check --stage 3 still compares loadShard(ranks=1) against load on the new code, and the separate cross-check in qwen27: pin the pair against the single Mac in the test suite #1435's receipt compares against main's own tensorfold-native built from 5897e0bc: it reproduces the same 256-token sha (a06fab34…) for the same 31-id chat prompt.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. Carrying the checkpoint matrix's width (gn) into a rank's slice keeps the 4-bit K splits one Mac uses. The embedding-bound fix and config_test are good. On a local merge with main, fmt and lean are clean, config_test.zig passes 17/17, and the qwen27 host steps we ran pass. Four asks:

  1. q27_attn_merge still strides 24 heads a row (zig/kernels/metal/qwen27/attention_mpp.metal:320,323). You fixed the same stride in the prompt merge. This kernel serves every multi-row round that is not a prompt chunk (attention.zig:343,410-413). A served chat feeds each earlier reply that way (qwen27_slots.zig:81,122, decodeChunk). On a rank, rows 1 and up land at twice their offset and read another row's gate. Please pass the rank's head count, as Q27PromptArgs now does.
  2. One Mac's split changes. in_proj_qkv now loads as three blocks (gpu_weights.zig:222), and projection.zig:61 keys its split on the q block's 2,048 rows: 8 K slices, where main gives 4 (:145 pins 8). Widths other than 4-bit g64 on the M5 use that split (:71). Please key it on the tensor's rows. Add a host test that every one-Mac stack keeps main's split.
  3. Before the M5, other widths take simd_qmm_bits, which chunks by the rank's width (quant_gpu.zig:217, simd_qmm_bits.zig:68). That gives 16 chunks for q, qkv and zba where one Mac takes 8. Please pass gn through, or refuse those widths on a pair by name, with a test.
  4. Small: Part.k0/k, Triple.k/k0 and Shard.cols/*Cols are unused outside tests. Please drop them. Also, no contract record names q27_attn_prompt_merge, so please correct the body.

This also needs #1432's rebase.

@akol1
akol1 force-pushed the qwen27/rank-slice branch 2 times, most recently from 0984521 to b3d9b7e Compare October 28, 2026 04:09
@akol1

akol1 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Refreshed and force-pushed: qwen27/rank-slice is now b3d9b7e7, rebased onto current main (db3fd5ab), with the body rewritten around this head.

  1. q27_attn_merge strides the rank's head count. It now takes constant uint& heads and indexes ((int64_t)node * (int64_t)heads + h) * D, with attention.zig setting it from the rank's geometry — the same argument Q27PromptArgs carries. The prompt merge writes the rank's KV node rows (kv_heads) instead of assuming the model's four. The path is: every multi-row round that is not a prompt chunk, including a served chat's earlier reply through decodeChunk.

  2. The in_proj_qkv split is keyed on the tensor's own rows. A whole 6,144-row qkv block keeps its member split; the trap is keyed on rows rather than on the stacked width. projection.zig's tests pin it: "every one-Mac stack keeps main's K split", "a rank's row slice keeps the checkpoint matrix's K split", and "stacked gate-up keeps the member K split rather than recomputing it from stacked width". config_test pins the rank spans against the real shapes (12 heads, 2 KV heads, 8,704 intermediate rows, 124,160 vocab rows, 2,560 output rows, q rows from 6,144) and synthetic_graph runs the same graph on both ranks.

  3. gn is carried through instead of refusing those widths. projection.prepare derives the slice from the checkpoint matrix, and simd_qmm_bits.call(..., whole) keeps the split rank's chunk count — the same through twinExact — so a rank's q, qkv and zba take the checkpoint matrix's K split (8 chunks) rather than the half width's 16.

  4. Dropped Part.k0/k, Triple.k/k0 and Shard.cols/*Cols; they are not used outside tests. The body no longer claims a contract record for q27_attn_prompt_merge: the checklist now says the two attention kernels this commit edits have no record on main either, and this series does not add one.

Gates on b3d9b7e7: zig build test 557/557 steps succeeded; 1153/1172 tests passed (19 skipped); test-kernel-contract 43/43; dev-tools 215/215; zig fmt --check build.zig zig clean; pair SUMMARY 15 kernels: 15 pass gates 1-4, 0 fail, 0 known findings; 35 controls: 35 red as planted, 0 missed; 0 limit problems; gates 300 ms, limits 146 ms; simd SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 0 controls: 0 red as planted, 0 missed; 0 limit problems; gates 34032 ms, limits 121 ms; lean 428 problems, 428 on the base, 0 new. tf-qwen27-tp-check is not a step at this commit; step 4 adds it, and this head is rebased on #1432's new one.

@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. All four are in on b3d9b7e7:

  1. q27_attn_merge takes the rank's head count and the prompt merge the rank's KV heads. On one Mac those are 24 and 4, so main's indexing is unchanged.
  2. The split keys on the tensor's own rows. in_proj_qkv's three blocks key on 10,240 rows (4 slices, 2 on the M5), as main's single tensor did, and the "every one-Mac stack keeps main's K split" table matches main's split() and m5Split() for all eight stacks.
  3. gn reaches simd_qmm_bits through strided and the fused runs, and the twin check is keyed on it.
  4. The unused fields are gone, and the body no longer claims a record.

Keeping b and a whole in a rank's zba, so its 3,168 rows stay on the tiled projection, is a nice touch. The M5 Ultra run set out on #1432 covers this step on the lane kernels, which no M4 can show.

akol1 added 3 commits October 28, 2026 07:25
Step 1 of 6 of the two-Mac 27B series; stacked on main (1b9a987) alone,
and steps 2-6 stack on this commit.

The seam exchanges whole output rows instead of K halves: rank r owns
2,560 of the 5,120 output columns (8,704 of 17,408 for the down
projection), reduces over the full K on its own rank, and `pair_gather`
copies whole rows in bf16 without doing any arithmetic, so no exchange
rounds or sums a partial product across ranks. A broken link surfaces as
bf16 NaN `0x7FC0`.

The seam's four kernels (`pair_stage`, `pair_gather`, `pair_argmax`,
`pair_pick`) move bf16 frames and compare values only, so they carry no
sums and no accumulator's error. One Mac fails a row holding any NaN or
±Inf, so `pair_argmax` reports such a row as NaN bits with bit 31 set in
its id word, and `pair_pick` turns that flag into the id sentinel: both
Macs then refuse a non-finite round where one Mac would, through the same
downstream check as a broken link. Five planted controls (a stage on the
peer's half, a gather writing its own half twice, an argmax and a pick
tie to the higher id, an argmax that does not flag) each go red on the
gate named in `docs/kernel-contract.md`; the next commit adds the sixth,
for `pair_pick`'s flag rule itself.

That transport is a new module, `pair_rows.zig` and `pair_rows.metal`,
over `pair_rows_layout.zig`'s slots and the relay protocol unchanged.
`pair.metal` and `pair.zig` stay for GLM-5.3-Flash, whose transport this
series does not touch: a shipped path cannot change without an A/B
against main.

`simd_qmm` gains a K-chunk width: `chunks(n, whole)` lets a rank that
still holds the whole output width keep the split one Mac derives from
that width, instead of one derived from the rank's narrower slice.
The pick's sentinel rule was the one pair rule without a planted
control. The argmax control proves the row's non-finite flag is written,
but nothing proved `pair_pick` acts on it: a pick that dropped the
`((am | ap) & PAIR_FLAG) != 0u` test would still pass every gate.

The sixth control replaces that test with `false`, planted only at the
nonfinite fill -- at shapes both ranks' words are finite, so dropping
the rule is invisible there and the control has to be red at the gate
that plants it. The control is red as planted, and the pair docs now
count six.
Step 2 of 6, stacked on step 1 (`qwen27/pair-seam`) and main (1b9a987);
step 3 stacks on this commit.

A rank's layer program now carries one `Reduce` interface instead of
assuming the whole matrix. Attention's o_proj and the MLP's down_proj
seam this rank's output columns, gather the whole frame, and let the
producer kernel reduce over full K on its own rank; the head seams the
last row's logits and takes the pair's argmax over the whole vocab, so
the picked token is the single Mac's token, not rank 0's half-vocab
pick. On one Mac `reduce` is null and every seam is compiled out of the
program: the single-Mac program, its digests and its tests are the ones
that ran before.

The rank-local K-split is fixed while the slice is. A projection's split
is keyed on the checkpoint tensor's rows, not the view's rows: `Triple`
carries `whole` (the matrix's row count) and `prepare` derives
`tensorRows` from it, while the M5 and prompt tables keep the checkpoint
rows they always keyed. `in_proj_qkv` loads as three blocks of one tensor
(10,240 rows), so its gate/up-style stack keeps main's 4 K slices on
6,144 rows instead of reading the q block's 2,048 rows and splitting into
8; every width main served on one Mac keeps main's split, and host tests
"a rank's row slice keeps the checkpoint matrix's K split" and "every
one-Mac stack keeps main's K split" cover the eight stacks (qkv 10,240
rows: 4, q 6,144: 4, against the 2,048-row view's 8). `Part` drops its
unused `k0`, `Triple` its `k`/`k0`, and `Shard`/the family's private
`cols`/`attentionCols`/`gdnCols`/`downCols` and the column-window code
`prepare`/`packTiles` used them for are gone, with `config_test.zig`'s
column-span assertions deleted and the row fields left as they were.

`simd_qmm.call` takes the rank's `whole` width and `chunks(n, whole)` picks
the chunk fence from it, so a rank's K tree is its own full matrix's while
a one-Mac stack passes 0 and keeps exactly main's fence (the core test
pins `chunks` on 6,144/12,288 and 5,120/10,240 at 8, and on 6,144 and
5,120 alone at 16, which is the pair's own-width split the `gn` forwarding
is there to avoid). `quant_gpu.strided` forwards the `gn` of the linear it
serves.

`q27_attn_merge` takes the rank's head count (buffer 11) and strides a row
by that many heads instead of 24 (`G * 4`), so the non-prompt multi-row
rounds a served chat feeds every earlier reply through merge the rank's
own rows; `attention.zig` binds `g.heads`. The buffered form is only what
a pair runs. One Mac has its 24 heads at compile time, and main's kernel
used them there (`G * 4`, and `PG * 4` in the prompt merge), so each site
now has a second instance: `q27_attn_merge_single` and
`q27_attn_prompt_merge_single` carry main's body verbatim — same
reduction, same float arithmetic, main's indexing, no `heads` or
`kv_heads` buffer — and the encoder takes a trailing `split` and picks
`io[4]`/`prompt[1]` for a pair and `io[10]`/`prompt[2]` for one Mac
(`decode_round.zig` passes `r.reduce != null`). The two instances compute
the same values, so only the emitted code differs. A function constant
would need `MTLFunctionConstantValues`, which the runtime library never
creates (`PipelineBuild` would fail the contract's first gate), so the
second compiled instance is the route that needs no new Metal machinery. The config gains its shard
parse (`config_test.zig` covers it), the embedding table stays whole on
every rank — only the head is sliced — with `embedBound` reading the
table's bound rather than the family's `vocab_size`, and the session keeps
the pair's pick as the round's token and surfaces `error.PairFailed` from
the head seam's sentinel rather than returning `maxInt(u32)`. The seam's
`Reduce` is threaded through `forward.encodeTp`/`session`, and the prompt
attention kernel's signature follows the seam's reopened encoder.
@akol1
akol1 force-pushed the qwen27/rank-slice branch from b3d9b7e to a2a94c8 Compare October 28, 2026 15:19
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