Skip to content

qwen27: serve the 27B pair, refuse what a pair cannot keep, and die with the peer - #1434

Open
akol1 wants to merge 2 commits into
ashhart:mainfrom
akol1:qwen27/pair-lifecycle
Open

akol1 wants to merge 2 commits into
ashhart:mainfrom
akol1:qwen27/pair-lifecycle

Conversation

@akol1

@akol1 akol1 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Step 3 of 6 of the two-Mac Qwen3.8-27B series, rebased onto main (94b90c9db), stacked on step 2 (qwen27/rank-slice, 1af209fce); step 1 (#1432) is merged and its content arrived in the squash merge 3f0a715c. Step 4 stacks on this commit.

The serving path learns the pair. qwen27_pair.zig is the transport's types and the seam ABI the engine calls, with the window one exchange may carry: seamBytes(rows) = rows * 8,704 * 2, chunk(requested) = min(requested, batch_rows), batch_rows = 128, and a host test that pins seamBytes(chunk(promptRows())) <= pairBytes() and seamBytes(512) > pairBytes() — the served pass asks for 512 rows on tensor units, and only the cap keeps its widest seam (this rank's half of the 17,408-column activated frame) inside the window. qwen27_link.zig is the link's liveness: rank 1 replays rank 0's four GPU operations a pair round is made of (begin, chunk, step, end), and both ranks stop when the link dies instead of one serving alone. The watchdog and rank 1's follower are the two threads of qwen27_link.Workers; close sets their quit and joins both, so neither outlives the pair it watched. qwen27_slots.zig understands that both ranks hold the frame: a slot's opening, fill, step and close announce the link's begin, chunk, step and end (glm_dsa's order), so both ranks stage the same round; the window timing stays main's qwen27_costs.zig, unchanged. zig/build/qwen27_host.zig adds the test-qwen27-served-host step and build.zig makes it part of zig build test, so the refusals' words are pinned on a CPU alone. A pair never resumes a slot in place: rank 1 resets the slot at each round's begin (link.follow), so attach passes the switch Metal.init read through lb.slotResume(init, pair), which keeps TF_QWEN27_SLOT_RESUME=0 off on one Mac and turns resume off for a pair (lb.resumesInPlace), or the ranks would part at the resumed offset; the host test pins that composition on both paths.

A pair refuses what it cannot keep, by name, before the backend moves any round state: rank 1 answers "speed-up mode: this Mac runs rank 0's requests; send requests to rank 0" (FollowsPeer), a sampled or grammar request is refused at the head of prefillFn in qwen27_lanes.zig (the check is pair_mod.refuseSampling, which lives in qwen27_pair.zig so the lane host stays inside the 600-line rule) with "speed-up mode: a pair answers greedy only; send the request with sampling and grammar off" (PairGreedyOnly) rather than being answered greedily for one token and failing on the next, --vision with a message that starts "--vision does not run on a pair" (PairNoVision), a second stream ("--speed-up serves one greedy stream: pass --parallel 1", PairOneStream), a drafter, read from the flag the user passes rather than from the loaded object (PairNoDrafter), a prompt cache or --learn (PairNoCache, PairNoLearn), and a watchdog that would not start (PairNoWatchdog, so a half cannot keep serving after its peer dies) — all named in one place, qwen27_pair_flags.zig; qwen27_pair.zig's refuseSampling and refuseFollower call them from the head of prefillFn, and qwen27_served.zig calls refusePairFlags before the backend moves any round state. For the drafter that call is refusePairFlags in open's first statement — before Pair.init, the shard or the vision tower loads — and in attach's first statement, before the loaded drafter is read; the host test builds Options{ .pair, .drafter } and hands it to the same helper, with a bound drafter as its second case, so a refusal that ever stops firing turns that test red. qwen27_pair_tests.zig is imported by qwen27_served.zig into the CPU-only test-qwen27-served-host step, so these words are pinned without shaders or a GPU; the tests leave the Metal and Stream fields undefined except the ones the guard reads, which proves the refusal happens before any other step.

A rank's verify never runs a tail. forward.zig's finish runs the head seam first and hands the reopened encoder back to the tail (zig/src/families/qwen27/forward.zig:230, :241); when the seam is head == .none it is skipped and the tail still runs, which is the one-Mac path. Because the head writes only this rank's vocabulary columns while the tail's matcher reads the whole row, decode_round.zig:221 refuses a round that has both with error.TailOnRank before the cache checks or any GPU work; zig/qwen27_bank_boundary_host.zig:53 pins that refusal with model-free stubs, and from step 3 on a pair refuses --drafter at the front door, so the two never meet.

