rlx_ir/ops/linalg.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//! Linear-algebra builders: matmul, LoRA, dequant, fused
6//! matmul+bias+activation (plan #53).
7
8use crate::op::{Activation, SynthKind};
9use crate::quant::{QuantScheme, ScaleLayout, ScaledFormat};
10use crate::{DType, Graph, NodeId, Op, Shape};
11
12impl Graph {
13 /// Matrix multiply.
14 pub fn matmul(&mut self, lhs: NodeId, rhs: NodeId, out_shape: Shape) -> NodeId {
15 self.push(Op::MatMul, vec![lhs, rhs], out_shape, None)
16 }
17
18 /// Dynamically quantize `x` (logical `[rows, cols]`, blocks along the last
19 /// axis) to low-precision `fmt` codes plus a scale tensor, per `layout`.
20 /// Returns `(codes, scale)`: `codes` is `DType::U8` with `x`'s shape;
21 /// `scale`'s shape/dtype follow the layout (`[1]` f32 for per-tensor,
22 /// `[rows, cols/block]` u8 for block layouts). The building block of
23 /// [`scaled_matmul`](Self::scaled_matmul); `fmt` may be any
24 /// [`ScaledFormat`], including a parameterized [`ScaledFormat::Custom`].
25 pub fn scaled_quantize(
26 &mut self,
27 x: NodeId,
28 fmt: ScaledFormat,
29 layout: ScaleLayout,
30 ) -> (NodeId, NodeId) {
31 let xs = self.node(x).shape.clone();
32 let cols = xs.dim(xs.rank() - 1).unwrap_static();
33 let rows = xs.num_elements().unwrap() / cols.max(1);
34 let scale_shape = match layout {
35 ScaleLayout::PerTensor => Shape::new(&[1], layout.scale_dtype()),
36 _ => Shape::new(
37 &[rows, cols.div_ceil(layout.block() as usize)],
38 layout.scale_dtype(),
39 ),
40 };
41 let scale = self.push(
42 Op::ScaledQuantScale {
43 format: fmt,
44 scale_layout: layout,
45 },
46 vec![x],
47 scale_shape,
48 None,
49 );
50 let codes = self.push(
51 Op::ScaledQuantize {
52 format: fmt,
53 scale_layout: layout,
54 },
55 vec![x, scale],
56 xs.with_dtype(DType::U8),
57 None,
58 );
59 (codes, scale)
60 }
61
62 /// Reconstruct f32 from packed `codes` + `scale` — the inverse of
63 /// [`scaled_quantize`](Self::scaled_quantize).
64 pub fn scaled_dequantize(
65 &mut self,
66 codes: NodeId,
67 scale: NodeId,
68 fmt: ScaledFormat,
69 layout: ScaleLayout,
70 ) -> NodeId {
71 let shape = self.node(codes).shape.clone().with_dtype(DType::F32);
72 self.push(
73 Op::ScaledDequantize {
74 format: fmt,
75 scale_layout: layout,
76 },
77 vec![codes, scale],
78 shape,
79 None,
80 )
81 }
82
83 /// Native low-precision GEMM (TN: `lhs [m,k] · rhs [n,k]ᵀ → [m,n]` f32).
84 /// Both operands are dynamically quantized to `fmt`/`layout` and fed
85 /// straight into the scaled matmul with f32 accumulation — no hand-wiring of
86 /// [`Op::ScaledQuantScale`]/[`Op::ScaledQuantize`]. `rhs` must already be
87 /// K-last (`[n, k]`); transpose a `[k, n]` weight first. `fmt` may be any
88 /// [`ScaledFormat`], including a parameterized [`ScaledFormat::Custom`]
89 /// (e.g. `ScaledFormat::custom(3, 0)` for `f4e3m0`).
90 pub fn scaled_matmul(
91 &mut self,
92 lhs: NodeId,
93 rhs: NodeId,
94 fmt: ScaledFormat,
95 layout: ScaleLayout,
96 ) -> NodeId {
97 self.scaled_matmul_bias(lhs, rhs, None, fmt, layout)
98 }
99
100 /// [`scaled_matmul`](Self::scaled_matmul) with an optional f32 bias `[n]`
101 /// added to each output row.
102 pub fn scaled_matmul_bias(
103 &mut self,
104 lhs: NodeId,
105 rhs: NodeId,
106 bias: Option<NodeId>,
107 fmt: ScaledFormat,
108 layout: ScaleLayout,
109 ) -> NodeId {
110 let m = self.node(lhs).shape.dim(0).unwrap_static();
111 let n = self.node(rhs).shape.dim(0).unwrap_static();
112 let (lq, ls) = self.scaled_quantize(lhs, fmt, layout);
113 let (rq, rs) = self.scaled_quantize(rhs, fmt, layout);
114 let mut inputs = vec![lq, rq, ls, rs];
115 if let Some(b) = bias {
116 inputs.push(b);
117 }
118 self.push(
119 Op::ScaledMatMul {
120 lhs_format: fmt,
121 rhs_format: fmt,
122 scale_layout: layout,
123 has_bias: bias.is_some(),
124 },
125 inputs,
126 Shape::new(&[m, n], DType::F32),
127 None,
128 )
129 }
130
131 /// Native low-precision *grouped* (MoE) GEMM — the expert-indexed
132 /// [`scaled_matmul`](Self::scaled_matmul). Dynamically quantizes f32
133 /// `input [M,K]` and the per-expert f32 weight stack `weight [E,N,K]`
134 /// (K-last) to `fmt`/`layout` codes, then wires
135 /// [`Op::ScaledGroupedMatMul`]. `expert_idx [M]` is the f32-encoded
136 /// expert id per token. Output `[M,N]` f32, with f32 accumulation.
137 pub fn scaled_grouped_matmul(
138 &mut self,
139 input: NodeId,
140 weight: NodeId,
141 expert_idx: NodeId,
142 fmt: ScaledFormat,
143 layout: ScaleLayout,
144 ) -> NodeId {
145 self.scaled_grouped_matmul_bias(input, weight, expert_idx, None, fmt, layout)
146 }
147
148 /// [`scaled_grouped_matmul`](Self::scaled_grouped_matmul) with an optional
149 /// per-expert f32 bias `[E, N]` added to each routed output row.
150 pub fn scaled_grouped_matmul_bias(
151 &mut self,
152 input: NodeId,
153 weight: NodeId,
154 expert_idx: NodeId,
155 bias: Option<NodeId>,
156 fmt: ScaledFormat,
157 layout: ScaleLayout,
158 ) -> NodeId {
159 let m = self.node(input).shape.dim(0).unwrap_static();
160 let wshape = self.node(weight).shape.clone();
161 // weight is [E, N, K]; N is the output dim, K the contraction axis.
162 let e = wshape.dim(0).unwrap_static();
163 let n = wshape.dim(wshape.rank() - 2).unwrap_static();
164 let k = wshape.dim(wshape.rank() - 1).unwrap_static();
165 // Activations [M,K] quantize with the generic (2-D) helper.
166 let (iq, is) = self.scaled_quantize(input, fmt, layout);
167 // The per-expert weight stack keeps its natural rank: the scale is
168 // `[E, N, ⌈K/block⌉]` (or `[1]` per-tensor), matching `ScaledQuantScale`
169 // shape inference so the verifier is happy. Byte layout is identical to
170 // the flattened `[E·N, ⌈K/block⌉]` the oracle indexes.
171 let w_scale_shape = match layout {
172 ScaleLayout::PerTensor => Shape::new(&[1], layout.scale_dtype()),
173 _ => Shape::new(
174 &[e, n, k.div_ceil(layout.block() as usize)],
175 layout.scale_dtype(),
176 ),
177 };
178 let ws = self.push(
179 Op::ScaledQuantScale {
180 format: fmt,
181 scale_layout: layout,
182 },
183 vec![weight],
184 w_scale_shape,
185 None,
186 );
187 let wq = self.push(
188 Op::ScaledQuantize {
189 format: fmt,
190 scale_layout: layout,
191 },
192 vec![weight, ws],
193 wshape.with_dtype(DType::U8),
194 None,
195 );
196 let mut inputs = vec![iq, wq, is, ws, expert_idx];
197 if let Some(b) = bias {
198 inputs.push(b);
199 }
200 self.push(
201 Op::ScaledGroupedMatMul {
202 lhs_format: fmt,
203 rhs_format: fmt,
204 scale_layout: layout,
205 has_bias: bias.is_some(),
206 },
207 inputs,
208 Shape::new(&[m, n], DType::F32),
209 None,
210 )
211 }
212
213 /// Dense linear solve `x = A⁻¹·b`. `A` must be `[N, N]`; `b` is
214 /// `[N]` for a single right-hand side or `[N, K]` for multiple.
215 /// `out_shape` matches `b`'s shape.
216 pub fn dense_solve(&mut self, a: NodeId, b: NodeId, out_shape: Shape) -> NodeId {
217 self.push(Op::DenseSolve, vec![a, b], out_shape, None)
218 }
219
220 /// Cholesky factorization `A = L·Lᵀ`. `a` is `[n, n]` symmetric
221 /// positive-definite; the output is the lower-triangular `L` (same shape,
222 /// strict upper triangle zeroed).
223 pub fn cholesky(&mut self, a: NodeId, out_shape: Shape) -> NodeId {
224 self.push(Op::Cholesky, vec![a], out_shape, None)
225 }
226
227 /// Triangular solve `op(A)·X = B`. `a` is `[n, n]` triangular (lower/upper
228 /// per `lower`), `op(A)` is `Aᵀ` when `transpose`; `b`/output are `[n]` or
229 /// `[n, nrhs]`.
230 pub fn triangular_solve(
231 &mut self,
232 a: NodeId,
233 b: NodeId,
234 lower: bool,
235 transpose: bool,
236 out_shape: Shape,
237 ) -> NodeId {
238 self.push(
239 Op::TriangularSolve { lower, transpose },
240 vec![a, b],
241 out_shape,
242 None,
243 )
244 }
245
246 /// Determinant of the square matrix `a` `[n, n]` → scalar (`out_shape` `[]`).
247 pub fn det(&mut self, a: NodeId, out_shape: Shape) -> NodeId {
248 self.push(Op::Det, vec![a], out_shape, None)
249 }
250
251 /// `log|det(a)|` of the square matrix `a` `[n, n]` → scalar (`out_shape` `[]`).
252 pub fn logdet(&mut self, a: NodeId, out_shape: Shape) -> NodeId {
253 self.push(Op::LogDet, vec![a], out_shape, None)
254 }
255
256 /// Sort `x` along `axis` (`descending` = largest-first). Output same shape.
257 pub fn sort(&mut self, x: NodeId, axis: usize, descending: bool, out_shape: Shape) -> NodeId {
258 self.push(Op::Sort { axis, descending }, vec![x], out_shape, None)
259 }
260
261 /// Indices that would sort `x` along `axis` (as f32). Output same shape.
262 pub fn argsort(
263 &mut self,
264 x: NodeId,
265 axis: usize,
266 descending: bool,
267 out_shape: Shape,
268 ) -> NodeId {
269 self.push(Op::ArgSort { axis, descending }, vec![x], out_shape, None)
270 }
271
272 /// One factor of the thin SVD `a = U·diag(S)·Vᵀ` (`a` `[m,n]`,
273 /// `k=min(m,n)`). `out_shape`: `U` `[m,k]`, `S` `[k]`, `Vt` `[k,n]`.
274 pub fn svd(&mut self, a: NodeId, part: crate::op::SvdPart, out_shape: Shape) -> NodeId {
275 self.push(Op::Svd { part }, vec![a], out_shape, None)
276 }
277
278 /// One factor of the thin QR `a = Q·R` (`a` `[m,n]`, `k=min(m,n)`).
279 /// `out_shape`: `Q` `[m,k]`, `R` `[k,n]`.
280 pub fn qr(&mut self, a: NodeId, part: crate::op::QrPart, out_shape: Shape) -> NodeId {
281 self.push(Op::Qr { part }, vec![a], out_shape, None)
282 }
283
284 /// Batched dense linear solve. `A` is `[B, N, N]`; `b` is
285 /// `[B, N]` (single-RHS) or `[B, N, K]` (multi-RHS). Per-batch
286 /// independent — each slice solved as a separate `dense_solve`.
287 /// Typically constructed by `vmap` of `dense_solve`.
288 pub fn batched_dense_solve(&mut self, a: NodeId, b: NodeId, out_shape: Shape) -> NodeId {
289 self.push(Op::BatchedDenseSolve, vec![a, b], out_shape, None)
290 }
291
292 /// Fused LoRA matmul: out = x·W + scale * (x·A)·B.
293 /// Inputs: x [m, k], w [k, n], a [k, r], b [r, n]. r is the
294 /// LoRA rank; scale is the alpha/rank coefficient.
295 pub fn lora_matmul(
296 &mut self,
297 x: NodeId,
298 w: NodeId,
299 a: NodeId,
300 b: NodeId,
301 scale: f32,
302 shape: Shape,
303 ) -> NodeId {
304 self.push(Op::LoraMatMul { scale }, vec![x, w, a, b], shape, None)
305 }
306
307 /// Fused dequant + matmul. See [`Op::DequantMatMul`] for per-scheme
308 /// input layout (4 inputs for legacy/NVFP4, 2 for GGUF).
309 pub fn dequant_matmul(
310 &mut self,
311 x: NodeId,
312 w_q: NodeId,
313 scale: NodeId,
314 zp: NodeId,
315 scheme: QuantScheme,
316 shape: Shape,
317 ) -> NodeId {
318 self.push(
319 Op::DequantMatMul { scheme },
320 vec![x, w_q, scale, zp],
321 shape,
322 None,
323 )
324 }
325
326 /// GGUF / K-quant packed weights — `[x, packed_w_bytes]` only.
327 pub fn dequant_matmul_packed(
328 &mut self,
329 x: NodeId,
330 packed_w: NodeId,
331 scheme: QuantScheme,
332 shape: Shape,
333 ) -> NodeId {
334 debug_assert!(
335 scheme.is_gguf(),
336 "dequant_matmul_packed requires a GGUF QuantScheme"
337 );
338 self.push(Op::DequantMatMul { scheme }, vec![x, packed_w], shape, None)
339 }
340
341 /// NVFP4 (E2M1) block matmul — group size 16, FP8 block scales,
342 /// optional f32 global scale (defaults to 1.0 when unset at runtime).
343 pub fn dequant_matmul_nvfp4(
344 &mut self,
345 x: NodeId,
346 w_q: NodeId,
347 block_scales: NodeId,
348 global_scale: NodeId,
349 shape: Shape,
350 ) -> NodeId {
351 self.dequant_matmul(
352 x,
353 w_q,
354 block_scales,
355 global_scale,
356 QuantScheme::Nvfp4Block,
357 shape,
358 )
359 }
360
361 /// On-chip codebook weight-synthesis matmul. See [`Op::SynthMatMul`].
362 /// `indices` is `[n, k/entry_dim]` u8; `codebook` is
363 /// `[num_entries, entry_dim]` f32; output is `[m, n]`. The weight is
364 /// reconstructed inside the matmul inner loop, never materialized.
365 pub fn synth_matmul(
366 &mut self,
367 x: NodeId,
368 indices: NodeId,
369 codebook: NodeId,
370 kind: SynthKind,
371 shape: Shape,
372 ) -> NodeId {
373 self.push(
374 Op::SynthMatMul { kind },
375 vec![x, indices, codebook],
376 shape,
377 None,
378 )
379 }
380
381 /// [`synth_matmul`](Self::synth_matmul) with a LOW-PRECISION codebook (fp8 /
382 /// fp4 / nvf4 / custom `fpXmYeZ`). The centroids are stored as `fmt`/`layout`
383 /// codes (`codebook_codes` U8 + `codebook_scale` from
384 /// [`scaled_quantize`](Self::scaled_quantize)) and decoded to f32 via
385 /// [`Op::ScaledDequantize`] before the synth matmul — so ANY [`ScaledFormat`],
386 /// including a parameterized [`ScaledFormat::Custom`] (e.g. `custom(3,0)` =
387 /// `f4e3m0`) or an [`ScaleLayout::Nvfp4`] layout, works on every backend with
388 /// no new kernel. The codebook is tiny, so the decode is negligible.
389 #[allow(clippy::too_many_arguments)]
390 pub fn synth_matmul_qcodebook(
391 &mut self,
392 x: NodeId,
393 indices: NodeId,
394 codebook_codes: NodeId,
395 codebook_scale: NodeId,
396 kind: SynthKind,
397 fmt: ScaledFormat,
398 layout: ScaleLayout,
399 shape: Shape,
400 ) -> NodeId {
401 let cb_f32 = self.scaled_dequantize(codebook_codes, codebook_scale, fmt, layout);
402 self.synth_matmul(x, indices, cb_f32, kind, shape)
403 }
404
405 /// KAN learnable spline activation. See [`Op::SplineActivation`]. `x` is
406 /// `[.., C]`, `coeff` is `[C, num_basis]`; the output matches `x`. Each
407 /// channel's univariate function is a Gaussian-RBF expansion with learned
408 /// per-channel coefficients.
409 pub fn spline_activation(
410 &mut self,
411 x: NodeId,
412 coeff: NodeId,
413 num_basis: u32,
414 grid_min: f32,
415 grid_max: f32,
416 ) -> NodeId {
417 let shape = self.node(x).shape.clone();
418 self.push(
419 Op::SplineActivation {
420 num_basis,
421 grid_min,
422 grid_max,
423 },
424 vec![x, coeff],
425 shape,
426 None,
427 )
428 }
429
430 /// Fused matmul + bias + activation (created by optimization passes).
431 pub fn fused_matmul_bias_act(
432 &mut self,
433 input: NodeId,
434 weight: NodeId,
435 bias: NodeId,
436 activation: Option<Activation>,
437 shape: Shape,
438 ) -> NodeId {
439 self.push(
440 Op::FusedMatMulBiasAct { activation },
441 vec![input, weight, bias],
442 shape,
443 None,
444 )
445 }
446
447 /// Real INT8-arithmetic matmul: i8 inputs, i32 bias, i8 output.
448 /// `mult = x_scale · w_scale / out_scale`. Caller's responsible
449 /// for asserting the input dtypes — the builder just plumbs the
450 /// shape with `dtype = I8` since that's what the kernel writes.
451 pub fn q_matmul(
452 &mut self,
453 x: NodeId,
454 w: NodeId,
455 bias: NodeId,
456 x_zp: i32,
457 w_zp: i32,
458 out_zp: i32,
459 mult: f32,
460 out_shape: Shape,
461 ) -> NodeId {
462 debug_assert_eq!(
463 out_shape.dtype(),
464 crate::DType::I8,
465 "q_matmul output dtype must be I8"
466 );
467 self.push(
468 Op::QMatMul {
469 x_zp,
470 w_zp,
471 out_zp,
472 mult,
473 },
474 vec![x, w, bias],
475 out_shape,
476 None,
477 )
478 }
479
480 /// Real INT8-arithmetic 2-D convolution. NCHW layout matching
481 /// `Op::Conv`. `mult = x_scale · w_scale / out_scale`.
482 #[allow(clippy::too_many_arguments)]
483 pub fn q_conv2d(
484 &mut self,
485 x: NodeId,
486 w: NodeId,
487 bias: NodeId,
488 kernel_size: Vec<usize>,
489 stride: Vec<usize>,
490 padding: Vec<usize>,
491 dilation: Vec<usize>,
492 groups: usize,
493 x_zp: i32,
494 w_zp: i32,
495 out_zp: i32,
496 mult: f32,
497 out_shape: Shape,
498 ) -> NodeId {
499 debug_assert_eq!(
500 out_shape.dtype(),
501 crate::DType::I8,
502 "q_conv2d output dtype must be I8"
503 );
504 self.push(
505 Op::QConv2d {
506 kernel_size,
507 stride,
508 padding,
509 dilation,
510 groups,
511 x_zp,
512 w_zp,
513 out_zp,
514 mult,
515 },
516 vec![x, w, bias],
517 out_shape,
518 None,
519 )
520 }
521}