Skip to content

perf(cpu-ep): inline intermediate dimension storage (-2 allocations/node) - #1422

Closed
justinchuby wants to merge 3 commits into
squad/resch-alloc-hoistfrom
squad/resch-buf-pool
Closed

justinchuby wants to merge 3 commits into
squad/resch-alloc-hoistfrom
squad/resch-buf-pool

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Closed: controlled null result

This change is not being merged. It is recorded here because the measurement is worth more than the code.

What it did

Removed the two heap allocations each routed node made for its output metadata — a shape clone and a strides vector — by storing both inline in IntermediateBuf. Verified with the deterministic counters from #1409 on the 8-element depth-100 chain:

allocations/node
before 8.17
after 6.19

The unattributed bucket fell 4.09 → 2.11: the removed allocations were exactly the ones in the routed loop between the named phases.

The controlled null result

No measurable performance benefit. Paired against its own parent on the same base, latest main:

depth 100, 8-element
with the change 1.455 1.473
without 1.424 1.491

The distributions overlap completely. The noise floor across repeated runs of identical code was ~4%, wider than any effect present.

This was built twice — first as a thread-local pool, then as inline storage with no bookkeeping at all — precisely to rule out the mechanism being at fault. Both hit 6.19/node; neither moved the ratio.

Why the prediction was wrong

The mechanism had been priced by injecting known allocations into the same node loop:

injected ratio
none 1.440
+2/node 1.548
+8/node 1.925

Linear, ~13–15 ns each, far outside noise — which predicted about −0.10 ratio for the two removed. None of it arrived.

The injected allocations are not equivalent to the real ones. The real shape/stride allocations are the same size on every iteration of a tight loop and land in glibc's tcache; the injected ones are held live behind a black_box that inhibits exactly the reuse being measured. Repeated same-size allocations on this path are close to free. The counters were right; the inference drawn from them was wrong.

Disposition

Closed unmerged. It removes real allocations, but it buys no measured time and adds a second representation for tensor dimensions, and that complexity is not worth carrying on a null result. Nothing is stacked on it, so the rest of the dispatch stack is unaffected.

The finding is recorded in #1077 §5e and in its "negative results — do not retry" list, along with the direct consequence: converting infer_shapes to caller-supplied buffers is retired as a performance action. It is ~720 lines across 13 match arms to remove 2 allocations per node now measured to be worth approximately nothing.

Review record retained in the comment below: independent adversarial review found no correctness defect, and its two nits were fixed — one of which was a genuine defect in my own test (a self-referential oracle that a row-major/column-major bug would have passed).

Part of #1077.

justinchuby and others added 3 commits August 19, 2026 06:16
Every routed node built two fresh vectors for each buffer-backed output --
a shape clone and a strides vector -- and dropped them when the intermediate
died. The bytes were already pooled; only the metadata was thrown away.

Pool the metadata alongside it. A dead intermediate now retires through one
entry point that reclaims all three, so a caller cannot reclaim the bytes and
silently drop the rest, which is exactly what every death site did before.

Reused pairs come back empty rather than resized. That is the property that
makes stale dimensions impossible rather than merely unlikely: there is no
element to read before the caller writes one, and a shorter shape cannot
inherit a longer one's trailing strides.

Measured on the 8-element depth-100 chain, latest main: 8.17 -> 6.19
allocations per node, with the unattributed bucket falling 4.09 -> 2.11 --
the two allocations this removes were the ones sitting in the routed loop
between the named phases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The pool in the previous commit removes two allocations per node, which the
counters confirm (8.17 -> 6.19). It does not make anything faster: paired
against its own parent it measured 1.440 vs 1.424, inside a noise floor of
about 2%.

Priced the mechanism instead of guessing. Injecting two extra allocations per
node into the same loop moved the ratio 1.440 -> 1.548, and eight moved it to
1.925 -- linear, and far outside noise. So the allocations the pool removes
should have been worth roughly -0.10 ratio, and none of it arrived. The
bookkeeping was eating the saving: two thread-local RefCell round trips per
node in place of two mallocs.

Inline storage gets the same allocation count with no bookkeeping at all. Rank
is unbounded in the ONNX spec, so this is not a fixed-capacity type -- it
spills to the heap past four dimensions and stays correct there. Four covers
NCHW and everything under it, and keeps the struct small enough that moving it
once per node does not cost back what the allocation saved.

It is also simply less machinery: no thread-local, no reset semantics, no
window in which a reused buffer holds a previous tenant's dimensions. Stale
metadata stops being a property that has to be tested for and becomes one that
cannot be expressed.

Measured: 6.19 allocations per node, identical to the pool. Timing unchanged
within noise, which is the honest result for both mechanisms -- see the issue
ledger for what that says about allocation count as a lever.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review found the stride test was comparing the function to itself: the
reference it checked against is a thin wrapper over the function under test,
so it proved determinism and nothing else. A shared row-major/column-major
mistake would have passed it. Anchored to the IR crate's implementation
instead, and confirmed by mutation that a column-major recurrence now fails it.

Also pins the invariant the inline storage made load-bearing. An input view is
transmuted to 'static, and shape/stride data now lives inside the
IntermediateBuf rather than in a separately allocated Vec, so growing the
intermediates vector while those views are live would be a use-after-move
rather than the benign edit it used to be. The vector is sized once and only
ever index-assigned; that is now written down where someone would go to change
it.

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

Copy link
Copy Markdown
Owner Author

Independent adversarial review (Opus 4.8, read-only, seven named attack surfaces): APPROVE WITH NITS — no correctness defect. It specifically cleared the highest-risk aspect, that shape/stride data now moves with IntermediateBuf where it used to be heap-stable behind a separate Vec: intermediates is sized once and only ever index-assigned or taken, and every buffer move happens strictly after the kernel's last read.

Both nits were real and are fixed in 7da61a7:

  1. My stride test compared the function to itself — the reference it checked against is a thin wrapper over the code under test, so it proved determinism and nothing more. A shared row-major/column-major mistake would have sailed through. Now anchored to onnx_runtime_ir::compute_contiguous_strides, and confirmed by mutation that a column-major recurrence fails it (the old oracle did not).
  2. The transmute-to-'static on input views now rests on intermediates never growing — previously benign, now load-bearing. Written down at the declaration, where someone would go to change it.

@github-actions

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
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 87.86 µs 143.92 µs +63.8%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 1.12 ms 1.79 ms +59.7%
🔴 matmul/large_generic_bf16_threads=8/32x1024x1024 1.59 ms 2.47 ms +55.3%
🔴 gather/large_f32_threads=1-internal/131072 31.07 µs 42.37 µs +36.4%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 34.71 µs 47.13 µs +35.8%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 5.10 ms 6.65 ms +30.5%
⚠️ matmul/medium_generic_bf16_threads=8/32x512x512 526.49 µs 683.53 µs +29.8%
⚠️ gather/large_f16_threads=1-internal/131072 14.87 µs 19.28 µs +29.7%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 32.09 µs 41.38 µs +29.0%
⚠️ qwen3_sampling_processors/top_k_partial_selection 164.17 µs 209.97 µs +27.9%
⚠️ kv_cache/alloc_dealloc_pages 45.45 µs 56.37 µs +24.0%
⚠️ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 514.97 µs 633.69 µs +23.1%
⚠️ gather/small_f32_threads=1-internal/4096 691.9 ns 850.2 ns +22.9%
⚠️ reduce_mean/medium_f32_threads=1-internal/65536 246.67 µs 300.20 µs +21.7%
⚠️ tokenization/decode_tokens_per_second 7.05 ms 8.57 ms +21.5%
⚠️ add/large_bf16_threads=1-internal/4194304 1.74 ms 2.11 ms +21.3%
⚠️ reduce_mean/small_f32_threads=1-internal/4096 15.82 µs 19.08 µs +20.6%
⚠️ tokenization/encode_tokens_per_second 409.66 µs 483.63 µs +18.1%
⚠️ add/small_f32_threads=1-internal/1024 209.6 ns 247.2 ns +17.9%
⚠️ sampling_latency/min_p_per_token 249.59 µs 293.71 µs +17.7%
⚠️ sampling_latency/greedy_per_token 3.43 µs 4.02 µs +17.0%
⚠️ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 4.23 ms 4.93 ms +16.5%
⚠️ matmul/large_generic_f16_threads=1/32x1024x1024 88.83 µs 102.63 µs +15.5%
⚠️ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 103.58 µs 119.26 µs +15.1%
✅ add/medium_f16_threads=1-internal/262144 115.60 µs 132.14 µs +14.3%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.57 ms 2.91 ms +13.2%
✅ add/medium_bf16_threads=1-internal/262144 118.13 µs 132.88 µs +12.5%
✅ block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 108.58 µs 121.82 µs +12.2%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.00 ms 1.12 ms +11.9%
✅ add/small_f16_threads=1-internal/1024 522.8 ns 584.5 ns +11.8%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 7.01 ms 7.81 ms +11.4%
✅ add/large_f32_threads=1-internal/4194304 751.26 µs 819.61 µs +9.1%
✅ add/small_bf16_threads=1-internal/1024 483.3 ns 526.3 ns +8.9%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 618.14 µs 668.73 µs +8.2%
✅ gather/small_bf16_threads=1-internal/4096 517.9 ns 559.1 ns +7.9%
✅ grammar_masking/llguidance_compute_mask/32 105.19 µs 113.22 µs +7.6%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.46 ms 2.65 ms +7.5%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 613.49 µs 657.52 µs +7.2%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 103.49 µs 110.65 µs +6.9%
✅ qwen3_sampling_processors/top_k_top_p_fast 782.03 µs 832.00 µs +6.4%
✅ matmul/small_generic_f16_threads=8/1x256x256 38.50 µs 40.78 µs +5.9%
✅ logit_processing/seven_processor_chain_per_step 381.45 µs 400.61 µs +5.0%
✅ add/medium_f32_threads=1-internal/262144 30.25 µs 31.54 µs +4.3%
✅ matmul/small_generic_bf16_threads=1/1x256x256 38.29 µs 39.74 µs +3.8%
✅ sampling_latency/top_k_per_token 61.90 µs 63.76 µs +3.0%
✅ matmul/small_generic_bf16_threads=8/1x256x256 43.22 µs 44.22 µs +2.3%
✅ gather/large_bf16_threads=1-internal/131072 17.62 µs 17.39 µs -1.3%
✅ sampling_latency/top_p_per_token 467.11 µs 455.92 µs -2.4%
✅ gather/small_f16_threads=1-internal/4096 547.3 ns 522.5 ns -4.5%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.50 ms 2.36 ms -5.3%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 11.40 ms 10.76 ms -5.6%
✅ add/large_f16_threads=1-internal/4194304 2.19 ms 2.06 ms -6.1%
✅ matmul/small_generic_f16_threads=1/1x256x256 42.75 µs 39.70 µs -7.1%
✅ gather/medium_f16_threads=1-internal/32768 3.12 µs 2.85 µs -8.6%
✅ gather/medium_bf16_threads=1-internal/32768 2.96 µs 2.70 µs -8.9%
✅ gather/medium_f32_threads=1-internal/32768 5.30 µs 4.78 µs -9.8%
✅ block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 1.65 ms 1.48 ms -9.9%
🟢 matmul/small_generic_f32_threads=1/1x256x256 55.86 µs 47.43 µs -15.1%
🟢 matmul/small_generic_f32_threads=8/1x256x256 67.65 µs 53.46 µs -21.0%

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: { 8.29 5.20 6.59 }
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 added a commit that referenced this pull request Aug 19, 2026
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.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
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