Skip to content

model: Bolt's plan places routed experts on the host tier (Experts::{Card,Placed,Host}) - #371

Closed
midagedev wants to merge 5 commits into
mainfrom
bolt-b2-place
Closed

midagedev wants to merge 5 commits into
mainfrom
bolt-b2-place

Conversation

@midagedev

@midagedev midagedev commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

What and why

Bolt's (nemotron_h_moe) plan can now put routed experts on the host tier. This is B2 of the offload row of #328. The common planner already makes that plan per layer. Only the family wrapper refused it.

hybrid.rs gains Experts::{Card, Placed, Host} and PlanInputs::plan_with. plan() is plan_with(.., Experts::Card): same plan, same error text. Placed keeps each routed layer's id prefix [0, n_l) on the card as its budget holds and the rest on the host. Host keeps none on the card. The context, tier and card-count refusals hold under every arm. The load still refuses host experts: the body has no host tier yet (B3).

Release notes: internal — the plan half of Bolt's --n-cpu-moe equivalent; no load uses it yet.

API for B3

  • PlanInputs::plan_with(&machine, ctx, &levers, Experts::{Card, Placed, Host}), re-exported as nemotron_h_moe::place::Experts.
  • Machine of a placed load: workstation::placed_on(RTX_3090, 88, workstation::SCRATCH + <the body's own scratch>). The body's scratch is the GPU side's to size. This PR counts none.
  • The split is in the plan: plan.n_l[l]; each routed row is Card(0) ids [0, n_l) then Host/HostFile ids [n_l, E_l); plan.host_experts(l); plan.host.expert_bytes. Sum only over layers that have routed stacks: layer_experts(l) of any other layer is the model's largest count.
  • No new lever. n_l follows from the bytes: BLOOMERY_CARD_BUDGET and --place gate already reach plan through PlanLevers.

Pinned facts (the real 24 GB header, ctx 4096, body scratch not counted)

  • The 3090 holds all 13,824 routed experts: plan_gate(88) and placed_on(RTX_3090, 88, SCRATCH) both. Placed there is Debug-equal to Card.
  • Half the card's expert bytes (budget B = 15,668,690,944 on the placed machine): 7,010 of 13,824 on the card, 6,814 on the host (8,549,646,336 B).
  • 12 GiB card room (the 3060 host's card): 4,705 on the card (5,710,950,400 B), 9,119 on the host (11,345,461,248 B). Headroom 1,074,593,792 B. The per-layer sums equal the plan's totals.
  • All on the host: 17,052,794,880 B.
  • The spread rule gives each layer the same expert count, not the same fraction: at 12 GiB layer 1 holds 118 of 240 and layer 85 holds 117 of 496.

Gates and FAIL-first

Fixture clauses in nemotron_h_moe::spec (C1 to C4), nemotron_h::place (C5), and hw_nemotron_h_moe_plans_the_host_tier on the real header. The clauses of a split have one owner, tests/common/host_split.rs. Each mutant ran through tools/mutant-run.sh with --expect on the intended clause, then a clean run.

mutant red
M1 swap the Placed and Host arms C1, C2, C3
M2 the host refusal kept under every arm C2, C3; hw alone
M3 placement.rs host experts counted at the model's largest layer C2 (f), C1, C3, C4; hw alone
M4 Host calls plan_routed C3
M5 placement.rs expert budget without the margin C2 (a); hw alone
M6 the tier check only under Card C4
M7 Host refused when nothing is routed C5
M8 host segment bytes off by one C2 (e)
M9 the down stack one expert short C2 (c)

9 of 9 killed, clean runs green. M3, M5 and M8, M9 are temporary mutants of crates/placement; none is committed. Clause (d), the stacks' lists equal, is implied by (c): no mutant isolates it.

tools/gate-paths.tsv gets a row for crates/model/src/arch/hybrid.rs (second commit): before, narrow kept the full list (167 recipes, "no row maps it"); after, rows 111 -> 80 recipes (102 in all; the row holds 82 recipes since the merge of main). recipes.py check: 134 gate rows, rc 0.

GPU Evidence

Box: RTX 3090 and RTX A6000, driver 615.71.09, nvcc cuda_13.3, Linux 6.8.0-142. All jobs through the box queue.

Two trees. The jobs below ran at head 9b630364c on base 962401358 (origin/main then), except the lines marked [tip]. After them, origin/main moved to e195c9e6a; this PR merged it (e263e7f59) and fixed two call sites for main's Option<u64> set-aside (766b36916). The merge moved the hybrid.rs row of tools/gate-paths.tsv from 80 to 82 recipes. The 80-gate batch below ran on the pre-merge tree; the [tip] lines re-ran the four gates the merge could change.

[tip] at 766b36916, tools/gate-batch.sh --tier fixture --round-ledger, DONE total=4 red=2 wall=608s:

  • gate-nemotron-h-moe-meta job s2246 rc 0: lib 15 passed, hw 2 passed.
  • gate-nemotron-meta job s2251 rc 0: lib 36 passed, hw 1 passed.
  • gate-qwen3-plan rc 101, not from this PR: error[E0308]: mismatched types at crates/gpu-gates/src/bin/shared/qwen3moe_place.rs:660 and :1364 (reserve_bytes() is Option<u64> since bd378cd6e; that file's reserves: line came with ea4b6228c). Both files are byte-identical to origin/main e195c9e6a.
  • gate-gpu-qwen38-nodes-remote rc 101, not from this PR: Error: "BLOOMERY_TIER_NODES names 0 addresses, the gate one". The batch sets no remote node.

Second merge: origin/main 402f9abc6 (85410e2b4, plain push) brings #372 (686da9f16), which fixes the qwen3moe_place.rs compile error above. just check on the box at 85410e2b4: rc 0. gate-qwen3-plan and the two meta gates were not re-run at 85410e2b4; the row is still 82 recipes (recipes.py check rc 0).

  • gate-nemotron-h-moe-meta job s2221 rc 0: lib 15 passed, hw 2 passed (hw_nemotron_h_moe_header, hw_nemotron_h_moe_plans_the_host_tier).
  • gate-nemotron-meta job s2222 rc 0: lib 36 passed, hw 1 passed.
  • 80-gate batch (the new row): tools/gate-batch.sh --list <the row> --tier fixture --round-ledger → DONE total=80 red=5 tier=fixture skipped=0 wall=5478s laneA=3243s laneB=3403s laneX=2073s deferred=120 real_only=20 real_file=16.
    • gate-gpu-nemotron-h-moe-e2e job s2065, A6000, rc 0: PASSED: gate_nemotron_h_moe_e2e the captured step holds the entries its layer kinds derive; eager and replay give bit-identical logits; a reset rewinds the ids and the logits bits; the scan state carries; the fault word stays clean; …
    • gate-gpu-nemotron-h-moe-serve job s2067, A6000, rc 0: PASSED: gate_nemotron_serve the seat plans its default and says so, refuses a second slot, loads at the context it named, …
  • Nano on its real file (the fixture tier has no Nano fixture, rc 66 there): gate-gpu-nemotron-e2e job s2218, 3090, rc 0, PASSED: gate_nemotron_e2e …; gate-gpu-nemotron-serve job s2220, A6000, rc 0, PASSED: gate_nemotron_serve ….
  • Red in the batch, not from this change:
    • gate-gpu-qwen4exp-e2e (s2063) and gate-gpu-qwen4exp-mtp (s2070), rc 1: card leg plan: … a layer's count Some(255)..Some(256) (want 256 …) FAIL. The same two lines fail on origin/main e195c9e6a with none of our commits (jobs s2219 and s2223).
    • gate-gpu-gemma-embedding2-e2e (s2073), rc 1: /models/embeddinggemma-2/embeddinggemma-2-Q8_0.gguf: No such file or directory. The box has no such file.
  • Deferred by name in the fixture tier (20 real-only, 16 real-file; list in the batch's deferred.list): gate-gpu-p6, gate-gpu-e2e, gate-gpu-hybrid, gate-gpu-p8, gate-gpu-moe, gate-gpu-ds41-moe, gate-gpu-ds41-chain-ffn, gate-gpu-p10, gate-placement, gate-gpu-qwen3moe-router, gate-gpu-qwen35moe-verify, gate-gpu-qwen35moe-mtp, gate-gpu-qwen35-dspark, gate-gpu-minicpm-text, gate-gpu-clef-hidden, gate-gpu-qwen35-media, gate-gpu-qwen35moe-media, gate-gpu-clef-serve, gate-gpu-lev-serve, gate-gpu-qwen4exp-iqleg, gate-gpu-gemm, gate-gpu-gemma-embedding2-serve, gate-gpu-qwen3moe-e2e, gate-fixture, gate-gpu-head, gate-gpu-ds41-long, gate-gpu-ds41-draft, gate-gpu-ds41-chat, gate-gpu-nvtier, gate-gpu-qwen38-residency, gate-gpu-qwen3-serve, gate-gpu-p8b, gate-gpu-dspark-experts, gate-gpu-dspark-kv, gate-gpu-dspark-graph, gate-gpu-glm5next-serve.
  • ptx-scan --no-jit of gate_glm5next_e2e, gate_kquant, gate_swap identical to the base (just narrow): no kernel moved.
  • Static: cargo check -p bloomery-model --all-targets 0 warnings; clippy 12 warnings, all in other files; fmt-check and every tools/check-*.sh rc 0 except check-recipes.sh, where narrow-scan.sh --self-test (16 FAIL) fails the same way on origin/main's tools/.

Code shape

  • No second copy: the split's clauses are one file (tests/common/host_split.rs) read by the fixture gate and the real-file gate. The placed machine has no wrapper here; qwen3moe's machine and counted_arena_bytes are a separate lift.
  • Model code holds only architecture facts: hybrid.rs gains an enum and one function, no per-model branch. A file with no routed stacks plans the same under every arm (C5).
  • No silent failure: Card keeps its named refusal of host experts. No comment carries a date or an issue number.
  • No gate or lint was relaxed.

This PR was written by a bot (a Claude Sonnet 5.5 agent in the Claude Code harness) under the lead's direction.

🤖 Generated with Claude Code

midagedev and others added 2 commits October 17, 2026 03:40
…Card,Placed,Host})

The Nemotron-H families' common owner gains `Experts`: where a plan puts the
routed experts. `Card` is today's plan and keeps its refusal of any host expert
byte for byte; `Placed` keeps each routed layer's id prefix [0, n_l) on the card
as its budget holds and the rest on the host tier; `Host` keeps none on the card.
`PlanInputs::plan` is `plan_with(.., Experts::Card)`. The context, tier and
card-count refusals hold under every arm. The load still refuses host experts
until the body has a host tier.

Gates: the fixture's C1 to C4 in nemotron_h_moe::spec (the whole-card Placed
plan is the Card plan; a small card keeps each layer's prefix and hosts the
rest; the Host arm keeps no routed expert on the card; the refusals loop over
the three arms), the no-routed-stacks case in nemotron_h::place, and
hw_nemotron_h_moe_plans_the_host_tier on the real header. The split's clauses
have one owner, tests/common/host_split.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A change to hybrid.rs found no row, so `just affected --narrow` kept the full
list. The row is the 80 gates `recipes.py reach` names for the file; the gates
whose own target is the module (gate-nemotron-meta, gate-nemotron-h-moe-meta
and the rest) stay outside it, as the table's rule says.

FAIL-first, the same three identical scan pairs, origin/main..HEAD of the plan change:
  before: narrow: full list — crates/model/src/arch/hybrid.rs: ... no row of tools/gate-paths.tsv maps it (167 recipes)
  after:  narrow: file crates/model/src/arch/hybrid.rs: own target of 12; rows 111 -> 80 recipes (102 recipes)
`recipes.py check`: 134 gate rows held to the derivation, rc 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@midagedev midagedev added model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja placement Placement planning: crates/placement tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo needs-gpu Changes code a card must check, and carries no gate run on a card labels Oct 16, 2026
midagedev and others added 2 commits October 17, 2026 05:34
tools/gate-paths.tsv: main's rows, and the hybrid.rs row recomputed on the merged
tree (`recipes.py reach --row`): main added gate-gpu-qwen38-nodes-remote and
gate-qwen3-plan to the gates that reach the file, so the row is 82 recipes, not 80.
`recipes.py check` rc 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…de it

Main's Card::set_aside_bytes returns None past u64 bytes. The two new budget
sums (the fixture's small card and the real header's budget B) take the value
with the same `expect("bounded set-aside")` the spill gate beside them uses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@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: Two new test owners leave the same fixture setup and expert-count pin duplicated.

Must fix

1. Use the fixture reader in the existing fixture tests

crates/model/src/arch/nemotron_h_moe/spec.rs:608 repeats the setup at crates/model/src/arch/nemotron_h_moe/spec.rs:489 and crates/model/src/arch/nemotron_h_moe/spec.rs:564: the same architecture, keys, tokenizer, tensors and read sequence (R14).
Reusing the helper now removes two setup blocks before the fixture changes.
Replace each header_typed-through-place::read setup with the following line, in test order:

let inputs = fixture_inputs("nhm-fixture");
let inputs = fixture_inputs("nhm-spill");

2. Use ROUTED in the existing header pin

crates/model/tests/nemotron_h_moe_meta.rs:325 introduces an owner for the Bolt file's total expert count, but crates/model/tests/nemotron_h_moe_meta.rs:263 keeps the same literal as a separate pin (R14).
Sharing it now prevents the header and placement gates from disagreeing after a fixture update.
Replace the existing condition with:

if u64::from(routed) != ROUTED || hp.n_expert != 496 {

Non-blocking

⚠ Check the aggregate host byte total: crates/model/tests/common/host_split.rs:132, a static trace shows that replacing expert_bytes: host_experts at crates/placement/src/placement.rs:3770 with expert_bytes: 0 escapes the new assertions. The real-file gate prints the total, and HostNeed::of uses it for sizing.
Add before the return: assert_eq!(plan.host.expert_bytes, layers.iter().map(|l| l.host_bytes).sum::<u64>());

Checked

Duplicates: both setup blocks match fixture_inputs; the header literal and ROUTED pin the same total. Both fixes reuse existing owners and add no elements.
Complexity: plan_with adds an enum dispatch without model-specific branches.
Silent failure: ignored fixture cleanup supplies no plan value; a missing expert list fails the segment comparison; show's discarded failures are checked immediately afterward; the Card-error fallback retains a failing pin.
Tests: static mutation traces at hybrid.rs:602 (Placed→Host) fail the whole-fit and small-card tests; :611 (Host→plan_routed) fails the Host and real-file tests; :589 (tier guard Card-only) fails the changed refusal test; a Host refusal at :610 fails the no-routed-stacks test.
Gates: no existing pin, case or gate was relaxed or removed.
Escape catalog: no C1–C4 pattern matches the diff.
Behavior: zero context, tier cards and wrong card counts are refused before dispatch; final invariants check card and host room. No decode or prefill work changes.

Notes

Static review only; gates were not rerun. GPU evidence omits the nvidia-smi output and exact just gate-* invocations.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@github-actions github-actions Bot removed the needs-gpu Changes code a card must check, and carries no gate run on a card label Oct 16, 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.

Since the last review: the three earlier items fall outside the incremental review scope.

Verdict: CLEAN: No in-scope must-fix items remain.

Checked

Evidence: read all three findings' cited code. The two duplication reports are kind B; the host-byte assertion proposal identifies a coverage gap without a reachable behavior failure.
Scope: none of the three finding paths appears in the supplied incremental diff. The re-review rule excludes these non-kind-A findings.
Diff: all 19 files in the incremental patch are byte-identical to BASE_TREE.
Validation: static inspection only; gates were not rerun.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@midagedev

Copy link
Copy Markdown
Owner Author

Superseded by #393: the same three commits rebased onto main ce96aff without the merge commits (gate-paths row refilled; owning gates green). I will close this PR when #393 merges.

Written by a bot (Claude agent) for the maintainer.

@midagedev

Copy link
Copy Markdown
Owner Author

Superseded by #393 (merged).

Written by a bot (Claude agent) for the maintainer.

@midagedev midagedev closed this Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja placement Placement planning: crates/placement tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant