Skip to main content

rlx_tensor/
tensor.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3
4//! Symbolic tensor handle — zero payload bytes until compile + run.
5
6use std::ops::{Add, Div, Mul, Neg, Sub};
7
8use rlx_ir::op::{Activation, BinaryOp, CmpOp, MaskKind, ReduceOp};
9use rlx_ir::{DType, Dim, GraphExt, NodeId, Op, ScaleLayout, ScaledFormat, Shape};
10
11use crate::handle::GraphHandle;
12use crate::scalar::{Scalar, promote_scalar};
13use crate::slice::SliceSpec;
14
15/// Generate same-shape unary [`Activation`] methods (`name => Variant`).
16/// Each delegates to [`Tensor::map_act`], so adding a JAX-style elementwise
17/// op is a one-line table entry, not a copied method body.
18macro_rules! unary_ops {
19    ($( $(#[$doc:meta])* $name:ident => $variant:ident ),+ $(,)?) => {
20        impl Tensor {
21            $(
22                $(#[$doc])*
23                pub fn $name(&self) -> Self {
24                    self.map_act(Activation::$variant)
25                }
26            )+
27        }
28    };
29}
30
31/// Generate elementwise comparison methods (`name => Variant`) → `Bool` tensor.
32macro_rules! cmp_ops {
33    ($( $(#[$doc:meta])* $name:ident => $variant:ident ),+ $(,)?) => {
34        impl Tensor {
35            $(
36                $(#[$doc])*
37                pub fn $name(&self, rhs: &Tensor) -> Self {
38                    self.map_cmp(BinaryRhs::Tensor(rhs), CmpOp::$variant)
39                }
40            )+
41        }
42    };
43}
44
45/// Generate axis-reduction methods (`name => Variant`).
46macro_rules! reduce_ops {
47    ($( $(#[$doc:meta])* $name:ident => $variant:ident ),+ $(,)?) => {
48        impl Tensor {
49            $(
50                $(#[$doc])*
51                pub fn $name(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
52                    self.map_reduce(ReduceOp::$variant, axes.into(), keep_dim)
53                }
54            )+
55        }
56    };
57}
58
59/// Convert a [`Dim`] to a reshape-style `i64` (dynamic → `-1`).
60fn dim_to_i64(d: &Dim) -> i64 {
61    match d {
62        Dim::Static(n) => *n as i64,
63        Dim::Dynamic(_) => -1,
64    }
65}
66
67/// Render row-major `data` as nested brackets matching `dims`.
68#[cfg(feature = "eval")]
69fn fmt_nested(data: &[f32], dims: &[usize]) -> String {
70    match dims {
71        [] => format!("{}", data.first().copied().unwrap_or(0.0)),
72        [_] => {
73            let body = data
74                .iter()
75                .map(|x| format!("{x}"))
76                .collect::<Vec<_>>()
77                .join(", ");
78            format!("[{body}]")
79        }
80        [outer, rest @ ..] => {
81            let inner: usize = rest.iter().product();
82            let parts = (0..*outer)
83                .map(|i| fmt_nested(&data[i * inner..(i + 1) * inner], rest))
84                .collect::<Vec<_>>()
85                .join(", ");
86            format!("[{parts}]")
87        }
88    }
89}
90
91#[cfg(feature = "eval")]
92impl std::fmt::Display for Tensor {
93    /// Realizes the tensor (compile + run) and prints values — the lazy graph
94    /// is forced here, like ndarray's eager `Display`.
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.write_str(&self.show())
97    }
98}
99
100/// A symbolic tensor in a traced graph.
101///
102/// **Lazy by construction.** Despite the eager-looking operator syntax, a
103/// `Tensor` is a deferred handle: `&a + &b`, `.relu()`, `.matmul(..)` etc.
104/// only append IR nodes — no host or device buffers are allocated and nothing
105/// executes. The graph is compiled (fused + memory-planned) and run **only**
106/// when you force it with [`to_vec`](Self::to_vec) / [`realize`](Self::realize)
107/// (both require the `eval` feature). This is what lets the whole expression
108/// fuse instead of materializing every intermediate.
109///
110/// **Zero-copy clone & slice.** `clone()` just bumps a refcount on the shared
111/// graph handle — no data is duplicated. `slice`/`narrow`/`reshape`/`select`
112/// add a *view* node into the same graph (referencing the parent by id), so
113/// they move zero payload bytes either. Use [`shares_graph`](Self::shares_graph)
114/// to observe storage sharing and [`storage_bytes`](Self::storage_bytes) to
115/// confirm no data was copied. (Combining two *independently-constructed*
116/// tensors is the one case that copies — the right operand's nodes are merged
117/// in once; see [`crate::handle`].)
118#[derive(Clone, Debug)]
119pub struct Tensor {
120    pub(crate) handle: GraphHandle,
121    pub(crate) id: NodeId,
122}
123
124impl Tensor {
125    pub(crate) fn new(handle: GraphHandle, id: NodeId) -> Self {
126        Self { handle, id }
127    }
128
129    /// IR node id (mix with raw [`rlx_ir::Graph`] builders via [`NodeId`]).
130    pub fn id(&self) -> NodeId {
131        self.id
132    }
133
134    pub fn shape(&self) -> Shape {
135        self.handle.with_graph(|g| g.shape(self.id).clone())
136    }
137
138    pub fn dtype(&self) -> DType {
139        self.shape().dtype()
140    }
141
142    pub fn rank(&self) -> usize {
143        self.shape().rank()
144    }
145
146    /// Static dimensions as a `Vec<usize>` (panics on a dynamic axis).
147    pub fn dims(&self) -> Vec<usize> {
148        self.shape()
149            .dims()
150            .iter()
151            .map(|d| match d {
152                Dim::Static(n) => *n,
153                Dim::Dynamic(_) => panic!("dims(): tensor has a dynamic axis"),
154            })
155            .collect()
156    }
157
158    /// Total number of elements (panics on a dynamic axis).
159    pub fn numel(&self) -> usize {
160        self.dims().iter().product()
161    }
162
163    /// True when every dimension is statically known.
164    pub fn is_static_shape(&self) -> bool {
165        self.shape().is_static()
166    }
167
168    /// True if `self` and `other` are backed by the **same** graph — i.e. one
169    /// is a [`clone`](Clone) or a view (`slice`/`reshape`/`narrow`/…) of the
170    /// other, sharing storage with zero copy. Tensors from independent
171    /// constructors return `false` until they're combined in an op.
172    pub fn shares_graph(&self, other: &Tensor) -> bool {
173        self.handle.same(&other.handle)
174    }
175
176    /// Total bytes of constant (payload) data physically held in the backing
177    /// graph. **Cloning and slicing do not increase this** — they share
178    /// storage and only add zero-payload view nodes. Combining tensors from
179    /// different graphs copies the right-hand operand's data in (once).
180    pub fn storage_bytes(&self) -> usize {
181        self.handle.with_graph(|g| {
182            g.nodes()
183                .iter()
184                .filter_map(|n| match &n.op {
185                    Op::Constant { data } => Some(data.len()),
186                    _ => None,
187                })
188                .sum()
189        })
190    }
191
192    /// View slice — lowers to [`narrow`](Self::narrow) (no copy at IR level).
193    pub fn slice(&self, spec: SliceSpec) -> Self {
194        spec.apply(self)
195    }
196
197    fn map_unary(&self, f: impl FnOnce(&mut rlx_ir::Graph, NodeId) -> NodeId) -> Self {
198        let id = self.handle.with_graph(|g| f(g, self.id));
199        Self::new(self.handle.clone(), id)
200    }
201
202    /// Pull `other`'s graph into this tensor's graph (no-op when they already
203    /// share one), returning the operand's node id in *this* graph.
204    pub(crate) fn adopt(&self, other: &Tensor) -> NodeId {
205        self.handle.adopt(&other.handle, other.id)
206    }
207
208    fn map_binary(
209        &self,
210        rhs: BinaryRhs<'_>,
211        f: impl FnOnce(&mut rlx_ir::Graph, NodeId, NodeId) -> NodeId,
212    ) -> Self {
213        let rhs_id = match rhs {
214            BinaryRhs::Tensor(v) => self.adopt(v),
215            BinaryRhs::Scalar(s) => self.handle.with_graph(|g| {
216                let dtype = g.shape(self.id).dtype();
217                promote_scalar(g, s, dtype)
218            }),
219        };
220        let id = self.handle.with_graph(|g| f(g, self.id, rhs_id));
221        Self::new(self.handle.clone(), id)
222    }
223
224    /// Apply a unary [`Activation`] with same-shape output.
225    fn map_act(&self, act: Activation) -> Self {
226        self.map_unary(|g, x| {
227            let s = rlx_ir::shape::unary_shape(g.shape(x));
228            g.activation(act, x, s)
229        })
230    }
231
232    /// Elementwise [`BinaryOp`] with broadcast shape inference.
233    fn map_binary_op(&self, rhs: BinaryRhs<'_>, op: BinaryOp) -> Self {
234        self.map_binary(rhs, |g, a, b| {
235            let s = rlx_ir::shape::binary_shape(g.shape(a), g.shape(b))
236                .expect("binary shape inference");
237            g.binary(op, a, b, s)
238        })
239    }
240
241    /// Elementwise comparison ([`CmpOp`]) → `Bool` tensor.
242    fn map_cmp(&self, rhs: BinaryRhs<'_>, op: CmpOp) -> Self {
243        self.map_binary(rhs, |g, a, b| {
244            let s = rlx_ir::shape::compare_shape(g.shape(a), g.shape(b))
245                .expect("compare shape inference");
246            g.add_node(Op::Compare(op), vec![a, b], s)
247        })
248    }
249
250    /// Reduction ([`ReduceOp`]) over `axes`.
251    fn map_reduce(&self, op: ReduceOp, axes: Vec<usize>, keep_dim: bool) -> Self {
252        self.map_unary(|g, x| {
253            let s = rlx_ir::shape::reduce_shape(g.shape(x), &axes, keep_dim)
254                .expect("reduce shape inference");
255            g.reduce(x, op, axes, keep_dim, s)
256        })
257    }
258
259    /// Matrix multiply (`@` in Python / JAX).
260    #[doc(alias = "@")]
261    pub fn mm(&self, rhs: &Tensor) -> Self {
262        self.matmul(rhs)
263    }
264
265    pub fn matmul(&self, rhs: &Tensor) -> Self {
266        self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.mm(a, b))
267    }
268
269    /// Native low-precision GEMM (TN: `self [m,k] · rhs [n,k]ᵀ → [m,n]`).
270    /// Both operands are dynamically quantized to the minifloat `fmt` +
271    /// `layout`; `rhs` must be K-last (`[n, k]`). `fmt` may be any
272    /// [`ScaledFormat`], including a parameterized `Custom` — e.g.
273    /// `t.scaled_matmul(&w, ScaledFormat::custom(3, 0), ScaleLayout::mx())` runs
274    /// the matmul in `f4e3m0`.
275    pub fn scaled_matmul(&self, rhs: &Tensor, fmt: ScaledFormat, layout: ScaleLayout) -> Self {
276        self.map_binary(BinaryRhs::Tensor(rhs), move |g, a, b| {
277            g.scaled_matmul(a, b, fmt, layout)
278        })
279    }
280
281    pub fn softmax(&self, axis: i32) -> Self {
282        self.map_unary(|g, x| g.sm(x, axis))
283    }
284
285    pub fn reshape(&self, new_shape: impl Into<Vec<i64>>) -> Self {
286        self.map_unary(|g, x| g.reshape_(x, new_shape.into()))
287    }
288
289    pub fn transpose(&self, perm: impl Into<Vec<usize>>) -> Self {
290        self.map_unary(|g, x| g.transpose_(x, perm.into()))
291    }
292
293    pub fn t(&self) -> Self {
294        let rank = self.rank();
295        assert!(rank >= 2, "t() requires rank >= 2");
296        let perm: Vec<usize> = (0..rank - 2).chain([rank - 1, rank - 2]).collect();
297        self.transpose(perm)
298    }
299
300    pub fn cast(&self, to: DType) -> Self {
301        self.map_unary(|g, x| g.cast(x, to))
302    }
303
304    pub fn narrow(&self, axis: usize, start: usize, len: usize) -> Self {
305        self.map_unary(|g, x| g.narrow_(x, axis, start, len))
306    }
307
308    /// Broadcast (expand) to an explicit target shape — NumPy `broadcast_to`.
309    /// A `1` axis stretches; sizes must otherwise match. Use `-1`-free static
310    /// targets.
311    pub fn broadcast_to(&self, shape: impl Into<Vec<i64>>) -> Self {
312        let target = shape.into();
313        self.map_unary(|g, x| {
314            let s = rlx_ir::shape::expand_shape(g.shape(x), &target)
315                .expect("broadcast_to shape inference");
316            g.add_node(
317                Op::Expand {
318                    target_shape: target,
319                },
320                vec![x],
321                s,
322            )
323        })
324    }
325
326    /// Flatten to a 1-D tensor (row-major).
327    pub fn flatten(&self) -> Self {
328        self.reshape(vec![-1_i64])
329    }
330
331    /// Insert a size-1 axis at `axis` (NumPy `expand_dims` / PyTorch
332    /// `unsqueeze`).
333    pub fn unsqueeze(&self, axis: usize) -> Self {
334        let mut dims = self.dims_i64();
335        assert!(axis <= dims.len(), "unsqueeze: axis {axis} out of range");
336        dims.insert(axis, 1);
337        self.reshape(dims)
338    }
339
340    /// Remove the size-1 axis at `axis` (panics if it is not size 1).
341    pub fn squeeze(&self, axis: usize) -> Self {
342        let dims = self.shape();
343        let dims = dims.dims();
344        assert!(
345            matches!(dims.get(axis), Some(Dim::Static(1))),
346            "squeeze: axis {axis} is not size 1"
347        );
348        let new: Vec<i64> = dims
349            .iter()
350            .enumerate()
351            .filter(|(i, _)| *i != axis)
352            .map(|(_, d)| dim_to_i64(d))
353            .collect();
354        self.reshape(new)
355    }
356
357    /// Remove every size-1 axis.
358    pub fn squeeze_all(&self) -> Self {
359        let new: Vec<i64> = self
360            .shape()
361            .dims()
362            .iter()
363            .filter(|d| !matches!(d, Dim::Static(1)))
364            .map(dim_to_i64)
365            .collect();
366        self.reshape(new)
367    }
368
369    /// Current dims as `i64` (dynamic → `-1`), for reshape-style builders.
370    fn dims_i64(&self) -> Vec<i64> {
371        self.shape().dims().iter().map(dim_to_i64).collect()
372    }
373
374    /// Split `axis` into contiguous pieces of the given `sizes` (must sum to
375    /// the axis length). NumPy `split` / PyTorch `split_with_sizes`.
376    pub fn split(&self, axis: usize, sizes: &[usize]) -> Vec<Tensor> {
377        let mut out = Vec::with_capacity(sizes.len());
378        let mut start = 0;
379        for &len in sizes {
380            out.push(self.narrow(axis, start, len));
381            start += len;
382        }
383        out
384    }
385
386    /// Split `axis` into `n` near-equal chunks (first `len % n` get one extra).
387    pub fn chunk(&self, axis: usize, n: usize) -> Vec<Tensor> {
388        assert!(n >= 1, "chunk: n must be >= 1");
389        let total = self.dims()[axis];
390        let base = total / n;
391        let rem = total % n;
392        let sizes: Vec<usize> = (0..n).map(|i| base + usize::from(i < rem)).collect();
393        self.split(axis, &sizes)
394    }
395
396    /// Repeat the whole tensor `reps` times along `axis` (NumPy `tile` on one
397    /// axis): output axis length becomes `len * reps`.
398    pub fn tile(&self, axis: usize, reps: usize) -> Tensor {
399        assert!(reps >= 1, "tile: reps must be >= 1");
400        let copies = vec![self; reps];
401        crate::array::cat(&copies, axis)
402    }
403
404    /// Reverse the order of elements along `axis` (NumPy `flip`).
405    pub fn flip(&self, axis: usize) -> Tensor {
406        let n = self.dims()[axis];
407        let rev: Vec<i64> = (0..n as i64).rev().collect();
408        self.index_select(axis, &Tensor::index_vec(rev))
409    }
410
411    /// Cyclically shift elements along `axis` by `shift` (NumPy `roll`).
412    pub fn roll(&self, axis: usize, shift: usize) -> Tensor {
413        let n = self.dims()[axis];
414        let s = if n == 0 { 0 } else { shift % n };
415        if s == 0 {
416            return self.clone();
417        }
418        let tail = self.narrow(axis, n - s, s);
419        let head = self.narrow(axis, 0, n - s);
420        crate::array::cat(&[&tail, &head], axis)
421    }
422
423    /// Pad `axis` with `before`/`after` entries of `value` (constant pad).
424    pub fn pad(&self, axis: usize, before: usize, after: usize, value: f32) -> Tensor {
425        let mut parts: Vec<Tensor> = Vec::new();
426        if before > 0 {
427            let mut d = self.dims();
428            d[axis] = before;
429            parts.push(Tensor::full(&d, value));
430        }
431        parts.push(self.clone());
432        if after > 0 {
433            let mut d = self.dims();
434            d[axis] = after;
435            parts.push(Tensor::full(&d, value));
436        }
437        let refs: Vec<&Tensor> = parts.iter().collect();
438        crate::array::cat(&refs, axis)
439    }
440
441    pub fn gather(&self, indices: &Tensor, axis: usize) -> Self {
442        self.map_binary(BinaryRhs::Tensor(indices), |g, table, idx| {
443            g.gather_(table, idx, axis)
444        })
445    }
446
447    /// Gather along `axis` by an index tensor — NumPy `take` / PyTorch
448    /// `index_select`. Alias for [`gather`](Self::gather).
449    pub fn index_select(&self, axis: usize, indices: &Tensor) -> Self {
450        self.gather(indices, axis)
451    }
452
453    /// Pick a single position along `axis`, **dropping** that axis (NumPy
454    /// `a[…, i, …]`). Negative `index` counts from the end.
455    pub fn select(&self, axis: usize, index: i64) -> Self {
456        let dim = match self.shape().dims().get(axis) {
457            Some(Dim::Static(n)) => Some(*n),
458            _ => None,
459        };
460        let pos = crate::slice::resolve_index(index, dim, axis);
461        self.narrow(axis, pos, 1).squeeze(axis)
462    }
463
464    pub fn stop_gradient(&self) -> Self {
465        self.map_unary(|g, x| g.stop_gradient(x))
466    }
467
468    /// Index of the maximum along `axis` (f32-encoded indices). Drops the axis
469    /// unless `keep_dim`.
470    pub fn argmax(&self, axis: usize, keep_dim: bool) -> Self {
471        self.map_unary(|g, x| {
472            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
473                .expect("argmax shape inference");
474            g.argmax(x, axis, keep_dim, s)
475        })
476    }
477
478    /// Index of the minimum along `axis` (f32-encoded indices).
479    pub fn argmin(&self, axis: usize, keep_dim: bool) -> Self {
480        self.map_unary(|g, x| {
481            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
482                .expect("argmin shape inference");
483            g.argmin(x, axis, keep_dim, s)
484        })
485    }
486
487    /// Matrix inverse of a square 2-D tensor. Composite over `DenseSolve`
488    /// (`inv(A) = solve(A, I)`), so it needs a BLAS-backed eval build (LAPACK)
489    /// to run, not the default splat backend.
490    pub fn inv(&self) -> Self {
491        let dims = self.dims();
492        assert!(
493            dims.len() == 2 && dims[0] == dims[1],
494            "inv() requires a square 2-D matrix, got {dims:?}"
495        );
496        let n = dims[0];
497        let eye_id = self.adopt(&Tensor::eye(n));
498        let id = self.handle.with_graph(|g| {
499            let s = Shape::new(&[n, n], DType::F32);
500            g.dense_solve(self.id, eye_id, s)
501        });
502        Self::new(self.handle.clone(), id)
503    }
504
505    /// Solve the linear system `self @ x = b` for `x` (LAPACK via `DenseSolve`;
506    /// needs a BLAS eval build). `x` takes `b`'s shape.
507    pub fn solve(&self, b: &Tensor) -> Self {
508        let s = b.shape();
509        let b_id = self.adopt(b);
510        let id = self.handle.with_graph(|g| g.dense_solve(self.id, b_id, s));
511        Self::new(self.handle.clone(), id)
512    }
513
514    /// Cumulative sum along `axis`. `exclusive` shifts so output[0] = 0.
515    pub fn cumsum(&self, axis: i32, exclusive: bool) -> Self {
516        self.map_unary(|g, x| {
517            let s = rlx_ir::shape::unary_shape(g.shape(x));
518            g.cumsum(x, axis, exclusive, s)
519        })
520    }
521
522    /// Population variance over `axes`: `mean((x - mean(x))²)`.
523    pub fn var(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
524        let axes = axes.into();
525        let mu = self.mean(axes.clone(), true);
526        let centered = self - &mu;
527        (&centered * &centered).mean(axes, keep_dim)
528    }
529
530    /// Population standard deviation over `axes`.
531    pub fn std(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
532        self.var(axes, keep_dim).sqrt()
533    }
534
535    /// L2 (Frobenius) norm over `axes`: `sqrt(sum(x²))`.
536    pub fn norm(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
537        (self * self).sum(axes, keep_dim).sqrt()
538    }
539
540    /// Numerically-stable `log(sum(exp(x)))` along `axis`
541    /// (`max + log(sum(exp(x - max)))`).
542    pub fn logsumexp(&self, axis: usize, keep_dim: bool) -> Self {
543        let m = self.max([axis], true);
544        let shifted = self - &m;
545        let summed = shifted.exp().sum([axis], true);
546        let lse = &summed.log() + &m;
547        if keep_dim { lse } else { lse.squeeze(axis) }
548    }
549
550    /// 1-D FFT along the last axis (unnormalized; `ifft(fft(x)) = N·x`).
551    /// Complex data uses the `[..., 2N]` block layout (real plane then imag
552    /// plane) for F32/F64, or a `C64` last axis. Output matches input shape.
553    pub fn fft(&self) -> Self {
554        self.map_unary(|g, x| g.fft(x, false))
555    }
556
557    /// Inverse 1-D FFT along the last axis (unnormalized — pairs with [`fft`]
558    /// up to the `N` factor).
559    pub fn ifft(&self) -> Self {
560        self.map_unary(|g, x| g.fft(x, true))
561    }
562
563    /// 1-D FFT along an arbitrary `axis` (transpose → fft → transpose).
564    pub fn fft_axis(&self, axis: usize, inverse: bool) -> Self {
565        self.map_unary(|g, x| g.fft_axis(x, axis, inverse))
566    }
567
568    pub fn layer_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
569        let g_id = self.adopt(gamma);
570        let b_id = self.adopt(beta);
571        let id = self.handle.with_graph(|g| {
572            let s = rlx_ir::shape::unary_shape(g.shape(self.id));
573            g.layer_norm(self.id, g_id, b_id, -1, eps, s)
574        });
575        Self::new(self.handle.clone(), id)
576    }
577
578    pub fn rms_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
579        let g_id = self.adopt(gamma);
580        let b_id = self.adopt(beta);
581        let id = self
582            .handle
583            .with_graph(|g| g.rms_norm(self.id, g_id, b_id, eps));
584        Self::new(self.handle.clone(), id)
585    }
586
587    pub fn conv2d(
588        &self,
589        weight: &Tensor,
590        kernel_size: [usize; 2],
591        stride: [usize; 2],
592        padding: [usize; 2],
593        dilation: [usize; 2],
594        groups: usize,
595    ) -> Self {
596        let w_id = self.adopt(weight);
597        let id = self.handle.with_graph(|g| {
598            g.conv2d(
599                self.id,
600                w_id,
601                kernel_size,
602                stride,
603                padding,
604                dilation,
605                groups,
606            )
607        });
608        Self::new(self.handle.clone(), id)
609    }
610
611    pub fn attention(
612        &self,
613        k: &Tensor,
614        v: &Tensor,
615        num_heads: usize,
616        head_dim: usize,
617        mask_kind: MaskKind,
618    ) -> Self {
619        let k_id = self.adopt(k);
620        let v_id = self.adopt(v);
621        let id = self.handle.with_graph(|g| {
622            let s = rlx_ir::shape::attention_shape(g.shape(self.id));
623            g.attention_kind(self.id, k_id, v_id, num_heads, head_dim, mask_kind, s)
624        });
625        Self::new(self.handle.clone(), id)
626    }
627
628    pub fn rope(&self, cos: &Tensor, sin: &Tensor, head_dim: usize) -> Self {
629        let cos_id = self.adopt(cos);
630        let sin_id = self.adopt(sin);
631        let id = self
632            .handle
633            .with_graph(|g| g.rope(self.id, cos_id, sin_id, head_dim));
634        Self::new(self.handle.clone(), id)
635    }
636
637    pub fn where_(&self, on_true: &Tensor, on_false: &Tensor) -> Self {
638        let t_id = self.adopt(on_true);
639        let f_id = self.adopt(on_false);
640        let id = self.handle.with_graph(|g| {
641            let s = rlx_ir::shape::binary_shape(g.shape(t_id), g.shape(f_id))
642                .expect("where shape inference");
643            g.add_node(Op::Where, vec![self.id, t_id, f_id], s)
644        });
645        Self::new(self.handle.clone(), id)
646    }
647
648    /// Replace entries of `self` where `mask` is true with `value` (NumPy
649    /// `masked_fill` / the attention-mask primitive: `scores.masked_fill(causal,
650    /// f32::NEG_INFINITY)` then `softmax`). Requires a static shape.
651    pub fn masked_fill(&self, mask: &Tensor, value: f64) -> Self {
652        let dims: Vec<usize> = self
653            .shape()
654            .dims()
655            .iter()
656            .map(|d| match d {
657                Dim::Static(n) => *n,
658                Dim::Dynamic(_) => panic!("masked_fill requires a static shape"),
659            })
660            .collect();
661        let fill = Tensor::full(dims, value as f32);
662        mask.where_(&fill, self)
663    }
664
665    /// Elementwise maximum.
666    pub fn maximum(&self, rhs: &Tensor) -> Self {
667        self.map_binary_op(BinaryRhs::Tensor(rhs), BinaryOp::Max)
668    }
669
670    /// Elementwise minimum.
671    pub fn minimum(&self, rhs: &Tensor) -> Self {
672        self.map_binary_op(BinaryRhs::Tensor(rhs), BinaryOp::Min)
673    }
674
675    /// Clamp values to `[min, max]`.
676    pub fn clamp(&self, min: f64, max: f64) -> Self {
677        self.clamp_min(min).clamp_max(max)
678    }
679
680    /// Lower-bound values at `min`.
681    pub fn clamp_min(&self, min: f64) -> Self {
682        self.map_binary_op(BinaryRhs::Scalar(min.into()), BinaryOp::Max)
683    }
684
685    /// Upper-bound values at `max`.
686    pub fn clamp_max(&self, max: f64) -> Self {
687        self.map_binary_op(BinaryRhs::Scalar(max.into()), BinaryOp::Min)
688    }
689
690    pub fn pow(&self, rhs: &Tensor) -> Self {
691        self.map_binary_op(BinaryRhs::Tensor(rhs), BinaryOp::Pow)
692    }
693
694    pub fn pow_scalar(&self, exp: f64) -> Self {
695        self.map_binary_op(BinaryRhs::Scalar(exp.into()), BinaryOp::Pow)
696    }
697}
698
699unary_ops! {
700    /// Rectified linear unit `max(x, 0)`.
701    relu => Relu,
702    /// Gaussian error linear unit (exact).
703    gelu => Gelu,
704    /// GELU, tanh approximation.
705    gelu_approx => GeluApprox,
706    /// Sigmoid-weighted linear unit `x · σ(x)` (a.k.a. SwiGLU gate).
707    silu => Silu,
708    /// Hyperbolic tangent.
709    tanh => Tanh,
710    /// Natural exponential `e^x`.
711    exp => Exp,
712    /// Square root.
713    sqrt => Sqrt,
714    /// Logistic sigmoid `1 / (1 + e^-x)`.
715    sigmoid => Sigmoid,
716    /// Natural logarithm.
717    log => Log,
718    /// Reciprocal square root `1 / sqrt(x)`.
719    rsqrt => Rsqrt,
720    /// Absolute value.
721    abs => Abs,
722    /// Sine.
723    sin => Sin,
724    /// Cosine.
725    cos => Cos,
726    /// Tangent.
727    tan => Tan,
728    /// Arctangent.
729    atan => Atan,
730    /// Round to nearest (half-to-even); straight-through gradient.
731    round => Round,
732}
733
734cmp_ops! {
735    /// Elementwise `==`.
736    eq => Eq,
737    /// Elementwise `!=`.
738    ne => Ne,
739    /// Elementwise `<`.
740    lt => Lt,
741    /// Elementwise `<=`.
742    le => Le,
743    /// Elementwise `>`.
744    gt => Gt,
745    /// Elementwise `>=`.
746    ge => Ge,
747}
748
749reduce_ops! {
750    /// Sum over `axes`.
751    sum => Sum,
752    /// Mean over `axes`.
753    mean => Mean,
754    /// Maximum over `axes`.
755    max => Max,
756    /// Minimum over `axes`.
757    min => Min,
758    /// Product over `axes`.
759    prod => Prod,
760}
761
762/// Generate typed little-endian readback methods (`name => (T, DType, width)`):
763/// realize, assert the tensor dtype, then decode fixed-width POD elements.
764/// `f32` (the default) and `bool` (multi-encoding) are hand-rolled separately.
765#[cfg(feature = "eval")]
766macro_rules! pod_readers {
767    ($($(#[$m:meta])* $name:ident => ($ty:ty, $dt:ident, $width:literal)),+ $(,)?) => {
768        $(
769            $(#[$m])*
770            pub fn $name(&self) -> Vec<$ty> {
771                let (bytes, dt) = self.eval_typed();
772                assert_eq!(
773                    dt, DType::$dt,
774                    concat!(stringify!($name), ": tensor dtype is {:?}"), dt,
775                );
776                bytes
777                    .chunks_exact($width)
778                    .map(|c| <$ty>::from_le_bytes(c.try_into().unwrap()))
779                    .collect()
780            }
781        )+
782    };
783}
784
785/// Materialization — compile this tensor's graph through `rlx_runtime` and
786/// read the result back to host memory. The graph stays lazy until one of
787/// these is called, so the compiler fuses and memory-plans the whole
788/// expression first. Available with the `eval` feature.
789#[cfg(feature = "eval")]
790impl Tensor {
791    /// Compile + run and copy the output to a `Vec<f32>`. The device is chosen
792    /// automatically — the fastest backend compiled into this build that can
793    /// run the graph (CPU when nothing faster is available).
794    pub fn to_vec(&self) -> Vec<f32> {
795        self.to_vec_on(self.auto_device())
796    }
797
798    /// Compile + run on an explicit device.
799    pub fn to_vec_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
800        // A raw constant's realized value is its own host data — device-independent,
801        // so return it directly and skip backend compilation. This is both an
802        // optimization (no GPU round-trip for a constant) and a robustness fix: the
803        // MLX backend crashes when asked to compile a constant-only trace.
804        if let Some((bytes, DType::F32)) = self.constant_payload() {
805            return bytes
806                .chunks_exact(4)
807                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
808                .collect();
809        }
810        let compiled = self.compiled_on(device);
811        let mut outputs = compiled.borrow_mut().run(&[]);
812        outputs.pop().expect("eval produced no output")
813    }
814
815    /// If this tensor's output node is a raw `Op::Constant` that physically holds its
816    /// full contiguous payload, returns `(bytes, dtype)` — its realized value with no
817    /// computation to run. (Views/slices of a constant are *not* raw constants, so
818    /// they fall through to normal evaluation.)
819    fn constant_payload(&self) -> Option<(Vec<u8>, DType)> {
820        self.handle.with_graph(|g| {
821            let node = g.node(self.id);
822            if let Op::Constant { data } = &node.op {
823                if node.shape.size_bytes() == Some(data.len()) {
824                    return Some((data.clone(), node.shape.dtype()));
825                }
826            }
827            None
828        })
829    }
830
831    /// Fastest available backend that can run this tensor's graph (no clone).
832    fn auto_device(&self) -> rlx_runtime::Device {
833        self.handle
834            .with_graph(|g| rlx_runtime::fastest_device_for(g))
835    }
836
837    /// Compiled graph for this tensor's output — borrows the shared graph for
838    /// the cache fingerprint, cloning only on a miss (zero-copy on hits).
839    fn compiled_on(
840        &self,
841        device: rlx_runtime::Device,
842    ) -> std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>> {
843        self.handle
844            .with_graph(|g| crate::cache::compiled_output(g, self.id, device))
845    }
846
847    /// Force evaluation — alias for [`to_vec`](Self::to_vec). Building a
848    /// `Tensor` allocates **nothing** and runs **nothing**; it only records IR.
849    /// `realize` is the single point where the accumulated graph is compiled
850    /// (fused + memory-planned) and executed. Use it to make the lazy model
851    /// explicit at call sites.
852    pub fn realize(&self) -> Vec<f32> {
853        self.to_vec()
854    }
855
856    /// [`realize`](Self::realize) on an explicit device.
857    pub fn realize_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
858        self.to_vec_on(device)
859    }
860
861    /// Realize a scalar (rank-0 or single-element) tensor to one `f32`.
862    pub fn item(&self) -> f32 {
863        let v = self.to_vec();
864        assert_eq!(v.len(), 1, "item(): tensor has {} elements, not 1", v.len());
865        v[0]
866    }
867
868    pod_readers! {
869        /// Realize and read back as `f64` (tensor dtype must be `F64`).
870        to_vec_f64 => (f64, F64, 8),
871        /// Realize and read back as `i64` (tensor dtype must be `I64`).
872        to_vec_i64 => (i64, I64, 8),
873        /// Realize and read back as `i32` (tensor dtype must be `I32`).
874        to_vec_i32 => (i32, I32, 4),
875    }
876
877    /// Realize and read back a `Bool` tensor. Robust to the backend storing
878    /// bools 1-byte or f32-encoded (an element is `true` iff any of its bytes
879    /// is non-zero).
880    pub fn to_vec_bool(&self) -> Vec<bool> {
881        let (bytes, dt) = self.eval_typed();
882        assert_eq!(dt, DType::Bool, "to_vec_bool: tensor dtype is {dt:?}");
883        let n = self.numel();
884        assert!(n > 0 && bytes.len() % n == 0, "to_vec_bool: ragged output");
885        let w = bytes.len() / n;
886        bytes
887            .chunks_exact(w)
888            .map(|c| c.iter().any(|&b| b != 0))
889            .collect()
890    }
891
892    /// Compile + run, returning the output's raw little-endian bytes + dtype.
893    /// Device is auto-selected; backs the typed `to_vec_*` readers.
894    fn eval_typed(&self) -> (Vec<u8>, DType) {
895        // Raw constant → its bytes are the result (see `to_vec_on`).
896        if let Some(ct) = self.constant_payload() {
897            return ct;
898        }
899        let compiled = self.compiled_on(self.auto_device());
900        let mut outputs = compiled.borrow_mut().run_typed(&[]);
901        outputs.pop().expect("eval produced no output")
902    }
903
904    /// Realize and pretty-print with shape + dtype, NumPy/ndarray-style nesting.
905    /// `println!("{}", t)` does the same (with the `eval` feature).
906    pub fn show(&self) -> String {
907        let dims = self.dims();
908        let data = self.to_vec();
909        format!(
910            "Tensor{dims:?} {:?}\n{}",
911            self.dtype(),
912            fmt_nested(&data, &dims)
913        )
914    }
915
916    /// Select a device fluently: `t.on(Device::Metal).to_vec()`.
917    pub fn on(&self, device: rlx_runtime::Device) -> Materialize<'_> {
918        Materialize {
919            tensor: self,
920            device,
921        }
922    }
923}
924
925/// Fluent device selector returned by [`Tensor::on`].
926#[cfg(feature = "eval")]
927pub struct Materialize<'a> {
928    tensor: &'a Tensor,
929    device: rlx_runtime::Device,
930}
931
932#[cfg(feature = "eval")]
933impl Materialize<'_> {
934    /// Compile + run on the selected device and copy the output to host.
935    pub fn to_vec(self) -> Vec<f32> {
936        self.tensor.to_vec_on(self.device)
937    }
938}
939
940/// Reverse-mode autodiff. Available with the `grad` feature; pair with `eval`
941/// to read the resulting gradient values.
942#[cfg(feature = "autodiff")]
943impl Tensor {
944    /// Gradients of this tensor (treated as the loss) w.r.t. each `wrt`,
945    /// returned as symbolic [`Tensor`]s in the same order as `wrt`.
946    ///
947    /// The seed `∂self/∂self = 1` is baked in as a constant (ones, matching
948    /// `self`'s shape — so a non-scalar `self` differentiates `sum(self)`),
949    /// making each returned gradient a self-contained graph you can
950    /// `to_vec()` or compose further.
951    ///
952    /// ```ignore
953    /// let loss = (&a * &b).sum([0], false);
954    /// let g = loss.grad(&[&a, &b]);   // [∂/∂a, ∂/∂b]
955    /// assert_eq!(g[0].to_vec(), b.to_vec());
956    /// ```
957    pub fn grad(&self, wrt: &[&Tensor]) -> Vec<Tensor> {
958        // Resolve each `wrt` to its node id *within this loss's graph*.
959        // Memoized adoption guarantees these are the exact nodes the loss was
960        // built from, so gradient actually flows (not to a stale duplicate).
961        let wrt_ids: Vec<NodeId> = wrt.iter().map(|t| self.adopt(t)).collect();
962        let loss_id = self.id;
963        let bwd = self.handle.with_graph(|g| {
964            let mut forward = g.clone();
965            forward.set_outputs(vec![loss_id]);
966            let mut bwd = rlx_autodiff::grad(&forward, &wrt_ids);
967            bake_unit_seed(&mut bwd);
968            bwd
969        });
970        let handle = GraphHandle::new(bwd);
971        let outputs = handle.with_graph(|g| g.outputs.clone());
972        outputs
973            .into_iter()
974            .map(|id| Tensor::new(handle.clone(), id))
975            .collect()
976    }
977}
978
979/// Replace `rlx_autodiff`'s `d_output` seed input with a constant of ones, so
980/// the backward graph runs with no external inputs.
981#[cfg(feature = "autodiff")]
982pub(crate) fn bake_unit_seed(bwd: &mut rlx_ir::Graph) {
983    let seed = bwd.nodes().iter().find_map(|n| match &n.op {
984        Op::Input { name } if name == "d_output" => Some(n.id),
985        _ => None,
986    });
987    if let Some(id) = seed {
988        let n = bwd.node(id).shape.num_elements().unwrap_or(1);
989        let data: Vec<u8> = (0..n).flat_map(|_| 1.0f32.to_le_bytes()).collect();
990        let node = bwd.node_mut(id);
991        node.op = Op::Constant { data };
992        node.inputs = Vec::new();
993    }
994}
995
996impl From<Tensor> for NodeId {
997    fn from(v: Tensor) -> Self {
998        v.id
999    }
1000}
1001
1002impl From<&Tensor> for NodeId {
1003    fn from(v: &Tensor) -> Self {
1004        v.id
1005    }
1006}
1007
1008pub(crate) enum BinaryRhs<'a> {
1009    Tensor(&'a Tensor),
1010    Scalar(Scalar),
1011}
1012
1013impl<'a> BinaryRhs<'a> {
1014    fn scalar(s: Scalar) -> Self {
1015        Self::Scalar(s)
1016    }
1017}
1018
1019macro_rules! impl_scalar_rhs {
1020    ($($ty:ty),+ $(,)?) => {
1021        $(
1022            impl From<$ty> for BinaryRhs<'_> {
1023                fn from(v: $ty) -> Self {
1024                    Self::Scalar(v.into())
1025                }
1026            }
1027        )+
1028    };
1029}
1030
1031impl_scalar_rhs!(bool, i32, i64, f32, f64);
1032
1033macro_rules! impl_tensor_binop {
1034    ($trait:ident, $method:ident, $graph:ident) => {
1035        impl $trait<&Tensor> for &Tensor {
1036            type Output = Tensor;
1037
1038            fn $method(self, rhs: &Tensor) -> Tensor {
1039                self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.$graph(a, b))
1040            }
1041        }
1042
1043        impl $trait<Tensor> for &Tensor {
1044            type Output = Tensor;
1045
1046            fn $method(self, rhs: Tensor) -> Tensor {
1047                self.$method(&rhs)
1048            }
1049        }
1050
1051        impl $trait<f64> for &Tensor {
1052            type Output = Tensor;
1053
1054            fn $method(self, rhs: f64) -> Tensor {
1055                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1056            }
1057        }
1058
1059        impl $trait<f32> for &Tensor {
1060            type Output = Tensor;
1061
1062            fn $method(self, rhs: f32) -> Tensor {
1063                self.$method(rhs as f64)
1064            }
1065        }
1066
1067        impl $trait<i32> for &Tensor {
1068            type Output = Tensor;
1069
1070            fn $method(self, rhs: i32) -> Tensor {
1071                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1072            }
1073        }
1074
1075        impl $trait<&Tensor> for Tensor {
1076            type Output = Tensor;
1077
1078            fn $method(self, rhs: &Tensor) -> Tensor {
1079                (&self).$method(rhs)
1080            }
1081        }
1082
1083        impl $trait<Tensor> for Tensor {
1084            type Output = Tensor;
1085
1086            fn $method(self, rhs: Tensor) -> Tensor {
1087                (&self).$method(&rhs)
1088            }
1089        }
1090
1091        impl $trait<f64> for Tensor {
1092            type Output = Tensor;
1093
1094            fn $method(self, rhs: f64) -> Tensor {
1095                (&self).$method(rhs)
1096            }
1097        }
1098
1099        impl $trait<f32> for Tensor {
1100            type Output = Tensor;
1101
1102            fn $method(self, rhs: f32) -> Tensor {
1103                (&self).$method(rhs)
1104            }
1105        }
1106
1107        impl $trait<i32> for Tensor {
1108            type Output = Tensor;
1109
1110            fn $method(self, rhs: i32) -> Tensor {
1111                (&self).$method(rhs)
1112            }
1113        }
1114    };
1115}
1116
1117impl_tensor_binop!(Add, add, add);
1118impl_tensor_binop!(Sub, sub, sub);
1119impl_tensor_binop!(Mul, mul, mul);
1120impl_tensor_binop!(Div, div, div);
1121
1122impl Neg for &Tensor {
1123    type Output = Tensor;
1124
1125    fn neg(self) -> Tensor {
1126        self.map_unary(|g, x| g.neg(x))
1127    }
1128}
1129
1130macro_rules! impl_scalar_left {
1131    ($trait:ident, $method:ident, $graph:ident) => {
1132        impl $trait<&Tensor> for f64 {
1133            type Output = Tensor;
1134
1135            fn $method(self, rhs: &Tensor) -> Tensor {
1136                let id = rhs.handle.with_graph(|g| {
1137                    let lhs_id = promote_scalar(g, self.into(), g.shape(rhs.id).dtype());
1138                    g.$graph(lhs_id, rhs.id)
1139                });
1140                Tensor::new(rhs.handle.clone(), id)
1141            }
1142        }
1143
1144        impl $trait<&Tensor> for f32 {
1145            type Output = Tensor;
1146
1147            fn $method(self, rhs: &Tensor) -> Tensor {
1148                (self as f64).$method(rhs)
1149            }
1150        }
1151
1152        impl $trait<&Tensor> for i32 {
1153            type Output = Tensor;
1154
1155            fn $method(self, rhs: &Tensor) -> Tensor {
1156                (self as i64 as f64).$method(rhs)
1157            }
1158        }
1159    };
1160}
1161
1162impl_scalar_left!(Add, add, add);
1163impl_scalar_left!(Sub, sub, sub);
1164impl_scalar_left!(Mul, mul, mul);
1165impl_scalar_left!(Div, div, div);
1166
1167impl Neg for Tensor {
1168    type Output = Tensor;
1169
1170    fn neg(self) -> Tensor {
1171        (&self).neg()
1172    }
1173}