Skip to content

qwen27: add the pair acceptance and supervision scripts - #1436

Open
akol1 wants to merge 6 commits into
ashhart:mainfrom
akol1:qwen27/pair-tools
Open

akol1 wants to merge 6 commits into
ashhart:mainfrom
akol1:qwen27/pair-tools

Conversation

@akol1

@akol1 akol1 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Step 5 of 6 of the two-Mac Qwen3.8-27B series, rebased onto main (c39c09b9), stacked on step 4 (qwen27/pair-tests, cd510c64), on top of steps 2-3 (qwen27/rank-slice d43966c5, qwen27/pair-lifecycle 6db850cf); step 1 (#1432) is merged, and the stack's first commit retargets main's cost-rule case test (3abfc26e). Two commits: the scripts, then the oracle's per-case build, the serve's /health policy line they read, and the acceptance run's cold case. Step 6 stacks on this branch's tip.

The scripts that make a two-Mac run reproducible, and the serve's half of the recorder's pair guard, with no machine name and no user path in them:

  • tools/qwen27/record_oracle.py records a single-Mac acceptance oracle: five cases (a 128-token and a 256-token /v1/completions, a 128-token and a 256-token /v1/chat/completions, and a two-turn chat case that asks a question, answers it, then asks a follow-up about the same example), each at temperature 0 with "draft": false. Each case records the token sha, the build that recorded it (runtime), and the gpu_family of the Mac it ran on: code-128, code-256, chat-128 and chat-256 say tensorfold-native 1.1.2, and the two-turn case, recorded later, says tensorfold-native 1.1.7, so a case names the exact binary that must reproduce it rather than one label for the file. The recorder reads the serve's /health first and refuses (exit 2, a plain line on stderr) a serve whose policy names the two-Mac speed-up mode — rank 0 of a pair would pass the case's own turn-1 check just as well, and an oracle has to come from one Mac — and also one whose /health is unreadable or carries no version string. The version it reports becomes the new case's runtime, and the refusal happens before any completion request, so a pair serve never answers a recording.
  • The pair's /health now carries the line that refusal reads. qwen27_pair_flags.healthPolicy returns speed-up mode: a two-Mac pair, rank 0 answers greedy requests only for a pair and nothing for one Mac, LaneHost.init takes it as .policy, and the server prints it under /health; a host test pins both halves. Before this commit the qwen27 host left engine_api.Info.policy empty, so a pair serve reported no policy line and the recorder's refusal could not fire — the guard and the line it reads land together.
  • tools/qwen27/pair_accept.py run replays that oracle against a served pair: every case is sent --repeat 2 times and both replies must be identical, and their token_sha must equal the oracle's, with --only labelled as a subset rather than a full acceptance. It refuses an oracle whose gpu_family it cannot match: it prints the recorded family, this Mac's family and the reason (before the M5 the bits come from simd_qmm, on the M5 from the lane kernels with their own K splits), and returns 2 before sending a request — the pair's exactness is exact on one family of arithmetic, so it will not compare across the boundary. cold builds a two-turn conversation from the oracle's chat fixture (its answer, then a follow-up about the same example) and passes when the pair reports no cached prompt tokens: a pair keeps no prompt cache (one slot, and each Mac holds only its own halves of its states), so a resumed-prompt case cannot pass on one, and this case pins that documented limitation instead of contradicting it.
  • tools/qwen27/pair_supervise.sh up|down|status|watch starts rank 1 on $PEER and rank 0 locally, waits for both to answer /health, restarts both when either stops answering, and stops only the pids it wrote to its pidfiles (never by command-line pattern). It verifies the peer's host key instead of turning the check off, and it refuses to start when the two binaries differ unless ALLOW_MISMATCH=1 says the A/B is deliberate. PEER_STATE defaults on rank 1 to rank 1's own ${TMPDIR:-/tmp}/tensorfold-pair, so neither rank writes to the other's state directory.

Receipt

  • Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: this commit (ea66002975d254638304d3caf6979de621ff333f) on main (c39c09b9fb5d539f600a171544fb5622fd65b363) plus steps 2-4; no MLX or PyTorch. The gates ran on an Apple M4 Pro (Mac16,11), Metal only. The two-Mac run quoted below was produced before the series was rebased onto this main, from the series tree at that time: zig build native there produced sha256 3a82766f4bffbc9f3bc3575f6dec6841b3e4274e324bff83755f96f70b81400e (the supervisor printed binaries match: 3a82766f4bff), both Macs installed that binary (tensorfold-native 1.1.8), on two Macs of the same GPU family (apple-m4), Metal only, at the checkpoint revision 22d8d538154e0e5b6f8dcb1fcc74a60a0798104c (4-bit affine, group 64). The second Mac is no longer available, so there is no fresh two-Mac run on the rebased heads: the receipt below records the pair path before the rebase, and the binary this commit builds is not that binary.
  • Exactness, from tools/bench_concurrent.py --alone --serial or token_sha against "draft": false: tools/qwen27/pair_accept.py run (defaults --repeat 2, --timeout 600) exited 0 with five PASS lines and pair_accept: 5/5 cases match the oracle on http://127.0.0.1:4100; 0 did not; no FAIL or SKIP line was printed. The five cases are code-128 at f5c9409cfe40, code-256 at 2b28aa2108de, chat-128 at b69b0269917f, chat-256 at a06fab348fbe, and the two-turn chat fixture chat-two-turn-128 at 7d156013fbdd, and each case ran twice with identical token_sha and identical reply text. Both health endpoints named the same GPU generation and backend ("device": "Apple M4 Pro", "backend": "metal"), both carried the pair's policy line ("policy": "speed-up mode: a two-Mac pair, rank 0 answers greedy requests only"), and the supervisor printed binaries match: 3a82766f4bff before starting the ranks, so both Macs served the same bytes. pair_accept.py cold on that pair then reports cold: turn 1 prefill marked 0; turn 2 cached=0 and passes with PASS cold: the pair kept no prompt cache, as its one slot over two Macs' states implies, exit 0: the pair accepts the two-turn chat case and pins the no-cache limitation the docs state. This is the recorded run from before the rebase, and the log it is quoted from is the one the supervisor and pair_accept.py wrote on this Mac then. The script the receipt ran is byte-identical to this head's — tools/qwen27/pair_accept.py is untouched by the rebase — but the binary is not: the receipt's tree predates this rebase, and this head sits on c39c09b9, so a fresh two-Mac acceptance would answer from a different tree. On the rebased heads the single-Mac loopback gate (tf-qwen27-tp-check, step 4) and the host tests in zig build test are what run here, and a fresh two-Mac acceptance needs a working pair (the second Mac is gone).
  • Decode speed before and after, from tools/bench_openai.py: n/a, these scripts are the harness for that measurement; the rates belong to the run above, not to this diff.
  • Prompt speed before and after: n/a, same reason; pair_accept.py records per-case prompt and decode rates in its JSON, but the two-Mac receipt above is the acceptance result, not a speed claim.
  • Tests run, with pass, fail and skip counts: zig build test: 580/580 steps succeeded; 249/269 tests passed (20 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 — this is also what runs the host test that pins the pair's /health policy line. zig build test-kernel-contract: 20/20 steps succeeded; 114/114 tests passed, exit 0. zig build dev-tools: 216/216 steps succeeded. zig build test-qwen27-served-host: 7/7 steps succeeded; 44/45 tests passed (1 skipped), exit 0. zig build tf-qwen27-tp-check: 42/42 steps succeeded. 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 38 ms, limits 216 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 34211 ms, limits 135 ms. Lean rule: 425 problems, 425 on the base, 0 new (tools/zig/lean_new.py, exit 0). The oracle file's five cases and the family refusal strings are checked in and reproducible with record_oracle.py and pair_accept.py as described above.
  • Not run, and why: a fresh two-Mac pair_accept.py run on the rebased heads — the second Mac is gone (the cluster was broken), so the acceptance above is the recorded pre-rebase run; the single-Mac loopback gate and the host tests cover the seam here. CUDA, HIP/RDNA, M1-M3, M5 and Windows are not in this room; pair_accept refuses the M5 because the oracle's family is apple-m4.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. — the scripts only drive a served pair; every case is temperature 0 with "draft": false and both repeats must match, so a draft path that appears would fail the acceptance rather than pass it.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. — the acceptance sends each case twice and requires identical replies; the two-turn case is the chat continuation that reuses a reply's state, and cold is the pair's resumed-prompt check: it drives the follow-up turn and passes when the pair reports no cached prompt tokens, the documented reason being that a pair keeps no prompt cache.
  • No precision traded for speed. If the bits change, the description says which sums change and why. — no arithmetic here; the family check is the guard that the comparison never crosses an arithmetic boundary.
  • 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 pair. The filtered contract runs in the receipt are the regression check for the kernels the run covers.
  • Prompt processing is no slower than the last release. — n/a, this commit adds no prompt kernel; the harness records the prompt rate in its JSON and no claim is made from this diff.
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not. — two apple-m4 Macs for the quoted run; no CUDA, HIP, M1-M3, M5 or Windows; the M5 is refused by the oracle's family check by design.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. — the scripts are new files, so their parent commit has none of them; pair_accept.py prints pair_accept: <url> did not answer: ... and exits 2 when nothing answers, instead of a traceback, and record_oracle.py names its own preconditions (no opening case, a turn with no text, a reply without token_ids) the same way. The /health policy line has a host test that fails if a pair reports no line or one Mac reports the pair's.
  • Comments and docstrings are one line. No measurements or history in the source. — lean: 425 problems, 425 on the base, 0 new.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines. — $PEER, $MODEL, $BIN and $STATE are read from the environment; the oracle stores a GPU family, not a host; pair_supervise.sh verifies the peer's host key and never disables the check.

Stacked on: qwen27/rank-slice, qwen27/pair-lifecycle, qwen27/pair-tests. This commit is step 5 of 6.

@akol1
akol1 force-pushed the qwen27/pair-tools branch from 54957a6 to 53a0d27 Compare October 27, 2026 03:58
@akol1
akol1 marked this pull request as ready for review October 27, 2026 04:32
@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner
  • The oracle. It says "A paired run must reproduce every token_sha here", but pair_known then accepts 58daf9743077 for chat-256. Please remove the exemption.
  • stop_peer and down kill every process matching tensorfold-native serve.*--speed-up on both Macs, which can include someone else's GLM or Flash Next pair. Kill only the pids the script started, and check them.
  • The default SSH_OPTS turns off StrictHostKeyChecking on a script that starts binaries remotely. Please keep the normal host-key check.

@akol1
akol1 force-pushed the qwen27/pair-tools branch 3 times, most recently from 6a3be2e to 0b4f1f4 Compare October 27, 2026 22:38
@akol1

akol1 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

All three are fixed on 0b4f1f4e:

  • The exemption is gone. There is no pair_known anywhere, and the oracle's four cases are compared with no accepted-different value: pair_accept.py fails a case whose token_sha differs instead of accepting a known sha.
  • Stopping is pidfile-only. down and its stop path kill only the pids the script recorded, wait out each one before moving on, and never match by command line, so another pair's processes can't be hit.
  • Host keys stay checked. SSH_OPTS defaults to -o BatchMode=yes -o ConnectTimeout=5; the host-key check keeps its default, so an unknown or changed key fails the connection rather than being accepted.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. The exemption is gone, stops act only on the recorded pids after checking the binary, and host keys stay verified. Three asks:

  1. The oracle (tools/qwen27/qwen38_27b_oracle.json) does not record which GPU produced it. One Mac's bits before the M5 come from simd_qmm, and on the M5 from the lane kernels with their own K splits. So a record made on one family cannot accept a pair on the other. Please record the GPU family and have pair_accept.py refuse a mismatch, or take the reference from a one-Mac serve on rank 0's Mac.
  2. Every case is a single turn, so acceptance never feeds an earlier reply. Earlier replies are the decoded spans behind qwen27: rank-slice the 27B family and gather the whole embedding table #1433's first point. Please add a two-turn chat case.
  3. Small: PEER_STATE defaults to rank 0's own $TMPDIR (tools/qwen27/pair_supervise.sh:48). On macOS that is a per-user /var/folders path rank 1 usually cannot create, unlike what the usage at :24 says. Please expand the default on the peer.

We are accepting this series on the loopback gate plus your two-Mac receipts, and the refreshed bodies quote none. Please post a two-Mac pair_accept.py run on the refreshed heads, including the two-turn case. This also needs #1432's rebase.

@akol1
akol1 force-pushed the qwen27/pair-tools branch 2 times, most recently from e1c16eb to 2ee8596 Compare October 28, 2026 04:09
@akol1

akol1 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

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

  1. The oracle records the GPU family, and pair_accept.py refuses a mismatch. tools/qwen27/qwen38_27b_oracle.json now carries "gpu_family": "apple-m4" (with the checkpoint revision it was recorded on), and a Mac outside that family is refused before any request is sent: the run says which family the record was made on, which family this Mac reports, and why one family's bits cannot accept the other, then exits 2. The one-Mac-bits argument is exactly yours: before the M5 the logits come from simd_qmm, on it from the lane kernels with their own K splits.

  2. A two-turn chat case is in the oracle and passes. chat-two-turn-128 feeds an earlier reply back through decodeChunk, which is the path qwen27: rank-slice the 27B family and gather the whole embedding table #1433's merge fix covers.

  3. PEER_STATE now expands on the peer. The rank-1 launcher resolves the default on rank 1's Mac before it uses it, so a default under ${TMPDIR:-/tmp} no longer points rank 1 at rank 0's per-user directory. The usage text says so.

Two-Mac acceptance on the refreshed heads

Built the refreshed series at its tip (660f7a35, docs step only above this commit; tensorfold-native 1.1.7), installed the same bytes on both Macs — [pair] binaries match: ac25aa210ae2 — started rank 1 before rank 0 (rank 1 is up (pid 23547); both ranks answer: rank 0 on :4100 and rank 1 on :4101; rank 0's port refused 2 probes while the pair handshake completed), and ran pair_accept.py run against rank 0. /health on both ranks reports "device": "Apple M4 Pro" and "backend": "metal".

  • PASS code-128 token_sha=f5c9409cfe40 (2 identical runs)
  • PASS code-256 token_sha=2b28aa2108de (2 identical runs)
  • PASS chat-128 token_sha=b69b0269917f (2 identical runs)
  • PASS chat-256 token_sha=a06fab348fbe (2 identical runs)
  • PASS chat-two-turn-128 token_sha=7d156013fbdd (2 identical runs)
  • pair_accept: 5/5 cases match the oracle on http://127.0.0.1:4100; 0 did not, RUN_EXIT=0

So the acceptance condition is met on the refreshed heads, two-turn case included, and the refreshed bodies quote this receipt. One honest line: pair_accept.py warm still exits 1 with FAIL warm: the next turn reused no prompt state (cached=0) — that is the documented limitation (the pair claims nothing about prompt-state reuse across turns); the two-turn case above is accepted by run, not by warm.

Gates on 2ee85964: zig build test 565/565 steps succeeded; 191/194 tests passed (3 skipped); test-kernel-contract 43/43; dev-tools 216/216; tf-qwen27-tp-check 42/42 steps succeeded; 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 288 ms, limits 109 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 30439 ms, limits 104 ms; lean 428 problems, 428 on the base, 0 new.

@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. The oracle now records its GPU family and pair_accept.py refuses another family before sending a request, the two-turn case is in, and PEER_STATE is resolved on rank 1. Thank you too for the two-Mac run on the refreshed heads: five of five, the two-turn case included. That's the two-Mac receipt we said we'd accept for this series.

Two small things, not blocking:

  • The oracle's runtime still reads tensorfold-native 1.1.2, though the two-turn case was recorded later. A runtime per case would say which build recorded each sha.
  • record_oracle.py checks that turn 1 reproduces chat-128, but rank 0 of a pair passes that check too. If the serve can say it is a pair, the recorder should refuse it, so an oracle always comes from one Mac.

@akol1
akol1 force-pushed the qwen27/pair-tools branch 3 times, most recently from eddd27e to 8060011 Compare October 28, 2026 17:00
@akol1
akol1 force-pushed the qwen27/pair-tools branch from 8060011 to d2e1e62 Compare October 28, 2026 18:36
@akol1

akol1 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Both asks are in on d2e1e62b, and the second one needed the serve's half of it, which was missing.

A runtime per case. qwen38_27b_oracle.json no longer carries one top-level runtime: code-128, code-256, chat-128 and chat-256 say tensorfold-native 1.1.2, chat-two-turn-128 says tensorfold-native 1.1.7, and the description says that each case names the build that recorded its own token_sha, so a case is never compared against a receipt whose recorder is unknown. pair_accept.py compares token_sha, not the build, so its cases are unchanged.

The recorder refuses a pair serve. record_oracle.py reads /health first and refuses, exit 2 with a plain line on stderr, a serve whose policy names the two-Mac speed-up mode, and also one whose /health is unreadable or carries no version string; the version it does report becomes the new case's runtime. The refusal happens before any completion request, so a pair serve never answers a recording.

That guard could not fire before this commit: engine_api.Info.policy existed (zig/src/core/engine_api.zig:224) and /health prints it (zig/src/server/status_routes.zig:36), but the qwen27 host left it empty and record_oracle.py never read /health at all. This commit adds both sides — the pair names the mode when it attaches, its host test pins the line, and the recorder reads /health first and refuses that serve before any completion. From rank 0 of the two-Mac run on this branch, trimmed to the fields that matter:

{"status": "ok", "model": "22d8d538154e0e5b6f8dcb1fcc74a60a0798104c", "max_batch_size": 1,
 "policy": "speed-up mode: a two-Mac pair, rank 0 answers greedy requests only",
 "warming": false, "version": "1.1.8", "backend": "metal", "device": "Apple M4 Pro"}

With that pair serving, the recorder now refuses:

$ tools/qwen27/record_oracle.py --base-url http://127.0.0.1:4100
record_oracle: http://127.0.0.1:4100/health reports the two-Mac pair policy (speed-up mode: a two-Mac pair, rank 0
answers greedy requests only); an oracle must come from a single-Mac serve
$ echo $?
2

A bug this flushed out in the acceptance script. pair_accept.py's warm case asked a pair for a resumed prompt state, but a pair keeps no prompt cache (one slot, and each Mac holds only its own halves of its states), so that case failed on every pair by construction — it is the line you saw fail in the receipt I posted. It is now cold: it drives a second turn in the same conversation and passes when the pair reports no cached tokens, which pins the documented "keeps no prompt cache" limitation instead of contradicting it. The two-Mac run on the final head:

pair_supervise.sh up reports binaries match: 3a82766f4bff (both Macs installed the same tensorfold-native 1.1.8 build of this head) and both ranks answer: rank 0 on :4100 and rank 1 on :4101, 4s after rank 0 started; the acceptance run then passes each case on two identical runs -- pair_accept: 5/5 cases match the oracle on http://127.0.0.1:4100; 0 did not (code-128 f5c9409cfe40, code-256 2b28aa2108de, chat-128 b69b0269917f, chat-256 a06fab348fbe, chat-two-turn-128 7d156013fbdd) -- and the new case ends it: cold: turn 1 prefill marked 0; turn 2 cached=0 / PASS cold: the pair kept no prompt cache, as its one slot over two Macs' states implies, exit 0. The supervisor then lists both ranks serving (exit 0) and stops both (exit 0).

Fresh receipts on d2e1e62b: zig build test 576/576 steps succeeded; 228/246 tests passed (18 skipped), exit 0; zig build dev-tools 216/216 steps succeeded; zig build tf-qwen27-tp-check 42/42 steps succeeded; zig build test-kernel-contract 43/43 steps succeeded; zig fmt --check build.zig zig clean; 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 35 ms, limits 152 ms and 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 34054 ms, limits 126 ms; lean 425 problems, 425 on the base, 0 new.

The head moved once more since my heads-up: it is now d2e1e62b, the doc comment above the new healthPolicy folded from two lines to one after this repo's one-line comment rule flagged it; the series tip on top of this step, 05bb79a9, adds only docs. Nothing under zig/kernels or on any arithmetic path is affected by either move, and the binary the two-Mac run above installed on both Macs is the one this head builds (3a82766f4bff…, binaries match: 3a82766f4bff).

@akol1
akol1 force-pushed the qwen27/pair-tools branch from d2e1e62 to 42d938e 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 42d938e8487604b1bc6429019e6b6aa3eaf786c7. The series order is unchanged, and these are re-run numbers from the rebased head, not carried over.

zig build test: 576/576 steps succeeded; 236/254 tests passed (18 skipped); zig build test-kernel-contract: 43/43; zig build dev-tools: 216/216; zig build tf-qwen27-tp-check: 42/42; zig fmt --check build.zig zig clean; filtered pair run gates 32 ms, limits 104 ms, filtered simd run gates 29789 ms, limits 118 ms; lean rule 425 problems, 425 on the base, 0 new.

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, the tools look good: the oracle records its build per case and its GPU family, the recorder refuses a pair, and the /health line one Mac reports doesn't change. There's one fix before this can land, and it's small.

In tools/qwen27/pair_supervise.sh, up() calls check_binaries without || return 1. Run directly, up still stops on a mismatch, because set -e applies there. But watch calls it as if up; then, and sh switches off set -e inside any function that an if tests. So under watch a binary mismatch is printed and ignored, and both ranks start on different builds. watch is the unattended mode, so that's the one place the check matters most. Here's a three-line repro:

/bin/sh -c 'set -eu; chk(){ return 1; }; up(){ chk; echo started anyway; }; if up; then echo up-ok; fi'

That prints "started anyway" and then "up-ok". Please make it check_binaries || return 1, and do the same for start_rank1 || return 1 and start_rank0 || return 1, so a failed start doesn't sit out the whole READY_TIMEOUT. It would also help if resolve_peer_state stopped the script with exit status 1 when it can't read rank 1's TMPDIR and PEER_STATE isn't set, rather than carrying on with an empty path.

That fix is all this PR needs. It lands after #1432 to #1435, in series order.

@akol1
akol1 force-pushed the qwen27/pair-tools branch from 42d938e to ac0688c Compare October 30, 2026 03:05
@akol1

akol1 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (581cbe65); qwen27/pair-tools is ac0688ce3c13a8f4039ab0f538c03a9ff8bbc6e1, two commits on step 4 (bd05c1a0): the scripts, then the serve's /health policy line they read.

All three fixes are in tools/qwen27/pair_supervise.sh:

  • up() now fails each start step itself: need_up || return 1 (:197), check_binaries || return 1 (:203), start_rank1 || return 1 (:205), start_rank0 || return 1 (:206). The comment above them (:201-202) names the reason: watch calls up inside an if, and sh switches set -e off in a function an if tests.
  • need_peer || return 1 also guards need_up (:110) and the two paths that read rank 1's state (:278, :291).
  • resolve_peer_state (:72) now clears PEER_STATE, says could not read rank 1's TMPDIR from $PEER; set PEER_STATE to rank 1's state directory, and exit 1 (:79) instead of carrying on with an empty path.

Receipts, run here on this head; sh -n on the script is clean:

$ /bin/sh -c 'set -eu; chk(){ return 1; }; up(){ chk || return 1; echo started anyway; }; if up; then echo up-ok; fi'
(no output, rc 0)

$ env -u PEER -u PEER_STATE sh tools/qwen27/pair_supervise.sh up
[pair] set PEER to the rank 1 ssh target
(rc 1)

$ env PEER=127.0.0.1 PEER_STATE= sh tools/qwen27/pair_supervise.sh up
[pair] could not read rank 1's TMPDIR from 127.0.0.1; set PEER_STATE to rank 1's state directory
(rc 1)

The first is your three-line repro with || return 1: it no longer prints "started anyway" or "up-ok". The last two stop before any binary starts, and the second one names PEER_STATE as you asked.

Numbers from this head, run here, not carried over: zig build test 580/580 steps succeeded; 243/263 tests passed (20 skipped); zig build test-qwen27-served-host 7/7 steps succeeded; 38/39 tests passed (1 skipped); zig build tf-qwen27-tp-check 42/42 steps succeeded; zig build test-kernel-contract 20/20 steps succeeded; 114/114 tests passed; zig build dev-tools 216/216 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 35 ms, limits 137 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 32457 ms, limits 125 ms; lean rule 425 problems, 425 on the base, 0 new.

The supervisor's two-rank paths still need two Macs, and the second Mac this receipt was recorded on is gone; the checks above are the one-host failures, and the host tests plus step 4's loopback gate carry the pair comparison.

@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. All three fixes are in. up() now fails each start step itself, so under watch a binary mismatch or a failed start stops the attempt instead of being ignored. resolve_peer_state stops with status 1 and names PEER_STATE when it cannot read rank 1's TMPDIR. I re-ran the three-line repro with the fix and it prints nothing. Nothing more is needed from you on this one. It's ready and will land right after #1433, #1434 and #1435, in series order (#1434 has one fix to make first).

@akol1
akol1 force-pushed the qwen27/pair-tools branch from ac0688c to 2e3a3f8 Compare October 30, 2026 16:12
@akol1

akol1 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased: qwen27/pair-tools is ea66002975d254638304d3caf6979de621ff333f, two commits on step 4 (cd510c64c6ca6275de1388f0bb9bee5a9fb71222), onto main (c39c09b9fb5d539f600a171544fb5622fd65b363); the stack's first commit (3abfc26e) retargets main's cost-rule case test, so the tree under this gate is green. The tools themselves are unchanged: every file this branch adds (pair_accept.py, pair_supervise.sh, the oracle JSON, record_oracle.py, pair_flags.zig) is byte-identical to the head you reviewed, and the /health line it adds to qwen27_served.zig is that same line (the file itself differs only by main's advance and step 3's qwen27_link.Workers move, and by the --drafter fix step 3 carries).

Numbers from this head, run here, not carried over: zig build test 580/580 steps succeeded; 249/269 tests passed (20 skipped); zig build test-qwen27-served-host 7/7 steps succeeded; 44/45 tests passed (1 skipped); zig build tf-qwen27-tp-check 42/42 steps succeeded (builds and skips without --model); zig build dev-tools 216/216 steps succeeded; zig build test-kernel-contract 20/20 steps succeeded; 114/114 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 38 ms, limits 216 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 34211 ms, limits 135 ms; lean rule 425 problems, 425 on the base, 0 new.

The two-Mac pair_accept.py receipt in the body is the original run and is not re-run: the second Mac is gone. The scripts are unchanged since that run, and zip/rsync/up/down are exercised on this Mac by the host-side paths the earlier rounds fixed.

akol1 added 3 commits October 30, 2026 10:15
`main`'s 5c9706f moved the 27B window timing out of the lane backend
into `zig/src/native/qwen27_costs.zig`: the lane backend's `measure` now
returns that file's `measure`, and the `cost_rule.fastest(timer, i)`
loop lives there. `zig/cost_rule_cases.zig` still embeds
`qwen27_lanes.zig` and looks for the loop in it, so the assertion fails
on `main` itself: `zig build test-cost-rule` reports `29/30`, and
`zig build test` depends on that same run, so any branch on top of
`main` starts red.

This commit retargets the assertion to the file that owns the timing and
keeps the lane-backend half of the check: `qwen27_costs.zig` contains
`cost_rule.fastest(timer, i)`, and `qwen27_lanes.zig` no longer does.
No production code changes; one test file, three lines.

A prerequisite, not part of the two-Mac series: step 1 of 6
(`qwen27/rank-slice`) stacks on this commit.
Step 2 of 6, stacked on step 1 (`qwen27/pair-seam`), on the prerequisite
cost-rule retarget (`3abfc26e`), and on main (`c39c09b9`); 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 (`c39c09b9`); 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. 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
neither rank resumes a slot in place: rank 1 resets the slot at each
round's `begin` (`link.follow`), so `attach` turns `slot_resume` off for
a pair (`lb.resumesInPlace`), or the ranks would part at the resumed
offset; a host test pins the rule.

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 added 3 commits October 30, 2026 10:16
Step 4 of 6, stacked on steps 1-3 (`qwen27/pair-seam`, `qwen27/rank-slice`,
`qwen27/pair-lifecycle`) and main (`c39c09b9`); step 5 stacks on this commit.

`tf-qwen27-tp-check` is built by `zig build test` and launched by the
suite. It needs `--model`, so CI's run prints `SKIP
qwen27-tp-check: no --model DIR` and carries on, and a host with the
checkpoint gets both stages. Stage 1 loads the model, then rank 1
through `loadShard`, and requires the same greedy ids, logits bytes and
prompt-state digest; stage 2 starts two rank-local shards joined by
`pair.Loopback`, a host-memory link, and requires both ranks to emit
the same ids as the single Mac, with `pair_logits_byte_equal` and
`pair_first_logits_difference: null` reported, and `bad_pick_at` and the
seam give-up counters naming any loss. `--stage 1|2` narrows it and
`--dump` prints each side's top two.

`--decode-prompt` feeds the prompt through `Session.decodeChunk` in
`--chunk N`-row rounds, an earlier reply's arithmetic rather than the
prompt kernels: the multi-row round a served chat gives every earlier
reply, which runs the tree merge (`q27_attn_merge_single` on one Mac) and not the prompt kernels' merge. Both
stages use the same feed, and the receipt reports `prompt_arithmetic:
"decode"` next to the chunk. `--chunk` may then reach 512, the served
chunk (`model.promptRows()`); the pair's stages keep 128, the rows the
seam window holds (`pair.stageBytes`), so `--chunk 512 --stage 2` is
refused by name rather than panicking at the seam. Stage 1 at chunk 128 is
where main's merge stride is exercised: it takes `q27_attn_merge` at
`chunk` rows a round, so the single-Mac logits bytes are the reference the
pair has to match.

`zig/qwen27_core.zig` exports the pair and `zig/build/qwen27.zig`
registers the program; `--forced-file` replays one side's picks on the
other side for divergence localisation, and `--taps` hashes five layers'
post-prompt residuals.
Step 5 of 6, stacked on steps 1-4 (`qwen27/pair-seam`, `qwen27/rank-slice`,
`qwen27/pair-lifecycle`, `qwen27/pair-tests`) and main (`c39c09b9`); step 6
stacks on this commit.

`tools/qwen27/pair_accept.py` replays the checked-in single-Mac oracle
against a served pair: every case is sent twice, both replies must be
identical, and their `token_sha` must equal the oracle's, with no case
exempt. `tools/qwen27/pair_supervise.sh` starts rank 1 on `$PEER` and
rank 0 locally, restarts both when either stops answering, stops only
the pids it started (never by pattern), and verifies the peer's host key
rather than turning the check off. `qwen38_27b_oracle.json` is the
single-Mac, undrafted, temperature-0 record they compare against.

The oracle records the GPU family it was taken on (`apple-m4` here), and
`pair_accept.py` refuses to replay it against a serve that reports a
different family, or one whose family it cannot read, instead of
comparing tokens across two GPUs as if they were the same machine. The
two-turn case (`chat-two-turn-128`) opens with the recorded `chat-128`
reply and replays the oracle's second turn, so the acceptance run covers
the multi-row `decodeChunk` rounds a chat takes, not just single-turn
prompts; `record_oracle.py` records it the same way.

`pair_supervise.sh`'s `PEER_STATE` default is now peer-relative (rank 1's
own `${TMPDIR:-/tmp}/tensorfold-pair`, expanded there) rather than rank
0's local directory, so a supervision run does not need rank 0 to know
rank 1's filesystem layout; a caller-set `PEER_STATE` is used as given,
and a peer TMPDIR that cannot be read stops the script with exit status
1 instead of carrying an empty path into `down`.

`up` returns each start-up step's failure itself
(`check_binaries || return 1`, `start_rank1 || return 1`,
`start_rank0 || return 1`): `watch` calls it as `if up`, and sh switches
`set -e` off inside a function an `if` tests, so a reported failure
alone would leave both ranks starting on different builds and a failed
start sitting out the whole `READY_TIMEOUT`.

Nothing in either script names a machine, a user path or an address:
every location is environment-overridable, and the shim's stop path is
the pidfile.
…fuse a pair serve

Two review findings on the oracle and its recorder, and the pair's own
side of the second one.

`qwen38_27b_oracle.json` carried one top-level `runtime`,
`tensorfold-native 1.1.2`, though `chat-two-turn-128` was recorded later.
Each case now carries the runtime that recorded its own `token_sha`:
`code-128`, `code-256`, `chat-128` and `chat-256` say
`tensorfold-native 1.1.2`, and `chat-two-turn-128` says
`tensorfold-native 1.1.7`, so a case names the exact binary that must
reproduce it. The description says so. `pair_accept.py` compares
`token_sha`, not the build, so its cases are unchanged.

`record_oracle.py` reads the serve's `/health` before it sends any
completion. It refuses (exit 2, a plain line on stderr) a serve whose
`policy` line reports the two-Mac pair speed-up mode, because rank 0 of a
pair passes the `chat-128` turn-1 check just as well, and an oracle must
come from one Mac. It also refuses a serve whose `/health` is unreadable
or carries no version string. The reported version becomes the new
case's `runtime`, so the receipt names the build that produced it, and
the turn-1 `chat-128` equality and decimal-token-id hash checks are
unchanged.

That refusal could not fire before this commit: `engine_api.Info.policy`
existed and the server prints it under `/health`, but the qwen27 host
left it empty, so a pair serve reported no policy line at all.
`qwen27_served` now names the mode when it attaches a pair, and its host
test pins the line, so a pair's `/health` carries "speed-up mode: a
two-Mac pair, rank 0 answers greedy requests only" and the recorder
refuses that serve.

`pair_accept.py`'s warm case asked a pair for a resumed prompt state, but
a pair keeps no prompt cache (its slots are one and its prompt states are
two Macs', so `qwen27_served.attach` enables none), so that case failed
by construction on any pair. It is now `cold`: it drives a second turn in
the same conversation and passes when the pair reports no cached prompt
tokens, pinning the documented "keeps no prompt cache" limitation
instead of contradicting it.
@akol1
akol1 force-pushed the qwen27/pair-tools branch from 2e3a3f8 to ea66002 Compare October 30, 2026 17:38
@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thanks @akol1. The scripts and the /health policy line look ready. A pair's /health now names the two-Mac mode and one Mac's reports nothing new, the recorder refuses a pair serve, and the supervisor's start steps each fail on their own under watch. This lands as soon as steps 2 to 4 (#1433, #1434 and #1435) are in, in series order. One small heads-up for the next rebase: main now carries the same cost-rule test retarget as the stack's first commit (3abfc26e), so that commit drops out as empty when the series is rebased onto current main. Nothing else is needed from you on this one.

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