onnx_runtime_ir/layout.rs
1//! Physical strided layout on tensor values (see `docs/architecture/ORT2.md` §5).
2//!
3//! Unlike upstream ONNX / `onnx-ir`, every [`crate::Value`] carries a
4//! [`TensorLayout`]. This lets optimization passes track non-contiguous
5//! (transposed / broadcast) layouts and eliminate copies at EP boundaries.
6
7use crate::dtype::DataType;
8use crate::error::IrError;
9
10/// Compute row-major (C-order) contiguous strides, in **elements**, for a shape.
11pub fn compute_contiguous_strides(shape: &[usize]) -> Vec<i64> {
12 let n = shape.len();
13 let mut strides = vec![1i64; n];
14 for i in (0..n.saturating_sub(1)).rev() {
15 strides[i] = strides[i + 1] * shape[i + 1] as i64;
16 }
17 strides
18}
19
20/// Whether `strides` describe a row-major contiguous layout for `shape`.
21pub fn is_contiguous(shape: &[usize], strides: &[i64]) -> bool {
22 if shape.len() != strides.len() {
23 return false;
24 }
25 // Accumulate the row-major stride walking backwards rather than
26 // materialising the whole vector to compare against it. Same reason as
27 // `is_dense`: this runs on the kernel fast-path checks, once per operand,
28 // and allocating to answer a question about a handful of integers is the
29 // dominant cost of asking it.
30 //
31 // Short-circuiting on the first mismatch also means this cannot overflow
32 // where the allocating version could, since that one built every stride
33 // before comparing any of them. Strictly more defensive, and unreachable
34 // either way for shapes whose element count fits in memory.
35 let mut expected: i64 = 1;
36 for i in (0..shape.len()).rev() {
37 if strides[i] != expected {
38 return false;
39 }
40 expected *= shape[i] as i64;
41 }
42 true
43}
44
45/// Whether a tensor with `shape` and `strides` is **dense**: it occupies a
46/// contiguous block of memory (no holes, no overlaps) even though the logical
47/// axis order may differ from row-major. This is exactly the condition under
48/// which a per-element unary op can process the backing buffer wholesale —
49/// every element lives at a unique offset in `[0, numel)` and the operation
50/// is order-independent.
51///
52/// Formally: when dimensions are sorted by ascending absolute stride, each
53/// stride must equal the product of all preceding dimensions' sizes. Dimensions
54/// of size 0 or 1 are ignored (their stride is unconstrained because they
55/// contribute no extent).
56///
57/// This is strictly weaker than [`is_contiguous`]: every contiguous tensor is
58/// dense, but a column-major or NHWC-permuted tensor is dense without being
59/// row-major contiguous.
60pub fn is_dense(shape: &[usize], strides: &[i64]) -> bool {
61 if shape.len() != strides.len() {
62 return false;
63 }
64 // `is_dense` runs once per operand per node on the dispatch path. Collecting
65 // into a `Vec` to inspect a handful of numbers put a heap allocation there:
66 // perf sampling of a 100-node elementwise chain attributed 5.25% of this
67 // EP's dispatch time to this function and the `Vec` it built, against 3%
68 // for the arithmetic the whole graph exists to do.
69 //
70 // Rank <= 8 covers every tensor ONNX produces in practice, so those pairs
71 // live on the stack. Higher ranks keep the heap path rather than impose a
72 // limit the type system does not have. Both paths hand the same slice to
73 // the same routine, so there is one implementation of the predicate.
74 // Row-major contiguous with every extent non-empty is the overwhelmingly
75 // common case on the dispatch path: every tensor ORT hands this EP is
76 // contiguous, and the strides the plugin builds for it are built *as*
77 // contiguous by `contiguous_strides`. The fact is therefore proven twice
78 // per operand -- once by construction, and once here by re-deriving it
79 // through a filter, a copy and a sort.
80 //
81 // Proving it the cheap way first is exact rather than heuristic: under
82 // these conditions the tensor occupies `[0, numel)` with no holes, which is
83 // the definition below. A `false` decides nothing and falls through to the
84 // general path, so this can only ever save work, never change an answer.
85 //
86 // The zero-extent test is load-bearing and not defensive. `[2, 0]` with
87 // strides `[0, 1]` satisfies the contiguity recurrence -- the accumulator
88 // is multiplied by 0 and every later stride matches 0 -- while the general
89 // path calls it *not* dense, because dimension 2 has stride 0 and no size-1
90 // exemption. `is_contiguous` alone is therefore the wrong predicate to
91 // shortcut with, which the differential test against the reference
92 // implementation caught immediately.
93 let mut expected: i64 = 1;
94 let mut fast = true;
95 for i in (0..shape.len()).rev() {
96 if shape[i] == 0 || strides[i] != expected {
97 fast = false;
98 break;
99 }
100 expected *= shape[i] as i64;
101 }
102 if fast {
103 return true;
104 }
105 const INLINE_RANK: usize = 8;
106 let nontrivial = |(&d, &s): (&usize, &i64)| (s.unsigned_abs() as i64, d);
107 if shape.len() <= INLINE_RANK {
108 let mut pairs = [(0i64, 0usize); INLINE_RANK];
109 let mut len = 0;
110 for pair in shape
111 .iter()
112 .zip(strides)
113 .filter(|&(&d, _)| d > 1)
114 .map(nontrivial)
115 {
116 pairs[len] = pair;
117 len += 1;
118 }
119 dense_extents(&mut pairs[..len])
120 } else {
121 let mut pairs: Vec<(i64, usize)> = shape
122 .iter()
123 .zip(strides)
124 .filter(|&(&d, _)| d > 1)
125 .map(nontrivial)
126 .collect();
127 dense_extents(&mut pairs)
128 }
129}
130
131/// The density predicate over the non-trivial `(abs_stride, size)` extents.
132///
133/// Sorts `pairs` in place by ascending stride, so the caller owns the storage
134/// and `is_dense` can keep it on the stack for ordinary ranks.
135fn dense_extents(pairs: &mut [(i64, usize)]) -> bool {
136 if pairs.is_empty() {
137 return true; // scalar or all-ones shape
138 }
139 // Sort by stride ascending.
140 pairs.sort_unstable_by_key(|&(s, _)| s);
141 // The smallest stride must be 1 (element-adjacent). The loop below would
142 // reject this case too, on its first iteration, since `expected_stride`
143 // starts at 1 -- mutation testing confirms removing this branch changes no
144 // result. It is kept as the statement of intent the loop obscures.
145 if pairs[0].0 != 1 {
146 return false;
147 }
148 // Each subsequent stride must equal the product of all preceding sizes.
149 let mut expected_stride: i64 = 1;
150 for &(stride, size) in &*pairs {
151 if stride != expected_stride {
152 return false;
153 }
154 expected_stride *= size as i64;
155 }
156 true
157}
158
159/// Compute the output shape of a numpy-style broadcast of `a` and `b`.
160pub fn broadcast_shapes(a: &[usize], b: &[usize]) -> Result<Vec<usize>, IrError> {
161 let max_ndim = a.len().max(b.len());
162 let mut result = Vec::with_capacity(max_ndim);
163 for i in 0..max_ndim {
164 let da = if i < a.len() { a[a.len() - 1 - i] } else { 1 };
165 let db = if i < b.len() { b[b.len() - 1 - i] } else { 1 };
166 if da == db || db == 1 {
167 result.push(da);
168 } else if da == 1 {
169 result.push(db);
170 } else {
171 return Err(IrError::BroadcastIncompatible {
172 a: a.to_vec(),
173 b: b.to_vec(),
174 });
175 }
176 }
177 result.reverse();
178 Ok(result)
179}
180
181/// Memory-format hint used to pick vectorized kernels.
182#[derive(Clone, Debug, PartialEq, Eq, Default)]
183pub enum MemoryFormat {
184 /// Standard row-major.
185 #[default]
186 Contiguous,
187 /// NHWC channels-last.
188 ChannelsLast,
189 /// Blocked/tiled format with the given block width (e.g. 16 for VNNI/AMX).
190 Blocked(usize),
191 /// An arbitrary strided layout that matches none of the named formats.
192 Custom,
193}
194
195/// First-class strided layout for a value.
196///
197/// `strides == None` means "contiguous row-major for the value's shape"; this
198/// is the common case and avoids materializing strides for every value.
199#[derive(Clone, Debug, PartialEq)]
200pub struct TensorLayout {
201 /// Physical strides in **elements**. `None` == contiguous row-major.
202 pub strides: Option<Vec<i64>>,
203 /// Memory-format hint.
204 pub format: MemoryFormat,
205 /// Required alignment in bytes for the backing allocation.
206 pub alignment: usize,
207}
208
209/// Default alignment (bytes) — 64 covers AVX-512 / cache-line requirements.
210pub const DEFAULT_ALIGNMENT: usize = 64;
211
212impl Default for TensorLayout {
213 fn default() -> Self {
214 Self {
215 strides: None,
216 format: MemoryFormat::Contiguous,
217 alignment: DEFAULT_ALIGNMENT,
218 }
219 }
220}
221
222impl TensorLayout {
223 /// A contiguous row-major layout (strides implied by shape).
224 pub fn contiguous() -> Self {
225 Self::default()
226 }
227
228 /// A layout with explicit strides (marked [`MemoryFormat::Custom`]).
229 pub fn strided(strides: Vec<i64>) -> Self {
230 Self {
231 strides: Some(strides),
232 format: MemoryFormat::Custom,
233 alignment: DEFAULT_ALIGNMENT,
234 }
235 }
236
237 /// Whether this layout is contiguous row-major for `shape`.
238 pub fn is_contiguous(&self, shape: &[usize]) -> bool {
239 match &self.strides {
240 None => true,
241 Some(s) => is_contiguous(shape, s),
242 }
243 }
244
245 /// The strides for `shape` under this layout, materializing the implied
246 /// contiguous strides when `strides == None`.
247 pub fn resolved_strides(&self, shape: &[usize]) -> Vec<i64> {
248 self.strides
249 .clone()
250 .unwrap_or_else(|| compute_contiguous_strides(shape))
251 }
252
253 /// Reorder axes without copying data (a lazy transpose).
254 pub fn transpose(&self, shape: &[usize], perm: &[usize]) -> Self {
255 let base = self.resolved_strides(shape);
256 let strides = perm.iter().map(|&p| base[p]).collect();
257 Self {
258 strides: Some(strides),
259 format: MemoryFormat::Custom,
260 alignment: self.alignment,
261 }
262 }
263
264 /// Total backing storage size in bytes: the largest byte offset reachable
265 /// via the strides, plus one element. Handles negative strides.
266 pub fn storage_size(&self, shape: &[usize], dtype: DataType) -> usize {
267 let elem = dtype.byte_size().max(1);
268 match &self.strides {
269 None => shape.iter().product::<usize>() * elem,
270 Some(strides) => {
271 let max_offset: i64 = shape
272 .iter()
273 .zip(strides.iter())
274 .map(|(&dim, &stride)| dim.saturating_sub(1) as i64 * stride.abs())
275 .sum();
276 (max_offset as usize + 1) * elem
277 }
278 }
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 /// The pre-optimisation `is_contiguous`, verbatim, as a differential
287 /// oracle -- it materialised the full stride vector and compared slices.
288 fn is_contiguous_reference(shape: &[usize], strides: &[i64]) -> bool {
289 strides == compute_contiguous_strides(shape).as_slice()
290 }
291
292 /// The allocation-free walk must agree with the materialise-and-compare
293 /// version on every input. Falsifier — accumulate the product in the wrong
294 /// direction, drop the length check, or use `shape[i + 1]` instead of
295 /// `shape[i]` when advancing, and this disagrees.
296 #[test]
297 fn contiguous_walk_agrees_with_the_materialising_implementation() {
298 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
299 let mut next = |bound: u64| -> u64 {
300 state = state
301 .wrapping_mul(6_364_136_223_846_793_005)
302 .wrapping_add(1_442_695_040_888_963_407);
303 (state >> 33) % bound
304 };
305
306 let mut agreed_true = 0usize;
307 for rank in 0..=6usize {
308 for _ in 0..500 {
309 let shape: Vec<usize> = (0..rank).map(|_| next(4) as usize).collect();
310 let strides: Vec<i64> = match next(3) {
311 // Exactly contiguous, so the accepting arm is exercised.
312 0 => compute_contiguous_strides(&shape),
313 // Contiguous with one axis disturbed, the near-miss case.
314 1 => {
315 let mut s = compute_contiguous_strides(&shape);
316 if !s.is_empty() {
317 let i = next(s.len() as u64) as usize;
318 s[i] += next(3) as i64 - 1;
319 }
320 s
321 }
322 _ => (0..rank).map(|_| next(9) as i64 - 4).collect(),
323 };
324 let got = is_contiguous(&shape, &strides);
325 if got && !shape.is_empty() {
326 agreed_true += 1;
327 }
328 assert_eq!(
329 got,
330 is_contiguous_reference(&shape, &strides),
331 "disagreement for shape {shape:?} strides {strides:?}"
332 );
333
334 // Ragged lengths must be rejected identically.
335 let mut long = strides.clone();
336 long.push(1);
337 assert_eq!(
338 is_contiguous(&shape, &long),
339 is_contiguous_reference(&shape, &long),
340 "disagreement for shape {shape:?} strides {long:?}"
341 );
342 }
343 }
344 assert!(
345 agreed_true > 100,
346 "the corpus never reached the accepting arm on a non-empty shape \
347 (only {agreed_true} cases), so it proved nothing"
348 );
349 }
350
351 /// The pre-optimisation `is_dense`, verbatim, as a differential oracle.
352 ///
353 /// Kept deliberately naive and heap-based: its whole value is that it was
354 /// not written by the same edit as the version under test, so a mistake in
355 /// the inline-storage path cannot hide behind a matching mistake here.
356 fn is_dense_reference(shape: &[usize], strides: &[i64]) -> bool {
357 if shape.len() != strides.len() {
358 return false;
359 }
360 let mut pairs: Vec<(i64, usize)> = shape
361 .iter()
362 .zip(strides)
363 .filter(|&(&d, _)| d > 1)
364 .map(|(&d, &s)| (s.unsigned_abs() as i64, d))
365 .collect();
366 if pairs.is_empty() {
367 return true;
368 }
369 pairs.sort_unstable_by_key(|&(s, _)| s);
370 if pairs[0].0 != 1 {
371 return false;
372 }
373 let mut expected_stride: i64 = 1;
374 for &(stride, size) in &pairs {
375 if stride != expected_stride {
376 return false;
377 }
378 expected_stride *= size as i64;
379 }
380 true
381 }
382
383 /// Every rank the inline path serves, plus the ranks that spill to the
384 /// heap, must agree with the original implementation on every case --
385 /// dense, non-dense, permuted, zero-sized, negative-strided and
386 /// mismatched-length. Falsifier — change `INLINE_RANK`, drop the `d > 1`
387 /// filter, or forget to truncate the inline array to `len`, and the two
388 /// implementations disagree here.
389 /// The contiguity shortcut in [`is_dense`] must reject zero extents.
390 ///
391 /// `[2, 0]` with strides `[0, 1]` satisfies the row-major contiguity
392 /// recurrence -- the accumulator hits 0 at the empty axis and every stride
393 /// below it matches 0 -- but it is **not** dense: dimension 2 has stride 0.
394 /// Shortcutting on `is_contiguous` alone returns `true` here and disagrees
395 /// with the reference. Found by the differential test, pinned here so the
396 /// specific case survives any future rewrite of the generator.
397 #[test]
398 fn the_contiguity_shortcut_rejects_zero_extents() {
399 assert!(is_contiguous(&[2, 0], &[0, 1]), "premise of this test");
400 assert!(!is_dense(&[2, 0], &[0, 1]));
401 assert_eq!(
402 is_dense(&[2, 0], &[0, 1]),
403 is_dense_reference(&[2, 0], &[0, 1])
404 );
405
406 // An empty tensor whose layout *is* dense still answers true, via the
407 // empty-extents exit rather than the shortcut.
408 assert!(is_dense(&[0], &[1]));
409 assert!(is_dense(&[0, 3], &[3, 1]));
410 }
411
412 /// The shortcut must not fire for a layout that is dense but not row-major
413 /// -- those still have to reach the sort. A negative stride is dense by the
414 /// absolute-stride rule and is exactly such a case.
415 #[test]
416 fn the_contiguity_shortcut_does_not_shadow_the_general_path() {
417 // Column-major: dense, not contiguous.
418 assert!(!is_contiguous(&[4, 3], &[1, 4]));
419 assert!(is_dense(&[4, 3], &[1, 4]));
420 // Negative innermost stride: dense under the absolute-stride rule.
421 assert!(is_dense(&[2, 3], &[3, -1]));
422 assert_eq!(
423 is_dense(&[2, 3], &[3, -1]),
424 is_dense_reference(&[2, 3], &[3, -1])
425 );
426 // Rank above INLINE_RANK still agrees.
427 let shape = [2usize, 1, 2, 1, 2, 1, 2, 1, 2, 2];
428 let strides = compute_contiguous_strides(&shape);
429 assert!(is_dense(&shape, &strides));
430 assert_eq!(
431 is_dense(&shape, &strides),
432 is_dense_reference(&shape, &strides)
433 );
434 }
435
436 #[test]
437 fn inline_storage_agrees_with_the_original_implementation() {
438 // Deterministic pseudo-random cases: a fixed LCG so a failure is
439 // reproducible from the printed inputs alone.
440 let mut state: u64 = 0x2545_F491_4F6C_DD1D;
441 let mut next = |bound: u64| -> u64 {
442 state = state
443 .wrapping_mul(6_364_136_223_846_793_005)
444 .wrapping_add(1_442_695_040_888_963_407);
445 (state >> 33) % bound
446 };
447
448 let mut checked_dense = 0usize;
449 // Ranks 0..=10 straddle INLINE_RANK (8) in both directions.
450 for rank in 0..=10usize {
451 for _ in 0..400 {
452 let shape: Vec<usize> = (0..rank).map(|_| next(4) as usize).collect();
453 // Mix genuinely contiguous layouts (so the dense arm is
454 // actually exercised, not just the early rejections) with
455 // arbitrary ones.
456 let strides: Vec<i64> = if next(2) == 0 {
457 compute_contiguous_strides(&shape)
458 } else {
459 (0..rank)
460 .map(|_| next(9) as i64 - 4) // includes 0 and negatives
461 .collect()
462 };
463 let got = is_dense(&shape, &strides);
464 // Count only cases that actually reached the sort-and-product
465 // logic. A shape that is empty or all-ones returns `true` from
466 // the empty-extents early exit without exercising anything, so
467 // counting those would let this guard be satisfied by rank 0
468 // alone and assert nothing about the arm it names.
469 if got && shape.iter().any(|&d| d > 1) {
470 checked_dense += 1;
471 }
472 assert_eq!(
473 got,
474 is_dense_reference(&shape, &strides),
475 "disagreement for shape {shape:?} strides {strides:?}"
476 );
477
478 // Dropping a stride makes this a length mismatch for every
479 // rank but 0, where both sides see two empty slices.
480 let mut short = strides.clone();
481 short.pop();
482 assert_eq!(
483 is_dense(&shape, &short),
484 is_dense_reference(&shape, &short),
485 "disagreement for shape {shape:?} strides {short:?}"
486 );
487 }
488 }
489 assert!(
490 checked_dense > 100,
491 "the corpus degenerated into rejections and trivial shapes; it proved \
492 nothing about the sort-and-product arm (only {checked_dense} dense \
493 cases with a dimension above 1)"
494 );
495 }
496
497 /// A rank of exactly `INLINE_RANK` with every dimension non-trivial is the
498 /// one input that fills the stack array completely, so `pairs[..len]` and
499 /// the whole array coincide and the truncation is a no-op. Pin it
500 /// deterministically rather than relying on the random corpus to land on
501 /// it, and cover dense-but-not-row-major while here.
502 #[test]
503 fn a_completely_full_inline_array_is_handled() {
504 let shape = [2usize; 8];
505 assert_eq!(shape.len(), 8, "this test must fill the inline array");
506
507 let strides = compute_contiguous_strides(&shape);
508 assert!(is_dense(&shape, &strides));
509 assert!(is_dense_reference(&shape, &strides));
510
511 // Dense without being row-major contiguous: reverse the axis order.
512 let mut permuted: Vec<i64> = strides.clone();
513 permuted.reverse();
514 assert!(!is_contiguous(&shape, &permuted));
515 assert_eq!(
516 is_dense(&shape, &permuted),
517 is_dense_reference(&shape, &permuted)
518 );
519 assert!(is_dense(&shape, &permuted));
520
521 // One stride off by one is a hole, and must be rejected.
522 let mut holed = strides.clone();
523 holed[0] += 1;
524 assert!(!is_dense(&shape, &holed));
525 assert!(!is_dense_reference(&shape, &holed));
526 }
527
528 /// The heap fallback must still be reachable and correct: a rank above
529 /// `INLINE_RANK` cannot fit the stack array.
530 #[test]
531 fn ranks_above_the_inline_bound_use_the_heap_path_correctly() {
532 let shape = [2usize, 2, 2, 2, 2, 2, 2, 2, 2];
533 let strides = compute_contiguous_strides(&shape);
534 assert!(shape.len() > 8, "this test must exercise the fallback");
535 assert!(is_dense(&shape, &strides));
536 assert!(is_dense_reference(&shape, &strides));
537
538 let mut broken = strides.clone();
539 broken[0] += 1;
540 assert!(!is_dense(&shape, &broken));
541 assert!(!is_dense_reference(&shape, &broken));
542 }
543
544 #[test]
545 fn contiguous_strides_row_major() {
546 assert_eq!(compute_contiguous_strides(&[2, 3, 4]), vec![12, 4, 1]);
547 assert_eq!(compute_contiguous_strides(&[5]), vec![1]);
548 assert_eq!(compute_contiguous_strides(&[]), Vec::<i64>::new());
549 }
550
551 #[test]
552 fn is_contiguous_check() {
553 assert!(is_contiguous(&[2, 3], &[3, 1]));
554 assert!(!is_contiguous(&[2, 3], &[1, 2]));
555 }
556
557 #[test]
558 fn broadcast_basic() {
559 assert_eq!(broadcast_shapes(&[3, 1], &[1, 4]).unwrap(), vec![3, 4]);
560 assert_eq!(broadcast_shapes(&[5], &[3, 5]).unwrap(), vec![3, 5]);
561 assert_eq!(broadcast_shapes(&[], &[2, 2]).unwrap(), vec![2, 2]);
562 }
563
564 #[test]
565 fn broadcast_incompatible() {
566 assert!(matches!(
567 broadcast_shapes(&[3], &[4]),
568 Err(IrError::BroadcastIncompatible { .. })
569 ));
570 }
571
572 #[test]
573 fn transpose_swaps_strides() {
574 let l = TensorLayout::contiguous();
575 let t = l.transpose(&[2, 3], &[1, 0]);
576 // contiguous [2,3] -> strides [3,1]; transposed -> [1,3]
577 assert_eq!(t.strides, Some(vec![1, 3]));
578 assert!(!t.is_contiguous(&[3, 2]));
579 }
580
581 #[test]
582 fn storage_size_contiguous_and_strided() {
583 let l = TensorLayout::contiguous();
584 assert_eq!(l.storage_size(&[2, 3], DataType::Float32), 24);
585 // transposed view still covers the same 6 elements
586 let t = l.transpose(&[2, 3], &[1, 0]);
587 assert_eq!(t.storage_size(&[3, 2], DataType::Float32), 24);
588 }
589}