Skip to content

feat(qmoe): expose per-expert region candidates for the expert bank (#82) - #1779

Merged
justinchuby merged 1 commit into
mainfrom
squad/82-qmoe-expert-region-candidates
Aug 22, 2026
Merged

justinchuby merged 1 commit into
mainfrom
squad/82-qmoe-expert-region-candidates

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Summary

First implementation slice for #82, per Roy's architecture verdict: introduce a typed per-expert resource identity/region-candidate seam for the QMoE expert bank so a future pluggable residency policy has real per-expert units — without changing residency behavior, adding a ResidencyPolicy trait, enabling paging, or touching kernel ABI/math. This slice is measurement-neutral: whole-bank-resident execution is unchanged.

Architecture invariant preserved

PMM/VMM allocation/accounting, stable VA ownership, synchronization, and correctness stay non-pluggable single authorities. No second cache/allocator was created. crates/onnx-runtime-ep-cuda/src/weight_paging.rs and kernels/qmoe.rs are not touched — this slice lives entirely in CPU-side/EP-agnostic code (onnx-runtime-loader, onnx-genai-engine, onnx-runtime-session), so CUDA/kernel behavior is unchanged by construction, not just by test.

Exact new type/contract

  • onnx_runtime_loader::qmoe_expert_tensor_layout(bits, block_size, blocks_per_row, dims) -> Option<ExpertTensorLayout> — the single source of truth for deriving a QMoE tensor's ExpertTensorLayout from node attributes + tensor dims. Reused by both the existing advisory placement pass (onnx-genai-engine::engine::placement, refactored to call it instead of constructing ExpertTensorLayout inline) and the new executor build-time seam, so the two call sites cannot silently drift onto different byte-range formulas.
  • Executor::expert_region_candidates: HashMap<ValueId, WeightRegionCatalog> — populated additively in build_lazy_weight_handles_and_candidates for every com.microsoft::QMoE expert-bank initializer that is a lazy-weight candidate. Nothing production-side reads this map yet (by design); a #[cfg(test)] accessor exists so tests can assert against it.

Supported / rejected layouts

  • Accepted (Pageable): rank-3 expert-major weight/scale/zero-point tensors with valid quantization (bits ∈ {1,2,4,8}, block_size > 0, blocks_per_row > 0) and a shape/byte-length that matches the derived layout exactly.
  • Rejected (NonPageable, with explicit reason): every QMoE-boundary initializer gets an entry — a value whose layout cannot even be derived (e.g. non-rank-3 dims) or that fails WeightRegionCatalog::classify's validation is recorded as NonPageable(NonPageableReason::{NotExpertMajor, ShapeMismatch, InvalidQuantization, InlineTensor, ...}) rather than silently omitted from the map.

Tests (added in this commit)

  • onnx-runtime-loader::weights::tests: qmoe_expert_tensor_layout rank-3 requirement + field population; a full pageable-partition test proving expert regions are contiguous/non-overlapping and exactly cover the tensor; an inline-tensor rejection-reason test.
  • onnx-runtime-session::executor::tests: qmoe_expert_region_candidates_partition_expert_major_bank_without_changing_handle_output (region ranges partition the bank; weight_handles/LazyWeight.regions counts are byte-identical to pre-change behavior) and qmoe_expert_region_candidates_record_reason_for_non_rank3_layout (an unsupported layout is recorded with an explicit NonPageableReason instead of vanishing).

Results

  • cargo test -p onnx-runtime-loader --lib: 19 passed
  • cargo test -p onnx-genai-engine --lib: 433 passed (placement.rs refactor unaffected)
  • cargo test -p onnx-runtime-session --lib: 198 passed
  • cargo clippy -p onnx-runtime-loader -p onnx-genai-engine -p onnx-runtime-session --tests: clean
  • cargo fmt: clean
  • CUDA/VMM comparison: not run — this slice touches no CUDA-specific files (weight_paging.rs/kernels/qmoe.rs untouched), so CUDA/paging behavior is unchanged by construction rather than by GPU measurement. A100s are available in this environment for the next slice, which does touch the CUDA paging path.

Reviewer verdict

An independent code-review pass flagged one substantive issue: a value whose layout could not even be derived (e.g. non-rank-3 dims) was silently absent from expert_region_candidates rather than recorded with a NonPageableReason, contradicting the field's own doc comment. Fixed: qmoe_push_expert_layout now always records an entry for any QMoE-boundary initializer, falling back to an Interleaved-ordered layout (which classify turns into NonPageableReason::NotExpertMajor) when a layout cannot be derived. Re-ran all targeted tests after the fix — all green.

Next dependency gate

Per Roy's verdict: the next slice introduces a ResidencyPolicy trait as a byte-identical refactor that reads expert_region_candidates for telemetry/decision-making, still without enabling paging or changing kernel behavior — this PR's map is exactly the seam that slice consumes.

Closes #82 (partially — first slice only; residency/paging enforcement is out of scope here).

)

