Skip to content

placement: a model's plan over several unified nodes (dgx-spark.md §9), and nodes_scale on one to four Sparks - #95

Merged
midagedev merged 1 commit into
midagedev:mainfrom
juliankang4:dgx-spark-nodes
Oct 14, 2026
Merged

midagedev merged 1 commit into
midagedev:mainfrom
juliankang4:dgx-spark-nodes

Conversation

@juliankang4

@juliankang4 juliankang4 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

One commit, 0e655ae4, on main at acfb220a. #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 at 0e655ae4 first, 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 the nodes_scale gate of §9.9, clauses 1 to 7, with C3 of docs/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_nodes plans (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.
  • Each node's budget is the card plan of that node alone (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_need is that sum, moved out of violations_beside, which now reads it for PoolOver as 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.
  • The split counts ids, as if every id were used alike, and gives the same ids on every routed layer. No node pages an id while another pool has room. Past every pool, the paged ids go where the windows, fills included, stay lowest, and NodePlan::fills_apart names two remotes whose fills end up more than a tenth apart (the split does not balance the fills themselves).
  • The predictor reads node 0's card plan (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).
  • One rule for the link: in a layer, node 0 sends the handoff only to the remotes that hold one of the token's slots, and only those send rows back and wait for the link. The link term and the reported messages follow this rule. The NIC carries the rows of a token's slots on the remotes, one row a slot. A remote the token reaches returns at least one row for its one handoff, so the rows are always the busier direction. A layer waits for its slowest node, and the expected slowest node is summed exactly over the multinomial of a token's slots. The link's cost is LinkCost (λ), so it does not share a name with the Link seam 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.
  • The lines compare exact values wherever u128 holds them. The 50 % NIC line, the link's tenth and the gain compare a step kept exact over a token's slot outcomes, the NIC with a token's mean bytes kept a fraction. Only the serial term and each outcome's slowest window round down to whole picoseconds, so a step reads less than L + 1 ps low for L routed layers, under 49 ps for Qwen3.8 [derived]. The remotes' fills compare whole sums. The values the plan reports (Prediction, NodeLoad, IdleWhy) are rounded down to whole ns and ‰.
  • A node whose own budget already passes its pool is named idle for that (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.
  • The NVMe fills of C3 are inputs (Fills: the arena's hit rate and one fill), since S7 and S10 own those terms.
  • Refusals by name: a node count the fabric does not join, a node with no unified pool, a budget made on another node, inputs the plan is undefined at (a read rate, NIC, prompt rate or row width of 0, a ruler outside 1 to 1000 ‰, no routed bytes, a hit rate past 1000 ‰), a row table read from the NVMe tier, a used node past its pool, and any of the node plan's own sums past its integer. The card plan refuses a machine of several nodes (PlacementError::Nodes) instead of planning node 0 alone.
  • The predictor counts a token's slot outcomes, experts^k, in u128 picoseconds. Past that it refuses the model by name (NodesError::Overflow); 512 experts at top-k 15 (512^15 = 2^135) is the test's case.

crates/placement/src/placement/workstation.rs adds a Spark's pool, read rate and NIC (GB10_POOL, GB10_READ at §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 adds gb10(free), the GB10 built through spec_of_device as the census reads one, and spark_nodes(n, layers, fabric), which panics by name at n = 0. The Spark machine of qwen38_cards now uses gb10 too, 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. Where nodes_scale holds 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_scale runs 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 of plan_with widens 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_today holds 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:

  • Held whole, Qwen3.8 fits, and node 0's planned bytes equal today's pool need (86,233,865,056 B at A low). V4.1 and GLM do not fit, and both plans refuse them with the same bytes: the node plan's PoolOver equals today's PoolOver (V4.1 at A low: 269,832,964,224 B against a pool of 115,284,164,096 B).
  • Paged, node 0 holds today's terms before their NVMe split and every id its pool has room for, and pages the rest. This half is not byte for byte: today's plan of one Spark does not page V4.1 or GLM (open question 2), so there is no paged plan to compare with until 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, and a layout comparison needs the id map that the tier server unit of dgx-spark: P5 goes ahead with generations only on the link #127 adds to the node plans.
  • The step and node 0's window are the shape's bytes over the read rate, which the test derives from the shape, not from the predictor.
  • The terms follow the plan: a BF16 head and shared expert count their f32 bytes on the card, and an engram table on the host counts its gathered rows. On the NVMe tier the table is refused by name. On the Qwen3.8 file's table, the PLE table on the NVMe tier is refused by name, and on the host it adds exactly its gathered rows to the serial term.
  • A 4 GiB host reserve on node 0 raises node 0's bytes by exactly 4 GiB.

Row tables on the NVMe tier

This is the fix the maintainer asked for on #127. Terms::of counted 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_read now 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"), so Terms::of gives no terms for that plan. Once row_table_tier moves 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_today holds 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 turns n1_is_today red.

Rounding before a line

This is the review's must-fix. nic_busy divided 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 ‰), so nic_bound returned None for a plan past the line. nic_busy now 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_apart compared the remotes' fills in whole thousandths a token. It now compares the sums before the division by the expert count.
  • plan_nodes compared the link's tenth and the gain on steps in whole nanoseconds, with the link term as the difference of two rounded steps.
  • The step those lines read was itself rounded. Each layer's expected window was divided by the slot outcomes and rounded on its own, and inside a window each slot's read, each row and each fill was rounded to whole picoseconds first: up to about 2 + 2k ps a layer, about 1 ns for Qwen3.8 [derived]. The step now keeps the layers' sums over the outcomes (experts^k) as whole picoseconds and a remainder (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.
  • The decode half of the NIC line divided by that rounded step. It now compares twice node 0's NIC time with the exact step by continued fractions, so no product passes u128, and NodeLoad::nic_permille is the exact share rounded down.
  • read_balance held 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 in link_share and never_slower now read the exact steps (step_ps, exposed_ps).

nic_bound names 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_bound names 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, and nic_bound() and idle() 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 of 26e3c63a on main acfb220a), each failed but one:

Case At fcdfd4a5
the review's case, with the model's tensor bytes 10⁹ times larger so that the budgets give [1, 2]; the row width, rates, prompt rate and ruler are the review's (nic_not_max) nic_bound None and node 0 at 428 ‰, where 4/7 is 571 ‰
the same split with ids of 666,666,664 B, a read rate of 10¹⁰ B/s, a NIC of 16 B/s and λ 2,000 ps: the decode share is exactly 1/2, on the line (nic_not_max) None, but node 0 at 375 ‰, where the share is 500 ‰
five ids at top-k 1 on three Sparks with room for one id each, hit rate 999 ‰: node 1 pages one id, node 2 none (fills_balance) fills_apart None: 0.2 and 0 thousandths both read 0
three ids of 1,000 B at top-k 1, a read rate of 10¹¹ B/s, λ 17,000 ps (link_share) Gain { without_ns: 50, with_ns: 55 }, where the link exposes 5,666⅔ of 55,666⅔ ps (10.2 %)
three ids of 19,199 B at top-k 1, 10¹² B/s, λ 32,000 ps: the link passes a tenth of 106,661⅔ ps by ½ ps (link_share) LinkShare { link_ns: 11, step_ns: 106 }, where the link is 10,666⅔ ps
three ids of 5,000 B at top-k 2, a read rate of 10¹² B/s, a ruler of 50 ‰ (never_slower) owned [2, 1], where the gain is 7.4 %, under twice the ruler
the same at 10¹⁴ B/s and a ruler of 38 ‰: 300 and 277⁷⁄₉ ps, a gain of 7.41 %, under 7.6 % (never_slower) passed, because both steps read 0 ns; red under the mutant that rounds each layer's expected window down (below)
three ids of 10⁹ B at top-k 1, 10¹⁸ B/s, two nodes of one id's room each, split [2, 1] (read_balance) the check held, where the windows of 666 and 333 ps are a third past the optimum's 500 ps: all three read 0 ns

I ran one probe of all 48 cells at fcdfd4a5 and at 0e655ae4. 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]:

Clause §9.8 table Predictor Why
4 link_share N = 4, λ 70, A low the same cell a third switched node exposes 2.270 of 22.351 ms (10.2 %), so the plan uses two; a ring of three exposes 2.229 of 22.311 ms (9.99 %) and keeps three
5 nic_not_max N ≥ 3 at S₁ 3,000, every model V4.1 and GLM at N ≥ 2, except three of V4.1's four cells at N = 2; never Qwen3.8 node 0 takes the rows of the remotes' share as the split sets it, where §9.8 assumed an even split. In the cells that fire the busiest NIC runs from 500 to 941 ‰ for V4.1 and from 531 to 1,007 ‰ for GLM; Qwen3.8 stays at or below 465 ‰. GLM at N = 2, λ 25, A low splits its ids [111, 177] and node 0's NIC is busy 551 ‰. V4.1 at N = 2, λ 25, A low sits on the line at 500 ‰
7 never_slower N = 3 at λ 70 with A high; N = 4 in every cell the same cells, but N = 4, λ 70, A low, where criterion 2 names the node first

Clause 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

  1. The ruler. RULER is 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, RULER changes and the pinned idle cells move. never_slower already shows what a wider ruler does: at 40 ‰, Qwen3.8 leaves node 1 idle at N = 2, λ 70.
  2. One Spark and a model past its pool. With paging allowed, the node plan at N = 1 pages V4.1 and GLM past node 0's pool, as §9.8's table expects ("V4.1 (pool only)" at N = 1). Today's plan_with does not: it gives the GB10's card the whole pool, and expert_nvme_tier at the pool's room then refuses both with HostRoomFloor unless 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 with plan_with today; 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?
  3. A paging node's arena. A budget is the card plan before its NVMe split, so a node that pages carries no arena and no run-ahead window in its budget. The unified tier's per-node books (§9.2) size those. Until then, a paged node plan can pass its pool by them. Held whole, nothing pages.
  4. Expected sets. §9.9 asks for expected sets from a derivation script under 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, and nodes_scale pins 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).
  5. The card plans the budgets come from. In the gate, node 0's budget comes from today's plan of the model and a remote's from the plan of 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.
  6. The split counts ids, as if every id were used alike. The router's counts, which would spread the hot ids, reach the split only when the engine calls it. The predictor's step is decode at n = 1; decode at n = N (§9.8's workload) is not predicted yet.
  7. The shape is the smallest the clauses read. Card has no node field, LinkCost (the design's Link) has no rail, the read rate and NIC sit on Nodes for all nodes alike, and the fabric is on Nodes, not on Machine. Each remote node's card is node 0's card spec, since all Sparks are alike here.
  8. The synthetic Qwen3.8 totals about 82 GB, not §9.7's 114 GB, because its PLE table is not modeled in the shape, so the "fits one, marginal" boundary is not exercised. The token's slots land on the nodes independently of each other, as §9.7 counts them.

Code shape

  • No second copy. The node budgets read the card plan's pool sum through Plan::pool_need, the method PoolOver reads; Terms::of reads node 0's card plan through card_bytes and per_expert, not per-model figures; the tests build every GB10 through spec_of_device.
  • Model code holds only architecture facts. No model file changes.
  • Dead code is gone.
  • No silent failure. Undefined inputs are 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.
  • Comments state what is true now.
  • No relaxed gate. A new gate, each clause red first (below). §9.9's clauses, expected sets and mutant list stay as written; where the gate holds a clause differently, §9.9 says how and §10 lists it.
  • cargo fmt and cargo clippy are clean on what I touched; the lint count did not go up.

Duplication found or removed (path:line), or "none": the pool sum in Plan::violations_beside (crates/placement/src/placement.rs) is now Plan::pool_need, which the node budgets read too; the GB10 device literal in qwen38_cards's Spark machine is now workstation::gb10.

What I ran

At 0e655ae4, on a Mac (Apple M5) through just, against this PR's base acfb220a as the scope base:

$ just mac-static acfb220a
mac-static: fmt-check rc 0
mac-static: lint rc 0                  (41 warnings, the ratchet's 41)
mac-static: combos rc 0                (0 shapes run, 48 skipped by scope, 27 green in the ledger)
mac-static: check-arch, check-comments, check-defaults, check-levers, check-loads, check-ptx-names,
            check-recipes, check-records, check-rustflags, check-unsafe, check-waits: rc 0
mac-static: ok
$ just mac-test
mac-check: test rc 101; 12 crates ...; 1149 passed, 1 failed, 177 ignored in 43 test binaries
                                       (bloomery-placement 154 passed, nodes_scale's 9 among them)

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's a_plain_target_with_no_draft_beside_is_the_family_path under the default TMPDIR, the same failure described below for 26e3c63a and reported in #137; this PR does not touch crates/refset. On a second Mac (Apple M4 Pro), cargo test -p bloomery-placement passed 154.

The cases of "Rounding before a line" ran on fcdfd4a5's nodes.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 main a7ed8b58, on the Mac (Apple M4 Pro):

$ just mac-static upstream/main
mac-static: fmt-check rc 0
mac-static: lint rc 0                  (41 warnings, the ratchet's 41)
mac-static: combos rc 0                (13 shapes run, 0 red, 35 skipped by scope, 7 green in the ledger)
mac-static: check-arch, check-comments, check-defaults, check-levers, check-loads, check-ptx-names,
            check-records, check-rustflags, check-unsafe, check-waits: rc 0
mac-static: check-recipes rc 1
$ TMPDIR=/private$TMPDIR just mac-test
mac-check: test rc 0; 12 crates ...; 1150 passed, 0 failed, 177 ignored in 45 test binaries
                                       (bloomery-placement 154 passed, nodes_scale's 9 among them)

check-recipes was red in two tools/ self-tests, which this PR does not touch: the gate-batch self-test, and one gpu-gate case that waits on a batch hold ("a hold released while the run waits", rc 75 where it wants 0). On main at a7ed8b58, in a detached worktree, the same step was also rc 1 on the gate-batch self-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-test ran with TMPDIR=/private$TMPDIR. Under the default TMPDIR (/var/folders/..., a symlink into /private/var), bloomery-refset's a_plain_target_with_no_draft_beside_is_the_family_path fails at this commit; this PR does not touch crates/refset.

Each clause red first. One mutant at a time at 0e655ae4, then cargo test -p bloomery-placement --lib nodes_scale. The first column names §9.9's mutant where the row is one:

§9.9 Mutant (its exact form) Red
M1 node 0's pool charged every node's bytes (over() sums held_bytes of every node at node 0) fills_balance, link_share, messages_o_n, never_slower, nic_not_max, pool_per_node, read_balance
M2 only nodes 0 and 1 planned (for j in 1..n.min(2)) fills_balance, link_share, messages_o_n, never_slower, nic_not_max, pool_per_node
M3 the split takes the first node with room (if best.is_none()) fills_balance, link_share, never_slower, nic_not_max, read_balance
M4 λ = 0 in the remote term (round_trip_ns().map(|_| 0u64)) link_share, messages_o_n, never_slower, nic_not_max
M5 no prompt term on the NIC (Some([decode, (0, 1)])) messages_o_n, nic_not_max
M6 a post on node 0's window, N = 1 included (+ POST_NS * 1000 on node 0's base) fills_balance, link_share, n1_is_today, never_slower, read_balance
M7 ruler fixed at 20 ‰ (2 * 20u128) never_slower
M7 ruler fixed at 10 ‰, the box's (2 * 10u128) never_slower
one message a slot in the report ((_, 0) => 2 * t.experts_used * remotes) messages_o_n
one post a slot in the cost term (link.post * (remote_slots - 1) for link.post * (reached - 1)) link_share, never_slower
fills out of the windows and the split (checked_add(fills * 0)) fills_balance
pages while a pool has room (full.contains(&false) && self.workload.whole) fills_balance, nic_not_max, pool_per_node, read_balance
a budget without the host's need (cards.checked_add(0 * host)) all nine
a node's bytes without its budget (b.checked_add(0) in held_bytes) n1_is_today, pool_per_node
the NIC's rows at an even split (t.experts * (m - 1) / m and t.experts / m) messages_o_n, nic_not_max
a token's mean NIC bytes rounded down (/ e * e on the bytes in nic_busy) nic_not_max
the remotes' fills compared in whole thousandths (/ experts before the comparison in fills_apart) fills_balance
the link share and the gain read in whole nanoseconds (/ 1000 * 1000 on both steps and the link in plan_nodes) link_share, never_slower
each layer's expected window rounded down before the step's sum (sum / of * of in step_ps) link_share, never_slower, nic_not_max
the NIC's decode line on the step's whole picoseconds (Ps { part: 0, ..step } in nic_bound) nic_not_max
a window's reads, rows and fills rounded down apart (each over its own rate in layer_ps) none (below)
terms at file bytes on a card (Format::Card(_) => Ok(r.read_bytes)) n1_is_today
a table on the NVMe tier counted as no read (the refusal dropped) ((Device::Nvme, _) => Ok(0)) n1_is_today
a node needed where a pool is over (the old rule) (if !now.over()?.is_empty()) fills_balance, pool_per_node, read_balance
a node needed whatever its own room (drop && now.room_ids(j) > 0) pool_per_node
over() reads every node, idle ones too (for node in 0..self.owned.len() in over()) pool_per_node
a node whose own budget passes its pool is tried (the Pool check dropped) (drop the fixed > pool check in plan_nodes) pool_per_node
idle list from the loop only (no fallback reason for a node the final split empties) pool_per_node
a prompt rate of 0 accepted (drop the prompt_rate == 0 refusal) named_refusals
a remote the token misses still waits (drop .filter(|&(_, &c)| c > 0) in layer_ps) fills_balance, link_share, messages_o_n, never_slower, nic_not_max, pool_per_node, read_balance
the prompt's node 0 FFN without Sh (no shared bytes on node 0's prompt FFN) nic_not_max
the pools' room summed in u64 (room() summed as u64) read_balance
the optimum's sum in u64 (the optimum's share sum as u64) none (below)
idle nodes reported with their budget (for j in 0..self.owned.len() in predict) pool_per_node
window_ns reported as 0 (window_ns: 0 * ...) n1_is_today
nic_‰ reported as 0 (nic_permille: 0 * ...) messages_o_n, nic_not_max
the link share read on the plan without the node (now.exposed_ps(a) and > a for next's and b) link_share
a pool past u64 planned (the plan's refusal dropped) (drop machine.pool_bytes()? in plan_rule, placement.rs) named_refusals

Two 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_balance among them, and the even split now also turns messages_o_n red. "window_ns reported as 0" no longer turns read_balance red: that test now reads the windows in picoseconds from the plan and has a new case at 10¹⁸ B/s. n1_is_today still pins the reported field. The fallback mutant ("idle list from the loop only") is red through a case on three Sparks in pool_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 in link_share from the old "at most 3"; the count is a tighter pin of the predictor's cell.

The cases the revision at 26e3c63a added, red at its previous head d29e9aec:

Case At d29e9aec
node 0 pages beside a remote whose own budget passes its pool (pool_per_node) PoolOver { node: 1, need: 121698955264, pool: 115284164096 }: the machine was refused, though leaving node 1 idle plans
the same remote while node 0 holds the model: its reason (pool_per_node) Gain { without_ns: 24239786, with_ns: 24239786 }, not the pool
one Spark whose pool reads u64::MAX bytes (named_refusals) today's plan accepted it, and NodeBudget::of panicked, "attempt to add with overflow"
the synthetic engram table on the NVMe tier, and the Qwen3.8 file's PLE table there (n1_is_today) Terms::of counted the table as no read
a remote with no room of its own, taken while node 0 pages and then emptied by a third node's room (pool_per_node) green; it is the case that turns the fallback mutant red
Qwen3.8 at N = 4, λ 70, A low uses exactly two nodes, not at most three (link_share) green

The cases added before that were red at 68d67a38, each in a test of its own that I then folded into the clauses:

Case At 68d67a38
a BF16 head and shared expert, an engram table on the NVMe tier and then on the host (n1_is_today) the step read 25,691,464 ns where the plan's own bytes take 29,281,966 ns
the Qwen3.8 file's row width (n1_is_today) 320, not 2560
a remote whose own budget passes its pool while node 0 holds the model (pool_per_node) PoolOver { node: 1, need: 121698955264, pool: 115284164096 } for a node the plan does not use
node 0 pages while node 1's pool has room (pool_per_node) owned [512, 0], node 1 named idle for its gain
a node the final split gives no id (pool_per_node) idle []
two pools whose ids pass u64 (read_balance) a panic, "attempt to add with overflow"
node 0's prompt at the split's own shares, owned [0, 2] (nic_not_max) 654 ‰ where the split's prompt gives 444 ‰
top-k 1 on four Sparks, the step and node 0's NIC by the handoff rule (messages_o_n) step 198,775,547 ns against 198,690,046 ns
a prompt rate of 0 (named_refusals) accepted

λ = 0 leaves read_balance green, as it should: both the windows and the optimum move with the remote term.

Earlier heads, each with its own logs:

  • d29e9aec (3bb8a15d rebased onto 80c17bbd, the same patch): mac-static upstream/main rc 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-recipes was rc 1, and also rc 1 on main's control at 80c17bbd (the gate-batch chain item that stops at rc 124). mac-test: 1149 passed and 1 failed, bloomery-refset's a_plain_target_with_no_draft_beside_is_the_family_path under the default TMPDIR (above); with TMPDIR=/private$TMPDIR, bloomery-refset passed 168.
  • 3bb8a15d: mac-static upstream/main rc 1 on check-recipes alone; 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 2e7824f3 and at #94's 83205a05. The placement tests, the plans and the gate ran at ee98dcfd against main at 9ab442ca.

$ just contrib-check --tier t1            # this PR, and the one under it, at 2e7824f3 and 83205a05
total=96 red=1
$ cargo test -p bloomery-placement --locked
test result: ok. 154 passed; 0 failed

T1's one red step, on both, was check-recipes: main's tools/mac-remote.sh --self-test failed 4 of its lock checks on an aarch64 Linux host, as #94 describes, and main's bd29e9c9 fixes it. This PR does not touch tools/, and every other step was green.

Today's planner changed in two places then (Plan::pool_need and the refusal of a machine with more than one node), so I compared single-node plans with main at ee98dcfd. bloomery-serve --plan with the host room fixed (BLOOMERY_HOST_ROOM=124000000000) printed the same bytes on this branch and on main for Qwen3.6-35B-A3B Q4_K_M, Qwen3-30B-A3B Q4_K_M and Qwen3.8-Flash-Next UD-Q4_K_XL, three runs each. Without the fixed room, the plans differ only in the free memory each run reads. gate-gpu-qwen3-serve without its --images clause (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-box selects 140 of 156 gate recipes, since every gate reads crates/placement (a static walk, no dep-info); the runs above are the ones that need no second card and no unpublished input. AGENTS.md runs just stage-gpu-load-v41 by name for a change to the placement and workstation code, which now lives under crates/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.md lists:

  • Card and driver: the runs' environment record reads NVIDIA GB10, 595.84, 12.1 and Linux 6.17.0-1032-nvidia aarch64; the checks ran in the dev container.

  • Commits: T1 at 2e7824f3 (this PR then) and at 83205a05 (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 at ee98dcfd, against main at 9ab442ca.

  • Model files: Qwen3.6-35B-A3B-Q4_K_M.gguf, Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf and Qwen3.8-Flash-Next-UD-Q4_K_XL-00001-of-00004.gguf for 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 18099 and exited 0. The gate ran the recipe's command without --images, and exited 0 on each tree:

    BLOOMERY_MODEL=qwen3moe ./tools/box.sh 'cargo oxide build --arch sm_86 -- -p bloomery-gpu-gates --features glm5next,clef,mimo2,vision --release --bin bloomery-serve --bin gate_qwen3_serve && cargo oxide build --arch sm_86 -- -p bloomery-gpu-gates --features gpu --release --bin generate_qwen3moe && D=target/qwen3-serve-gate-noimg && mkdir -p $D && Q36=$(sed -n "s/^MODEL=\${BLOOMERY_REF_MODEL:-\(.*\)}$/\1/p" tools/ref/models/qwen35moe.sh) && test -n "$Q36" && BLOOMERY_GATE_CARD=any bash tools/gpu-gate.sh gate_qwen3_serve --model "$BLOOMERY_REF_MODEL" --model "$Q36" --dir $D'
    

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 초안 리뷰는 제가 직접 했습니다.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread crates/placement/src/placement/nodes.rs Outdated
Comment on lines +475 to +476
} else if nodes.ruler_permille == 0 {
Some("a ruler of 0")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reject rulers above 1000 permille before the gain product can overflow u128.

Suggested change
} 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")

Comment thread crates/placement/src/placement/nodes.rs Outdated
nic_permille,
msgs_per_layer: match (on, j) {
(false, _) => 0,
(true, 0) => 2 * remotes,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A token with top-k = 1 can select experts on at most one remote; cap the message bound by experts_used.

Suggested change
(true, 0) => 2 * remotes,
(true, 0) => 2 * remotes.min(t.experts_used),

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread crates/placement/src/placement/nodes.rs Outdated
Comment on lines +250 to +252
(Role::RoutedExperts | Role::EngramTable | Role::Unread | Role::Unused, _) => {
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On unified nodes, host engram tables contribute pool reads that these terms omit; refuse them until terms receive their planned tier.

Suggested change
(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,

@juliankang4 juliankang4 changed the title placement: a model's plan over several unified nodes (dgx-spark.md §8), and nodes_scale on one to four Sparks placement: a model's plan over several unified nodes (dgx-spark.md §9), and nodes_scale on one to four Sparks Oct 13, 2026
@juliankang4

Copy link
Copy Markdown
Collaborator Author

Rebased onto main as one commit, fcdfd4a5, now that #94 is in. The EngramTable fix from the #127 answer is in it: a row table left on the NVMe tier is refused by name, and the body's new section "Row tables on the NVMe tier" says why I chose the refusal over adding a fill term. The body's results and mutant table are updated for this head.

@github-actions github-actions Bot added dgx-spark The DGX Spark (aarch64, GB10) port documentation Improvements or additions to documentation and removed gpu The card engine: crates/gpu*, crates/app model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja needs-gpu Changes code a card must check, and carries no gate run on a card labels Oct 13, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@juliankang4

Copy link
Copy Markdown
Collaborator Author

Fixed in 0e655ae4. nic_busy now keeps a token's mean NIC bytes as a fraction over the expert count until the 50 % comparison, and it reads the rows alone, because a remote the token reaches returns at least one row for its handoff. nic_not_max holds the counterexample (three ids at top-k 1, owned [1, 2], the model's tensor bytes scaled by 10⁹ so the budgets give that split) and asserts NicBound { node: 0, busy_permille: 571 }; at fcdfd4a5 it read None and 428 ‰, and putting the rounding back turns it red. The same rounding before a line was also in fills_apart, in the link-share and gain checks of plan_nodes, in the step those checks and the NIC's decode line read, and in read_balance's check. The lines now compare exact values and read_balance compares picoseconds, with cases in fills_balance, link_share, never_slower, nic_not_max and read_balance. What still rounds is the serial term and each outcome's slowest window, down to whole picoseconds, so a step reads less than L + 1 ps low for L routed layers (49 ps for Qwen3.8) [derived]; an exact step would need a denominator of read × NIC × experts^k, which passes u128. "Rounding before a line" in the description has the details.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@midagedev
midagedev merged commit 33fb317 into midagedev:main Oct 14, 2026
7 checks passed
@juliankang4

Copy link
Copy Markdown
Collaborator Author

Both non-blocking points are in #163, on top of main now that this is merged.

  • Combined window rounding (8d2ac015): nic_not_max gains a paged case on two nodes, where each window carries a read of 64.95 ps and a fill of 19/20 ps. The step is 325.7 ps, the predictor reads 324 ps, and the NIC line sits at 323.5 ps, 2.2 ps under the step and so more than the 2 ps a one-layer step can read low. Rounding the read and the fill apart reads a step of 323 ps and turns nic_not_max red.
  • R12 (d51ec11f): plan_nodes now calls checked_id_bytes for the input checks and idle_why for the decision on each node. It is a move in its own commit. The checks keep their order and the comparisons are the same expressions.

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

Labels

dgx-spark The DGX Spark (aarch64, GB10) port documentation Improvements or additions to documentation placement Placement planning: crates/placement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants