Skip to content

fix(ep): claim and correctly execute MatMulNBits and QLinearMatMul - #1101

Merged
justinchuby merged 3 commits into
mainfrom
squad/roy-matmul-claim-bugs
Aug 17, 2026
Merged

justinchuby merged 3 commits into
mainfrom
squad/roy-matmul-claim-bugs

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

The finding

Our CPU EP has never executed MatMulNBits or QLinearMatMul through the ORT plugin path. Not "deferred them under policy" — never claimed them at all. Every int4/int8 quantized model that has run through this EP had its matmuls executed by ORT's own kernels, silently, regardless of what assignment_policy said and regardless of session.disable_cpu_ep_fallback.

That invalidates the reachability half of every plugin-path claim in CPU_MATMUL_ASSIGNMENT.md. It also means the recent architectural direction — this EP must never hand a node to ORT's CPU EP — could not have been satisfied by removing the defer matrix alone.

There are four independent defects, each of which is sufficient on its own to lose the op.

1. The dtype filter excluded MatMulNBits outright

node_passes_dtype_filter (ep.rs) returns false unless every input and output dtype of the node appears in entry.supported_dtypes. supported_dtypes_for_op("MatMulNBits", "com.microsoft") returned F32_ONLY.

MatMulNBits inputs are A: float, B: uint8, scales: float, zero_points: uint8, g_idx: int32. The uint8 weight fails the filter on input 1, so the claim is dropped before anything else is consulted. The correct set is the one the kernel itself enforces at matmul_nbits.rs:780-815: float32/float16/bfloat16 on A/scales/Y, uint8 on B and zero_points, int32 on g_idx.

2. MatMulNBits used the plain-matmul shape rule

ShapeInference::for_node mapped "MatMul" | "MatMulNBits" to Self::MatMul. B is not a matmul operand — it is the packed [N, blocks_per_col, blob_bytes] weight — so the rule broadcast the activation against the packing:

K N inferred correct
256 4096 [4096, 1, 16] [1, 4096]
64 8 [8, 1, 16] [1, 8]

The correct rule is the activation shape with its last dim replaced by the N attribute, which is what onnx-runtime-shape-inference's quantized_matmul (handlers/linalg.rs:36) already does. Because N is only visible on the node, the attribute-free for_op_domain table must decline rather than fall back to the matmul rule.

Defect 1 masked defect 2: fixing only the dtypes turns a silent escape into a hard Y must have shape [1, 4096], got [4096, 1, 16].

3. QLinearMatMul was in neither shape table

Missing from for_op_domain and for_node, so it resolved to ShapeInference::Declined and GetCapability's fail-closed shape filter dropped the whole claim. This is the same failure mode as the com.microsoft activations fixed in #1082.

Note that the MatMul rule would have been wrong here too: the ONNX operand order is a, a_scale, a_zero_point, b, b_scale, b_zero_point, y_scale, y_zero_point, so the operands are inputs 0 and 3.

4. input_slots numbered ORT inputs by position, not by value

Once claimed, qlinear_u8 still failed:

Compute: input slot 7 maps to ORT input 7, but ORT bound only 7 input(s) to this fused node

ORT's fused-node metadata carries a set of input names. A node that names the same value twice is bound once, and the sequential numbering shifted every later slot — the last one past the end of the array ORT actually passes. This is not exotic: a real quantized QLinearMatMul routinely shares one zero-point or scale initializer across its a/b/y triples, and any Mul(x, x)-shaped node does the same. On main this surfaced as an opaque Compute: internal panic from an out-of-bounds index across the C ABI.

Slots are now assigned per distinct value in first-appearance order, and the fast path fails closed with the message above instead of panicking.

5. The union dtype list over-claimed what the kernels accept

Found by review of the first commit. supported_dtypes is a flat set and the node filter tests membership in it, so on a mixed-dtype op it is strictly weaker than the kernel's own rule. com.microsoft::MatMulNBits spec-allows zero_points in uint8, int32, float16 or float (contrib_defs.cc T3), and ORT builds a session for every one of them — I probed all three against a plain-ORT session and all three loaded. Our kernel implements packed uint8 only. Fixing defect 1 by advertising the union therefore claimed a float16-zero-point node and then failed inside execute with zero_points must have dtype Uint8 — turning a model that previously produced an answer into a hard error.

KernelRegistryEntry gains input_dtype_constraints: &'static [(usize, &'static [DataType])]. A listed slot is checked against its own set instead of the union; unlisted and absent slots keep the union, which is exact for every uniform op. Populated straight from the kernels' require_dtype calls:

op slot allowed
MatMulNBits 0 A, 2 scales, 5 bias f32 / f16 / bf16
1 B, 3 zero_points uint8
4 g_idx int32
QLinearMatMul 1 a_scale, 4 b_scale, 6 y_scale f32
0, 2, 3, 5, 7 uint8 / int8

The QLinearMatMul half matters for the same reason: its scales flow through ARITH_DTYPES, which admits f16/bf16, and opset 21 spec-allows those — so defect 3's fix would newly have claimed a config qlinear_matmul.rs:316 rejects.

Remaining gap, stated plainly

Float / float16 / bfloat16 zero_points on MatMulNBits is a quantization semantic this EP does not implement. It is declined at GetCapability, and a_float_zero_point_matmul_nbits_is_declined_not_claimed_and_failed pins that it is declined and still computed rather than claimed and failed. That is a genuine coverage gap in the matmul family, recorded here rather than hidden: closing it means implementing the unpacked float zero-point dequant path, which is a new numeric semantic and does not belong in an urgent claim fix.

Falsifiers