First implementation slice for issue #82: introduce a typed per-expert
resource identity/region candidate seam for the QMoE expert bank so a
future pluggable residency policy has real per-expert units, without
changing residency behavior, kernel ABI/math, or adding a policy trait.

- Add onnx_runtime_loader::qmoe_expert_tensor_layout(bits, block_size,
  blocks_per_row, dims) as the single source of truth for deriving a
  QMoE tensor's ExpertTensorLayout from node attributes + tensor dims;
  reused by both the existing advisory placement pass
  (engine::placement, refactored to call it instead of constructing
  ExpertTensorLayout inline) and the new executor build-time seam, so
  the two call sites cannot drift onto different byte-range formulas.
- Add Executor::expert_region_candidates: HashMap<ValueId,
  WeightRegionCatalog>, populated additively alongside the existing
  lazy-weight handle map (build_lazy_weight_handles_and_candidates)
  for com.microsoft::QMoE expert-bank initializers. Existing
  WeightHandle/LazyWeight construction is unchanged in shape and
  count; nothing yet reads this new map outside tests.
- Every QMoE-boundary initializer that is a lazy-weight candidate now
  gets an entry: a derivable expert-major layout is classified as
  Pageable with its per-expert byte ranges, and a value whose layout
  cannot be derived or fails validation is classified NonPageable with
  an explicit NonPageableReason (e.g. NotExpertMajor) rather than
  silently omitted from the map.
- Tests: unit tests for qmoe_expert_tensor_layout's rank-3 requirement
  and field population, a WeightRegionCatalog pageable-partition test
  proving expert regions are contiguous/non-overlapping and cover the
  full tensor, an inline-tensor rejection-reason test, and two
  executor-level tests proving (a) region candidates partition the
  expert-major bank while weight_handles/regions are byte-identical to
  pre-change behavior, and (b) a non-rank-3 layout is recorded with an
  explicit NonPageableReason instead of vanishing from the map.

No kernel, CUDA weight_paging.rs, or ABI changes; whole-bank-resident
execution and existing binder/allocation counts are preserved by
construction and by the added tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby merged commit fbccba5 into main Aug 22, 2026
4 of 5 checks passed
@justinchuby
justinchuby deleted the squad/82-qmoe-expert-region-candidates branch August 22, 2026 21:52
@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.88889% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 80.63%. Comparing base (f38a4f0) to head (d3b6784).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-loader/src/weights.rs 98.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1779      +/-   ##
==========================================
- Coverage   80.70%   80.63%   -0.07%     
==========================================
  Files         411      411              
  Lines      199384   199648     +264     
  Branches   199384   199648     +264     
==========================================
+ Hits       160906   160985      +79     
- Misses      33024    33200     +176     
- Partials     5454     5463       +9     
Flag Coverage Δ
cli-ort-linux ?
cli-ort-windows 71.97% <ø> (ø)
mlas 85.33% <ø> (ø)
offline 80.79% <98.88%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-loader/src/lib.rs 75.80% <ø> (ø)
crates/onnx-runtime-session/src/executor/state.rs 83.33% <ø> (ø)
crates/onnx-runtime-loader/src/weights.rs 81.64% <98.88%> (+3.05%) ⬆️

... and 11 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

justinchuby added a commit that referenced this pull request Aug 22, 2026
Working as Batty (CUDA & Performance Engineer).

## Goal
Next #82 slice per Roy's architecture verdict: introduce the smallest
**real, pluggable** expert-residency policy boundary that consumes the
per-expert region candidates from #1779, while default behavior stays
exactly whole-bank-resident. This is a policy-boundary refactor, not a
residency behavior change.

## Exact new contract (`crates/onnx-runtime-ep-api/src/weight.rs`)
- `ResidencyDecision` — `WholeBankResident { reason:
Option<ResidencyDegradationReason> }` | `PerExpertCandidate { experts:
Vec<usize> }`.
- `ResidencyDegradationReason` — `NonPageableCatalog(NonPageableReason)`
| `PolicyDeclinedSplit` | `RejectedByValidation(String)`.
- `ResidencyPlan` — pure data: `HashMap<ValueId, ResidencyDecision>` +
`policy_name`. No allocator/stream/pointer fields; cannot
allocate/copy/synchronize by construction. `policy_name()`,
`decision()`, `ordered_values()` (deterministic ascending order),
`len()`/`is_empty()`, `resident_count()`/`degraded_count()` (disjoint).
- `ResidencyPolicyInput<'a>` — `boundary`, `catalog:
&WeightRegionCatalog`, `budget_bytes: Option<u64>`.
- `ResidencyPolicy` trait — `name()`, `decide(&self,
&ResidencyPolicyInput) -> ResidencyDecision`. Immutable borrow only in,
owned value out — no way to mutate external state through the signature
(the "non-owning" proof).
- `WholeBankResidentPolicy` — the only shipped policy; always resident,
surfacing the catalog's own `NonPageableReason` (if any) rather than
inventing one.
- `plan_residency(candidates, policy, budget_bytes) -> ResidencyPlan` —
the single plan-creation call site.
- `validate_decision(catalog, decision)` — fail-closed: any invalid
`PerExpertCandidate` (out-of-range index, duplicate index, proposed over
a non-pageable catalog, or an index with no real region) degrades that
single entry to `WholeBankResident { reason:
Some(RejectedByValidation(..)) }`. Never panics, never fails the whole
plan.

## Call sites (requirement #2)
- **Plan creation**: `Executor::build_with_cuda_requirement`
(`build.rs`) calls `plan_default_residency()` immediately after
computing `expert_region_candidates`, using `WholeBankResidentPolicy`.
Result stored as `Executor.residency_plan` (new field, `state.rs`),
read-only under `#[cfg(test)]` today.
- **Execution/validation**: `plan_residency()` itself validates every
decision before it's accepted into the plan (see above) — this is
"existing infrastructure" in the sense that no new dispatch-time
consumer was introduced; no production path acts on `PerExpertCandidate`
yet, matching the explicit scope (no kernel ABI changes, no
CUDA/`weight_paging.rs` touch).
- **Telemetry**: a new `"session.residency_plan"` trace span records
`policy`, `resident`, `degraded`, `total`, using the existing
`trace_span`/`Args` infra — no duplicate counter/state.

## Non-owning proof
`ResidencyPlan`/`ResidencyPolicy`/`ResidencyPolicyInput` hold no
allocator, stream, or pointer/handle fields — only value-level data
(`ValueId`, `usize` indices, `&'static str`, catalog references). The
trait's `decide()` takes `&self` and an immutable input reference and
returns an owned enum — it has no path to allocate, copy, or
synchronize.

