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, 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    pub fn softmax(&self, axis: i32) -> Self {
270        self.map_unary(|g, x| g.sm(x, axis))
271    }
272
273    pub fn reshape(&self, new_shape: impl Into<Vec<i64>>) -> Self {
274        self.map_unary(|g, x| g.reshape_(x, new_shape.into()))
275    }
276
277    pub fn transpose(&self, perm: impl Into<Vec<usize>>) -> Self {
278        self.map_unary(|g, x| g.transpose_(x, perm.into()))
279    }
280
281    pub fn t(&self) -> Self {
282        let rank = self.rank();
283        assert!(rank >= 2, "t() requires rank >= 2");
284        let perm: Vec<usize> = (0..rank - 2).chain([rank - 1, rank - 2]).collect();
285        self.transpose(perm)
286    }
287
288    pub fn cast(&self, to: DType) -> Self {
289        self.map_unary(|g, x| g.cast(x, to))
290    }
291
292    pub fn narrow(&self, axis: usize, start: usize, len: usize) -> Self {
293        self.map_unary(|g, x| g.narrow_(x, axis, start, len))
294    }
295
296    /// Broadcast (expand) to an explicit target shape — NumPy `broadcast_to`.
297    /// A `1` axis stretches; sizes must otherwise match. Use `-1`-free static
298    /// targets.
299    pub fn broadcast_to(&self, shape: impl Into<Vec<i64>>) -> Self {
300        let target = shape.into();
301        self.map_unary(|g, x| {
302            let s = rlx_ir::shape::expand_shape(g.shape(x), &target)
303                .expect("broadcast_to shape inference");
304            g.add_node(
305                Op::Expand {
306                    target_shape: target,
307                },
308                vec![x],
309                s,
310            )
311        })
312    }
313
314    /// Flatten to a 1-D tensor (row-major).
315    pub fn flatten(&self) -> Self {
316        self.reshape(vec![-1_i64])
317    }
318
319    /// Insert a size-1 axis at `axis` (NumPy `expand_dims` / PyTorch
320    /// `unsqueeze`).
321    pub fn unsqueeze(&self, axis: usize) -> Self {
322        let mut dims = self.dims_i64();
323        assert!(axis <= dims.len(), "unsqueeze: axis {axis} out of range");
324        dims.insert(axis, 1);
325        self.reshape(dims)
326    }
327
328    /// Remove the size-1 axis at `axis` (panics if it is not size 1).
329    pub fn squeeze(&self, axis: usize) -> Self {
330        let dims = self.shape();
331        let dims = dims.dims();
332        assert!(
333            matches!(dims.get(axis), Some(Dim::Static(1))),
334            "squeeze: axis {axis} is not size 1"
335        );
336        let new: Vec<i64> = dims
337            .iter()
338            .enumerate()
339            .filter(|(i, _)| *i != axis)
340            .map(|(_, d)| dim_to_i64(d))
341            .collect();
342        self.reshape(new)
343    }
344
345    /// Remove every size-1 axis.
346    pub fn squeeze_all(&self) -> Self {
347        let new: Vec<i64> = self
348            .shape()
349            .dims()
350            .iter()
351            .filter(|d| !matches!(d, Dim::Static(1)))
352            .map(dim_to_i64)
353            .collect();
354        self.reshape(new)
355    }
356
357    /// Current dims as `i64` (dynamic → `-1`), for reshape-style builders.
358    fn dims_i64(&self) -> Vec<i64> {
359        self.shape().dims().iter().map(dim_to_i64).collect()
360    }
361
362    /// Split `axis` into contiguous pieces of the given `sizes` (must sum to
363    /// the axis length). NumPy `split` / PyTorch `split_with_sizes`.
364    pub fn split(&self, axis: usize, sizes: &[usize]) -> Vec<Tensor> {
365        let mut out = Vec::with_capacity(sizes.len());
366        let mut start = 0;
367        for &len in sizes {
368            out.push(self.narrow(axis, start, len));
369            start += len;
370        }
371        out
372    }
373
374    /// Split `axis` into `n` near-equal chunks (first `len % n` get one extra).
375    pub fn chunk(&self, axis: usize, n: usize) -> Vec<Tensor> {
376        assert!(n >= 1, "chunk: n must be >= 1");
377        let total = self.dims()[axis];
378        let base = total / n;
379        let rem = total % n;
380        let sizes: Vec<usize> = (0..n).map(|i| base + usize::from(i < rem)).collect();
381        self.split(axis, &sizes)
382    }
383
384    /// Repeat the whole tensor `reps` times along `axis` (NumPy `tile` on one
385    /// axis): output axis length becomes `len * reps`.
386    pub fn tile(&self, axis: usize, reps: usize) -> Tensor {
387        assert!(reps >= 1, "tile: reps must be >= 1");
388        let copies = vec![self; reps];
389        crate::array::cat(&copies, axis)
390    }
391
392    /// Reverse the order of elements along `axis` (NumPy `flip`).
393    pub fn flip(&self, axis: usize) -> Tensor {
394        let n = self.dims()[axis];
395        let rev: Vec<i64> = (0..n as i64).rev().collect();
396        self.index_select(axis, &Tensor::index_vec(rev))
397    }
398
399    /// Cyclically shift elements along `axis` by `shift` (NumPy `roll`).
400    pub fn roll(&self, axis: usize, shift: usize) -> Tensor {
401        let n = self.dims()[axis];
402        let s = if n == 0 { 0 } else { shift % n };
403        if s == 0 {
404            return self.clone();
405        }
406        let tail = self.narrow(axis, n - s, s);
407        let head = self.narrow(axis, 0, n - s);
408        crate::array::cat(&[&tail, &head], axis)
409    }
410
411    /// Pad `axis` with `before`/`after` entries of `value` (constant pad).
412    pub fn pad(&self, axis: usize, before: usize, after: usize, value: f32) -> Tensor {
413        let mut parts: Vec<Tensor> = Vec::new();
414        if before > 0 {
415            let mut d = self.dims();
416            d[axis] = before;
417            parts.push(Tensor::full(&d, value));
418        }
419        parts.push(self.clone());
420        if after > 0 {
421            let mut d = self.dims();
422            d[axis] = after;
423            parts.push(Tensor::full(&d, value));
424        }
425        let refs: Vec<&Tensor> = parts.iter().collect();
426        crate::array::cat(&refs, axis)
427    }
428
429    pub fn gather(&self, indices: &Tensor, axis: usize) -> Self {
430        self.map_binary(BinaryRhs::Tensor(indices), |g, table, idx| {
431            g.gather_(table, idx, axis)
432        })
433    }
434
435    /// Gather along `axis` by an index tensor — NumPy `take` / PyTorch
436    /// `index_select`. Alias for [`gather`](Self::gather).
437    pub fn index_select(&self, axis: usize, indices: &Tensor) -> Self {
438        self.gather(indices, axis)
439    }
440
441    /// Pick a single position along `axis`, **dropping** that axis (NumPy
442    /// `a[…, i, …]`). Negative `index` counts from the end.
443    pub fn select(&self, axis: usize, index: i64) -> Self {
444        let dim = match self.shape().dims().get(axis) {
445            Some(Dim::Static(n)) => Some(*n),
446            _ => None,
447        };
448        let pos = crate::slice::resolve_index(index, dim, axis);
449        self.narrow(axis, pos, 1).squeeze(axis)
450    }
451
452    pub fn stop_gradient(&self) -> Self {
453        self.map_unary(|g, x| g.stop_gradient(x))
454    }
455
456    /// Index of the maximum along `axis` (f32-encoded indices). Drops the axis
457    /// unless `keep_dim`.
458    pub fn argmax(&self, axis: usize, keep_dim: bool) -> Self {
459        self.map_unary(|g, x| {
460            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
461                .expect("argmax shape inference");
462            g.argmax(x, axis, keep_dim, s)
463        })
464    }
465
466    /// Index of the minimum along `axis` (f32-encoded indices).
467    pub fn argmin(&self, axis: usize, keep_dim: bool) -> Self {
468        self.map_unary(|g, x| {
469            let s = rlx_ir::shape::reduce_shape(g.shape(x), &[axis], keep_dim)
470                .expect("argmin shape inference");
471            g.argmin(x, axis, keep_dim, s)
472        })
473    }
474
475    /// Matrix inverse of a square 2-D tensor. Composite over `DenseSolve`
476    /// (`inv(A) = solve(A, I)`), so it needs a BLAS-backed eval build (LAPACK)
477    /// to run, not the default splat backend.
478    pub fn inv(&self) -> Self {
479        let dims = self.dims();
480        assert!(
481            dims.len() == 2 && dims[0] == dims[1],
482            "inv() requires a square 2-D matrix, got {dims:?}"
483        );
484        let n = dims[0];
485        let eye_id = self.adopt(&Tensor::eye(n));
486        let id = self.handle.with_graph(|g| {
487            let s = Shape::new(&[n, n], DType::F32);
488            g.dense_solve(self.id, eye_id, s)
489        });
490        Self::new(self.handle.clone(), id)
491    }
492
493    /// Solve the linear system `self @ x = b` for `x` (LAPACK via `DenseSolve`;
494    /// needs a BLAS eval build). `x` takes `b`'s shape.
495    pub fn solve(&self, b: &Tensor) -> Self {
496        let s = b.shape();
497        let b_id = self.adopt(b);
498        let id = self.handle.with_graph(|g| g.dense_solve(self.id, b_id, s));
499        Self::new(self.handle.clone(), id)
500    }
501
502    /// Cumulative sum along `axis`. `exclusive` shifts so output[0] = 0.
503    pub fn cumsum(&self, axis: i32, exclusive: bool) -> Self {
504        self.map_unary(|g, x| {
505            let s = rlx_ir::shape::unary_shape(g.shape(x));
506            g.cumsum(x, axis, exclusive, s)
507        })
508    }
509
510    /// Population variance over `axes`: `mean((x - mean(x))²)`.
511    pub fn var(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
512        let axes = axes.into();
513        let mu = self.mean(axes.clone(), true);
514        let centered = self - &mu;
515        (&centered * &centered).mean(axes, keep_dim)
516    }
517
518    /// Population standard deviation over `axes`.
519    pub fn std(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
520        self.var(axes, keep_dim).sqrt()
521    }
522
523    /// L2 (Frobenius) norm over `axes`: `sqrt(sum(x²))`.
524    pub fn norm(&self, axes: impl Into<Vec<usize>>, keep_dim: bool) -> Self {
525        (self * self).sum(axes, keep_dim).sqrt()
526    }
527
528    /// Numerically-stable `log(sum(exp(x)))` along `axis`
529    /// (`max + log(sum(exp(x - max)))`).
530    pub fn logsumexp(&self, axis: usize, keep_dim: bool) -> Self {
531        let m = self.max([axis], true);
532        let shifted = self - &m;
533        let summed = shifted.exp().sum([axis], true);
534        let lse = &summed.log() + &m;
535        if keep_dim { lse } else { lse.squeeze(axis) }
536    }
537
538    /// 1-D FFT along the last axis (unnormalized; `ifft(fft(x)) = N·x`).
539    /// Complex data uses the `[..., 2N]` block layout (real plane then imag
540    /// plane) for F32/F64, or a `C64` last axis. Output matches input shape.
541    pub fn fft(&self) -> Self {
542        self.map_unary(|g, x| g.fft(x, false))
543    }
544
545    /// Inverse 1-D FFT along the last axis (unnormalized — pairs with [`fft`]
546    /// up to the `N` factor).
547    pub fn ifft(&self) -> Self {
548        self.map_unary(|g, x| g.fft(x, true))
549    }
550
551    /// 1-D FFT along an arbitrary `axis` (transpose → fft → transpose).
552    pub fn fft_axis(&self, axis: usize, inverse: bool) -> Self {
553        self.map_unary(|g, x| g.fft_axis(x, axis, inverse))
554    }
555
556    pub fn layer_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
557        let g_id = self.adopt(gamma);
558        let b_id = self.adopt(beta);
559        let id = self.handle.with_graph(|g| {
560            let s = rlx_ir::shape::unary_shape(g.shape(self.id));
561            g.layer_norm(self.id, g_id, b_id, -1, eps, s)
562        });
563        Self::new(self.handle.clone(), id)
564    }
565
566    pub fn rms_norm(&self, gamma: &Tensor, beta: &Tensor, eps: f32) -> Self {
567        let g_id = self.adopt(gamma);
568        let b_id = self.adopt(beta);
569        let id = self
570            .handle
571            .with_graph(|g| g.rms_norm(self.id, g_id, b_id, eps));
572        Self::new(self.handle.clone(), id)
573    }
574
575    pub fn conv2d(
576        &self,
577        weight: &Tensor,
578        kernel_size: [usize; 2],
579        stride: [usize; 2],
580        padding: [usize; 2],
581        dilation: [usize; 2],
582        groups: usize,
583    ) -> Self {
584        let w_id = self.adopt(weight);
585        let id = self.handle.with_graph(|g| {
586            g.conv2d(
587                self.id,
588                w_id,
589                kernel_size,
590                stride,
591                padding,
592                dilation,
593                groups,
594            )
595        });
596        Self::new(self.handle.clone(), id)
597    }
598
599    pub fn attention(
600        &self,
601        k: &Tensor,
602        v: &Tensor,
603        num_heads: usize,
604        head_dim: usize,
605        mask_kind: MaskKind,
606    ) -> Self {
607        let k_id = self.adopt(k);
608        let v_id = self.adopt(v);
609        let id = self.handle.with_graph(|g| {
610            let s = rlx_ir::shape::attention_shape(g.shape(self.id));
611            g.attention_kind(self.id, k_id, v_id, num_heads, head_dim, mask_kind, s)
612        });
613        Self::new(self.handle.clone(), id)
614    }
615
616    pub fn rope(&self, cos: &Tensor, sin: &Tensor, head_dim: usize) -> Self {
617        let cos_id = self.adopt(cos);
618        let sin_id = self.adopt(sin);
619        let id = self
620            .handle
621            .with_graph(|g| g.rope(self.id, cos_id, sin_id, head_dim));
622        Self::new(self.handle.clone(), id)
623    }
624
625    pub fn where_(&self, on_true: &Tensor, on_false: &Tensor) -> Self {
626        let t_id = self.adopt(on_true);
627        let f_id = self.adopt(on_false);
628        let id = self.handle.with_graph(|g| {
629            let s = rlx_ir::shape::binary_shape(g.shape(t_id), g.shape(f_id))
630                .expect("where shape inference");
631            g.add_node(Op::Where, vec![self.id, t_id, f_id], s)
632        });
633        Self::new(self.handle.clone(), id)
634    }
635
636    /// Replace entries of `self` where `mask` is true with `value` (NumPy
637    /// `masked_fill` / the attention-mask primitive: `scores.masked_fill(causal,
638    /// f32::NEG_INFINITY)` then `softmax`). Requires a static shape.
639    pub fn masked_fill(&self, mask: &Tensor, value: f64) -> Self {
640        let dims: Vec<usize> = self
641            .shape()
642            .dims()
643            .iter()
644            .map(|d| match d {
645                Dim::Static(n) => *n,
646                Dim::Dynamic(_) => panic!("masked_fill requires a static shape"),
647            })
648            .collect();
649        let fill = Tensor::full(dims, value as f32);
650        mask.where_(&fill, self)
651    }
652
653    /// Elementwise maximum.
654    pub fn maximum(&self, rhs: &Tensor) -> Self {
655        self.map_binary_op(BinaryRhs::Tensor(rhs), BinaryOp::Max)
656    }
657
658    /// Elementwise minimum.
659    pub fn minimum(&self, rhs: &Tensor) -> Self {
660        self.map_binary_op(BinaryRhs::Tensor(rhs), BinaryOp::Min)
661    }
662
663    /// Clamp values to `[min, max]`.
664    pub fn clamp(&self, min: f64, max: f64) -> Self {
665        self.clamp_min(min).clamp_max(max)
666    }
667
668    /// Lower-bound values at `min`.
669    pub fn clamp_min(&self, min: f64) -> Self {
670        self.map_binary_op(BinaryRhs::Scalar(min.into()), BinaryOp::Max)
671    }
672
673    /// Upper-bound values at `max`.
674    pub fn clamp_max(&self, max: f64) -> Self {
675        self.map_binary_op(BinaryRhs::Scalar(max.into()), BinaryOp::Min)
676    }
677
678    pub fn pow(&self, rhs: &Tensor) -> Self {
679        self.map_binary_op(BinaryRhs::Tensor(rhs), BinaryOp::Pow)
680    }
681
682    pub fn pow_scalar(&self, exp: f64) -> Self {
683        self.map_binary_op(BinaryRhs::Scalar(exp.into()), BinaryOp::Pow)
684    }
685}
686
687unary_ops! {
688    /// Rectified linear unit `max(x, 0)`.
689    relu => Relu,
690    /// Gaussian error linear unit (exact).
691    gelu => Gelu,
692    /// GELU, tanh approximation.
693    gelu_approx => GeluApprox,
694    /// Sigmoid-weighted linear unit `x · σ(x)` (a.k.a. SwiGLU gate).
695    silu => Silu,
696    /// Hyperbolic tangent.
697    tanh => Tanh,
698    /// Natural exponential `e^x`.
699    exp => Exp,
700    /// Square root.
701    sqrt => Sqrt,
702    /// Logistic sigmoid `1 / (1 + e^-x)`.
703    sigmoid => Sigmoid,
704    /// Natural logarithm.
705    log => Log,
706    /// Reciprocal square root `1 / sqrt(x)`.
707    rsqrt => Rsqrt,
708    /// Absolute value.
709    abs => Abs,
710    /// Sine.
711    sin => Sin,
712    /// Cosine.
713    cos => Cos,
714    /// Tangent.
715    tan => Tan,
716    /// Arctangent.
717    atan => Atan,
718    /// Round to nearest (half-to-even); straight-through gradient.
719    round => Round,
720}
721
722cmp_ops! {
723    /// Elementwise `==`.
724    eq => Eq,
725    /// Elementwise `!=`.
726    ne => Ne,
727    /// Elementwise `<`.
728    lt => Lt,
729    /// Elementwise `<=`.
730    le => Le,
731    /// Elementwise `>`.
732    gt => Gt,
733    /// Elementwise `>=`.
734    ge => Ge,
735}
736
737reduce_ops! {
738    /// Sum over `axes`.
739    sum => Sum,
740    /// Mean over `axes`.
741    mean => Mean,
742    /// Maximum over `axes`.
743    max => Max,
744    /// Minimum over `axes`.
745    min => Min,
746    /// Product over `axes`.
747    prod => Prod,
748}
749
750/// Generate typed little-endian readback methods (`name => (T, DType, width)`):
751/// realize, assert the tensor dtype, then decode fixed-width POD elements.
752/// `f32` (the default) and `bool` (multi-encoding) are hand-rolled separately.
753#[cfg(feature = "eval")]
754macro_rules! pod_readers {
755    ($($(#[$m:meta])* $name:ident => ($ty:ty, $dt:ident, $width:literal)),+ $(,)?) => {
756        $(
757            $(#[$m])*
758            pub fn $name(&self) -> Vec<$ty> {
759                let (bytes, dt) = self.eval_typed();
760                assert_eq!(
761                    dt, DType::$dt,
762                    concat!(stringify!($name), ": tensor dtype is {:?}"), dt,
763                );
764                bytes
765                    .chunks_exact($width)
766                    .map(|c| <$ty>::from_le_bytes(c.try_into().unwrap()))
767                    .collect()
768            }
769        )+
770    };
771}
772
773/// Materialization — compile this tensor's graph through `rlx_runtime` and
774/// read the result back to host memory. The graph stays lazy until one of
775/// these is called, so the compiler fuses and memory-plans the whole
776/// expression first. Available with the `eval` feature.
777#[cfg(feature = "eval")]
778impl Tensor {
779    /// Compile + run and copy the output to a `Vec<f32>`. The device is chosen
780    /// automatically — the fastest backend compiled into this build that can
781    /// run the graph (CPU when nothing faster is available).
782    pub fn to_vec(&self) -> Vec<f32> {
783        self.to_vec_on(self.auto_device())
784    }
785
786    /// Compile + run on an explicit device.
787    pub fn to_vec_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
788        // A raw constant's realized value is its own host data — device-independent,
789        // so return it directly and skip backend compilation. This is both an
790        // optimization (no GPU round-trip for a constant) and a robustness fix: the
791        // MLX backend crashes when asked to compile a constant-only trace.
792        if let Some((bytes, DType::F32)) = self.constant_payload() {
793            return bytes
794                .chunks_exact(4)
795                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
796                .collect();
797        }
798        let compiled = self.compiled_on(device);
799        let mut outputs = compiled.borrow_mut().run(&[]);
800        outputs.pop().expect("eval produced no output")
801    }
802
803    /// If this tensor's output node is a raw `Op::Constant` that physically holds its
804    /// full contiguous payload, returns `(bytes, dtype)` — its realized value with no
805    /// computation to run. (Views/slices of a constant are *not* raw constants, so
806    /// they fall through to normal evaluation.)
807    fn constant_payload(&self) -> Option<(Vec<u8>, DType)> {
808        self.handle.with_graph(|g| {
809            let node = g.node(self.id);
810            if let Op::Constant { data } = &node.op {
811                if node.shape.size_bytes() == Some(data.len()) {
812                    return Some((data.clone(), node.shape.dtype()));
813                }
814            }
815            None
816        })
817    }
818
819    /// Fastest available backend that can run this tensor's graph (no clone).
820    fn auto_device(&self) -> rlx_runtime::Device {
821        self.handle
822            .with_graph(|g| rlx_runtime::fastest_device_for(g))
823    }
824
825    /// Compiled graph for this tensor's output — borrows the shared graph for
826    /// the cache fingerprint, cloning only on a miss (zero-copy on hits).
827    fn compiled_on(
828        &self,
829        device: rlx_runtime::Device,
830    ) -> std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>> {
831        self.handle
832            .with_graph(|g| crate::cache::compiled_output(g, self.id, device))
833    }
834
835    /// Force evaluation — alias for [`to_vec`](Self::to_vec). Building a
836    /// `Tensor` allocates **nothing** and runs **nothing**; it only records IR.
837    /// `realize` is the single point where the accumulated graph is compiled
838    /// (fused + memory-planned) and executed. Use it to make the lazy model
839    /// explicit at call sites.
840    pub fn realize(&self) -> Vec<f32> {
841        self.to_vec()
842    }
843
844    /// [`realize`](Self::realize) on an explicit device.
845    pub fn realize_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
846        self.to_vec_on(device)
847    }
848
849    /// Realize a scalar (rank-0 or single-element) tensor to one `f32`.
850    pub fn item(&self) -> f32 {
851        let v = self.to_vec();
852        assert_eq!(v.len(), 1, "item(): tensor has {} elements, not 1", v.len());
853        v[0]
854    }
855
856    pod_readers! {
857        /// Realize and read back as `f64` (tensor dtype must be `F64`).
858        to_vec_f64 => (f64, F64, 8),
859        /// Realize and read back as `i64` (tensor dtype must be `I64`).
860        to_vec_i64 => (i64, I64, 8),
861        /// Realize and read back as `i32` (tensor dtype must be `I32`).
862        to_vec_i32 => (i32, I32, 4),
863    }
864
865    /// Realize and read back a `Bool` tensor. Robust to the backend storing
866    /// bools 1-byte or f32-encoded (an element is `true` iff any of its bytes
867    /// is non-zero).
868    pub fn to_vec_bool(&self) -> Vec<bool> {
869        let (bytes, dt) = self.eval_typed();
870        assert_eq!(dt, DType::Bool, "to_vec_bool: tensor dtype is {dt:?}");
871        let n = self.numel();
872        assert!(n > 0 && bytes.len() % n == 0, "to_vec_bool: ragged output");
873        let w = bytes.len() / n;
874        bytes
875            .chunks_exact(w)
876            .map(|c| c.iter().any(|&b| b != 0))
877            .collect()
878    }
879
880    /// Compile + run, returning the output's raw little-endian bytes + dtype.
881    /// Device is auto-selected; backs the typed `to_vec_*` readers.
882    fn eval_typed(&self) -> (Vec<u8>, DType) {
883        // Raw constant → its bytes are the result (see `to_vec_on`).
884        if let Some(ct) = self.constant_payload() {
885            return ct;
886        }
887        let compiled = self.compiled_on(self.auto_device());
888        let mut outputs = compiled.borrow_mut().run_typed(&[]);
889        outputs.pop().expect("eval produced no output")
890    }
891
892    /// Realize and pretty-print with shape + dtype, NumPy/ndarray-style nesting.
893    /// `println!("{}", t)` does the same (with the `eval` feature).
894    pub fn show(&self) -> String {
895        let dims = self.dims();
896        let data = self.to_vec();
897        format!(
898            "Tensor{dims:?} {:?}\n{}",
899            self.dtype(),
900            fmt_nested(&data, &dims)
901        )
902    }
903
904    /// Select a device fluently: `t.on(Device::Metal).to_vec()`.
905    pub fn on(&self, device: rlx_runtime::Device) -> Materialize<'_> {
906        Materialize {
907            tensor: self,
908            device,
909        }
910    }
911}
912
913/// Fluent device selector returned by [`Tensor::on`].
914#[cfg(feature = "eval")]
915pub struct Materialize<'a> {
916    tensor: &'a Tensor,
917    device: rlx_runtime::Device,
918}
919
920#[cfg(feature = "eval")]
921impl Materialize<'_> {
922    /// Compile + run on the selected device and copy the output to host.
923    pub fn to_vec(self) -> Vec<f32> {
924        self.tensor.to_vec_on(self.device)
925    }
926}
927
928/// Reverse-mode autodiff. Available with the `grad` feature; pair with `eval`
929/// to read the resulting gradient values.
930#[cfg(feature = "autodiff")]
931impl Tensor {
932    /// Gradients of this tensor (treated as the loss) w.r.t. each `wrt`,
933    /// returned as symbolic [`Tensor`]s in the same order as `wrt`.
934    ///
935    /// The seed `∂self/∂self = 1` is baked in as a constant (ones, matching
936    /// `self`'s shape — so a non-scalar `self` differentiates `sum(self)`),
937    /// making each returned gradient a self-contained graph you can
938    /// `to_vec()` or compose further.
939    ///
940    /// ```ignore
941    /// let loss = (&a * &b).sum([0], false);
942    /// let g = loss.grad(&[&a, &b]);   // [∂/∂a, ∂/∂b]
943    /// assert_eq!(g[0].to_vec(), b.to_vec());
944    /// ```
945    pub fn grad(&self, wrt: &[&Tensor]) -> Vec<Tensor> {
946        // Resolve each `wrt` to its node id *within this loss's graph*.
947        // Memoized adoption guarantees these are the exact nodes the loss was
948        // built from, so gradient actually flows (not to a stale duplicate).
949        let wrt_ids: Vec<NodeId> = wrt.iter().map(|t| self.adopt(t)).collect();
950        let loss_id = self.id;
951        let bwd = self.handle.with_graph(|g| {
952            let mut forward = g.clone();
953            forward.set_outputs(vec![loss_id]);
954            let mut bwd = rlx_autodiff::grad(&forward, &wrt_ids);
955            bake_unit_seed(&mut bwd);
956            bwd
957        });
958        let handle = GraphHandle::new(bwd);
959        let outputs = handle.with_graph(|g| g.outputs.clone());
960        outputs
961            .into_iter()
962            .map(|id| Tensor::new(handle.clone(), id))
963            .collect()
964    }
965}
966
967/// Replace `rlx_autodiff`'s `d_output` seed input with a constant of ones, so
968/// the backward graph runs with no external inputs.
969#[cfg(feature = "autodiff")]
970pub(crate) fn bake_unit_seed(bwd: &mut rlx_ir::Graph) {
971    let seed = bwd.nodes().iter().find_map(|n| match &n.op {
972        Op::Input { name } if name == "d_output" => Some(n.id),
973        _ => None,
974    });
975    if let Some(id) = seed {
976        let n = bwd.node(id).shape.num_elements().unwrap_or(1);
977        let data: Vec<u8> = (0..n).flat_map(|_| 1.0f32.to_le_bytes()).collect();
978        let node = bwd.node_mut(id);
979        node.op = Op::Constant { data };
980        node.inputs = Vec::new();
981    }
982}
983
984impl From<Tensor> for NodeId {
985    fn from(v: Tensor) -> Self {
986        v.id
987    }
988}
989
990impl From<&Tensor> for NodeId {
991    fn from(v: &Tensor) -> Self {
992        v.id
993    }
994}
995
996pub(crate) enum BinaryRhs<'a> {
997    Tensor(&'a Tensor),
998    Scalar(Scalar),
999}
1000
1001impl<'a> BinaryRhs<'a> {
1002    fn scalar(s: Scalar) -> Self {
1003        Self::Scalar(s)
1004    }
1005}
1006
1007macro_rules! impl_scalar_rhs {
1008    ($($ty:ty),+ $(,)?) => {
1009        $(
1010            impl From<$ty> for BinaryRhs<'_> {
1011                fn from(v: $ty) -> Self {
1012                    Self::Scalar(v.into())
1013                }
1014            }
1015        )+
1016    };
1017}
1018
1019impl_scalar_rhs!(bool, i32, i64, f32, f64);
1020
1021macro_rules! impl_tensor_binop {
1022    ($trait:ident, $method:ident, $graph:ident) => {
1023        impl $trait<&Tensor> for &Tensor {
1024            type Output = Tensor;
1025
1026            fn $method(self, rhs: &Tensor) -> Tensor {
1027                self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.$graph(a, b))
1028            }
1029        }
1030
1031        impl $trait<Tensor> for &Tensor {
1032            type Output = Tensor;
1033
1034            fn $method(self, rhs: Tensor) -> Tensor {
1035                self.$method(&rhs)
1036            }
1037        }
1038
1039        impl $trait<f64> for &Tensor {
1040            type Output = Tensor;
1041
1042            fn $method(self, rhs: f64) -> Tensor {
1043                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1044            }
1045        }
1046
1047        impl $trait<f32> for &Tensor {
1048            type Output = Tensor;
1049
1050            fn $method(self, rhs: f32) -> Tensor {
1051                self.$method(rhs as f64)
1052            }
1053        }
1054
1055        impl $trait<i32> for &Tensor {
1056            type Output = Tensor;
1057
1058            fn $method(self, rhs: i32) -> Tensor {
1059                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1060            }
1061        }
1062
1063        impl $trait<&Tensor> for Tensor {
1064            type Output = Tensor;
1065
1066            fn $method(self, rhs: &Tensor) -> Tensor {
1067                (&self).$method(rhs)
1068            }
1069        }
1070
1071        impl $trait<Tensor> for Tensor {
1072            type Output = Tensor;
1073
1074            fn $method(self, rhs: Tensor) -> Tensor {
1075                (&self).$method(&rhs)
1076            }
1077        }
1078
1079        impl $trait<f64> for Tensor {
1080            type Output = Tensor;
1081
1082            fn $method(self, rhs: f64) -> Tensor {
1083                (&self).$method(rhs)
1084            }
1085        }
1086
1087        impl $trait<f32> for Tensor {
1088            type Output = Tensor;
1089
1090            fn $method(self, rhs: f32) -> Tensor {
1091                (&self).$method(rhs)
1092            }
1093        }
1094
1095        impl $trait<i32> for Tensor {
1096            type Output = Tensor;
1097
1098            fn $method(self, rhs: i32) -> Tensor {
1099                (&self).$method(rhs)
1100            }
1101        }
1102    };
1103}
1104
1105impl_tensor_binop!(Add, add, add);
1106impl_tensor_binop!(Sub, sub, sub);
1107impl_tensor_binop!(Mul, mul, mul);
1108impl_tensor_binop!(Div, div, div);
1109
1110impl Neg for &Tensor {
1111    type Output = Tensor;
1112
1113    fn neg(self) -> Tensor {
1114        self.map_unary(|g, x| g.neg(x))
1115    }
1116}
1117
1118macro_rules! impl_scalar_left {
1119    ($trait:ident, $method:ident, $graph:ident) => {
1120        impl $trait<&Tensor> for f64 {
1121            type Output = Tensor;
1122
1123            fn $method(self, rhs: &Tensor) -> Tensor {
1124                let id = rhs.handle.with_graph(|g| {
1125                    let lhs_id = promote_scalar(g, self.into(), g.shape(rhs.id).dtype());
1126                    g.$graph(lhs_id, rhs.id)
1127                });
1128                Tensor::new(rhs.handle.clone(), id)
1129            }
1130        }
1131
1132        impl $trait<&Tensor> for f32 {
1133            type Output = Tensor;
1134
1135            fn $method(self, rhs: &Tensor) -> Tensor {
1136                (self as f64).$method(rhs)
1137            }
1138        }
1139
1140        impl $trait<&Tensor> for i32 {
1141            type Output = Tensor;
1142
1143            fn $method(self, rhs: &Tensor) -> Tensor {
1144                (self as i64 as f64).$method(rhs)
1145            }
1146        }
1147    };
1148}
1149
1150impl_scalar_left!(Add, add, add);
1151impl_scalar_left!(Sub, sub, sub);
1152impl_scalar_left!(Mul, mul, mul);
1153impl_scalar_left!(Div, div, div);
1154
1155impl Neg for Tensor {
1156    type Output = Tensor;
1157
1158    fn neg(self) -> Tensor {
1159        (&self).neg()
1160    }
1161}