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}