rlx_ir/layout.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Shared layout vocabulary (plan #3).
6//!
7//! Tile / coordinate / stride types used by every kernel-author
8//! crate. Lives in `rlx-ir` (the leaf) so CPU and Metal stop
9//! re-deriving stride math independently.
10//!
11//! Backend-specific I/O (CPU pointer reads, Metal threadgroup
12//! loads) lives in the backend's own crate behind a `TileIO` trait
13//! — only the *vocabulary* is shared here.
14
15/// 2-D row-major or strided tile shape (in elements).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Tile2 {
18 pub rows: usize,
19 pub cols: usize,
20}
21
22impl Tile2 {
23 pub const fn new(rows: usize, cols: usize) -> Self {
24 Self { rows, cols }
25 }
26 pub const fn area(self) -> usize {
27 self.rows * self.cols
28 }
29}
30
31/// 2-D coordinate within a tile.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Coord2 {
34 pub row: usize,
35 pub col: usize,
36}
37
38/// Per-axis strides in **elements** (not bytes). `row` is the
39/// distance between consecutive rows; `col` between consecutive
40/// columns. For a contiguous row-major tile of shape (R, C):
41/// `Strides2 { row: C, col: 1 }`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Strides2 {
44 pub row: usize,
45 pub col: usize,
46}
47
48impl Strides2 {
49 pub const fn row_major(cols: usize) -> Self {
50 Self { row: cols, col: 1 }
51 }
52 pub const fn col_major(rows: usize) -> Self {
53 Self { row: 1, col: rows }
54 }
55}
56
57/// Hierarchical shape tuple (plan #38). Borrowed from MAX's
58/// `layout/int_tuple.mojo`: shapes nest, so a `((B, S), (H, D))`
59/// expression captures the "outer batch+seq, inner heads+head_dim"
60/// structure of a tiled layout. Useful for kernels that want to
61/// reason about block-tiled sweeps without re-deriving the
62/// implied stride math each time.
63///
64/// Stays alongside the existing flat [`crate::Shape`] (which is
65/// what every op carries today). New code that benefits from
66/// hierarchy uses [`ShapeTuple`]; we don't migrate Shape because
67/// the entire codebase is built around it and the win is
68/// concentrated in advanced layout / fusion code.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum ShapeTuple {
71 /// Single concrete dimension.
72 Leaf(usize),
73 /// Ordered list of sub-tuples. Nesting is unbounded.
74 Nested(Vec<ShapeTuple>),
75}
76
77impl ShapeTuple {
78 /// One-dim leaf. `ShapeTuple::leaf(8)`.
79 pub fn leaf(n: usize) -> Self {
80 Self::Leaf(n)
81 }
82
83 /// Wrapping constructor for nested layouts.
84 pub fn nested(parts: Vec<ShapeTuple>) -> Self {
85 Self::Nested(parts)
86 }
87
88 /// Convenience: build a flat tuple from `&[usize]`. Each
89 /// element becomes a `Leaf`. `flat(&[2, 3, 4])` is equivalent
90 /// to `Nested(vec![Leaf(2), Leaf(3), Leaf(4)])`.
91 pub fn flat(dims: &[usize]) -> Self {
92 Self::Nested(dims.iter().map(|&n| Self::Leaf(n)).collect())
93 }
94
95 pub fn is_leaf(&self) -> bool {
96 matches!(self, Self::Leaf(_))
97 }
98
99 /// Top-level rank. Leaves are rank 1; nested tuples are the
100 /// length of the outer list.
101 pub fn rank(&self) -> usize {
102 match self {
103 Self::Leaf(_) => 1,
104 Self::Nested(v) => v.len(),
105 }
106 }
107
108 /// Total element count, traversing the entire hierarchy.
109 pub fn product(&self) -> usize {
110 match self {
111 Self::Leaf(n) => *n,
112 Self::Nested(v) => v.iter().map(|p| p.product()).product(),
113 }
114 }
115
116 /// Flatten into a row-major sequence of leaves. Useful when
117 /// converting to the existing `Shape` type.
118 pub fn flatten(&self) -> Vec<usize> {
119 let mut out = Vec::new();
120 self.flatten_into(&mut out);
121 out
122 }
123
124 fn flatten_into(&self, out: &mut Vec<usize>) {
125 match self {
126 Self::Leaf(n) => out.push(*n),
127 Self::Nested(v) => v.iter().for_each(|p| p.flatten_into(out)),
128 }
129 }
130
131 /// Walk a path of indices through the hierarchy. Returns
132 /// the sub-tuple at `path` or `None` if the path goes out of
133 /// bounds at any level.
134 ///
135 /// `[]` returns `Some(self)`; `[0]` returns the first child.
136 pub fn get(&self, path: &[usize]) -> Option<&ShapeTuple> {
137 if path.is_empty() {
138 return Some(self);
139 }
140 match self {
141 Self::Leaf(_) => None, // can't descend into a leaf
142 Self::Nested(v) => v.get(path[0]).and_then(|c| c.get(&path[1..])),
143 }
144 }
145}
146
147/// Ragged-tensor descriptor (plan #4). Represents a tensor of
148/// variable-length sequences laid out without padding:
149///
150/// data: [total_elems, trailing_dim] flat
151/// offsets: [batch + 1] cumulative starts
152///
153/// `data[offsets[i]..offsets[i+1]]` is row `i`'s contents (each
154/// row has `(offsets[i+1] - offsets[i])` elements times trailing).
155///
156/// Borrowed from MAX's `nn/_ragged_utils.mojo`, `kv_cache_ragged.mojo`,
157/// and `gemv_partial_norm.mojo`. Essential for serving throughput when
158/// sequences in a batch have very different lengths — padding to max
159/// wastes most of the work; ragged + offset-driven kernels process each
160/// row at its actual length.
161///
162/// Today this is the type vocabulary; kernel paths come per-op as
163/// the ragged use-case lands (the cumsum primitive #44 already
164/// covers offset construction).
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct Ragged {
167 /// Number of rows (= batch).
168 pub rows: usize,
169 /// Trailing per-element width. For BERT it's the hidden
170 /// dimension; for KV cache it's `num_heads * head_dim`. 1 if
171 /// the tensor is a flat sequence of scalars.
172 pub trailing: usize,
173 /// Total elements across all rows (sum of per-row lengths).
174 /// Equals `offsets[rows]` when offsets are materialized.
175 pub total: usize,
176}
177
178impl Ragged {
179 pub const fn new(rows: usize, trailing: usize, total: usize) -> Self {
180 Self {
181 rows,
182 trailing,
183 total,
184 }
185 }
186
187 /// Total f32 element count (data) — does not count the offsets
188 /// table.
189 pub const fn data_elements(self) -> usize {
190 self.total * self.trailing
191 }
192
193 /// Element count of the offsets table (`rows + 1`).
194 pub const fn offsets_elements(self) -> usize {
195 self.rows + 1
196 }
197}
198
199/// 3-D extension for `[batch, rows, cols]` tiles. Common for
200/// per-head attention sweeps.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct Tile3 {
203 pub batch: usize,
204 pub rows: usize,
205 pub cols: usize,
206}
207
208impl Tile3 {
209 pub const fn new(batch: usize, rows: usize, cols: usize) -> Self {
210 Self { batch, rows, cols }
211 }
212 pub const fn area(self) -> usize {
213 self.batch * self.rows * self.cols
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub struct Strides3 {
219 pub batch: usize,
220 pub row: usize,
221 pub col: usize,
222}
223
224impl Strides3 {
225 pub const fn row_major(rows: usize, cols: usize) -> Self {
226 Self {
227 batch: rows * cols,
228 row: cols,
229 col: 1,
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn tile2_area() {
240 assert_eq!(Tile2::new(3, 4).area(), 12);
241 }
242
243 #[test]
244 fn strides2_presets() {
245 assert_eq!(Strides2::row_major(8), Strides2 { row: 8, col: 1 });
246 assert_eq!(Strides2::col_major(8), Strides2 { row: 1, col: 8 });
247 }
248
249 #[test]
250 fn strides3_row_major() {
251 assert_eq!(
252 Strides3::row_major(3, 4),
253 Strides3 {
254 batch: 12,
255 row: 4,
256 col: 1
257 }
258 );
259 }
260
261 // Tuple tests live here so `tuple` test names cover the new
262 // hierarchical type (the runtime check covers the const fns).
263 #[test]
264 fn tuple_leaf_constructors() {
265 let a = ShapeTuple::leaf(8);
266 assert_eq!(a.flatten(), vec![8]);
267 assert_eq!(a.product(), 8);
268 assert!(a.is_leaf());
269 }
270
271 #[test]
272 fn tuple_flat_constructor() {
273 let s = ShapeTuple::flat(&[2, 3, 4]);
274 assert_eq!(s.flatten(), vec![2, 3, 4]);
275 assert_eq!(s.product(), 24);
276 assert_eq!(s.rank(), 3);
277 }
278
279 #[test]
280 fn tuple_nested_product_and_flatten() {
281 // BERT-shape: ((batch, seq), (heads, head_dim)).
282 let bs = ShapeTuple::nested(vec![ShapeTuple::leaf(8), ShapeTuple::leaf(15)]);
283 let nh = ShapeTuple::nested(vec![ShapeTuple::leaf(12), ShapeTuple::leaf(64)]);
284 let s = ShapeTuple::nested(vec![bs, nh]);
285 assert_eq!(s.product(), 8 * 15 * 12 * 64);
286 assert_eq!(s.flatten(), vec![8, 15, 12, 64]);
287 assert_eq!(s.rank(), 2); // top-level rank
288 }
289
290 #[test]
291 fn tuple_get_resolves_path() {
292 let inner = ShapeTuple::nested(vec![ShapeTuple::leaf(12), ShapeTuple::leaf(64)]);
293 let s = ShapeTuple::nested(vec![ShapeTuple::leaf(8), ShapeTuple::leaf(15), inner]);
294 assert_eq!(s.get(&[0]), Some(&ShapeTuple::Leaf(8)));
295 assert_eq!(s.get(&[2, 1]), Some(&ShapeTuple::Leaf(64)));
296 assert_eq!(s.get(&[2, 99]), None);
297 }
298
299 #[test]
300 fn ragged_element_counts() {
301 // 4 rows with total 30 elements; trailing = 8 (hidden dim).
302 let r = Ragged::new(4, 8, 30);
303 assert_eq!(r.data_elements(), 240); // 30 * 8 floats
304 assert_eq!(r.offsets_elements(), 5); // rows + 1
305 }
306}