Repository navigation
placement: a model's plan over several unified nodes (dgx-spark.md §9), and nodes_scale on one to four Sparks - #95
Conversation
b009506 to
2e7824f
Compare
There was a problem hiding this comment.
Verdict: MUST_FIX: the planner accepts an overflowing ruler and reports messages above its contract for small top-k workloads.
Must fix
1. Reject rulers above 1000 permille
crates/placement/src/placement/nodes.rs:475 only rejects zero. Take two nodes with room for both ids, read = 1 B/s, serial = 1 GB, one routed layer with no shared bytes, two experts of 6 GB each, top-k = 2, no fills, and a pair link at 25 µs. The one-node prediction is 13 × 10^18 ns; splitting [1, 1] gives 10 × 10^18 + 18,750 ns, with only 18,750 ns exposed to the link [derived]. The link and non-gain guards pass. A ruler of u64::MAX reaches line 530: 2 × ruler × step exceeds u128::MAX. Checked builds panic; unchecked builds wrap instead of returning a named error (kind A).
Fix the input boundary before callers depend on this API. Replace the validation expression and add 1001 and u64::MAX to named_refusals:
let undefined = if nodes.read == 0 {
Some("a read rate of 0")
} else if nodes.nic == 0 {
Some("a NIC of 0")
} else if !(1..=1000).contains(&nodes.ruler_permille) {
Some("a ruler outside 1..=1000 permille")
} else if terms.experts_used == 0 || terms.experts_used > terms.experts {
Some("a token using none of the experts, or more than there are")
} else if terms.id_bytes() == Some(0) {
Some("routed experts of no bytes")
} else if workload.fills.is_some_and(|f| f.hit_permille > 1000) {
Some("a hit rate past 1000 permille")
} else {
None
};2. Bound messages by the token's top-k
crates/placement/src/placement/nodes.rs:1001 reports two messages per remote, even when fewer experts are selected. A whole model needing four pools with top-k = 1 reports six messages on node 0; §9.8 criterion 6 permits at most two (kind A).
The existing shapes use top-k 6, 8 and 10, so messages_o_n misses this defect. Correct the report before the protocol uses it. Replace the node-0 arm:
(true, 0) => 2 * remotes.min(t.experts_used),Add a whole-placement case to messages_o_n: Shape { experts: 7, used: 1, expert: 1_000_000_000, ..V41 } on four Sparks. Each pool holds two ids; all four nodes are required. Assert the busiest node reports at most two messages. Restoring the current arm makes that assertion red.
Non-blocking
⚠ Derive the expected cells independently: crates/placement/src/placement/nodes_scale.rs:473 pins cells obtained from the predictor itself. The PR body acknowledges the missing §9.9 derivation script.
Add the independent derivation and its self-test, then regenerate the cells. This establishes a reference before engine integration.
Checked
Cited production paths, contract sections and test assertions were read. Both inline replacements are valid Rust and cover added lines.
Tests: named_refusals covers a zero ruler, not an upper bound. messages_o_n uses only top-k 6, 8 and 10, above every remote count it exercises. The proposed boundary assertions fail on the current code.
Gates: the diff removes no existing pin or clause. The PR body reports FAIL-first results for the new clauses.
Escape catalog: C1–C4 do not match these findings. The ledger has no lessons in force.
Notes
Validation used source and integer arithmetic; no crate build was run. The PR body omits the nvidia-smi output required for GPU evidence and reports stage-gpu-load-v41 unrun.
Card check: needs-gpu. This pull request changes crates/gpu-gates/src/bin/gate_deepseek41_tier.rs, crates/gpu-gates/src/bin/gate_glm5next_residency.rs, crates/gpu-gates/src/bin/shared/residency_clauses.rs … 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)
| } else if nodes.ruler_permille == 0 { | ||
| Some("a ruler of 0") |
There was a problem hiding this comment.
Reject rulers above 1000 permille before the gain product can overflow u128.
| } else if nodes.ruler_permille == 0 { | |
| Some("a ruler of 0") | |
| } else if !(1..=1000).contains(&nodes.ruler_permille) { | |
| Some("a ruler outside 1..=1000 permille") |
| nic_permille, | ||
| msgs_per_layer: match (on, j) { | ||
| (false, _) => 0, | ||
| (true, 0) => 2 * remotes, |
There was a problem hiding this comment.
A token with top-k = 1 can select experts on at most one remote; cap the message bound by experts_used.
| (true, 0) => 2 * remotes, | |
| (true, 0) => 2 * remotes.min(t.experts_used), |
2e7824f to
f54e2e7
Compare
There was a problem hiding this comment.
Verdict: MUST_FIX: the predictor silently omits gathered reads from host engram tables on unified nodes.
Must fix
1. Account for the planned tier of engram tables
nodes.rs:250 skips every EngramTable. With sufficient host room, row_table_tier places these tables on Device::Host at placement.rs:2792. On a unified node their gathered reads use the pool. Terms::of accepts the model but omits those reads from serial; nodes.rs:935 consumes that value and returns an understated step. The gain and NIC calculations also use it. This is a kind B silent omission with a mechanical refusal available.
Fix this owner before the protocol consumes its predictions. Let the terms read Plan::row_tier() and include gathered(t)? for host tables. Until the terms receive a plan, replace the skipped-role arm with:
(Role::EngramTable, _) => return Err(PlacementError::tensor(t, "node terms need the table's planned tier")),
(Role::RoutedExperts | Role::Unread | Role::Unused, _) => continue,Non-blocking
⚠ Message and NIC counts disagree: nodes.rs:809 charges a handoff to every used remote. The new fixture at N = 4, top-k = 1 uses all four nodes, so it charges three outgoing handoffs per layer, while line 1001 reports two total messages on node 0 [derived]. Define which remotes receive a handoff and use that rule for the NIC, post costs and report.
⚠ The message gate accepts a zero report: nodes_scale.rs:519 checks only upper bounds. Replacing all msgs_per_layer values with zero leaves both assertions green; the other message assertion requires zero at N = 1. Add an independent count assertion so a dropped reporting branch fails. For the existing fixtures, this catches that mutant:
assert!(cell.1 == 1 || busiest > 0, "{cell:?}: no messages reported");Checked
Validation: all three finding locations and their supporting paths read. The table omission reaches the public prediction. The message-count discrepancy and zero-report mutant are confirmed by static arithmetic.
Inline suggestion: lines 250–252 are added by the diff; the replacement uses valid match arms and the existing error API.
Escape catalog: no patterns matched. The ledger has no active lessons. No issue candidates were supplied.
Notes
The PR provides no nvidia-smi output or just gate-* commands with output and reports that stage-gpu-load-v41 was not run. Validation used static and arithmetic checks; Rust tests were not executed.
Card check: needs-gpu. This pull request changes crates/gpu-gates/src/bin/gate_deepseek41_tier.rs, crates/gpu-gates/src/bin/gate_glm5next_residency.rs, crates/gpu-gates/src/bin/shared/residency_clauses.rs … 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)
| (Role::RoutedExperts | Role::EngramTable | Role::Unread | Role::Unused, _) => { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
On unified nodes, host engram tables contribute pool reads that these terms omit; refuse them until terms receive their planned tier.
| (Role::RoutedExperts | Role::EngramTable | Role::Unread | Role::Unused, _) => { | |
| continue; | |
| } | |
| (Role::EngramTable, _) => return Err(PlacementError::tensor(t, "node terms need the table's planned tier")), | |
| (Role::RoutedExperts | Role::Unread | Role::Unused, _) => continue, |
f54e2e7 to
fcdfd4a
Compare
|
Rebased onto main as one commit, |
There was a problem hiding this comment.
Verdict: MUST_FIX: truncating expected NIC traffic hides a plan above the 50% limit.
Must fix
1. Keep fractional NIC bytes until the threshold comparison
crates/placement/src/placement/nodes.rs:961 divides expected traffic by all before the 50% comparison at line 1004.
Kind A, reachable synthetic case: two unified nodes on a zero-latency pair; one routed layer with shared bytes 0 and three 1-byte experts; top-k 1, serial 4 B, width 1, read 1 B/s, NIC 5 B/s, ruler 1 permille, prompt rate 1 token/s, whole: true, and no fills. Matching budgets have room for [1, 2] ids. The capacity branch at line 591 selects both nodes, and the split owns [1, 2]. Mean traffic is 8/3 B/token; the prompt NIC share is 4/7. Truncation reports 3/7, so nic_bound() returns None for a share above 50% [derived].
Fix this before callers use the bound to choose a prompt mode.
Replace nic_busy with this function and change its doc comment to say that mean bytes remain fractional. Rows dominate handoffs: each reached remote returns at least one row.
fn nic_busy(&self, node: usize, step_ps: u128) -> Option<[(u128, u128); 2]> {
let t = self.terms;
let e = u128::from(t.experts);
let ids = if node == 0 {
self.remote_ids()
} else {
self.owned[node]
};
let bytes_num = u128::from(ROW_VALUE.checked_mul(t.n_embd)?)
.checked_mul(t.layers.len() as u128)?
.checked_mul(u128::from(t.experts_used))?
.checked_mul(u128::from(ids))?;
let nic = u128::from(self.nodes.nic);
let decode = (
bytes_num.checked_mul(PS)?,
step_ps.checked_mul(nic)?.checked_mul(e)?,
);
let id_k = u128::from(self.id_bytes).checked_mul(u128::from(t.experts_used))?;
let mut ffn = 0u128;
for j in self.used() {
let own = id_k.checked_mul(u128::from(self.owned[j]))?;
let shared = if j == 0 {
u128::from(t.shared_bytes()?).checked_mul(e)?
} else {
0
};
ffn = ffn.max(own.checked_add(shared)?);
}
let serial = u128::from(t.serial);
let one = serial.checked_add(u128::from(t.window_bytes()?))?;
let prompt = (
u128::from(self.workload.prompt_rate)
.checked_mul(one)?
.checked_mul(bytes_num)?,
serial.checked_mul(e)?.checked_add(ffn)?.checked_mul(nic)?,
);
Some([decode, prompt])
}Add this case to nic_not_max: assert a bound with busy_permille == 571. Restoring integer-byte truncation must turn that assertion red.
Checked
The cited code is added by the diff. Planner guards, capacity selection, ownership, decode timing, and the prompt threshold were checked. Exact rational arithmetic reproduces the counterexample and verifies the replacement ratios. nic_not_max lacks this fractional case; its nic_of reference also truncates mean bytes. No what-not-to-flag exclusion or escape-catalog pattern applies to this finding. No ledger lessons are in force.
Notes
Rust gates were not run during validation. The PR lacks the complete card evidence list.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
…), and nodes_scale on one to four Sparks placement::nodes plans plan (b') with each remote node in place of a tier card. Node 0 runs every layer and the head; each other node owns a share of every routed layer's ids in its own pool. Each node's budget is the card plan of that node alone (NodeBudget::of): every term of its pool need (Plan::pool_need, the sum Violation::PoolOver reads, now one method) but the routed ids, which the node plan splits at their file bytes. The card plan refuses by name a pool whose bytes with its cards' context pass u64. The split counts ids, as if every id were used alike, the same ids on every routed layer, and no node pages an id while another pool has room. The predictor reads node 0's card plan (Terms::of): each tensor's rows the plan reads a token in the bytes it holds them in, the card's format on a card and the file's on the host, and refuses by name rows other than routed ids read from the NVMe tier, for which it has no fill term; the row width is an input. It takes the nodes' read rate and NIC and the fabric's round trip, and the expected slowest node of a layer exactly over the multinomial of a token's slots. In a layer node 0 sends the handoff only to the remotes that hold one of the token's slots; the link term and the reported messages follow that rule. The NIC carries the rows of a token's slots on the remotes, which bound its handoffs. A step keeps each layer's expected window exact over the slot outcomes; only each outcome's slowest window and the serial term round down to whole picoseconds. The 50 % NIC line, the link's tenth and the gain compare that step exactly, the NIC with a token's mean bytes kept a fraction, and the remotes' fills compare whole sums. plan_nodes uses a node while the pools of the nodes before it cannot hold every id and its own has room, and any other node only where the link stays within a tenth of the step and the step gains twice the machine's ruler. A node whose own budget passes its pool is named idle for it. Every node the final split gives no id is named idle, and an idle node's pool is 0. The NVMe fills (C3 of tierengine.md (k)) are inputs: hit rate and fill time. The node plan's sums are checked or run in u128; a read rate, NIC, prompt rate or row width of 0 and a ruler outside 1 to 1000 permille are refused by name. The card plan refuses a machine of several nodes by name. nodes_scale holds §9.9's clauses 1 to 7 and C3 on synthetic Sparks built from one GB10 census reading, with §9.7's model terms, every tensor whole Q4_K rows. N = 1 is today's plan: held whole, node 0's bytes are the card plan's pool need, or both refuse it by the same bytes; paged, node 0 keeps the card plan's terms before its NVMe split. The pins are the predictor's cells; they match §9.8's table but for criterion 5, where node 0 takes the rows of the remotes' share as the split sets it. Criterion 7 compares each plan with the N - 1 plan on the same link. dgx-spark.md §9.2, §9.8 and the P5 lines of §1 and §10 describe the node plans in the tree. §9.9 keeps the gate's clauses, expected sets and mutants as written and adds where nodes_scale holds a clause differently; §10 lists those for the maintainers. Nothing outside the placement crate calls the node plans yet.
fcdfd4a to
0e655ae
Compare
|
Fixed in |
There was a problem hiding this comment.
Verdict: CLEAN: The remaining findings concern test coverage and a planner refactor.
Non-blocking
⚠ Pin combined window rounding: crates/placement/src/placement/nodes.rs:1158. The explicit link and gain boundary cases use whole: true, which disables fills. The PR reports that dividing reads, rows and fills separately leaves every test green. Add a fractional read/fill boundary case to an existing threshold test whose assertion fails under that mutant.
⚠ Separate planner responsibilities (R12): crates/placement/src/placement/nodes.rs:579. This function combines input validation, candidate construction, acceptance and idle reporting. Extract validation and candidate acceptance into private helpers, retaining the exact comparisons. Doing this before engine integration makes each decision easier to review independently.
Checked
Duplicates: 131 new items checked; no duplicated fact found. The dryer pair sums expert bytes and shared bytes; these are different terms.
Complexity: plan_nodes is new at 42, with several planning stages. layer_ps (33), link_term_ps (22) and nic_busy (22) are new checked-arithmetic predictors. plan_rule rises from 32 to 34 through two named refusals.
Silent failure: 15 candidate sites followed. Failed conversions reach named errors; zero results and saturation follow guarded or documented cases. Idle reasons are filled before collection.
Tests: all nine new tests traced to production mutations. The reported M1–M7 failures agree with their assertions; named refusals and the NIC boundary have assertions. Separate window rounding remains unguarded, as noted above. Rust tests were not rerun.
Gates: new nodes_scale; no existing numeric pin moved. Differences from the planned specification are documented.
Performance: planning code only; no new decode, prefill or kernel work.
Escape catalog: no C1–C4 pattern matched. No ledger lessons are in force.
Notes
GPU evidence is incomplete: the PR gives a card/driver record and summarizes earlier gate results, without attaching the gate output. Validation here used source inspection and independent rational-arithmetic checks.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
|
Both non-blocking points are in #163, on top of main now that this is merged.
|
What and why
One commit,
0e655ae4, on main atacfb220a. #94 (Machine::nodes), which this PR builds on, is in main now. This revision fixes the review's must-fix on the NIC line and the same rounding in the five other places it reached a line, one bullet each in "Rounding before a line" below. "What I ran" gives the checks at0e655ae4first, then those of earlier heads.Item 2 of P5's placement half in
docs/contrib/dgx-spark.md§9: the node plans (the §9.2 shape) and thenodes_scalegate of §9.9, clauses 1 to 7, with C3 ofdocs/research/tierengine.md(k). Part of #13 (P5, agreed in #13 not to wait for S10). No engine code calls the node plans yet; the protocol half will.crates/placement/src/placement/nodes.rs:plan_nodesplans (b′): node 0 runs every layer and the head, and each other node owns a share of every routed layer's ids in its own pool.NodeBudget::of). Node 0's comes from today's plan of the model, a remote node's from the plan of the model's routed stacks with no cache. The budget keeps every term the card plan's pool bound counts: the cache, each card's set-aside and margin, and the host's tables, reserves, shadows and arena. It leaves out the routed ids, which the node plan splits at their file bytes. A plan whose card lays the routed ids out in other bytes than the file's is refused by name.Plan::pool_needis that sum, moved out ofviolations_beside, which now reads it forPoolOveras well. The node plans add no pool arithmetic of their own. The card plan refuses by name a unified pool whose bytes and its cards' context pass u64 (PlacementError::PoolPastU64), so the sum cannot wrap.NodePlan::fills_apartnames two remotes whose fills end up more than a tenth apart (the split does not balance the fills themselves).Terms::of(&plan, n_embd)). Each tensor counts the rows the plan reads of it a token, in the bytes the plan holds them in: the card's format on a card (a BF16 tensor counts its f32 bytes) and the file's on the host. A routed expert counts its file bytes, the bytes the split holds it at. Rows other than routed ids that the plan reads from the NVMe tier (a row table left there) are refused by name, because the predictor has no fill term for them. The row width is an input, the model's embedding length, because a head tensor's first dimension is not the width in every file (the Qwen3.8 file's last head tensor gives 320, not 2560).LinkCost(λ), so it does not share a name with theLinkseam of gpu, gate-paths: the tier page is reached over its link; host/link.rs holds the shared page, and the stage card, each tier card and the host take the page's addresses and words there #96.Prediction,NodeLoad,IdleWhy) are rounded down to whole ns and ‰.IdleWhy::Pool) and is never used. A node is used while the pools of the nodes before it cannot hold every id and its own pool has room. Any other node is used only where the link stays within a tenth of the step and the step gains twice the machine's ruler; otherwise the plan names it idle (NodeIdle, with the reason). Every node the final split gives no id is named idle, and it reports 0 for its pool, window, NIC and messages. The pool bound reads only the nodes the plan uses.Fills: the arena's hit rate and one fill), since S7 and S10 own those terms.PlacementError::Nodes) instead of planning node 0 alone.NodesError::Overflow); 512 experts at top-k 15 (512^15 = 2^135) is the test's case.crates/placement/src/placement/workstation.rsadds a Spark's pool, read rate and NIC (GB10_POOL,GB10_READat §9.7's measured 208.8 GB/s,GB10_NIC) and the box's ruler (RULER), all for tests only, since only the tests read them. It also addsgb10(free), the GB10 built throughspec_of_deviceas the census reads one, andspark_nodes(n, layers, fabric), which panics by name atn = 0. The Spark machine ofqwen38_cardsnow usesgb10too, so the tests have one GB10.docs/contrib/dgx-spark.md: §9.2, §9.8 and the P5 lines of §1 and §10 describe the node plans in the tree. §9.9 keeps the gate's clauses, its expected sets and the M1 to M7 list as they were written. Wherenodes_scaleholds a clause differently today, the clause says how in sentences of its own, and a new §10 item lists those differences for the maintainer to accept or change.nodes_scaleruns on three synthetic shapes with §9.7's model terms (V4.1, GLM-5.3-Flash and Qwen3.8, A low and high), N = 1 to 4, and λ at 25 and 70 µs. Every tensor is whole Q4_K rows, so today's planner takes the shapes as they are; each term is §9.7's rounded down to whole rows. GLM's A and Sh are the header read's 7.36 and 1.60 GB, one value each, so its A low and high cases are one plan. Clause 1 also plans the Qwen3.8 file's tensor table (its model tensors and first two layers) by its program's routed rule; the default rule ofplan_withwidens the file's Q5_1 downs on the card, which the node plans refuse by name. No model file is read.Clause 1 against today's plan
§9.9 asks that every N = 1 plan equal today's plan byte for byte.
n1_is_todayholds that where node 0 holds every id, and compares the pool sum, not the layout. Node 0's budget is today's pool need less the routed ids, so the clause checks that the node plan puts the same sum back:PoolOverequals today'sPoolOver(V4.1 at A low: 269,832,964,224 B against a pool of 115,284,164,096 B).Row tables on the NVMe tier
This is the fix the maintainer asked for on #127.
Terms::ofcounted a row table (Role::EngramTable) that the plan leaves on the NVMe tier as no read, so the step left out every gathered NVMe read a token, while the routed ids that page got a fill term. A plan leaves a table there when the host's room is short, which is the large-model Spark case.pool_readnow refuses such a table by name (PlacementError::Tensor: "rows read a token from the NVMe tier, which the node plans take no fill term for"), soTerms::ofgives no terms for that plan. Oncerow_table_tiermoves the table to the host, it counts the gathered rows. I chose the refusal over a fill term because the node plans take their fills as given inputs (Fills, for the routed ids), and a table's fill rate is not one of them yet; a table fill term can replace the refusal when one exists.n1_is_todayholds both sides on the synthetic engram table and on the Qwen3.8 file's PLE table: refused on the NVMe tier, counted on the host. The mutant that drops the refusal turnsn1_is_todayred.Rounding before a line
This is the review's must-fix.
nic_busydivided a token's NIC bytes by the slot outcomes and rounded them down before the 50 % line. On two nodes with three ids of one routed layer at top-k 1, owned [1, 2], a token carries 8/3 B, two thirds of a 4 B row: the prompt share is 4/7 (571 ‰), but the rounded 2 B gave 3/7 (428 ‰), sonic_boundreturnedNonefor a plan past the line.nic_busynow keeps the numerator and the denominator apart up to the comparison. The mean is exactly k × ids / experts rows a token, because the sum over the experts^k outcomes reduces to that, and the smaller denominator also keeps GLM's and Qwen3.8's terms inside u128. The handoff term left the NIC: the old maximum always took the rows (above).I then searched the node plan for the same pattern, a value rounded down and then held to a line, and fixed each place:
fills_apartcompared the remotes' fills in whole thousandths a token. It now compares the sums before the division by the expert count.plan_nodescompared the link's tenth and the gain on steps in whole nanoseconds, with the link term as the difference of two rounded steps.Ps), and a window's reads, rows and fills share one denominator, so the window rounds once. The link's tenth and the gain compare these steps exactly, and the link term is their exact difference.NodeLoad::nic_permilleis the exact share rounded down.read_balanceheld the slowest window to the optimum in whole nanoseconds, so a window under 1 ns read 0. It now compares picoseconds (NodePlan::window_ps,balanced_window_ps). The cell checks inlink_shareandnever_slowernow read the exact steps (step_ps,exposed_ps).nic_boundnames node 0 or no node. Node 0 takes the rows of every remote's slots, and both of a node's NIC terms scale with its ids, so node 0's shares are at least any remote's. The old code picked the busiest node by the reported ‰, which also always gave node 0.What still rounds [derived]: the serial term and each outcome's slowest window round down to whole picoseconds. A step therefore reads less than L + 1 ps low for L routed layers (49 ps for Qwen3.8's 48), and the link term, where the serial term cancels, is within L ps. An exact step needs a denominator of read × NIC × experts^k, about 2.1 × 10¹¹ × 2.4 × 10¹⁰ × 2⁹⁰ ≈ 6 × 10⁴⁸ for Qwen3.8, past u128 (3.4 × 10³⁸). So a line can fall either way only within that bound of a step, a few parts in 10⁹ of a real step. Two inputs inside the bound stay out of the tests. The NIC case below at a read rate of 1.5 × 10¹⁰ B/s and λ 0 has a decode share of exactly 1/2, but its step reads 1⅓ ps low, so
nic_boundnames node 0. The 1,000 B link case below at 98.04 GB/s passes a tenth of the step by 0.04 ps; it comes out right, but inside the bound.The windows that order the split and that each node reports, and the optimum clause 3 holds them to (
mean_ps,link_term_ps), still round each term down, a few picoseconds a window. No line reads them but clause 3's 2.5 %.Debuggability:
predict()reports the step, the link and each node's window, NIC share and fills, andnic_bound()andidle()name the node and the reason. I promoted no probe.Each case below is in the tests now. At
fcdfd4a5, the previous head (the patch of26e3c63aon mainacfb220a), each failed but one:fcdfd4a5nic_not_max)nic_boundNoneand node 0 at 428 ‰, where 4/7 is 571 ‰nic_not_max)None, but node 0 at 375 ‰, where the share is 500 ‰fills_balance)fills_apartNone: 0.2 and 0 thousandths both read 0link_share)Gain { without_ns: 50, with_ns: 55 }, where the link exposes 5,666⅔ of 55,666⅔ ps (10.2 %)link_share)LinkShare { link_ns: 11, step_ns: 106 }, where the link is 10,666⅔ psnever_slower)never_slower)read_balance)I ran one probe of all 48 cells at
fcdfd4a5and at0e655ae4. No split, idle node, NIC bound or ‰ moved. The reported step moved by 1 ns in 12 cells, the reported link by 1 ns in 9, and the step in one idle reason (Qwen3.8, N = 4, λ 70, A low: 22,351,467 to 22,351,468 ns) by 1 ns. No value this description, §9.9 or the tests pin moved. A token's mean NIC bytes an id are whole for V4.1 (12,800 B) and Qwen3.8 (9,600 B); GLM's are 57,344/3 B, and its shares did not move either.Where the predictor differs from §9.8
The test pins the predictor's cells until the derivation script of §9.9 lands (open question 4). They match §9.8's "where each fires" table for criteria 2 and 7 and differ for criterion 5. The numbers are the predictor's [derived]:
link_sharenic_not_maxnever_slowerClause 7 compares each plan with the N − 1 plan on the same link: a pair for a ring of three, three switched Sparks for four. No chosen plan is slower there. On its own fabric the N − 1 plan can be faster: Qwen3.8 on four switched Sparks, of which the plan uses three, takes 20.997 ms at λ 25, A low, and on a ring of three 20.955 ms. The difference is the switch's hop. I have not chosen between the two readings of criterion 7; the test holds the same-link one, which is what the planner can choose.
At N = 1 the step is 24.240 ms for Qwen3.8 at A low, 38.429 ms for V4.1 at A low and 67.370 ms for GLM, at the measured read rate.
Open questions
RULERis AGENTS.md's 95 % interval at four rounds ("±1.0 % at four rounds"), 10 ‰. The gain line is then 2 × ruler (2 %), and clause 3 holds the slowest window within 1 + 2.5 × ruler (2.5 %) of the optimum. §9.8 reads both ways. Criterion 7 says "2 × ruler (2 % on the box)", which is a 1 % ruler. Criterion 4 says "so 5 % of it", and the box's default in §9.8 is 2 %, which is a 2 % ruler. I would change criterion 4's 5 % to 2.5 % and the box default to 1 %; if 2 % is the intended ruler,RULERchanges and the pinned idle cells move.never_sloweralready shows what a wider ruler does: at 40 ‰, Qwen3.8 leaves node 1 idle at N = 2, λ 70.plan_withdoes not: it gives the GB10's card the whole pool, andexpert_nvme_tierat the pool's room then refuses both withHostRoomFloorunless a card budget is set. placement, model: on a unified machine a plan the NVMe tier's floor refuses is planned again under a card budget that leaves the floor #100 changes that path: it plans V4.1 and GLM on a unified pool again under a lower card budget, so one Spark pages them. Clause 1's paged half compares withplan_withtoday; once placement, model: on a unified machine a plan the NVMe tier's floor refuses is planned again under a card budget that leaves the floor #100 is in, the comparison should be with that family plan. Which one should one Spark's node 0 budget come from?tools/with its own self-test, and the doc keeps that. The script is not in this PR: it is its own unit in dgx-spark: P5 goes ahead with generations only on the link #127, on top of this one, andnodes_scalepins its output once it lands. Until then the pins are the predictor's own values, and §9.9 and the table above name each cell that differs from §9.8. A script that only repeated the predictor's arithmetic would pin nothing new, so I would like to agree first on what it derives on its own (the §9.7 band arithmetic, say).routed_only(model)with no cache. The engine needs one call that makes both from a model and each node's census, perhaps a plan that takes a remote's id list. Which API that is stays open.Cardhas no node field,LinkCost(the design'sLink) has no rail, the read rate and NIC sit onNodesfor all nodes alike, and the fabric is onNodes, not onMachine. Each remote node's card is node 0's card spec, since all Sparks are alike here.Code shape
Plan::pool_need, the methodPoolOverreads;Terms::ofreads node 0's card plan throughcard_bytesandper_expert, not per-model figures; the tests build every GB10 throughspec_of_device.NodesError::Undefined, the node plan's own sums are checked or run in u128 (NodesError::Overflow), a row table on the NVMe tier is refused by name, and the card plan refuses a machine of several nodes and a pool past u64 by name.cargo fmtandcargo clippyare clean on what I touched; the lint count did not go up.Duplication found or removed (
path:line), or "none": the pool sum inPlan::violations_beside(crates/placement/src/placement.rs) is nowPlan::pool_need, which the node budgets read too; the GB10 device literal inqwen38_cards's Spark machine is nowworkstation::gb10.What I ran
At
0e655ae4, on a Mac (Apple M5) throughjust, against this PR's baseacfb220aas the scope base:The 27 green combos come from the same step on an earlier build of this revision, whose code differs only in one test comment; it ran 20 shapes with 0 red. The one failure is
bloomery-refset'sa_plain_target_with_no_draft_beside_is_the_family_pathunder the defaultTMPDIR, the same failure described below for26e3c63aand reported in #137; this PR does not touchcrates/refset. On a second Mac (Apple M4 Pro),cargo test -p bloomery-placementpassed 154.The cases of "Rounding before a line" ran on
fcdfd4a5'snodes.rs: each one failed but the gain case at 10¹⁴ B/s, with the values in that section's table.At
26e3c63a, the previous revision on maina7ed8b58, on the Mac (Apple M4 Pro):check-recipeswas red in twotools/self-tests, which this PR does not touch: thegate-batchself-test, and onegpu-gatecase that waits on a batch hold ("a hold released while the run waits", rc 75 where it wants 0). On main ata7ed8b58, in a detached worktree, the same step was also rc 1 on thegate-batchself-test. Each run had the Mac to itself; the load average read 7 to 30 at their starts and ends, from the checks' own parallel lanes.mac-testran withTMPDIR=/private$TMPDIR. Under the defaultTMPDIR(/var/folders/..., a symlink into/private/var),bloomery-refset'sa_plain_target_with_no_draft_beside_is_the_family_pathfails at this commit; this PR does not touchcrates/refset.Each clause red first. One mutant at a time at
0e655ae4, thencargo test -p bloomery-placement --lib nodes_scale. The first column names §9.9's mutant where the row is one:over()sumsheld_bytesof every node at node 0)fills_balance,link_share,messages_o_n,never_slower,nic_not_max,pool_per_node,read_balancefor j in 1..n.min(2))fills_balance,link_share,messages_o_n,never_slower,nic_not_max,pool_per_nodeif best.is_none())fills_balance,link_share,never_slower,nic_not_max,read_balanceround_trip_ns().map(|_| 0u64))link_share,messages_o_n,never_slower,nic_not_maxSome([decode, (0, 1)]))messages_o_n,nic_not_max+ POST_NS * 1000on node 0's base)fills_balance,link_share,n1_is_today,never_slower,read_balance2 * 20u128)never_slower2 * 10u128)never_slower(_, 0) => 2 * t.experts_used * remotes)messages_o_nlink.post * (remote_slots - 1)forlink.post * (reached - 1))link_share,never_slowerchecked_add(fills * 0))fills_balancefull.contains(&false) && self.workload.whole)fills_balance,nic_not_max,pool_per_node,read_balancecards.checked_add(0 * host))b.checked_add(0)inheld_bytes)n1_is_today,pool_per_nodet.experts * (m - 1) / mandt.experts / m)messages_o_n,nic_not_max/ e * eon the bytes innic_busy)nic_not_max/ expertsbefore the comparison infills_apart)fills_balance/ 1000 * 1000on both steps and the link inplan_nodes)link_share,never_slowersum / of * ofinstep_ps)link_share,never_slower,nic_not_maxPs { part: 0, ..step }innic_bound)nic_not_maxlayer_ps)Format::Card(_) => Ok(r.read_bytes))n1_is_today(Device::Nvme, _) => Ok(0))n1_is_todayif !now.over()?.is_empty())fills_balance,pool_per_node,read_balance&& now.room_ids(j) > 0)pool_per_nodefor node in 0..self.owned.len()inover())pool_per_nodePoolcheck dropped) (drop thefixed > poolcheck inplan_nodes)pool_per_nodepool_per_nodeprompt_rate == 0refusal)named_refusals.filter(|&(_, &c)| c > 0)inlayer_ps)fills_balance,link_share,messages_o_n,never_slower,nic_not_max,pool_per_node,read_balancenic_not_maxroom()summed as u64)read_balancefor j in 0..self.owned.len()inpredict)pool_per_nodewindow_ns: 0 * ...)n1_is_todaynic_permille: 0 * ...)messages_o_n,nic_not_maxnow.exposed_ps(a)and> afornext's andb)link_sharemachine.pool_bytes()?inplan_rule,placement.rs)named_refusalsTwo mutants stay green. The optimum's share sum in u64 cannot pass u64 in a test: each share is at most 1000 × experts, so it needs more than 2^64 / 4,000 experts, and the split walks every id. The rule (u128 sums) holds for inputs the tests cannot build.
The window mutant rounds a window's read, rows and fill down one by one again, which reads a layer's slowest window at most 1 ps lower where a window has two of the three terms, and 2 ps where it has all three [derived]. In the link and gain cases of "Rounding before a line", every read is a whole number of picoseconds, so neither form rounds. A case that turns the mutant red needs terms with fractions close to a whole picosecond, and a line further from the step than the remaining bound (L + 1 ps) but nearer than that bound plus the mutant's extra. Such a case can exist [derived], but I did not build one, so this rounding is held only by its review.
Against the table at
fcdfd4a5, six rows are new, one for each rounding this revision removes: a token's mean NIC bytes, the remotes' fills in thousandths, the link share and the gain in whole nanoseconds, each layer's window, the step under the decode line, and a window's terms. The row "node 0 hands off to every remote every token" is gone, because the NIC no longer reads the handoffs (the rows bound them), so that mutant has no code to change. M6 and the old rule for a needed node each turn more tests red,read_balanceamong them, and the even split now also turnsmessages_o_nred. "window_ns reported as 0" no longer turnsread_balancered: that test now reads the windows in picoseconds from the plan and has a new case at 10¹⁸ B/s.n1_is_todaystill pins the reported field. The fallback mutant ("idle list from the loop only") is red through a case on three Sparks inpool_per_node(owned[275, 0, 237], node 1 named idle), where a remote with no room of its own is taken for its gain while node 0 pages and a later node with room takes back all its ids. The link share mutant, which now swaps in the exact steps (now.exposed_ps(a)and> a), is red through the chosen plan's link term as well (10.2 % on the switch), so no mutant here separates the exact two-node count inlink_sharefrom the old "at most 3"; the count is a tighter pin of the predictor's cell.The cases the revision at
26e3c63aadded, red at its previous headd29e9aec:d29e9aecpool_per_node)PoolOver { node: 1, need: 121698955264, pool: 115284164096 }: the machine was refused, though leaving node 1 idle planspool_per_node)Gain { without_ns: 24239786, with_ns: 24239786 }, not the poolu64::MAXbytes (named_refusals)NodeBudget::ofpanicked, "attempt to add with overflow"n1_is_today)Terms::ofcounted the table as no readpool_per_node)link_share)The cases added before that were red at
68d67a38, each in a test of its own that I then folded into the clauses:68d67a38n1_is_today)n1_is_today)pool_per_node)PoolOver { node: 1, need: 121698955264, pool: 115284164096 }for a node the plan does not usepool_per_node)[512, 0], node 1 named idle for its gainpool_per_node)[]read_balance)[0, 2](nic_not_max)messages_o_n)named_refusals)λ = 0 leaves
read_balancegreen, as it should: both the windows and the optimum move with the remote term.Earlier heads, each with its own logs:
d29e9aec(3bb8a15drebased onto80c17bbd, the same patch):mac-static upstream/mainrc 1. fmt-check, lint (41, the ratchet's 41), combos (20 shapes run, 0 red, 28 skipped by scope) and every check script but one were rc 0.check-recipeswas rc 1, and also rc 1 on main's control at80c17bbd(thegate-batchchain item that stops at rc 124).mac-test: 1149 passed and 1 failed,bloomery-refset'sa_plain_target_with_no_draft_beside_is_the_family_pathunder the default TMPDIR (above); withTMPDIR=/private$TMPDIR,bloomery-refsetpassed 168.3bb8a15d:mac-static upstream/mainrc 1 oncheck-recipesalone; combos ran no shape (0 run, 48 skipped by scope, 20 green in the ledger).mac-test: 1148 passed, 0 failed.cargo test -p bloomery-placement: 154 passed.The Spark runs are from earlier revisions, on one DGX Spark (aarch64), in the dev container. T1 ran at
2e7824f3and at #94's83205a05. The placement tests, the plans and the gate ran atee98dcfdagainst main at9ab442ca.T1's one red step, on both, was
check-recipes: main'stools/mac-remote.sh --self-testfailed 4 of its lock checks on an aarch64 Linux host, as #94 describes, and main'sbd29e9c9fixes it. This PR does not touchtools/, and every other step was green.Today's planner changed in two places then (
Plan::pool_needand the refusal of a machine with more than one node), so I compared single-node plans with main atee98dcfd.bloomery-serve --planwith the host room fixed (BLOOMERY_HOST_ROOM=124000000000) printed the same bytes on this branch and on main for Qwen3.6-35B-A3BQ4_K_M, Qwen3-30B-A3BQ4_K_Mand Qwen3.8-Flash-NextUD-Q4_K_XL, three runs each. Without the fixed room, the plans differ only in the free memory each run reads.gate-gpu-qwen3-servewithout its--imagesclause (the mmproj file is not on this machine) passed on both, the same 118 checks. Since then the card plan also refuses a pool whose bytes and its cards' context pass u64, which no census reading reaches, and the node plans changed only in what a single-node plan does not read, so I did not run the plan comparison again.The node plans have no engine caller yet and their clauses are pure placement.
just affected upstream/main --no-boxselects 140 of 156 gate recipes, since every gate readscrates/placement(a static walk, no dep-info); the runs above are the ones that need no second card and no unpublished input. AGENTS.md runsjust stage-gpu-load-v41by name for a change to the placement and workstation code, which now lives undercrates/placement/src/; it stages on both cards, so I could not run it here.Hardware (if the change is for a machine)
The predictor's terms are a DGX Spark's: the model terms of §9.7 [assumed], the NIC of §9.1 [derived] and the read rate measured in §5. No term was measured for this PR, and no number in this description is a timing: every step, share and gain above is the predictor's [derived].
The Spark runs under "What I ran" are functional checks, not timings. Each carries the evidence
CONTRIBUTING.mdlists:Card and driver: the runs' environment record reads
NVIDIA GB10, 595.84, 12.1andLinux 6.17.0-1032-nvidia aarch64; the checks ran in the dev container.Commits: T1 at
2e7824f3(this PR then) and at83205a05(placement, model, gpu, gpu-gates: a Machine's host and unified pool live in Machine::nodes, this process's node first #94). The placement tests, the plan comparison and the gate atee98dcfd, against main at9ab442ca.Model files:
Qwen3.6-35B-A3B-Q4_K_M.gguf,Qwen3-30B-A3B-Instruct-2507-Q4_K_M.ggufandQwen3.8-Flash-Next-UD-Q4_K_XL-00001-of-00004.gguffor the plans, three runs each on each tree; the first two for the gate, which passed 118 checks and failed none on each tree.Commands and output: the T1 and test block above. Each plan ran
BLOOMERY_HOST_ROOM=124000000000 ./target/release/bloomery-serve -m <file> --plan --host 127.0.0.1 --port 18099and exited 0. The gate ran the recipe's command without--images, and exited 0 on each tree:The Mac checks ran on an Apple M5 (32 GB) and an Apple M4 Pro (24 GB), with
rustc 1.100.0-nightly (e457a7b0d 2026-08-27).AI help
AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.