## Substitutability proof
A test-only `AlwaysSplitPolicy`/`AlwaysSplit` policy (in both
`weight.rs` and `executor/tests.rs`) proposes `PerExpertCandidate` for
every pageable catalog and is accepted by `plan_residency()`/wired
through `plan_residency_with()` (a test/measurement-only seam alongside
the production `plan_default_residency()`), proving the trait boundary
is real, not an inert marker — without shipping
LRU/static-hot/q*/prefetch-execution behavior.

## Tests (same commit)
- `weight.rs`: default-policy equivalence for pageable and non-pageable
catalogs, alternate-policy substitutability, three invalid-decision
rejections (out-of-range index, duplicate index,
per-expert-over-non-pageable), deterministic ordering.
- `executor/tests.rs`: default plan matches whole-bank-resident with
unchanged `weight_handles` output; non-pageable reason surfaced without
changing handles; seam substitutability at the executor's own candidate
wiring, with the default `Executor.residency_plan` unaffected by the
substitution.

## Local evidence
- `cargo test -p onnx-runtime-ep-api --lib`: 71 passed.
- `cargo test -p onnx-runtime-session --lib`: 201 passed (+3 new vs.
pre-slice 198).
- `cargo test -p onnx-genai-engine --lib`: 445 passed, 1 ignored
(unaffected, run as a broader regression check).
- `cargo clippy -p onnx-runtime-ep-api -p onnx-runtime-session --lib
--tests`: clean.
- `cargo fmt` on touched crates: clean.
- No CUDA/`weight_paging.rs`/kernel-ABI files touched in this diff — a
CUDA/VMM comparison run is not applicable to this slice (verified via
`git diff --stat`, only `onnx-runtime-ep-api`/`onnx-runtime-session`
files changed); 5x A100 confirmed available for future slices that do
touch CUDA paths.

## Reviewer verdict
Independent code-review agent found one substantive issue:
`resident_count()`'s doc comment said "no degradation reason" but the
implementation counted all `WholeBankResident` entries including
degraded ones, so `resident_count()` + `degraded_count()` weren't
disjoint as documented (misleading telemetry). Fixed: `resident_count()`
now filters on `reason: None` to match its documented semantics and stay
disjoint from `degraded_count()`; corresponding tests updated. Re-ran
all targeted tests after the fix — all green.

## Explicitly out of scope (per Roy's staged plan,
`.squad/decisions/inbox/roy-residency-policy-gated-on-82.md`)
No static-hot algorithm, LRU mutation, `plan_double_buffer`/prefetch
execution wiring, q*, elastic resize, or kernel ABI changes.
`weight_paging.rs`'s `CudaWeightResidency`/`eviction_for_boundary`
remain untouched and the single non-pluggable authority for
allocation/accounting/VA/sync.

## Next policy-implementation slice
Per Roy: refactor `eviction_for_boundary`/static-pin-threshold logic in
`weight_paging.rs` into a real `ResidencyPolicy` implementation
(byte-identical behavior first), then wire the already-implemented
`plan_double_buffer` (`executor/prefetch.rs`) through the plan for
actual prefetch execution.

Part of #82 — this is one slice of a staged plan; issue should remain
open for the follow-ups above.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby pushed a commit that referenced this pull request Aug 22, 2026
…orkflow

Architecture A, completed. A bare-decoder package keeps `model.io` as its sole
serialized answer; the loader compiles that ABI into a canonical WorkflowSpec in
memory and the interpreter's canonical decode loop executes it. There is no
longer a direct decode path to fall back to.

The split that makes this real rather than a wrapper is in `decode_loop.rs`:

* `forward_step` is the `onnx-genai.autoregressive-decode` primitive -- one
  decoder forward pass, taking the device greedy-argmax or device-sampling fast
  path when it applies. KV stays the executor's business, exactly as the lowered
  workflow declares (`management: runtime`), so device residency is untouched.
* `select_and_commit_step` is the `onnx-genai.token-policy` primitive -- the
  logit-processor chain, sampler, logprobs, KV commit, and stop/EOS detection.

Both are the *only* implementations. `step_decode_loop` is now written on them,
so a caller cannot reach a second copy of the policy.

