Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions crates/onnx-runtime-ep-cpu/tests/task_runtime_latency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,74 @@ fn report_latency_under_concurrent_sessions() {
counters.slot_exhausted, counters.dispatches
);
}

/// How many outer Rayon workers to probe nesting with.
const NEST_DISPATCHERS: [usize; 5] = [1, 2, 4, 8, 16];

/// Inner fan-out size for the nesting probe, in `u64` elements.
const NEST_INNER: usize = 4096;

/// Nesting: several Rayon workers dispatching into the task pool at once.
///
/// This is the shape `packed_nbits_output_row` and `int8_row` take for `m > 1`
/// with `parallel_columns` set -- an outer `par_chunks_mut` over rows, and an
/// inner column fan-out inside each row. The pool has a fixed number of job
/// slots, so past some number of concurrent dispatchers the surplus is declined
/// and runs inline on the caller. That is correct and bounded, and until now it
/// was only ever an argument. This measures it.
///
/// The assertion is correctness, not speed: under nesting every element must
/// still be written exactly once, and nothing may deadlock.
#[test]
#[ignore = "measurement; run deliberately on a quiet machine"]
fn nested_dispatch_slot_pressure() {
use rayon::prelude::*;

let width = task_runtime::testing::pool_width();
println!("\nnested dispatch into a {width}-lane pool");
println!(
"{:>12} {:>10} {:>12} {:>10} {:>9}",
"dispatchers", "wall", "dispatches", "declined", "inline"
);

for &outer in &NEST_DISPATCHERS {
let mut rows = vec![0u64; outer * NEST_INNER];
let before = task_runtime::testing::counters();
let started = Instant::now();

rows.par_chunks_mut(NEST_INNER).for_each(|row| {
let base = row.as_mut_ptr() as usize;
task_runtime::for_each_range(NEST_INNER, 64, |start, end| {
// Each task owns a disjoint `start..end` of this row, so the
// reconstructed slice never overlaps another task's.
let row = unsafe { std::slice::from_raw_parts_mut(base as *mut u64, NEST_INNER) };
for slot in &mut row[start..end] {
*slot += 1;
}
});
});

let elapsed = started.elapsed();
let after = task_runtime::testing::counters();
let dispatches = after.dispatches - before.dispatches;
let declined = after.slot_exhausted - before.slot_exhausted;
let inline = after.tasks_by_dispatcher - before.tasks_by_dispatcher;

assert!(
rows.iter().all(|&v| v == 1),
"nested fan-out did not cover every element exactly once at {outer} dispatchers"
);
assert_eq!(after.panics, before.panics, "a nested task body panicked");

println!(
"{outer:>12} {:>8.1}us {dispatches:>12} {declined:>10} {inline:>9}",
as_us(elapsed)
);
}

let counters = task_runtime::testing::counters();
println!(
"totals: {} of {} dispatches declined for want of a slot",
counters.slot_exhausted, counters.dispatches
);
}
35 changes: 31 additions & 4 deletions docs/benchmarks/2026-08-15-cpu-ep-vs-ort-attention-moe.md
Original file line number Diff line number Diff line change
Expand Up @@ -3930,10 +3930,37 @@ still spinning.

### 38.7 What is still open

Nesting is still unmeasured, exactly as §36.8 left it: `packed_nbits_output_row`
and `int8_row` dispatch into the task pool from inside a `par_chunks_mut`, the
eight job slots bound the surplus by falling back inline, and that remains an
argument rather than a number.
Nesting is no longer open. §36.8 left it as an argument rather than a number:
`packed_nbits_output_row` and `int8_row` dispatch into the task pool from inside
a `par_chunks_mut` for `m > 1` with `parallel_columns` set, and the eight job
slots were said to bound the surplus by declining it back to the caller. That is
now measured by `nested_dispatch_slot_pressure` in
`crates/onnx-runtime-ep-cpu/tests/task_runtime_latency.rs`, which reproduces the
shape directly — an outer `par_chunks_mut` over rows, an inner
`task_runtime::for_each_range` in each — and reads the pool counters across it.

Four runs against a 16-lane pool, `declined` being `slot_exhausted`:

| outer dispatchers | wall | dispatches | declined |
| --- | --- | --- | --- |
| 1 | ~1.9–2.4 ms | 1 | 0 |
| 2 | 0.29–0.44 ms | 2 | 0 |
| 4 | 0.45–0.74 ms | 4 | 0 |
| 8 | 0.84–1.51 ms | 8 | 0 |
| 16 | 1.35–1.84 ms | 13–16 | 0 (3 once) |

Slot exhaustion essentially does not happen. Three of the four runs declined
nothing at any width; the single run that declined 3 of 16 was the first
dispatch of a cold process. The slots turn around faster than sixteen Rayon
workers can collide on them, so the inline fallback is a real safety net that is
almost never used. Per-row cost also improves monotonically with dispatcher
count (175 → 99 µs/row from 2 to 16), so the nesting is not serialising.

Two incidental notes from the same table. The one-dispatcher row is ~2 ms in
every run because it pays for building the pool — the first dispatch in a
process is not representative of anything else. And the test asserts what
actually matters under nesting: every element covered exactly once, and no task
body panicking.

§36.8's `with_decode_pool` hypothesis needs a correction before anyone spends
time on it. `with_decode_pool` early-returns inline when `IN_DECODE_POOL` is
Expand Down
Loading