Skip to main content

rlx_ir/
infer.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Shape-inferred graph builder — ergonomic API that auto-computes output shapes.
6//!
7//! Import [`GraphExt`] and call short-name methods instead of providing explicit shapes:
8//! ```rust
9//! use rlx_ir::*;
10//! use rlx_ir::infer::GraphExt;
11//!
12//! let mut g = Graph::new("example");
13//! let x = g.input("x", Shape::new(&[4, 384], DType::F32));
14//! let w = g.param("w", Shape::new(&[384, 1536], DType::F32));
15//! let b = g.param("b", Shape::new(&[1536], DType::F32));
16//! let mm = g.mm(x, w);
17//! let add = g.add(mm, b);
18//! let out = g.gelu(add);
19//! let two = g.constant(2.0, DType::F32);
20//! let scaled = g.mul(x, two);
21//! let c = g.try_constant(2.0, DType::F32).unwrap(); // fallible variant
22//! g.set_outputs(vec![out, scaled, c]);
23//! ```
24
25use crate::dtype::scalar_constant_bytes;
26use crate::op::*;
27use crate::shape;
28use crate::{DType, Graph, NodeId, Op, Shape};
29
30/// Extension trait for shape-inferred graph building.
31pub trait GraphExt {
32    // ── Linear algebra ──────────────────────────────────────
33    fn mm(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
34
35    // ── Binary ──────────────────────────────────────────────
36    fn add(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
37    fn sub(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
38    fn mul(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
39    fn div(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
40
41    // ── Activation ──────────────────────────────────────────
42    fn gelu(&mut self, x: NodeId) -> NodeId;
43    /// Tanh-approximation GELU (PyTorch's default `gelu` formula,
44    /// also candle's `Tensor::gelu`). Use this when porting models
45    /// whose reference implementations use the tanh form for
46    /// numerical parity (e.g. DINOv2, many ViTs).
47    fn gelu_approx(&mut self, x: NodeId) -> NodeId;
48    fn silu(&mut self, x: NodeId) -> NodeId;
49    fn relu(&mut self, x: NodeId) -> NodeId;
50    fn exp(&mut self, x: NodeId) -> NodeId;
51    /// Elementwise reciprocal `1 / x`.
52    fn recip(&mut self, x: NodeId) -> NodeId;
53    fn sqrt(&mut self, x: NodeId) -> NodeId;
54    /// Reciprocal square root `1/sqrt(x)`.
55    fn rsqrt(&mut self, x: NodeId) -> NodeId;
56    fn neg(&mut self, x: NodeId) -> NodeId;
57    fn tanh(&mut self, x: NodeId) -> NodeId;
58    /// Logistic sigmoid `1/(1+exp(-x))`. Its CDF interpretation makes it the
59    /// natural building block for discretized (bin-mass) entropy rates.
60    fn sigmoid(&mut self, x: NodeId) -> NodeId;
61    /// Natural logarithm `ln(x)`.
62    fn log(&mut self, x: NodeId) -> NodeId;
63    /// Elementwise absolute value `|x|`.
64    fn abs(&mut self, x: NodeId) -> NodeId;
65
66    // ── Normalization ───────────────────────────────────────
67    fn ln(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId;
68    fn layer_norm2d(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId;
69    fn group_norm(
70        &mut self,
71        x: NodeId,
72        gamma: NodeId,
73        beta: NodeId,
74        num_groups: usize,
75        eps: f32,
76    ) -> NodeId;
77    fn rms_norm(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId;
78
79    /// DiT adaLN-Zero modulation: `norm(x)·(1+scale)+shift`. `norm` normalizes
80    /// over the last axis with no learnable affine; `scale`/`shift` broadcast
81    /// over every axis except the last (the `[B,1,D]` modulation). See
82    /// [`Op::AdaLayerNorm`].
83    fn ada_layer_norm(
84        &mut self,
85        x: NodeId,
86        scale: NodeId,
87        shift: NodeId,
88        norm: crate::op::AdaNormKind,
89        eps: f32,
90    ) -> NodeId;
91
92    /// DiT gated residual: `x + gate·y`. `gate` broadcasts over every axis
93    /// except the last (the `[B,1,D]` modulation gate). See
94    /// [`Op::GatedResidual`].
95    fn gated_residual(&mut self, x: NodeId, y: NodeId, gate: NodeId) -> NodeId;
96
97    // ── Convolution (NCHW) ───────────────────────────────────
98    fn conv2d(
99        &mut self,
100        input: NodeId,
101        weight: NodeId,
102        kernel_size: [usize; 2],
103        stride: [usize; 2],
104        padding: [usize; 2],
105        dilation: [usize; 2],
106        groups: usize,
107    ) -> NodeId;
108    fn conv_transpose2d(
109        &mut self,
110        input: NodeId,
111        weight: NodeId,
112        kernel_size: [usize; 2],
113        stride: [usize; 2],
114        padding: [usize; 2],
115        dilation: [usize; 2],
116        output_padding: [usize; 2],
117        groups: usize,
118    ) -> NodeId;
119
120    // ── Pooling (NCHW) ───────────────────────────────────────
121    /// 2-D pooling; channels pass through, only the spatial axes shrink.
122    fn pool2d(
123        &mut self,
124        input: NodeId,
125        kind: crate::op::ReduceOp,
126        kernel_size: [usize; 2],
127        stride: [usize; 2],
128        padding: [usize; 2],
129    ) -> NodeId;
130
131    // ── Reduction ───────────────────────────────────────────
132    fn sum(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId;
133    fn mean(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId;
134    fn sm(&mut self, x: NodeId, axis: i32) -> NodeId;
135
136    // ── Shape manipulation ──────────────────────────────────
137    fn reshape_(&mut self, x: NodeId, new_shape: Vec<i64>) -> NodeId;
138    fn transpose_(&mut self, x: NodeId, perm: Vec<usize>) -> NodeId;
139    fn narrow_(&mut self, x: NodeId, axis: usize, start: usize, len: usize) -> NodeId;
140    fn concat_(&mut self, inputs: Vec<NodeId>, axis: usize) -> NodeId;
141    /// Pad each axis by `pads[i] = [before, after]` (rank-aligned) per `mode`.
142    fn pad_(&mut self, x: NodeId, pads: Vec<[usize; 2]>, mode: PadMode) -> NodeId;
143    /// Strided slice along `axis`: `len` reads at `start + j*step` (`step != 0`,
144    /// may be negative for reverse).
145    fn slice_(&mut self, x: NodeId, axis: usize, start: usize, len: usize, step: i64) -> NodeId;
146    /// Cyclic shift: `out[i] = x[(i - shifts[k]) mod n]` along each `dims[k]`.
147    /// Shape-preserving; shifts may be negative or exceed the axis length.
148    fn roll_(&mut self, x: NodeId, shifts: Vec<i64>, dims: Vec<usize>) -> NodeId;
149    /// Elementwise `clamp(x, min, max)`.
150    fn clamp_(&mut self, x: NodeId, min: f32, max: f32) -> NodeId;
151    /// Tile `x` by `reps[i]` along axis `i` (rank-aligned).
152    fn tile_(&mut self, x: NodeId, reps: Vec<usize>) -> NodeId;
153    /// Keep upper/lower triangle (last two axes) relative to `diagonal`.
154    fn trilu_(&mut self, x: NodeId, upper: bool, diagonal: i64) -> NodeId;
155    /// Cumulative product along `axis` (shape-inferring).
156    fn cumprod_(&mut self, x: NodeId, axis: i32, exclusive: bool) -> NodeId;
157    /// Cumulative maximum along `axis` (shape-inferring).
158    fn cummax_(&mut self, x: NodeId, axis: i32, exclusive: bool) -> NodeId;
159    fn gather_(&mut self, table: NodeId, indices: NodeId, axis: usize) -> NodeId;
160
161    // ── Comparison ──────────────────────────────────────────
162    fn eq(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
163    fn lt(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
164
165    // ── Attention ───────────────────────────────────────────
166    fn attention_(
167        &mut self,
168        q: NodeId,
169        k: NodeId,
170        v: NodeId,
171        mask: NodeId,
172        num_heads: usize,
173        head_dim: usize,
174    ) -> NodeId;
175
176    // ── RoPE ────────────────────────────────────────────────
177    fn rope(&mut self, x: NodeId, cos: NodeId, sin: NodeId, head_dim: usize) -> NodeId;
178    /// Partial RoPE: rotate the first `n_rot` dims (NeoX offset `n_rot/2`).
179    fn rope_n(
180        &mut self,
181        x: NodeId,
182        cos: NodeId,
183        sin: NodeId,
184        head_dim: usize,
185        n_rot: usize,
186    ) -> NodeId;
187    /// RoPE with an explicit pairing flavor ([`crate::op::RopeStyle`]).
188    fn rope_styled(
189        &mut self,
190        x: NodeId,
191        cos: NodeId,
192        sin: NodeId,
193        head_dim: usize,
194        style: crate::op::RopeStyle,
195    ) -> NodeId;
196    /// Partial RoPE with an explicit pairing flavor.
197    fn rope_n_styled(
198        &mut self,
199        x: NodeId,
200        cos: NodeId,
201        sin: NodeId,
202        head_dim: usize,
203        n_rot: usize,
204        style: crate::op::RopeStyle,
205    ) -> NodeId;
206
207    // ── Cast ────────────────────────────────────────────────
208    fn cast(&mut self, x: NodeId, to: DType) -> NodeId;
209
210    // ── Literals ────────────────────────────────────────────
211    /// Rank-0 broadcastable scalar (`Op::Constant`). `f16` / `bf16`
212    /// are lowered as `f32` constant + `cast`.
213    fn constant(&mut self, value: f64, dtype: DType) -> NodeId;
214
215    /// Fallible variant of [`GraphExt::constant`]. Returns an error when
216    /// `value` is out of range for `dtype` or when `dtype` cannot be encoded
217    /// directly (callers may lower `f16` / `bf16` via `try_constant` on
218    /// `F32` plus `cast`).
219    fn try_constant(&mut self, value: f64, dtype: DType) -> Result<NodeId, String>;
220
221    /// A constant **zeros tensor** of `dims` (an `Op::Constant`, so autodiff
222    /// gives it no gradient). Use this for zero-padding via `concat_` — e.g.
223    /// causal / "same" padding — instead of declaring a trainable zero `param`,
224    /// which pollutes the trained weights and (being batch-sized) mismatches the
225    /// node shape at a different inference batch. The shape is concrete, so the
226    /// tensor is rebuilt at the right size whenever the graph is rebuilt.
227    fn zeros(&mut self, dims: &[usize], dtype: DType) -> NodeId;
228
229    /// A constant tensor of `dims` filled with `value` (like [`GraphExt::zeros`]
230    /// but any scalar). An `Op::Constant`, so no gradient.
231    fn full(&mut self, dims: &[usize], value: f32, dtype: DType) -> NodeId;
232
233    /// Trainable BatchNorm with **batch statistics** (the training-mode BN that
234    /// rlx otherwise lacks — only `BatchNormInference` with frozen stats exists).
235    /// `x` is `[N, C, H, W]`; `gamma`/`beta` are `[C]`. The batch-coupled backward
236    /// is the tricky part autodiff can't compose — here it's obtained for FREE by
237    /// composing existing ops: transpose C↔N so `GroupNorm(num_groups=1)` (which
238    /// has a hand-written, batch-safe backward) normalises each channel over
239    /// `(N, H, W)` with an identity affine, then transpose back and apply the real
240    /// per-channel `gamma`/`beta` (a plain affine autodiff handles). Numerically
241    /// equals PyTorch `nn.BatchNorm2d` in `train()` mode (batch mean/var, `eps`).
242    fn batch_norm_training(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId;
243
244    // ── Stop gradient ───────────────────────────────────────
245    /// Identity forward, zero backward. The reverse-mode autodiff rule
246    /// for `Op::StopGradient` returns no gradient contribution to the
247    /// input. Equivalent to PyTorch's `tensor.detach()` /
248    /// `jax.lax.stop_gradient` / TF's `tf.stop_gradient`.
249    fn stop_gradient(&mut self, x: NodeId) -> NodeId;
250}
251
252/// Reduce over `axes`, decomposing a NON-CONTIGUOUS multi-axis reduce into
253/// sequential single-axis reduces. The CPU `Reduce` thunk only supports a
254/// contiguous axis range; a reduce like `mean(x, [0,2,3])` (keeps axis 1)
255/// silently returns all zeros. Since sum is associative and mean composes
256/// (÷W · ÷H · ÷N = ÷NHW), reducing one axis at a time — descending, with
257/// keep_dim=true so the remaining axis indices stay valid — is numerically
258/// identical and correct on every backend. Contiguous / single-axis reduces
259/// keep the direct fast path.
260fn reduce_axes(g: &mut Graph, x: NodeId, op: ReduceOp, axes: Vec<usize>, keep_dim: bool) -> NodeId {
261    let mut sorted = axes.clone();
262    sorted.sort_unstable();
263    let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1);
264    if axes.len() <= 1 || contiguous {
265        let s = shape::reduce_shape(g.shape(x), &axes, keep_dim).expect("reduce shape inference");
266        return g.reduce(x, op, axes, keep_dim, s);
267    }
268    // Non-contiguous: fold one axis at a time, highest first, keeping dims so
269    // the lower axis indices don't shift underneath us.
270    let mut cur = x;
271    for &ax in sorted.iter().rev() {
272        let s = shape::reduce_shape(g.shape(cur), &[ax], true).expect("reduce shape inference");
273        cur = g.reduce(cur, op, vec![ax], true, s);
274    }
275    if keep_dim {
276        cur
277    } else {
278        let s = shape::reduce_shape(g.shape(x), &axes, false).expect("reduce shape inference");
279        let dims: Vec<i64> = s.dims().iter().map(|d| d.unwrap_static() as i64).collect();
280        g.reshape(cur, dims, s)
281    }
282}
283
284impl GraphExt for Graph {
285    fn mm(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
286        let s =
287            shape::matmul_shape(self.shape(lhs), self.shape(rhs)).expect("matmul shape inference");
288        self.matmul(lhs, rhs, s)
289    }
290
291    fn add(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
292        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("add shape inference");
293        self.binary(BinaryOp::Add, lhs, rhs, s)
294    }
295
296    fn sub(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
297        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("sub shape inference");
298        self.binary(BinaryOp::Sub, lhs, rhs, s)
299    }
300
301    fn mul(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
302        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("mul shape inference");
303        self.binary(BinaryOp::Mul, lhs, rhs, s)
304    }
305
306    fn div(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
307        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("div shape inference");
308        self.binary(BinaryOp::Div, lhs, rhs, s)
309    }
310
311    fn gelu(&mut self, x: NodeId) -> NodeId {
312        let s = shape::unary_shape(self.shape(x));
313        self.activation(Activation::Gelu, x, s)
314    }
315
316    fn gelu_approx(&mut self, x: NodeId) -> NodeId {
317        let s = shape::unary_shape(self.shape(x));
318        self.activation(Activation::GeluApprox, x, s)
319    }
320
321    fn silu(&mut self, x: NodeId) -> NodeId {
322        let s = shape::unary_shape(self.shape(x));
323        self.activation(Activation::Silu, x, s)
324    }
325
326    fn relu(&mut self, x: NodeId) -> NodeId {
327        let s = shape::unary_shape(self.shape(x));
328        self.activation(Activation::Relu, x, s)
329    }
330
331    fn exp(&mut self, x: NodeId) -> NodeId {
332        let s = shape::unary_shape(self.shape(x));
333        self.activation(Activation::Exp, x, s)
334    }
335
336    fn recip(&mut self, x: NodeId) -> NodeId {
337        let s = shape::unary_shape(self.shape(x));
338        self.activation(Activation::Recip, x, s)
339    }
340
341    fn sqrt(&mut self, x: NodeId) -> NodeId {
342        let s = shape::unary_shape(self.shape(x));
343        self.activation(Activation::Sqrt, x, s)
344    }
345
346    fn rsqrt(&mut self, x: NodeId) -> NodeId {
347        let s = shape::unary_shape(self.shape(x));
348        self.activation(Activation::Rsqrt, x, s)
349    }
350
351    fn neg(&mut self, x: NodeId) -> NodeId {
352        let s = shape::unary_shape(self.shape(x));
353        self.activation(Activation::Neg, x, s)
354    }
355
356    fn tanh(&mut self, x: NodeId) -> NodeId {
357        let s = shape::unary_shape(self.shape(x));
358        self.activation(Activation::Tanh, x, s)
359    }
360
361    fn sigmoid(&mut self, x: NodeId) -> NodeId {
362        let s = shape::unary_shape(self.shape(x));
363        self.activation(Activation::Sigmoid, x, s)
364    }
365
366    fn log(&mut self, x: NodeId) -> NodeId {
367        let s = shape::unary_shape(self.shape(x));
368        self.activation(Activation::Log, x, s)
369    }
370
371    fn abs(&mut self, x: NodeId) -> NodeId {
372        let s = shape::unary_shape(self.shape(x));
373        self.activation(Activation::Abs, x, s)
374    }
375
376    fn ln(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
377        let s = shape::unary_shape(self.shape(x));
378        self.layer_norm(x, gamma, beta, -1, eps, s)
379    }
380
381    fn layer_norm2d(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
382        Graph::layer_norm2d(self, x, gamma, beta, eps)
383    }
384
385    fn group_norm(
386        &mut self,
387        x: NodeId,
388        gamma: NodeId,
389        beta: NodeId,
390        num_groups: usize,
391        eps: f32,
392    ) -> NodeId {
393        Graph::group_norm(self, x, gamma, beta, num_groups, eps)
394    }
395
396    fn conv2d(
397        &mut self,
398        input: NodeId,
399        weight: NodeId,
400        kernel_size: [usize; 2],
401        stride: [usize; 2],
402        padding: [usize; 2],
403        dilation: [usize; 2],
404        groups: usize,
405    ) -> NodeId {
406        Graph::conv2d(
407            self,
408            input,
409            weight,
410            kernel_size,
411            stride,
412            padding,
413            dilation,
414            groups,
415        )
416    }
417
418    fn pool2d(
419        &mut self,
420        input: NodeId,
421        kind: crate::op::ReduceOp,
422        kernel_size: [usize; 2],
423        stride: [usize; 2],
424        padding: [usize; 2],
425    ) -> NodeId {
426        Graph::pool2d(self, input, kind, kernel_size, stride, padding)
427    }
428
429    fn conv_transpose2d(
430        &mut self,
431        input: NodeId,
432        weight: NodeId,
433        kernel_size: [usize; 2],
434        stride: [usize; 2],
435        padding: [usize; 2],
436        dilation: [usize; 2],
437        output_padding: [usize; 2],
438        groups: usize,
439    ) -> NodeId {
440        Graph::conv_transpose2d(
441            self,
442            input,
443            weight,
444            kernel_size,
445            stride,
446            padding,
447            dilation,
448            output_padding,
449            groups,
450        )
451    }
452
453    fn rms_norm(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
454        let s = shape::unary_shape(self.shape(x));
455        self.add_node(Op::RmsNorm { axis: -1, eps }, vec![x, gamma, beta], s)
456    }
457
458    fn ada_layer_norm(
459        &mut self,
460        x: NodeId,
461        scale: NodeId,
462        shift: NodeId,
463        norm: crate::op::AdaNormKind,
464        eps: f32,
465    ) -> NodeId {
466        let s = shape::unary_shape(self.shape(x));
467        self.add_node(Op::AdaLayerNorm { norm, eps }, vec![x, scale, shift], s)
468    }
469
470    fn gated_residual(&mut self, x: NodeId, y: NodeId, gate: NodeId) -> NodeId {
471        let s = shape::unary_shape(self.shape(x));
472        self.add_node(Op::GatedResidual, vec![x, y, gate], s)
473    }
474
475    fn sum(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId {
476        reduce_axes(self, x, ReduceOp::Sum, axes, keep_dim)
477    }
478
479    fn mean(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId {
480        reduce_axes(self, x, ReduceOp::Mean, axes, keep_dim)
481    }
482
483    fn sm(&mut self, x: NodeId, axis: i32) -> NodeId {
484        let s = shape::softmax_shape(self.shape(x));
485        self.softmax(x, axis, s)
486    }
487
488    fn reshape_(&mut self, x: NodeId, new_shape: Vec<i64>) -> NodeId {
489        let s = shape::reshape_shape(self.shape(x), &new_shape).expect("reshape shape inference");
490        self.reshape(x, new_shape, s)
491    }
492
493    fn transpose_(&mut self, x: NodeId, perm: Vec<usize>) -> NodeId {
494        let s = shape::transpose_shape(self.shape(x), &perm).expect("transpose shape inference");
495        self.add_node(Op::Transpose { perm }, vec![x], s)
496    }
497
498    fn narrow_(&mut self, x: NodeId, axis: usize, start: usize, len: usize) -> NodeId {
499        let s = shape::narrow_shape(self.shape(x), axis, len).expect("narrow shape inference");
500        self.add_node(Op::Narrow { axis, start, len }, vec![x], s)
501    }
502
503    fn concat_(&mut self, inputs: Vec<NodeId>, axis: usize) -> NodeId {
504        let shapes: Vec<&Shape> = inputs.iter().map(|&id| self.shape(id)).collect();
505        let s = shape::concat_shape(&shapes, axis).expect("concat shape inference");
506        self.concat(inputs, axis, s)
507    }
508
509    fn pad_(&mut self, x: NodeId, pads: Vec<[usize; 2]>, mode: PadMode) -> NodeId {
510        let s = shape::pad_shape(self.shape(x), &pads).expect("pad shape inference");
511        self.add_node(Op::Pad { pads, mode }, vec![x], s)
512    }
513
514    fn roll_(&mut self, x: NodeId, shifts: Vec<i64>, dims: Vec<usize>) -> NodeId {
515        assert_eq!(
516            shifts.len(),
517            dims.len(),
518            "roll: shifts and dims must be the same length"
519        );
520        let s = shape::unary_shape(self.shape(x));
521        self.add_node(Op::Roll { shifts, dims }, vec![x], s)
522    }
523
524    fn slice_(&mut self, x: NodeId, axis: usize, start: usize, len: usize, step: i64) -> NodeId {
525        let s = shape::slice_shape(self.shape(x), axis, len).expect("slice shape inference");
526        self.add_node(
527            Op::Slice {
528                axis,
529                start,
530                len,
531                step,
532            },
533            vec![x],
534            s,
535        )
536    }
537
538    fn clamp_(&mut self, x: NodeId, min: f32, max: f32) -> NodeId {
539        let s = shape::unary_shape(self.shape(x));
540        self.add_node(Op::Clamp { min, max }, vec![x], s)
541    }
542
543    fn tile_(&mut self, x: NodeId, reps: Vec<usize>) -> NodeId {
544        let s = shape::tile_shape(self.shape(x), &reps).expect("tile shape inference");
545        self.add_node(Op::Tile { reps }, vec![x], s)
546    }
547
548    fn trilu_(&mut self, x: NodeId, upper: bool, diagonal: i64) -> NodeId {
549        let s = shape::unary_shape(self.shape(x));
550        self.add_node(Op::Trilu { upper, diagonal }, vec![x], s)
551    }
552
553    fn cumprod_(&mut self, x: NodeId, axis: i32, exclusive: bool) -> NodeId {
554        let s = shape::unary_shape(self.shape(x));
555        self.add_node(Op::CumProd { axis, exclusive }, vec![x], s)
556    }
557
558    fn cummax_(&mut self, x: NodeId, axis: i32, exclusive: bool) -> NodeId {
559        let s = shape::unary_shape(self.shape(x));
560        self.add_node(Op::CumMax { axis, exclusive }, vec![x], s)
561    }
562
563    fn gather_(&mut self, table: NodeId, indices: NodeId, axis: usize) -> NodeId {
564        let s = shape::gather_shape(self.shape(table), self.shape(indices), axis)
565            .expect("gather shape inference");
566        self.gather(table, indices, axis, s)
567    }
568
569    fn eq(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
570        let s = shape::compare_shape(self.shape(lhs), self.shape(rhs))
571            .expect("compare shape inference");
572        self.add_node(Op::Compare(CmpOp::Eq), vec![lhs, rhs], s)
573    }
574
575    fn lt(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
576        let s = shape::compare_shape(self.shape(lhs), self.shape(rhs))
577            .expect("compare shape inference");
578        self.add_node(Op::Compare(CmpOp::Lt), vec![lhs, rhs], s)
579    }
580
581    fn attention_(
582        &mut self,
583        q: NodeId,
584        k: NodeId,
585        v: NodeId,
586        mask: NodeId,
587        num_heads: usize,
588        head_dim: usize,
589    ) -> NodeId {
590        let s = shape::attention_shape(self.shape(q));
591        self.attention(q, k, v, mask, num_heads, head_dim, s)
592    }
593
594    fn rope(&mut self, x: NodeId, cos: NodeId, sin: NodeId, head_dim: usize) -> NodeId {
595        self.rope_n(x, cos, sin, head_dim, head_dim)
596    }
597
598    fn rope_n(
599        &mut self,
600        x: NodeId,
601        cos: NodeId,
602        sin: NodeId,
603        head_dim: usize,
604        n_rot: usize,
605    ) -> NodeId {
606        self.rope_n_styled(x, cos, sin, head_dim, n_rot, crate::op::RopeStyle::NeoX)
607    }
608
609    fn rope_styled(
610        &mut self,
611        x: NodeId,
612        cos: NodeId,
613        sin: NodeId,
614        head_dim: usize,
615        style: crate::op::RopeStyle,
616    ) -> NodeId {
617        self.rope_n_styled(x, cos, sin, head_dim, head_dim, style)
618    }
619
620    fn rope_n_styled(
621        &mut self,
622        x: NodeId,
623        cos: NodeId,
624        sin: NodeId,
625        head_dim: usize,
626        n_rot: usize,
627        style: crate::op::RopeStyle,
628    ) -> NodeId {
629        assert!(
630            n_rot <= head_dim && n_rot.is_multiple_of(2),
631            "rope_n: n_rot={n_rot} must be even and <= head_dim={head_dim}"
632        );
633        let s = shape::unary_shape(self.shape(x));
634        self.add_node(
635            Op::Rope {
636                head_dim,
637                n_rot,
638                style,
639            },
640            vec![x, cos, sin],
641            s,
642        )
643    }
644
645    fn cast(&mut self, x: NodeId, to: DType) -> NodeId {
646        let s = shape::cast_shape(self.shape(x), to);
647        self.add_node(Op::Cast { to }, vec![x], s)
648    }
649
650    fn try_constant(&mut self, value: f64, dtype: DType) -> Result<NodeId, String> {
651        if matches!(dtype, DType::F16 | DType::BF16) {
652            let f32_id = self.try_constant(value, DType::F32)?;
653            return Ok(self.cast(f32_id, dtype));
654        }
655        let data = scalar_constant_bytes(value, dtype)?;
656        Ok(self.add_node(Op::Constant { data }, vec![], Shape::scalar(dtype)))
657    }
658    fn zeros(&mut self, dims: &[usize], dtype: DType) -> NodeId {
659        let numel: usize = dims.iter().product();
660        let data = vec![0u8; numel * dtype.size_bytes()];
661        self.add_node(Op::Constant { data }, vec![], Shape::new(dims, dtype))
662    }
663    fn full(&mut self, dims: &[usize], value: f32, dtype: DType) -> NodeId {
664        let numel: usize = dims.iter().product();
665        let elem = scalar_constant_bytes(value as f64, dtype).expect("full: encode value");
666        let data: Vec<u8> = elem
667            .iter()
668            .cloned()
669            .cycle()
670            .take(numel * elem.len())
671            .collect();
672        self.add_node(Op::Constant { data }, vec![], Shape::new(dims, dtype))
673    }
674    fn batch_norm_training(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
675        let dims: Vec<usize> = self
676            .shape(x)
677            .dims()
678            .iter()
679            .map(|d| d.unwrap_static())
680            .collect();
681        assert_eq!(dims.len(), 4, "batch_norm_training expects [N,C,H,W]");
682        let c = dims[1];
683        let dt = self.shape(x).dtype();
684        // Fast primitive form: per-channel statistics over (N,H,W) directly, no
685        // transpose / GroupNorm detour. The mean over [0,2,3] is non-contiguous
686        // (keeps axis 1) and is decomposed into sequential single-axis reduces by
687        // `reduce_axes`, so it is correct on every backend. Standard batch-BN:
688        //   x̂ = (x − μ_c) / √(σ²_c + eps),  y = γ_c·x̂ + β_c.
689        let mu = self.mean(x, vec![0, 2, 3], true);
690        let xc = self.sub(x, mu);
691        let sq = self.mul(xc, xc);
692        let var = self.mean(sq, vec![0, 2, 3], true);
693        let eps_c = self.constant(eps as f64, dt);
694        let var_eps = self.add(var, eps_c);
695        let std = self.sqrt(var_eps);
696        let xhat = self.div(xc, std);
697        let gr = self.reshape_(gamma, vec![1, c as i64, 1, 1]);
698        let br = self.reshape_(beta, vec![1, c as i64, 1, 1]);
699        let scaled = self.mul(xhat, gr);
700        self.add(scaled, br)
701    }
702
703    fn constant(&mut self, value: f64, dtype: DType) -> NodeId {
704        self.try_constant(value, dtype)
705            .expect("scalar constant encoding")
706    }
707
708    fn stop_gradient(&mut self, x: NodeId) -> NodeId {
709        let s = shape::unary_shape(self.shape(x));
710        self.add_node(Op::StopGradient, vec![x], s)
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn inferred_conv2d_and_conv_transpose2d() {
720        let mut g = Graph::new("conv");
721        let f = DType::F32;
722        let x = g.input("x", Shape::new(&[1, 4, 8, 8], f));
723        let w = g.param("w", Shape::new(&[8, 2, 3, 3], f));
724        let y = g.conv2d(x, w, [3, 3], [1, 1], [1, 1], [1, 1], 2);
725        assert_eq!(g.shape(y), &Shape::new(&[1, 8, 8, 8], f));
726
727        let wt = g.param("wt", Shape::new(&[4, 8, 2, 2], f));
728        let z = g.conv_transpose2d(x, wt, [2, 2], [2, 2], [0, 0], [1, 1], [0, 0], 1);
729        assert_eq!(g.shape(z), &Shape::new(&[1, 8, 16, 16], f));
730    }
731
732    #[test]
733    fn batch_norm_training_and_full_shapes() {
734        let mut g = Graph::new("bn");
735        let f = DType::F32;
736        let x = g.input("x", Shape::new(&[2, 3, 4, 5], f));
737        let gamma = g.param("g", Shape::new(&[3], f));
738        let beta = g.param("b", Shape::new(&[3], f));
739        let y = g.batch_norm_training(x, gamma, beta, 1e-5); // composes transpose+GroupNorm+affine
740        assert_eq!(g.shape(y), &Shape::new(&[2, 3, 4, 5], f)); // shape preserved
741        let ones = g.full(&[3], 1.0, f);
742        assert_eq!(g.shape(ones), &Shape::new(&[3], f));
743        assert!(matches!(g.node(ones).op, Op::Constant { .. }));
744    }
745
746    #[test]
747    fn zeros_tensor_shape_and_no_params() {
748        let mut g = Graph::new("zeros");
749        let f = DType::F32;
750        let z = g.zeros(&[2, 3, 1, 4], f);
751        assert_eq!(g.shape(z), &Shape::new(&[2, 3, 1, 4], f));
752        // It's a constant (no trainable param), so concat-padding never trains it.
753        assert!(matches!(g.node(z).op, Op::Constant { .. }));
754    }
755
756    #[test]
757    fn inferred_layer_norm2d() {
758        let mut g = Graph::new("ln2d");
759        let f = DType::F32;
760        let x = g.input("x", Shape::new(&[1, 4, 8, 8], f));
761        let gamma = g.param("g", Shape::new(&[4], f));
762        let beta = g.param("b", Shape::new(&[4], f));
763        let y = g.layer_norm2d(x, gamma, beta, 1e-6);
764        assert_eq!(g.shape(y), &Shape::new(&[1, 4, 8, 8], f));
765    }
766
767    #[test]
768    fn inferred_matmul_bias_gelu() {
769        let mut g = Graph::new("test");
770        let x = g.input("x", Shape::new(&[4, 15, 384], DType::F32));
771        let w = g.param("w", Shape::new(&[384, 1536], DType::F32));
772        let b = g.param("b", Shape::new(&[1536], DType::F32));
773
774        // No explicit shapes needed!
775        let mm = g.mm(x, w);
776        let add = g.add(mm, b);
777        let out = g.gelu(add);
778        g.set_outputs(vec![out]);
779
780        assert_eq!(g.shape(mm), &Shape::new(&[4, 15, 1536], DType::F32));
781        assert_eq!(g.shape(add), &Shape::new(&[4, 15, 1536], DType::F32));
782        assert_eq!(g.shape(out), &Shape::new(&[4, 15, 1536], DType::F32));
783    }
784
785    #[test]
786    fn inferred_bert_ffn() {
787        let mut g = Graph::new("bert_ffn");
788        let f = DType::F32;
789        let h = 384;
790        let int = 1536;
791
792        let x = g.input("x", Shape::new(&[4, 15, h], f));
793        let int_w = g.param("int.w", Shape::new(&[h, int], f));
794        let int_b = g.param("int.b", Shape::new(&[int], f));
795        let out_w = g.param("out.w", Shape::new(&[int, h], f));
796        let out_b = g.param("out.b", Shape::new(&[h], f));
797        let gamma = g.param("g", Shape::new(&[h], f));
798        let beta = g.param("b", Shape::new(&[h], f));
799
800        let mm1 = g.mm(x, int_w);
801        let a1 = g.add(mm1, int_b);
802        let ffn = g.gelu(a1);
803        let mm2 = g.mm(ffn, out_w);
804        let out = g.add(mm2, out_b);
805        let res = g.add(out, x);
806        let normed = g.ln(res, gamma, beta, 1e-12);
807        g.set_outputs(vec![normed]);
808
809        assert_eq!(g.shape(ffn), &Shape::new(&[4, 15, int], f));
810        assert_eq!(g.shape(out), &Shape::new(&[4, 15, h], f));
811        assert_eq!(g.shape(normed), &Shape::new(&[4, 15, h], f));
812    }
813
814    #[test]
815    fn inferred_gather_reshape() {
816        let mut g = Graph::new("test");
817        let table = g.param("emb", Shape::new(&[30522, 384], DType::F32));
818        let ids = g.input("ids", Shape::new(&[4, 15], DType::I64));
819
820        let gathered = g.gather_(table, ids, 0);
821        assert_eq!(g.shape(gathered), &Shape::new(&[4, 15, 384], DType::F32));
822
823        let reshaped = g.reshape_(gathered, vec![60, 384]);
824        assert_eq!(g.shape(reshaped), &Shape::new(&[60, 384], DType::F32));
825
826        let transposed = g.transpose_(reshaped, vec![1, 0]);
827        assert_eq!(g.shape(transposed), &Shape::new(&[384, 60], DType::F32));
828    }
829
830    #[test]
831    fn inferred_constant_broadcasts() {
832        let mut g = Graph::new("const");
833        let x = g.input("x", Shape::new(&[2, 3], DType::F32));
834        let c = g.constant(2.0, DType::F32);
835        assert_eq!(g.shape(c), &Shape::scalar(DType::F32));
836        let y = g.mul(x, c);
837        assert_eq!(g.shape(y), &Shape::new(&[2, 3], DType::F32));
838    }
839
840    #[test]
841    fn inferred_constant_f16_via_cast() {
842        let mut g = Graph::new("const_f16");
843        let c = g.constant(1.5, DType::F16);
844        assert_eq!(g.shape(c), &Shape::scalar(DType::F16));
845        let x = g.input("x", Shape::new(&[2], DType::F16));
846        let y = g.add(x, c);
847        assert_eq!(g.shape(y), &Shape::new(&[2], DType::F16));
848    }
849
850    #[test]
851    fn inferred_constant_arithmetic_chain() {
852        let mut g = Graph::new("const_chain");
853        let x = g.input("x", Shape::new(&[4], DType::F32));
854        let one = g.constant(1.0, DType::F32);
855        let two = g.constant(2.0, DType::F32);
856        let sum = g.add(x, one);
857        let y = g.div(sum, two);
858        assert_eq!(g.shape(y), &Shape::new(&[4], DType::F32));
859        g.set_outputs(vec![y]);
860    }
861
862    #[test]
863    fn try_constant_rejects_out_of_range() {
864        let mut g = Graph::new("try_const");
865        let err = g.try_constant(128.0, DType::I8).unwrap_err();
866        assert!(err.contains("out of range"));
867    }
868
869    #[test]
870    fn try_constant_f16_via_cast() {
871        let mut g = Graph::new("try_const_f16");
872        let c = g.try_constant(1.5, DType::F16).unwrap();
873        assert_eq!(g.shape(c), &Shape::scalar(DType::F16));
874    }
875
876    #[test]
877    fn inferred_reduce_softmax() {
878        let mut g = Graph::new("test");
879        let x = g.input("x", Shape::new(&[4, 15, 384], DType::F32));
880
881        let s = g.sm(x, -1);
882        assert_eq!(g.shape(s), &Shape::new(&[4, 15, 384], DType::F32));
883
884        let m = g.mean(x, vec![2], false);
885        assert_eq!(g.shape(m), &Shape::new(&[4, 15], DType::F32));
886
887        let mk = g.mean(x, vec![2], true);
888        assert_eq!(g.shape(mk), &Shape::new(&[4, 15, 1], DType::F32));
889    }
890}