`pipeline::canonical_decode::run_canonical_decode` is the one token loop. It
owns iteration count, the context-limit stop, the stop decision, and the emitted
token stream; the body is forward-then-policy. `run_decode_loop` -- the second
token loop only the direct `Engine` path could drive -- is deleted, and its unit
tests re-point at the loop that remains. The ORT session path, both native decode
sessions, and the workflow path now share it.

Fail-closed, not mode-switched. `install_canonical_workflow` runs at load for
every decoder package and asserts the lowered workflow declares the two contracts
this runtime implements; a package that cannot be lowered fails to load.
`generate_with_callbacks` then refuses outright if no canonical form is present,
naming the fact that the old path no longer exists. Two hand-built test doubles
tripped that guard on the first run -- they skip the loader -- and now carry the
same lowering a real minimal decoder gets, rather than the guard being weakened.

Parity, on real models: greedy goldens for phi35-mini int4, phi4-mini CUDA,
qwen2.5-0.5b CUDA, qwen3-0.6b, and gpt-oss-20b are byte-identical to the
pre-change baseline captured at c58eb5b. The tokens did not move; only who
produced them did.

`canonical_execution_parity` (8) pins what goldens cannot: that a bare decoder is
lowered at load and reports `provenance: lowered` while still serializing only
`model.io`; that prefill, cached decode on a reused session, deterministic greedy
generation, EOS stopping, seeded sampling, and batching (where the decode path
supports it) all run through the canonical loop; that an authored workflow is
executed as authored and never lowered beside itself; and -- the case the whole
change rests on -- that the legacy direct path cannot be selected: no constructor
skips lowering, and a runtime with its canonical form removed refuses to generate
instead of falling back.

