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        let compiled = self.compiled_on(device);
789        let mut outputs = compiled.borrow_mut().run(&[]);
790        outputs.pop().expect("eval produced no output")
791    }
792
793    /// Fastest available backend that can run this tensor's graph (no clone).
794    fn auto_device(&self) -> rlx_runtime::Device {
795        self.handle
796            .with_graph(|g| rlx_runtime::fastest_device_for(g))
797    }
798
799    /// Compiled graph for this tensor's output — borrows the shared graph for
800    /// the cache fingerprint, cloning only on a miss (zero-copy on hits).
801    fn compiled_on(
802        &self,
803        device: rlx_runtime::Device,
804    ) -> std::rc::Rc<std::cell::RefCell<rlx_runtime::CompiledGraph>> {
805        self.handle
806            .with_graph(|g| crate::cache::compiled_output(g, self.id, device))
807    }
808
809    /// Force evaluation — alias for [`to_vec`](Self::to_vec). Building a
810    /// `Tensor` allocates **nothing** and runs **nothing**; it only records IR.
811    /// `realize` is the single point where the accumulated graph is compiled
812    /// (fused + memory-planned) and executed. Use it to make the lazy model
813    /// explicit at call sites.
814    pub fn realize(&self) -> Vec<f32> {
815        self.to_vec()
816    }
817
818    /// [`realize`](Self::realize) on an explicit device.
819    pub fn realize_on(&self, device: rlx_runtime::Device) -> Vec<f32> {
820        self.to_vec_on(device)
821    }
822
823    /// Realize a scalar (rank-0 or single-element) tensor to one `f32`.
824    pub fn item(&self) -> f32 {
825        let v = self.to_vec();
826        assert_eq!(v.len(), 1, "item(): tensor has {} elements, not 1", v.len());
827        v[0]
828    }
829
830    pod_readers! {
831        /// Realize and read back as `f64` (tensor dtype must be `F64`).
832        to_vec_f64 => (f64, F64, 8),
833        /// Realize and read back as `i64` (tensor dtype must be `I64`).
834        to_vec_i64 => (i64, I64, 8),
835        /// Realize and read back as `i32` (tensor dtype must be `I32`).
836        to_vec_i32 => (i32, I32, 4),
837    }
838
839    /// Realize and read back a `Bool` tensor. Robust to the backend storing
840    /// bools 1-byte or f32-encoded (an element is `true` iff any of its bytes
841    /// is non-zero).
842    pub fn to_vec_bool(&self) -> Vec<bool> {
843        let (bytes, dt) = self.eval_typed();
844        assert_eq!(dt, DType::Bool, "to_vec_bool: tensor dtype is {dt:?}");
845        let n = self.numel();
846        assert!(n > 0 && bytes.len() % n == 0, "to_vec_bool: ragged output");
847        let w = bytes.len() / n;
848        bytes
849            .chunks_exact(w)
850            .map(|c| c.iter().any(|&b| b != 0))
851            .collect()
852    }
853
854    /// Compile + run, returning the output's raw little-endian bytes + dtype.
855    /// Device is auto-selected; backs the typed `to_vec_*` readers.
856    fn eval_typed(&self) -> (Vec<u8>, DType) {
857        let compiled = self.compiled_on(self.auto_device());
858        let mut outputs = compiled.borrow_mut().run_typed(&[]);
859        outputs.pop().expect("eval produced no output")
860    }
861
862    /// Realize and pretty-print with shape + dtype, NumPy/ndarray-style nesting.
863    /// `println!("{}", t)` does the same (with the `eval` feature).
864    pub fn show(&self) -> String {
865        let dims = self.dims();
866        let data = self.to_vec();
867        format!(
868            "Tensor{dims:?} {:?}\n{}",
869            self.dtype(),
870            fmt_nested(&data, &dims)
871        )
872    }
873
874    /// Select a device fluently: `t.on(Device::Metal).to_vec()`.
875    pub fn on(&self, device: rlx_runtime::Device) -> Materialize<'_> {
876        Materialize {
877            tensor: self,
878            device,
879        }
880    }
881}
882
883/// Fluent device selector returned by [`Tensor::on`].
884#[cfg(feature = "eval")]
885pub struct Materialize<'a> {
886    tensor: &'a Tensor,
887    device: rlx_runtime::Device,
888}
889
890#[cfg(feature = "eval")]
891impl Materialize<'_> {
892    /// Compile + run on the selected device and copy the output to host.
893    pub fn to_vec(self) -> Vec<f32> {
894        self.tensor.to_vec_on(self.device)
895    }
896}
897
898/// Reverse-mode autodiff. Available with the `grad` feature; pair with `eval`
899/// to read the resulting gradient values.
900#[cfg(feature = "autodiff")]
901impl Tensor {
902    /// Gradients of this tensor (treated as the loss) w.r.t. each `wrt`,
903    /// returned as symbolic [`Tensor`]s in the same order as `wrt`.
904    ///
905    /// The seed `∂self/∂self = 1` is baked in as a constant (ones, matching
906    /// `self`'s shape — so a non-scalar `self` differentiates `sum(self)`),
907    /// making each returned gradient a self-contained graph you can
908    /// `to_vec()` or compose further.
909    ///
910    /// ```ignore
911    /// let loss = (&a * &b).sum([0], false);
912    /// let g = loss.grad(&[&a, &b]);   // [∂/∂a, ∂/∂b]
913    /// assert_eq!(g[0].to_vec(), b.to_vec());
914    /// ```
915    pub fn grad(&self, wrt: &[&Tensor]) -> Vec<Tensor> {
916        // Resolve each `wrt` to its node id *within this loss's graph*.
917        // Memoized adoption guarantees these are the exact nodes the loss was
918        // built from, so gradient actually flows (not to a stale duplicate).
919        let wrt_ids: Vec<NodeId> = wrt.iter().map(|t| self.adopt(t)).collect();
920        let loss_id = self.id;
921        let bwd = self.handle.with_graph(|g| {
922            let mut forward = g.clone();
923            forward.set_outputs(vec![loss_id]);
924            let mut bwd = rlx_autodiff::grad(&forward, &wrt_ids);
925            bake_unit_seed(&mut bwd);
926            bwd
927        });
928        let handle = GraphHandle::new(bwd);
929        let outputs = handle.with_graph(|g| g.outputs.clone());
930        outputs
931            .into_iter()
932            .map(|id| Tensor::new(handle.clone(), id))
933            .collect()
934    }
935}
936
937/// Replace `rlx_autodiff`'s `d_output` seed input with a constant of ones, so
938/// the backward graph runs with no external inputs.
939#[cfg(feature = "autodiff")]
940pub(crate) fn bake_unit_seed(bwd: &mut rlx_ir::Graph) {
941    let seed = bwd.nodes().iter().find_map(|n| match &n.op {
942        Op::Input { name } if name == "d_output" => Some(n.id),
943        _ => None,
944    });
945    if let Some(id) = seed {
946        let n = bwd.node(id).shape.num_elements().unwrap_or(1);
947        let data: Vec<u8> = (0..n).flat_map(|_| 1.0f32.to_le_bytes()).collect();
948        let node = bwd.node_mut(id);
949        node.op = Op::Constant { data };
950        node.inputs = Vec::new();
951    }
952}
953
954impl From<Tensor> for NodeId {
955    fn from(v: Tensor) -> Self {
956        v.id
957    }
958}
959
960impl From<&Tensor> for NodeId {
961    fn from(v: &Tensor) -> Self {
962        v.id
963    }
964}
965
966pub(crate) enum BinaryRhs<'a> {
967    Tensor(&'a Tensor),
968    Scalar(Scalar),
969}
970
971impl<'a> BinaryRhs<'a> {
972    fn scalar(s: Scalar) -> Self {
973        Self::Scalar(s)
974    }
975}
976
977macro_rules! impl_scalar_rhs {
978    ($($ty:ty),+ $(,)?) => {
979        $(
980            impl From<$ty> for BinaryRhs<'_> {
981                fn from(v: $ty) -> Self {
982                    Self::Scalar(v.into())
983                }
984            }
985        )+
986    };
987}
988
989impl_scalar_rhs!(bool, i32, i64, f32, f64);
990
991macro_rules! impl_tensor_binop {
992    ($trait:ident, $method:ident, $graph:ident) => {
993        impl $trait<&Tensor> for &Tensor {
994            type Output = Tensor;
995
996            fn $method(self, rhs: &Tensor) -> Tensor {
997                self.map_binary(BinaryRhs::Tensor(rhs), |g, a, b| g.$graph(a, b))
998            }
999        }
1000
1001        impl $trait<Tensor> for &Tensor {
1002            type Output = Tensor;
1003
1004            fn $method(self, rhs: Tensor) -> Tensor {
1005                self.$method(&rhs)
1006            }
1007        }
1008
1009        impl $trait<f64> for &Tensor {
1010            type Output = Tensor;
1011
1012            fn $method(self, rhs: f64) -> Tensor {
1013                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1014            }
1015        }
1016
1017        impl $trait<f32> for &Tensor {
1018            type Output = Tensor;
1019
1020            fn $method(self, rhs: f32) -> Tensor {
1021                self.$method(rhs as f64)
1022            }
1023        }
1024
1025        impl $trait<i32> for &Tensor {
1026            type Output = Tensor;
1027
1028            fn $method(self, rhs: i32) -> Tensor {
1029                self.map_binary(BinaryRhs::scalar(rhs.into()), |g, a, b| g.$graph(a, b))
1030            }
1031        }
1032
1033        impl $trait<&Tensor> for Tensor {
1034            type Output = Tensor;
1035
1036            fn $method(self, rhs: &Tensor) -> Tensor {
1037                (&self).$method(rhs)
1038            }
1039        }
1040
1041        impl $trait<Tensor> for Tensor {
1042            type Output = Tensor;
1043
1044            fn $method(self, rhs: Tensor) -> Tensor {
1045                (&self).$method(&rhs)
1046            }
1047        }
1048
1049        impl $trait<f64> for Tensor {
1050            type Output = Tensor;
1051
1052            fn $method(self, rhs: f64) -> Tensor {
1053                (&self).$method(rhs)
1054            }
1055        }
1056
1057        impl $trait<f32> for Tensor {
1058            type Output = Tensor;
1059
1060            fn $method(self, rhs: f32) -> Tensor {
1061                (&self).$method(rhs)
1062            }
1063        }
1064
1065        impl $trait<i32> for Tensor {
1066            type Output = Tensor;
1067
1068            fn $method(self, rhs: i32) -> Tensor {
1069                (&self).$method(rhs)
1070            }
1071        }
1072    };
1073}
1074
1075impl_tensor_binop!(Add, add, add);
1076impl_tensor_binop!(Sub, sub, sub);
1077impl_tensor_binop!(Mul, mul, mul);
1078impl_tensor_binop!(Div, div, div);
1079
1080impl Neg for &Tensor {
1081    type Output = Tensor;
1082
1083    fn neg(self) -> Tensor {
1084        self.map_unary(|g, x| g.neg(x))
1085    }
1086}
1087
1088macro_rules! impl_scalar_left {
1089    ($trait:ident, $method:ident, $graph:ident) => {
1090        impl $trait<&Tensor> for f64 {
1091            type Output = Tensor;
1092
1093            fn $method(self, rhs: &Tensor) -> Tensor {
1094                let id = rhs.handle.with_graph(|g| {
1095                    let lhs_id = promote_scalar(g, self.into(), g.shape(rhs.id).dtype());
1096                    g.$graph(lhs_id, rhs.id)
1097                });
1098                Tensor::new(rhs.handle.clone(), id)
1099            }
1100        }
1101
1102        impl $trait<&Tensor> for f32 {
1103            type Output = Tensor;
1104
1105            fn $method(self, rhs: &Tensor) -> Tensor {
1106                (self as f64).$method(rhs)
1107            }
1108        }
1109
1110        impl $trait<&Tensor> for i32 {
1111            type Output = Tensor;
1112
1113            fn $method(self, rhs: &Tensor) -> Tensor {
1114                (self as i64 as f64).$method(rhs)
1115            }
1116        }
1117    };
1118}
1119
1120impl_scalar_left!(Add, add, add);
1121impl_scalar_left!(Sub, sub, sub);
1122impl_scalar_left!(Mul, mul, mul);
1123impl_scalar_left!(Div, div, div);
1124
1125impl Neg for Tensor {
1126    type Output = Tensor;
1127
1128    fn neg(self) -> Tensor {
1129        (&self).neg()
1130    }
1131}