Receipt

  • Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: this commit (eb8f417cf0c01a7021784a3295f4595f693d2cfd) on main (94b90c9db65dcb71bbc39715901d6fb1f00407d0) plus step 2; 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. The host refusals and the window arithmetic need no checkpoint; the point where this commit needs weights (a served pair) is not exercised here: step 4's gate runs two rank-local shards over the seam's loopback link on one Mac, and step 5's oracle is the two-Mac acceptance.
  • Exactness, from tools/bench_concurrent.py --alone --serial or token_sha against "draft": false: n/a at this commit — it adds the serving pair but no model gate; the exactness evidence it makes possible is step 4's tf-qwen27-tp-check (per-token shas against the single Mac) and step 5's pair_accept.py oracle. Nothing here changes a sum: the chunk cap can only lower a request's row count to the window's 128 rows.
  • Decode speed before and after, from tools/bench_openai.py: n/a at this commit; the pair's decode rate on two Macs is step 4's and step 5's receipt, and a loopback figure would be one Mac's.
  • Prompt speed before and after: n/a at this commit; the seam window caps a pair's prefill round at 128 rows, so a served 512-row pass is fed as 128-row rounds (step 4's --chunk gate pins the bound). The cap is a correctness bound on the exchange, not a throughput claim. The series-level cold-prefill A/B was recorded on the earlier tree and is not re-run at this commit; a pair's prefill is capped by construction rather than measured here.
  • Tests run, with pass, fail and skip counts: zig build test: 582/582 steps succeeded; 8771/8824 tests passed (53 skipped) (run here with a cold local cache, so these are the counts of the whole suite; every step succeeded), exit 0. The CPU-only pair tests are in that run: rank 1's refusal, the sampled and grammar refusal with the exact words, the chunk-times-seam window test, the --vision refusal and the --drafter wiring test that passes Options with .drafter set through the same helper open calls. A third test pins slotResume: a pair refuses in-place resume at either rank, a single Mac keeps the switch Metal.init read, and TF_QWEN27_SLOT_RESUME=0 stays off, so a refusal that put the user's switch back on turns it red. zig build test-kernel-contract: 20/20 steps succeeded; 141/141 tests passed, exit 0. zig build dev-tools: 219/219 steps succeeded. zig build test-qwen27-served-host: 7/7 steps succeeded; 48/49 tests passed (1 skipped), exit 0; the CPU-only pair tests it runs are the ones above. zig build tf-qwen27-tp-check: no such step at this commit (step 4 adds it). zig fmt --check build.zig zig: clean. Filtered contract runs: SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 6 controls: 6 red as planted, 0 missed; 0 limit problems; gates 33 ms, limits 121 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 30077 ms, limits 121 ms. Lean rule: lean: 424 problems, 424 on the base, 0 new (tools/zig/lean_new.py, exit 0).
  • Not run, and why: a two-Mac run of this commit (the supervisor scripts are step 5's; step 5's receipt is that run, and it exercises this commit's liveness end to end — both ranks start, stop, and the watchdog ends a rank when its peer stops; that receipt was recorded before the series was rebased onto this base and cannot be re-run now that the second Mac is gone); the model gates (tf-qwen27-tp-check arrives in step 4); the unfiltered kernel corpus (UNSUPPORTED metal moss image); 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 pair path replays rank 0's GPU rounds on rank 1 and breaks the lane into per-round commands; nothing serial answers a token, and a sampled or drafted request is refused instead of served differently.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. — a pair refuses --drafter from the flag the user passes, before the loaded object and refuses sampling/grammar, so there is no drafted pair output to compare; the one-Mac equality gates are main's and steps 4-5 add the pair's.
  • No precision traded for speed. If the bits change, the description says which sums change and why. — no arithmetic changes; the only row-count change is the chunk cap, which moves a request's rows to the window's 128 rather than splitting it differently.
  • 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. — this commit adds no GPU kernel; it drives the four pair launches that core: add the row-split pair seam and split simd_qmm over K #1432 put on main and the family's existing kernels. The filtered contract runs in the receipt are the regression check for the kernels it talks to.
  • Prompt processing is no slower than the last release. — n/a, this commit adds no prompt kernel; a pair's prefill is capped at the window's rows by construction, and the one-Mac prompt path is unchanged.
  • 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); the host-only tests need no GPU; no CUDA, HIP, M1-M3, M5 or Windows.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. — the parent commit has no qwen27_pair_tests.zig, no PairGreedyOnly, no pairChunk; those tests fail to build there, and they skip nothing because they run on CPU alone.
  • Comments and docstrings are one line. No measurements or history in the source. — lean: 424 problems, 424 on the base, 0 new.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines. — the diff names no user path, hostname or peer address and reads no environment variable: the refusals it adds name flags and reasons, not machines or paths, and the peer state directory is step 5's default.

