Skip to main content

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}