Skip to main content

rlx_ir/ops/
linalg.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//! Linear-algebra builders: matmul, LoRA, dequant, fused
17//! matmul+bias+activation (plan #53).
18
19use crate::op::Activation;
20use crate::quant::{QuantScheme, ScaleLayout, ScaledFormat};
21use crate::{DType, Graph, NodeId, Op, Shape};
22
23impl Graph {
24    /// Matrix multiply.
25    pub fn matmul(&mut self, lhs: NodeId, rhs: NodeId, out_shape: Shape) -> NodeId {
26        self.push(Op::MatMul, vec![lhs, rhs], out_shape, None)
27    }
28
29    /// Dynamically quantize `x` (logical `[rows, cols]`, blocks along the last
30    /// axis) to low-precision `fmt` codes plus a scale tensor, per `layout`.
31    /// Returns `(codes, scale)`: `codes` is `DType::U8` with `x`'s shape;
32    /// `scale`'s shape/dtype follow the layout (`[1]` f32 for per-tensor,
33    /// `[rows, cols/block]` u8 for block layouts). The building block of
34    /// [`scaled_matmul`](Self::scaled_matmul); `fmt` may be any
35    /// [`ScaledFormat`], including a parameterized [`ScaledFormat::Custom`].
36    pub fn scaled_quantize(
37        &mut self,
38        x: NodeId,
39        fmt: ScaledFormat,
40        layout: ScaleLayout,
41    ) -> (NodeId, NodeId) {
42        let xs = self.node(x).shape.clone();
43        let cols = xs.dim(xs.rank() - 1).unwrap_static();
44        let rows = xs.num_elements().unwrap() / cols.max(1);
45        let scale_shape = match layout {
46            ScaleLayout::PerTensor => Shape::new(&[1], layout.scale_dtype()),
47            _ => Shape::new(
48                &[rows, cols.div_ceil(layout.block() as usize)],
49                layout.scale_dtype(),
50            ),
51        };
52        let scale = self.push(
53            Op::ScaledQuantScale {
54                format: fmt,
55                scale_layout: layout,
56            },
57            vec![x],
58            scale_shape,
59            None,
60        );
61        let codes = self.push(
62            Op::ScaledQuantize {
63                format: fmt,
64                scale_layout: layout,
65            },
66            vec![x, scale],
67            xs.with_dtype(DType::U8),
68            None,
69        );
70        (codes, scale)
71    }
72
73    /// Reconstruct f32 from packed `codes` + `scale` — the inverse of
74    /// [`scaled_quantize`](Self::scaled_quantize).
75    pub fn scaled_dequantize(
76        &mut self,
77        codes: NodeId,
78        scale: NodeId,
79        fmt: ScaledFormat,
80        layout: ScaleLayout,
81    ) -> NodeId {
82        let shape = self.node(codes).shape.clone().with_dtype(DType::F32);
83        self.push(
84            Op::ScaledDequantize {
85                format: fmt,
86                scale_layout: layout,
87            },
88            vec![codes, scale],
89            shape,
90            None,
91        )
92    }
93
94    /// Native low-precision GEMM (TN: `lhs [m,k] · rhs [n,k]ᵀ → [m,n]` f32).
95    /// Both operands are dynamically quantized to `fmt`/`layout` and fed
96    /// straight into the scaled matmul with f32 accumulation — no hand-wiring of
97    /// [`Op::ScaledQuantScale`]/[`Op::ScaledQuantize`]. `rhs` must already be
98    /// K-last (`[n, k]`); transpose a `[k, n]` weight first. `fmt` may be any
99    /// [`ScaledFormat`], including a parameterized [`ScaledFormat::Custom`]
100    /// (e.g. `ScaledFormat::custom(3, 0)` for `f4e3m0`).
101    pub fn scaled_matmul(
102        &mut self,
103        lhs: NodeId,
104        rhs: NodeId,
105        fmt: ScaledFormat,
106        layout: ScaleLayout,
107    ) -> NodeId {
108        self.scaled_matmul_bias(lhs, rhs, None, fmt, layout)
109    }
110
111    /// [`scaled_matmul`](Self::scaled_matmul) with an optional f32 bias `[n]`
112    /// added to each output row.
113    pub fn scaled_matmul_bias(
114        &mut self,
115        lhs: NodeId,
116        rhs: NodeId,
117        bias: Option<NodeId>,
118        fmt: ScaledFormat,
119        layout: ScaleLayout,
120    ) -> NodeId {
121        let m = self.node(lhs).shape.dim(0).unwrap_static();
122        let n = self.node(rhs).shape.dim(0).unwrap_static();
123        let (lq, ls) = self.scaled_quantize(lhs, fmt, layout);
124        let (rq, rs) = self.scaled_quantize(rhs, fmt, layout);
125        let mut inputs = vec![lq, rq, ls, rs];
126        if let Some(b) = bias {
127            inputs.push(b);
128        }
129        self.push(
130            Op::ScaledMatMul {
131                lhs_format: fmt,
132                rhs_format: fmt,
133                scale_layout: layout,
134                has_bias: bias.is_some(),
135            },
136            inputs,
137            Shape::new(&[m, n], DType::F32),
138            None,
139        )
140    }
141
142    /// Dense linear solve `x = A⁻¹·b`. `A` must be `[N, N]`; `b` is
143    /// `[N]` for a single right-hand side or `[N, K]` for multiple.
144    /// `out_shape` matches `b`'s shape.
145    pub fn dense_solve(&mut self, a: NodeId, b: NodeId, out_shape: Shape) -> NodeId {
146        self.push(Op::DenseSolve, vec![a, b], out_shape, None)
147    }
148
149    /// Batched dense linear solve. `A` is `[B, N, N]`; `b` is
150    /// `[B, N]` (single-RHS) or `[B, N, K]` (multi-RHS). Per-batch
151    /// independent — each slice solved as a separate `dense_solve`.
152    /// Typically constructed by `vmap` of `dense_solve`.
153    pub fn batched_dense_solve(&mut self, a: NodeId, b: NodeId, out_shape: Shape) -> NodeId {
154        self.push(Op::BatchedDenseSolve, vec![a, b], out_shape, None)
155    }
156
157    /// Fused LoRA matmul: out = x·W + scale * (x·A)·B.
158    /// Inputs: x [m, k], w [k, n], a [k, r], b [r, n]. r is the
159    /// LoRA rank; scale is the alpha/rank coefficient.
160    pub fn lora_matmul(
161        &mut self,
162        x: NodeId,
163        w: NodeId,
164        a: NodeId,
165        b: NodeId,
166        scale: f32,
167        shape: Shape,
168    ) -> NodeId {
169        self.push(Op::LoraMatMul { scale }, vec![x, w, a, b], shape, None)
170    }
171
172    /// Fused dequant + matmul. See [`Op::DequantMatMul`] for per-scheme
173    /// input layout (4 inputs for legacy/NVFP4, 2 for GGUF).
174    pub fn dequant_matmul(
175        &mut self,
176        x: NodeId,
177        w_q: NodeId,
178        scale: NodeId,
179        zp: NodeId,
180        scheme: QuantScheme,
181        shape: Shape,
182    ) -> NodeId {
183        self.push(
184            Op::DequantMatMul { scheme },
185            vec![x, w_q, scale, zp],
186            shape,
187            None,
188        )
189    }
190
191    /// GGUF / K-quant packed weights — `[x, packed_w_bytes]` only.
192    pub fn dequant_matmul_packed(
193        &mut self,
194        x: NodeId,
195        packed_w: NodeId,
196        scheme: QuantScheme,
197        shape: Shape,
198    ) -> NodeId {
199        debug_assert!(
200            scheme.is_gguf(),
201            "dequant_matmul_packed requires a GGUF QuantScheme"
202        );
203        self.push(Op::DequantMatMul { scheme }, vec![x, packed_w], shape, None)
204    }
205
206    /// NVFP4 (E2M1) block matmul — group size 16, FP8 block scales,
207    /// optional f32 global scale (defaults to 1.0 when unset at runtime).
208    pub fn dequant_matmul_nvfp4(
209        &mut self,
210        x: NodeId,
211        w_q: NodeId,
212        block_scales: NodeId,
213        global_scale: NodeId,
214        shape: Shape,
215    ) -> NodeId {
216        self.dequant_matmul(
217            x,
218            w_q,
219            block_scales,
220            global_scale,
221            QuantScheme::Nvfp4Block,
222            shape,
223        )
224    }
225
226    /// Fused matmul + bias + activation (created by optimization passes).
227    pub fn fused_matmul_bias_act(
228        &mut self,
229        input: NodeId,
230        weight: NodeId,
231        bias: NodeId,
232        activation: Option<Activation>,
233        shape: Shape,
234    ) -> NodeId {
235        self.push(
236            Op::FusedMatMulBiasAct { activation },
237            vec![input, weight, bias],
238            shape,
239            None,
240        )
241    }
242
243    /// Real INT8-arithmetic matmul: i8 inputs, i32 bias, i8 output.
244    /// `mult = x_scale · w_scale / out_scale`. Caller's responsible
245    /// for asserting the input dtypes — the builder just plumbs the
246    /// shape with `dtype = I8` since that's what the kernel writes.
247    pub fn q_matmul(
248        &mut self,
249        x: NodeId,
250        w: NodeId,
251        bias: NodeId,
252        x_zp: i32,
253        w_zp: i32,
254        out_zp: i32,
255        mult: f32,
256        out_shape: Shape,
257    ) -> NodeId {
258        debug_assert_eq!(
259            out_shape.dtype(),
260            crate::DType::I8,
261            "q_matmul output dtype must be I8"
262        );
263        self.push(
264            Op::QMatMul {
265                x_zp,
266                w_zp,
267                out_zp,
268                mult,
269            },
270            vec![x, w, bias],
271            out_shape,
272            None,
273        )
274    }
275
276    /// Real INT8-arithmetic 2-D convolution. NCHW layout matching
277    /// `Op::Conv`. `mult = x_scale · w_scale / out_scale`.
278    #[allow(clippy::too_many_arguments)]
279    pub fn q_conv2d(
280        &mut self,
281        x: NodeId,
282        w: NodeId,
283        bias: NodeId,
284        kernel_size: Vec<usize>,
285        stride: Vec<usize>,
286        padding: Vec<usize>,
287        dilation: Vec<usize>,
288        groups: usize,
289        x_zp: i32,
290        w_zp: i32,
291        out_zp: i32,
292        mult: f32,
293        out_shape: Shape,
294    ) -> NodeId {
295        debug_assert_eq!(
296            out_shape.dtype(),
297            crate::DType::I8,
298            "q_conv2d output dtype must be I8"
299        );
300        self.push(
301            Op::QConv2d {
302                kernel_size,
303                stride,
304                padding,
305                dilation,
306                groups,
307                x_zp,
308                w_zp,
309                out_zp,
310                mult,
311            },
312            vec![x, w, bias],
313            out_shape,
314            None,
315        )
316    }
317}