Stacked on: qwen27/rank-slice (step 2). This commit is step 3 of 6.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Besides the token equality in #1432 and #1433:

  • Rank 1 serves HTTP. A request sent to rank 1's port calls settle, dropRound, promoteDue and bind on the backend the follower thread is mutating, and only then fails with an unnamed FollowsPeer. Refuse it by name before touching the backend ("send requests to rank 0", as GLM and Flash Next do), with a test.
  • The watchdog fails open. If its thread can't spawn, the server logs "a dead peer leaves this rank up" and serves anyway. It must fail closed.
  • Images. I found no refusal of --vision or image prompts on a pair. Rank 1 replays ids only, so an image prompt would run different inputs on the two ranks. Add a named refusal and a test.
  • Scope. server/dump.zig (TF_DUMP_IDS) writes prompt and output ids to disk for every family, with the start of the user's message in the filename. It's out of scope here and puts prompt content on disk. Please drop it, and move the benchmark refactor out too.
  • Line limit. After a rebase on main, zig/src/server/cli.zig is 603 lines, one new lean finding. Main grew it to 597.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch 3 times, most recently from 0269c7b to 277929c Compare October 27, 2026 22:38
@akol1

akol1 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

All five are in the refreshed head 277929ca:

  • Rank 1 refuses HTTP before touching the backend. The refusal is at the head of prefill, rank 1's first backend call, and returns error.FollowsPeer before settle, dropRound, promoteDue and bind. The server names it: "speed-up mode: this Mac runs rank 0's requests; send requests to rank 0". A test pins both the ordering and the message.
  • The watchdog fails closed. If its thread can't start, the server returns error.PairNoWatchdog instead of logging and serving; a test covers the refusal.
  • Images are refused. --vision and image prompts return error.PairNoVision by name, with a test; rank 1 replays ids only, so an image prompt would run different inputs on the two ranks.
  • server/dump.zig and the benchmark refactor are dropped from this series; there is no TF_DUMP_IDS path left in the trees.
  • Line limit. zig/src/server/cli.zig is 599 lines on the refreshed head (main is also 599), so there is no lean finding, and tools/zig/lean_new.py reports 0 new against 5897e0bc (428 problems on both).

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1: all five earlier points are in. Rank 1's refusal comes before any backend call and has a test. The watchdog fails closed. --vision is refused by name. server/dump.zig is gone. Moving measure into qwen27_warm.zig is a pure move. Two serving defects remain:

  1. On an M5 the served pair prefills in 512-row chunks (zig/src/native/qwen27_served.zig:393, zig/src/families/qwen27/model.zig:186, attention_plan.zig:54). The seam window holds 128 rows of the widest seam (qwen27_pair.zig:87-88). So any prompt longer than 128 tokens reaches the @panic at zig/src/core/pair_rows.zig:290, on both ranks. tf-qwen27-tp-check cannot show this, because --chunk stops at 128. Please cap the pair's chunk at the window's rows, or size the window from the chunk. Add a host test that the served chunk times the widest seam fits pairBytes().
  2. Sampled and grammar requests are not refused before work starts. Both ranks prefill, firstFn returns the greedy pick for a sampled stream (qwen27_lanes.zig:406-409), and only the next draw fails, with an unnamed PairGreedyOnly (:224). Please refuse s.sampling and s.grammar at the head of prefillFn with a message in words(). A test like the FollowsPeer one would cover it.

This also needs #1432's rebase.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch 2 times, most recently from ec2dc03 to 03d0de3 Compare October 28, 2026 04:09
@akol1

