Repository navigation
placement, tools: nodes_scale holds the node plans to cells tools/flow/nodes_scale.py derives apart from the planner - #166
Conversation
There was a problem hiding this comment.
Verdict: MUST_FIX: the oracle uses a remote with zero room and zero gain, and stores an unused field.
Must fix
1. Apply link and gain checks when a remote has zero room
tools/flow/nodes_scale.py:359 bypasses both objectives whenever the existing pools page, even when the remote adds no room. The fixture at tools/flow/nodes_scale.py:584 has four experts, top-k 1 and rooms [3, 0]. Both steps are 10 seconds, yet cell() uses the remote and the self-test requires it. This is a reachable kind A failure: the oracle accepts a zero-gain node contrary to criterion 7. The planner requires positive remote room for its capacity exception at crates/placement/src/placement/nodes.rs:710.
Correct this before the wrong self-test expectation becomes the accepted contract. Replace the capacity branch with:
if c.room(m) < E and c.rooms[1] > 0:
m, now = m + 1, nxt
continueReplace the checks at tools/flow/nodes_scale.py:585 with:
expect("a room-0 remote has paging bounds and fits its fixed-byte budget",
c3.rooms == (3, 0) and not c3.pool_idle and c3.limits(1) == [(0.75, 1.0), (0.0, 1.0)])
_, m, idle, _, _ = cell(mc, dict(sh, fixed1=95), 2, 0, 10)
expect("a remote of room 0 must gain twice the ruler", m == 0 and idle == [(1, "gain")])2. Remove the unused node-count field
tools/flow/nodes_scale.py:236 stores self.n, but it has no reader. The constructor uses the local n to choose the hop. This is an unneeded element, kind B. Deleting it requires one line and no caller changes:
self.mc, self.sh = mc, shChecked
Duplicates: the base contract §9.9 requires a separate derivation; no duplicate finding retained.
Complexity: the changed Rust module is test-only; no runtime hot path changes.
Silent failure: zero room and fixed bytes beyond a pool have explicit branches; the zero-room branch above skips the objectives.
Tests: the existing fixture reproduces the zero-gain selection. With the proposed fixes applied in memory, the full Python self-test and generated-table check pass. Without the room guard, the corrected fixture fails.
Gates: the fixes preserve the generated cells and both checked doc tables. All three inline suggestions target added lines and compile together.
Escape catalog: no pattern matched. No ledger lessons are in force.
Notes
Rust tests were not rerun during validation.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
| idle.append((j, "pool")) | ||
| continue | ||
| nxt = c.plan(m + 1, c.link) | ||
| if c.room(m) < E: |
There was a problem hiding this comment.
A remote with zero expert room adds no resident capacity and must pass the link and gain objectives; also correct the room-zero self-test expectation.
| if c.room(m) < E: | |
| if c.room(m) < E and c.rooms[1] > 0: |
| expect("a remote of room 0 is no pool's idle node: it takes paged ids where every pool is full", | ||
| c3.rooms == (3, 0) and not c3.pool_idle and c3.limits(1) == [(0.75, 1.0), (0.0, 1.0)]) | ||
| _, m, idle, _, _ = cell(mc, dict(sh, fixed1=95), 2, 0, 10) | ||
| expect("a remote of room 0 the pools need is used", m == 1 and idle == []) |
There was a problem hiding this comment.
This fixture has equal one-node and two-node steps, so a remote with zero room must be idle for gain after adding the room guard.
| expect("a remote of room 0 the pools need is used", m == 1 and idle == []) | |
| expect("a remote of room 0 must gain twice the ruler", m == 0 and idle == [(1, "gain")]) |
|
|
||
| class Case: | ||
| def __init__(self, mc, sh, n, lam): | ||
| self.mc, self.sh, self.n = mc, sh, n |
There was a problem hiding this comment.
The stored node count has no reader; the constructor only needs the local n.
| self.mc, self.sh, self.n = mc, sh, n | |
| self.mc, self.sh = mc, sh |
9e5429c to
c7eaa01
Compare
|
Both findings hold, and both are fixed in
|
There was a problem hiding this comment.
Verdict: MUST_FIX: Remove two added parameters that no caller varies.
Must fix
1. Remove the unused path and ruler parameters
tools/flow/nodes_scale.py:166 and tools/flow/nodes_scale.py:435: The three load() calls at lines 514, 643 and 732 use INPUTS. The four where() calls at lines 454–457 use the enclosing r; only rate varies. The two parameters add configuration the change does not use. This is kind B under “fewest elements”.
Remove them while every caller already uses the fixed values. No caller needs an edit.
Replace load with:
def load():
try:
with open(INPUTS, encoding="utf-8") as f:
text = f.read()
except OSError as e:
raise Refused(f"{INPUTS}: {e.strerror}") from None
return inputs(text, INPUTS)Replace the nested where in blocks with:
def where(model, pick, rate=s1_hi):
"""The cells `pick` names, by N; an N alone is every λ and A at it."""
hits = []
for n in range(1, 5):
each = [(lam, [a for a in ("low", "high") if pick(at[(model, a, n, lam, r, rate)])]) for lam in (lo, hi)]
if all(len(ends) == 2 for _, ends in each):
hits.append(f"N = {n}")
continue
hits += [f"N = {n}, λ {lam}, A {' and '.join(ends)}" for lam, ends in each if ends]
return "; ".join(hits) or "never"Checked
Scope: both parameters are added in this full review. The revert and no-merge mirror exclusions do not apply.
Contract: base §9.9 requires the independent derivation. Removing the parameters preserves it.
Silent failure: load converts an OSError into a named Refused error; both replacements preserve input validation.
Tests: the current script and the exact replacements compile and pass all 55 self-test checks, including the cells fixture and documentation --check. The replacements were tested in memory.
Escape catalog: C1–C4 do not match the parameter finding. No ledger lessons are in force.
Notes
Rust tests were not rerun locally.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
c7eaa01 to
974c99d
Compare
|
Fixed in
|
There was a problem hiding this comment.
Verdict: MUST_FIX: one unneeded element in the gate (fires is d.fires under another name); no behavior defect found.
Must fix
1. fires() adds a branch that changes nothing
nodes_scale.rs:611 fires(d) returns side(d, Edge::Nic) on an edge cell and d.fires otherwise. side returns the derived d.fires, after it asserts that the derived side equals the pin (L573). So fires(d) == d.fires on every input. expected_sets already calls side on every edge (L754) and compares the pinned set to the derived edges, so the pin check does not need a second caller.
Cost: a reader takes fires for the place where the pinned side wins over the derivation. It is not. The bool return of side exists only for this helper.
Fix: delete fires, make side return () (drop the fires binding's let result, end with the assert_eq!), and at L1367 write:
if derived_at(cell, RULER, rate).fires {Non-blocking
⚠ Clauses 4 and 7 lost their local exactness: nodes_scale.rs:1590 never_slower now collects only Qwen3.8 at N = 2 and asserts it is empty, and link_share uses fired.contains(..) instead of equality. A planner change that adds a gain-idle or link-idle cell is still caught, but only by expected_sets. Say so in the two doc comments, or compare fired with the derived idle set, so each clause names its own failure.
⚠ + 2 in step_tol, nic_tol and the edge tol (nodes_scale.py:366, L407) is a band widening with no stated reason. Name it as the rounding slack of the picosecond sums in one comment line.
⚠ self_test calls check() (nodes_scale.py:718) outside the try in main. A bad inputs file ends --self-test with a traceback and exit 1, not the named refusal and exit 2 of the other modes. Catch Refused in main around self_test() too.
Checked
- Duplicates: 14 new items checked (
input,Derived,Edge,side,fires,past_tenth,idle_of,nic_bytes,nic_prompt, the script's functions). Dryer: 0 pairs. The TSV split inqwen38_cards.rs:154reads a different file; no shared owner worth a helper. One redundant element: finding 1. - Complexity: crapper flagged nothing.
- Silent failure: 6 sites followed (
inputparse, cells header and width, unknown shape, unknown edge, missing derived cell,SIDESwithout a pin). Each panics by name. The script refuses bad inputs with exit 2. - Tests: mutation check on
nic_not_max's new half-line case. A>to>=flip innic_boundturnsat(line)red; dropping theat(line - 1)branch turns it red.expected_setsgoes red on a one-id move in the split (per the PR's own mutant table, not rerun here). - Gates: no band, tolerance or
KNOWN_DIVERGENCEvalue moved. The new tolerances are checks beside the pins. The removed explicit lists in clauses 4, 5 and 7 moved into the cells file. - Hot path: none (test and tool code).
- Escape catalog: none matched (no shell added).
- Mac-only run: the placement tests and
--self-testran at9e5429cf, not at the head; the PR body says so.
Notes
- The PR body has no gate run on a card per
CONTRIBUTING.md(nojust gate-*output with both commit hashes). This does not change the verdict. - The gate mutants were not rerun at the head
974c99de. The author states this.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
…w/nodes_scale.py derives apart from the planner The script reads crates/placement/tests/nodes_scale_inputs.tsv (each row with its kind and source) and writes the 144 cells of nodes_scale_cells.tsv: per model, A, N, λ, ruler and S₁ the nodes used, why each other node is idle, the ids paged in all and per node, the step, the busiest NIC and node 0's messages a layer, each value with the largest change one id moved between two used nodes makes. It splits by §9.2's rule in real shares and builds each window from the count tuples of a token's slots, one slot at a time, with no code or method shared with nodes.rs. A value whose band holds a line (the NIC's half, the link share's tenth, twice the ruler) is named an edge. nodes_scale first holds its machine, shapes and budgets to the inputs, then every plan to its cell (expected_sets): the nodes used and idle exactly, the paged ids per node within one, the step and the NIC within their bands, and each edge on the side SIDES pins by hand. The exact used and idle sets of clauses 4 and 7 move into expected_sets; clause 5 takes its firing cells and clause 6 node 0's messages from the cells. dgx-spark.md §9.7 gains the (a) rows at the gate's inputs and §9.8 the table of where each criterion fires, both the script's --table; §9.9 names the script and §10 what it recomputes and what still uses the first inputs. §9.7's capacity rows at N = 1 and 2 take GLM's cells from plan_nodes on the file's header read and V4.1's at N = 1 from node 0's budget: the node plans refuse the V4.1 file by name, since node 0's card plan reads its engram tables from the NVMe tier, and §10 lists the fill term they lack.
974c99d to
4e48071
Compare
|
Fixed in
At |
There was a problem hiding this comment.
Verdict: CLEAN: No must-fix findings remain. The independent derivation preserves the existing cell pins and adds prediction-band and boundary checks.
Checked
Duplicates: §9.9 explicitly requires the independent verifier.
Silent failure: input parsing and domain checks produce named failures; missing Rust input rows, cells and edge pins panic by name.
Tests: Python --self-test passed, including the cells and documentation checks. Four in-memory mutants each returned 1: dropped remote post, overwritten probability, one-ruler gain threshold, and decode-only NIC demand.
Rust assertions inspected: expected_sets checks used/idle sets and prediction bands; link_share checks dropped-link witnesses; nic_not_max pins exactly half and just past half; messages_o_n checks node 0; never_slower rejects slower plans.
Gates: the embedded cells retain the base link, gain and NIC firing sets. Edge sides remain pinned in SIDES.
Escape catalog: no C1–C4 pattern matched the added code.
Performance: nodes.rs is unchanged; the diff changes tests, tools and documentation.
Notes
Rust tests were not rerun here. The PR reports 168 placement tests passing on an earlier commit and one refset failure. The original item counts and dryer/crapper results were not independently verified. No NVIDIA gate run with the required evidence is supplied.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
|
The review is done and the contribution check passed, so I'm merging this. |
What and why
Follows #95, now merged. This PR is one commit,
4e480715, on main at84f77b01. The runs below are ata515bc44on54ceee74; the rebase onto84f77b01left the patch as it was (git range-diff), and main changed no file undercrates/in between.docs/contrib/dgx-spark.md§9.9 asks for the gate's expected sets to come from a derivation script undertools/with its own self-test, so the pins have one derivation, and §10 asks that script to recompute the[derived]tables of §9.7 and §9.8. #95 pinned the predictor's own cells and left the script open (its open question 4). This PR addstools/flow/nodes_scale.py, which derives the cells from §9.7's model and §9.8's criteria with no code or method shared withnodes.rs, andnodes_scaleholds every plan to them. It recomputes §9.8's table and §9.7's (a) rows at the gate's inputs. The rest of §9.7 needs inputs the script does not have, so that part of the §10 item stays open (below). Part of #13 (P5).What the script derives, and what it takes as given
Derived for each synthetic shape (V4.1, GLM, Qwen3.8; A low and high), N = 1 to 4, λ 25 and 70 µs, at the gate's
RULERand S₁ 1,000 and 3,000 tok/s and at a 4 % ruler and S₁ 3,000 (144 cells):poolfor criterion 1,linkfor criterion 2,gainfor criterion 7);How it stays apart from the planner:
nodes.rsnodes_scale.pyThree definitions are shared on purpose, because they are what the gate pins, not a method:
1/(1−φ+φ/N), which this is at an even split with no Shshape()is the gate'sShape::bytes, because the shapes are the gate's inputsTaken as given, from
crates/placement/tests/nodes_scale_inputs.tsv: the machine (BW 208.8 GB/s, NIC 23.7 GB/s, a node's pool, λ, the hop, the post, the ruler, S₁), §9.7's model terms, and each node's bytes beside the routed ids (fixed0,fixed1). The last are what today's card plan sets for the shape, which the script cannot rebuild without repeating the card planner. Every row carries its kind (measured, derived or assumed) and its source. The script refuses an input outside its domain by name (top-k outside 1 to the experts, a ruler outside 1 to 1000 ‰, a zero rate, pool or row size, node 0's bytes past its pool, and so on). The gate first holds its own constants, shapes and budgets to these rows (expected_sets), so the inputs cannot drift from the gate.Tolerances, edges and pins
The planner splits whole ids, and the script splits real shares. So every value carries a tolerance: the largest change one id moved makes, between node 0 and any remote either way and between two remotes, within each node's bounds. Each band also carries two whole units (
SLACK) for rounding: the script truncates the band and rounds its value to the nearest unit, and the gate reads the planner's value rounded down, which together move the two under three units apart. The gate holds the planner's step and busiest NIC to that band, the nodes used and idle exactly, and the paged ids in all exactly and per node to one id.Where a band holds a line (the NIC's 50 %, the link share's tenth, twice the ruler), the cells file names an edge:
nic,link:<node>:<ppm>:<tol ppm>orgain:<node>:<ppm>:<tol ppm>. The derivation cannot decide such a cell, so the gate pins its side by hand inSIDES, the sides #95 already pinned, and also holds the planner's value to the band. An edge with no pin, or a derived side that differs from its pin, panics by name. Four rows are edges, two cells each at two (ruler, S₁) keys:RULERand at 4 ×RULER: the busiest NIC is 501 ± 5 ‰ against the 500 ‰ line, and criterion 5 fires.RULER, S₁ 1,000 and 3,000: node 2's link share is 99,956 ± 78 ppm against 100,000, and the node is used.No cell has a gain edge at these inputs.
What changed
tools/flow/nodes_scale.py(stdlib only): prints the cells,--writescrates/placement/tests/nodes_scale_cells.tsv,--checks it and the two doc tables, prints §9.8's and §9.7's tables (--table), and has a--self-test(below). Registered intools/check-recipes.sh.crates/placement/src/placement/nodes_scale.rs: a new clause,expected_sets, holds the inputs and then every plan to its cell, as above. The exact used and idle sets that clauses 4 and 7 listed move intoexpected_sets, and both clauses' doc comments say so; clause 4 keeps Qwen3.8 at N = 4, λ 70, A low among its link-idle cells, and clause 7 keeps the 4 % ruler case. Clause 5 takes its firing cells and clause 6 node 0's messages from the cells. Clause 4 adds that a node dropped for its link share would pass the tenth, read on theNodeIdlewhole nanoseconds: the link is under one more nanosecond and the step at least its own, so placement: a model's plan over several unified nodes (dgx-spark.md §9), and nodes_scale on one to four Sparks #95's case of 10 of 106 ns still counts. Clause 5 adds the line itself: a NIC share of exactly one half reads 500 ‰ and does not fire, and one just past it also reads 500 ‰ and fires.nodes.rsis unchanged.docs/contrib/dgx-spark.md, as additions to the maintainer's text: §9.7 gains the (a) rows at the gate's inputs and a paragraph naming what is not recomputed; §9.8 gains "where each fires at the gate's inputs" and the edge paragraph; §9.9 names the script, the cells file and the edge rule; §10 says what the script recomputes and what stays open. §9.7's capacity rows for GLM at N = 1 and 2 and for V4.1 at N = 1 are struck through and corrected from the real files, and §9.7 says that the node plans refuse the V4.1 file (below).What stays open in §10
The script recomputes §9.8's "where each fires" table and §9.7's (a) rows at the gate's inputs. §9.7's one-node and NVMe rows, (b), (c) and N requests in flight keep their first inputs: they need ρ, the NVMe rate, a pipeline model, the union R(2) and a batched step, none of which is an input of the script. The §10 item stays open for those. §10 also gains an open item: a fill term in the node plans for rows read from the NVMe tier, which the V4.1 file needs (below).
V4.1 and GLM on two Sparks, from the files' headers
plan_nodeson node 0's card plan of each real file, a 115 GB pool a node, at 4,096 and 32,768 positions, with the test below. The runs were at8cb76e03, an earlier version of this commit with the samenodes.rs(What I ran, below). The GLM rows ran on a machine that holds that file. They give the same owned, room and paged counts, idle sets and pools as a first read atd29e9aec; only a predicted step or link time differs, by 1 ns in some cells (#95's later change to exact picosecond sums, rounded down).The machine does not hold the V4.1 file, and the test reads only headers, so the V4.1 read used the file's header bytes in files of the full size. The node plans refuse that file at every N, before
plan_nodesruns. Node 0's card plan leaves V4.1's twoengram_embdtables (84.5 GB) on the NVMe tier, a token reads 5,280 B of them, andTerms::ofrefuses a row read from the NVMe tier by name (blk.1.engram_embd.weight). That refusal is #95's fix for #127, for the reason #95's section "Row tables on the NVMe tier" gives. The first read atd29e9aeccame before it and counted those reads as no bytes, so its V4.1 plans at N = 2 and 3 left a gathered NVMe read a token out of the step, and this PR does not use them. §9.7 now says that the node plans refuse the file, and §10 lists the table fill term that would replace the refusal.NodeBudget::ofdoes not go throughTerms::of. It gives node 0 a room of 154 ids at 4,096 positions and 153 at 32,768, so 230 and 231 of the 384 ids do not fit one Spark. That is the only V4.1 figure §9.7 takes from the real file.UD-Q4_K_XL(288 ids)Q3_K_M(384 ids)Terms::of; node 0's budget has room for 154 ids (4,096) and 153 (32,768), so 230 ids, 155.0 GB, and 231, 155.7 GB, do not fitTerms::ofTerms::ofThe synthetic shapes agree for GLM, which pages 0 at N = 2. The synthetic V4.1 shape has no engram tables, so the gate still plans it: §9.9's clause 2 and §9.7's (a) rows, which page 62 to 67 ids at N = 2.
The test that read the real files (not in this PR)
A throwaway integration test in
crates/model/tests/, run withSCRATCH_V41orSCRATCH_GLMset to the first shard andcargo test --locked -p bloomery-model --release --test scratch_nodes_real -- --nocapture. It builds node 0's card plan withplan_withon a GB10 spec, a remote's fromrouted_only, readsNodeBudget::ofandTerms::offrom them, and printsplan_nodesat N = 1 to 4, λ 25 and 70 µs, paged and whole. It asserts nothing. The V4.1 run stops atTerms::of, as above. A second throwaway test, with the sameSCRATCH_V41, prints how node 0's plan places each tensor role and readsNodeBudget::of; it gave the 84.5 GB, the 5,280 B and the rooms named above.What the derived table says
At the gate's inputs (
--table, now in §9.8):Nothing fires at S₁ 1,000. At a 4 % ruler, Qwen3.8 leaves node 1 idle at N = 2 at λ 70 only. §9.9's M7 names no λ; at λ 25 node 1 gains 10.1 % at A low and 8.4 % at A high, past the 8 % line, and is used.
What I ran into
None of this blocks the PR.
fixed0andfixed1are today's card plan of each shape. Deriving them would mean a second card planner. If the card plan's terms move (the KV, a reserve),expected_setsgoes red on the input row first and names it, before any cell moves.Code shape
nodes.rs; the three definitions it shares on purpose are named above. The gate reads its constants and budgets once and holds them to the input rows.expected_sets.--self-testamong them). The gate panics by name on a missing input row, a cells file of another header or row width, a cell it cannot find, and an edge with no pin.SIDES, clause 5 still says "exactly", and the bands are checks added beside them. No clause lost a check.cargo fmtandcargo clippyare clean on what I touched; the lint count did not go up.Duplication found or removed (
path:line), or "none": none.What I ran
On an Apple M5 (32 GB),
just mac-static 54ceee74andjust mac-testata515bc44, on main at54ceee74:check-recipesran three blocks at a time on this Mac (its roughly 35 blocks started at once overload four performance cores and miss the timing bounds inside several self-tests). An earlier run at5ba09875on a second Mac, with every block started at once, was red in four: the depth-serve runner's stub tests, the gate-batch self-test, the gpu-gate queue self-test and the review runner's self-test. Each passed when run alone on the same commit, and the run above is green in all of them.The one red is
bloomery-refset'sa_plain_target_with_no_draft_beside_is_the_family_path: the temporary directory comes back once with and once without macOS's/privateprefix. Neither this PR nor #95 touchescrates/refset, and on earlier runs the same test was red on the same Mac at main'sacfb220a,5c98cc70,ce422577andbaeedd73.The script at
a515bc44(check-recipesabove also runs its--self-test):FAIL-first, the script. One mutant at a time in a copy of
nodes_scale.py, then--self-test. All 31 go red on a hand-worked case; most also on--check:plan()'s balanceplan()'s balanceFAIL-first, the gate. One mutant at a time in the tree at
a515bc44, then thenodes_scaleclauses (cargo test -p bloomery-placement --lib nodes_scale) on the same Mac. Every mutant goes red; with none, all ten clauses pass.10 * link_ns > step_nslink_share, on #95's case of 19,199 B ids (10 of 106 ns)--checkrc 1 first)expected_sets(Qwen3.8 at N = 4, λ 70, A low: the nodes used and idle),messages_o_n--checkrc 1 first)expected_sets(V4.1 at N = 2, λ 25, A low: the derived NIC side is not the pinned one),nic_not_maxexpected_sets(paged per node)nodes.rs: after the split, one id from the first remote to node 0expected_sets(V4.1's step out of its band),pool_per_node,read_balance,link_share,nic_not_max,never_slower,fills_balancenodes.rs: after the split, one id from the second remote to the firstexpected_sets,pool_per_node,messages_o_n,fills_balancenodes.rs: the link line at 20 % (link.times(5) > b)expected_sets(Qwen3.8 at N = 4, λ 70, A low),link_share,messages_o_nnodes.rs: the gain line at one rulerexpected_sets(Qwen3.8 at N = 4, λ 25, A low),never_slower,nic_not_max,messages_o_nnodes.rs: no further post in the stepexpected_sets(V4.1's step at N = 3 out of its band),nic_not_max,messages_o_nnodes.rs:nic_boundfires on the floored permille (nic_permille(0, step) > 500)nic_not_maxnodes.rs: node 0 reports no messagesmessages_o_n(node 0's messages against the cell's)Hardware (if the change is for a machine)
No term was measured for this PR; the inputs are §9.7's and §9.1's, with their kinds in the inputs file. The checks ran on an Apple M5 (32 GB). The real-file reads ran on one DGX Spark (GB10, driver 595.84, Ubuntu 24.04.5, kernel 6.17.0-1032-nvidia, 128 GB unified,
nightly-2026-08-28) and used GLM-5.3-FlashUD-Q4_K_XL(six shards, 199.7 GB, sha256 checked) and the headers of V4.1-FlashQ3_K_M(nine shards, header bytes only, in files of the full size).AI help
AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.