Skip to main content

fuel_ir/
layout.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Tensor memory layout: shape, strides, and start offset.
3//!
4//! A [`Layout`] describes how a tensor's elements are stored in memory.
5//! Contiguous layouts use row-major strides; non-contiguous layouts arise from
6//! operations like [`transpose`](Layout::transpose) or [`narrow`](Layout::narrow).
7use crate::{DimVec, Error, Result, Shape, StrideVec};
8use smallvec::smallvec;
9
10/// Describes the memory layout of a tensor: shape, strides, and start offset.
11///
12/// Strides are in units of elements (not bytes) and are **signed** —
13/// metadata-only view ops (notably `Op::Flip`) reverse iteration along
14/// a dim by negating that dim's stride and adjusting `start_offset`,
15/// so a stride can be negative even though sizes can't. A contiguous
16/// (row-major) tensor of shape `[a, b, c]` has strides `[b*c, c, 1]`
17/// (all positive); a Flip-on-dim-0 of that has stride `[-b*c, c, 1]`
18/// with `start_offset` set to the byte offset of the (originally)
19/// last element along dim 0.
20///
21/// `start_offset` is the byte offset of the layout's *first* element
22/// in iteration order — by construction always non-negative, even for
23/// negative-stride layouts.
24#[derive(Debug, PartialEq, Eq, Clone)]
25pub struct Layout {
26    shape: Shape,
27    // Signed: see struct doc comment.
28    stride: StrideVec,
29    start_offset: usize,
30}
31
32impl Layout {
33    /// Creates a layout with explicit shape, strides, and start offset.
34    pub fn new(shape: Shape, stride: StrideVec, start_offset: usize) -> Self {
35        Self {
36            shape,
37            stride,
38            start_offset,
39        }
40    }
41
42    /// Creates a contiguous (row-major) layout with the given start offset.
43    pub fn contiguous_with_offset<S: Into<Shape>>(shape: S, start_offset: usize) -> Self {
44        let shape = shape.into();
45        let stride = shape.stride_contiguous();
46        Self {
47            shape,
48            stride,
49            start_offset,
50        }
51    }
52
53    /// Creates a contiguous (row-major) layout with zero start offset.
54    pub fn contiguous<S: Into<Shape>>(shape: S) -> Self {
55        Self::contiguous_with_offset(shape, 0)
56    }
57
58    /// Returns the dimension sizes as a slice.
59    pub fn dims(&self) -> &[usize] {
60        self.shape.dims()
61    }
62
63    /// Returns the size of a specific dimension.
64    pub fn dim<D: crate::shape::Dim>(&self, dim: D) -> Result<usize> {
65        let dim = dim.to_index(&self.shape, "dim")?;
66        Ok(self.dims()[dim])
67    }
68
69    /// Returns a reference to the [`Shape`].
70    pub fn shape(&self) -> &Shape {
71        &self.shape
72    }
73
74    /// Whether the layout's shape has any bounded-symbolic (`Range`) axis.
75    /// Phase D step 1b.
76    pub fn has_dynamic(&self) -> bool {
77        self.shape.has_dynamic()
78    }
79
80    /// Resolve the layout's shape through `env` (every dynamic axis →
81    /// concrete), keeping **strides and offset unchanged**: the physical walk
82    /// of a fixed-capacity buffer is the same; only the logical live extent
83    /// becomes concrete. Errors if a dynamic axis's symbol is unbound.
84    /// Phase D step 1b.
85    pub fn resolve(&self, env: &crate::SymEnv) -> Result<Layout> {
86        Ok(Layout {
87            shape: self.shape.resolve(env)?,
88            stride: self.stride.clone(),
89            start_offset: self.start_offset,
90        })
91    }
92
93    /// Returns the strides as a slice (in elements, not bytes).
94    /// Signed — see struct-level docs.
95    pub fn stride(&self) -> &[isize] {
96        &self.stride
97    }
98
99    /// Returns strides cast to `usize`, debug-asserting non-negative.
100    /// Convenience for kernels that haven't been updated to handle
101    /// signed strides yet (most of them, since auto-Contiguize
102    /// guarantees positive strides on kernel input today). One stack
103    /// allocation via [`DimVec`]; effectively zero cost for rank ≤ 6.
104    ///
105    /// **Don't use this** if your kernel needs to support negative
106    /// strides (e.g. is the consumer of an `Op::Flip` view): iterate
107    /// via [`crate::StridedIndex`] which handles the sign correctly.
108    pub fn stride_unsigned(&self) -> DimVec {
109        self.stride
110            .iter()
111            .map(|&s| {
112                debug_assert!(
113                    s >= 0,
114                    "stride_unsigned() called on a layout with negative stride; \
115                use StridedIndex or operate on signed strides"
116                );
117                s as usize
118            })
119            .collect()
120    }
121
122    /// Returns the start offset into the underlying storage.
123    pub fn start_offset(&self) -> usize {
124        self.start_offset
125    }
126
127    /// Returns `(start, end)` offsets if the layout is contiguous, `None` otherwise.
128    pub fn contiguous_offsets(&self) -> Option<(usize, usize)> {
129        if self.is_contiguous() {
130            let start_o = self.start_offset;
131            Some((start_o, start_o + self.shape.elem_count()))
132        } else {
133            None
134        }
135    }
136
137    /// Returns `true` if the layout is C-contiguous (row-major).
138    ///
139    /// This does not require the start offset to be 0.
140    pub fn is_contiguous(&self) -> bool {
141        self.shape.is_contiguous(&self.stride)
142    }
143
144    /// Returns `true` if the layout is Fortran-contiguous (column-major).
145    pub fn is_fortran_contiguous(&self) -> bool {
146        self.shape.is_fortran_contiguous(&self.stride)
147    }
148
149    /// Returns a narrowed layout selecting `len` elements starting at `start` on dimension `dim`.
150    pub fn narrow(&self, dim: usize, start: usize, len: usize) -> Result<Self> {
151        let dims = self.shape().dims();
152        if dim >= dims.len() {
153            Err(Error::DimOutOfRange {
154                shape: Box::new(self.shape().clone()),
155                dim: dim as i32,
156                op: "narrow",
157            }
158            .bt())?
159        }
160        if start + len > dims[dim] {
161            Err(Error::NarrowInvalidArgs {
162                shape: Box::new(self.shape.clone()),
163                dim,
164                start,
165                len,
166                msg: "start + len > dim_len",
167            }
168            .bt())?
169        }
170        let mut dims = DimVec::from_slice(dims);
171        dims[dim] = len;
172        // start * stride[dim] is element offset; for negative strides
173        // it shifts in the negative element direction, which then adds
174        // (potentially as a wrapping subtraction) to start_offset.
175        // start_offset stays non-negative because narrow only ever
176        // moves the iteration window forward in the original storage's
177        // sense — for positive stride, start*stride is non-negative;
178        // for negative stride, start*stride is non-positive and we add
179        // a non-positive shift, which is fine as long as the original
180        // start_offset already accommodates the layout's full footprint.
181        let new_start = (self.start_offset as isize + self.stride[dim] * start as isize) as usize;
182        Ok(Self {
183            shape: Shape::from(dims),
184            stride: self.stride.clone(),
185            start_offset: new_start,
186        })
187    }
188
189    /// Returns a layout with two dimensions swapped.
190    pub fn transpose(&self, dim1: usize, dim2: usize) -> Result<Self> {
191        let rank = self.shape.rank();
192        if rank <= dim1 || rank <= dim2 {
193            Err(Error::UnexpectedNumberOfDims {
194                expected: usize::max(dim1, dim2),
195                got: rank,
196                shape: Box::new(self.shape().clone()),
197            }
198            .bt())?
199        }
200        let mut stride = StrideVec::from_slice(self.stride());
201        let mut dims = DimVec::from_slice(self.shape().dims());
202        dims.swap(dim1, dim2);
203        stride.swap(dim1, dim2);
204        Ok(Self {
205            shape: Shape::from(dims),
206            stride,
207            start_offset: self.start_offset,
208        })
209    }
210
211    /// Returns a layout with dimensions reordered according to `idxs`.
212    pub fn permute(&self, idxs: &[usize]) -> Result<Self> {
213        let is_permutation =
214            idxs.len() == self.shape.rank() && (0..idxs.len()).all(|i| idxs.contains(&i));
215        if !is_permutation {
216            crate::bail!(
217                "dimension mismatch in permute, tensor {:?}, dims: {:?}",
218                self.dims(),
219                idxs
220            )
221        }
222        let stride = self.stride();
223        let dims = self.shape().dims();
224        let mut perm_stride = StrideVec::from_slice(stride);
225        let mut perm_dims = DimVec::from_slice(dims);
226        for (i, &idx) in idxs.iter().enumerate() {
227            perm_stride[i] = stride[idx];
228            perm_dims[i] = dims[idx];
229        }
230        Ok(Self {
231            shape: Shape::from(perm_dims),
232            stride: perm_stride,
233            start_offset: self.start_offset,
234        })
235    }
236
237    /// Returns a layout with a size-1 dimension inserted at position `dim`.
238    /// `dim` must be in `0..=rank` — `dim == rank` appends to the end.
239    /// Pure metadata: the underlying bytes are unchanged.
240    ///
241    /// Stride for the inserted size-1 axis is set to 0. This stride
242    /// value never affects indexing (the axis only ever has coordinate
243    /// 0, and `0 * stride == 0`), so any value would be correct.
244    /// Choosing 0 matches the convention used by `broadcast_as` for
245    /// size-1 axes broadcast to larger sizes — keeps the convention
246    /// uniform and `Shape::is_contiguous` stays unaffected (it already
247    /// skips dims of size 1).
248    pub fn unsqueeze(&self, dim: usize) -> Result<Self> {
249        let rank = self.shape().rank();
250        if dim > rank {
251            return Err(Error::Msg(format!(
252                "unsqueeze: dim {dim} out of bounds for rank {rank}",
253            ))
254            .bt());
255        }
256        let mut new_dims = DimVec::from_slice(self.shape().dims());
257        new_dims.insert(dim, 1);
258        let mut new_stride = StrideVec::from_slice(self.stride());
259        new_stride.insert(dim, 0);
260        Ok(Self {
261            shape: Shape::from(new_dims),
262            stride: new_stride,
263            start_offset: self.start_offset,
264        })
265    }
266
267    /// Drop a size-1 dimension at position `dim`. Inverse of
268    /// [`Self::unsqueeze`]. The pruned dim's stride is discarded —
269    /// it's irrelevant for indexing because the dim has size 1.
270    /// Errors if `dim` is out of bounds or its size isn't 1.
271    pub fn squeeze(&self, dim: usize) -> Result<Self> {
272        let rank = self.shape().rank();
273        if dim >= rank {
274            return Err(
275                Error::Msg(format!("squeeze: dim {dim} out of bounds for rank {rank}",)).bt(),
276            );
277        }
278        if self.shape().dims()[dim] != 1 {
279            return Err(Error::Msg(format!(
280                "squeeze: dim {dim} has size {}, expected 1",
281                self.shape().dims()[dim],
282            ))
283            .bt());
284        }
285        let mut new_dims = DimVec::from_slice(self.shape().dims());
286        new_dims.remove(dim);
287        let mut new_stride = StrideVec::from_slice(self.stride());
288        new_stride.remove(dim);
289        Ok(Self {
290            shape: Shape::from(new_dims),
291            stride: new_stride,
292            start_offset: self.start_offset,
293        })
294    }
295
296    /// Returns a layout that iterates `dim` in reverse — pure metadata,
297    /// zero copy. The stride at `dim` is negated; `start_offset` is
298    /// shifted forward by `(size-1) * |stride|` so iteration starts at
299    /// what was the last element along `dim`.
300    ///
301    /// Size-0 and size-1 axes are no-ops (no element to flip; the new
302    /// start_offset shift is 0). Successive `flip` calls on the same
303    /// dim are involutive: stride re-negates, offset cancels, you get
304    /// the original layout back.
305    pub fn flip(&self, dim: usize) -> Result<Self> {
306        let rank = self.shape().rank();
307        if dim >= rank {
308            return Err(Error::Msg(format!("flip: dim {dim} out of bounds for rank {rank}",)).bt());
309        }
310        let size = self.shape().dims()[dim];
311        let mut new_stride = StrideVec::from_slice(self.stride());
312        let s = new_stride[dim];
313        new_stride[dim] = -s;
314        // For size <= 1 the iteration window is a single position (or
315        // empty), so the offset shift is 0 — flipping degenerate axes
316        // is a no-op.
317        let shift = if size > 1 { (size as isize - 1) * s } else { 0 };
318        // shift may be negative when stride is positive (most common
319        // case) — that pushes start_offset forward in storage units.
320        // For an already-negative stride we'd shift backwards, which
321        // is exactly what undoes a prior flip.
322        let new_start = (self.start_offset as isize + shift) as usize;
323        Ok(Self {
324            shape: self.shape.clone(),
325            stride: new_stride,
326            start_offset: new_start,
327        })
328    }
329
330    /// Returns a layout broadcast to the given shape.
331    ///
332    /// Dimensions of size 1 in `self` are stretched to match the target shape.
333    pub fn broadcast_as<S: Into<Shape>>(&self, shape: S) -> Result<Self> {
334        let shape = shape.into();
335        if shape.rank() < self.shape().rank() {
336            return Err(Error::BroadcastIncompatibleShapes {
337                src_shape: Box::new(self.shape().clone()),
338                dst_shape: Box::new(shape),
339            }
340            .bt());
341        }
342        let added_dims = shape.rank() - self.shape().rank();
343        let mut stride: StrideVec = smallvec![0_isize; added_dims];
344        for (&dst_dim, (&src_dim, &src_stride)) in shape.dims()[added_dims..]
345            .iter()
346            .zip(self.dims().iter().zip(self.stride()))
347        {
348            let s = if dst_dim == src_dim {
349                src_stride
350            } else if src_dim != 1 {
351                return Err(Error::BroadcastIncompatibleShapes {
352                    src_shape: Box::new(self.shape().clone()),
353                    dst_shape: Box::new(shape),
354                }
355                .bt());
356            } else {
357                0
358            };
359            stride.push(s)
360        }
361        Ok(Self {
362            shape,
363            stride,
364            start_offset: self.start_offset,
365        })
366    }
367
368    pub fn strided_index(&self) -> crate::StridedIndex<'_> {
369        crate::StridedIndex::from_layout(self)
370    }
371
372    pub fn strided_blocks(&self) -> crate::StridedBlocks<'_> {
373        let mut block_len: usize = 1;
374        let mut contiguous_dims = 0; // These are counted from the right.
375        for (&stride, &dim) in self.stride().iter().zip(self.dims().iter()).rev() {
376            // Contiguous block detection: stride must equal +block_len
377            // exactly. Negative strides break the run (a flipped axis
378            // can't extend a contiguous block).
379            if stride != block_len as isize {
380                break;
381            }
382            block_len *= dim;
383            contiguous_dims += 1;
384        }
385        let index_dims = self.dims().len() - contiguous_dims;
386        if index_dims == 0 {
387            crate::StridedBlocks::SingleBlock {
388                start_offset: self.start_offset,
389                len: block_len,
390            }
391        } else {
392            let block_start_index = crate::StridedIndex::new(
393                &self.dims()[..index_dims],
394                &self.stride[..index_dims],
395                self.start_offset,
396            );
397            crate::StridedBlocks::MultipleBlocks {
398                block_start_index,
399                block_len,
400            }
401        }
402    }
403
404    // Returns the contiguous offsets with broadcast if applicable.
405    pub fn offsets_b(&self) -> Option<ContiguousOffsetsWithBroadcast> {
406        let mut left_broadcast: usize = 1;
407        let mut right_broadcast: usize = 1;
408        let strides = self.stride();
409        let dims = self.dims();
410        let mut start_cont = 0;
411        let mut end_cont = dims.len();
412        for (&s, &d) in strides.iter().zip(dims.iter()) {
413            if s != 0 {
414                break;
415            }
416            start_cont += 1;
417            left_broadcast *= d;
418        }
419        if start_cont == dims.len() {
420            return Some(ContiguousOffsetsWithBroadcast {
421                start: self.start_offset,
422                len: 1,
423                left_broadcast,
424                right_broadcast: 1,
425            });
426        }
427        for (&s, &d) in strides.iter().zip(dims.iter()).rev() {
428            if s != 0 {
429                break;
430            }
431            end_cont -= 1;
432            right_broadcast *= d;
433        }
434        // Check that the inner dims are contiguous
435        let strides = &strides[start_cont..end_cont];
436        let dims = &dims[start_cont..end_cont];
437        let mut len: usize = 1;
438        for (&stride, &dim) in strides.iter().zip(dims.iter()).rev() {
439            // Same as strided_blocks: positive contiguous run only.
440            if stride != len as isize {
441                return None;
442            }
443            len *= dim;
444        }
445        Some(ContiguousOffsetsWithBroadcast {
446            start: self.start_offset,
447            len,
448            left_broadcast,
449            right_broadcast,
450        })
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn flip_dim0_of_3x4_negates_stride_and_shifts_offset() {
460        let l = Layout::contiguous(Shape::from_dims(&[3, 4]));
461        let f = l.flip(0).unwrap();
462        assert_eq!(f.shape().dims(), &[3, 4]);
463        assert_eq!(f.stride(), &[-4_isize, 1]);
464        assert_eq!(f.start_offset(), 8); // (3-1) * 4
465        assert!(!f.is_contiguous());
466    }
467
468    #[test]
469    fn flip_is_involutive() {
470        let l = Layout::contiguous(Shape::from_dims(&[3, 4]));
471        let ff = l.flip(0).unwrap().flip(0).unwrap();
472        assert_eq!(ff.shape().dims(), l.shape().dims());
473        assert_eq!(ff.stride(), l.stride());
474        assert_eq!(ff.start_offset(), l.start_offset());
475    }
476
477    #[test]
478    fn flip_size_one_axis_is_noop_offset() {
479        let l = Layout::contiguous(Shape::from_dims(&[1, 4]));
480        let f = l.flip(0).unwrap();
481        assert_eq!(f.start_offset(), 0);
482        assert_eq!(f.stride(), &[-4_isize, 1]);
483    }
484
485    #[test]
486    fn flip_iteration_yields_reversed_offsets() {
487        let l = Layout::contiguous(Shape::from_dims(&[4]));
488        let f = l.flip(0).unwrap();
489        let offsets: Vec<usize> = f.strided_index().collect();
490        assert_eq!(offsets, vec![3, 2, 1, 0]);
491    }
492
493    #[test]
494    fn flip_dim_oob_errors() {
495        let l = Layout::contiguous(Shape::from_dims(&[3, 4]));
496        assert!(l.flip(2).is_err());
497    }
498}
499
500/// Offsets for a layout that is contiguous in its inner dimensions but may be broadcast
501/// on the left or right outer dimensions.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct ContiguousOffsetsWithBroadcast {
504    /// Start offset of the contiguous block.
505    pub start: usize,
506    /// Length of the contiguous block in elements.
507    pub len: usize,
508    /// Number of times the block is repeated on the left (outer) dimensions.
509    pub left_broadcast: usize,
510    /// Number of times the block is repeated on the right (inner) dimensions.
511    pub right_broadcast: usize,
512}