no_matmul_family_node_escapes_to_the_ort_cpu_ep builds ten models from scratch, runs each under a session with session.disable_cpu_ep_fallback=1, asserts via ORT's own record_ep_graph_assignment_info that the op is on cpu_ep and that ops_not_on_our_ep() is empty, then compares elementwise against a second session with no EP appended — i.e. pure ORT CPU. A non-degeneracy guard requires at least half the outputs to be non-zero and finite first, so a both-sides-zero comparison cannot pass vacuously.

case shape worst |ours − ORT| tolerance
matmul_f32_decode M1 K256 N4096 4e-6 1e-3
matmul_f32_prefill M256 K256 N4096 5e-6 over 1,048,576 elems 1e-3
matmul_f16_decode M1 K256 N4096 4.88e-4 5e-2
gemm_f16_decode M1 K256 N4096 4.88e-4 5e-2
nbits4_decode bits4 blk32 M1 K256 N4096 1.83e-4 0.5
nbits8_prefill bits8 blk32 M256 K256 N4096 2.93e-3 over 1,048,576 elems 0.5
nbits4_block512 bits4 blk512 M1 K512 N2048 ours runs; ORT refuses the model —
nbits4_f16_decode bits4 blk32 M1 K256 N4096, f16 activation 0.0 4.0
qlinear_u8 M1 K256 N4096 0.0 (bit-exact) 1
qlinear_i8 M1 K256 N4096 0.0 (bit-exact) 1

Two further cases outside the sweep: mul_self (defect 4) and nbits_float_zp (the declined-not-crashed guard).

nbits4_block512 is the negative control: ORT rejects block_size 512 at kernel construction (matmul_nbits.cc:131), we run it, and the test asserts ORT refuses so the claim in the assignment doc cannot go stale silently.

nbits4_f16_decode exists because fixing defect 1 widens what we claim — f16 activations were unreachable before, and this pins that they are now correct rather than merely accepted.

a_node_that_names_one_value_twice_is_bound_once is the direct falsifier for defect 4: Mul(X, X) on a graph input, no initializers, no constant-sharing pass involved. It asserts the node lands on our EP and that every element equals x*x exactly.

Each fix falsified independently

Reverting one source file at a time on this branch, with the test unchanged:

reverted first failure
all three nbits4_decode — session creation fails, ours=[], others=["MatMulNBits"]
compute.rs only (shape rules) nbits4_decode — MatMulNBits: Y must have shape [1, 4096], got [4096, 1, 16]
ep.rs only (slot dedup) qlinear_u8 — input slot 7 maps to ORT input 7, but ORT bound only 7 input(s)
slot mapping only, back to positional mul_self — input slot 1 maps to ORT input 1, but ORT bound only 1 input(s)

Plus seven hardware-independent unit tests: four in compute.rs pinning the two new shape rules — including an explicit assert_eq!(wrong, vec![vec![4096, 1, 16]]) so the plain-MatMul alias cannot return unnoticed — two in kernels/mod.rs asserting the quantized edge dtypes are advertised, and one in ep.rs asserting the per-slot filter declines a float16-zero-point node and a uint8-activation node while accepting the packed-uint8 one.

Performance

No performance claim. This PR changes nothing about the kernels; it changes which kernels run at all. It does not make anything faster, and the ratios in CPU_MATMUL_ASSIGNMENT.md are not restated here — see below.

What this does to the published matrix

The MatMulNBits and QLinearMatMul rows in CPU_MATMUL_ASSIGNMENT.md were measured with bench_generic, which drives the kernels natively, so those numbers remain valid as kernel-level measurements. What is now known to be wrong is the implication that the plugin path ever reached those kernels. Re-stating the matrix as plugin-path numbers is deliberately not in this PR — it is a doc change that depends on #1097's rewrite of the same file, and folding it in here would make an urgent correctness fix conflict with an open architectural PR.

Related, not fixed here

Same class of bug, outside this PR's remit, reported to the owners rather than fixed silently:

  • ("MoE" | "QMoE", "com.microsoft") => F32_ONLY — both carry uint8 weight edges.
  • ("GatherBlockQuantized", "com.microsoft") => FLOAT_DTYPES — same.

Both are very likely dropped by the dtype filter for the same reason as defect 1.

Verification

  • cargo test -p onnx-runtime-ep-cpu-plugin --test plugin_ort_e2e — 50 passed (was 47; +3)
  • cargo test -p onnx-runtime-ep-plugin --lib — 229 passed (was 224; +5)
  • cargo test -p onnx-runtime-ep-cpu --lib — 1317 passed; with --features mlas 1334 passed (+2 dtype tests)
  • cargo test -p onnx-runtime-ep-shared-mock-plugin — passes (registry-entry field addition)
  • cargo fmt --all -- --check clean
  • cargo clippy --all-targets clean both with and without --features mlas

Host: AMD EPYC 9V74, 32 vCPU, AVX2+FMA+F16C, ORT 1.27.0.

Independent review

Reviewed by an independent Opus reviewer against a separate worktree, with the ORT 1.27.0 source for partitioning_utils.cc pulled at the prebuilt's own commit. Verdict was REQUEST CHANGES on the over-claim above; all findings are addressed in 905f7c47c:

finding resolution
major: float zero-point MatMulNBits claimed then fails at Run per-slot constraints; declined; regression test
major: f16/bf16-scale QLinearMatMul claimed then fails at Run same mechanism, QLinearMatMul slot table
minor: sweep does not exercise the slot dedup it does — reverting only the slot mapping fails qlinear_u8 — but a direct Mul(X, X) case was added anyway, since that one covers a graph input rather than an initializer
nit: line-wrapped error string fixed
nit: "nine models" / "the two noted" fixed

