Repository navigation
Conversation
e6e6e96 to
cb488f2
Compare
|
6011c77 to
0c50037
Compare
|
Both text bugs are fixed on
|
|
Thanks @akol1: the chat-256 divergence is gone from the docs, and the RUNBOOK's rank-1 exit now matches
This also needs #1432's rebase. |
f041082 to
660f7a3
Compare
|
Refreshed and force-pushed:
Gates on |
|
Thanks @akol1. All three corrections are in on |
380ae40 to
05bb79a
Compare
|
Thanks — done on The formats paragraph no longer reads as a claim for stacks nothing has run. It now says the 4-bit That is also what the two-Mac run in step 5 measured: the oracle it replays is the 4-bit checkpoint at revision Fresh receipts on This is step 6 of 6, stacked on step 5 ( |
05bb79a to
7718b1c
Compare
|
Rebased onto the current
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 |
7718b1c to
c30fc31
Compare
|
Rebased onto The text now says, in the formats paragraph, that a pair takes Metal affine and no CUDA-only format; in the recipe, that each rank reduces its own output rows at full K, that Numbers from this head, run here, not carried over: This commit edits three Markdown files and makes no code or exactness claim of its own; the exactness and speed rows are |
|
Thanks @akol1. Everything from the earlier rounds is answered on c30fc31: the chat-256 divergence is gone, the rank-1 exit matches the watchdog, the recipe describes the half-vocab argmax and pair_pick, the refusals quote the real messages with --vision listed, and the formats paragraph says only the 4-bit stack has a pair receipt. As planned, this one waits until #1433 to #1436 have landed. One correction while it waits. The recipe (and the description) says a non-finite logit is flagged by Small and optional: the |
c30fc31 to
e38effb
Compare
|
Both points are in, on
Within the files this branch touches, Numbers from this head, run here, not carried over: |
3165ba3 to
6c0f341
Compare
|
Both points are in, on
Within the files this branch touches, every one is byte-identical to the head you reviewed: the non-finite and break edits are the ones you read. The only change against that head is the Numbers from this head, run here, not carried over: |
6c0f341 to
48bfaa4
Compare
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.
Step 4 of 6, stacked on steps 1-3 (`qwen27/pair-seam`, `qwen27/rank-slice`, `qwen27/pair-lifecycle`) and main (`40cb15b4`); 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 (`40cb15b4`); 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.
Step 6 of 6, stacked on steps 1-5 (`qwen27/pair-seam`, `qwen27/rank-slice`, `qwen27/pair-lifecycle`, `qwen27/pair-tests`, `qwen27/pair-tools`) and main (`40cb15b4`). The recipe and the runbook now carry the two-Mac 27B case: both Macs hold the same checkpoint, each keeps its rank-local half of every row-split projection, `--parallel 1`, requests to rank 0 only, and the watchdog that ends a rank when its peer stops signalling. The known issues page says what the pair does not carry: no drafter, no prompt cache, no learning, one greedy stream, and no `pair_known` or `TF_DUMP_IDS` shortcut. The seam splits by output rows with the full K on each rank, and its gather moves bf16 halves without arithmetic, so there is no fp32 partial sum and no tie policy; the pair's logits are byte-identical to one Mac's on the recorded fixtures and `tf-qwen27-tp-check` fails on any token difference with no exemptions. The recipe says what the pair actually does at a decided token: each rank takes the argmax of its own half of the 248,320 logits and `pair_pick` chooses between the two half-vocab winners, and a non-finite logit on either rank is flagged by `pair_argmax` and fails the request through `pair_pick`'s sentinel as `error.NonfiniteLogits`, while a broken link reports `error.PairFailed` — the pair never takes a whole-vocabulary argmax over a merged buffer. A sampled or grammar-constrained request is refused at the head of prefill by name, not answered greedily for one token; `--vision` is listed with the other refused flags in the recipe and in `docs/known-issues.md`. The formats section says which checkpoints a pair takes: the MLX affine stacks a single Mac reads on Metal, each rank keeping its row slice while the K split stays the checkpoint matrix's, and nothing CUDA-only.
…eipt The recipe said a pair reads every affine stack one Mac reads, and listed 2- through 8-bit beside the 4-bit checkpoint it is qualified on. Only the 4-bit stack has a pair receipt -- the two-Mac acceptance run and tf-qwen27-tp-check replay one Mac's recorded 4-bit replies -- so the other affine widths now read as formats the pair loads through the same readers and splits the same way, with no recorded run behind them yet.
The recipe said a non-finite logit on either rank reaches the request as `error.PairFailed`. `pair_argmax`'s flag and `pair_pick`'s sentinel are right, but on a healthy link `Session.greedy` and `picked()` report `error.NonfiniteLogits`, the error one Mac gives; `error.PairFailed` is reserved for a link that broke. The sentence now says both, and the `docs/known-issues.md` bullet joins a short line break after "each rank reads the whole input K".
48bfaa4 to
6e8cd99
Compare
|
Rebased onto The non-finite-logit wording now matches one Mac: Run here, on this head, not carried over: This room has M4 Pro hardware only ( The PR body is refreshed with these same receipts. |
|
Thanks @akol1. Both points are in: the recipe now says a non-finite logit fails with |
|
Thanks @akol1. We ran this branch on one Mac (M5 Ultra, Qwen3.8-27B MLX 4-bit) against its main base, base/new/new/base, decode sweeps at 1 and 4 streams:
Both chats (thinking on and off) qualified. Each change is inside our noise threshold, so the run reads GREEN, but six of eight cells moved the same way. Before this lands we'll rerun it alternating six times each to see whether the 1-2% at 4 streams is real. That's queued after today's 1.2.1 release, so this PR isn't in 1.2.1. |
|
Correction to this follow-up: these six-and-six runs used copy drafts only, while the first run used the DFlash2 drafter (161 tok/s at one stream). So they show no slowdown without the drafter (every cell -0.1% to +3.5%), but they don't settle the 1-2% at 4 streams the first run showed with it. We're rerunning the same six-and-six with DFlash2 now and will post it here. Without the drafter, base against this branch, medians:
|
|
The six-and-six rerun with DFlash2 is in, on the same M5 Ultra and Qwen3.8-27B MLX 4-bit (medians):
Five cells are within half a percent, but greedy chat at 4 streams with the drafter is slower in both reruns (-1.6% in the first, -3.1% here). Its six runs each are base 306.1-325.4 and this PR 301.8-317.3. They overlap, but this PR's mean is 2.3% lower. We can't land a slowdown, even a small one, so before we gate it: does anything in |
What this changes
Step 6 of 6 of the two-Mac Qwen3.8-27B series, rebased onto
main(94b90c9db), stacked on step 5 (qwen27/pair-tools,3b6e996d5), on top of steps 2-4; step 1 (#1432) is merged. Docs only, three commits:RUNBOOK.md,docs/known-issues.mdanddocs/recipes/qwen3.8-27b.md.The recipe gains a "Two Macs over Thunderbolt" section: both Macs hold the same
TensorFold/Qwen3.8-27B-MLX-4bitcheckpoint, each serves it from its own directory with its own settings file, rank 1 starts before rank 0,--parallel 1is required, and requests go to rank 0 only. It says what the split covers (12 of 24 attention heads, 2 of 4 key/value heads, half of the linear attention's heads and value channels, 8,704 of 17,408 MLP columns, 124,160 of 248,320 lm_head rows), that each rank computes its own output rows at the full-K reduction a single Mac uses so no cross-rank partial sum exists, and that embeddings and norms stay whole on both Macs. At a decided token each rank takes the argmax over its own half of the 248,320 logits andpair_pickchooses between the two half-vocab winners; the pair never merges the halves into one whole-vocabulary argmax. A non-finite logit on either rank is flagged bypair_argmaxandpair_pickanswers its sentinel: the request fails witherror.NonfiniteLogits, as on one Mac, and a dropped link fails it witherror.PairFailed.The docs list the refused set by name:
--drafter(rank 1 has no drafter checkpoint; the served pair reads the flag the user passes, before the shard or the vision tower loads, so the name is the refusal),--prompt-cache-giband--learn(each Mac holds only its own halves of a slot's state),--vision(rank 1 replays rank 0's token ids, so an image prompt would run different rows on the two ranks; it is listed with the other refused flags indocs/known-issues.mdandRUNBOOK.md),--parallelabove 1, and a request that samples or carries a grammar, refused at the head of prefill withspeed-up mode: a pair answers greedy only; send the request with sampling and grammar offrather than answered greedily for one token. The formats section says which checkpoints a pair takes: the MLX affine stacks a single Mac reads on Metal, reading the same checkpoint directory on each Mac. The 4-bitTensorFold/Qwen3.8-27B-MLX-4bitstack is the one qualified on a pair — the two-Mac acceptance run andtf-qwen27-tp-checkreplay one Mac's recorded 4-bit replies against it — while the 2-, 3-, 5-, 6- and 8-bit affine stacks of groups 64 load through the same readers and split the same way, each rank keeping its row slice with the K split staying the checkpoint matrix's, but no pair run has produced a receipt on them yet: a pair serve there is unqualified. The branch's second commit is that qualification, with the receipt behind it. The third makes the non-finite-logit sentence sayerror.NonfiniteLogitsfor a healthy link anderror.PairFailedonly for a broken one, as the maintainer asked, and joins the short line break in thedocs/known-issues.mdbullet. Nothing CUDA-only is read (no NVFP4, no EXL3); a tied embedding head is refused at load, as it is on one Mac.RUNBOOK.mdgets the two-Mac commands in its speed-up section and the lifecycle in words: rank 1 first and rank 0 within five minutes, the ready-word timeout, the three-second link watchdog that ends a rank when its peer stops signalling so no rank serves alone, and "restart both after a link failure".docs/known-issues.mdgains the same limits as an issue-shaped entry: one greedy stream, no drafts, no prompt cache, no learning, the refused flags, and the output-row split withpair_gatherjoining bf16 halves without arithmetic so pair logits are byte-identical to one Mac's on the recorded fixtures.A rank's verify never runs a tail.
forward.zig'sfinishruns the head seam first and hands the reopened encoder back to the tail (zig/src/families/qwen27/forward.zig:230,:241); when the seam ishead == .noneit 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:221refuses a round that has both witherror.TailOnRankbefore the cache checks or any GPU work;zig/qwen27_bank_boundary_host.zig:53pins that refusal with model-free stubs, and from step 3 on a pair refuses--drafterat the front door, so the two never meet.Receipt
6e8cd9973636de1440ed4690be8c21b6dcd1c45c) onmain(94b90c9db65dcb71bbc39715901d6fb1f00407d0) plus steps 2-5; no MLX or PyTorch; macOS 27.0.1 on an Apple M4 Pro (Mac16,11), Metal only (applegpu_g16, no tensor units), Apple metal version 32023.921. No checkpoint is loaded by this commit's gates: it edits three Markdown files, and the tree's tests are step 5's.tools/bench_concurrent.py --alone --serialortoken_shaagainst"draft": false: n/a, this commit changes no code and makes no exactness claim of its own; the exactness text it documents is measured by step 4'stf-qwen27-tp-check(byte-identical logits,error.PairTokensDifferFromSingleMacon any token difference), which this Mac still runs, and by step 5'spair_accept.pyoracle, whose receipt predates the rebase because the second Mac is no longer available.tools/bench_openai.py: n/a, docs-only.zig build test:584/584 steps succeeded; 8772/8825 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.zig build test-kernel-contract:20/20 steps succeeded; 141/141 tests passed, exit 0.zig build dev-tools:220/220 steps succeeded.zig build test-qwen27-served-host:7/7 steps succeeded; 49/50 tests passed (1 skipped), exit 0.zig build tf-qwen27-tp-check:42/42 steps succeeded(it needs--model, so on a host with the checkpoint the suite runs both stages and elsewhere it prints its SKIP line).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 34 ms, limits 121 msandSUMMARY 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 119 ms. Lean rule:lean: 424 problems, 424 on the base, 0 new(tools/zig/lean_new.py, exit 0).Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. — docs-only; the recipe says a pair drafts nothing and keeps no prompt cache, and points at the gates that compare against a single Mac.pair_gathermoves bf16 halves without arithmetic, and that no cross-rank partial sum and no tie policy exist.SUMMARYline is in the receipt. — no GPU kernel changes in this commit; the filtered contract runs in the receipt are the regression check for the pair's four records core: add the row-split pair seam and split simd_qmm over K #1432 put onmain.applegpu_g16) for the gates; the docs say which formats and platforms a pair takes (Metal affine, no CUDA-only formats) and do not claim a run on them.tf-qwen27-tp-checkstep it documents skips cleanly without--model: the suite run in the receipt printsSKIP qwen27-tp-check: no --model DIR, so this host has no checkpoint to checkand exits 0.lean: 424 problems, 424 on the base, 0 new; the three files are prose and the rule does not apply to Markdown, and no measurement or history was added.~/models/qwen3.8-27bform (the RUNBOOK writes it as$HOME/models/qwen3.8-27b), and no peer host is named.Stacked on:
qwen27/rank-slice,qwen27/pair-lifecycle,qwen27/pair-tests,qwen27/pair-tools. This commit is step 6 of 6.