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 3 commits into
ashhart:mainfrom
akol1:qwen27/pair-lifecycle
Open

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

Conversation

@akol1

@akol1 akol1 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Gives the pair a serving lifecycle. Rank 1 replays rank 0's rounds and both ranks stop when the link dies; rank 0 does not answer /health until rank 1's handshake completes, so a load balancer cannot send a request into a pair that is not up; the first turn is warmed over the link before the listener opens; and the pair names what it refuses instead of quietly serving something else — --drafter, --prompt-cache-gib, --learn, a request that samples, carries a grammar or asks for images all fail, because one Mac holds only its own halves of a slot's state and rank 1 has no drafter checkpoint. --speed-up opens the pair on the 27B and is refused by Nemotron and the 2B before any weight loads. The benchmark grows --speed-up for its own serve child, and dump/input move out of benchmark.zig into their own units.

Refs #1236 and #808. #808 asked for prompt-splitting on two Macs with drafts and images kept; this splits the weights and the decode rounds and keeps neither, so it neither answers nor claims that request. #1236 tracks the mixed-backend group; this is two Metal Macs over a socket. Stacked on #1432 and #1433; the diff against main carries them too, so review this pull request's own commit, 95c94523.

Receipt

  • Environment, with the TensorFold commit, MLX or PyTorch version, machine and GPU, checkpoint and revision: 8c36359d plus this commit (95c94523); 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; checkpoint TensorFold/Qwen3.8-27B-MLX-4bit at revision 22d8d538154e0e5b6f8dcb1fcc74a60a0798104c, 4-bit affine with group 64, the same checkpoint on both Macs; two Macs joined over Thunderbolt 5; the binary on both Macs is sha256 2dda0e50fad3ca10487c215820f5cd9acee83d3914ab474955659333d9e81bdf, and later commits add only tests, tools and docs, so this is the native code this commit describes.
  • Exactness, from tools/bench_concurrent.py --alone --serial or token_sha against "draft": false: four fixture runs, temperature 0, seed 1234, each sent once per arm after a supervised restart. code-128 f5c9409cfe40, code-256 2b28aa2108de and chat-128 b69b0269917f match the single-Mac oracle byte for byte; chat-256 is the recorded 58daf9743077, which is the bf16 reduction-order tie at token 236 that docs/known-issues.md carries — pair_accept: 3/4 cases match the oracle, 1 recorded as different, 0 unexpected. The one-Mac arm of the same binary reproduces all four oracle shas exactly, chat-256 included (a06fab348fbe), which is the "draft": false side of that comparison. Supervision, from the script's own log: both installs matched (binaries match: 2dda0e50fad3), the pair answered 3s after rank 0 started after the first start and 2s after rank 0 started after a restart, and rank 0's port refused 2 health probes while the handshake completed, so a client cannot lose a request to that delay. kill -9 on rank 0 then produced [pair] rank 0 stopped answering: restarting both (restart 1) and stopped rank 1 (pid 22003), READY (2 up transitions) after 10s, and the same 3/4 acceptance afterwards. On TERM the supervisor stopped both ranks (stopped rank 0 (pid 18814), stopped rank 1 (pid 22290), supervisor gone after 3s) and left no serve process or listener on either host; one Thunderbolt-fabric line reports a refused signal as the link tears down, after both ranks had stopped answering.
  • Decode speed before and after, from tools/bench_openai.py: one Mac's arm of the same binary serves the fixtures at 12.3 to 13.6 tok/s (128 and 256-token replies, 13.0, 12.3, 13.4 and 13.2 in order), and the pair serves them at 16.8 to 17.1 tok/s, about 1.2x on the one Mac against the two; two Macs buy capacity for a model that does not fit one, not two times the speed on a model that does, and this stack makes no capacity claim it could not test here. tools/bench_openai.py is not yet pair-aware, so these numbers are the server's own tok/s lines rather than that tool's.
  • Prompt speed before and after: n/a — no long-prompt cold receipt was taken on either arm. The fixtures' prompts are 14 and 31 tokens, where one Mac reports 0.198 to 0.382 s to first token and the pair 0.131 to 0.229 s; the pair leaves prompt processing on both ranks' own rows, so nothing here is a prompt-speed claim.
  • Tests run, with pass, fail and skip counts:zig build test test-golden at this tree: 7116 passed, 45 skipped, 0 failed of 7161, with 535 of 535 build steps; the 45 skips are the image-codec and pixel cases that need libjpeg-turbo 3.1.4.1 or libwebp 1.6.0, plus SKIP qwen27-tp-check: no --model DIR; the five tests this commit adds (benchmark_run_test's --speed-up child and receipt case, and the served/lanes/dump/input cases) are part of the 7116, and SKIP is the same single skip. The same tree is green in CI on ubuntu-latest (zig), windows-latest (zig-windows) and macos-26 (zig-macos-dev-tools): the two --speed-up cases assert whichever answer the build's flag table gives — on the CUDA build that is the managed server's refusal, not a skip — and dump.zig takes the platform's default directory attributes where fromMode does not exist.
  • Not run, and why: CUDA, HIP/RDNA, M1-M3, M5 and Windows are absent here, so the CI zig-windows job and a reviewer's CUDA box carry those; the full tf-kernel-contract corpus stops at Gemma 4 31B (TensorUnitsRequired) on this host; and there is no maintainer-agreed gate for a tensor-parallel family — zig/gate.json has no entries at 8c36359d — so the exactness and speed numbers above are reported against the checked-in single-host oracle, not against an agreed threshold.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. — the pair runs each round in both ranks' lanes and only the seam's partials cross the link; one Mac keeps the exact path it had, which the four fixture shas above show.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. — the pair refuses a drafter and keeps no prompt cache, so drafted output and resumed prompts are refused by name rather than served; concurrent requests on a pair are limited to one stream, also by name.
  • No precision traded for speed. If the bits change, the description says which sums change and why. — the seam sums in fp32; the only reply that differs from one Mac is the recorded near-tie, and the wording in docs/known-issues.md says which two contenders it was between and why the lower id won.
  • 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 launches no new kernel; the pair's four are registered in the first pull request, whose filtered SUMMARY is quoted there.
  • Prompt processing is no slower than the last release. — n/a, no prompt-path change: the pair processes a prompt once per rank on its own rows, and no cold long-prompt receipt exists to compare.
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not. — ran Metal on two M4 Pro Macs; no CUDA, no HIP, no M1-M3/M5, no Windows (see Not run).
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. — the pair tests need the pair, which does not exist on the parent, so red-before is a missing symbol; the served/lanes tests that attach a host now pass null for the absent pair, and the benchmark tests cover --speed-up refusals without loading weights.
  • Comments and docstrings are one line. No measurements or history in the source. — the lean check reports 428 problems, 428 on the base, 0 new.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines. — the diff names no machine, user path, address or serial; the two settings files and the peer endpoint come from arguments or the environment.

akol1 added 2 commits October 26, 2026 18:18
The seam carries one round's partials between two Macs: pair.metal writes a
frame, the host relay owns the exchange instead of spinning on device
counters (which only works while both ranks share a launch group), and
pair_sum completes the partial on the far rank. The payload is f32, the
seam's accumulation, and simd_qmm grows the matching store. pair.zig splits
into pair_layout and pair_loopback so each module stays under the 600-line
rule.

Nothing registered the seam before: three of the four kernels already ran
under the older ABI on the shipped glm_dsa speed-up path, but there was no
record and no spec, so no gate covered the kernels this stack changes. Add
the four launch records, the seam spec with its four probes, the
`metal pair ` route in the limits table, and the fp32-out simd_qmm variant,
so the transport and family changes that follow have a SUMMARY to post.

The glm_dsa call sites and the two Pipeline.load test call sites are forced
by the shared signatures; the family changes themselves come later.
On a pair rank the embedding table is global while the vocabulary in the
split config is the shard's width, so the gather bound took the shard's
number and read the wrong rows; embedBound now takes the whole table and
refuses a bound below it. Rank-sliced projections, the head seam, the GDN
partials and the attention plan follow the same split, and the drafts-off
path keeps the two ranks in lockstep.

The regression test pins the old behaviour (124160) against the new
(248320) and fails when the split width is used again.
A supervised pair has to survive an unattended night: rank 0 now waits for
rank 1's handshake before it advertises /health, the first turn is warmed
so the client does not pay the load, and either rank exiting takes the
other down instead of leaving it serving alone. The launch script starts
and stops both, with the kill/restart drill recorded in the receipt.

The benchmark and TF_DUMP_IDS receipts in server/ are the support code for
those runs; dump.zig and input.zig are their two halves.
@akol1
akol1 force-pushed the qwen27/pair-lifecycle branch from 95d2292 to 95c9452 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

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.

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