Skip to content

perf(ir): keep is_dense off the heap for ordinary ranks - #1433

Merged
justinchuby merged 28 commits into
mainfrom
squad/resch-is-dense-inline
Aug 19, 2026
Merged

justinchuby merged 28 commits into
mainfrom
squad/resch-is-dense-inline

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Part of #1077.

What

is_dense collected the non-trivial (stride, size) extents into a Vec on
every call. It runs once per operand per node on the CPU plugin dispatch path,
so a rank-1 tensor of eight floats paid a malloc/free pair to have four
numbers inspected.

Rank <= 8 covers every tensor ONNX produces in practice, so those extents now
live on the stack. Higher ranks keep the heap path rather than impose a limit
the signature does not have. Both paths hand the same slice to the same
routine
(dense_extents), so there is exactly one implementation of the
predicate and the two cannot drift.

Why this function

perf record sampling of the 100-node 8-element Relu chain, our EP side
alone (NXRT_MM_BENCH_SIDE=ours), 120k iterations so the steady state
dominates rather than model construction:

% self time symbol
23.55 compute_execute::{closure#0}
23.16 malloc / _int_free / cfree
5.25 is_dense + the Vec it built
2.97 WorkspacePlanCache::lookup
2.61 __memcmp_avx2_movbe
~3 the elementwise arithmetic the graph exists to do

is_dense cost more than the kernel work it was gating.

Measurement

Paired A/B, same host and session, three interleaved repetitions, 100-node
8-element Relu chain, one thread on both sides, CPU fallback disabled,
assigned == executed enforced by the suite:

ours p50 ratio p50
control 0.0324 / 0.0330 / 0.0329 ms 1.367 / 1.375 / 1.368
with fix 0.0307 / 0.0307 / 0.0309 ms 1.261 / 1.299 / 1.316

-6.4% on our side. The two sets are non-overlapping across every
repetition and the effect is well clear of this host's ~4% wall-clock noise
floor. Per-node slope (depth 100 minus depth 1, divided by 99) moves from
0.307 to 0.285 us/node against ORT's 0.223.

Correctness

is_dense had no tests in its own crate -- the only coverage was seven
assertions in a consumer crate. The new differential test runs the
pre-optimisation implementation verbatim as an oracle over ranks 0..=10,
straddling the inline bound in both directions, across contiguous, permuted,
zero-sized, negative-strided and length-mismatched cases. It also asserts the
corpus actually reached the dense arm, so it cannot silently degenerate into
"everything was rejected and both agreed".

Mutation-verified -- the test fails for each of:

mutation result
inline array not truncated to the filled length FAILS
d > 1 filter dropped on the inline path FAILS
stride not taken as absolute FAILS
INLINE_RANK lowered to 2 passes -- correctly, since the array size and the branch bound derive from the same constant, so this is a performance-only change and not a defect

cargo test -p onnx-runtime-ir 69 passed; cargo test -p onnx-runtime-ep-cpu --lib 1447 passed; plugin_ort_e2e 55 passed including
every_assigned_node_is_also_executed_by_this_ep; clippy clean.

Also in this PR: is_contiguous (a null result, kept as a simplification)

is_contiguous had the identical shape of defect -- it built the entire
row-major stride vector on the heap and compared slices against it. It now
accumulates the expected stride while walking backwards, with no allocation and
a short-circuit on the first mismatch.

This is not claimed as a performance win. On the dispatch benchmark it is a
null result, because our elementwise fast path gates on is_dense and reaches
is_contiguous barely at all -- compute_contiguous_strides is 0.04% of
sampled self time. Paired A/B, three repetitions:

ours p50, 100 nodes
is_dense fix only 0.0307 / 0.0307 / 0.0309 ms
plus is_contiguous 0.0310 / 0.0311 / 0.0306 ms

Indistinguishable. It is kept because it removes an allocation and a line of
indirection from a predicate used across the dtype-conversion, elementwise and
host-ABI paths this benchmark does not exercise -- negative complexity, unlike
#1422, which was closed unmerged for being a null result that added
representation complexity.

Short-circuiting also means the walk cannot overflow where the materialising
version could, since that one built every stride before comparing any of them.

Differential test against the previous implementation over ranks 0..=6, with
exactly-contiguous, one-axis-disturbed and arbitrary strides plus ragged
lengths, asserting the corpus actually reached the accepting arm.
Mutation-verified against three corruptions: product advancing on the wrong
index, walking forwards, and dropping the length check.

Review follow-ups (applied)

Independent adversarial review returned APPROVE WITH NITS, no correctness
defects, and confirmed by diff that the differential oracle is a faithful
verbatim copy of the pre-change implementation. Two nits were real and are
fixed:

  • The checked_dense > 100 guard was satisfied four times over by rank-0
    shapes, which return true from the empty-extents early exit without
    reaching the sort-and-product logic the guard names. It now counts only cases
    with a dimension above 1; the corpus supplies 836 of them.
  • Added a deterministic test for a rank of exactly INLINE_RANK with every
    dimension non-trivial -- the single input where the filled length equals the
    whole array, so the truncation mutation cannot be caught. It was reached once
    by chance in the random corpus and should not depend on the seed. It also
    covers dense-but-not-row-major.

Mutation testing additionally showed the pairs[0].0 != 1 early rejection is
redundant -- the loop's first iteration makes the same comparison. It is
preserved verbatim from the original and now labelled so a reader does not take
it for load-bearing.

Stacking

Based on squad/resch-plan-cache (#1430). Auto-merge deliberately off
while the base is a non-main branch.

justinchuby and others added 16 commits August 19, 2026 03:54
Issue #1077 asks why our per-Run dispatch overhead is higher than ORT's.
Answering that repeatedly has meant hand-editing `Instant` probes into the
hot path, measuring, and tearing them out — which is slow, unreviewable,
and leaves nothing behind that would catch a regression.

This adds `dispatch_probe`, a counter and phase-timing module gated behind
a non-default Cargo feature, and wires it through the CPU dispatch path:
ORT callback entry, input metadata queries, tensor binding, output
allocation, dispatch lookup, kernel invocation, and status/ABI crossing.
It also tallies FFI round trips, heap allocations and `OrtStatus`
creations, which are the three things actually worth removing.

The counters are kept twice. The thread-local copy is precise, and is
what makes exact-count assertions possible without a neighbouring test
bleeding into the measurement. The global mirror exists because ORT picks
the thread that runs `Compute`, and the e2e harness reading these numbers
back over the C ABI is not necessarily on it; a thread-local-only probe
would report zero there and read as "we made no FFI calls" rather than
"you are on the wrong thread".

Without the feature the whole thing is a `Drop`-less zero-sized guard and
a set of `#[inline(always)]` empty functions, so production builds pay
nothing. `probe_is_compiled_out_in_production` asserts that directly
rather than trusting it. Timing is gated a second time, on
`ONNX_GENAI_PROFILE_DISPATCH=1`, because a test asserting call counts
does not want two `Instant::now()` calls added to every phase it measures.

The counts themselves are pinned by a hand-built `OrtApi` in
`kernel_ctx`, which answers just enough of the C API to run
`read_inputs` without a live ONNX Runtime. Reading one input costs eight
round trips and five allocations today; three inputs cost 1 + 3x7 and
1 + 3x4, so the model is linear rather than a single data point. Any
change that adds a round trip or an allocation to the per-Run path now
has to come here and edit a number in a diff a reviewer can see.

Two of the five allocations are already visibly waste: the
`format!("input {i}")` label is built eagerly on every input even when
validation succeeds, and `compute_execute` formats a staging log line
unconditionally before the function that early-returns when logging is
off. Those are removed in a follow-up, which this change exists to
measure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`probe_is_compiled_out_in_production` compared `snapshot()` after some
counting against `snapshot()` before it. Both are calls to the same
constant function in a disabled build, so the assertion held no matter
what the probe did — verified by mutation: a version whose disabled
`count_n` recorded into a static and whose `snapshot` returned a non-zero
`Counters` passed the test unchanged.

Compare against `Counters::default()` instead, which is an absolute claim
rather than a self-consistency one, and fails against that mutation.
Also assert `!needs_drop::<PhaseGuard>()`, so a `Drop` impl added to the
production guard — the one way it could regain a cost while staying
zero-sized — is caught.

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

Review of #1387 found the module's central claim was false. It documented
`OrtFfiCall` as "how many ORT FFI calls a Run made" and called it the
headline number for #1077, but only `read_inputs` and `allocate_output`
were instrumented. On the happy path of a single-node Run, `device_mem_info`,
`ort_input_mem_info`, `mem_info_is_device`, `alloc_scratch`,
`host_pool::install` and `CreateStatus` all reach into ORT and none of
them were counted. A probe that under-counts while presenting itself as
exact is worse than no probe: it invites the reader to conclude the
dispatch path is cheaper than it is, which is precisely the conclusion
#1077 exists to prevent.

Every leaf call through an `OrtApi` function pointer in this crate is now
counted. Rather than assert that in a comment, `ffi_coverage` enforces it:
it scans the source of each file that reaches into `OrtApi`, extracts the
API members it names — ORT's C API is CamelCase and our own fields are
snake_case, so `.CamelCase` isolates them cleanly — and fails if that set
or the instrumentation count moves. Adding an uninstrumented call now
fails with the member's name in the message. Verified by mutation: adding
a stray `GetTensorShapeElementCount` reference failed with exactly that.
A second test pins that the scan finds real members, so the guard cannot
degrade into a heuristic that matches nothing and passes forever.

`DispatchAlloc` gets the opposite treatment, because hand-placed counts
genuinely cannot be exhaustive: `Vec::new()` does not allocate,
`Vec::with_capacity(0)` does not allocate, and whether a `collect`
allocates once or twice is a property of `size_hint` rather than of
anything visible at the call site. It is now documented as a lower bound
that is exact only for `read_inputs`, and callers who need the true
whole-Run figure get `CountingAllocator`, a `GlobalAlloc` wrapper they can
install in a test or benchmark. This crate deliberately does not install
it — a library that defines a `#[global_allocator]` takes that choice away
from every dependent.

Also from review:
- A node with no inputs was charged one allocation for a
  `Vec::with_capacity(0)` that never happens. Now conditional, and pinned
  by a test.
- The phase timings are documented as what they are: non-exhaustive on
  the success path, and nesting on the error path, where `StatusCrossing`
  opens inside whichever phase was live because a guard closes at scope
  exit rather than at the early return. `DispatchLookup` is entered twice
  per node and reports the total. Summing them does not yield Run time in
  either direction, and a reader who assumed it did would be misled.
- `probe_is_compiled_out_in_production` cannot prove `count` has no side
  effect — a disabled build has no storage to observe one — so it no
  longer implies it does. What it can prove, it does: the guard is a ZST
  and does not need dropping.

Validation: clippy clean and `--lib` green in both feature configurations
(253 without, 260 with), and 55/55 `plugin_ort_e2e` against real ORT.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Stacked on #1387 (base `squad/resch-dispatch-probe`). The
instrumentation there exists to make changes like this one provable;
this is the first of them.

## Why

Two heap allocations sat on the per-`Run` dispatch path purely to serve
diagnostics that are switched off.

**1. `staging_log(&format!(…))`.** `staging_log` returns immediately
when the transfer trace is disabled — but the argument is evaluated
first. Every one of these builds a `String` and formats each
interpolated value on the way to a function that drops it on the floor.
One of them is in `compute_execute`, before any real work happens:

```rust
staging_log(&format!(
    "[plugin/staging #982] compute_execute enter: entries={} routing={} device_staging={}",
    exported.entries.len(), exported.routing.is_some(), exported.device_staging.is_some()
));
```

So every dispatch in every production run has been paying for a debug
line nobody asked for.

**2. `format!("input {i}")` in `read_inputs`.** Built for every input on
every `Run`, to label an error that is almost never raised.

## What

A `staging_log!` macro that checks `staging_trace_enabled()` *before*
formatting, so a disabled trace costs one relaxed `OnceLock` load and
nothing else. Thirteen call sites convert.

`validate_dims` now takes `impl Display` instead of `&str`, so the
caller passes `format_args!("input {i}")` — which borrows its arguments
and formats only if a message is actually produced. Same treatment for
`transfer.rs`'s `CopyTensors[{i}]`.

## Evidence

The pinned counts in `dispatch_cost` (added by #1387) move:

| | before | after |
|---|---|---|
| allocations, 1 input | 5 | **4** |
| allocations, 3 inputs | 1 + 3×4 | **1 + 3×3** |

That is the instrumentation earning its keep. The improvement is a
number changing in a test — reproducible on any machine, by anyone, at
any time — rather than a claim resting on a benchmark. That matters here
in particular: the box is currently shared with other work and sitting
at ~74% idle against my ≥93% gate, so a wall-clock A/B taken right now
would be noise. I will post interleaved p50/p90 once the machine
quiesces, but the correctness of the change does not depend on it.

## Correctness

- **The trace still works.** Ran the e2e suite with
`ONNX_GENAI_PLUGIN_TRANSFER_TRACE=1` and confirmed the `compute_execute
enter` line still appears. A macro that silently disabled the trace
would otherwise be indistinguishable from one that made it cheap.
- **No side-effecting expression moved inside a now-conditional macro.**
I checked every converted site; the only mutation in the neighbourhood
(`to_stage.push(p)`) is outside the macro call.
- **Emitted text is unchanged** — the format strings and arguments are
copied verbatim, only the gate moved.
- clippy clean and `--lib` green in both feature configurations (253 /
260), and **55/55 `plugin_ort_e2e`** against real ORT.

Relates to #1077.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…uted-loop fixes (2.31 -> 1.71 at depth 100) (#1397)

> **Note on scope.** Enabling auto-merge across the stack caused each
stacked PR to merge into its *parent feature branch* (those branches are
unprotected; only `main` requires checks), and GitHub retargeted the
children. No `main` protection was bypassed and no work was lost, but
this PR now carries the whole dispatch-overhead stack below #1387, not
just the memory-info change:
>
> - #1394 — the #1077 benchmark grid
> - **this PR** — `device_mem_info`, 7 → 4 FFI calls per `Run`
> - #1401 — routed-loop instrumentation, the O(depth²) retirement scan,
lazy absent strides, and the 8-element dispatch-isolating cases
>
> Each was independently reviewed before it merged; the sections below
cover the memory-info change, and #1394/#1401 retain their own
descriptions and review records. #1387 (probe + lazy formatting) remains
a separate PR to `main`.

## Stop asking ORT three times where the inputs live

Part of #1077. Stacked on #1394 (which measured the cost this removes).

### The finding

Every `Compute` call resolves where a routed subgraph's intermediate
tensors should be allocated. On a host EP — and the CPU plugin is always
a host EP — that resolution cost **7 FFI calls** for a single-input node
to arrive at the answer "host memory". Three of them were asking ORT for
something already in hand.

`device_mem_info` scans the inputs looking for a device-resident one:

1. per input: `GetValue` → `GetTensorMemoryInfo` → `mem_info_is_device`
2. on a host EP it never finds one, so the scan runs to completion
3. it then falls through to "use input 0's memory info" — and **fetches
input 0 again**, two more calls for a value it had already looked at and
discarded
4. the caller then asked `mem_info_is_device` on the result — a
**third** query for a fact the function had just established internally
and thrown away

### The change

The scan remembers input 0's memory info as it walks, and returns
`(mem_info, is_device)` so the caller does not have to re-derive the
second half.

The fallback fetch stays, for the one case that genuinely needs it: no
input count available, so the scan never ran and nothing was remembered.

### Why this is behaviour-identical

- The scan visits the same inputs, in the same order, and returns on the
same condition.
- The remembered value is exactly what the re-fetch would have produced:
both are input 0's `OrtMemoryInfo`, and it is immutable for the lifetime
of the value.
- The `recon_mem_info` branch reports `is_device = true` without asking.
That branch is only reachable when the scan found a device-resident
input, so it is device-resident by construction — which is precisely
what the removed call was confirming.

### Evidence

`mem_info_cost` (new, in `compute.rs`) drives `device_mem_info` through
a hand-built `OrtApi` and pins the call count.

| scenario | before | after |
|---|---|---|
| host EP, 1 input | **7** | **4** |
| device-resident input (short-circuit) | 3 | 3 |
| no input count (fallback path) | 2 | 2 |
| zero inputs | 0 | 0 |

The "before" column is not a recollection — it is the same test run
against the previous implementation.

Three FFI calls per `Run`, off the fixed per-`Run` cost that #1394
measures at **~0.9 µs worse than ORT's**.

### Why counters and not a stopwatch

This machine has not been quiet enough all session for a trustworthy
wall-clock A/B (`mpstat` shows cores at 75–78% idle against a ≥93%
gate). An FFI-call count is deterministic and load-independent, so it is
reproducible now and will still be reproducible on someone else's
machine. That is the case for #1387 existing.

### Also pinned

The three non-host paths, so a future change cannot quietly break them
while the host count stays pretty: device-resident short-circuit,
missing input count, and a node with no inputs (reports no memory info
rather than inventing one).

### Validation

- `cargo test -p onnx-runtime-ep-plugin --lib` — 253 pass (debug;
`--release` silently disables `debug_assert!`)
- same with `--features dispatch_probe` — 264 pass
- `plugin_ort_e2e` — **55/55**, including
`every_assigned_node_is_also_executed_by_this_ep`
- clippy clean in both feature configurations

No ORT CPU fallback. No MLAS.

---

## Independent review (Opus 4.8, adversarial) — **APPROVE WITH NITS**,
all findings addressed

Commissioned specifically to attack "behaviour is unchanged in every
case". It held for every EP that ships, with one crack worth fixing.

**Finding 1 (MINOR) — the recon branch's `is_device = true` was
convention, not construction.**

Upheld, and fixed properly. My comment claimed the reconstruction is
"device-resident by construction". The reviewer traced it:
`recon_mem_info` is built from the EP's reported `device_type`, and
`device_staging` is attached whenever an EP supplies a
`HostToDeviceCopier` — and **nothing in that trait requires a
copier-providing EP to be non-CPU**. Every one today is (CUDA→GPU,
QNN→NPU), which is exactly what made the hardcode look safe. A CPU-typed
EP that grew a copier would build a host-typed memory info and the
hardcode would hand it to the device scratch path — host pointers
treated as device pointers, the failure this code exists to prevent,
inverted. The old code had no such exposure because it asked ORT.

Rather than paper over it with a `debug_assert`, the invariant is now
structural. `mem_info_is_device` is exactly `device_type != CPU`, and
the reconstruction is created by passing that same `device_type` to
`CreateMemoryInfo_V2` — so recording it at construction reproduces the
old query **in every case, including the one that does not exist yet**,
and still costs no FFI call.

**Finding 2 (MINOR) — the one branch whose reporting changed was
untested.** Correct: all four original tests passed `staging = None` and
never reached it. Three tests added — CPU-typed reconstruction is not
reported as a device, device-typed one is (4 calls, not 5), and a real
device input still wins over the fallback.

Falsified: restoring the hardcoded `true` fails **exactly one** test,
the one naming the case; the other six correctly stay silent.

**Finding 3 (NIT) — test 2's `== 4` does not distinguish new from old.**
Accurate. The device-input path cost 4 before as well; the real guard in
that test is the tuple value, not the count. Left as-is (it pins the
device path against future regressions), noted here so nobody reads it
as evidence for this PR.

### Claims the reviewer tried to break and could not

These are now load-bearing rather than merely unchallenged:

- **Pointer stability** — the remembered input-0 memory info cannot
differ from what the re-fetch produced: same `OrtValue`, straight-line
code with no intervening mutation, and the pointer's lifetime window is
identical in both versions.
- **The fallback path does not hardcode anything** — it still queries,
so a device-resident input on that path still reports `true`, exactly as
before.
- **Zero-input nodes** — identical (`None`, not an invented memory
info).
- **Caller equivalence** — `Option<(*const _, bool)>` is `Copy`; no
move, no shadowing, no ordering change.
- **`mem::zeroed::<OrtApi>()`** — sound; all fields are `Option<extern
fn>`, zero is the null-fn niche, and the sentinel pointers are never
dereferenced.
- **The `ffi_coverage` tripwire stays honest** — 9 members / 9
`ort_call()` sites, unchanged and correct: this PR removes *dynamic*
calls, not *static* sites.
- **Concurrency** — entirely stack-local; no new shared mutable state.
- **No vacuous test** — the reviewer was briefed that I have twice
shipped a test that passed against the broken version, and specifically
hunted a third. None found.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Issue #1077 has ~7 allocations per node left and no way to say where
they are. This adds per-phase allocation attribution and gets it
reporting from a live routed dispatch.

The allocator hook charges each allocation to the innermost open phase.
PhaseGuard now saves the enclosing phase on enter and restores it on
drop, so nested phases -- which the dispatch path really does produce,
because a guard closes at scope exit rather than at an early return --
attribute to the inner one rather than losing the outer.

Reading it back needs the counters to come from the library ORT
dlopens, not from the test binary: the cdylib has its own global
allocator, so an allocator installed in the harness never sees an
allocation made inside Compute. The counting allocator is therefore
installed in the cdylib under a new dispatch_probe feature, and the
harness reads totals through the existing C export.

Also fixes a resolver bug this uncovered. cdylib_resolve rebuilds the
cdylib with a fixed feature list, and dispatch_probe was missing from
it, so the rebuild silently replaced the instrumented library with a
default-feature one and the probe reported zeros for every phase. The
file already warned about this hazard for mlas. Nothing fails loudly
when a feature is omitted, so the comment now says so explicitly.

Production is unaffected: without the feature record_alloc is an
inline no-op, no global allocator is installed, and the harness path
is additionally gated on NXRT_MM_BENCH_PROBE=1.

Falsified: reverting the outer-phase restore fails exactly the two
nesting tests; the five attribution tests install the counting
allocator for real rather than calling record_alloc by hand.

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

The first live attribution run said four allocations per node and gave
no way to tell whether that was all of them. It was a third of them.

Allocations made with no phase open were being discarded rather than
counted, so the per-phase rows could not be checked against any total.
They now land in an explicit UNATTRIBUTED bucket, which makes the
columns add up: 12.11 allocations per node on a 100-node chain, of
which 4 are inside named phases and 8.03 are in the routed loop
between them. The hand-placed DispatchAlloc tally independently reads
12.19, which is the cross-check that this is now complete.

The harness also kept its own copy of the phase order, and the copy
was wrong: it labelled TensorBind's allocations "OutputMeta" and
Allocate's "TensorBind". Every number in that table was correct and
attached to the wrong phase, which is the kind of error that survives
review. The names now come from the library through a new
nxrt_dispatch_probe_phase_name export, so there is one source of
truth, and a test pins the export against Phase::name.

Falsified: renaming one exported phase fails the pinning test;
reverting the unattributed bucket fails the outside-a-phase test,
which now asserts the allocation is visible rather than absent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The allocation table said 12.11 allocations per node, and only 4 of
them were inside named dispatch phases. The other 8 were these six
vectors -- ort_outputs, buf_writes, absent_scratch, slot_kinds,
new_bufs and node_ort_operands -- being created and dropped once per
node, plus a shape clone.

They are hoisted above the loop and cleared at the top of each
iteration, so node N reuses node N-1's capacity. Clearing at entry
rather than at exit means no node can observe another node's contents
even on an early return.

buf_writes no longer clones the output shape. It stores the output
slot instead and indexes output_shapes, which is already live for the
whole iteration; the clone was one more allocation per buffer-bound
output for data that already existed.

Measured on the 8-element Relu chain at depth 100, 1 thread:

  allocations per node   12.11 -> 8.17
  bytes per Run         102288 -> 45792
  ratio vs ORT CPU       1.713 -> 1.416
  per node               0.39us -> 0.31us  (ORT 0.22us)

Depth 1 is unchanged at 1.517, which is the expected signature: this
touches per-node cost only, not the fixed per-Run floor.

Dynamic shapes, optional/absent slots, concurrency and retained
outputs are unaffected -- the vectors were already per-node scratch
with no cross-node meaning. 259 lib tests and all 55 e2e conformance
tests pass, including every_assigned_node_is_also_executed_by_this_ep.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The plan cache promotes every hit to the front so a hot signature stays
ahead of a one-off prefill shape once the cache is full. In the steady
state -- one shape, every node, every Run -- the hit is already at the
front, so the promotion is identity, but remove + insert still memmoves
the whole vector twice to achieve nothing.

Guarding the promotion on idx != 0 costs one compare and removes both
memmoves on the common path. Callgrind on the 100-node 8-element Relu
chain, differenced by iteration count so one-time partitioning cancels
exactly: lookup drops from 141 to 101 instructions per node, -28%. The
absolute counter is bit-reproducible across three independent runs, and
a null control (identical binary, two runs) moves it by zero.

Move-to-front was entirely untested before this change: deleting the
promotion outright passed all 259 tests. Added a falsifier that drives
the cache past capacity while re-dispatching one signature and asserts
on observed planning calls; it fails both with the promotion deleted and
with the new guard forced always-false.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The docstring claims to be a move-to-front falsifier, but the loop ran
to CAPACITY - 1, which with the hot signature fills the cache to exactly
capacity and never evicts. It therefore passed with or without the
promotion and proved nothing -- one of the two reasons move-to-front
went untested.

Flood past capacity so eviction actually happens. Mutation-verified:
deleting the promotion now fails this test as well as the new one, where
before it only failed the new one.

Also correct the mechanism described in the lookup comment. In the
profiled benchmark the cache holds a single entry, so the instructions
the guard removes are the call, bounds checks and length bookkeeping of
remove + insert, not a multi-element move. The measurement quoted is
unchanged; only the explanation of it was imprecise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
is_dense collected the non-trivial (stride, size) extents into a Vec on
every call. It runs once per operand per node on the CPU plugin dispatch
path, so a rank-1 tensor of eight floats paid a malloc/free pair to have
four numbers inspected.

perf sampling of the 100-node 8-element Relu chain, our EP side alone,
steady state: is_dense and the Vec it built together accounted for 5.25%
of dispatch time (3.32% in the collect, 1.93% in the function), against
roughly 3% for the elementwise arithmetic the whole graph exists to do.

Rank <= 8 covers every tensor ONNX produces in practice, so those extents
now live on the stack. Higher ranks keep the heap path rather than impose
a limit the signature does not have. Both paths hand the same slice to
the same routine, so there is exactly one implementation of the
predicate and the two cannot drift.

Paired A/B on the same host and session, three interleaved repetitions,
100-node 8-element Relu chain, one thread, CPU fallback disabled:

  control   ours p50 0.0324 / 0.0330 / 0.0329 ms   ratio 1.367 / 1.375 / 1.368
  with fix  ours p50 0.0307 / 0.0307 / 0.0309 ms   ratio 1.261 / 1.299 / 1.316

-6.4% on our side, non-overlapping across repetitions and well clear of
this host's ~4% wall-clock noise floor.

The new differential test runs the pre-optimisation implementation as an
oracle over ranks 0..=10 -- straddling the inline bound in both
directions -- with contiguous, permuted, zero-sized, negative-strided and
length-mismatched cases, and asserts the corpus actually reached the
dense arm rather than degenerating into rejections. Mutation-verified:
it fails if the inline array is not truncated to the filled length, if
the d > 1 filter is dropped, or if the stride is not taken as absolute.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Independent review found the `checked_dense > 100` guard was satisfied
four times over by rank-0 shapes alone. Those return true from the
empty-extents early exit without reaching the sort-and-product logic the
guard names, so it asserted nothing about that arm. Count only cases
with a dimension above 1: the corpus supplies 836 of them.

Add a deterministic test for a rank of exactly INLINE_RANK with every
dimension non-trivial. That is the single input where the filled length
equals the whole stack array, so the truncation to `..len` is a no-op and
the corresponding mutation cannot be caught -- it was reached once by
chance in the random corpus and should not depend on the seed. It also
covers dense-but-not-row-major, which the corpus only reaches by
accident.

Record that the smallest-stride check is redundant. Mutation testing
shows removing it changes no result, because the loop's first iteration
makes the same comparison. Kept for intent, now labelled so a reader
does not take it for load-bearing.

Fix the length-mismatch comment: at rank 0 popping a stride leaves two
empty slices, which is not a mismatch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby and others added 6 commits August 19, 2026 07:56
is_contiguous built the entire row-major stride vector on the heap and
compared slices against it. Accumulating the expected stride while
walking backwards answers the same question with no allocation and
short-circuits on the first mismatch.

This is a simplification, not a measured win: on the dispatch benchmark
it is a null result, because our elementwise fast path gates on is_dense
and reaches is_contiguous barely at all (compute_contiguous_strides is
0.04% of sampled self time). Paired A/B, three repetitions, 100-node
8-element Relu: 0.0310 / 0.0311 / 0.0306 ms against 0.0307 / 0.0307 /
0.0309 -- indistinguishable. It is kept because it removes an allocation
and a line of indirection from a predicate used across the dtype
conversion, elementwise and host ABI paths that this benchmark does not
exercise, not because it moved this number.

Short-circuiting means the walk cannot overflow where the materialising
version could, since that one built every stride before comparing any of
them. Strictly more defensive, and unreachable either way for shapes
whose element count fits in memory.

Differential test against the previous implementation over ranks 0..=6,
with exactly-contiguous, one-axis-disturbed and arbitrary strides plus
ragged lengths, asserting the corpus actually reached the accepting arm.
Mutation-verified: it fails if the product advances on the wrong index,
if the walk runs forwards, or if the length check is dropped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Main landed several dispatch-path changes that overlap this stack. Resolved
in favour of keeping both sides' wins rather than either side wholesale:

- staging_log! doc comment: main's wording (the macro bodies were identical).
- Scratch memory-info resolution: main moved it inside the routed branch so
  the single-node path stops paying an ORT memory-info query per
  kernel-context input per Run. Kept that placement, but on this stack
  device_mem_info already returns (mem_info, is_device) so the follow-up
  mem_info_is_device call is not made a second time. SubgraphFallback::
  Deferred maps the tuple back to the pointer.
- Single-node output views: kept this stack's absent_slot_strides, which
  builds one stride entry per *absent* slot and allocates nothing when there
  are none, over main's variant that clones every output shape whenever any
  slot is absent. Took main's lazy ort_view_iter, which drops the collect-
  then-drain Vec of views per call. The DispatchAlloc counter that named that
  Vec goes with it.
- Single-node operand list: took main's OrtOperands::Slots, which removes the
  per-call node_ort_operands Vec. Kept the DispatchLookup phase marker.

Gates: onnx-runtime-ep-plugin lib 259 passed; plugin_ort_e2e 55 passed
(including every_assigned_node_is_also_executed_by_this_ep); clippy clean on
onnx-runtime-ep-plugin and onnx-runtime-ir with --all-targets.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Base automatically changed from squad/resch-plan-cache to main August 19, 2026 19:12
Both conflicts are squash artifacts and both took main's side; this branch
touches neither the probe module nor the cdylib manifest.

Refs #1077

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

Copy link
Copy Markdown
Owner Author

Pre-merge validation (local, comprehensive)

Retargeted onto main and merged the latest main normally. Both conflicts were squash artifacts and both took main's side; this branch touches neither the probe module nor the cdylib manifest. Against current main the change is now exactly one file, crates/onnx-runtime-ir/src/layout.rs.

A/B

Tiny (8-element) Relu grid, 4 intra-op threads each side, taskset -c 9,11,13,15, 800 iters / 100 warmup, ratio = ours/ORT, lower is better.

Depth 100, clean reps: 1.179 / 1.172 / 1.166 / 1.162, median 1.169, against main (= #1430) at median 1.247. Absolute per-Run 0.0306–0.0311 ms against 0.0327–0.0333 ms.

Two further reps were discarded, not averaged in: they read 1.039 and 1.091, but their ORT arms read 0.0509 and 0.0442 ms against a usual 0.0265 ms, so both were contaminated by external load on this shared box. Discarding them makes the claim smaller. Every rep kept was gated on ~99% idle across the four pinned cores.

Gates

  • cargo fmt — clean.
  • cargo clippy --locked --all-targets -D warnings on the four affected crates — clean.
  • Full Fast (Linux x86_64) offline-linux scope, 42 packages — 3964 passed, 0 failed, exit 0. mlas-sys excluded: it starves under a concurrent full-suite run on this shared box, and this change touches zero MLAS files.
  • ORT conformance with CPU fallback disabled — 55 passed, including every_assigned_node_is_also_executed_by_this_ep.
  • Miri clean on onnx-runtime-ir layout:: — 10 tests. This is the gate that matters for this change: it replaces heap Vecs with fixed inline stack storage for rank ≤ 8, so out-of-bounds or uninitialised reads are the failure mode to rule out, and the rank > 8 fallback path is exercised alongside.
  • Cross-compile: onnx-runtime-ir clean on aarch64-unknown-linux-gnu, aarch64-pc-windows-msvc, and 32-bit i686-unknown-linux-gnu. The 32-bit target is deliberate here — inline storage sized against usize is exactly the kind of code that breaks on a 32-bit pointer width.

Carries an independent Opus review, APPROVE WITH NITS, nits fixed.

@justinchuby
justinchuby merged commit 5cb8ce4 into main Aug 19, 2026
3 checks passed
@justinchuby
justinchuby deleted the squad/resch-is-dense-inline branch August 19, 2026 19:21
justinchuby added a commit that referenced this pull request Aug 19, 2026
…de (#1472)

Removes one heap allocation and one `Drain` drop per **routed node, per
`Run`**.

## What

The routed node loop collected `TensorMut`s for every ORT-bound output
into
`ort_out_views`, then immediately `drain(..)`ed it to feed the map that
builds
`all_output_views`. The intermediate vector was never read as a vector —
every
element went straight through to the next collect.

`main` already removed exactly this shape of code on the **single-node**
path.
This does the same on the routed path, which is where it is paid *per
node*
rather than once per `Run`.

## Why it is safe

`ort_outputs.push(..)` and `slot_kinds.push(RoutedSlotKind::Ort)` happen
together in the one `NodeOutputSink::Ort` arm, so there is exactly one
`Ort`
slot kind per ORT output. The lazy iterator therefore yields precisely
the same
views, in the same order, and is exhausted identically. `Buffer` and
`Absent`
slots are untouched. `TensorMut` construction has no side effects, so
not
building views for outputs that are never consumed is unobservable — and
by the
lockstep argument there are none.

The mutable borrow of `ort_outputs` now *starts later* (at the block
rather than
before the `new_bufs` loop) and ends at the same place, so this is
strictly less
restrictive to the borrow checker, not more.

## Measurement — this is a null result, and is labelled as one

Paired interleaved A/B, `grid_relu_100_tiny` (100-node 8-element `Relu`
chain),
800 iters, 100 warmup, 4 threads, pinned to cores 9/11/13/15 verified
≥99% idle,
3 reps:

| | ratio vs ORT CPU |
|---|---|
| before | 1.193 / 1.190 / 1.180 |
| after | 1.207 / 1.192 / 1.056\* |

\* third rep contaminated (both sides inflated ~1.75×, host
interference).
Medians: **1.193 → 1.192**. Indistinguishable against a ~4% wall-clock
noise
floor.

`perf record` self time attributes ~2% to the collect and its drain
(`Vec<TensorMut>` collect 1.22%, `Drain<TensorMut>::drop` 0.79%), which
this
grid cannot resolve. This is the **third** independent confirmation of
the
finding recorded in #1077 §5e: removing allocations one or two at a time
does
not move this number.

**So it is not offered as a performance fix.** It is offered as a
simplification: it deletes a binding and a drain, removes an allocation
the
profile does show, and makes the routed path agree with the single-node
path.
That is what separates it from #1422, which was closed unmerged because
it
bought an equally null result at the cost of *added* representation
complexity.
Recorded in #1077 as a null result and **not counted toward the parity
target**.

## Validation

- `onnx-runtime-ep-plugin` lib: 261 passed
- `plugin_ort_e2e`: 55 passed, including
`every_assigned_node_is_also_executed_by_this_ep` (assigned == executed
holds)
- clippy `--all-targets` clean on the touched crate
- No ORT CPU fallback, no runtime/default MLAS

Refs #1077. Stacked on #1433 — auto-merge deliberately **off** while the
base is
an unprotected branch.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 19, 2026
Resolves 12 conflicts from the six squash landings of the #1077 stack
(#1387, #1409, #1412, #1430, #1433, #1472).

compute.rs (6): took main's side throughout. Main is the superset --
#1397's `device_mem_info` now returns `(mem_info, bool)`, #1401 builds
one stride entry per *absent* slot rather than cloning every output
shape, and the entry/lookup probe spans are new. This branch's
`has_absent` guard is subsumed by the per-absent-slot construction.

kernel_ctx.rs (6): kept this branch's one-call
`GetTensorElementTypeAndShapeDataReference` structure and interleaved
main's probe counters into both the borrowed and the legacy path, so
`Event::OrtFfiCall` stays a total. Took main's `impl std::fmt::Display`
for `validate_dims` (a superset of this branch's `Arguments<'_>`).
Kept the inline-rank output-dims path, with the allocation counter
moved to the heap-only branch where the allocation actually happens.

Two instrumentation updates the merge made necessary:

- dispatch_probe's `every_ort_entry_point_is_accounted_for` fired
  exactly as designed: this branch adds a 12th ORT API member. Table
  updated to 12 members / 12 `ort_call()` sites.

- The pinned costs `1 + 7` calls and `1 + 3` allocations describe the
  *legacy* path: `fake_api()` zeroes the struct, so the reference hook
  is null there and the fallback runs. The fast path -- the one real
  ORT 1.27 takes, and the entire point of this PR -- had no cost pin at
  all. Added `the_reference_hook_path_costs_exactly_three_ort_calls_per_input`
  (pins 1 + 3, down from 1 + 7) and
  `the_reference_hook_path_allocates_less_than_the_legacy_path`.

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

## What this is

`read_inputs` runs **once per input per `Run`**, and it asked ORT for
the element
type and the dimensions through the classic five-call sequence:

```
GetTensorTypeAndShape  →  GetTensorElementType
                       →  GetDimensionsCount
                       →  GetDimensions
                       →  ReleaseTensorTypeAndShapeInfo
```

The first of those makes ORT **heap-allocate** an
`OrtTensorTypeAndShapeInfo` and
copy the shape into it; the last frees it. Five FFI crossings and one
allocation
on ORT's side of the boundary, per input, per `Run`, to read data the
`OrtValue`
already owns.

`GetTensorElementTypeAndShapeDataReference` (ORT C API, since 1.24)
returns the
element type **and a reference to the value's own shape array** in one
call,
allocating nothing. This PR routes `read_inputs` through it.

Stacked on #1244 (since merged; this branch now targets `main`
directly).
No kernel is touched, no numerics change, and node assignment
and execution ownership are unchanged: the CPU EP still executes every
node it
claims, locally, with ORT CPU fallback off.

## The three cuts

**1. Five ORT calls and one ORT-side allocation per input → one call, no
allocation.**

The plugin already fails closed below API 27, so the hook is always
present in
practice. The five-call sequence is kept as a fallback for a host that
leaves it
null, and a host offering **neither** now fails closed with a message
naming
both — it does not silently read garbage shapes.

The borrowed pointer is used only to build the owned `shape`/`strides`
of the
`OwnedInput`; nothing retains it. ORT spells a scalar as a **null**
pointer with
count 0, and `slice::from_raw_parts` is UB on null, so that case is
guarded
explicitly — see *Correctness* below for how that guard is actually
enforced.

**2. `validate_dims` takes a lazily-formatted label, not `&str`.**

Every call site passed `&format!("input {i}")`, i.e. a heap `String`
built on the
success path purely to label an error that almost never happens. Callers
now pass
`format_args!(..)` and the string is only materialised inside the error
branches.
One `String` per input per `Run` gone. All call sites converted,
including
`transfer.rs` and the unit tests; no message text changed.

(The merge with `main` took main's `impl std::fmt::Display` signature
here, which
is a superset of this branch's original `Arguments<'_>` and keeps both
callers
working unchanged.)

**3. `allocate_output` converts the output shape to `i64` on the
stack.**

`let dims: Vec<i64> = shape.iter().map(|&d| d as i64).collect()` was a
heap
allocation per output per `Run` whose entire lifetime was the
`KernelContext_GetOutput`
call below it. Rank ≤ 8 now goes to a stack array; a taller tensor still
works,
through the `Vec`.

For a 1-in/1-out elementwise node that is **5 fewer ORT FFI calls**, one
fewer
ORT-side allocation, and **2 fewer heap allocations** per `Run`, on top
of #1244.

## A/B, one thread

`taskset -c 8-15`, this branch vs its merge parent (`origin/main` +
#1244),
**interleaved** (A,B,A,B,… so drift hits both arms equally), 400
iterations after
50 warmup, **five rounds**. Ratio is **ours/ORT, lower is better**; each
column is
the median of the five rounds. "ORT drift" is the control: ORT's own p50
measured
in the two arms, which must not move for the comparison to mean
anything.

| case | before p50 | after p50 | before p90 | after p90 | ORT drift |
rounds won |
|---|---|---|---|---|---|---|
| `sqrt_f32_4k` | 1.023 | **0.952** | 1.031 | **0.964** | +2.9 % | 4/5 |
| `leakyrelu_f32_4k` | 1.202 | **1.097** | 1.210 | **1.104** | 0.0 % |
5/5 |
| `hardsigmoid_f32_4k` | 1.213 | **1.144** | 1.226 | **1.149** | 0.0 % |
5/5 |
| `thresholdedrelu_f32_4k` | 1.316 | **1.209** | 1.314 | **1.220** | 0.0
% | 5/5 |
| `sigmoid_f32_4k` | 1.328 | **1.252** | 1.333 | **1.258** | 0.0 % | 5/5
|
| `tanh_f32_4k` | 1.368 | **1.287** | 1.369 | **1.291** | 0.0 % | 5/5 |
| `erf_f32_4k` | 1.383 | **1.327** | 1.387 | **1.329** | 0.0 % | 5/5 |
| `celu_f32_4k` | 0.430 | **0.411** | 0.435 | **0.418** | 0.0 % | 4/5 |
| `elu_f32_4k` | 0.437 | **0.416** | 0.443 | **0.422** | −1.8 % | 4/5 |
| `selu_f32_4k` | 0.467 | **0.441** | 0.471 | **0.446** | 0.0 % | 5/5 |
| `log_f32_4k` | 0.727 | **0.700** | 0.728 | **0.706** | 0.0 % | 5/5 |
| `mish_f32_4k` | 0.268 | **0.264** | 0.271 | **0.266** | 0.0 % | 4/5 |

12 of 12 cases improve at p50 and p90; 56 of 60 individual arm-pairs
favour the
change, and no case regressed in its median. In absolute terms it is
**0.2–0.3 µs
off every `Run`**: `tanh_f32_4k` 4.20 µs → 3.90 µs against ORT's 3.10
µs,
`thresholdedrelu_f32_4k` 3.10 µs → 2.80 µs against ORT's 2.30 µs. That
is the
shape of a fixed per-call cost being removed, which is what it is.

`sqrt_f32_4k` crosses below 1.00 — the plugin path now dispatches that
op faster
than plain ORT does.

## A/B, four threads

Same protocol, `NXRT_MM_BENCH_THREADS` =
`ONNX_GENAI_MLAS_THREADPOOL_THREADS` =
`RAYON_NUM_THREADS` = 4, three rounds.

| case | before p50 | after p50 | before p90 | after p90 |
|---|---|---|---|---|
| `leakyrelu_f32_4k` | 1.069 | **0.966** | 1.075 | **0.976** |
| `hardsigmoid_f32_4k` | 1.152 | **1.050** | 1.164 | **1.060** |
| `thresholdedrelu_f32_4k` | 1.212 | **1.091** | 1.228 | **1.106** |
| `sqrt_f32_4k` | 1.121 | **1.036** | 1.130 | **1.045** |
| `sigmoid_f32_4k` | 1.346 | **1.264** | 1.359 | **1.263** |
| `tanh_f32_4k` | 1.434 | **1.319** | 1.453 | **1.325** |
| `erf_f32_4k` | 1.581 | **1.502** | 1.588 | **1.510** |
| `celu_f32_4k` | 0.460 | **0.437** | 0.476 | **0.445** |
| `elu_f32_4k` | 0.481 | **0.448** | 0.486 | **0.468** |
| `selu_f32_4k` | 0.500 | **0.458** | 0.482 | **0.464** |
| `log_f32_4k` | 0.766 | **0.731** | 0.781 | **0.734** |
| `mish_f32_4k` | 0.332 | **0.322** | 0.345 | **0.339** |

36 of 36 arm-pairs favour the change. The cost is per call, not per
element or
per worker, so the gain is the same absolute microseconds at every
thread count.

## Drift control: the 1 Mi grid does not move

Same protocol, `_f32_1m`, 200 iterations after 30 warmup, five
interleaved rounds,
one thread. A 1 Mi tensor amortises a fixed per-call cost away, so
**these must
not move** — and they don't:

| case | before | after | | case | before | after |
|---|---|---|---|---|---|---|
| `relu_f32_1m` | 1.024 | 1.028 | | `sqrt_f32_1m` | 0.313 | 0.309 |
| `exp_f32_1m` | 0.580 | 0.576 | | `log_f32_1m` | 0.264 | 0.258 |
| `sigmoid_f32_1m` | 0.588 | 0.584 | | `mish_f32_1m` | 0.100 | 0.099 |
| `tanh_f32_1m` | 0.618 | 0.620 | | `celu_f32_1m` | 0.127 | 0.129 |
| `erf_f32_1m` | 0.651 | 0.668 | | `elu_f32_1m` | 0.129 | 0.129 |
| `gelu_exact_f32_1m` | 0.585 | 0.584 | | `selu_f32_1m` | 0.136 | 0.138
|
| `gelu_tanh_f32_1m` | 0.602 | 0.646 | | `hardsigmoid_f32_1m` | 0.455 |
0.453 |
| `fastgelu_f32_1m` | 0.608 | 0.639 | | `leakyrelu_f32_1m` | 0.409 |
0.417 |
| `quickgelu_f32_1m` | 0.429 | 0.438 | | `thresholdedrelu_f32_1m` |
0.567 | 0.573 |

The per-case round-win counts here are 0/5–4/5 with a median of 2/5,
i.e. a coin
flip — the signature of noise, not of an effect. The two largest movers,
`gelu_tanh` (+7 %) and `fastgelu` (+5 %), are the two cases whose
**ORT** side also
drifted most in the same rounds (−4.4 % and −2.8 %), so the ratio moved
because
the denominator did. This host's floor is roughly ±5 % on the 1 Mi grid
and I am
not claiming anything below it.

## Correctness

**Five new tests, three with a verified falsifier**, driving
`read_inputs` and
`allocate_output` against a hand-built `OrtApi` whose hooks count their
calls.

| test | what it pins | falsifier |
|---|---|---|
| `input_shapes_come_from_one_call_when_ort_offers_the_reference_hook` |
1 reference call, **0** legacy calls, and the shape/strides/dtype that
come out | forcing the legacy route fails it with `left: 0, right: 1`
(run) |
| `the_five_call_fallback_produces_the_same_input_as_the_reference_hook`
| the fallback is exercised and its `OwnedInput` matches the reference
path field for field | — (it *is* the parity check) |
| `a_borrowed_scalar_shape_never_dereferences_null` | ORT's documented
scalar spelling — null pointer, count 0 — takes the borrowed route and
yields rank 0 | deleting the null guard makes **Miri** report
`out-of-bounds pointer use: null pointer is a dangling pointer` at
`kernel_ctx.rs:280` (run) |
| `read_inputs_fails_closed_when_no_shape_route_exists` | the error
names **both** routes; no silent garbage shapes | — |
| `output_dims_are_identical_on_the_inline_and_heap_ranks` | ranks
0/1/8/9/12 all reach ORT with every dimension intact | dropping the heap
arm delivers rank 9 truncated to 8 dims (run) |

**The null guard is now actually enforced, not just asserted.**
Natively,
`from_raw_parts(null, 0)` returns an empty slice, so a test asserting
the
resulting shape passes with or without the guard — the guarantee only
exists if
Miri sees it. `onnx-runtime-ep-plugin` was not in the Miri matrix, so
this PR adds
`kernel_ctx::` as a lane in `.github/workflows/miri.yml` (the crate's
other
modules dlopen ORT and are not Miri-tractable, which is why the lane is
scoped to
the module rather than the crate). 25 tests pass under Miri in 4.15 s;
with the
guard deleted the lane fails. That pairing is what makes the test
non-vacuous.

**Suites.** `-p onnx-runtime-ep-plugin`: **252** unit tests pass (247
before,
+5 new). `-p onnx-runtime-ep-cpu-plugin` with
`NXRT_REQUIRE_ORT_TESTS=1`: every
suite green, including all **55** `plugin_ort_e2e` cases —
`every_assigned_node_is_also_executed_by_this_ep`,
`no_supported_node_is_ever_left_to_the_ort_cpu_ep`,
`no_matmul_family_node_escapes_to_the_ort_cpu_ep` and
`every_fixture_loads_with_cpu_fallback_disabled` all pass, so **assigned
still
equals executed** with ORT CPU fallback disabled. `cargo clippy
--release
--all-targets -p onnx-runtime-ep-plugin` and `cargo fmt` are clean.

**Build identity.** Pure native CPU EP: no MLAS at runtime, no ORT CPU
EP
fallback, no new dependency. AVX2/FMA host (`avx2 fma f16c`, no
AVX-512), so ORT
and we are on the same instruction footing.

## Independent review

Reviewed by **Claude Opus 4.8**, read-only, briefed with the exact ORT
contract
for the borrowed pointer and asked specifically to hunt UB and vacuous
tests.
Verdict **APPROVE**, no blockers. It confirmed the null/scalar guard is
unreachable-by-construction for `from_raw_parts`, that the borrow cannot
outlive
the `OrtValue` (its only consumer copies), that
`ReleaseTensorTypeAndShapeInfo`
still runs on every legacy error path — and noted that moving
`DataType::from_onnx`
after the match incidentally closes a **pre-existing** leak of
`type_shape` on the
unsupported-dtype path.

It raised two test-quality defects, both real and both **fixed** in
`6027f6e16`:

1. The three counting tests shared two process-wide `AtomicUsize`es
while
asserting exact counts, and libtest runs them in parallel — one test's
reset
   could land inside another's assertion window. They now take a shared
   `SHAPE_COUNTER_LOCK` that resets both counters under the guard.
2. `a_borrowed_scalar_shape_never_dereferences_null` was **vacuous**
with respect
to its name, for exactly the reason above, and the crate was not under
Miri.
Hence the Miri lane, and the test now also asserts the scalar went
through the
   borrowed route so it cannot pass on the fallback.

It also flagged that `OrtStatus` is not released on error paths in this
file.
That is pre-existing and repo-wide in `kernel_ctx.rs` — the old
five-call code
leaked identically — so it is not touched here; it belongs in its own
change.

## What is left

After #1244 and this PR, a 1-in/1-out elementwise `Run` is down to
roughly
`KernelContext_GetInputCount` + `GetInput` + the one shape reference +
`GetTensorData` + `GetOutput` + `GetTensorMutableData`. What remains:

* **`OwnedInput`'s `shape` and `strides` `Vec`s**, `kernel_inputs`,
`infer_shapes`'s `Vec<Vec<usize>>`, `slot_map`, `output_views`, and the
`Box<HostPool>` in `host_pool::install` — each worth ~25–35 ns. Removing
them
needs an inline-capacity vector type or per-session caching of the parts
that
cannot change between `Run`s. Both are worth doing; neither belongs
here.
* **The `HostPool` box** specifically is @sebastian's 16-thread
scheduling file
  and should come from him or after his PRs land.

---

## Refreshed against `main` @ `6a855d5e0`, and measured as a stack

`origin/main` moved a long way while this sat in the CI queue (#1346,
#1352 and
#1361 on the quality lane; #1154, #1232, #1238 on the CPU side). Merged
in
normally — no rebase — and re-measured from scratch against the new
baseline.

Production pure-native A/B, plain ORT as the control arm. No MLAS, no
ORT CPU
fallback, no deferral. `taskset -c 8-15`, one thread, 400 iterations,
five
interleaved rounds out of two worktrees, started only once cores 8-15
were
>=93% idle. **Ratio is ours/ORT, lower is better.** `before` is `main`
at
`6a855d5e0`; `after` is #1244 + #1246 together, since #1246 is stacked
on #1244
and the pair is what a user gets.

| case | ratio p50 main | ratio p50 stack | Δ | ratio p90 main | ratio
p90 stack | ours us | ORT drift | rounds won |
|---|---|---|---|---|---|---|---|---|
| `thresholdedrelu_f32_4k` | 1.512 | **1.216** | -19.6% | 1.519 |
**1.216** | 3.6 → **2.8** | -4.2% | 5/5 |
| `tanh_f32_4k` | 1.511 | **1.279** | -15.4% | 1.513 | **1.284** | 4.6 →
**3.9** | +0.0% | 5/5 |
| `sigmoid_f32_4k` | 1.475 | **1.248** | -15.4% | 1.484 | **1.256** |
4.7 → **4.0** | +0.0% | 5/5 |
| `erf_f32_4k` | 1.470 | **1.363** | -7.3% | 1.489 | **1.364** | 7.5 →
**7.0** | +0.0% | 5/5 |
| `hardsigmoid_f32_4k` | 1.416 | **1.127** | -20.4% | 1.426 | **1.133**
| 3.5 → **2.8** | +0.0% | 5/5 |
| `leakyrelu_f32_4k` | 1.361 | **1.094** | -19.6% | 1.371 | **1.104** |
3.5 → **2.8** | +0.0% | 5/5 |
| `sqrt_f32_4k` | 1.141 | **0.946** | -17.1% | 1.150 | **0.955** | 4.0 →
**3.4** | -2.8% | 5/5 |
| `log_f32_4k` | 0.767 | **0.698** | -9.0% | 0.776 | **0.703** | 7.9 →
**7.2** | +0.0% | 5/5 |
| `selu_f32_4k` | 0.505 | **0.440** | -12.9% | 0.512 | **0.444** | 5.6 →
**4.9** | +0.0% | 5/5 |
| `elu_f32_4k` | 0.480 | **0.416** | -13.3% | 0.488 | **0.421** | 5.3 →
**4.6** | +0.0% | 5/5 |
| `celu_f32_4k` | 0.470 | **0.411** | -12.6% | 0.478 | **0.418** | 5.7 →
**5.0** | +0.0% | 5/5 |
| `mish_f32_4k` | 0.276 | **0.264** | -4.3% | 0.278 | **0.268** | 17.3 →
**16.6** | +0.0% | 5/5 |

**Every case, every round.** The two rows with a moving control (`sqrt`
-2.8%,
`thresholdedrelu` -4.2%) are reported rather than dropped; both won 5/5
anyway
and their absolute time fell by the same ~0.7 us as everything else.

That constant ~0.7 us is the point. It is not proportional to tensor
size — the
same absolute amount comes off `hardsigmoid` (3.5 -> 2.8 us) as off
`mish`
(17.3 -> 16.6 us) — which is what a fixed per-`Run` cost looks like when
you
remove some of it. It moves the cheap ops the most because they had the
least
to hide it behind, and `sqrt` crosses from 1.141 to **0.946**, from a
loss to a
win.

### Where the remaining time goes

Measured directly, by instrumenting `compute_execute` segment by segment
on top
of this stack (temporary probe, not committed; `perf` is unavailable on
this
host — `perf_event_paranoid=4`). Per `Run`, one-in/one-out elementwise
node,
4096 `f32`, microseconds:

| segment | us | note |
|---|---|---|
| `KernelContext_GetOutput` | 0.35 | ORT's own API — ours to call, not
to optimise |
| `read_inputs` | 0.15 | 4 ORT FFI calls, already one shape call after
#1246 |
| rest of `allocate_output` | 0.13 | `GetTensorMutableData` + strides |
| `prepare_workspace` | 0.09 | metadata vector + plan-cache lookup, for
a kernel needing 0 bytes |
| `host_pool::install` | 0.05 | @sebastian's, not touched |
| `infer_shapes` | 0.05 | |
| `kernel_inputs` | 0.04 | |
| `output_views` | 0.04 | |

Non-kernel node cost is **~1.25 us and near-constant across all twelve
operators** (0.28 to 15.2 us of kernel time), which is the direct
confirmation
that small-node ratios on this EP are dispatch-bound rather than
kernel-bound.
There is no single large item left — the biggest,
`KernelContext_GetOutput`, is
ORT's. The rest is a long tail of 0.04-0.15 us items, which is what
#1358
(`InlineVec`) starts on.

### And nothing breaks at 1 Mi

Same harness, 1048576 elements, 120 iterations, 3 rounds. A fixed
per-`Run`
cost should be invisible here, and it is:

| case | ratio p50 main | ratio p50 stack | ours us | ORT drift |
|---|---|---|---|---|
| `celu_f32_1m` | 0.141 | 0.140 | 382.1 → 377.9 | -0.0% |
| `elu_f32_1m` | 0.138 | 0.136 | 347.7 → 343.6 | +0.1% |
| `erf_f32_1m` | 0.671 | 0.670 | 595.2 → 594.6 | +0.1% |
| `exp_f32_1m` | 0.606 | 0.590 | 247.0 → 240.5 | +0.1% |
| `fastgelu_f32_1m` | 0.643 | 0.648 | 415.3 → 413.2 | -1.7% |
| `gelu_exact_f32_1m` | 0.572 | 0.592 | 706.1 → 710.6 | -2.8% ⚠ |
| `gelu_tanh_f32_1m` | 0.657 | 0.651 | 414.8 → 410.5 | +0.3% |
| `hardsigmoid_f32_1m` | 0.376 | 0.372 | 88.0 → 69.6 | -0.9% |
| `leakyrelu_f32_1m` | 0.421 | 0.430 | 90.1 → 87.3 | -2.2% ⚠ |
| `log_f32_1m` | 0.270 | 0.274 | 616.4 → 612.4 | +1.7% |
| `mish_f32_1m` | 0.105 | 0.105 | 1626.0 → 1626.0 | -0.2% |
| `quickgelu_f32_1m` | 0.455 | 0.453 | 321.5 → 320.0 | -0.5% |
| `relu_f32_1m` | 1.034 | 1.022 | 131.7 → 130.2 | +0.0% |
| `selu_f32_1m` | 0.147 | 0.148 | 374.1 → 371.8 | -1.5% |
| `sigmoid_f32_1m` | 0.471 | 0.606 | 231.6 → 229.0 | -23.4% ⚠ |
| `sqrt_f32_1m` | 0.314 | 0.302 | 148.1 → 143.9 | +1.1% |
| `tanh_f32_1m` | 0.644 | 0.631 | 226.2 → 221.9 | -0.2% |
| `thresholdedrelu_f32_1m` | 0.500 | 0.486 | 70.9 → 68.6 | -0.3% |

Flat, as predicted — 0.7 us against 70-1626 us of work. Absolute time is
equal
or better in 16 of 18 cases. The two ⚠ rows had the control move more
than the
effect: `sigmoid` is unusable (ORT itself moved -23.4%; our own absolute
went
231.6 -> 229.0 us), and `gelu_exact`'s +0.6% absolute sits inside its
-2.8%
control. Reported rather than dropped.

This is the coverage claim for the change: it buys ~0.7 us at every
size, which
is 20% of a small node and nothing at all of a large one, and it costs
nothing
anywhere.

---

## Status after merging latest `main` (2026-08-19)

Validated on latest `main` after the six-PR #1077 stack landed
(#1387, #1409, #1412, #1430, #1433, #1472). Full evidence in the PR
comment
below; the measured summary:

**Deterministic counters, `relu_1_tiny`** — `OrtFfiCall` **10 → 6 per
`Run`**,
`DispatchAlloc` **24 → 20**. Four fewer round trips for one input, i.e.
the
predicted 7 → 3 per input.

**Timing A/B**, production build, 3 clean reps (a 4th discarded for
contention —
it read 0.973, which would have flattered us):

| case | main | this PR |
|---|---|---|
| `relu_1_tiny` | 1.397 | **1.271** |
| `relu_10_tiny` | 1.250 | **1.173** |
| `relu_100_tiny` | 1.163 | **1.110** |

The gain shrinks as depth grows — the signature of a fixed-cost fix,
since this
removes FFI calls per `Run`, not per node. Fixed per-`Run` overhead
**1.38 →
1.23** against ORT; per-node slope unchanged.

**Test coverage gap this merge exposed and closed:** the pinned costs
(`1 + 7` calls, `1 + 3` allocations) run against a zeroed `fake_api()`,
where the
reference hook is null — so they describe the *legacy fallback*, not the
fast
path this PR exists to add. Added
`the_reference_hook_path_costs_exactly_three_ort_calls_per_input` (pins
`1 + 3`)
and `the_reference_hook_path_allocates_less_than_the_legacy_path`.
`dispatch_probe`'s FFI-coverage table updated to 12 members / 12
`ort_call()`
sites, which is the guard that flagged the new API member in the first
place.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Resch <resch@squad.local>
@codecov

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.14%. Comparing base (4a9f4ec) to head (94d25f3).
⚠️ Report is 77 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #1433       +/-   ##
===========================================
- Coverage   82.10%   80.14%    -1.97%     
===========================================
  Files          12      377      +365     
  Lines        5471   165131   +159660     
  Branches     5471   165131   +159660     
===========================================
+ Hits         4492   132340   +127848     
- Misses        780    27956    +27176     
- Partials      199     4835     +4636     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (?)
cli-ort-windows 82.10% <ø> (ø)
offline 80.05% <100.00%> (?)

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

Files with missing lines Coverage Δ
crates/onnx-runtime-ir/src/layout.rs 99.28% <100.00%> (ø)

... and 364 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.

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