Round two returned APPROVE. The reviewer disabled input_dtype_constraints_for_op for MatMulNBits and confirmed the float-zero-point test then fails, i.e. the per-slot list is load-bearing and nothing else catches it; wrote an independent opset-21 f16-scale QLinearMatMul probe and confirmed it is declined and still computed; reverted the slot mapping to positional and confirmed both mul_self and qlinear_u8 fail; and checked all fourteen constrained slots against the two kernels' require_dtype calls. One useful correction: for QLinearMatMul the per-slot list is redundant with a pre-existing native unsupported_reason gate, so it is defence in depth there and load-bearing only for MatMulNBits.

The reviewer independently confirmed from ORT's MakeComputeCapability that the fused-node input set is deduplicated by NodeArg identity in first-appearance order, over graph inputs and initializers alike, and that the multi-node routing path already keys on value position so it stays consistent with the fast path.

Post-review fix

f8f7a486a — the generated-model element-type constants were annotated u32. ONNXTensorElementDataType is a c_uint, which bindgen renders as u32 on Linux but i32 on Windows, so Rust (Windows ARM64) and Rust coverage (Windows x86_64) failed to compile the test file while every Linux lane was green. They now use ORT's own alias. Test-only, no behaviour change.

@codecov

codecov Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.95023% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.56%. Comparing base (867f02d) to head (f8f7a48).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-ep-plugin/src/compute.rs 77.52% 15 Missing and 5 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1101      +/-   ##
==========================================
+ Coverage   80.17%   80.56%   +0.39%     
==========================================
  Files         367      367              
  Lines      155918   156610     +692     
  Branches   155918   156610     +692     
==========================================
+ Hits       125004   126170    +1166     
+ Misses      26232    25730     -502     
- Partials     4682     4710      +28     
Flag Coverage Δ
cli-ort-linux 83.79% <ø> (ø)
cli-ort-windows ?
offline 80.44% <90.95%> (+0.40%) ⬆️

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

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-cpu-plugin/src/lib.rs 74.35% <100.00%> (+29.76%) ⬆️
crates/onnx-runtime-ep-cpu/src/kernels/mod.rs 92.95% <100.00%> (+0.13%) ⬆️
crates/onnx-runtime-ep-cuda-plugin/src/lib.rs 71.84% <ø> (ø)
crates/onnx-runtime-ep-plugin/src/ep.rs 82.49% <100.00%> (+1.41%) ⬆️
...ates/onnx-runtime-ep-shared-mock-plugin/src/lib.rs 77.75% <100.00%> (+10.22%) ⬆️
crates/onnx-runtime-ep-plugin/src/compute.rs 77.39% <77.52%> (-0.07%) ⬇️

... and 20 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 matmul/large_generic_f16_threads=8/32x1024x1024 78.46 µs 135.98 µs +73.3%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 879.66 µs 1.42 ms +61.6%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.95 ms 6.19 ms +56.7%
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 47.33 µs 71.97 µs +52.0%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 43.87 µs 65.80 µs +50.0%
🔴 matmul/medium_generic_f32_threads=1/32x512x512 2.14 ms 2.98 ms +39.3%
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 554.68 µs 771.86 µs +39.2%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 27.72 µs 38.21 µs +37.8%
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 491.54 µs 646.86 µs +31.6%
⚠️ matmul/large_generic_bf16_threads=1/32x1024x1024 1.83 ms 2.35 ms +28.7%
⚠️ matmul/large_generic_f16_threads=1/32x1024x1024 74.00 µs 94.27 µs +27.4%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 28.70 µs 35.09 µs +22.3%
⚠️ tokenization/encode_tokens_per_second 404.13 µs 493.12 µs +22.0%
⚠️ matmul/small_generic_bf16_threads=8/1x256x256 29.20 µs 35.24 µs +20.7%
⚠️ matmul/large_generic_bf16_threads=8/32x1024x1024 1.34 ms 1.60 ms +19.2%
⚠️ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 163.20 µs 191.72 µs +17.5%
⚠️ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 445.31 µs 520.00 µs +16.8%
⚠️ tokenization/decode_tokens_per_second 6.39 ms 7.36 ms +15.3%
✅ matmul/small_generic_bf16_threads=1/1x256x256 30.48 µs 34.62 µs +13.6%
✅ gather/large_f32_threads=1-internal/131072 25.87 µs 29.04 µs +12.2%
✅ add/small_f32_threads=1-internal/1024 221.3 ns 248.2 ns +12.2%
✅ sampling_latency/greedy_per_token 3.25 µs 3.54 µs +9.0%
✅ matmul/small_generic_f32_threads=8/1x256x256 34.46 µs 37.11 µs +7.7%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 502.36 µs 540.49 µs +7.6%
✅ sampling_latency/top_k_per_token 54.80 µs 57.83 µs +5.5%
✅ sampling_latency/min_p_per_token 208.73 µs 218.15 µs +4.5%
✅ reduce_mean/large_f32_threads=1-internal/262144 991.72 µs 1.04 ms +4.4%
✅ matmul/small_generic_f16_threads=1/1x256x256 28.69 µs 29.40 µs +2.5%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 8.87 ms 8.98 ms +1.3%
✅ matmul/small_generic_f32_threads=1/1x256x256 34.05 µs 34.13 µs +0.2%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 549.01 µs 537.84 µs -2.0%
✅ gather/small_f32_threads=1-internal/4096 702.2 ns 678.5 ns -3.4%
✅ matmul/small_generic_f16_threads=8/1x256x256 29.43 µs 28.36 µs -3.6%
✅ reduce_mean/medium_f32_threads=1-internal/65536 266.99 µs 253.97 µs -4.9%
✅ qwen3_sampling_processors/top_k_top_p_fast 722.47 µs 671.35 µs -7.1%
✅ sampling_latency/top_p_per_token 402.98 µs 373.61 µs -7.3%
✅ gather/small_f16_threads=1-internal/4096 520.9 ns 463.7 ns -11.0%
✅ gather/medium_bf16_threads=1-internal/32768 2.67 µs 2.37 µs -11.3%
✅ logit_processing/seven_processor_chain_per_step 349.14 µs 306.40 µs -12.2%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 6.58 ms 5.74 ms -12.7%
✅ gather/large_bf16_threads=1-internal/131072 11.57 µs 9.88 µs -14.6%
🟢 qwen3_sampling_processors/top_k_full_sort_baseline 2.50 ms 2.12 ms -15.3%
🟢 reduce_mean/small_f32_threads=1-internal/4096 18.59 µs 15.57 µs -16.2%
🟢 grammar_masking/llguidance_compute_mask/32 86.13 µs 70.47 µs -18.2%
🟢 gather/medium_f16_threads=1-internal/32768 2.90 µs 2.34 µs -19.2%
🟢 gather/small_bf16_threads=1-internal/4096 593.9 ns 476.6 ns -19.8%
🟢 kv_cache/alloc_dealloc_pages 46.12 µs 36.53 µs -20.8%
🟢 qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 4.67 ms 3.70 ms -20.9%
🟢 gather/large_f16_threads=1-internal/131072 13.04 µs 10.19 µs -21.8%
🟢 add/large_f16_threads=1-internal/4194304 2.26 ms 1.75 ms -22.5%
🟢 gather/medium_f32_threads=1-internal/32768 5.03 µs 3.85 µs -23.5%
🟢 add/large_f32_threads=1-internal/4194304 932.13 µs 686.62 µs -26.3%
🟢 add/small_bf16_threads=1-internal/1024 736.5 ns 516.5 ns -29.9%
🟢 add/large_bf16_threads=1-internal/4194304 2.54 ms 1.69 ms -33.4%
🟢 add/medium_bf16_threads=1-internal/262144 157.07 µs 102.54 µs -34.7%
🟢 add/small_f16_threads=1-internal/1024 767.1 ns 496.4 ns -35.3%
🟢 qwen3_sampling_processors/top_k_partial_selection 225.12 µs 143.53 µs -36.2%
🟢 add/medium_f32_threads=1-internal/262144 57.73 µs 30.16 µs -47.8%
🟢 add/medium_f16_threads=1-internal/262144 269.69 µs 107.43 µs -60.2%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 2.84 3.29 6.19 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

