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