Skip to main content

rlx_ir/
infer.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Shape-inferred graph builder — ergonomic API that auto-computes output shapes.
17//!
18//! Import [`GraphExt`] and call short-name methods instead of providing explicit shapes:
19//! ```rust
20//! use rlx_ir::*;
21//! use rlx_ir::infer::GraphExt;
22//!
23//! let mut g = Graph::new("example");
24//! let x = g.input("x", Shape::new(&[4, 384], DType::F32));
25//! let w = g.param("w", Shape::new(&[384, 1536], DType::F32));
26//! let b = g.param("b", Shape::new(&[1536], DType::F32));
27//! let mm = g.mm(x, w);
28//! let add = g.add(mm, b);
29//! let out = g.gelu(add);
30//! let two = g.constant(2.0, DType::F32);
31//! let scaled = g.mul(x, two);
32//! let c = g.try_constant(2.0, DType::F32).unwrap(); // fallible variant
33//! g.set_outputs(vec![out, scaled, c]);
34//! ```
35
36use crate::dtype::scalar_constant_bytes;
37use crate::op::*;
38use crate::shape;
39use crate::{DType, Graph, NodeId, Op, Shape};
40
41/// Extension trait for shape-inferred graph building.
42pub trait GraphExt {
43    // ── Linear algebra ──────────────────────────────────────
44    fn mm(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
45
46    // ── Binary ──────────────────────────────────────────────
47    fn add(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
48    fn sub(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
49    fn mul(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
50    fn div(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
51
52    // ── Activation ──────────────────────────────────────────
53    fn gelu(&mut self, x: NodeId) -> NodeId;
54    /// Tanh-approximation GELU (PyTorch's default `gelu` formula,
55    /// also candle's `Tensor::gelu`). Use this when porting models
56    /// whose reference implementations use the tanh form for
57    /// numerical parity (e.g. DINOv2, many ViTs).
58    fn gelu_approx(&mut self, x: NodeId) -> NodeId;
59    fn silu(&mut self, x: NodeId) -> NodeId;
60    fn relu(&mut self, x: NodeId) -> NodeId;
61    fn exp(&mut self, x: NodeId) -> NodeId;
62    fn sqrt(&mut self, x: NodeId) -> NodeId;
63    fn neg(&mut self, x: NodeId) -> NodeId;
64    fn tanh(&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    // ── Reduction ───────────────────────────────────────────
121    fn sum(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId;
122    fn mean(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId;
123    fn sm(&mut self, x: NodeId, axis: i32) -> NodeId;
124
125    // ── Shape manipulation ──────────────────────────────────
126    fn reshape_(&mut self, x: NodeId, new_shape: Vec<i64>) -> NodeId;
127    fn transpose_(&mut self, x: NodeId, perm: Vec<usize>) -> NodeId;
128    fn narrow_(&mut self, x: NodeId, axis: usize, start: usize, len: usize) -> NodeId;
129    fn concat_(&mut self, inputs: Vec<NodeId>, axis: usize) -> NodeId;
130    fn gather_(&mut self, table: NodeId, indices: NodeId, axis: usize) -> NodeId;
131
132    // ── Comparison ──────────────────────────────────────────
133    fn eq(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
134    fn lt(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId;
135
136    // ── Attention ───────────────────────────────────────────
137    fn attention_(
138        &mut self,
139        q: NodeId,
140        k: NodeId,
141        v: NodeId,
142        mask: NodeId,
143        num_heads: usize,
144        head_dim: usize,
145    ) -> NodeId;
146
147    // ── RoPE ────────────────────────────────────────────────
148    fn rope(&mut self, x: NodeId, cos: NodeId, sin: NodeId, head_dim: usize) -> NodeId;
149    /// Partial RoPE: rotate the first `n_rot` dims (NeoX offset `n_rot/2`).
150    fn rope_n(
151        &mut self,
152        x: NodeId,
153        cos: NodeId,
154        sin: NodeId,
155        head_dim: usize,
156        n_rot: usize,
157    ) -> NodeId;
158    /// RoPE with an explicit pairing flavor ([`crate::op::RopeStyle`]).
159    fn rope_styled(
160        &mut self,
161        x: NodeId,
162        cos: NodeId,
163        sin: NodeId,
164        head_dim: usize,
165        style: crate::op::RopeStyle,
166    ) -> NodeId;
167    /// Partial RoPE with an explicit pairing flavor.
168    fn rope_n_styled(
169        &mut self,
170        x: NodeId,
171        cos: NodeId,
172        sin: NodeId,
173        head_dim: usize,
174        n_rot: usize,
175        style: crate::op::RopeStyle,
176    ) -> NodeId;
177
178    // ── Cast ────────────────────────────────────────────────
179    fn cast(&mut self, x: NodeId, to: DType) -> NodeId;
180
181    // ── Literals ────────────────────────────────────────────
182    /// Rank-0 broadcastable scalar (`Op::Constant`). `f16` / `bf16`
183    /// are lowered as `f32` constant + `cast`.
184    fn constant(&mut self, value: f64, dtype: DType) -> NodeId;
185
186    /// Fallible variant of [`GraphExt::constant`]. Returns an error when
187    /// `value` is out of range for `dtype` or when `dtype` cannot be encoded
188    /// directly (callers may lower `f16` / `bf16` via `try_constant` on
189    /// `F32` plus `cast`).
190    fn try_constant(&mut self, value: f64, dtype: DType) -> Result<NodeId, String>;
191
192    /// A constant **zeros tensor** of `dims` (an `Op::Constant`, so autodiff
193    /// gives it no gradient). Use this for zero-padding via `concat_` — e.g.
194    /// causal / "same" padding — instead of declaring a trainable zero `param`,
195    /// which pollutes the trained weights and (being batch-sized) mismatches the
196    /// node shape at a different inference batch. The shape is concrete, so the
197    /// tensor is rebuilt at the right size whenever the graph is rebuilt.
198    fn zeros(&mut self, dims: &[usize], dtype: DType) -> NodeId;
199
200    /// A constant tensor of `dims` filled with `value` (like [`GraphExt::zeros`]
201    /// but any scalar). An `Op::Constant`, so no gradient.
202    fn full(&mut self, dims: &[usize], value: f32, dtype: DType) -> NodeId;
203
204    /// Trainable BatchNorm with **batch statistics** (the training-mode BN that
205    /// rlx otherwise lacks — only `BatchNormInference` with frozen stats exists).
206    /// `x` is `[N, C, H, W]`; `gamma`/`beta` are `[C]`. The batch-coupled backward
207    /// is the tricky part autodiff can't compose — here it's obtained for FREE by
208    /// composing existing ops: transpose C↔N so `GroupNorm(num_groups=1)` (which
209    /// has a hand-written, batch-safe backward) normalises each channel over
210    /// `(N, H, W)` with an identity affine, then transpose back and apply the real
211    /// per-channel `gamma`/`beta` (a plain affine autodiff handles). Numerically
212    /// equals PyTorch `nn.BatchNorm2d` in `train()` mode (batch mean/var, `eps`).
213    fn batch_norm_training(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId;
214
215    // ── Stop gradient ───────────────────────────────────────
216    /// Identity forward, zero backward. The reverse-mode autodiff rule
217    /// for `Op::StopGradient` returns no gradient contribution to the
218    /// input. Equivalent to PyTorch's `tensor.detach()` /
219    /// `jax.lax.stop_gradient` / TF's `tf.stop_gradient`.
220    fn stop_gradient(&mut self, x: NodeId) -> NodeId;
221}
222
223/// Reduce over `axes`, decomposing a NON-CONTIGUOUS multi-axis reduce into
224/// sequential single-axis reduces. The CPU `Reduce` thunk only supports a
225/// contiguous axis range; a reduce like `mean(x, [0,2,3])` (keeps axis 1)
226/// silently returns all zeros. Since sum is associative and mean composes
227/// (÷W · ÷H · ÷N = ÷NHW), reducing one axis at a time — descending, with
228/// keep_dim=true so the remaining axis indices stay valid — is numerically
229/// identical and correct on every backend. Contiguous / single-axis reduces
230/// keep the direct fast path.
231fn reduce_axes(g: &mut Graph, x: NodeId, op: ReduceOp, axes: Vec<usize>, keep_dim: bool) -> NodeId {
232    let mut sorted = axes.clone();
233    sorted.sort_unstable();
234    let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1);
235    if axes.len() <= 1 || contiguous {
236        let s = shape::reduce_shape(g.shape(x), &axes, keep_dim).expect("reduce shape inference");
237        return g.reduce(x, op, axes, keep_dim, s);
238    }
239    // Non-contiguous: fold one axis at a time, highest first, keeping dims so
240    // the lower axis indices don't shift underneath us.
241    let mut cur = x;
242    for &ax in sorted.iter().rev() {
243        let s = shape::reduce_shape(g.shape(cur), &[ax], true).expect("reduce shape inference");
244        cur = g.reduce(cur, op, vec![ax], true, s);
245    }
246    if keep_dim {
247        cur
248    } else {
249        let s = shape::reduce_shape(g.shape(x), &axes, false).expect("reduce shape inference");
250        let dims: Vec<i64> = s.dims().iter().map(|d| d.unwrap_static() as i64).collect();
251        g.reshape(cur, dims, s)
252    }
253}
254
255impl GraphExt for Graph {
256    fn mm(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
257        let s =
258            shape::matmul_shape(self.shape(lhs), self.shape(rhs)).expect("matmul shape inference");
259        self.matmul(lhs, rhs, s)
260    }
261
262    fn add(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
263        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("add shape inference");
264        self.binary(BinaryOp::Add, lhs, rhs, s)
265    }
266
267    fn sub(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
268        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("sub shape inference");
269        self.binary(BinaryOp::Sub, lhs, rhs, s)
270    }
271
272    fn mul(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
273        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("mul shape inference");
274        self.binary(BinaryOp::Mul, lhs, rhs, s)
275    }
276
277    fn div(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
278        let s = shape::binary_shape(self.shape(lhs), self.shape(rhs)).expect("div shape inference");
279        self.binary(BinaryOp::Div, lhs, rhs, s)
280    }
281
282    fn gelu(&mut self, x: NodeId) -> NodeId {
283        let s = shape::unary_shape(self.shape(x));
284        self.activation(Activation::Gelu, x, s)
285    }
286
287    fn gelu_approx(&mut self, x: NodeId) -> NodeId {
288        let s = shape::unary_shape(self.shape(x));
289        self.activation(Activation::GeluApprox, x, s)
290    }
291
292    fn silu(&mut self, x: NodeId) -> NodeId {
293        let s = shape::unary_shape(self.shape(x));
294        self.activation(Activation::Silu, x, s)
295    }
296
297    fn relu(&mut self, x: NodeId) -> NodeId {
298        let s = shape::unary_shape(self.shape(x));
299        self.activation(Activation::Relu, x, s)
300    }
301
302    fn exp(&mut self, x: NodeId) -> NodeId {
303        let s = shape::unary_shape(self.shape(x));
304        self.activation(Activation::Exp, x, s)
305    }
306
307    fn sqrt(&mut self, x: NodeId) -> NodeId {
308        let s = shape::unary_shape(self.shape(x));
309        self.activation(Activation::Sqrt, x, s)
310    }
311
312    fn neg(&mut self, x: NodeId) -> NodeId {
313        let s = shape::unary_shape(self.shape(x));
314        self.activation(Activation::Neg, x, s)
315    }
316
317    fn tanh(&mut self, x: NodeId) -> NodeId {
318        let s = shape::unary_shape(self.shape(x));
319        self.activation(Activation::Tanh, x, s)
320    }
321
322    fn ln(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
323        let s = shape::unary_shape(self.shape(x));
324        self.layer_norm(x, gamma, beta, -1, eps, s)
325    }
326
327    fn layer_norm2d(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
328        Graph::layer_norm2d(self, x, gamma, beta, eps)
329    }
330
331    fn group_norm(
332        &mut self,
333        x: NodeId,
334        gamma: NodeId,
335        beta: NodeId,
336        num_groups: usize,
337        eps: f32,
338    ) -> NodeId {
339        Graph::group_norm(self, x, gamma, beta, num_groups, eps)
340    }
341
342    fn conv2d(
343        &mut self,
344        input: NodeId,
345        weight: NodeId,
346        kernel_size: [usize; 2],
347        stride: [usize; 2],
348        padding: [usize; 2],
349        dilation: [usize; 2],
350        groups: usize,
351    ) -> NodeId {
352        Graph::conv2d(
353            self,
354            input,
355            weight,
356            kernel_size,
357            stride,
358            padding,
359            dilation,
360            groups,
361        )
362    }
363
364    fn conv_transpose2d(
365        &mut self,
366        input: NodeId,
367        weight: NodeId,
368        kernel_size: [usize; 2],
369        stride: [usize; 2],
370        padding: [usize; 2],
371        dilation: [usize; 2],
372        output_padding: [usize; 2],
373        groups: usize,
374    ) -> NodeId {
375        Graph::conv_transpose2d(
376            self,
377            input,
378            weight,
379            kernel_size,
380            stride,
381            padding,
382            dilation,
383            output_padding,
384            groups,
385        )
386    }
387
388    fn rms_norm(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
389        let s = shape::unary_shape(self.shape(x));
390        self.add_node(Op::RmsNorm { axis: -1, eps }, vec![x, gamma, beta], s)
391    }
392
393    fn ada_layer_norm(
394        &mut self,
395        x: NodeId,
396        scale: NodeId,
397        shift: NodeId,
398        norm: crate::op::AdaNormKind,
399        eps: f32,
400    ) -> NodeId {
401        let s = shape::unary_shape(self.shape(x));
402        self.add_node(Op::AdaLayerNorm { norm, eps }, vec![x, scale, shift], s)
403    }
404
405    fn gated_residual(&mut self, x: NodeId, y: NodeId, gate: NodeId) -> NodeId {
406        let s = shape::unary_shape(self.shape(x));
407        self.add_node(Op::GatedResidual, vec![x, y, gate], s)
408    }
409
410    fn sum(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId {
411        reduce_axes(self, x, ReduceOp::Sum, axes, keep_dim)
412    }
413
414    fn mean(&mut self, x: NodeId, axes: Vec<usize>, keep_dim: bool) -> NodeId {
415        reduce_axes(self, x, ReduceOp::Mean, axes, keep_dim)
416    }
417
418    fn sm(&mut self, x: NodeId, axis: i32) -> NodeId {
419        let s = shape::softmax_shape(self.shape(x));
420        self.softmax(x, axis, s)
421    }
422
423    fn reshape_(&mut self, x: NodeId, new_shape: Vec<i64>) -> NodeId {
424        let s = shape::reshape_shape(self.shape(x), &new_shape).expect("reshape shape inference");
425        self.reshape(x, new_shape, s)
426    }
427
428    fn transpose_(&mut self, x: NodeId, perm: Vec<usize>) -> NodeId {
429        let s = shape::transpose_shape(self.shape(x), &perm).expect("transpose shape inference");
430        self.add_node(Op::Transpose { perm }, vec![x], s)
431    }
432
433    fn narrow_(&mut self, x: NodeId, axis: usize, start: usize, len: usize) -> NodeId {
434        let s = shape::narrow_shape(self.shape(x), axis, len).expect("narrow shape inference");
435        self.add_node(Op::Narrow { axis, start, len }, vec![x], s)
436    }
437
438    fn concat_(&mut self, inputs: Vec<NodeId>, axis: usize) -> NodeId {
439        let shapes: Vec<&Shape> = inputs.iter().map(|&id| self.shape(id)).collect();
440        let s = shape::concat_shape(&shapes, axis).expect("concat shape inference");
441        self.concat(inputs, axis, s)
442    }
443
444    fn gather_(&mut self, table: NodeId, indices: NodeId, axis: usize) -> NodeId {
445        let s = shape::gather_shape(self.shape(table), self.shape(indices), axis)
446            .expect("gather shape inference");
447        self.gather(table, indices, axis, s)
448    }
449
450    fn eq(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
451        let s = shape::compare_shape(self.shape(lhs), self.shape(rhs))
452            .expect("compare shape inference");
453        self.add_node(Op::Compare(CmpOp::Eq), vec![lhs, rhs], s)
454    }
455
456    fn lt(&mut self, lhs: NodeId, rhs: NodeId) -> NodeId {
457        let s = shape::compare_shape(self.shape(lhs), self.shape(rhs))
458            .expect("compare shape inference");
459        self.add_node(Op::Compare(CmpOp::Lt), vec![lhs, rhs], s)
460    }
461
462    fn attention_(
463        &mut self,
464        q: NodeId,
465        k: NodeId,
466        v: NodeId,
467        mask: NodeId,
468        num_heads: usize,
469        head_dim: usize,
470    ) -> NodeId {
471        let s = shape::attention_shape(self.shape(q));
472        self.attention(q, k, v, mask, num_heads, head_dim, s)
473    }
474
475    fn rope(&mut self, x: NodeId, cos: NodeId, sin: NodeId, head_dim: usize) -> NodeId {
476        self.rope_n(x, cos, sin, head_dim, head_dim)
477    }
478
479    fn rope_n(
480        &mut self,
481        x: NodeId,
482        cos: NodeId,
483        sin: NodeId,
484        head_dim: usize,
485        n_rot: usize,
486    ) -> NodeId {
487        self.rope_n_styled(x, cos, sin, head_dim, n_rot, crate::op::RopeStyle::NeoX)
488    }
489
490    fn rope_styled(
491        &mut self,
492        x: NodeId,
493        cos: NodeId,
494        sin: NodeId,
495        head_dim: usize,
496        style: crate::op::RopeStyle,
497    ) -> NodeId {
498        self.rope_n_styled(x, cos, sin, head_dim, head_dim, style)
499    }
500
501    fn rope_n_styled(
502        &mut self,
503        x: NodeId,
504        cos: NodeId,
505        sin: NodeId,
506        head_dim: usize,
507        n_rot: usize,
508        style: crate::op::RopeStyle,
509    ) -> NodeId {
510        assert!(
511            n_rot <= head_dim && n_rot.is_multiple_of(2),
512            "rope_n: n_rot={n_rot} must be even and <= head_dim={head_dim}"
513        );
514        let s = shape::unary_shape(self.shape(x));
515        self.add_node(
516            Op::Rope {
517                head_dim,
518                n_rot,
519                style,
520            },
521            vec![x, cos, sin],
522            s,
523        )
524    }
525
526    fn cast(&mut self, x: NodeId, to: DType) -> NodeId {
527        let s = shape::cast_shape(self.shape(x), to);
528        self.add_node(Op::Cast { to }, vec![x], s)
529    }
530
531    fn try_constant(&mut self, value: f64, dtype: DType) -> Result<NodeId, String> {
532        if matches!(dtype, DType::F16 | DType::BF16) {
533            let f32_id = self.try_constant(value, DType::F32)?;
534            return Ok(self.cast(f32_id, dtype));
535        }
536        let data = scalar_constant_bytes(value, dtype)?;
537        Ok(self.add_node(Op::Constant { data }, vec![], Shape::scalar(dtype)))
538    }
539    fn zeros(&mut self, dims: &[usize], dtype: DType) -> NodeId {
540        let numel: usize = dims.iter().product();
541        let data = vec![0u8; numel * dtype.size_bytes()];
542        self.add_node(Op::Constant { data }, vec![], Shape::new(dims, dtype))
543    }
544    fn full(&mut self, dims: &[usize], value: f32, dtype: DType) -> NodeId {
545        let numel: usize = dims.iter().product();
546        let elem = scalar_constant_bytes(value as f64, dtype).expect("full: encode value");
547        let data: Vec<u8> = elem
548            .iter()
549            .cloned()
550            .cycle()
551            .take(numel * elem.len())
552            .collect();
553        self.add_node(Op::Constant { data }, vec![], Shape::new(dims, dtype))
554    }
555    fn batch_norm_training(&mut self, x: NodeId, gamma: NodeId, beta: NodeId, eps: f32) -> NodeId {
556        let dims: Vec<usize> = self
557            .shape(x)
558            .dims()
559            .iter()
560            .map(|d| d.unwrap_static())
561            .collect();
562        assert_eq!(dims.len(), 4, "batch_norm_training expects [N,C,H,W]");
563        let c = dims[1];
564        let dt = self.shape(x).dtype();
565        // Fast primitive form: per-channel statistics over (N,H,W) directly, no
566        // transpose / GroupNorm detour. The mean over [0,2,3] is non-contiguous
567        // (keeps axis 1) and is decomposed into sequential single-axis reduces by
568        // `reduce_axes`, so it is correct on every backend. Standard batch-BN:
569        //   x̂ = (x − μ_c) / √(σ²_c + eps),  y = γ_c·x̂ + β_c.
570        let mu = self.mean(x, vec![0, 2, 3], true);
571        let xc = self.sub(x, mu);
572        let sq = self.mul(xc, xc);
573        let var = self.mean(sq, vec![0, 2, 3], true);
574        let eps_c = self.constant(eps as f64, dt);
575        let var_eps = self.add(var, eps_c);
576        let std = self.sqrt(var_eps);
577        let xhat = self.div(xc, std);
578        let gr = self.reshape_(gamma, vec![1, c as i64, 1, 1]);
579        let br = self.reshape_(beta, vec![1, c as i64, 1, 1]);
580        let scaled = self.mul(xhat, gr);
581        self.add(scaled, br)
582    }
583
584    fn constant(&mut self, value: f64, dtype: DType) -> NodeId {
585        self.try_constant(value, dtype)
586            .expect("scalar constant encoding")
587    }
588
589    fn stop_gradient(&mut self, x: NodeId) -> NodeId {
590        let s = shape::unary_shape(self.shape(x));
591        self.add_node(Op::StopGradient, vec![x], s)
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn inferred_conv2d_and_conv_transpose2d() {
601        let mut g = Graph::new("conv");
602        let f = DType::F32;
603        let x = g.input("x", Shape::new(&[1, 4, 8, 8], f));
604        let w = g.param("w", Shape::new(&[8, 2, 3, 3], f));
605        let y = g.conv2d(x, w, [3, 3], [1, 1], [1, 1], [1, 1], 2);
606        assert_eq!(g.shape(y), &Shape::new(&[1, 8, 8, 8], f));
607
608        let wt = g.param("wt", Shape::new(&[4, 8, 2, 2], f));
609        let z = g.conv_transpose2d(x, wt, [2, 2], [2, 2], [0, 0], [1, 1], [0, 0], 1);
610        assert_eq!(g.shape(z), &Shape::new(&[1, 8, 16, 16], f));
611    }
612
613    #[test]
614    fn batch_norm_training_and_full_shapes() {
615        let mut g = Graph::new("bn");
616        let f = DType::F32;
617        let x = g.input("x", Shape::new(&[2, 3, 4, 5], f));
618        let gamma = g.param("g", Shape::new(&[3], f));
619        let beta = g.param("b", Shape::new(&[3], f));
620        let y = g.batch_norm_training(x, gamma, beta, 1e-5); // composes transpose+GroupNorm+affine
621        assert_eq!(g.shape(y), &Shape::new(&[2, 3, 4, 5], f)); // shape preserved
622        let ones = g.full(&[3], 1.0, f);
623        assert_eq!(g.shape(ones), &Shape::new(&[3], f));
624        assert!(matches!(g.node(ones).op, Op::Constant { .. }));
625    }
626
627    #[test]
628    fn zeros_tensor_shape_and_no_params() {
629        let mut g = Graph::new("zeros");
630        let f = DType::F32;
631        let z = g.zeros(&[2, 3, 1, 4], f);
632        assert_eq!(g.shape(z), &Shape::new(&[2, 3, 1, 4], f));
633        // It's a constant (no trainable param), so concat-padding never trains it.
634        assert!(matches!(g.node(z).op, Op::Constant { .. }));
635    }
636
637    #[test]
638    fn inferred_layer_norm2d() {
639        let mut g = Graph::new("ln2d");
640        let f = DType::F32;
641        let x = g.input("x", Shape::new(&[1, 4, 8, 8], f));
642        let gamma = g.param("g", Shape::new(&[4], f));
643        let beta = g.param("b", Shape::new(&[4], f));
644        let y = g.layer_norm2d(x, gamma, beta, 1e-6);
645        assert_eq!(g.shape(y), &Shape::new(&[1, 4, 8, 8], f));
646    }
647
648    #[test]
649    fn inferred_matmul_bias_gelu() {
650        let mut g = Graph::new("test");
651        let x = g.input("x", Shape::new(&[4, 15, 384], DType::F32));
652        let w = g.param("w", Shape::new(&[384, 1536], DType::F32));
653        let b = g.param("b", Shape::new(&[1536], DType::F32));
654
655        // No explicit shapes needed!
656        let mm = g.mm(x, w);
657        let add = g.add(mm, b);
658        let out = g.gelu(add);
659        g.set_outputs(vec![out]);
660
661        assert_eq!(g.shape(mm), &Shape::new(&[4, 15, 1536], DType::F32));
662        assert_eq!(g.shape(add), &Shape::new(&[4, 15, 1536], DType::F32));
663        assert_eq!(g.shape(out), &Shape::new(&[4, 15, 1536], DType::F32));
664    }
665
666    #[test]
667    fn inferred_bert_ffn() {
668        let mut g = Graph::new("bert_ffn");
669        let f = DType::F32;
670        let h = 384;
671        let int = 1536;
672
673        let x = g.input("x", Shape::new(&[4, 15, h], f));
674        let int_w = g.param("int.w", Shape::new(&[h, int], f));
675        let int_b = g.param("int.b", Shape::new(&[int], f));
676        let out_w = g.param("out.w", Shape::new(&[int, h], f));
677        let out_b = g.param("out.b", Shape::new(&[h], f));
678        let gamma = g.param("g", Shape::new(&[h], f));
679        let beta = g.param("b", Shape::new(&[h], f));
680
681        let mm1 = g.mm(x, int_w);
682        let a1 = g.add(mm1, int_b);
683        let ffn = g.gelu(a1);
684        let mm2 = g.mm(ffn, out_w);
685        let out = g.add(mm2, out_b);
686        let res = g.add(out, x);
687        let normed = g.ln(res, gamma, beta, 1e-12);
688        g.set_outputs(vec![normed]);
689
690        assert_eq!(g.shape(ffn), &Shape::new(&[4, 15, int], f));
691        assert_eq!(g.shape(out), &Shape::new(&[4, 15, h], f));
692        assert_eq!(g.shape(normed), &Shape::new(&[4, 15, h], f));
693    }
694
695    #[test]
696    fn inferred_gather_reshape() {
697        let mut g = Graph::new("test");
698        let table = g.param("emb", Shape::new(&[30522, 384], DType::F32));
699        let ids = g.input("ids", Shape::new(&[4, 15], DType::I64));
700
701        let gathered = g.gather_(table, ids, 0);
702        assert_eq!(g.shape(gathered), &Shape::new(&[4, 15, 384], DType::F32));
703
704        let reshaped = g.reshape_(gathered, vec![60, 384]);
705        assert_eq!(g.shape(reshaped), &Shape::new(&[60, 384], DType::F32));
706
707        let transposed = g.transpose_(reshaped, vec![1, 0]);
708        assert_eq!(g.shape(transposed), &Shape::new(&[384, 60], DType::F32));
709    }
710
711    #[test]
712    fn inferred_constant_broadcasts() {
713        let mut g = Graph::new("const");
714        let x = g.input("x", Shape::new(&[2, 3], DType::F32));
715        let c = g.constant(2.0, DType::F32);
716        assert_eq!(g.shape(c), &Shape::scalar(DType::F32));
717        let y = g.mul(x, c);
718        assert_eq!(g.shape(y), &Shape::new(&[2, 3], DType::F32));
719    }
720
721    #[test]
722    fn inferred_constant_f16_via_cast() {
723        let mut g = Graph::new("const_f16");
724        let c = g.constant(1.5, DType::F16);
725        assert_eq!(g.shape(c), &Shape::scalar(DType::F16));
726        let x = g.input("x", Shape::new(&[2], DType::F16));
727        let y = g.add(x, c);
728        assert_eq!(g.shape(y), &Shape::new(&[2], DType::F16));
729    }
730
731    #[test]
732    fn inferred_constant_arithmetic_chain() {
733        let mut g = Graph::new("const_chain");
734        let x = g.input("x", Shape::new(&[4], DType::F32));
735        let one = g.constant(1.0, DType::F32);
736        let two = g.constant(2.0, DType::F32);
737        let sum = g.add(x, one);
738        let y = g.div(sum, two);
739        assert_eq!(g.shape(y), &Shape::new(&[4], DType::F32));
740        g.set_outputs(vec![y]);
741    }
742
743    #[test]
744    fn try_constant_rejects_out_of_range() {
745        let mut g = Graph::new("try_const");
746        let err = g.try_constant(128.0, DType::I8).unwrap_err();
747        assert!(err.contains("out of range"));
748    }
749
750    #[test]
751    fn try_constant_f16_via_cast() {
752        let mut g = Graph::new("try_const_f16");
753        let c = g.try_constant(1.5, DType::F16).unwrap();
754        assert_eq!(g.shape(c), &Shape::scalar(DType::F16));
755    }
756
757    #[test]
758    fn inferred_reduce_softmax() {
759        let mut g = Graph::new("test");
760        let x = g.input("x", Shape::new(&[4, 15, 384], DType::F32));
761
762        let s = g.sm(x, -1);
763        assert_eq!(g.shape(s), &Shape::new(&[4, 15, 384], DType::F32));
764
765        let m = g.mean(x, vec![2], false);
766        assert_eq!(g.shape(m), &Shape::new(&[4, 15], DType::F32));
767
768        let mk = g.mean(x, vec![2], true);
769        assert_eq!(g.shape(mk), &Shape::new(&[4, 15, 1], DType::F32));
770    }
771}