akol1 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Refreshed and force-pushed: qwen27/pair-lifecycle is now 03d0de36, rebased onto current main (db3fd5ab), with the body rewritten around this head.

  1. The pair's chunk is capped at the seam window's 128 rows — chunk(requested) = min(requested, batch_rows), batch_rows = 128 — so a served pair no longer lets the 512-row prompt pass reach @panic at pair_rows.zig. seamBytes(rows) = rows * 8,704 * 2, and a host test pins seamBytes(chunk(promptRows())) <= pairBytes() and seamBytes(512) > pairBytes(): the served pass asks for 512 rows on tensor units and only the cap keeps its widest seam (this rank's half of the 17,408-column activated frame) inside the window. A test in qwen27_pair_tests.zig exercises chunk() directly on top of that.

  2. A sampled or grammar request is refused at the head of prefillFn, before any backend moves a round: speed-up mode: a pair answers greedy only; send the request with sampling and grammar off (PairGreedyOnly, in words()), rather than being answered greedily for one token and failing on the next. A test of the same shape as the FollowsPeer one pins the message, in the CPU-only test-qwen27-served-host step that zig build test now runs.

Gates on 03d0de36: zig build test 563/563 steps succeeded; 359/377 tests passed (18 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 314 ms, limits 149 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 33367 ms, limits 117 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.

@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. Both are in on 03d0de36. The served pair's chunk goes through pairChunk (qwen27_served.zig:397), so a 512-row pass reaches the seam as 128-row rounds, and the host test pins the window. prefillFn now refuses a sampled or grammar request before anything else, with its words and a test. The M5 Ultra run set out on #1432 includes a check that 128-row rounds give the same bits as one Mac's 512-row rounds there.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch 3 times, most recently from 4c14ed9 to c7dcbce Compare October 28, 2026 17:00
@akol1

akol1 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both are in, and they did not move under the rebases. On the current head c7dcbce2, rebased onto dd0a9cc5:

  1. The served pair's chunk is capped by the window: qwen27_served.zig:393-394 does if (pair != null) h.back.chunk = lb.pairChunk(@intCast(h.back.chunk));, with pairChunk = pair_mod.chunk (qwen27_lanes.zig:28), so a 512-row pass reaches the seam as 128-row rounds. The host test the served pair's chunk times the widest seam fits the window (qwen27_pair_tests.zig:48) asserts chunk <= batch_rows, seamBytes(chunk) <= pairBytes(), and that the uncapped seamBytes(512) > pairBytes() — so the cap is what keeps the exchange legal, and the test would notice if either side of that inequality moved.
  2. prefillFn refuses before any backend call: try refusePairSampling(b, s); try refuseFollower(b); (qwen27_lanes.zig:400-401). refusePairSampling (:392-395) returns error.PairGreedyOnly for s.sampling != null or s.grammar != null, and words() names it "speed-up mode: a pair answers greedy only; send the request with sampling and grammar off" (qwen27_served.zig:127); refuseFollower (:387-388) gives rank 1 "speed-up mode: this Mac runs rank 0's requests; send requests to rank 0". The tests are qwen27_pair_tests.zig:9 (FollowsPeer), :25 (PairGreedyOnly), and :57 (--vision refused by name).

Fresh receipts on c7dcbce2: zig build test 574/574 steps succeeded; 358/376 tests passed (18 skipped), exit 0; zig build dev-tools 215/215; zig build test-kernel-contract 43/43; the pair filter SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 6 controls: 6 red as planted, 0 missed; 0 limit problems; gates 64 ms, limits 212 ms, the simd filter 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 41543 ms, limits 140 ms; lean 425 problems, 425 on the base, 0 new.

The commits above this one add the tp-check option, the oracle tooling and the docs; the four files behind the cap and the refusals are unchanged except for step 5's /health policy line — git diff c7dcbce2 05bb79a9 -- zig/src/native/qwen27_served.zig zig/src/native/qwen27_lanes.zig zig/src/native/qwen27_pair_tests.zig zig/src/core/pair.zig reports only that one file, +14/−1. Your M5 stage-3 cell is the receipt for a 512-row prompt reaching the pair's 128-row rounds; on this Mac the cap is the same code, and the host test above is what pins it.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from c7dcbce to 5d2db8e Compare October 29, 2026 04:21
@akol1

akol1 commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the current main (7c068895) and force-pushed; the head is now 5d2db8e8294e46ccbf3d7a01d72133cca76f0222. The series order is unchanged, and these are re-run numbers from the rebased head, not carried over.

zig build test: 574/574 steps succeeded; 235/253 tests passed (18 skipped); zig build test-kernel-contract: 43/43; zig build dev-tools: 215/215; zig fmt --check build.zig zig clean; filtered pair run gates 32 ms, limits 107 ms, filtered simd run gates 29770 ms, limits 103 ms; lean rule 425 problems, 425 on the base, 0 new.

The rebase also moved this commit's refusal words into one small module, zig/src/native/qwen27_pair_flags.zig (20 lines, one-line header): qwen27_served.zig was 601 lines when the refusals lived in it, one over the lean rule's 600, so the flags and their messages are named in that module and qwen27_served.zig calls it before the backend moves any round state. Every message string is unchanged.

This room is one Mac now: the second Mac the pair receipts were recorded on is gone (the cluster was broken), so a fresh two-Mac pair_accept.py run is not possible here. The bodies keep that run labelled as a dated receipt from before the rebase and put the single-Mac loopback gate (tf-qwen27-tp-check) and the host tests next to it. No other question from the earlier comments is still open.

@ashhart

ashhart commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. All the earlier points are in, and the one-Mac path looks unchanged: with no link the refusals do nothing, the chunk cap and the cache, rewind and learn wiring stay as on main, and moving the window timing into qwen27_warm.zig is a pure move. One serving defect remains, plus a small cleanup:

  1. A resumed prompt desyncs the two ranks. The pair leaves slot 0's in-place resume on (slot_resume defaults to true, and attach doesn't clear it for a pair). After a reply, unbind -> leave records where slot 0 stands on rank 0. When the next prompt extends that prompt plus its reply, opening() (qwen27_slots.zig:35-44) sends begin, takeStanding returns n > 0, and rank 0 keeps its state and sends only the suffix chunks. Rank 1's follower resets the slot on every begin (qwen27_link.zig:24) and replays that suffix from position 0. The seam row counts still match, so nothing fails: the two halves just hold different state, and the reply is wrong. Please turn slot resume off on a pair (for example if (pair != null) h.back.slot_resume = false; in attach, next to where the cache and rewind are skipped), or carry the resumed offset in begin so rank 1 skips its reset. Please also add a host test that a pair never resumes in place. Step 5's cold check would catch this only when the resume actually applies, so a host test is the reliable pin.

  2. Start-up error path. The watchdog thread starts before h.host.start() and before rank 1's follower spawns. If either of those fails, attach's errdefers free the host, which holds quit, and open's errdefer frees the pair, while the watchdog still reads both. An errdefer right after the spawn that sets quit, joins the watcher (and stops following) would close this.

Once those are in, this lands in order after #1432 and #1433.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from 5d2db8e to ebb337d Compare October 30, 2026 03:05
@akol1

akol1 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (581cbe65); qwen27/pair-lifecycle is ebb337d4269160886983754c9fa26fd3148a53e4, one commit on step 2 (d844977e).

Both review points are in:

  1. A resumed prompt desyncs the ranks. resumesInPlace is pair == null (zig/src/native/qwen27_pair.zig:102), and attach sets h.back.slot_resume = lb.resumesInPlace(pair); (zig/src/native/qwen27_served.zig:404), so slot 0 no longer resumes in place on either rank: rank 0 opens cold exactly when rank 1 resets at begin. The host test is zig/src/native/qwen27_pair_tests.zig:59, test "a pair never resumes a slot in place"; it asserts !resumesInPlace(&pair) for a rank-0 pair and a rank-1 pair, and resumesInPlace(null) for a single Mac (main's in-place resume, Proposal: Qwen3.8-27B follow-up turns resume from slot 0 (Metal, small-memory Macs) #1197).
  2. Start-up error path. Right after the watchdog spawn (zig/src/native/qwen27_served.zig:461) an errdefer (:466-472) sets h.quit, calls p.stopFollowing(), joins the follower and joins the watcher. It sits before h.host.start() (:474) and before rank 1's follower spawn (:477), so a failure in either tears down what the watchdog reads before open frees it.

Numbers from this head, run here, not carried over: zig build test 578/578 steps succeeded; 375/395 tests passed (20 skipped); zig build test-qwen27-served-host 7/7 steps succeeded; 37/38 tests passed (1 skipped); zig build test-kernel-contract 20/20 steps succeeded; 114/114 tests passed; zig build dev-tools 215/215 steps succeeded; zig fmt --check build.zig zig clean; filtered pair run SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 6 controls: 6 red as planted, 0 missed; 0 limit problems; gates 33 ms, limits 127 ms; filtered simd run 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 32444 ms, limits 125 ms; lean rule 425 problems, 425 on the base, 0 new. tf-qwen27-tp-check is not a step at this commit; step 4 adds it.

The paths that need two Macs are not re-run here: the second Mac the pair receipts were recorded on is gone. The host test pins the resume rule on one Mac, and step 4's loopback gate compares the pair against the single Mac byte for byte.

@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. Everything from our earlier review is answered, and nothing changes on a single Mac: every new branch needs --speed-up. One thing must change before this lands, though.

--drafter is never refused on a pair. In qwen27_served.zig, attach checks pair_flags.refuse(drafter != null, ...). But open() always passes null for that drafter parameter, since it is only for a caller that already loaded a drafter model. The path --drafter sets reaches attach as o.drafter. So with --speed-up pair.json --drafter X, error.PairNoDrafter never fires. Instead the drafter loads against the rank-local shard, warm-up times a 16-row window through the seam, and the user gets a generic CommandOutOfStep load error, or a hang if the two ranks were started with different flags.

To fix it:

  • Check the flag the user passes (o.drafter != null), and preferably do the refusal in open() before Pair.init and before images.open, so neither the shard nor the vision tower loads first.
  • Add a host test that goes through that wiring with .drafter set (for example, a small helper that takes Options, called from both open and attach), so a refusal that never fires turns the test red. Today qwen27_pair_tests.zig calls pair_flags.refuse directly, so it can't catch this.

Until then, the changelog line and the description shouldn't say --drafter is refused by name. Once it's in, this goes in with #1433, after #1433's one-Mac cells.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from ebb337d to 6febdc2 Compare October 30, 2026 16:11
@akol1

akol1 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Both parts of the --drafter point are in, on a new head e550d50a, one commit on step 2 (579f9b45), rebased onto main (40cb15b4ebbbae4aa5ef028b264363bfd5a18524), which now carries the cost-rule retarget the tree needs. Against the head you reviewed, every difference is either main's advance (54 files), or one of: the fix below (qwen27_served.zig, qwen27_pair_tests.zig); the step-3 link keeping its follower and watchdog as qwen27_link.Workers, a move of the two thread bodies with the same order in stop (qwen27_served.zig only calls startWatch, startFollower, stop); and qwen27_warm.zig retired, because the window timing it held now comes from main's qwen27_costs.zig. Steps 4-6 carry the fix unchanged.

  1. The refusal reads the user's flag, before anything loads. refusePairFlags(pair, o, bound_drafter, why) (zig/src/native/qwen27_served.zig:294) passes bound_drafter or o.drafter != null into pair_flags.refuse, and it is the first statement of both open (:300, before Pair.init, the shard and images.open) and attach (:333, before the loaded drafter is touched). attach still takes o, so a caller-bound drafter is refused there too; open reads only o.drafter.
  2. A host test goes through that wiring, not through pair_flags.refuse. zig/src/native/qwen27_pair_tests.zig:76, test "--drafter is refused at the front door, by name": it builds the same served.Options open receives, with .pair = "rank0.json" and .drafter = "z-lab/Qwen3.8-27B-DFlash2", and expects error.PairNoDrafter with a message starting --drafter does not run on a pair; a second case keeps the pair, sets .drafter = null and passes bound_drafter = true (the attach shape); a third call with pair = false passes, so one Mac keeps its drafter. It runs in the CPU-only test-qwen27-served-host step, so a refusal that stops firing turns the test red without a GPU. The description and the recipe say the refusal happens before the shard or the vision tower loads; this branch edits no changelog line naming refused flags.

Numbers from this head, run here, not carried over: zig build test 578/578 steps succeeded; 8565/8617 tests passed (52 skipped); zig build test-qwen27-served-host 7/7 steps succeeded; 43/44 tests passed (1 skipped); zig build dev-tools 215/215 steps succeeded; zig build test-kernel-contract 20/20 steps succeeded; 138/138 tests passed; zig fmt --check build.zig zig clean; filtered pair run SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 6 controls: 6 red as planted, 0 missed; 0 limit problems; gates 36 ms, limits 127 ms; filtered simd run 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 32295 ms, limits 126 ms; lean rule 425 problems, 425 on the base, 0 new. tf-qwen27-tp-check is not a step at this commit; step 4 adds it.

Two Macs are still not re-run: the second Mac the pair receipts were recorded on is gone, so the two-Mac pair_accept.py receipt remains step 5's, recorded before the rebases. The host test pins the refusal on one Mac.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from 6febdc2 to 6db850c Compare October 30, 2026 17:38
@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. The --drafter point is answered: refusePairFlags is the first thing open() and attach() do, before the seam, the shard or the vision tower loads, and the new test goes through that helper with .drafter set, so a refusal that stops firing would fail it.

One more change before this lands, on the single-Mac path:

attach now overrides TF_QWEN27_SLOT_RESUME=0 on one Mac. Metal.init reads the switch into slot_resume (slots.slotResumeEnv()). Then attach sets h.back.slot_resume = lb.resumesInPlace(pair); with no condition. With no pair that is true, so a user who turned in-place resume off with TF_QWEN27_SLOT_RESUME=0 (the README documents this) gets it back on. Please only ever turn it off for a pair, for example h.back.slot_resume = h.back.slot_resume and lb.resumesInPlace(pair); or if (pair != null) h.back.slot_resume = false;. Please also add a host test that would catch this: the current test checks resumesInPlace() on its own, so it passes either way. A small helper that takes the value from init and the pair, called from attach, would let the test cover the real wiring.

Everything else from our earlier reviews is still in place after the rebase, and this applies cleanly on current main along with step 2. Once the fix is in, this lands together with #1433, after its one-Mac checks.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch 2 times, most recently from e550d50 to 161b323 Compare October 30, 2026 19:23
@akol1

akol1 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Both the --drafter point and your slot_resume catch are in, on a new head 161b323f, one commit on step 2 (663bd7a0), rebased onto main (40cb15b4ebbbae4aa5ef028b264363bfd5a18524), which now carries the cost-rule retarget the tree needs. Against the head you reviewed, the only change is the slotResume fix you asked for: four files under zig/src/native (qwen27_pair.zig, qwen27_lanes.zig, qwen27_served.zig, qwen27_pair_tests.zig), +15/-8. There is no base advance: both heads sit on main's 40cb15b4, which already carries the cost-rule retarget, so the tree under this gate is green. The --drafter fix you confirmed at the front door is unchanged, and steps 4-6 carry both fixes unchanged.

  1. The refusal reads the user's flag, before anything loads. refusePairFlags(pair, o, bound_drafter, why) (zig/src/native/qwen27_served.zig:294) passes bound_drafter or o.drafter != null into pair_flags.refuse, and it is the first statement of both open (:300, before Pair.init, the shard and images.open) and attach (:333, before the loaded drafter is touched). attach still takes o, so a caller-bound drafter is refused there too; open reads only o.drafter.
  2. A host test goes through that wiring, not through pair_flags.refuse. zig/src/native/qwen27_pair_tests.zig:77, test "--drafter is refused at the front door, by name": it builds the same served.Options open receives, with .pair = "rank0.json" and .drafter = "z-lab/Qwen3.8-27B-DFlash2", and expects error.PairNoDrafter with a message starting --drafter does not run on a pair; a second case keeps the pair, sets .drafter = null and passes bound_drafter = true (the attach shape); a third call with pair = false passes, so one Mac keeps its drafter. It runs in the CPU-only test-qwen27-served-host step, so a refusal that stops firing turns the test red without a GPU. The description and the recipe say the refusal happens before the shard or the vision tower loads; this branch edits no changelog line naming refused flags.
  3. attach no longer turns a single Mac's TF_QWEN27_SLOT_RESUME=0 back on. It wrote h.back.slot_resume = lb.resumesInPlace(pair), which on one Mac (pair == null) replaced the switch Metal.init read, and the host test only asked resumesInPlace(pair), so the regression stayed green. It now passes the loaded switch through slotResume(init, pair) = init and resumesInPlace(pair) (zig/src/native/qwen27_pair.zig:107), exported as lb.slotResume (qwen27_lanes.zig:31) and called from attach at qwen27_served.zig:420: TF_QWEN27_SLOT_RESUME=0 stays off on one Mac, and a pair still opens cold at both ranks because rank 1 resets the slot at each round's begin. The test, a pair never resumes a slot in place, and one Mac keeps the switch it read (qwen27_pair_tests.zig:58), now draws both directions: slotResume(true, &pair) is false for rank 0 and for rank 1, slotResume(true, null) true, and slotResume(false, null) false — a refusal that puts the user's switch back turns it red.

Numbers from this head, run here, not carried over: zig build test 578/578 steps succeeded; 8565/8617 tests passed (52 skipped); zig build test-qwen27-served-host 7/7 steps succeeded; 43/44 tests passed (1 skipped); zig build dev-tools 215/215 steps succeeded; zig build test-kernel-contract 20/20 steps succeeded; 138/138 tests passed; zig fmt --check build.zig zig clean; filtered pair run SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 6 controls: 6 red as planted, 0 missed; 0 limit problems; gates 36 ms, limits 135 ms; filtered simd run 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 32420 ms, limits 125 ms; lean rule 425 problems, 425 on the base, 0 new. tf-qwen27-tp-check is not a step at this commit; step 4 adds it.

Two Macs are still not re-run: the second Mac the pair receipts were recorded on is gone, so the two-Mac pair_accept.py receipt remains step 5's, recorded before the rebases. The host tests pin both fixes on one Mac.

@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from 161b323 to 573105e Compare October 31, 2026 01:45
akol1 added 2 commits October 30, 2026 18:54
Step 2 of 6, stacked on step 1 (`qwen27/pair-seam`) and main
(`40cb15b4`); 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.
…ith the peer

Step 3 of 6, stacked on steps 1-2 (`qwen27/pair-seam`, `qwen27/rank-slice`)
and main (`40cb15b4`); step 4 stacks on this commit.

The serving path learns the pair: a rank reads its rank-local shard, the
serve loop routes every seam through the link, and the head's pick is
the reply. A pair refuses what it cannot keep: vision, a rank count the
shard does not match, and a drafter whose state only rank 0 holds, and
it fails closed when its watchdog cannot be started. The slot accounting
understands that both ranks hold the frame.

A pair also keeps three serving rules. Its prefill chunk is capped at the
seam window's rows (`pair.chunk`), because each chunk stages a seam and
the window holds `batch_rows` of them; the host test pins that the
served chunk times the widest seam fits `pairBytes()`. A sampled or
grammar-constrained request is refused at the head of `prefillFn`
(`error.PairGreedyOnly`) instead of being answered greedily and failing
on the second token, with a name in `words()` and a host test. And a
pair never resumes a slot in place: rank 1 resets the slot at each
round's `begin` (`link.follow`), so `attach` passes the switch
`Metal.init` read through `lb.slotResume(init, pair)`, which keeps
`TF_QWEN27_SLOT_RESUME=0` off on one Mac and turns resume off for a pair
(`lb.resumesInPlace`), or the ranks would part at the resumed offset; the
host test pins that composition on both paths.

A start-up step that fails after the watchdog is up is undone: the two
threads live in `qwen27_link.Workers`, so `attach` sets their `quit`,
stops following and joins the watchdog, and a host already started is
stopped; `close` joins both before the pair it watched is freed, so
neither thread reads a pair that `open` has already freed.

`qwen27_link.zig` is the pair's link over the existing relay, and holds
the watchdog and rank 1's follower in one `Workers`; `qwen27_pair.zig`
holds the rank-local shard and the seam transport for the serve loop;
main's `qwen27_costs.zig` times the window both ranks share; and
`qwen27_served.zig`/`qwen27_slots.zig`/`qwen27_lanes.zig` the accounting
a shard adds. GLM-5.3's transport is not touched: `pair.zig`/`pair.metal`
are as on main, and no `glm_dsa` file changes in this series.
@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from 573105e to eb8f417 Compare October 31, 2026 02:25
@akol1

akol1 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 94b90c9db; the head is eb8f417cf0c01a7021784a3295f4595f693d2cfd, the single commit of step 3 on step 2 (qwen27/rank-slice 1af209fce92b1bcf4ccd848f99e97a301bb4306f). The base advance carried 30 commits, including #1726's lane loop (q27-m5-lane-loop, merged at 24358c1e2), whose prefillFn head now carries this step's front-door refusal. git range-diff against the chain you reviewed shows three changes in this commit's own diff, all forced by the new base: the Metal doc comment was rewritten upstream, refuseFollower/refuseSampling moved out of Metal into qwen27_pair.zig (called as pair_mod.refuseSampling(b.link, s) and pair_mod.refuseFollower(b.link) at the head of prefillFn, so qwen27_lanes.zig stays under the 600-line rule), and the single-Mac argmax sweep is now if (fused) try @import("qwen27_keep.zig").finishPrefetch(b) else if (greedy and b.runner.reduce == null) try b.argmaxRows(0, total);, so a pair never runs it. The --drafter wiring and the slotResume fix are unchanged.

  1. The refusal reads the user's flag, before anything loads. refusePairFlags(pair, o, bound_drafter, why) (zig/src/native/qwen27_served.zig:297) passes bound_drafter or o.drafter != null into pair_flags.refuse, and it is the first statement of both open (:302, called at :303, before Pair.init, the shard or the vision tower loads) and attach (:335, called at :336, before the loaded drafter is read). attach still takes o, so a caller-bound drafter is refused there too.
  2. A host test goes through that wiring. zig/src/native/qwen27_pair_tests.zig:78, test "--drafter is refused at the front door, by name": it builds the same served.Options open receives, with .pair = "rank0.json" and .drafter = "z-lab/Qwen3.8-27B-DFlash2", and expects error.PairNoDrafter with a message starting --drafter does not run on a pair; a second case keeps the pair, sets .drafter = null and passes bound_drafter = true (the attach shape); a third call with pair = false passes, so one Mac keeps its drafter. It runs in the CPU-only test-qwen27-served-host step.
  3. attach no longer turns a single Mac's TF_QWEN27_SLOT_RESUME=0 back on. It passes the loaded switch through slotResume(init, pair) = init and resumesInPlace(pair) (zig/src/native/qwen27_pair.zig:120), exported as lb.slotResume (qwen27_lanes.zig:31) and called from attach at qwen27_served.zig:426: TF_QWEN27_SLOT_RESUME=0 stays off on one Mac, and a pair still opens cold at both ranks. The test, a pair never resumes a slot in place, and one Mac keeps the switch it read (qwen27_pair_tests.zig:59), draws both directions: slotResume(true, &pair) is false for rank 0 and rank 1, slotResume(true, null) true, and slotResume(false, null) false.

Run here, on this head, not carried over: zig build test 582/582 steps succeeded; 8771/8824 tests passed (53 skipped) (cold local cache, so the whole suite ran; every step succeeded, exit 0); zig build test-kernel-contract 20/20 steps succeeded; 141/141 tests passed; zig build dev-tools 219/219 steps succeeded; zig build test-qwen27-served-host 7/7 steps succeeded; 48/49 tests passed (1 skipped); zig build test-qwen27-bank-boundary-host 7/7 steps succeeded; 4/4 tests passed; zig fmt --check build.zig zig clean; filtered pair run SUMMARY 4 kernels: 4 pass gates 1-4, 0 fail, 0 known findings; 6 controls: 6 red as planted, 0 missed; 0 limit problems; gates 33 ms, limits 121 ms; filtered simd run 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 30077 ms, limits 121 ms; lean: 424 problems, 424 on the base, 0 new (tools/zig/lean_new.py, exit 0). tf-qwen27-tp-check is not a step at this commit; step 4 adds it.

Two Macs are still not re-run: the second Mac the pair receipts were recorded on is gone, so the two-Mac pair_accept.py receipt is the dated pre-rebase one in step 5's body, clearly labelled there. The host tests pin both fixes on one Mac. This room has M4 Pro hardware only (applegpu_g16, macOS 27.0.1, metal 32023.921, no tensor units); the M5 and M3 Ultra cells in the matrix remain yours.

The PR body is refreshed with these same receipts.

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