@justinchuby
justinchuby force-pushed the squad/roy-matmul-claim-bugs branch from dc37bd1 to ea13281 Compare August 17, 2026 02:57
justinchuby added a commit that referenced this pull request Aug 17, 2026
## What

```
$ git checkout origin/main && cargo fmt --all -- --check
Diff in crates/onnx-runtime-ep-cpu/src/kernels/governed_weight_cache.rs:263
Diff in crates/onnx-runtime-ep-cpu/src/kernels/matmul.rs:354, 2127, 2148, 2163, 3206
Diff in crates/onnx-runtime-ep-cpu/src/lib.rs:92
```

`main` is fmt-broken at a0ed99a (#1100). `main` is unprotected and the
merge queue does not re-run CI on the merge result, so this fails the
*Check formatting* step of both **Fast (Linux x86_64)** and **Rust
quality** on every branch cut from it — including #1101, which is how I
found it.

## What this is

The literal output of `cargo fmt --all`, nothing else. No logic change,
no test change. 17 insertions, 20 deletions, all whitespace and
line-wrapping.

## Verification

- `cargo fmt --all -- --check` — clean
- `cargo test -p onnx-runtime-ep-cpu --lib` — unchanged

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby and others added 2 commits August 17, 2026 03:13
Four independent defects meant the plugin EP had never executed either
quantized matmul under an ORT host, whatever the assignment policy said.

1. `supported_dtypes_for_op("MatMulNBits", "com.microsoft")` returned
   `F32_ONLY`, but the node filter requires *every* input and output dtype
   to be listed and the packed weight is `uint8`. Every int4/int8 node was
   dropped and ORT's own kernel ran.
2. `ShapeInference::for_node` aliased `MatMulNBits` to plain `MatMul`, which
   broadcasts the activation against the *packed* `[N, blocks, blob]` weight:
   K=256/N=4096 inferred `[4096, 1, 16]` instead of `[1, 4096]`.
3. `QLinearMatMul` was absent from both shape tables, so it resolved to
   `Declined` and the fail-closed shape filter dropped the claim. Its
   operands are inputs 0 and 3, not 0 and 1.
4. `input_slots` numbered ORT inputs by position. ORT's fused-node metadata
   carries a *set* of input names, so a node naming one value twice — a
   `QLinearMatMul` sharing a zero-point or scale initializer, or any
   `Mul(x, x)` — shifted every later slot off the end of the bound array.

The single-kernel fast path now fails closed with a diagnosable message
instead of indexing out of bounds and surfacing as `Compute: internal panic`.

Falsified by a 10-case generated-model conformance sweep that asserts each
op lands on our EP with CPU fallback disabled and then matches a plain-ORT
session elementwise, plus hardware-independent shape and dtype unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review follow-up. `supported_dtypes` is the *union* of an op's edge dtypes, so
on a block-quantized op it admits combinations no kernel accepts: a
`MatMulNBits` with `float16` `zero_points` — spec-valid, and a session ORT
builds happily — passed the union test because both `float16` and `uint8` are
somewhere in the union, and then failed inside `execute`.

Adds `KernelRegistryEntry::input_dtype_constraints`, a per-input-slot override
that the node filter applies instead of the union, populated for `MatMulNBits`
(float compute on A/scales/bias, uint8 on B/zero_points, int32 on g_idx) and
`QLinearMatMul` (float32 scales, uint8/int8 operands and zero points) directly
from those kernels' `require_dtype` calls. Every other op keeps the union,
which is exact for a uniform op.

Also:
- `Mul(x, x)` falsifier for the input-slot dedup. ORT dedups *graph inputs*
  too, not only initializers, so the old positional mapping ran off the end for
  any node naming one value twice: `input slot 1 maps to ORT input 1, but ORT
  bound only 1 input(s)`.
- A regression test pinning that a float16-zero-point `MatMulNBits` is declined
  and still computed, rather than claimed and failed.
- Unit test for the per-slot filter; corrected a line-wrapped error message and
  two stale doc comments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the squad/roy-matmul-claim-bugs branch from ea13281 to 905f7c4 Compare August 17, 2026 03:14
`ONNXTensorElementDataType` is `c_uint`, which bindgen renders as `u32` on
Linux and `i32` on Windows, so hardcoding `u32` broke the Windows x86_64 and
ARM64 lanes at compile time. Use the alias.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby marked this pull request as ready for review August 17, 2026 04:37
@justinchuby
justinchuby merged commit 1158698 into main Aug 17, 2026
12 of 18 checks passed
@justinchuby
justinchuby deleted the squad/roy-matmul-claim-bugs branch August 17, 2026 04:38
justinchuby added a commit that referenced this pull request Aug 17, 2026
`main` (#1101) independently hit the same dtype-filter bug class for
`MatMulNBits`/`QLinearMatMul` and added `FLOAT_COMPUTE_DTYPES` — the
identical f32/f16/bf16 set as this branch's `MLAS_FLOAT_DTYPES`. Resolved
`kernels/mod.rs` by taking main's block verbatim, dropping the duplicate
constant, pointing `Conv` at `FLOAT_COMPUTE_DTYPES` and folding this
branch's rationale into main's doc comment.

The merge also surfaced two shape-table drifts, both caught by
`every_registered_op_has_a_shape_rule_or_is_a_known_gap`:

* The probe supplied no attributes, so `com.microsoft::MatMulNBits` —
  whose rule derives the output width from `N` — looked like a gap it is
  not. A node without `N` is malformed rather than defaulted, so no real
  graph hits that path. The probe now sweeps an attribute bundle
  alongside the empty one, keeping default-fallback rules honest while
  modelling production for rules that cannot default.

* With that fixed, the first assert stopped masking the second:
  `QLinearMatMul` gained a rule in main and was stale in `DECLINED`.
  Removed. Gap count 65 -> 64, group 3 35 -> 34.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 17, 2026
`main` is currently red on `cargo fmt --check`: #1101 landed one slice
expression that stable rustfmt reformats. That fails the "Check
formatting" step in both `Fast (Linux x86_64)` and `Rust quality`, on
`main` and on every open PR.

This applies **exactly the diff CI printed**, verbatim:

```diff
             for c in 0..group {
-                let block_values = &packed_rows[c]
-                    [block * packed_block_size..(block + 1) * packed_block_size];
+                let block_values =
+                    &packed_rows[c][block * packed_block_size..(block + 1) * packed_block_size];
```

No behaviour change, no other file touched. `cargo fmt --all -- --check`
is clean locally after it.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 17, 2026
## What this changes

When our CPU EP is selected, it must not hand work to ORT's CPU EP. This
PR
removes the **three** mechanisms by which it was doing so for the
activation
and normalization families. `GetCapability` runs three independent
fail-closed
filters and a claim must clear all of them; each of the last two was
found only
after this PR had already claimed the job was done.

### 1. The performance-based decline policy is deleted

`assignment_policy.rs` (2118 lines) measured whether we beat ORT on a
given
shape/dtype and, where we lost, returned `ClaimPreference::defer` so
ORT's CPU
EP would take the node. That whole file is gone, along with the
`claim_preference` override in `provider.rs`. `claim_preference_node`
now
returns `Claim` immediately.

Beyond the architectural rule, the policy could not have worked as
intended:

- **A deferral splits the graph.** Every declined node is a partition
boundary,
which costs fusion, prepacking and buffer reuse across it — none of
which the
  per-node threshold accounted for.
- **It cannot see the thread count.** Capability runs before the
session's
intra-op pool is known. `Sqrt` at 64 Ki wins 1.9x against a
single-threaded
ORT and loses at 0.30x against 16 threads. One number cannot be right
for
  both.
- **Sometimes there is nothing to defer to.** ORT has no bf16 kernel for
these
ops and no f16 kernel for most. Declining a bf16 `Gelu` does not get a
faster
  kernel, it gets a load failure.
- **The thresholds were tuned on one host.** Every number was measured
on a
single EPYC 9V74. Shipping it made every other machine's latency a
guess.

Removing the override is also a small capability-time win: the default
adapter
deep-clones every input `Shape` and collects dtypes for every node in
order to
build the metadata the policy consumed.

### 2. The shape-inference filter was declining the same ops anyway

Deleting the policy did not, by itself, achieve the goal.
`GetCapability` runs
a second, independent fail-closed filter
(`onnx-runtime-ep-plugin/src/ep.rs`):
it drops any claim containing a node whose `ShapeInference::for_node`
returns
`Declined`, and that match ends in `_ => Declined`. **An op we register
a
kernel for, but which is absent from that table, is silently handed to
ORT no
matter what `supports_op` answers.** This is the same mechanism that
made the
`com.microsoft` activations unreachable until #1082.

The trigonometric, hyperbolic and remaining activation ops were all in
that
gap. Now listed: `Sin`, `Cos`, `Tan`, `Asin`, `Acos`, `Atan`, `Sinh`,
`Cosh`,
`Asinh`, `Acosh`, `Atanh`, `ThresholdedRelu`, `Swish`,
`com.microsoft::Silu`
and `PRelu`.

`Silu` had been deliberately excluded with the comment that ORT has no
kernel
for it. That reasoning was backwards: an op ORT cannot run is precisely
the one
we must never hand over.

`GroupNormalization` was in the same gap and is now covered too.

### 3. The dtype filter was declining a different set of ops

Review found a third filter, and it was still handing over one of the
very ops
section 2 had just fixed.

`node_passes_dtype_filter` looks the node's op up in the plugin's
`KernelRegistryEntry` list and returns `false` when there is no entry.
That
list is built from `build_cpu_registry_with_descriptors`, which recorded
keys
as they were registered — but `register_cnn_ops` takes `&mut OpRegistry`
and
writes *past* the recording wrapper. Eighteen ops were in the registry
and
absent from the descriptors, so `supports_op` claimed each one and
capability
then dropped it:

> `PRelu`, `BatchNormalization`, `InstanceNormalization`,
`GroupNormalization`,
> `Conv`, `ConvTranspose`, `MaxPool`, `AveragePool`, `GlobalMaxPool`,
> `GlobalAveragePool`, `GlobalLpPool`, `LpPool`, `Resize`, `GridSample`,
> `AffineGrid`, `Col2Im`, `CenterCropPad`, `SpaceToDepth`

Four are activations or normalizations this EP owns. **`PRelu` is the
sharpest
case: section 2 gave it a shape rule, so it cleared filter two, and the
dtype
filter declined it anyway.** Both pure-Rust inventory tests passed while
real
ORT ran the node.

Descriptors are now derived from `OpRegistry::keys()` instead of a
parallel
recorded list, making the two sets identical by construction rather than
by
convention. They are also sorted: they get leaked into a `'static` slice
ORT
reads, and hash-map iteration order would make any snapshot diff flap.

`descriptors_derived_from_real_registry_not_hand_maintained` had been
asserting
this bug as *correct behaviour* — it allowed a delta of up to 50 entries
and
named CNN ops as the expected difference. It now asserts set equality.

The lesson, having now been caught twice: an inventory test is only as
good as
its source of truth, and two review rounds passed on tests that
enumerated the
wrong set. The only check that cannot be fooled this way is the
end-to-end one
that asks real ORT which EP got the node.

## Scope — what this does *not* fix

**64 registered ops remain in the shape-inference gap.** This PR closes
the
activation/elementwise families; it does not close the gap universally.
The
full list is asserted exactly by the new inventory test and summarised
in
`docs/performance/CPU_ACTIVATION_GAPS.md`:

- **20 data-dependent** — output shape is a function of an input's
*values*
(`NonZero`, `Unique`, `Compress`, `Expand`, `Tile`, `Pad`, `TopK`,
`Split`,
  `Unsqueeze`, `Resize`, `AffineGrid`, `Col2Im`, `CenterCropPad`, ...).
  Correctly declined today, though most carry a constant initializer in
practice, so a pass that resolves initializer values at capability time
could
  claim them.
- **10 internal fusion ops** created after capability, never candidates
(`FusedGemm`, `FusedAttention`, `FusedMatMulBias`, the `pkg.nxrt` ops).
- **34 inferrable but unwritten — this is the work.** Ten are one-line
  shape-preserving rules (`QuantizeLinear`, `DequantizeLinear`,
  `CastLike`, `ScatterND`, `Trilu`, `CumSum`, ...); nine
  are pooling/CNN geometry (`MaxPool`, `AveragePool`, `Global*Pool`,
  `ConvTranspose`, `GridSample`, `SpaceToDepth`) inferrable exactly as
`build_conv` already does for `Conv`; eight more follow from attributes
(`ArgMax`, `Flatten`, `GatherElements`, `Size`, ...); the rest are
contrib
  and model ops.

Two entries deserve singling out. `com.microsoft::Attention` is *the*
attention
op in exported GenAI models, and the existing opset-23 arm is guarded to
the
default domain, so we hand it over. `LinearAttention` (both domains) and
`com.microsoft::CausalConvWithState` are the Qwen3.5 / Qwen3-Next hybrid
linear-attention primitives — **ORT has no kernel for them at all**, so
declining them does not get a faster implementation, it gets a load
failure.

Closing that remainder is the next PR. It is a different domain from the
activation kernels and each entry needs its own numeric test.

### Corrections to my earlier figures

I published two wrong counts before this test existed, in opposite
directions,
and the test found both.

**66, then 52, now 66 again — for different reasons each time.** The
first
figure came from a scratch script that text-matched the table and missed
its
guard arms, so it reported the whole `Reduce*` family (handled by
`op_name if
is_reduction(op_name)`) as a gap. Correcting that, I over-corrected and
claimed
the pooling family "has rules already" — it does not; `compute.rs` has
no
pooling arm at all. The 52 figure was also built on
`build_cpu_registry_with_descriptors`, which is **not** the registry:
`register_cnn_ops` writes straight to the inner `OpRegistry`, so 14 CNN
ops and
`PRelu` never appear in the descriptors at all. The test now enumerates
`OpRegistry::keys()` — the same set `supports_op` consults.

**`MoE`/`QMoE`/`LinearAttention`/`CausalConvWithState` were
misclassified as
internal fusion ops.** They are read from exported models —
`deepseek_v2_tiny_qmoe_native_e2e.rs` asserts a loaded graph contains a
`com.microsoft::QMoE` node, and the linear-attention pair are Qwen3.5
primitives. All are real gaps.

**Three gaps I had missed entirely:** `Unsqueeze` (declines whenever
`axes` is
input[1], i.e. every opset-13+ graph), `com.microsoft::Attention`, and
`EyeLike`.

This is the argument for the inventory test: hand-maintained prose about
which
ops reach ORT was wrong three times in a row, in both directions, and
each time
it read as confident.

## Tests

### Inventory - the test that would have caught this class of bug

`every_registered_op_has_a_shape_rule_or_is_a_known_gap` enumerates
`OpRegistry::keys()` and asserts the set of ops that decline shape
inference
*exactly*. Registering an op without a shape rule fails; adding a shape
rule
without removing the op from the list also fails. Neither direction can
pass
silently, and the allowlist doubles as the gap inventory. It needs no
ORT, so
it runs in every job rather than only the ORT-gated one.

The probe is a sweep, not a point: opsets {1, 13, 18, 22, 23} x arities
1..4 x
ranks 1..4, counting an op as declining only when nothing in the matrix
produces a rule. A single one-input rank-2 probe reported `Conv` as a
gap,
because `build_conv` reads `input_shapes[1][0]` for its output channel
count
and needs rank >= 3. Sweeping removes that artifact and keeps the
allowlist from
encoding one arbitrary opset.

Verified both directions falsify: deleting `| "Cos"` fails with `[("",
"Cos")]`;
adding `| "Trilu"` fails with `these ops now have a shape rule but are
still
listed as declined: [("", "Trilu")]`.

`no_activation_or_norm_op_is_left_to_ort` is a standing guard on the 39
activation and norm ops this EP owns - the families #1082, #1093 and
#1097 made
reachable. It asserts in both directions: a filter of the form
`registered.contains(op) && declines(op)` would let a *deleted
registration*
pass silently, which is the same hand-off by a different route. Verified
by
renaming the `Silu` kernel key, which now fails with `these ops are no
longer
registered by the CPU EP, so ORT will execute them: [("com.microsoft",
"Silu")]`. That direction immediately caught two entries I had wrong:
`Swish`
is registered in the default domain, not `com.microsoft`, and
`HardSwish` has
no kernel at all.

### Assignment sweeps

Two sweeps replace the 13 deleted deferral tests. Both iterate 21
fixtures:

- `no_supported_node_is_ever_left_to_the_ort_cpu_ep` - for every
fixture, the
  op under test appears in *our* EP's node list and in no other EP's.
- `every_fixture_loads_with_cpu_fallback_disabled` - loads each fixture
with
  `session.disable_cpu_ep_fallback`, so a silent hand-off becomes a load
  failure rather than a slow success.

Verified the sweep falsifies too: with `| "Sin"` removed from the table
it
fails with `[sin_assignment_f32] ours=[], others=["Sin"]`.

Both run fail-closed in CI: `conformance_setup` panics rather than
skipping when
`NXRT_REQUIRE_ORT_TESTS=1`, which the `CLI ORT` job sets. (That job's
plugin
step was itself being skipped whenever an earlier step failed - fixed
separately in #1096.)

`erf_reference` would have become dead code when the deferral tests were
deleted. Rather than remove it, it is now used by
`float16_biasgelu_runs_on_our_ep_with_correct_numerics`, which checks
*our*
kernel's numerics - more important now that we always execute `BiasGelu`
instead of sometimes declining it.

## Docs

`CPU_MATMUL_ASSIGNMENT.md` is reframed from claim/defer to win/gap.
Every
measurement is unchanged — the numbers still say exactly where we are
slower
than ORT; they are now a work list rather than a decline table.

`CPU_ACTIVATION_GAPS.md` is new: every range where we still lose, and
the two
root causes. Neither is polynomial accuracy:

1. **Our elementwise kernels are single-threaded** while ORT splits
across its
intra-op pool. This is the flat ~0.7-0.8x plateau across the f32
activation
   family, and it is worth more than any further approximation work.
   `KernelContext_ParallelFor` is the untried lever.
2. **~1.2 us of fixed per-node plugin dispatch overhead**, which is what
the
   ~0.75-0.8x at n=1 measures.

## Validation

`cargo fmt`, clippy clean on the three affected crates, 1266 `ep-cpu`
lib tests, 224 `ep-plugin` unit tests, 2 inventory tests, and 37 plugin
E2E tests under `NXRT_REQUIRE_ORT_TESTS=1` with real ORT.



## Update — third decline path (commit `67a611353`)

Opus review round 4 returned **NO-GO** on the grounds that a fourth
decline
path existed and the PR's central claim was therefore still false. It
was
right. See section 3 above.

Independently verified before fixing: 18 of the 177 unique `(domain,
op_type)`
pairs in the registry had no descriptor, including `PRelu`,
`BatchNormalization`, `InstanceNormalization` and `GroupNormalization`.
Counting individual registry entries (`op_type` + `domain` +
`since_version`,
the unit `OpRegistry::len()` reports) the registry holds 208, and
descriptors
now match it exactly —
`descriptors_derived_from_real_registry_not_hand_maintained`
asserts that equality.

New guards, each verified to fail when its invariant is broken:

| guard | falsified by | observed failure |
| --- | --- | --- |
| `every_registered_op_has_a_kernel_registry_entry` | filtering `PRelu`
out of descriptors | `1 registered ops have no kernel-registry entry ...
["::PRelu"]` |
| `activation_and_norm_ops_clear_every_capability_filter` | same |
`::PRelu: no kernel-registry entry (dtype filter declines it)` |
| `prelu_assignment_f32` (real ORT) | same | `ours=[], others=["PRelu"]`
→ `'PRelu' must run on this EP` |
| `descriptors_derived_from_real_registry_not_hand_maintained` | same |
names the missing ops instead of tolerating a delta of 50 |

After the fix, real ORT reports `ours=["PRelu"], others=[]` and
`ours=["GroupNormalization"], others=[]`.

Writing `activation_and_norm_ops_clear_every_capability_filter`
immediately
found two more real gaps: **`Celu` and `Mish` have no kernel at all**.
That is a
missing feature rather than a decline, so they are excluded from that
test with
a comment naming them, and recorded in `CPU_ACTIVATION_GAPS.md` under a
new
"Activations with no kernel at all" section rather than quietly dropped.

Re-validated: `cargo fmt --all --check`, scoped clippy clean, 1267
`ep-cpu` lib
tests, 224 `ep-plugin` unit tests, and 57 plugin tests under
`NXRT_REQUIRE_ORT_TESTS=1` (37 E2E + 4 coverage + 9 + 6 + 1).

## Update — Opus round 5: `GO WITH FINDINGS`

Round 5 confirmed the central claim now holds, verified against real ORT
(`ours=["PRelu"], others=[]` and `ours=["GroupNormalization"],
others=[]`), and
traced every node-removing gate in `ep_get_capability_inner` to confirm
no
fifth decline path exists for the activation/norm families. Two
findings, both
addressed:

**Finding 1 (minor, real) — `Conv` advertised a dtype its kernel
rejects.**
Giving `Conv` a `KernelRegistryEntry` was itself an over-claim:
`supported_dtypes_for_op("Conv", "")` returned `FLOAT_DTYPES`, which
includes
f64, but `ConvKernel::execute` rejects anything outside f32/f16/bf16.
Before
this PR `Conv` had no descriptor so an f64 `Conv` was declined; after
it, the
node would clear both filters, compile, and then fail at `Run`. Fixed by
adding
`MLAS_FLOAT_DTYPES` and giving `Conv` its own arm.

This is not a re-introduced fallback. Advertising a dtype we cannot
execute is
a different thing from declining one we can: f64 `Conv` is genuinely
unsupported, and reporting that honestly at capability time is correct.
The
rest of the CNN family really does dispatch f64 through
`dispatch_float!` and
keeps `FLOAT_DTYPES`. Pinned by
`conv_does_not_advertise_a_dtype_its_kernel_rejects`.

**Finding 2 (minor, docs) — stale counts in this description.** The
breakdown
said 20 + 10 + 36 = 66 while the total said 65, and still listed
`GroupNormalization` among the unwritten shape-preserving rules even
though
this PR wrote one. Corrected to 35 above. The "177" figure was unique
`(domain, op_type)` pairs, not `OpRegistry::len()` (208) — both are now
stated
explicitly. The committed `CPU_ACTIVATION_GAPS.md` was already correct.

---

## Merge with `main`

`main` moved under this branch and #1101 independently hit the *same*
dtype-filter
bug class, for `MatMulNBits` and `QLinearMatMul`, adding
`FLOAT_COMPUTE_DTYPES` —
byte-for-byte the same f32/f16/bf16 set as this branch's
`MLAS_FLOAT_DTYPES`.
Two people finding the same trap independently is the strongest evidence
that
the fail-closed dtype filter needed the systematic fix in this PR rather
than
another per-op patch. `kernels/mod.rs` was resolved by taking main's
block
verbatim, deleting the duplicate constant, pointing `Conv` at
`FLOAT_COMPUTE_DTYPES` and folding this branch's rationale into main's
doc
comment.

The merge then made the inventory test earn its keep twice:

**The probe was lying about `com.microsoft::MatMulNBits`.** `declines()`
supplied
no attributes, deliberately: an attribute-dependent rule should fall
back to the
ONNX default when the attribute is absent, and the empty bundle keeps
that
honest. But `MatMulNBits` derives its output width from `N`, and a node
without
`N` is *malformed*, not defaulted — `for_node` declines it, correctly.
No real
graph reaches that path, so the test was reporting a gap that does not
exist.
The probe now sweeps a plausible attribute bundle **alongside** the
empty one,
so default-fallback rules are still checked against absence while rules
that
cannot default are modelled the way production sees them.

**With that fixed, the first assert stopped masking the second.**
`QLinearMatMul` gained a real rule in main and was stale in this
branch's
`DECLINED` list. Removed. That is the drift check doing exactly the job
it was
written for — the list cannot quietly stop describing reality in either
direction.

Gap count 65 → 64; group 3, 35 → 34. `CPU_ACTIVATION_GAPS.md` updated to
match.

Post-merge validation, all green: `cargo fmt --all -- --check`; scoped
clippy
(`onnx-runtime-ep-cpu`, `-ep-plugin`, `-ep-cpu-plugin`, `--all-targets`)
clean;
1280 `ep-cpu` lib tests; and the full plugin suite under
`NXRT_REQUIRE_ORT_TESTS=1` — 40 real-ORT E2E assignment tests plus the 4
inventory tests.

> The `Fast` lane on this branch will stay red on `cargo fmt --all --
--check`
> until #1107 lands. That failure is `main`'s, from #1101, in
> `matmul_nbits.rs` — a file this PR does not touch. The fix is
deliberately
> **not** duplicated here; this branch will pick it up by merging `main`
once
> #1107 is in.

---------

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.

1 participant