Skip to content

app, gpu, gpu-deepseek41, gpu-gates, gpu-glm5next, levers, model, placement: node 1 serves its expert tier to node 0 over a control channel, and a lost node ends both sides - #210

Closed
juliankang4 wants to merge 4 commits into
midagedev:mainfrom
juliankang4:dgx-spark-tier-server

Conversation

@juliankang4

@juliankang4 juliankang4 commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #164.

What and why

This is the tier server row of the P5 plan in #127, docs/contrib/dgx-spark.md 9.5 items 5 and 6. Node 1 serves its share of the routed experts to node 0 over a control channel, the load plans its tier card from the census node 1 sends, and a lost node ends both sides by name. It is built and checked over loopback, two processes on one DGX Spark. The run on two Sparks needs the transport row of #127 and is not part of this PR. Part of #13.

The branch is four commits on main (e6d14f0a..cd99d4a4). The runs below are at eb98a9c0 on main 89d5ce01; the rebase onto e6d14f0a had one conflict, in tools/gate-paths.tsv, where the 47 rows this branch touches keep main's text and add only gate-gpu-qwen38-nodes (in the gate list or, on two rows, in the exceptions), and git range-diff shows no other change to the patch. Since the body of #164 was last edited it adds a check that both nodes read the same tier bytes (below), it is rebased over #172 and #174 and then onto main 89d5ce01, and it fixes the review bot's three must-fixes at c891688f: a send checks its deadline after its last write too, the nodes gate joins a child's stderr reader before it reads the loss lines, and the gate's relay takes the launch tag from wire::Tag. It also fixes the fifth review's must-fix: beside, the binary next to the running one, has one owner in crates/gpu-gates/src/lib.rs, which the nodes gate and serve_client both use. The nodes gate's recipe on the fixture then failed before any clause, because tools/box.sh exports the fixture's card budget and the gate refused that lever; the gate now reads it as gate_qwen38_twocard does ("At 05ab0b68" below). #172 moved the host's reads, stores, releases and resets of the tier page's words into Link, so this branch's node side of the page now goes through those methods, and a loopback link with a tier on a node is refused by name ("After the rebase" below). The DGX Spark runs under "What I ran" open with eb98a9c0 on main 89d5ce01 (the rebase of 05ab0b68 onto main's 44 newer commits), then 05ab0b68, where the nodes gate ran through its just recipe on the fixture and on the real file, then 18ae4660 on main 6f87bfb8; the runs of earlier heads follow. The commits:

  1. 059f5c92, a refactor with no behavior change: TierCard keeps the stage side, and a tier card's own work on its card moves into TierWork.
  2. 747dae62, the tier server, the channel, the load's plan from the census, and both ends of a lost node.
  3. 9a608d65, the docs.
  4. eb98a9c0, a rename with no behavior change: Link::stage_end is Link::own_end, the end of the context that made or opened the page, since a tier node binds its page through it too.

How it works:

  • Two processes, one tier. Node 0 is a Qwen3.8 load whose plan has an expert tier, with BLOOMERY_TIER_NODES=<ip:port>[,...] naming the node of each tier in the plan's tier order. The lever takes loopback addresses only and refuses any other by name: the page below is this host's memory, and the channel has no authentication. Node 1 runs tier_server --listen <ip:port> [-m <first shard>]. It serves one load and exits once node 0 closes it; a connection that opened no tier (a census read, a refused open) leaves it serving the next. Unset, every tier is a card of the loading process, as before.
  • Control channel (crates/gpu/src/host/node.rs, node/wire.rs): TCP from std::net, one connection a load, one reply a request. A frame is a u32 length, a u8 tag and little-endian fields. The requests are open, bind, launch, capture, enqueue, sync, drain, clear fault, fault read and close. Every request gets Ok with its payload or Err with an error kind and the server's text, and a fault on node 1 comes back to node 0 as a fault, with its layer and sites. On node 0 each request and its reply run under one deadline; node 1 takes its reply's deadline once the request's work returns, so an open or a drain that runs long still gets its reply. A send is late when its deadline has passed after any of its writes, the last one included, and a read once a 50 ms read slice that began past it finds no frame. The server keeps watching the tier's progress while a request is still arriving.
  • Census before the plan (9.2). On connect node 1 sends its census: each device's census row. A census of another version, with no device, a free count above the total or a device listed twice is refused by name. node_cards reads the nodes' censuses and makes each tier card with spec_of_device from the row its node sent. Place::on in crates/gpu-gates (the placement of the generate and serve bins and of the gates) calls it when the lever is set, so such a load plans plan (b′) with node 1's card as the tier card before it loads anything, and the gate plans through the same function. A load placed another way plans its tier card from its own census, and node 1 then opens it only if its census lists the same device (TierNode::open refuses another by name).
  • The open. Node 0 sends the tier's rows of its plan (tensor, card format, expert ids, resident bytes, and the sha256 of the row's bytes in node 0's file), the tier's set and its shape. Node 1 refuses a routed row whose experts are not the tier's set for that layer, then checks each tensor by name against node 0's description and refuses another shard, type, shape or size by name (NodeOpen::rows_on, Format::of_word). It then hashes each row's bytes in its own file and refuses a row whose sha256 is not node 0's, naming the tensor and its experts. Only then does it open its card and load the rows from its own copy of the file with Weights::load_rows.
  • Which bytes the open hashes. A row's sha256 covers the file bytes of the experts the tier holds, in slot order: exactly the bytes node 1 uploads. SegmentFile::of (weights.rs) finds a segment's spans in the file and borrows their bytes for the upload and for the digest alike, and model::fileio::sha256_hex_of hashes them one span after another. A copy of the model whose headers match node 0's file but whose tier experts differ is refused by name instead of serving other weights; bytes outside the tier's rows do not reach node 1's computation, so they are not hashed. The table below compares the ways to check that both nodes read the same model.
  • Requests on node 1. Each request is checked before it reaches the tier's stream. One that fails after that ends the connection as a lost node 0 does (below). A second bind is refused before the mapping changes, and a fault clear while work is in flight is refused. No request waits on the stream without a bound.
  • The tier card, split (commit 1). TierCard keeps the stage side: the books, the go order each graph was captured for, the page words it reads and the stage's stream. Its tier side is TierWork, a card of this process, or TierNode, a client of the channel. TierWork is the tier half that existed before (its Gpu, weights, the family's TierExperts, staging, graphs and its end of the page). The tier server holds one and calls the same methods. The replay or feed decision, the settle, the release and the reset stay on the stage side for both.
  • The page across two processes. Over loopback the tier page is one file under /dev/shm, created with mode 0600. Node 0 makes it, maps it and registers it with its card (MappedHost::shared_file: cuMemHostRegister with PORTABLE | DEVICEMAP). Node 1 opens only a tier page's name there, then maps and registers the same file. Node 0 removes the file's name once node 1 has bound it, and a creation that fails removes it too, so a killed process leaves nothing under /dev/shm. Both cards reach one physical page, so the page's protocol runs unchanged across the two processes. The in-process tier page is MappedHost::new (cuMemHostAlloc, not write-combined), so neither page is write-combined and the bits below compare like with like.
  • Lost node 1. On node 0 a request that fails, or gets no reply within its deadline (the go deadline for a step's requests), marks the node lost (GpuError::Link). The host tier then names the tier a lost card (CardLost), and the next step and a reset are refused as for a lost card of node 0's. A node that answers but stops signalling is still the go deadline's.
  • Lost node 0. Node 1 ends on two conditions: the channel closes with no close request, or work asked of it makes no progress past the go deadline and its grace (node 0 stopped, its channel still open). Either way node 1 writes RELEASE to its own go words, so every wait its stream holds passes, drains its stream under the drain deadline, and returns the loss by name. tier_server prints it once, as its exit error, and exits 1. The loss names how many of the layers asked of it it had served and how long node 0 sent no go. If the drain fails, the work is not dropped, so nothing frees memory the stream may still use.
  • A stopped node 0 that runs again. It first takes the reply node 1 sent while it was stopped: a read is late only when a read that began after its deadline finds no frame. Its stage then finds node 1's go words released when it settles the step. The settle refuses the step with the tier a lost card, naming the release, so no token comes from rows node 1 served after it.
How the open checks the model bytes, and what it costs
Way Same headers, other tier weights refused? Bytes each node hashes per open, Qwen3.8 UD-Q4_K_XL [derived] New code
sha256 of every shard (sha256_hex(g.mapping())) on both nodes yes 111,334,654,784 B, the whole file a field and a function
refset's header digest (the census of a reference set) no, header bytes only
headpick's vocabulary digest no, vocabulary only
a download's digest (the repository's LFS sha256, hf::fetch's check) yes for the whole file, but no load reads it and a file made locally has none the whole file, or a manifest both nodes would have to read a manifest reader
sha256 of the tier rows' bytes (this PR) yes, exactly the bytes node 1 uploads the tier rows' file bytes, which the open prints: 14,639,820,800 B for the 144 rows of this plan's tier, 13 % of the file a NodeRow field, segment_sha256 and sha256_hex_of; SegmentFile is the upload's own lookup moved out

Each node hashes B bytes on one core, so the open takes B / R more on node 0 (before it sends the open) and B / R more on node 1 (inside the open), where R is one core's sha256 rate. That rate has not been measured on the DGX Spark, so no time is given here. The whole-file digest takes 111,334,654,784 / 14,639,820,800 = 7.6 times the bytes [derived], and on a 128 GB machine it also pulls the whole file through the page cache on every open. Node 1 reads its tier rows to upload them anyway, so its digest reads no byte the load would not. The open's deadline on node 0 still holds the digest and the upload together: 120 s plus 14,639,820,800 B at 100 MiB/s is 259.6 s [derived] for this plan.

What a node tier adds to each pass

Derived from the code, not measured. RTT is one request's round trip over the loopback channel: node 0 writes the frame, node 1 reads it, does the call and writes its reply, node 0 reads it (TierNode::call).

  • A replayed pass (each decode step after the first feeds): before the host serves the pass's first layer, node 0 sends one launch request to each node tier with a layer in the pass and waits for its reply (TierCard::replay, TierNode::launch). The host's service of that pass starts at most n_node_tiers × RTT later. A tier of node 0's own launches the same graph with a call, so the round trip is the extra.
  • A fed pass (a chain's first pass, before its graph is captured): node 0 sends one enqueue request for each tier layer the pass serves, each before the host serves that layer (HostTier::feed_tiers, TierNode::enqueue), and one capture request after the pass settles. That adds at most (L_served + 1) × RTT.
  • The gate's history on the real file has 82 replayed and 4 fed passes, with the tier on 48 layers: at most 82 + 4 × 49 = 278 round trips for the whole history [derived].
  • The settle reads the tier's words and fault copy from the shared page, with no request.

The decode A/B below ran plan (a) on one DGX Spark, which holds every routed expert on the card and has no tier card. It times the HostTier path the split touches and covers none of these terms. A same-plan A/B of a tier of node 0's own against a node tier needs a second Gpu on one card, which only the gate's harness opens (the decode runner refuses --place bp on one card), so it is not run here. The transport row replaces the launch message: it can ride in the first image of the mirrored pages (9.5 item 5).

What this PR does not do:

  • plan_nodes (placement: a model's plan over several unified nodes (dgx-spark.md §9), and nodes_scale on one to four Sparks #95) is not called. It needs read rates, a NIC figure and per-node budgets that nothing measures on a live load yet. The census carries no host room: node 1's MemAvailable reading joins it with the plan term that reads it, once each node has a pool of its own in the plan (9.2).
  • A remote tier serves no prompt batch. The batch port refuses a load with a tier on a node at its group start, by name.
  • Only Qwen3.8 opens a tier on a node (NODE_FAMILY in arch/qwen3moe/tier38.rs). GLM and V4.1 open their tier cards through TierOpen::open_here, which refuses a tier on a node by name before any card opens.
  • No tier node off this host. Besides the loopback-only lever, the channel has no keepalive. Over loopback a dead peer ends the connection at once, and every request has its deadline. The open's deadline is derived, not measured: 120 s plus the tier's resident bytes at 100 MiB/s.
  • The RDMA link replaces the shared file with its mirrored pages, and the launch message can then ride in the first image (9.5 item 5). That is the transport row.

After the rebase over #172. #172 gave Link the methods the host uses on the tier page's words (load, store, release, at_rest, reset). This branch now builds on them:

At eb98a9c0, just gate-gpu-qwen38-nodes passed on a DGX Spark on the Qwen3.8 fixture and refused by name a fixture budget that differs from the header's; at 05ab0b68, the same patch on main 6f87bfb8, it also passed on UD-Q4_K_XL. At 18ae4660 these ran there ("What I ran"): the PTX scans, gate-gpu-lib, gate-gpu-qwen38-nodes on the fixture and on UD-Q4_K_XL (all six clauses, among them the other-bytes open and the two lost node 0 clauses the stderr fix reaches), and the FAIL-first mutants of the send deadline and of the stderr reader. gate-gpu-ds41-tier and weekly-gpu-ds41-lost, which run the merged page code, end with exit 66 as shipped on that machine, at main as at the head, because it has no DSpark fixture draft; the same gate passed its landing and lost clauses there, over the shared page and over loopback, with two local edits.

Where each part lives
  • crates/gpu/src/host/tier.rs (commit 1): the TierCard split into the stage side and TierWork. TierCard::nodes_per_layer and the books are unchanged. Commit 2 adds TierSide, the node calls, the release check the settle reads and TierOpen::open_here.
  • crates/gpu/src/host/node.rs: NodeCensus, node_cards, NodeOpen and NodeRow (the open's rows, each with its bytes' sha256, segment_sha256), TierNode (node 0's client: the census, the open, the per-request calls and lost), NodeFamily (a family's tensor reader and tier computation for the server) and serve (node 1's loop, the stopped check and the end on a lost node 0).
  • crates/gpu/src/host/node/wire.rs: frames, tags (Tag is public, and the nodes gate's relay reads the launch tag from it), the channel with its deadlines, and the readers and writers of the fields. A payload that ends early or runs past its fields is refused by name. The protocol is version 3.
  • crates/gpu/src/weights.rs: SegmentFile::of, the lookup upload_segment did inline (the tensor found and checked in the file, its spans and their bytes), now shared with the digest. crates/model/src/fileio.rs: sha256_hex_of, the sha256 of several slices one after another; sha256_hex calls it.
  • crates/gpu/src/host/link.rs: Link::across (node 0 makes and maps the file), Link::open_across (node 1 maps it, a tier page's name only), and the file's removal. Commit 4 renames stage_end to own_end.
  • crates/gpu/src/graph.rs: MappedHost::shared_file and the unregister in its drop; a shared page is a whole number of 64 KiB.
  • crates/gpu/src/host/mod.rs: attach_tiers opens a node's tier through the channel; a failed request marks the tier lost; the settle refuses a released node tier; the batch port's group start refuses a node tier.
  • crates/gpu/src/arch/qwen3moe/tier38.rs and swap38.rs: NODE_FAMILY, and stacks_of_set, the stacks of the layers a tier set gives experts (a node holds one tier's set and no plan).
  • crates/gpu-gates/src/generate.rs: Place::on takes the tier cards from node_cards when the lever is set, and a load line's cards field names a node tier's device.
  • crates/levers: the BLOOMERY_TIER_NODES row (class C, kind Addrs, loopback only) and tier_nodes().
  • crates/placement/src/placement.rs: Format::of_word, the format a word names, with a unit test; Format::ALL is held to the enum by an exhaustive match.
  • crates/gpu-gates/src/lib.rs: beside, the binary next to the running one, moved here from serve_client.rs, which re-exports it, so a bin built with the gpu feature alone reaches it. gate_qwen38_nodes starts tier_server through it.
  • crates/gpu-gates/src/bin/tier_server.rs, gate_qwen38_nodes.rs and shared/q38_history.rs, the recipe gate-gpu-qwen38-nodes, and the recipe added to 47 of the 62 tools/gate-paths.tsv rows that name gate-gpu-qwen38-twocard: the rows of the app, runtime, tokenizer, model, gpu, gguf, placement and similar sources a Qwen3.8 load reads, and the fixture generator's row, whose header key the gate's budget check reads. The other 15 rows (the Qwen3.8 fixture module, reference-set readers, residency files, vision position rows and the like) are unchanged. The gate reads BLOOMERY_CARD_BUDGET as gate_qwen38_twocard does: tier::plan_levers checks it against the fixture's header in the fixture tier, and the plans keep the gate's own budgets. tier_server acts on none of the four levers the gate reads and refuses each one that is set, so the gate starts it without them. The two rows that name gate-gpu-qwen38-twocard among their exceptions, the model readers of mimo2 and nemotron_h, name gate-gpu-qwen38-nodes beside it for the same reason.
  • tools/unsafe-ratchet.txt: gpu 1749 to 1754 blocks for the shared file's mapping, its registration, its device pointer, the unregister on that pointer's failure, and MappedHost's drop split into one block a release call, each with its SAFETY line.
  • The callers of TierOpen::of_machine, which now returns the lever's error, take a ? (app, gpu-deepseek41, gpu-glm5next and five gate bins).
  • docs/contrib/dgx-spark.md: 9.5 items 5 and 6 state what is built and what is not, 9.2 and section 10 say the census reaches node 0 and the load plans from it, and section 1's P5 row names the loopback run.

Code shape

  • No second copy. What both Qwen3.8 tier gates hold a load to is one file, crates/gpu-gates/src/bin/shared/q38_history.rs, which gate_qwen38_twocard and gate_qwen38_nodes include: the history (Run, step, window, history, same, WINDOWS), the plan's tier layers (tier_layers), the tier's graph and chain checks after the history (tier_graphs_and_chains) and the nodes a tier layer (TIER_LAYER_NODES). The two-card gate keeps only its residency count, its stage graph line, its tier: stats line and its precondition; the nodes gate keeps its precondition on the tier's experts and resident bytes. Run's write and read in the nodes gate use the channel's own field writer and reader (node/wire.rs). A tier node binds its page through Link::own_end, the stage card's method.

  • Model code holds only architecture facts. Qwen3.8's code gains NODE_FAMILY (its tensor reader and tier computation, both existing functions) and stacks_of_set. The node path, the channel and the loss handling are common code.

  • Dead code is gone.

  • No silent failure. These are each refused by name:

    • a census of another protocol version, with no device, a free count above the total, or a device listed twice;
    • a flag byte other than 0 or 1;
    • a lever address off this host, or a lever that names a different number of nodes than the plan has tiers;
    • an open whose routed rows are not the tier's set, a tensor that differs from node 0's description, or a row whose bytes' sha256 differs from node 0's;
    • a second open or bind, a fault clear with work in flight, a close with a payload, a chain word out of range;
    • a payload that ends early or runs past its fields, a frame over the size limit, an unknown tag, a format word no format has, a stream that ends inside a frame;
    • a node tier on a family without a node opener;
    • a released node tier at the settle;
    • in the fixture tier, a BLOOMERY_CARD_BUDGET that differs from the fixture header's budget (the nodes gate, as the other gates).

    The other-file and version refusals, the census refusals and the error reply's kinds (a fault with its words, a protocol error, a refusal) have unit tests (node.rs, where the open's rows read back with their sha256), the channel's frames and ends a loopback test (wire.rs), and the refusal of a node tier for GLM and V4.1 a unit test of TierOpen::open_here (tier.rs); the swapped open and the open on a copy with other tier bytes are gate clauses.

  • Comments state what is true now.

  • No relaxed gate. No band, tolerance or lint moved. The unsafe ratchet goes up for new code only, with a PIN(2026-10-14) line.

  • cargo fmt and cargo clippy are clean on what I touched at 05ab0b68 (aarch64, below); the lint count did not go up. The later heads change no Rust code beyond the rebases.

Duplication found or removed (path:line), or "none": the Qwen3.8 history, the tier layers, the tier's graph and chain checks and TIER_LAYER_NODES, now in crates/gpu-gates/src/bin/shared/q38_history.rs, are removed from gate_qwen38_twocard.rs and have no copy in gate_qwen38_nodes.rs. gate_qwen38_residency.rs and the GLM and V4.1 twocard gates keep their own histories, which differ in what they step and compare. The nodes gate's own copy of beside (gate_qwen38_nodes.rs:471 at 18ae4660) is gone; it uses the one in crates/gpu-gates/src/lib.rs.

What I ran

On a DGX Spark

The runs here are in the dev container (Hardware, below), with --ipc=host so both processes see one /dev/shm, with BLOOMERY_BOX_LOCAL=1 and PTX for sm_86 (the driver JITs it for the GB10). The nodes gate at 05ab0b68 and before its budget fix, and the V4.1 rows at 18ae4660 ran their just recipes, which go through tools/box.sh; the other rows ran tools/gate.sh and tools/gpu-gate.sh directly. Each log starts with the tree's commit; a mutant's log adds the mutant's name.

At eb98a9c0, against main 89d5ce01. The branch is 05ab0b68 rebased onto main's 44 newer commits. git range-diff 6f87bfb8..05ab0b68 89d5ce01..eb98a9c0 shows commits 1, 3 and 4 equal and commit 2 changed only in tools/gate-paths.tsv and tools/spawn-allow.txt, the two lists main also changed: three of the rows this branch adds gate-gpu-qwen38-nodes to (crates/runtime/src/**, crates/threads/src/lib.rs and crates/placement/src/**) carry main's edits to their gate lists as well, and the nodes gate's spawn-allow.txt line sits beside main's changed lines. The recipe still lands in 47 of the 62 rows that name gate-gpu-qwen38-twocard. git diff --numstat of the whole branch is the same before and after the rebase, file by file. Of the files main changed, crates/levers/src/lib.rs, crates/levers/src/tests.rs and justfile are also edited here, in other hunks (main removed the slot count from the paged rule; this branch adds TIER_NODES). The nodes gate, tier_server and the gate's recipe are not touched by main. The runs below are the ones those changes can reach, on the same DGX Spark and container as the 05ab0b68 runs; the real-file run, just fmt-check and just lint were not run again at eb98a9c0, and their results at 05ab0b68 stand below for that commit.

At eb98a9c0, against main 89d5ce01 Result
BLOOMERY_BOX_LOCAL=1 BLOOMERY_TIER=fixture BLOOMERY_GATE_BOUND=3600 just gate-gpu-qwen38-nodes PASSED: gate_qwen38_nodes, clauses: 6 ran, no FAIL line, gate 119 s, just exit 0; no /dev/shm file and no copy left
The same with BLOOMERY_BOX_ENV=BLOOMERY_CARD_BUDGET=9489612799 refused before any clause: gate_qwen38_nodes: BLOOMERY_CARD_BUDGET=9489612799 and the fixture's bloomery.fixture.card_budget is 9489612800: the fixture tier plans under the header's budget, just exit 1
BLOOMERY_HOST_POPULATE=1 target/release/tier_server --listen 127.0.0.1:0 refused at once, exit 1, as at 05ab0b68
cargo test -p bloomery-levers exit 0, 39 passed
The eleven tools/check-*.sh ten exit 0. check-recipes exits 1 on five self-test cases (the Mac remote lock's self-test and gpu-gate.sh's read-only queue case); main 89d5ce01 fails the same five in the same container, and the failing lines are equal after the temporary directory names are removed. Its gate-path check passes: 120 gate rows held to the derivation, 23 rows with exceptions naming 264 gates, against main's 262

At 05ab0b68, against main 6f87bfb8. git range-diff 6f87bfb8 18ae4660 05ab0b68 shows two changes, both in the tier server commit, whose message says both: beside moves to crates/gpu-gates/src/lib.rs and the nodes gate drops its copy, and the nodes gate reads the budget lever (below), with the fixture generator's tools/gate-paths.tsv row naming the gate. No kernel or engine file changed, so the scans, gate-gpu-lib and the V4.1 rows at 18ae4660 below still describe this head. The tree is a clone at 05ab0b68 in the same container on another DGX Spark (NVIDIA GB10, 12.1, 595.84), built in a target directory of its own with cargo oxide 0.2.1, whose backend is built from cuda-oxide 87bc9518, the workspace's pin. BLOOMERY_GATE_BOUND=3600 was set as in the earlier runs; each gate run took less than the default 900 s.

The fixture tier through the recipe. Before this fix (and, by the code, at 18ae4660, whose gate lists the same levers [derived]), BLOOMERY_TIER=fixture just gate-gpu-qwen38-nodes failed before any clause. tools/box.sh exports the fixture header's card budget as BLOOMERY_CARD_BUDGET in the fixture tier, and the gate, which did not list that lever, refused it. The fixture rows at 18ae4660 and before started tools/gpu-gate.sh directly, so they never saw the export. The gate now lists the lever and checks it against the fixture's header with tier::plan_levers, the check gate_qwen38_twocard makes; the plans keep the gate's own budgets. tier_server acts on none of the four levers the gate reads and refuses each one that is set, so the gate starts it without them.

At 05ab0b68, against main 6f87bfb8 Result
FAIL-first, before the budget fix (05ab0b68 without it), on the same machine: BLOOMERY_BOX_LOCAL=1 BLOOMERY_TIER=fixture BLOOMERY_GATE_BOUND=3600 just gate-gpu-qwen38-nodes red before any clause: gate_qwen38_nodes: BLOOMERY_CARD_BUDGET="9489612800" is refused: this binary (gate_qwen38_nodes) does not act on it; unset it, just exit 1
BLOOMERY_BOX_LOCAL=1 BLOOMERY_TIER=fixture BLOOMERY_GATE_BOUND=3600 just gate-gpu-qwen38-nodes PASSED: gate_qwen38_nodes, clauses: 6 ran, no FAIL line, 113 s, just exit 0; no /dev/shm file and no copy left (block below)
The same with BLOOMERY_BOX_ENV=BLOOMERY_CARD_BUDGET=9489612799, one byte below the header's budget (tools/box.sh exports the entry after the profile) refused before any clause: gate_qwen38_nodes: BLOOMERY_CARD_BUDGET=9489612799 and the fixture's bloomery.fixture.card_budget is 9489612800: the fixture tier plans under the header's budget, just exit 1
BLOOMERY_HOST_POPULATE=1 target/release/tier_server --listen 127.0.0.1:0, a lever the gate reads, set for tier_server refused at once: tier_server: BLOOMERY_HOST_POPULATE="1" is refused: this binary (tier_server) does not act on it; unset it, exit 1. This is why the gate starts node 1 without the four levers it reads
BLOOMERY_BOX_LOCAL=1 BLOOMERY_TIER=real BLOOMERY_GATE_BOUND=3600 just gate-gpu-qwen38-nodes on UD-Q4_K_XL, the file the profile tools/ref/models/qwen4exp.sh names PASSED: gate_qwen38_nodes, clauses: 6 ran, no FAIL line, 431 s, just exit 0; no /dev/shm file and no copy left (block below)
just fmt-check and just lint (clippy with the recipe's features, native aarch64) in the same container, on 05ab0b68 and on main 6f87bfb8 fmt-check exit 0, no diff. lint exit 0, 0 errors, ^warning: 39 on both commits; the warning lines differ only in which of a pair is marked duplicate. The ratchet's 41 is the Mac's x86_64 count, so the aarch64 count is compared with main's
The eleven tools/check-*.sh that just mac-static runs, in the same container on 05ab0b68 ten exit 0. check-recipes exits 1 on five self-test cases that fail the same way on main 6f87bfb8 there (the Mac remote lock's self-test and gpu-gate.sh's read-only queue case); its gate-path check passes with 264 gates named in exceptions, against main's 262
cargo oxide build --arch sm_86 -- -p bloomery-gpu-gates --features deepseek41 --release --bin gate_qwen3_serve, a bin that takes beside through serve_client, before the budget fix (serve_client.rs and lib.rs are the same at 05ab0b68) builds, exit 0

On the Qwen3.8 fixture through the recipe, abridged: the cargo output, the budget lines between 16 and 8 GiB, the expert lists, the host tier's load lines and node 1's listening, census and loss lines are cut, long lines end in ..., and the data directory shows as $BLOOMERY_DATA. just exited 0.

$ BLOOMERY_BOX_LOCAL=1 BLOOMERY_TIER=fixture BLOOMERY_GATE_BOUND=3600 just gate-gpu-qwen38-nodes
BLOOMERY_MODEL=qwen4exp BLOOMERY_CARD=a6000 ./tools/box.sh 'export BLOOMERY_GATE_V41_LOAD=1 && cargo oxide build --arch sm_86 -- -p bloomery-gpu-gates --features gpu --release --bin gate_qwen38_nodes --bin tier_server && bash tools/gpu-gate.sh gate_qwen38_nodes'
gpu-gate.sh: gate_qwen38_nodes on this machine's card(s), the local gate lock (asked 3090), tier fixture: no V4.1 load lock
census of node 1: device 0 "NVIDIA GB10", total 130596122624 B, free 124649967616 B, integrated true
census: the plan's tier card "GB10" takes the free bytes node 1 sent (Some(124649967616) = 124649967616 B): PASS
plan (b′) under 7 GiB a card: stage 417 experts (a layer (104, 105)), tier 1489 experts (a layer (372, 373)) on 4 layers, host 142 experts
ok open: node 1 answered the swapped segment with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:45679 refused the open request: ...
open: the tier's 12 rows hold 5107507200 file bytes, which each node hashes
open: blk.0.ffn_down_exps.weight expert 105: byte 1713607040 of shard 0 flipped in a copy of its 8883314816 B under $BLOOMERY_DATA/gate_qwen38_nodes-232
ok open: node 1 on the copy answered the plan's tier with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:44023 refused the open request: tier_server: blk.0.ffn_down_exps.weight: the experts Some([105, ...]) hold other bytes in this file ...
load in 3.9 s: stage 5779783276 B, tier 1489 experts (5107507200 B) on this process
[node 0] load in 21.9 s: stage 5779783276 B, tier 1489 experts (5107507200 B) on the node at 127.0.0.1:45679
[node 0] node 0: the tier is served by the node at Some(127.0.0.1:45679): PASS
[node 0] ok lost node 1: the step fails in 0.1 s naming the lost node: Hybrid::serve: layer 0: the expert tier on GB10 at the node 127.0.0.1:45679: the launch request: Connection reset by peer (os error 104): the card is lost
[node 0] ok lost node 1: the next step is refused: ...
[node 0] ok lost node 1: a reset is refused by the lost card's poison: ...
ok node 0 and node 1: 98 tokens, 98 logits rows bit for bit the reference's
structure: node 0's stage graphs hold [129, 135, 135, 135] nodes, the one-process load's [129, 135, 135, 135]: PASS
precondition: node 1 was past 12 of the 16 layers node 0 asked of it when node 0 was killed: PASS
ok lost node 0 (killed): node 1 ended Some(ExitStatus(unix_wait_status(256))) 0.4 s after, the loss named true, its words released and its stream drained true
ok lost node 0 (stopped): continued, node 0's held step gave held step: Err(Protocol { ...
precondition: node 1 was past 13 of the 16 layers node 0 asked of it when node 0 was stopped: PASS
ok lost node 0 (stopped): node 1 ended Some(ExitStatus(unix_wait_status(256))) 10.4 s after, the loss named true, its words released and its stream drained true
clauses: 6 ran, 0 left to the real tier (deferred(real) lines above)
PASSED: gate_qwen38_nodes
gpu-gate.sh: gate_qwen38_nodes ran 113 s (exit 0)

On the real file through the recipe, abridged the same way:

$ BLOOMERY_BOX_LOCAL=1 BLOOMERY_TIER=real BLOOMERY_GATE_BOUND=3600 just gate-gpu-qwen38-nodes
BLOOMERY_MODEL=qwen4exp BLOOMERY_CARD=a6000 ./tools/box.sh 'export BLOOMERY_GATE_V41_LOAD=1 && cargo oxide build --arch sm_86 -- -p bloomery-gpu-gates --features gpu --release --bin gate_qwen38_nodes --bin tier_server && bash tools/gpu-gate.sh gate_qwen38_nodes'
gpu-gate.sh: gate_qwen38_nodes on this machine's card(s), the local gate lock (asked 3090), V4.1 load lock
census of node 1: device 0 "NVIDIA GB10", total 130596122624 B, free 124824203264 B, integrated true
census: the plan's tier card "GB10" takes the free bytes node 1 sent (Some(124824203264) = 124824203264 B): PASS
plan (b′) under 16 GiB a card: stage 1962 experts (a layer (40, 41)), tier 4671 experts (a layer (97, 98)) on 48 layers, host 17943 experts
ok open: node 1 answered the swapped segment with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:35901 refused the open request: ...
open: the tier's 144 rows hold 14639820800 file bytes, which each node hashes
open: blk.41.ffn_down_exps.weight expert 41: byte 50392096 of shard 3 flipped in a copy of its 12087983520 B under $BLOOMERY_DATA/gate_qwen38_nodes-227
ok open: node 1 on the copy answered the plan's tier with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:39069 refused the open request: tier_server: blk.41.ffn_down_exps.weight: the experts Some([41, ...]) hold other bytes in this file ...
load in 37.1 s: stage 14821690252 B, tier 4671 experts (14639820800 B) on this process
[node 0] load in 100.7 s: stage 14821690252 B, tier 4671 experts (14639820800 B) on the node at 127.0.0.1:35901
[node 0] node 0: the tier is served by the node at Some(127.0.0.1:35901): PASS
[node 0] ok lost node 1: the step fails in 0.2 s naming the lost node: Hybrid::serve: layer 0: the expert tier on GB10 at the node 127.0.0.1:35901: the launch request: Connection reset by peer (os error 104): the card is lost
[node 0] ok lost node 1: the next step is refused: ...
[node 0] ok lost node 1: a reset is refused by the lost card's poison: ...
ok node 0 and node 1: 98 tokens, 98 logits rows bit for bit the reference's
structure: node 0's stage graphs hold [1343, 1427, 1427, 1427] nodes, the one-process load's [1343, 1427, 1427, 1427]: PASS
precondition: node 1 was past 145 of the 192 layers node 0 asked of it when node 0 was killed: PASS
ok lost node 0 (killed): node 1 ended Some(ExitStatus(unix_wait_status(256))) 0.5 s after, the loss named true, its words released and its stream drained true
ok lost node 0 (stopped): continued, node 0's held step gave held step: Err(Protocol { ...
precondition: node 1 was past 145 of the 192 layers node 0 asked of it when node 0 was stopped: PASS
ok lost node 0 (stopped): node 1 ended Some(ExitStatus(unix_wait_status(256))) 10.7 s after, the loss named true, its words released and its stream drained true
clauses: 6 ran, 0 left to the real tier (deferred(real) lines above)
PASSED: gate_qwen38_nodes
gpu-gate.sh: gate_qwen38_nodes ran 431 s (exit 0)

At 18ae4660, against main 6f87bfb8. Both trees are git archive of the two commits, built from scratch:

At 18ae4660, against main 6f87bfb8 Result
nvidia-smi on the machine of the run NVIDIA GB10, 12.1, 595.84, CUDA Version 13.2 (block below)
ptx-scan --no-jit of gate_kquant, gate_swap, gate_qwen38_twocard, gate_e2e (gpu), generate_ds41 (gpu, deepseek41) and bloomery-serve-ds41 (deepseek41, vision) on both commits, tools/ref/ptx-canon.py --scans rc 0 on both, no scan failure; md5 identical=N moved=0 for every bin: 238, 235, 234, 234, 301 and 317
gate-gpu-lib 248 passed (1, 243 and 4 in its three runs), 0 failed, among them a_channel_reads_whole_frames_and_its_ends with the review's expired-send assertion, messages_read_back_as_written and a_tier_on_a_node_is_refused_by_name
gate_qwen38_nodes on the Qwen3.8 fixture PASSED, clauses: 6 ran, no FAIL line, 124 s; no /dev/shm file and no copy left (block below)
gate_qwen38_nodes on UD-Q4_K_XL PASSED, clauses: 6 ran, no FAIL line, 555 s; no /dev/shm file and no copy left (block below)
FAIL-first: Channel::send checks its deadline only while bytes are left (at < self.outbuf.len() && Instant::now() > deadline), gate-gpu-lib's command on host::node::wire::tests red: a_channel_reads_whole_frames_and_its_ends is the one failure, called Result::unwrap_err() on an Ok value; bloomery-gpu compiled again
FAIL-first: wait_within returns without joining the stderr reader, and the reader sleeps 1000 ms before each append; the nodes gate on the fixture red: FAIL lost node 0 (killed) and FAIL lost node 0 (stopped), each with "the loss named false" and its precondition failing at "past 0 of the 0 layers", then FAILED (exit 1)
The same 1000 ms sleep with the join kept, the nodes gate on the fixture PASSED, no FAIL line; node 1 ended 1.1 s after node 0 was killed and 11.3 s after it was stopped, against 0.3 s and 10.4 s in the run with no sleep
just gate-gpu-ds41-tier and just weekly-gpu-ds41-lost as shipped, BLOOMERY_TIER=fixture, on both commits exit 66 on both commits: tools/ref-paths.sh wants the one DSpark fixture draft, which this machine does not have
gate_deepseek41_tier on the V4.1 fixture with the two local edits below: the landing clauses (--union --fault --two --batch --batch2 --bfault) and the lost clauses (--bfirst --blost --lost) on both commits, and both again on the head with BLOOMERY_TIER_LINK=loopback six runs, all PASSED: gate_deepseek41_tier, no FAIL line; the stage graph 288 nodes, the tier's step and pair graphs 64 and 127, as at #172

Where this run differed from the plan for it:

  • The stderr mutant's sleep. The two stderr runs first slept 200 ms, and both passed, the one without the join too. A likely reason, not measured: the reader sleeps once a 4096-byte read, and on this machine node 1 exits more than 200 ms after its last write to stderr, so the reader is done before the gate reads the text. At 1000 ms the run without the join is red and the run with it passes, as the rows above show, and the slower exits in the run with the join are the join waiting on the reader. The two 1000 ms runs were built from the same 18ae4660 tree with BLOOMERY_GIT_COMMIT set to it; their logs have no tree line.
  • How the V4.1 rows were built. They ran through just and tools/box.sh in local mode, with a cargo oxide built from cuda-oxide 87bc9518, the workspace's pin. Every other row ran cargo oxide build in the container directly, with the image's cargo oxide from cuda-oxide 4da6c138.
  • The V4.1 rows' inputs. The six stand-in runs carry the two edits that the refactor's runs below describe, on both commits and not part of this PR: a draft reserve of 0 B, and a tier depth of at most the real file's 23 experts a layer. They also read engram/corpus-prose.ids, which this machine does not have. The first attempt of the six ended with exit 1 on the missing file. A stand-in from a fixed-seed generator (seed 20261013, sha256 27cc28dbcc6521ec...) was then put in place by hand, and the rows above are the second attempt.
  • The nodes gate's corpus. qwen4exp/corpus-prose.ids is not on this machine either, so the nodes gate read the same 4096-id stand-in as every nodes gate run in this PR (its first 64 ids are listed below).

nvidia-smi on the machine of the run:

$ nvidia-smi --query-gpu=name,compute_cap,driver_version --format=csv,noheader
NVIDIA GB10, 12.1, 595.84
$ nvidia-smi | grep 'Driver Version'
| NVIDIA-SMI 595.84                 Driver Version: 595.84         CUDA Version: 13.2     |

On the Qwen3.8 fixture (gate_qwen38_nodes with BLOOMERY_TIER=fixture), abridged: the budget lines between 16 and 8 GiB, the expert lists, the host tier's load lines and node 1's census as node 0 prints it are cut, long lines end in ..., and the data directory shows as $BLOOMERY_DATA. Node 1 prints its loss line once in each lost node 0 clause, as tier_server's exit error.

[tree] head commit 18ae4660fe867c2abae7d3260e20a41ab0d8af5f
[node 1] listening on 127.0.0.1:41195
census of node 1: device 0 "NVIDIA GB10", total 130661761024 B, free 124361629696 B, integrated true
census: the plan's tier card "GB10" takes the free bytes node 1 sent (Some(124361629696) = 124361629696 B): PASS
budget 16 GiB: the tier holds no expert; next
...
budget 8 GiB: no layer before the last holds a host expert (host experts a layer [0, 0, 0, 0]); next
plan (b′) under 7 GiB a card: stage 417 experts (a layer (104, 105)), tier 1489 experts (a layer (372, 373)) on 4 layers, host 142 experts
ok open: node 1 answered the swapped segment with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:41195 refused the open request: tier_server: blk.0.ffn_down_exps.weight (layer Some(0)): node 0's plan gives the tier the experts ...
open: the tier's 12 rows hold 5107507200 file bytes, which each node hashes
open: blk.0.ffn_down_exps.weight expert 105: byte 1713607040 of shard 0 flipped in a copy of its 8883314816 B under $BLOOMERY_DATA/gate_qwen38_nodes-206
[node 1] listening on 127.0.0.1:38143
ok open: node 1 on the copy answered the plan's tier with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:38143 refused the open request: tier_server: blk.0.ffn_down_exps.weight: the experts Some([105, ...]) hold other bytes in this file (sha256 a04a13340082deb8a766dad6ed4fad2d9e10f5a2732f9a2d3b31f89540157541) than in node 0's (c44b46388f4bfb191566d472d7816dcd851a1454b9330c032846143ea8710a0a): the two processes read other files" })
load in 11.1 s: stage 5779783276 B, tier 1489 experts (5107507200 B) on this process
structure: the tier's graphs of the step and the 2, 3, 4-column verifies hold [Some(37), Some(37), Some(37), Some(37)] nodes, 9 a tier layer and the fault copy (Some(37)): PASS
structure: the tier's chains fed 4 and captured 4 times, replayed 82 times; each of the history's 4 chains fed and captured once, its 86 passes' others replayed (4, 4, 82): PASS
precondition: the tier holds 1489 experts (5107507200 B) and every tier layer was sent a routed slot (missing []): PASS
[node 0] load in 23.0 s: stage 5779783276 B, tier 1489 experts (5107507200 B) on the node at 127.0.0.1:41195
[node 0] node 0: the tier is served by the node at Some(127.0.0.1:41195): PASS
[node 0] structure: the tier's graphs of the step and the 2, 3, 4-column verifies hold [Some(37), Some(37), Some(37), Some(37)] nodes, 9 a tier layer and the fault copy (Some(37)): PASS
[node 0] structure: the tier's chains fed 4 and captured 4 times, replayed 82 times; each of the history's 4 chains fed and captured once, its 86 passes' others replayed (4, 4, 82): PASS
[node 0] precondition: the tier holds 1489 experts (5107507200 B) and every tier layer was sent a routed slot (missing []): PASS
[node 0] lost node 1: kill -KILL 207: exit status: 0
[node 0] ok lost node 1: the step fails in 0.1 s naming the lost node: Hybrid::serve: layer 0: the expert tier on GB10 at the node 127.0.0.1:41195: the launch request: Connection reset by peer (os error 104): the card is lost
[node 0] ok lost node 1: the next step is refused: qwen4exp Body38: position 4: the call there failed after its chain was launched, so the recurrent stores hold it already (they stand at 5); reset ...
[node 0] ok lost node 1: a reset is refused by the lost card's poison: Hybrid::settle: the host tier is poisoned (Hybrid::serve lost the expert tier's card GB10 at layer 0: ...
node 0 ended exit status: 0: its clauses PASS
ok node 0 and node 1: 98 tokens, 98 logits rows bit for bit the reference's
structure: node 0's stage graphs hold [129, 135, 135, 135] nodes, the one-process load's [129, 135, 135, 135]: PASS
node 1 ended Some(ExitStatus(unix_wait_status(9))) (killed by node 0's lost node clause)
[node 0] armed: the next step waits for a line on stdin
[node 1] tier_server: node 0 at 127.0.0.1:59410 is lost: the channel ended with no close, the tier past 12 of the 16 layers asked of it; the tier GB10: the tier released its words and its stream drained
precondition: node 1 was past 12 of the 16 layers node 0 asked of it when node 0 was killed: PASS
ok lost node 0 (killed): node 1 ended Some(ExitStatus(unix_wait_status(256))) 0.3 s after, the loss named true, its words released and its stream drained true
[node 0] armed: the next step waits for a line on stdin
[node 1] tier_server: node 0 at 127.0.0.1:38088 is lost: the tier past 13 of the 16 layers asked of it and node 0 sent no go for 10.1s; the tier GB10: the tier released its words and its stream drained
[node 0] held step: Err(Protocol { what: "Hybrid::serve", detail: "layer 3: the expert tier on GB10 at the node 127.0.0.1:33357: the node released the tier's words, having seen no go from this process past its deadline, so the rows it served since are stale: ...
ok lost node 0 (stopped): continued, node 0's held step gave held step: Err(Protocol { ...
precondition: node 1 was past 13 of the 16 layers node 0 asked of it when node 0 was stopped: PASS
ok lost node 0 (stopped): node 1 ended Some(ExitStatus(unix_wait_status(256))) 10.4 s after, the loss named true, its words released and its stream drained true
clauses: 6 ran, 0 left to the real tier (deferred(real) lines above)
PASSED: gate_qwen38_nodes
gpu-gate.sh: gate_qwen38_nodes ran 124 s (exit 0)
shm after: 0 bloomery-tier files

On the real file, Qwen3.8 UD-Q4_K_XL, abridged the same way. The plan puts 4671 experts on node 1's tier and 17943 on the host:

[tree] head commit 18ae4660fe867c2abae7d3260e20a41ab0d8af5f
census: the plan's tier card "GB10" takes the free bytes node 1 sent (Some(124946776064) = 124946776064 B): PASS
plan (b′) under 16 GiB a card: stage 1962 experts (a layer (40, 41)), tier 4671 experts (a layer (97, 98)) on 48 layers, host 17943 experts
ok open: node 1 answered the swapped segment with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:40109 refused the open request: tier_server: blk.0.ffn_down_exps.weight (layer Some(0)): node 0's plan gives the tier the experts ...
open: the tier's 144 rows hold 14639820800 file bytes, which each node hashes
open: blk.41.ffn_down_exps.weight expert 41: byte 50392096 of shard 3 flipped in a copy of its 12087983520 B under $BLOOMERY_DATA/gate_qwen38_nodes-149
ok open: node 1 on the copy answered the plan's tier with Err(Shape { what: "TierNode", detail: "the node at 127.0.0.1:36847 refused the open request: tier_server: blk.41.ffn_down_exps.weight: the experts Some([41, ...]) hold other bytes in this file (sha256 082d4ed32fc6b55bc280a794e39a3ec889b711ffcb5c83abc1029f3164267773) than in node 0's (3e7f52f6dd01906e6a38e706c20e557fcca9ed78d7d777a82c842d8c7bbb373c): the two processes read other files" })
load in 47.4 s: stage 14821690252 B, tier 4671 experts (14639820800 B) on this process
structure: the tier's graphs of the step and the 2, 3, 4-column verifies hold [Some(433), Some(433), Some(433), Some(433)] nodes, 9 a tier layer and the fault copy (Some(433)): PASS
structure: the tier's chains fed 4 and captured 4 times, replayed 82 times; each of the history's 4 chains fed and captured once, its 86 passes' others replayed (4, 4, 82): PASS
[node 0] load in 110.8 s: stage 14821690252 B, tier 4671 experts (14639820800 B) on the node at 127.0.0.1:40109
[node 0] node 0: the tier is served by the node at Some(127.0.0.1:40109): PASS
[node 0] precondition: the tier holds 4671 experts (14639820800 B) and every tier layer was sent a routed slot (missing []): PASS
[node 0] ok lost node 1: the step fails in 0.2 s naming the lost node: Hybrid::serve: layer 0: the expert tier on GB10 at the node 127.0.0.1:40109: the launch request: Connection reset by peer (os error 104): the card is lost
[node 0] ok lost node 1: the next step is refused: ...
[node 0] ok lost node 1: a reset is refused by the lost card's poison: ...
ok node 0 and node 1: 98 tokens, 98 logits rows bit for bit the reference's
structure: node 0's stage graphs hold [1343, 1427, 1427, 1427] nodes, the one-process load's [1343, 1427, 1427, 1427]: PASS
[node 1] tier_server: node 0 at 127.0.0.1:35970 is lost: the channel ended with no close, the tier past 145 of the 192 layers asked of it; the tier GB10: the tier released its words and its stream drained
precondition: node 1 was past 145 of the 192 layers node 0 asked of it when node 0 was killed: PASS
ok lost node 0 (killed): node 1 ended Some(ExitStatus(unix_wait_status(256))) 0.8 s after, the loss named true, its words released and its stream drained true
[node 1] tier_server: node 0 at 127.0.0.1:57868 is lost: the tier past 145 of the 192 layers asked of it and node 0 sent no go for 10.1s; the tier GB10: the tier released its words and its stream drained
[node 0] held step: Err(Protocol { what: "Hybrid::serve", detail: "layer 47: the expert tier on GB10 at the node 127.0.0.1:42343: the node released the tier's words, having seen no go from this process past its deadline, so the rows it served since are stale: ...
ok lost node 0 (stopped): continued, node 0's held step gave held step: Err(Protocol { ...
precondition: node 1 was past 145 of the 192 layers node 0 asked of it when node 0 was stopped: PASS
ok lost node 0 (stopped): node 1 ended Some(ExitStatus(unix_wait_status(256))) 10.6 s after, the loss named true, its words released and its stream drained true
clauses: 6 ran, 0 left to the real tier (deferred(real) lines above)
PASSED: gate_qwen38_nodes
gpu-gate.sh: gate_qwen38_nodes ran 555 s (exit 0)
shm after: 0 bloomery-tier files

The runs of the earlier heads 6b16c8d2 and 69a25bf6 are in the first comment below, to keep this body under GitHub's size limit.

On a Mac

At 18ae4660, with main 6f87bfb8 as the base: just mac-static, just mac-test, python3 tools/recipes.py check and the aarch64 cross check of the workspace (BLOOMERY_CHECK_TARGET=aarch64-unknown-linux-gnu tools/mac-check.sh check, the Spark's triple). The block is abridged: the eleven check-script lines are merged into one, and the timings and long tails are cut.

$ just mac-static 6f87bfb8
mac-static: fmt-check rc 0 (rustfmt diff blocks: 0)
mac-static: lint rc 0 (lint ^warning: 41 = the ratchet 41 in tools/lint-ratchet.txt)
mac-static: combos rc 0 (0 shapes run, 0 red, 49 skipped (scoped) ...; 42 green in the ledger ...)
mac-static: check-arch, check-comments, check-defaults, check-levers, check-loads, check-ptx-names,
            check-records, check-recipes, check-rustflags, check-unsafe, check-waits: rc 0 each
mac-static: ok
$ just mac-test
mac-check: test rc 101; 12 crates (crate=rc): ... bloomery-placement=0 bloomery-refset=101 ...; 1162 passed, 1 failed, 179 ignored in 43 test binaries
$ python3 tools/recipes.py check
check-recipes: tools/gate-paths.tsv: 120 gate rows held to the derivation, 23 rows with exceptions naming 264 gates
(rc 0)
$ BLOOMERY_CHECK_TARGET=aarch64-unknown-linux-gnu tools/mac-check.sh check
mac-check: check rc 0; ... 14 bloomery crates checked or compiled

The combos come from the ledger: they ran at 2cc0eeb9, the same four patches on main 15838a46 (git range-diff shows them equal), 42 of 42 green, and main's one commit since changes three Python tools, which no build shape reads. The one red is bloomery-refset's a_plain_target_with_no_draft_beside_is_the_family_path: the temporary directory comes back once with and once without macOS's /private prefix. This PR does not touch crates/refset, and the same test is red on the same Mac at main cb5688c9 alone.

Hardware (if the change is for a machine)

One DGX Spark a run (GB10, cc 12.1), Linux 6.17.0-1032-nvidia aarch64, host driver 595.84. The census total was 130,596,122,624 B in the runs at eb98a9c0 and 05ab0b68, 130,661,761,024 B in the runs at 18ae4660, 130,596,118,528 B in the runs at 69a25bf6 and 130,596,122,624 B in the earlier runs. The checks ran in a container built on nvidia/cuda:13.3.1-devel-ubuntu24.04, with rustc 1.100.0-nightly (e457a7b0d 2026-08-27) and just 1.58.0. Both processes ran on the one GB10. Apart from the decode A/B these are pass and fail results, not timings. The logs of the runs at 69a25bf6 did not keep the machine's nvidia-smi line; the runs at 6b16c8d2, 18ae4660, 05ab0b68 and eb98a9c0 recorded NVIDIA GB10, 12.1, 595.84.

AI help

AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.

… side that drives it

TierCard held both halves of an expert tier: the stage side (the replay or
feed decision, the go order each graph was captured for, the books, the
settle, the release and the reset) and the tier's work on its card (its Gpu,
its stacks, the architecture's computation, the staging, the row windows over
the page and the graphs). The work is now TierWork, which TierCard holds and
calls; each TierWork method is one step the stage side asks of the tier.
TierWork keeps the page's layout with its end of the link, so
TierCard::capture and TierCard::enqueue_eager no longer take the page.

A split with no behavior change: each step makes the same driver calls on the
tier card, in the same order and under the same context binds. Nothing outside
crates/gpu/src/host changes.
@juliankang4

Copy link
Copy Markdown
Collaborator Author

Runs of the earlier heads, moved here from the description for its size limit.

At 6b16c8d2, the head before the rebase over #172, against main a13125a8:

At 6b16c8d2, against main a13125a8 Result
nvidia-smi on the machine of the run NVIDIA GB10, 12.1, 595.84, CUDA Version 13.2
ptx-scan --no-jit of the scan set and gate_qwen38_twocard, tools/ref/ptx-canon.py --scans base and head rc 0, no scan failure; identical: 238, 235, 234 and 317 entries, moved 0
The pure crates' tests, and cargo test -p bloomery-model --lib -- fileio r8file head_list 209 passed, 0 failed; 3 passed, 0 failed
gate-gpu-lib 243 passed (1, 238 and 4 in its three runs), 0 failed, among them messages_read_back_as_written, a_tier_on_a_node_is_refused_by_name and a_channel_reads_whole_frames_and_its_ends
gate_qwen38_nodes on the Qwen3.8 fixture, with the new clause PASSED; the tier's 12 rows hold 5,107,507,200 file bytes; node 1 on the copy refuses the plan's tier naming blk.0.ffn_down_exps.weight and its experts; no /dev/shm file and no copy left
gate_qwen38_nodes on UD-Q4_K_XL, with the new clause PASSED; the tier's 144 rows hold 14,639,820,800 file bytes; node 1 on the copy refuses the plan's tier naming blk.41.ffn_down_exps.weight and its experts; no /dev/shm file and no copy left
FAIL-first: node 1 with the sha256 compare removed, the nodes gate on the fixture red: the copy passes the open and is refused later by the tier's shape, so the clause fails
FAIL-first: the open's writer dropping each row's sha256, host::node::tests red: messages_read_back_as_written failed

The new clause copies only the shard that holds the row it changes, the smallest such shard, under $BLOOMERY_DATA, with links to the other shards, and removes the copy after. On the real file that is at most the 12,087,983,520 B of its fourth shard. Main's a13125a8 changed crates/gpu/src/graph.rs (a branch forked again in one capture) beside this PR's lines there, so the runs at 6b16c8d2 are the first of this branch on top of it.

At 69a25bf6 on main 54ceee74, the head before the byte check:

At 69a25bf6 Result
The pure crates' tests (cargo test -p bloomery-levers -p bloomery-placement) 208 passed, 0 failed
gate-gpu-lib (the hw_held_ tests on one thread) 242 passed (1, 237 and 4 in its three runs), 0 failed
ptx-scan --no-jit of the scan set (gate_kquant, gate_swap, bloomery-serve-ds41) and gate_qwen38_twocard, against main 54ceee74 identical: 238, 235, 317 and 234 entries, every table row and md5 the same (tools/ref/ptx-canon.py --scans)
gate_qwen38_nodes on the Qwen3.8 fixture PASSED in 44 s (five clauses, before the other-bytes clause)
gate_qwen38_nodes on Qwen3.8 UD-Q4_K_XL PASSED in 227 s (five clauses)

The scan set covers every kernel carrier, so the branch as a whole moves no kernel. The refactor's own scan, against its base, is below.

The corpus the nodes gate read. just gate-gpu-qwen38-nodes reads qwen4exp/corpus-prose.ids, which is not on these machines, so every nodes gate run here used a stand-in: 4096 ids in [1000, 101000) from a fixed-seed generator. The gate reads its first 64, listed below so the run can be repeated. Ids that are not prose change which experts route, not what the clauses check.

The 64 ids the gate read

The file is one id a line; its first 64 ids, in order (sha256 of the whole 4096-id file eff777739fb152324bd267fe93c84bf3914591b3bde787441446012d1eb286ac):

62561 80444 81962 29753 73470 8947 100161 30809 61838 59142 57535 14690 50300 12153 11072 9569 96645 32426 74725 92053 47309 33988 49176 69019 77180 70953 60090 95028 37845 96249 17039 33043 76230 55925 24752 32915 61899 74270 89492 31988 48499 14947 55709 38118 52501 95911 25376 4808 75181 65979 9465 63380 61931 50054 7628 57184 22222 11767 41309 69676 66220 11217 20973 5844

FAIL-first of the unit tests. One mutant each, one edit to 69a25bf6, run with tools/gate.sh --oxide -p bloomery-gpu --release --lib -- --include-ignored host::node::wire host::node::tests host::tier::tests::a_tier_on_a_node. Each log shows bloomery-gpu compiled again, and in each the named test is the only one of the four that fails:

Mutant Result
Channel::poll refuses a frame of exactly MAX_FRAME (len > MAX_FRAME as >=) red: a_channel_reads_whole_frames_and_its_ends, "a poll: ... a frame of 67108864 bytes"
Channel::poll loses its Ok(0) if self.have == 0 arm, so a stream that ends between frames is an error red: a_channel_reads_whole_frames_and_its_ends, "a poll: Kind(UnexpectedEof)"
A read overwrites the bytes the frame already has (self.have += n as self.have = n) red: a_channel_reads_whole_frames_and_its_ends, the frame written in two parts never completes, "a frame: ... a frame's read passed its deadline"
TierOpen::open_here skips its node check red: a_tier_on_a_node_is_refused_by_name, "a refusal" (the GB10 opened instead)
write_err sends a fault as a refusal (ERR_REFUSED) red: messages_read_back_as_written, the fault "read back as Shape { ... }"
read_err drops the fault word it read (Fault::from_words(1, s)) red: messages_read_back_as_written, the fault read back as Fault { layer: 0, code: 1, sites: 24, ... }, not layer 5

FAIL-first of the gate's clauses. One mutant per key check, on the fixture, each one edit to 6fceb4de, an earlier head of this branch. The review fixes since then do not touch the lines these mutants edit:

Mutant Result
Node 1 sends its census with 0 free bytes on its device red: the plan refuses the load, "the device had 0 B free of its usable 130595946496 B at plan time"
Node 1 skips the experts' launches of a tier opened over the channel, its signals kept red: FAIL node 0 and node 1: tokens apart at Some(0)
Node 1 does not release its go words when node 0 is lost red: both lost node 0 clauses, "its stream drained false"; the stopped node 0's step names a stalled tier, not the release
Node 1 never checks for a stopped node 0 red: FAIL lost node 0 (stopped), the held step gave Ok(50324) and node 1 did not end
Node 0 never marks node 1 lost red: the three lost node 1 clauses (the poison is Failed, want CardLost)
Node 0's settle ignores a node tier's released words red: FAIL lost node 0 (stopped), the held step gave Ok(12457)
Node 1 checks only that a routed row has a set, not that its experts are the set red: FAIL open, the swapped open gets past the set check and fails later in Weights::load_placed on a byte count instead of by name

How the lost node 0 clause holds node 1 mid-step. Holding node 0 with a host flag before a step does not reach node 1: each pass first copies its rows from pageable memory, and that copy blocks the host behind the held stream before the launch request goes out. So the gate relays node 0's channel through a TCP relay of its own. The relay holds the step's first launch frame, waits 500 ms, kills or stops node 0, then delivers the frame (and in the kill case closes the relay's side to node 1). Node 1 then waits on go words that do not come. The precondition line checks that node 1 had served fewer layers than were asked of it. The plan's budget search also requires a host expert on a layer before the last, because a stopped process's card keeps running and only a host leg makes it wait. In the stopped case the gate then continues node 0 and reads the held step's result.

The refactor alone (b570f82c against main 33fb317b). It is a split, so it must change no kernel and no result. Its measured commit b570f82c is the patch now at c9af1729 on a13125a8 (git range-diff shows them equal). Main changed none of the files it touches in the rebases since. These ran on b570f82c and its base:

  • ptx-scan --no-jit of the scan set (tools/recipes.py scan-set: bloomery-serve-ds41, gate_kquant, gate_swap) and of gate_qwen38_twocard, on both commits: every entry's row and normalized md5 are identical (tools/ref/ptx-canon.py --scans: 238 rows and 239 md5s, and 315 and 315). The bloomery-gpu bundle is 231 bytes shorter; the comparison is per entry.

  • Bits: Qwen3.8 UD-Q4_K_XL at plan (b′), its tier a second Gpu on the same GB10, in one process, the gate's history (98 tokens and logits rows). The tokens, every logit's bits, the stage graph nodes [1343, 1427, 1427, 1427], the tier graph nodes (433 for the step and each verify width) and the tier's feeds, captures and replays (4, 4, 82) are byte for byte the same file on both commits. The run used a small harness bin built from one source on both trees, not part of this PR.

  • gate_deepseek41_tier on the V4.1 fixture, the landing clauses (--union --fault --two --batch --batch2 --bfault) and the lost clauses (--bfirst --blost --lost), at both commits: all PASSED, the stage graph 288 nodes, the tier's step and pair graphs 64 and 127. Two local edits, not in this PR, were needed on both: this machine had no DSpark draft fixture, so the plan takes a draft reserve of 0 B, and with that reserve the tier depth is capped at the real file's 23 experts a layer. The same clauses also passed at 6fceb4de, the whole branch before the review fixes, with the same two edits.

  • gate-gpu-lib at b570f82c: 234 passed (1, 229 and 4 in its three runs), 0 failed.

  • Decode timing, measured before the rebase on b87a62dc against fcdfd4a5 and not run again on main. The refactor's patch and the files it touches did not change in the rebase. One same-lease sitting of tools/ref/depth-qwen3moe.sh 6 bin:<fcdfd4a5 build>:6 6@BLOOMERY_AB_LOAD=arm (BLOOMERY_MODEL=qwen4exp, eight rounds, arm order rotated, BLOOMERY_DRAFT=off BLOOMERY_DECODE_N=160 BLOOMERY_GEN_WARM=16) under a noninf card (predict: -0.2..+0.2, margin 1 %). tools/ref/ab-decode.sh times bloomery-decode, the CPU engine, which links nothing this commit touches, so the GPU decode runner is the one that can see it. On one GB10 the serving plan (a) holds every routed expert on the card and no tier card, so this times the step's path through HostTier with no tier, which the split also touches; the in-process card tier needs two Gpus, and the runner refuses --place bp on one card. The card tier path adds one match and one Box deref a tier request against a step of about 32 ms, which is 0 at this ruler (derived, not measured).

    ratio d=6  ours/bin:base  mean 0.9993 ± 0.0026 (n=8)  per round: r1 1.0016 r2 0.9940 r3 1.0032 r4 0.9994 r5 0.9955 r6 0.9994 r7 1.0019 r8 0.9994
    ratio d=6  ours/ours@BLOOMERY_AB_LOAD=arm  mean 1.0004 ± 0.0019 (n=8)
    mean bin:base d=6  31.36 tok/s  31.45 tok/s(p50) (n=8)
    mean ours d=6      31.34 tok/s  31.44 tok/s(p50) (n=8)
    card: mean 0.9993 ± 0.0026 (n=8): effect -0.070 %, lower bound -0.334 %
    card: verdict: pass: the lower bound -0.334 % is above -1 %
    

    tok/s @ n=160, depth 6, GB10. No round had differing tokens, no row was [cpu-busy], and the lease saw no Xid. The bin: rows are all [cold] because that arm's page faults count over its whole process, its load included.

No run here sets the lever for GLM or V4.1; their refusal of a node tier is the open_here unit test above. open_here is the call that opens their tier card, so deleting the refusal at either call site no longer compiles; a call site rewritten to call Gpu::open_card directly would bypass it.

Not run here: gate-gpu-qwen38-twocard, gate-gpu-glm5next-twocard and gate-gpu-ds41-twocard, which need two cards, and stage-gpu-load-v41, which a change to crates/gpu/src/weights.rs runs by name and which stages plan (b) on two cards. The weights.rs change moves upload_segment's lookup of a segment's bytes into SegmentFile::of with the same checks and refusal texts; every load in the nodes gate goes through it (Weights::load_rows on node 1, Weights::load_placed in one process), and the ptx-scan --no-jit at 18ae4660 is above. gate_qwen38_twocard now includes the shared history and structure checks. It compiles, its ptx-scan --no-jit table is identical to the base's (above), and the history and the tier's graph and chain checks it runs are the code gate_qwen38_nodes ran in its one-process reference above. gate-gpu-qwen38-nodes itself has not run on an A6000. Its recipe names that card (BLOOMERY_CARD=a6000, tools/box.sh's lane on the maintainers' box) because both processes share one card and two 16 GiB budgets pass the 3090's 24 GB; in local mode the run takes the machine's own card.

@github-actions github-actions Bot added dgx-spark The DGX Spark (aarch64, GB10) port documentation Improvements or additions to documentation gpu The card engine: crates/gpu*, crates/app host Host crates: qdot, threads, engram, engram-lab, runtime, sampler, levers, decision, vision, refset model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja needs-gpu Changes code a card must check, and carries no gate run on a card placement Placement planning: crates/placement tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo labels Oct 14, 2026
…cement, gate-paths: node 1 serves its expert tier to node 0 over a control channel, and a lost node ends both sides

Node 1 runs tier_server, which serves one load's expert tier over a TCP control channel
(host/node.rs, host/node/wire.rs): one connection a load, one reply a request, each request
and its reply under one deadline. On connect the server sends its census, each device's
census row. BLOOMERY_TIER_NODES (a parsed lever of
kind Addrs, loopback addresses only: the page is this host's memory and the channel has no
authentication) names the node of each tier. node_cards reads the nodes' censuses, and
Place::on plans with each node's card as a tier card (spec_of_device of the row the node
sent); the gate plans through the same function.

TierCard keeps the stage side: its books, the page words it reads and the stage's stream.
Its tier side is the TierWork of a tier of this process, or a TierNode, a client of the
channel. The open carries the tier's rows of node 0's plan. Node 1 refuses rows whose
experts are not the tier's set, then loads them from its own copy of the file, each tensor
checked against node 0's description (NodeOpen::rows_on, Format::of_word) and each row's
bytes against their sha256 in node 0's file (NodeRow::sha256), captures its
graphs from the go order it is sent, and launches, enqueues, drains and clears its fault
word on request. Each request is checked before it reaches the stream; one that fails after
it ends the connection with the tier's words released. A second bind and a fault clear with
work in flight are refused by name. Over loopback the page is one /dev/shm file of mode 0600
that each process maps and registers with its card (MappedHost::shared_file, Link::across
and Link::open_across); node 1 opens only a tier page's name, and node 0 removes the name
once both ends hold it.

A lost node ends both sides by name. On node 0 a request that fails or passes its deadline
marks the node lost (GpuError::Link), and the host tier names the tier a lost card
(CardLost). Node 1 ends when the channel closes without a close, or when work asked of it
makes no progress past the go deadline and its grace. It then writes RELEASE to its own go
words, drains its stream under the drain deadline and returns the loss by name. A node 0
that was stopped and runs again finds those words released when it settles the step, and
fails the step with the tier a lost card rather than return rows served after the release.

Only Qwen3.8 opens a tier on a node (NODE_FAMILY, swap38's stacks_of_set). GLM and V4.1
open their tier cards through TierOpen::open_here, which refuses a tier on a node by name
before any card opens, and the batch port's group start refuses one too. TierOpen::of_machine returns
the lever's error, so its callers take a ?.

A row's sha256 covers the file bytes of the experts the tier holds, the bytes node 1 uploads:
weights.rs's SegmentFile finds a segment's spans and borrows their bytes for the upload and
for the digest alike, and model::fileio::sha256_hex_of hashes the spans one after another.
Both nodes hash the tier's rows on each open, not the whole file, so a copy whose headers
match node 0's file but whose tier experts differ is refused by name instead of serving
other weights. The protocol is version 3.

gate_qwen38_nodes (just gate-gpu-qwen38-nodes) holds the census, an open of other experts
refused, an open on a copy of the file whose one tier row holds other bytes refused, the bits against the same plan in one process, and both ends of a lost node. The
history it holds a load to, its comparison, the plan's tier layers and the tier's graph and
chain checks after the history move to shared/q38_history.rs, which gate_qwen38_twocard now
includes too. The gate starts tier_server through beside, the binary beside the running
one, which moves from serve_client to the crate root so a gpu-only bin reaches it;
serve_client re-exports it. A loopback test in wire.rs holds the channel's framing with no card, and
messages_read_back_as_written (node.rs) the census, the open and each kind of error reply read
back as written. tools/unsafe-ratchet.txt: gpu 1749 -> 1754 blocks for the shared file's
mapping, its registration, its device pointer, the unregister on that pointer's failure, and
MappedHost's drop split into one block a release call.

The gate reads BLOOMERY_CARD_BUDGET as gate_qwen38_twocard does: the fixture tier's budget,
which box.sh exports, is checked against the fixture's header, and tier_server starts without
the levers the gate reads.

tools/spawn-allow.txt lists the gate's threads, and tools/defaults-twins.tsv names the clause
that runs the gate's plan with BLOOMERY_TIER_NODES unset.
…t node as built over loopback

9.5 items 5 and 6 describe what is built: tier_server and its control channel, the census
the load plans its tier cards from (node_cards, read by Place::on and by the gate), the
open refused for rows that are not the tier's set or whose bytes are not node 0's (each
row's sha256, which both nodes take of the tier's rows rather than the whole file), the
TierCard split, the /dev/shm page
over loopback, and the two ends of a lost node, with a stopped node 0 that runs again
failing its step on the released words. BLOOMERY_TIER_NODES takes loopback addresses only,
since the page is this host's memory and the channel has no authentication. Item 5 lists
what is not yet done: the census carries no host room until a plan term reads it, once
each node has a pool of its own, a remote tier serves no prompt batch, and only Qwen3.8
opens a tier on a node.
9.2's remote census bullet and section 10's engine-side item say the census now reaches
node 0 over the channel and the load plans from it, and section 1's P5 row names the
loopback run and the tier node off this host as open.
…opened the page

A tier node binds its page through the same method as the stage card, so the name says
whose context the end is in: the one that made the page (the stage card) or opened it (a
tier node). A rename with no behavior change; its four callers in host/tier.rs follow.
@juliankang4

Copy link
Copy Markdown
Collaborator Author

Closing this for now. A lost-node path still needs a fix and a run before it is ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dgx-spark The DGX Spark (aarch64, GB10) port documentation Improvements or additions to documentation gpu The card engine: crates/gpu*, crates/app host Host crates: qdot, threads, engram, engram-lab, runtime, sampler, levers, decision, vision, refset model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja needs-gpu Changes code a card must check, and carries no gate run on a card placement Placement planning: crates/placement tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant