Skip to content
Closed
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
267 changes: 253 additions & 14 deletions crates/onnx-runtime-ep-plugin/src/compute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,13 +851,105 @@ pub struct SubgraphRouting {
/// an illegal host-pointer dereference. ORT owns scratch memory for the
/// duration of the `Compute` call, which exactly matches an intermediate's
/// lifetime, so `IntermediateBuf` never frees `scratch_ptr`.
/// Dimension storage that costs no heap allocation at the ranks that occur.
///
/// Rank is unbounded in the ONNX spec, so this is not a fixed-capacity type: it
/// spills to the heap past `N` and stays correct. `N = 4` is chosen against two
/// opposing costs. Below it, every extra inline slot enlarges `IntermediateBuf`,
/// which is moved into and out of a vector once per node -- past a point the
/// memmove costs more than the allocation being avoided. Above it, a rank-5
/// tensor would allocate anyway. Four covers NCHW and everything under it,
/// which is every tensor these partitions have been observed to carry.
///
/// Measured: a heap allocation on this path costs ~12-15 ns (priced directly by
/// injecting known allocations into the node loop), and this removes two per
/// node.
#[derive(Clone, Debug)]
pub struct Dims<T: Copy + Default, const N: usize> {
inline: [T; N],
len: usize,
/// Empty unless the rank exceeds `N`, in which case it holds the whole
/// thing and `inline`/`len` are unused. Non-emptiness is the discriminant,
/// which is sound because a spill only ever happens for rank > N >= 1 and
/// so is never itself empty.
spill: Vec<T>,
}

impl<T: Copy + Default, const N: usize> Default for Dims<T, N> {
fn default() -> Self {
Self { inline: [T::default(); N], len: 0, spill: Vec::new() }
}
}

impl<T: Copy + Default, const N: usize> Dims<T, N> {
pub fn from_slice(src: &[T]) -> Self {
let mut d = Self::default();
if src.len() <= N {
d.inline[..src.len()].copy_from_slice(src);
d.len = src.len();
} else {
d.spill.extend_from_slice(src);
}
d
}

pub fn as_slice(&self) -> &[T] {
if self.spill.is_empty() {
&self.inline[..self.len]
} else {
&self.spill
}
}
}

impl<const N: usize> Dims<i64, N> {
/// Contiguous strides for `shape`, without a temporary.
pub fn contiguous_for(shape: &[usize]) -> Self {
let mut d = Self::default();
let rank = shape.len();
if rank > N {
d.spill.resize(rank, 1i64);
for i in (0..rank.saturating_sub(1)).rev() {
d.spill[i] = d.spill[i + 1] * shape[i + 1] as i64;
}
} else {
d.len = rank;
if rank > 0 {
d.inline[rank - 1] = 1;
for i in (0..rank - 1).rev() {
d.inline[i] = d.inline[i + 1] * shape[i + 1] as i64;
}
}
}
d
}
}

impl<T: Copy + Default, const N: usize> std::ops::Deref for Dims<T, N> {
type Target = [T];
fn deref(&self) -> &[T] {
self.as_slice()
}
}

impl<T: Copy + Default + PartialEq, const N: usize> PartialEq<[T]> for Dims<T, N> {
fn eq(&self, other: &[T]) -> bool {
self.as_slice() == other
}
}

/// Shape storage for an intermediate.
pub type ShapeDims = Dims<usize, 4>;
/// Stride storage for an intermediate.
pub type StrideDims = Dims<i64, 4>;

pub struct IntermediateBuf {
pub data: Vec<u8>,
/// When non-null, the buffer is backed by ORT scratch memory (possibly on
/// device) instead of the host `data` vector. Not owned — never freed here.
pub scratch_ptr: *mut u8,
pub shape: Vec<usize>,
pub strides: Vec<i64>,
pub shape: ShapeDims,
pub strides: StrideDims,
pub dtype: DataType,
}

Expand Down Expand Up @@ -2303,6 +2395,14 @@ unsafe extern "C" fn compute_execute(
}

// Allocate intermediate buffer slots (uninitialized until written).
// Sized once and never grown. That is load-bearing, not incidental:
// an input `TensorView` is transmuted to `'static` below, and since
// shape/stride storage became inline it lives *inside* the
// `IntermediateBuf` rather than in a separately heap-allocated
// `Vec`. A push or resize here would move that storage while those
// views are live, turning a previously benign edit into a
// use-after-move. Slots are index-assigned and `take`n, never
// inserted.
let mut intermediates: Vec<Option<IntermediateBuf>> = (0..routing
.num_intermediate_buffers)
.map(|_| None)
Expand Down Expand Up @@ -2520,7 +2620,7 @@ unsafe extern "C" fn compute_execute(
let shape = &output_shapes[out_slot];
let numel: usize = shape.iter().product();
let byte_len = dtype.byte_size() * numel;
let strides = contiguous_strides(shape);

let (data, scratch_ptr) = match intermediate_scratch {
Some(mem_info) => {
match unsafe {
Expand All @@ -2541,8 +2641,8 @@ unsafe extern "C" fn compute_execute(
IntermediateBuf {
data,
scratch_ptr,
shape: shape.clone(),
strides,
shape: ShapeDims::from_slice(shape),
strides: StrideDims::contiguous_for(shape),
dtype,
},
));
Expand Down Expand Up @@ -2646,7 +2746,7 @@ unsafe extern "C" fn compute_execute(
));
}
if let Some(stale) = intermediates[buf_idx].take() {
recycle_host_intermediate(stale.data);
retire_intermediate(stale);
}
intermediates[buf_idx] = Some(buf);
}
Expand All @@ -2658,13 +2758,13 @@ unsafe extern "C" fn compute_execute(
for &buf_idx in &retire_items[retire_starts[node_idx]..retire_starts[node_idx + 1]]
{
if let Some(dead) = intermediates[buf_idx].take() {
recycle_host_intermediate(dead.data);
retire_intermediate(dead);
}
}
}

for slot in intermediates.drain(..).flatten() {
recycle_host_intermediate(slot.data);
retire_intermediate(slot);
}
} else if exported.entries.len() == 1 {
// ── Fast path: single-kernel subgraph ─────────────────────────
Expand Down Expand Up @@ -2901,6 +3001,16 @@ thread_local! {
const { std::cell::RefCell::new(Vec::new()) };
}

/// Retire a dead intermediate, reclaiming its bytes.
///
/// Single entry point rather than three open-coded `recycle_host_intermediate`
/// calls: the death sites are easy to add and easy to get subtly wrong, and
/// routing them through one function is what makes the pooling contract
/// checkable in one place.
fn retire_intermediate(buf: IntermediateBuf) {
recycle_host_intermediate(buf.data);
}

/// Storage for one host intermediate of `len` bytes, reusing a retired buffer
/// when one is large enough.
///
Expand Down Expand Up @@ -2974,6 +3084,7 @@ fn drain_host_intermediate_pool() {
HOST_INTERMEDIATE_POOL.with(|pool| pool.borrow_mut().clear());
}


/// For each intermediate buffer, the highest node index that reads it, or
/// `None` when no node does.
///
Expand Down Expand Up @@ -3063,11 +3174,21 @@ fn absent_slot_strides(
}

fn contiguous_strides(shape: &[usize]) -> Vec<i64> {
let mut strides = vec![1i64; shape.len()];
let mut strides = Vec::new();
contiguous_strides_into(shape, &mut strides);
strides
}

/// Write `shape`'s contiguous strides into `out`, reusing its capacity.
///
/// `out` is cleared first, so any previous tenant's contents are gone before a
/// single stride is written and a short shape cannot inherit a long one's tail.
fn contiguous_strides_into(shape: &[usize], out: &mut Vec<i64>) {
out.clear();
out.resize(shape.len(), 1i64);
for i in (0..shape.len().saturating_sub(1)).rev() {
strides[i] = strides[i + 1] * shape[i + 1] as i64;
out[i] = out[i + 1] * shape[i + 1] as i64;
}
strides
}

/// Build a mutable TensorView from an IntermediateBuf (unsafe: caller ensures
Expand Down Expand Up @@ -4889,8 +5010,8 @@ mod tests {
let buf = IntermediateBuf {
data,
scratch_ptr: std::ptr::null_mut(),
shape: shape.clone(),
strides,
shape: ShapeDims::from_slice(&shape),
strides: StrideDims::from_slice(&strides),
dtype: DataType::Float32,
};
let v = buf.view();
Expand Down Expand Up @@ -5349,8 +5470,126 @@ mod tests {
let _ = panicky; // swallowed, as compute_release_state does
}

// ── Intermediate buffer overflow test ─────────────────────────────────
/// The reusing writer must agree with the allocating one everywhere,
/// including the degenerate shapes that only show up in real models.
#[test]
fn contiguous_strides_into_matches_the_allocating_version() {
let cases: &[&[usize]] = &[
&[],
&[1],
&[0],
&[5],
&[2, 3],
&[2, 3, 4],
&[3, 0, 2],
&[1, 1, 1, 7],
&[6, 1, 1],
];
for shape in cases {
let mut out = Vec::new();
super::contiguous_strides_into(shape, &mut out);
// Anchored to the IR crate's implementation, not to
// `contiguous_strides` -- that is a thin wrapper over the function
// under test here, so comparing against it would only prove
// determinism and would pass just as happily on a shared
// row-major/column-major mistake.
assert_eq!(
out,
onnx_runtime_ir::compute_contiguous_strides(shape),
"mismatch for shape {shape:?}"
);
}
}

/// A reused stride buffer must not keep the previous shape's tail.
///
/// This is the failure a `resize`-without-clear would produce: the new
/// shape is shorter, so the extra trailing strides survive and the tensor
/// silently claims more dimensions than it has.
#[test]
fn a_reused_stride_buffer_cannot_inherit_a_longer_shapes_tail() {
let mut out = Vec::new();
super::contiguous_strides_into(&[2, 3, 4, 5], &mut out);
assert_eq!(out.len(), 4);
super::contiguous_strides_into(&[7], &mut out);
assert_eq!(out, super::contiguous_strides(&[7]));
assert_eq!(out.len(), 1, "trailing strides survived the reuse");
}

/// Inline and spilled storage must be indistinguishable to every reader.
#[test]
fn dims_roundtrip_inline_and_spilled() {
let cases: &[&[usize]] = &[
&[],
&[7],
&[0],
&[2, 3],
&[2, 3, 4],
&[2, 3, 4, 5],
&[2, 3, 4, 5, 6],
&[1, 2, 3, 4, 5, 6, 7, 8],
&[3, 0, 2, 1, 9],
];
for shape in cases {
let d = super::ShapeDims::from_slice(shape);
assert_eq!(d.as_slice(), *shape, "shape {shape:?}");
// Deref must agree with the explicit accessor, since call sites
// reach the slice through coercion rather than `as_slice`.
assert_eq!(&d[..], *shape, "deref disagreed for {shape:?}");
assert_eq!(d.len(), shape.len());
}
}

/// The allocation-free stride builder must agree with the allocating one at
/// every rank, on both sides of the spill boundary.
#[test]
fn dims_contiguous_for_matches_the_allocating_version() {
let cases: &[&[usize]] = &[
&[],
&[1],
&[0],
&[5],
&[2, 3],
&[2, 3, 4],
&[2, 3, 4, 5],
&[2, 3, 4, 5, 6],
&[3, 0, 2],
&[1, 1, 1, 7, 2, 2],
];
for shape in cases {
let d = super::StrideDims::contiguous_for(shape);
assert_eq!(
d.as_slice(),
onnx_runtime_ir::compute_contiguous_strides(shape).as_slice(),
"strides mismatch for shape {shape:?}"
);
}
}

/// Ranks up to the inline capacity must not touch the heap -- that is the
/// entire point of the type, and a silent regression to spilling would be
/// invisible in behaviour.
#[test]
fn dims_do_not_spill_within_inline_capacity() {
for rank in 0..=4usize {
let shape: Vec<usize> = (1..=rank).collect();
let d = super::ShapeDims::from_slice(&shape);
assert_eq!(d.spill.capacity(), 0, "rank {rank} spilled to the heap");
let st = super::StrideDims::contiguous_for(&shape);
assert_eq!(st.spill.capacity(), 0, "rank {rank} strides spilled");
}
let over = [1usize, 2, 3, 4, 5];
assert!(
super::ShapeDims::from_slice(&over).spill.capacity() > 0,
"rank 5 should spill"
);
assert!(
super::StrideDims::contiguous_for(&over).spill.capacity() > 0,
"rank 5 strides should spill"
);
}

// ── Intermediate buffer overflow test ─────────────────────────────────
#[test]
fn contiguous_strides_empty_shape() {
let s = super::contiguous_strides(&[]);
Expand Down
Loading