Verified: onnx-genai-engine 72 targets green under `native-backend`; server /
CLI / C ABI / metadata / bench 30 green; H200 native-cuda green
(canonical_execution_parity 8, native_workflow_parity 12, corpus 5,
one_runtime_e2e 5, fixture floor 3); `cargo fmt --all --check`; clippy
`-D warnings` clean apart from a `too_many_arguments` lint on
`engine/placement.rs`, which arrived with main (#1779) and this branch does not
touch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 23, 2026
…paging authorities (#82) (#1789)

Part of #82 (DeepSeek-V4/GLM-5.2 QMoE vertical slice). 4th slice of the
pluggable residency-policy line: #1779 (region candidates) → #1784
(policy seam) → #1787 (migrate existing decision logic into
`CudaHotSetResidencyPolicy`) → this PR (elastic resize).

## Goal
Introduce FreeToken-style elastic expert-residency **resizing** as a
policy-issued resize *intent*, executed transactionally by the existing
Resource Governor + PMM/VMM/weight-paging authorities. The policy never
allocates/frees/copies/relocates a virtual address — it only asks;
existing infrastructure executes.

## New contract (`onnx-runtime-ep-api::weight`)
- `ResidencyResizeRequest { direction: Grow | Shrink, target_bytes,
priority }`
- `ResizeSafePoint { capturing, pending_deferred_releases,
admission_in_flight, multi_device }` with `is_safe()` /
`blocking_reason()`
- `ResizeRejection` (`NotSafePoint` / `WouldExposeColdExpert` /
`ExecutionFailed` / `NoOp`)
- `ResidencyResizePlan::{Accepted(request), Rejected { request, reason
}}` produced by the pure `plan_resize(request, safe_point)` — no
allocation, copy, or VA touched during planning.
- `ResidencyResizeOutcome` (before/after bytes, `accepted_bytes`,
`rollback_count`, `safe_point`, `is_success()`).

## Executor side (`onnx-runtime-ep-cuda::weight_paging`)
- `CudaWeightResidency::resize_safe_point(device_count)` builds the
snapshot from **existing** signals only: `CudaRuntime::is_capturing()`,
the provider deferred-release queue's pending count, and the existing
in-flight-fill quarantine. No new state.
- `CudaWeightResidency::execute_resize(plan, device_count)` is the
*only* place a plan becomes bytes actually moved:
- **Grow** reuses the existing `MemoryLease::grow` path — the same one
`admit_committed_span`'s over-budget handling already uses.
- **Shrink** reuses the existing
`ReclaimableMappedHolder::reclaim_mapped` (governor-driven LRU eviction
+ deferred-release-queue-idle wait) when a mapped allowance is
installed, or an equivalent plain-lease evict-then-shrink through the
same `remove_page` helper otherwise (so process-global
resident-byte/eviction telemetry stays in lockstep, not duplicated).
- Re-validates the safe point immediately before committing (planning
and execution can be separated in time).
- Refuses to shrink an *ungoverned* budget (no lease/allowance) —
mirrors `set_ungoverned_budget`'s existing refusal to shrink once
nothing governs the budget.

## Safe-point invariant
A resize may only commit when: no CUDA graph slot in use is
capturing/replaying; the provider's deferred-release queue has nothing
pending; no page admission is currently in flight; and the call is
**not** running under multi-device/TP. No existing barrier/authority in
this codebase coordinates a resize across devices, so multi-device fails
closed with an explicit reason rather than inventing distributed
synchronization (confirmed via search — no TP/multi-device coordination
primitive exists yet).

## What can actually be reclaimed this slice
Honest scope: only the existing dense/MatMul weight-paging cache's
ordinary LRU-evictable, non-pinned resident bytes — the same population
`reclaim_mapped` already touches. **No QMoE per-expert reclaim is
enabled.** Whole-bank QMoE residency is unchanged; slice 2/3's "no cold
expert may reach QMoE" invariant is preserved because nothing here
changes per-expert eviction. Per-expert-aware eviction (requiring a
routed-residency guarantee) is the next dependency gate.

## Default behavior
Unchanged:
`budget()`/`adopt_governed_budget()`/`set_ungoverned_budget()` are
untouched, and every existing `weight_paging` test still passes without
this seam ever being invoked.

## Tests
- `crates/onnx-runtime-ep-api/src/weight.rs` — `plan_resize`
acceptance/no-op/capture/pending-release/in-flight/multi-device
rejection, deterministic blocking-reason priority, outcome success
reporting.
- `crates/onnx-runtime-ep-cuda/src/weight_paging.rs` — ungoverned cache
refuses grow/shrink leaving budget untouched; governed grow moves
exactly the requested bytes / fails over-capacity with budget unchanged;
zero-byte resize is a rejected no-op; multi-device fails closed;
repeated grow/shrink oscillation keeps accounting consistent;
rejected-plan outcome preserves the original request's direction.

## Local validation (A100)
- `cargo fmt` / `cargo clippy -D warnings` on both touched crates —
clean.
- `cargo test -p onnx-runtime-ep-cuda --features cuda --lib` — 524
passed, 0 failed, 31 ignored (GPU-only helpers not applicable),
including a single-threaded rerun to rule out cross-test device
contention.
- `cargo test -p onnx-runtime-ep-api --lib` — 79 passed (+ new resize
tests, 88 total counting weight.rs additions).
- `cargo test -p onnx-runtime-session --lib --features cuda` — 205
passed.

## Independent review
A code-review agent inspected the diff before this commit and found two
substantive issues, both fixed prior to committing:
1. The plain-lease shrink fallback updated only per-instance accounting
and skipped the process-global resident-byte/eviction gauges every other
eviction call site updates — now routed through the existing
`remove_page` helper so telemetry stays consistent.
2. A plan-time-rejected outcome always reported `direction: Grow`
regardless of the original request — `ResidencyResizePlan::Rejected` now
carries the original request so telemetry reports the correct
direction/bytes even on rejection.

## Explicitly out of scope
q* CPU/GPU split, predictor-based prefetch, a new allocator/cache, and
enabling per-expert QMoE eviction/reclaim (next dependency gate, once a
routed-residency guarantee exists).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add routed-expert paging, leases, and routing-aware scheduling

1 participant