Repository navigation
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 - #164
Conversation
There was a problem hiding this comment.
Verdict: MUST_FIX: one proven duplicate method in the new link code; no behavior failure path survived.
Must fix
1. Link::opened_end is a second copy of Link::stage_end
link.rs:132 and link.rs:139 have the same body: LinkEnd { dev: self.page.dev_at(0) }. opened_end has one caller, tier.rs:1185. If one body changes (for example a page offset) and the other does not, a tier node binds to a different address than the stage card.
Cost: now it is one deleted method; later the two drift unnoticed, because nothing compares them.
Fix: delete opened_end (link.rs:130-136) and call link.stage_end() at tier.rs:1185. Optionally rename stage_end to own_end (callers tier.rs:621, 723, 763, 1185) with the doc "The device address of the page's first byte in the context that made or opened it."
Non-blocking
⚠ Session::handle is 124 lines: node.rs:1086. R12 allows two screens (120 lines). The Open and Capture arms are the long ones. Move each to a fn returning Result<(), Failed>; handle then only dispatches. Not a kernel, so the split is allowed.
⚠ The channel framing has no test without a GPU: wire.rs:309. Channel::poll and recv carry the deadline rule. The mutants len > MAX_FRAME to >=, a dropped Ok(0) if self.have == 0 arm, and a frame split across two writes handled wrongly pass every unit test. A loopback TcpListener test in wire.rs needs no card.
⚠ The GLM and V4.1 refusal test does not protect the call sites: tier.rs:2246 calls refuse_node directly. Deleting t.refuse_node(WHAT)? at gpu-glm5next/src/tier.rs:362 or at gpu-deepseek41/src/body.rs:2629 keeps it green, and the family would then open its tier on a card of node 0.
⚠ NodeCensus::host_room and host_read (node.rs:89) have no consumer except a gate println (gate_qwen38_nodes.rs:531). The PR names a next row for them, so I do not block. If that row slips, remove the two fields.
⚠ just gate-gpu-qwen38-nodes names BLOOMERY_CARD=a6000 (justfile:1922) and never ran there, per the PR. Please run it once on that card or name the card it was proved on.
Checked
- Duplicates: about 40 new items checked. dryer
link.rs:132~139is finding 1;wire.rs:80~85andlib.rs:221/254/262share only a shape. - Complexity:
Session::handle(⚠ above);NodeOpen::read,NodeCensus::read,Session::run,Channel::pollare flat reads or state loops. - Silent failure: 9 sites followed, including
opened_progress().unwrap_or(0)at node.rs:1118 and :1184. None reaches a consumer as a wrong value. - Tests, mutation: the tier-name, census and lever tests go red on their planted mutants. Gates: no pin moved. Escape catalog: no match.
Notes
- Evidence is from
6fceb4de, not the headdc22ecab; the PR says so. - "Node 0 is any placed load" is wider than the code: only
gpu-gatesPlace::onplans from node 1's census.
Card check: gpu-evidence. This pull request changes crates/app/src/arch/deepseek41/mod.rs, crates/gpu-deepseek41/src/body.rs, crates/gpu-gates/Cargo.toml … and carries a gate run on the author's card, which counts as verified.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
dc22eca to
11b3445
Compare
|
All six points are addressed at Must fix:
The channel framing.
In each the new channel test is the only one of the three that fails, and each log shows The GLM and V4.1 refusal. The check now lives in the call that opens the card.
Notes. "Node 0 is any placed load" is narrowed in the body: only a load placed through |
There was a problem hiding this comment.
Verdict: MUST_FIX: one proven duplicate between the two Qwen3.8 tier gates, which the PR body says it removed; the code of the change itself held up against every failure I built.
Must fix
1. The tier gates keep a second copy of the tier-layer and tier-structure checks
gate_qwen38_nodes.rs:270 is tier_layers, byte for byte gate_qwen38_twocard.rs:422. tier_structure (nodes:334-362) repeats the tier-graph node check and the fed, captured and replayed count of twocard:468-501. Both read WINDOWS, PROMPT and STEPS, which this PR already moved to shared/q38_history.rs. The PR body says "No second copy"; that holds for the history only.
Cost: when the history changes (a new window, a new chain), one copy is updated and the other passes on a wrong count, or fails with a text nobody can match to the clause.
Fix: move both into shared/q38_history.rs, delete the two local copies, and keep each gate's own extras (twocard's stage-node lines and its tier: stats line; nodes' resident_bytes precondition).
/// The tier layers: the plan's layers with a tier expert.
pub fn tier_layers(plan: &model::placement::Plan<'_>) -> Vec<usize> {
plan.tier_n_l
.first()
.map(|t| (0..t.len()).filter(|&l| t[l] > 0).collect())
.unwrap_or_default()
}Then tier_graphs_and_chains(tier: &TierCard, layers: usize) -> bool holds the graph-nodes clause and the feed/capture/replay clause once, and both gates call it. Net result: about 45 lines fewer, no new name beyond these two.
Non-blocking
⚠ The error and fault reply has no test: node.rs:491 write_err and node.rs:504 read_err. Mutant: write_err maps GpuError::Fault to ERR_REFUSED, or read_err drops the fault words. No unit test or gate clause turns red. The PR body says "a fault on node 1 comes back to node 0 as a fault", and no run raises one. The Tag::Fault request (node.rs:761) is reached only through tier_fault (batch.rs:1364), after a prompt group that a node tier refuses at begin_group. Add a round-trip unit test for write_err and read_err on a Fault, a Protocol and a Shape error. Or say in the PR body that the fault path over a node is not covered yet.
⚠ The reply deadline is taken before the request runs: node.rs:989. After an open (up to minutes) or a drain (up to DRAIN_DEADLINE), the reply is sent under an expired deadline. It works today because a small frame goes out in the first write, and send checks the deadline only after a partial write. Compute the deadline after self.handle(..) returns.
Also 2 minor: the cuMemHostUnregister result in the error path of graph.rs:614 is dropped, while Drop records it. In messages_read_back_as_written, the mutant free_bytes > total_bytes as >= stays green, since no case has free equal to total.
Checked
- Duplicates: about 30 new items checked. Hits:
tier_layers(exact) and the tier-structure block (finding 1).plan_underin the two gates differs in contract, so it is not a copy. Dryer pairs read (3):Out::u32/u64and theGpuErrorconstructors differ by width and variant, and merging them adds elements. No finding. The count check of nodes against tiers inPlace::on_nodesandTierOpen::of_machineruns at two phases (before the plan and at the load), so both stay. - Complexity:
Session::handle47 (a tag dispatch whose long arms are alreadyhandle_openandhandle_capture),NodeOpen::read37 (a straight field reader),Session::run23. All are new functions that are complex on their own; I found no split worth its names. Gate bodies are not asked to split. - Silent failure: 9 sites followed (
unwrap_or(0)at node.rs:982, 1087 and 1132, thedrop(Result)calls at node.rs:578 and 822 and link.rs:421,unwrap_or(u64::MAX)at node.rs:748,unwrap_or_defaultinfail_tier). Each is a message value or best-effort cleanup, and none reaches a consumer as data. - Tests: the PR's FAIL-first table covers
MAX_FRAME, theOk(0)arm,have += nandopen_here. My mutant onacross_named(drop the prefix check) turnsa_tier_node_opens_only_a_tier_pages_namered. Census version, flag, uuid-twice and shape mutants turnmessages_read_back_as_writtenred. - Gates: none moved. The unsafe ratchet rise has a dated PIN, and I count 5 new blocks in graph.rs, as claimed.
- Hot path: the in-process tier gets one match per request and one
node()test per settle, which is constant. Node requests per layer exist only with the lever set. No performance finding. - Escape catalog: no match. The gate signals pids it captured from its own children.
Notes
- The PR body has no literal
nvidia-smiline. Its Hardware section names the GB10 and driver 595.84, with both commit hashes and the gate output. gate-gpu-qwen38-nodeshas not run on the A6000 that its recipe names (stated in the PR body).
Card check: gpu-evidence. This pull request changes crates/app/src/arch/deepseek41/mod.rs, crates/gpu-deepseek41/src/body.rs, crates/gpu-gates/Cargo.toml … and carries a gate run on the author's card, which counts as verified.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
11b3445 to
671bcc9
Compare
|
The must-fix and the two non-blocking points are fixed at Must fix: The reply deadline. Node 1 now takes a reply's deadline when The error replies.
In each, Not changed. The |
There was a problem hiding this comment.
Verdict: MUST_FIX: the node open accepts different model weights with matching tensor metadata.
Must fix
1. Check model bytes before opening the tier
crates/gpu/src/host/node.rs:277 checks shard, type, dimensions and byte count. Start tier_server -m with a model copy whose tier-resident expert weights differ while its headers stay identical: these checks pass. crates/gpu/src/host/node.rs:1243 loads that copy's weights. When those experts are routed, inference combines node 0's model with node 1's changed weights; finite results return without a model-mismatch error. This is a kind A failure path.
Adding identity to the new protocol now avoids diagnosing wrong outputs after deployment.
Reuse model::fileio::sha256_hex. Add this field to NodeOpen and this method to its existing impl:
pub file_hashes: Vec<String>,pub fn file_hashes(file: &Split) -> Result<Vec<String>, GpuError> {
(0..file.shard_count())
.map(|s| {
let g = file.shard(s).ok_or_else(|| {
GpuError::shape(WHAT, format!("missing shard {s}"))
})?;
Ok(model::fileio::sha256_hex(g.mapping()))
})
.collect()
}In tier38.rs's open initializer, add:
file_hashes: NodeOpen::file_hashes(file)?,In swapped_open's initializer, add:
file_hashes: NodeOpen::file_hashes(&Split::open(ref_model_path()?)?)?,In the message unit test's initializer, add file_hashes: vec!["fixture".into()], and assert back.file_hashes == open.file_hashes.
In NodeOpen::write, append this after the block sizes and before Ok(()):
o.len(self.file_hashes.len());
for hash in &self.file_hashes {
o.str(hash);
}In NodeOpen::read's initializer, append:
file_hashes: (0..i.len("shard count")?)
.map(|_| i.str("shard sha256"))
.collect::<Result<Vec<_>, _>>()?,In Session::open, before describing tensors or opening a card, add:
if open.file_hashes != NodeOpen::file_hashes(self.file)? {
return Err(GpuError::shape(
SERVER,
"node 0 and node 1 opened different model bytes",
));
}Set the wire VERSION to 3. Extend the open clause with a model copy whose expert data changes while its headers stay identical; the open must fail.
Non-blocking
⚠ Measure the node launch wait: crates/gpu/src/host/node.rs:702 adds one synchronous TCP launch round trip per participating remote tier per replayed pass. A fed pass instead adds one enqueue round trip per served tier layer at node.rs:735. The reported A/B uses no tier and cannot measure this term. Supply a same-plan local-tier versus loopback-tier GPU decode A/B under the timing lease, with its prediction and output.
Checked
Model identity: traced the server's independent file selection, open validation and weight upload. None compares model contents across nodes.
Performance: traced replay/feed dispatch through TierNode::call's synchronous send/receive.
Tests: the open gate swaps expert IDs; it does not mutate weights while preserving headers. The proposed digest fix uses existing public APIs.
Scope: full review; both findings cite added lines. Neither matches a review exclusion or C1–C4; no ledger lessons are in force.
Notes
Static validation only; Rust and GPU gates were not run. The PR gives gate output and commit references but omits the required nvidia-smi line.
Card check: needs-gpu. This pull request changes crates/app/src/arch/deepseek41/mod.rs, crates/gpu-deepseek41/src/body.rs, crates/gpu-gates/Cargo.toml … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
671bcc9 to
c891688
Compare
There was a problem hiding this comment.
Verdict: MUST_FIX: write deadlines can be bypassed, the loss gate races its stderr reader, and the launch tag has two owners.
Must fix
1. Check the deadline after the final write
crates/gpu/src/host/node/wire.rs:299
A small frame sent with an expired deadline completes in one write. at == outbuf.len() skips the check and returns Ok, violating send's deadline contract. In TierNode::call:679, a pause after choosing the deadline permits a late request; recv also accepts a complete reply past that deadline, so the call can succeed without marking the node lost.
Fix the channel owner before more requests depend on its bound. Replace line 299:
if Instant::now() > deadline {Add this assertion inside the existing channel test, using its pair closure. The current code returns Ok, so unwrap_err fails:
let (_w, mut ch) = pair();
assert_eq!(
ch.send(Tag::Close, &[], Instant::now() - Duration::from_secs(1))
.unwrap_err()
.kind(),
ErrorKind::TimedOut
);2. Finish collecting stderr before returning the child's exit
crates/gpu-gates/src/bin/gate_qwen38_nodes.rs:421
Node 1 writes its loss line and exits while the detached reader has not appended it. wait_within returns the exit, then lost_node0:970 reads an empty or partial string. Its loss, release and progress checks fail despite correct server behavior. The mutex does not establish reader completion.
Fix the collection owner now to prevent intermittent gate failures.
Add this field to Proc:
stderr_reader: Option<std::thread::JoinHandle<()>>,Replace the stderr thread's opening line at 378:
let stderr_reader = std::thread::spawn(move || {Add this field to the Proc initializer:
stderr_reader: Some(stderr_reader),Replace the successful exit branch:
if let Some(s) = self.child.try_wait()? {
if let Some(reader) = self.stderr_reader.take() {
reader.join().map_err(|_| {
format!("{}: stderr reader panicked", self.tag)
})?;
}
return Ok(Some(s));
}A regression that delays the reader before its append must still collect the complete loss line after the child exits.
3. Use the wire enum for the relay's launch tag
crates/gpu-gates/src/bin/gate_qwen38_nodes.rs:108 duplicates crates/gpu/src/host/node/wire.rs:30. Both declarations own the protocol value for Launch (R14).
Remove the second owner before a protocol change requires coordinated edits. Delete LAUNCH_TAG and its comment. Replace the enum declaration at wire.rs:26:
pub enum Tag {Replace the relay condition at gate_qwen38_nodes.rs:906:
if frame.first().copied()
== Some(bloomery_gpu::host::node::wire::Tag::Launch as u8)
&& armed.swap(false, Ordering::SeqCst)
{Non-blocking
⚠ Attach current gate evidence: gate_qwen38_nodes.rs:1001 adds the other-bytes open clause. Head has six clauses; the pasted runs show five. Attach output covering this clause and the nvidia-smi card/driver line required by CONTRIBUTING.md.
Checked
Duplicates: confirmed the two Launch values; the proposed fix reuses the enum and removes the extra constant.
Complexity: no finding rests on a complexity score; tools were not rerun during validation.
Silent failure: followed the expired send through TierNode::call and its reply handling.
Tests: the existing channel test covers late buffered reads, but lacks the expired-send assertion. No tests executed during validation.
Gates: confirmed the stderr race reaches the loss gate's assertions and the added other-bytes clause is called.
Escapes: no catalog pattern applies to the retained findings.
Card check: needs-gpu. This pull request changes crates/app/src/arch/deepseek41/mod.rs, crates/gpu-deepseek41/src/body.rs, crates/gpu-gates/Cargo.toml … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
| Err(e) if slice_over(&e) || e.kind() == io::ErrorKind::Interrupted => {} | ||
| Err(e) => return Err(e), | ||
| } | ||
| if at < self.outbuf.len() && Instant::now() > deadline { |
There was a problem hiding this comment.
The final successful write must also fail when its deadline has passed.
| if at < self.outbuf.len() && Instant::now() > deadline { | |
| if Instant::now() > deadline { |
|
The must-fix is fixed with each tier row's bytes rather than each whole shard; the reasons and the cost are below. The fix ran on a DGX Spark at Must fix: the model bytes in the open. Each Why the tier's rows and not the whole file:
Each node hashes The gate clause. The open clause of The launch wait (non-blocking). Not measured. The body now gives the cost from the code,
The decode A/B in the body ran plan (a) with no tier card, so it covers none of this. The same-plan A/B of a local tier against a node tier needs a second Gates the head does not run. The |
c891688 to
18ae466
Compare
|
All three must-fixes are fixed at 1. The deadline after the last write. 2. The stderr reader. 3. One owner of the launch tag. The rebase. Main moved to Checks at The gate evidence (non-blocking). The body now carries both |
There was a problem hiding this comment.
Verdict: MUST_FIX: the node gate duplicates an existing sibling-binary helper.
Must fix
1. Reuse the sibling-binary helper (kind B)
crates/gpu-gates/src/bin/gate_qwen38_nodes.rs:471 repeats crates/gpu-gates/src/serve_client.rs:300: both resolve an executable with current_exe().with_file_name(). This is the same path rule with two owners (R14).
Consolidating now removes one function before another gate copies it.
Move the existing function into crates/gpu-gates/src/lib.rs, where PathBuf is already imported. This makes it available with gpu alone; serve_client is gated by deepseek41.
/// The binary `name` beside the running one.
pub fn beside(name: &str) -> Result<PathBuf, GateError> {
Ok(std::env::current_exe()?.with_file_name(name))
}Replace the function in serve_client.rs with:
pub use crate::beside;Replace the gate's local function with:
use bloomery_gpu_gates::beside;The two function bodies become one; seven code lines become five.
Non-blocking
⚠ Measure the node control overhead: crates/gpu/src/host/node.rs:723 adds one blocking launch request/reply per node tier with work in a replayed pass. A fed pass adds one enqueue request/reply per served tier layer and one capture request/reply per fed tier [derived]. The reported plan (a) A/B has no tier; the PR explains that the decode runner cannot run the needed placement. Provide a same-plan local-tier/node-tier A/B on the contributor's card, retaining the bit and graph checks.
Checked
Duplicates: both cited bodies implement the same path rule; the serve-client helper already exists in the base. The proposed move reuses that function in an existing module and removes the new copy.
Control path: traced replay, per-layer feed and capture through TierCard into TierNode::call, which sends a request and waits for its reply.
Gates: the tier node-count pin moved unchanged into the shared history. The unsafe ratchet increase has PIN(2026-10-14) and its reason. The new binaries declare required-features = ["gpu"].
Scope: full review; both findings concern added code. No ledger lessons are in force. Escape catalog C1–C4 does not apply to these findings.
Notes
Static review only; CUDA gates were not rerun here. The PR reports passing GPU runs through tools scripts, but supplies no passing just gate-* invocation with its output. Under the requested evidence rule, gpu.json records evidence=false.
Card check: needs-gpu. This pull request changes crates/app/src/arch/deepseek41/mod.rs, crates/gpu-deepseek41/src/body.rs, crates/gpu-gates/Cargo.toml … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
… 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.
…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.
18ae466 to
c9d0dfd
Compare
|
Closing this one: it has used the review bot's automatic reviews. The same branch, now at |
|
Continued in #210. |
What and why
This is the tier server row of the P5 plan in #127,
docs/contrib/dgx-spark.md9.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 (
6f87bfb8..18ae4660). Since the body 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 main6f87bfb8, and it fixes the review bot's three must-fixes atc891688f: 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 fromwire::Tag. #172 moved the host's reads, stores, releases and resets of the tier page's words intoLink, 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 with18ae4660itself on main6f87bfb8; the runs of earlier heads follow. The commits:582cabac, a refactor with no behavior change:TierCardkeeps the stage side, and a tier card's own work on its card moves intoTierWork.21c7e7a7, the tier server, the channel, the load's plan from the census, and both ends of a lost node.f652ad2e, the docs.18ae4660, a rename with no behavior change:Link::stage_endisLink::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:
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 runstier_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.crates/gpu/src/host/node.rs,node/wire.rs): TCP fromstd::net, one connection a load, one reply a request. A frame is au32length, au8tag and little-endian fields. The requests are open, bind, launch, capture, enqueue, sync, drain, clear fault, fault read and close. Every request getsOkwith its payload orErrwith 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.node_cardsreads the nodes' censuses and makes each tier card withspec_of_devicefrom the row its node sent.Place::onincrates/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::openrefuses another 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 withWeights::load_rows.SegmentFile::of(weights.rs) finds a segment's spans in the file and borrows their bytes for the upload and for the digest alike, andmodel::fileio::sha256_hex_ofhashes 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.TierCardkeeps 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 isTierWork, a card of this process, orTierNode, a client of the channel.TierWorkis the tier half that existed before (itsGpu, weights, the family'sTierExperts, 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./dev/shm, created with mode 0600. Node 0 makes it, maps it and registers it with its card (MappedHost::shared_file:cuMemHostRegisterwithPORTABLE | 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 isMappedHost::new(cuMemHostAlloc, not write-combined), so neither page is write-combined and the bits below compare like with like.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.RELEASEto 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_serverprints 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.How the open checks the model bytes, and what it costs
UD-Q4_K_XL[derived]sha256_hex(g.mapping())) on both nodeshf::fetch's check)NodeRowfield,segment_sha256andsha256_hex_of;SegmentFileis the upload's own lookup moved outEach node hashes
Bbytes on one core, so the open takesB / Rmore on node 0 (before it sends the open) andB / Rmore on node 1 (inside the open), whereRis one core's sha256 rate. That rate has not been measured on the GB10, 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.
RTTis 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).TierCard::replay,TierNode::launch). The host's service of that pass starts at mostn_node_tiers × RTTlater. A tier of node 0's own launches the same graph with a call, so the round trip is the extra.HostTier::feed_tiers,TierNode::enqueue), and one capture request after the pass settles. That adds at most(L_served + 1) × RTT.82 + 4 × 49 = 278round trips for the whole history [derived].The decode A/B below ran plan (a) on one GB10, which holds every routed expert on the card and has no tier card. It times the
HostTierpath 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 secondGpuon one card, which only the gate's harness opens (the decode runner refuses--place bpon 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'sMemAvailablereading joins it with the plan term that reads it, once each node has a pool of its own in the plan (9.2).NODE_FAMILYinarch/qwen3moe/tier38.rs). GLM and V4.1 open their tier cards throughTierOpen::open_here, which refuses a tier on a node by name before any card opens.After the rebase over #172. #172 gave
Linkthe methods the host uses on the tier page's words (load,store,release,at_rest,reset). This branch now builds on them:Linkkeeps gpu, levers, justfile, tools, docs: the tier page's counters cross a link as generations; a loopback link carries them between two pages on one machine with a host pump #172's fields and methods.Link::across,Link::open_acrossand the file's removal on drop sit beside them, and a shared page has no mirror. The node side's word reads, stores and releases, which went through this branch's ownword()andhost_atbefore, now go throughLink::load,storeandrelease.TierPage::newis gpu, levers, justfile, tools, docs: the tier page's counters cross a link as generations; a loopback link carries them between two pages on one machine with a host pump #172's, unchanged. A load with a tier on a node builds its page withTierPage::across, and both go through one constructor for the page's words.release_opened), now with oneLink::releasecall.Linkdoes on the stage side.bind_openedchecks the page's size against the layout before any word is read, so no request reaches that.BLOOMERY_TIER_LINK=loopbackis refused by name (host/mod.rs,attach_tiers), because the loopback link's tier side is a page of node 0's process and node 1 maps the shared page. The refusal comes when node 0 attaches its tiers, after node 1 has opened its rows. No gate runs it yet.At
18ae4660these ran on a DGX Spark ("What I ran"): the PTX scans, gate-gpu-lib,gate-gpu-qwen38-nodeson the fixture and onUD-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-tierandweekly-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): theTierCardsplit into the stage side andTierWork.TierCard::nodes_per_layerand the books are unchanged. Commit 2 addsTierSide, the node calls, the release check the settle reads andTierOpen::open_here.crates/gpu/src/host/node.rs:NodeCensus,node_cards,NodeOpenandNodeRow(the open's rows, each with its bytes' sha256,segment_sha256),TierNode(node 0's client: the census, the open, the per-request calls andlost),NodeFamily(a family's tensor reader and tier computation for the server) andserve(node 1's loop, the stopped check and the end on a lost node 0).crates/gpu/src/host/node/wire.rs: frames, tags (Tagis 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 lookupupload_segmentdid 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_hexcalls 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 renamesstage_endtoown_end.crates/gpu/src/graph.rs:MappedHost::shared_fileand the unregister in its drop; a shared page is a whole number of 64 KiB.crates/gpu/src/host/mod.rs:attach_tiersopens 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.rsandswap38.rs:NODE_FAMILY, andstacks_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::ontakes the tier cards fromnode_cardswhen the lever is set, and a load line'scardsfield names a node tier's device.crates/levers: theBLOOMERY_TIER_NODESrow (class C, kindAddrs, loopback only) andtier_nodes().crates/placement/src/placement.rs:Format::of_word, the format a word names, with a unit test;Format::ALLis held to the enum by an exhaustive match.crates/gpu-gates/src/bin/tier_server.rs,gate_qwen38_nodes.rsandshared/q38_history.rs, the recipegate-gpu-qwen38-nodes, and the recipe added to 46 of the 62tools/gate-paths.tsvrows that namegate-gpu-qwen38-twocard: the rows of the app, runtime, tokenizer, model, gpu, gguf, placement and similar sources a Qwen3.8 load reads. The other 16 rows (fixtures, reference-set readers, residency files, vision position rows and the like) are unchanged. The two rows that namegate-gpu-qwen38-twocardamong their exceptions, the model readers ofmimo2andnemotron_h, namegate-gpu-qwen38-nodesbeside 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, andMappedHost's drop split into one block a release call, each with its SAFETY line.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, whichgate_qwen38_twocardandgate_qwen38_nodesinclude: 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, itstier: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 throughLink::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) andstacks_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:
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 ofTierOpen::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 fmtandcargo clippyare clean on what I touched; the lint count did not go up.Duplication found or removed (
path:line), or "none": the Qwen3.8 history, the tier layers, the tier's graph and chain checks andTIER_LAYER_NODES, now incrates/gpu-gates/src/bin/shared/q38_history.rs, are removed fromgate_qwen38_twocard.rsand have no copy ingate_qwen38_nodes.rs.gate_qwen38_residency.rsand the GLM and V4.1 twocard gates keep their own histories, which differ in what they step and compare.What I ran
On a DGX Spark
The runs here are in the dev container (Hardware, below), with
--ipc=hostso both processes see one/dev/shm, runningtools/gate.shandtools/gpu-gate.shdirectly withBLOOMERY_BOX_LOCAL=1and PTX forsm_86(the driver JITs it for the GB10); the V4.1 rows at18ae4660went throughtools/box.sh(below). Each log starts with the tree's commit; a mutant's log adds the mutant's name.At
18ae4660, against main6f87bfb8. Both trees aregit archiveof the two commits, built from scratch:18ae4660, against main6f87bfb8nvidia-smion the machine of the runNVIDIA GB10, 12.1, 595.84, CUDA Version 13.2 (block below)ptx-scan --no-jitofgate_kquant,gate_swap,gate_qwen38_twocard,gate_e2e(gpu),generate_ds41(gpu, deepseek41) andbloomery-serve-ds41(deepseek41, vision) on both commits,tools/ref/ptx-canon.py --scansmd5 identical=N moved=0for every bin: 238, 235, 234, 234, 301 and 317a_channel_reads_whole_frames_and_its_endswith the review's expired-send assertion,messages_read_back_as_writtenanda_tier_on_a_node_is_refused_by_namegate_qwen38_nodeson the Qwen3.8 fixtureclauses: 6 ran, noFAILline, 124 s; no/dev/shmfile and no copy left (block below)gate_qwen38_nodesonUD-Q4_K_XLclauses: 6 ran, noFAILline, 555 s; no/dev/shmfile and no copy left (block below)Channel::sendchecks its deadline only while bytes are left (at < self.outbuf.len() && Instant::now() > deadline), gate-gpu-lib's command onhost::node::wire::testsa_channel_reads_whole_frames_and_its_endsis the one failure,called Result::unwrap_err() on an Ok value;bloomery-gpucompiled againwait_withinreturns without joining the stderr reader, and the reader sleeps 1000 ms before each append; the nodes gate on the fixtureFAIL lost node 0 (killed)andFAIL lost node 0 (stopped), each with "the loss named false" and its precondition failing at "past 0 of the 0 layers", thenFAILED(exit 1)FAILline; 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 sleepjust gate-gpu-ds41-tierandjust weekly-gpu-ds41-lostas shipped,BLOOMERY_TIER=fixture, on both commitstools/ref-paths.shwants the one DSpark fixture draft, which this machine does not havegate_deepseek41_tieron 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 withBLOOMERY_TIER_LINK=loopbackPASSED: gate_deepseek41_tier, noFAILline; the stage graph 288 nodes, the tier's step and pair graphs 64 and 127, as at #172Where this run differed from the plan for it:
18ae4660tree withBLOOMERY_GIT_COMMITset to it; their logs have no tree line.justandtools/box.shin local mode, with acargo oxidebuilt from cuda-oxide87bc9518, the workspace's pin. Every other row rancargo oxide buildin the container directly, with the image'scargo oxidefrom cuda-oxide4da6c138.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, sha25627cc28dbcc6521ec...) was then put in place by hand, and the rows above are the second attempt.qwen4exp/corpus-prose.idsis 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-smion the machine of the run:On the Qwen3.8 fixture (
gate_qwen38_nodeswithBLOOMERY_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, astier_server's exit error.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:At
6b16c8d2, the head before the rebase over #172, against maina13125a8:6b16c8d2, against maina13125a8nvidia-smion the machine of the runNVIDIA GB10, 12.1, 595.84, CUDA Version 13.2ptx-scan --no-jitof the scan set andgate_qwen38_twocard,tools/ref/ptx-canon.py --scanscargo test -p bloomery-model --lib -- fileio r8file head_listmessages_read_back_as_written,a_tier_on_a_node_is_refused_by_nameanda_channel_reads_whole_frames_and_its_endsgate_qwen38_nodeson the Qwen3.8 fixture, with the new clauseblk.0.ffn_down_exps.weightand its experts; no/dev/shmfile and no copy leftgate_qwen38_nodesonUD-Q4_K_XL, with the new clauseblk.41.ffn_down_exps.weightand its experts; no/dev/shmfile and no copy lefthost::node::testsmessages_read_back_as_writtenfailedThe 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'sa13125a8changedcrates/gpu/src/graph.rs(a branch forked again in one capture) beside this PR's lines there, so the runs at6b16c8d2are the first of this branch on top of it.At
69a25bf6on main54ceee74, the head before the byte check:69a25bf6cargo test -p bloomery-levers -p bloomery-placement)hw_held_tests on one thread)ptx-scan --no-jitof the scan set (gate_kquant,gate_swap,bloomery-serve-ds41) andgate_qwen38_twocard, against main54ceee74tools/ref/ptx-canon.py --scans)gate_qwen38_nodeson the Qwen3.8 fixturegate_qwen38_nodeson Qwen3.8UD-Q4_K_XLThe 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-nodesreadsqwen4exp/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):FAIL-first of the unit tests. One mutant each, one edit to
69a25bf6, run withtools/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 showsbloomery-gpucompiled again, and in each the named test is the only one of the four that fails:Channel::pollrefuses a frame of exactlyMAX_FRAME(len > MAX_FRAMEas>=)a_channel_reads_whole_frames_and_its_ends, "a poll: ... a frame of 67108864 bytes"Channel::pollloses itsOk(0) if self.have == 0arm, so a stream that ends between frames is an errora_channel_reads_whole_frames_and_its_ends, "a poll: Kind(UnexpectedEof)"self.have += nasself.have = n)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_hereskips its node checka_tier_on_a_node_is_refused_by_name, "a refusal" (the GB10 opened instead)write_errsends a fault as a refusal (ERR_REFUSED)messages_read_back_as_written, the fault "read back as Shape { ... }"read_errdrops the fault word it read (Fault::from_words(1, s))messages_read_back_as_written, the fault read back asFault { layer: 0, code: 1, sites: 24, ... }, not layer 5FAIL-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:FAIL node 0 and node 1: tokens apart at Some(0)FAIL lost node 0 (stopped), the held step gaveOk(50324)and node 1 did not endFailed, wantCardLost)FAIL lost node 0 (stopped), the held step gaveOk(12457)FAIL open, the swapped open gets past the set check and fails later inWeights::load_placedon a byte count instead of by nameHow 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 (
b570f82cagainst main33fb317b). It is a split, so it must change no kernel and no result. Its measured commitb570f82cis the patch now atc9af1729ona13125a8(git range-diffshows them equal). Main changed none of the files it touches in the rebases since. These ran onb570f82cand its base:ptx-scan --no-jitof the scan set (tools/recipes.py scan-set:bloomery-serve-ds41,gate_kquant,gate_swap) and ofgate_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). Thebloomery-gpubundle is 231 bytes shorter; the comparison is per entry.Bits: Qwen3.8
UD-Q4_K_XLat plan (b′), its tier a secondGpuon 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_tieron 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 at6fceb4de, 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
b87a62dcagainstfcdfd4a5and not run again on main. The refactor's patch and the files it touches did not change in the rebase. One same-lease sitting oftools/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 anoninfcard (predict: -0.2..+0.2, margin 1 %).tools/ref/ab-decode.shtimesbloomery-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 throughHostTierwith no tier, which the split also touches; the in-process card tier needs twoGpus, and the runner refuses--place bpon one card. The card tier path adds one match and oneBoxderef a tier request against a step of about 32 ms, which is 0 at this ruler (derived, not measured).tok/s @ n=160, depth 6, GB10. No round had differing tokens, no row was[cpu-busy], and the lease saw no Xid. Thebin: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_hereunit test above.open_hereis the call that opens their tier card, so deleting the refusal at either call site no longer compiles; a call site rewritten to callGpu::open_carddirectly would bypass it.Not run here:
gate-gpu-qwen38-twocard,gate-gpu-glm5next-twocardandgate-gpu-ds41-twocard, which need two cards, andstage-gpu-load-v41, which a change tocrates/gpu/src/weights.rsruns by name and which stages plan (b) on two cards. Theweights.rschange movesupload_segment's lookup of a segment's bytes intoSegmentFile::ofwith the same checks and refusal texts; every load in the nodes gate goes through it (Weights::load_rowson node 1,Weights::load_placedin one process), and theptx-scan --no-jitat18ae4660is above.gate_qwen38_twocardnow includes the shared history and structure checks. It compiles, itsptx-scan --no-jittable is identical to the base's (above), and the history and the tier's graph and chain checks it runs are the codegate_qwen38_nodesran in its one-process reference above.gate-gpu-qwen38-nodesitself 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.On a Mac
At
18ae4660, with main6f87bfb8as the base:just mac-static,just mac-test,python3 tools/recipes.py checkand 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.The combos come from the ledger: they ran at
2cc0eeb9, the same four patches on main15838a46(git range-diffshows them equal), 42 of 42 green, and main's one commit since changes three Python tools, which no build shape reads. The one red isbloomery-refset'sa_plain_target_with_no_draft_beside_is_the_family_path: the temporary directory comes back once with and once without macOS's/privateprefix. This PR does not touchcrates/refset, and the same test is red on the same Mac at maincb5688c9alone.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,661,761,024 B in the runs at
18ae4660, 130,596,118,528 B in the runs at69a25bf6and 130,596,122,624 B in the earlier runs. The checks ran in a container built onnvidia/cuda:13.3.1-devel-ubuntu24.04, withrustc 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 at69a25bf6did not keep the machine'snvidia-smiline; the runs at6b16c8d2and18ae4660recordedNVIDIA GB10, 12.1, 595.84.AI help
AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.