Skip to main content

combs_models/
qlinear.rs

1//! The quantized linear seam: per-tensor
2//! kernel dispatch behind a stable `Linear` type, so model code calls
3//! `layer.q.forward(x, bias)` and never knows whether the weight is a dense
4//! burn tensor or packed GGUF blocks fed to our fused CubeCL kernels.
5//!
6//! Dispatch happens **once, at load time**: [`try_quant_linear`] returns a
7//! backend-specific op only when (a) the source stores the tensor packed in
8//! a supported GGUF format (Q4_0/Q5_0/Q8_0/Q4_K/Q5_K/Q6_K), and (b) the
9//! backend runs on the wgpu runtime the kernels target. Every other
10//! combination falls back to the portable dense
11//! path — HF-kernels principle #2 (kernels are accelerators, never
12//! load-bearing for correctness).
13//!
14//! Three backends get the fast path:
15//! - `Fusion<CubeBackend<WgpuRuntime, f32, …>>` — the default build. The
16//!   matmul enters the fusion stream as a custom operation (burn's
17//!   sanctioned escape hatch), so ops before/after it still fuse and the
18//!   packed weight is read directly by our kernel at execution time.
19//! - `CubeBackend<WgpuRuntime, f32, …>` — unfused f32; direct launch.
20//! - `CubeBackend<WgpuRuntime, f16, …>` — the `--features f16` build; the
21//!   activation is cast f16→f32 around the kernel (weights dominate memory,
22//!   activations are negligible).
23//!
24//! Backend selection uses `Any` downcasts keyed on the backend type — safe,
25//! no `unsafe`, and models stay generic over `B: Backend`.
26
27use std::any::{Any, TypeId};
28use std::sync::Arc;
29
30use burn::backend::wgpu::{CubeBackend, CubeTensor, WgpuDevice, WgpuRuntime};
31use burn::tensor::backend::Backend;
32use burn::tensor::{DType, Device, FloatDType, Shape, Tensor, TensorPrimitive};
33use burn_cubecl::fusion::FusionCubeRuntime;
34use burn_cubecl::kernel::into_contiguous;
35use burn_cubecl_fusion::CubeFusionHandle;
36use burn_fusion::Fusion;
37use burn_fusion::stream::{Operation, OperationStreams};
38use burn_ir::{CustomOpIr, HandleContainer, OperationIr, TensorIr, TensorStatus};
39use combs_formats::ModelSource;
40
41use crate::llama::linear as dense_linear;
42use crate::qmatmul::QuantWeight;
43use crate::{ModelError, Result};
44
45/// The default engine backend (fused f32 wgpu).
46type FusedF32 = Fusion<CubeBackend<WgpuRuntime, f32, i32, u32>>;
47/// Unfused f32 wgpu (used when the fusion feature is off).
48type UnfusedF32 = CubeBackend<WgpuRuntime, f32, i32, u32>;
49/// The `--features f16` backend.
50type UnfusedF16 = CubeBackend<WgpuRuntime, burn::tensor::f16, i32, u32>;
51/// The inner (non-fusion) backend the custom op executes on.
52type InnerF32 = CubeBackend<WgpuRuntime, f32, i32, u32>;
53
54/// A backend-specific quantized-linear forward. Boxed into [`Linear::Quant`]
55/// at load time by [`try_quant_linear`].
56pub trait QuantLinearOp<B: Backend>: Send + Sync {
57    /// `y = x @ W^T` for `x: [batch, seq, k]` → `[batch, seq, n_out]`.
58    fn forward(&self, x: Tensor<B, 3>) -> Tensor<B, 3>;
59    /// `[n_out, k]`, matching a dense weight's `dims()`.
60    fn dims(&self) -> [usize; 2];
61    /// Bytes the packed weight occupies in VRAM.
62    fn vram_bytes(&self) -> usize;
63}
64
65/// A linear layer weight: dense tensor (portable path) or packed quant
66/// blocks bound to a fused device kernel.
67pub enum Linear<B: Backend> {
68    /// Portable path: `[n_out, k]` dense tensor, burn matmul.
69    Dense(Tensor<B, 2>),
70    /// Fast path: packed weight + backend-specific kernel dispatch.
71    Quant(Box<dyn QuantLinearOp<B>>),
72}
73
74impl<B: Backend> Linear<B> {
75    /// `[n_out, k]`.
76    pub fn dims(&self) -> [usize; 2] {
77        match self {
78            Linear::Dense(w) => w.dims(),
79            Linear::Quant(op) => op.dims(),
80        }
81    }
82
83    /// `y = x @ W^T (+ b)` for `x: [batch, seq, k]`.
84    pub fn forward(&self, x: Tensor<B, 3>, bias: Option<&Tensor<B, 1>>) -> Tensor<B, 3> {
85        match self {
86            Linear::Dense(w) => dense_linear(x, w, bias),
87            Linear::Quant(op) => {
88                let out = op.forward(x);
89                match bias {
90                    Some(b) => {
91                        let [batch, seq, dim] = out.dims();
92                        out + b.clone().reshape([1, 1, dim]).expand([batch, seq, dim])
93                    }
94                    None => out,
95                }
96            }
97        }
98    }
99}
100
101/// The one concrete op implementation: a [`QuantWeight`] on the wgpu
102/// runtime. Implements [`QuantLinearOp`] for each supported backend.
103struct CubeQuantLinear {
104    w: Arc<QuantWeight>,
105}
106
107impl CubeQuantLinear {
108    fn dims(&self) -> [usize; 2] {
109        [self.w.n_out(), self.w.k()]
110    }
111
112    /// Shared unfused path: contiguous f32 `CubeTensor` in, f32 out.
113    fn forward_cube(&self, x: CubeTensor<WgpuRuntime>, batch: usize, seq: usize) -> CubeTensor<WgpuRuntime> {
114        let x = into_contiguous(x);
115        let out_h = self.w.matmul_device(&x.client, x.handle.clone(), batch * seq);
116        CubeTensor::new_contiguous(
117            x.client.clone(),
118            x.device.clone(),
119            Shape::from([batch, seq, self.w.n_out()]),
120            out_h,
121            DType::F32,
122        )
123    }
124}
125
126/// The kernels read/write f32; tensors of other float dtypes are cast
127/// around the launch and the output follows the input dtype. (burn 0.21
128/// resolves a tensor's dtype from per-device default settings, so even an
129/// f32 backend can hand us f16 tensors.)
130fn to_f32<B: Backend>(x: Tensor<B, 3>) -> Tensor<B, 3> {
131    match x.dtype() {
132        DType::F32 => x,
133        _ => x.cast(FloatDType::F32),
134    }
135}
136
137fn to_dtype<B: Backend>(out: Tensor<B, 3>, dtype: DType) -> Tensor<B, 3> {
138    match dtype {
139        DType::F16 => out.cast(FloatDType::F16),
140        DType::BF16 => out.cast(FloatDType::BF16),
141        _ => out,
142    }
143}
144
145impl QuantLinearOp<UnfusedF32> for CubeQuantLinear {
146    fn forward(&self, x: Tensor<UnfusedF32, 3>) -> Tensor<UnfusedF32, 3> {
147        let in_dtype = x.dtype();
148        let [batch, seq, _] = x.dims();
149        let prim = to_f32(x).into_primitive().tensor();
150        let out = self.forward_cube(prim, batch, seq);
151        to_dtype(
152            Tensor::from_primitive(TensorPrimitive::Float(out)),
153            in_dtype,
154        )
155    }
156
157    fn dims(&self) -> [usize; 2] {
158        CubeQuantLinear::dims(self)
159    }
160
161    fn vram_bytes(&self) -> usize {
162        self.w.vram_bytes()
163    }
164}
165
166impl QuantLinearOp<UnfusedF16> for CubeQuantLinear {
167    fn forward(&self, x: Tensor<UnfusedF16, 3>) -> Tensor<UnfusedF16, 3> {
168        // Casting the (small) activation up costs nothing next to the
169        // weight win, and the f32-accumulated matmul is *better*
170        // numerically than an f16 one.
171        let in_dtype = x.dtype();
172        let [batch, seq, _] = x.dims();
173        let prim = to_f32(x).into_primitive().tensor();
174        let out = self.forward_cube(prim, batch, seq);
175        to_dtype(
176            Tensor::<UnfusedF16, 3>::from_primitive(TensorPrimitive::Float(out)),
177            in_dtype,
178        )
179    }
180
181    fn dims(&self) -> [usize; 2] {
182        CubeQuantLinear::dims(self)
183    }
184
185    fn vram_bytes(&self) -> usize {
186        self.w.vram_bytes()
187    }
188}
189
190/// The fusion-stream operation for the fused backend: executed when the
191/// stream drains, with inputs resolved to real device tensors.
192struct QuantMatmulOp {
193    desc: CustomOpIr,
194    w: Arc<QuantWeight>,
195    batch: usize,
196    seq: usize,
197}
198
199impl core::fmt::Debug for QuantMatmulOp {
200    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
201        write!(
202            f,
203            "QuantMatmulOp {{ w: [{}, {}], m: {} }}",
204            self.w.n_out(),
205            self.w.k(),
206            self.batch * self.seq
207        )
208    }
209}
210
211impl Operation<FusionCubeRuntime<WgpuRuntime>> for QuantMatmulOp {
212    fn execute(&self, handles: &mut HandleContainer<CubeFusionHandle<WgpuRuntime>>) {
213        let ([input], [output]) = self.desc.as_fixed::<1, 1>();
214        let x: CubeTensor<WgpuRuntime> = handles.get_float_tensor::<InnerF32>(input);
215        let x = into_contiguous(x);
216        let out_h = self.w.matmul_device(&x.client, x.handle.clone(), self.batch * self.seq);
217        let out = CubeTensor::new_contiguous(
218            x.client.clone(),
219            x.device.clone(),
220            Shape::from([self.batch, self.seq, self.w.n_out()]),
221            out_h,
222            DType::F32,
223        );
224        handles.register_float_tensor::<InnerF32>(&output.id, out);
225    }
226}
227
228impl QuantLinearOp<FusedF32> for CubeQuantLinear {
229    fn forward(&self, x: Tensor<FusedF32, 3>) -> Tensor<FusedF32, 3> {
230        let in_dtype = x.dtype();
231        let [batch, seq, _] = x.dims();
232        let prim = to_f32(x).into_primitive().tensor();
233        let client = prim.client.clone();
234
235        let mut streams = OperationStreams::default();
236        streams.tensor(&prim);
237        let input_ir = prim.into_ir();
238        let out_ir = TensorIr {
239            id: client.create_empty_handle(),
240            shape: Shape::from([batch, seq, self.w.n_out()]),
241            status: TensorStatus::NotInit,
242            dtype: DType::F32,
243        };
244        let desc = CustomOpIr::new("combs_quant_matmul", &[input_ir], &[out_ir]);
245        let op = QuantMatmulOp {
246            desc: desc.clone(),
247            w: self.w.clone(),
248            batch,
249            seq,
250        };
251        let mut outputs = client.register(streams, OperationIr::Custom(desc), op);
252        let out = outputs.pop().expect("custom op declares one output");
253        to_dtype(
254            Tensor::from_primitive(TensorPrimitive::Float(out)),
255            in_dtype,
256        )
257    }
258
259    fn dims(&self) -> [usize; 2] {
260        CubeQuantLinear::dims(self)
261    }
262
263    fn vram_bytes(&self) -> usize {
264        self.w.vram_bytes()
265    }
266}
267
268/// Boxes `op` as a `QuantLinearOp<B>` iff `B` is `T` — a safe runtime
269/// type-equality bridge (models stay generic; no specialization needed).
270fn cast_op<B: Backend, T: Backend>(op: Box<dyn QuantLinearOp<T>>) -> Option<Box<dyn QuantLinearOp<B>>> {
271    let any: Box<dyn Any> = Box::new(op);
272    any.downcast::<Box<dyn QuantLinearOp<B>>>().ok().map(|b| *b)
273}
274
275/// Tries to build the quantized fast path for `name`: packed bytes from the
276/// source + a kernel dispatch matching `B`. `None` → caller uses the dense
277/// fallback. Errors only on malformed packed data.
278fn debug_quant(name: &str, outcome: &str) {
279    if std::env::var_os("COMBS_DEBUG_QUANT").is_some() {
280        eprintln!("quant-linear {name}: {outcome}");
281    }
282}
283
284pub fn try_quant_linear<B: Backend>(
285    source: &dyn ModelSource,
286    name: &str,
287    device: &Device<B>,
288) -> Result<Option<Box<dyn QuantLinearOp<B>>>> {
289    // Escape hatch: force the portable dense path (weights dequantized to
290    // float at load). Costs the VRAM win; useful to isolate kernel issues.
291    if std::env::var_os("COMBS_NO_QUANT_KERNELS").is_some_and(|v| v != "0") {
292        return Ok(None);
293    }
294    let supported = [
295        TypeId::of::<FusedF32>(),
296        TypeId::of::<UnfusedF32>(),
297        TypeId::of::<UnfusedF16>(),
298    ];
299    if !supported.contains(&TypeId::of::<B>()) {
300        debug_quant(name, "backend not wgpu f32/f16 — dense fallback");
301        return Ok(None);
302    }
303    let device_any: &dyn Any = device;
304    let Some(wgpu_device) = device_any.downcast_ref::<WgpuDevice>() else {
305        debug_quant(name, "device not WgpuDevice — dense fallback");
306        return Ok(None);
307    };
308    let Some(qt) = source.open_tensor_quant(name).map_err(ModelError::Format)? else {
309        debug_quant(name, "no packed quant tensor — dense fallback");
310        return Ok(None);
311    };
312    let &[n_out, k] = qt.shape.as_slice() else {
313        debug_quant(name, "not rank-2 — dense fallback");
314        return Ok(None);
315    };
316
317    let client = <WgpuRuntime as cubecl::prelude::Runtime>::client(wgpu_device);
318    // A tensor the kernels can't take (e.g. k not a block multiple — ggml
319    // itself falls back to 32-block formats for such shapes) is not an
320    // error: the dense path handles it. Kernels are accelerators, never
321    // load-bearing.
322    let Ok(w) = QuantWeight::from_quant_tensor(&client, qt.format, &qt.data, n_out, k) else {
323        debug_quant(name, "kernel-incompatible shape — dense fallback");
324        return Ok(None);
325    };
326    debug_quant(name, "packed on device");
327    let lin = CubeQuantLinear { w: Arc::new(w) };
328
329    if TypeId::of::<B>() == TypeId::of::<FusedF32>() {
330        return Ok(cast_op::<B, FusedF32>(Box::new(lin)));
331    }
332    if TypeId::of::<B>() == TypeId::of::<UnfusedF32>() {
333        return Ok(cast_op::<B, UnfusedF32>(Box::new(lin)));
334    }
335    if TypeId::of::<B>() == TypeId::of::<UnfusedF16>() {
336        return Ok(cast_op::<B, UnfusedF16>(Box::new(lin)));
337    }
338    Ok(None)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use burn::tensor::TensorData;
345    use combs_formats::QuantFormat;
346    use cubecl::prelude::Runtime;
347
348    /// Synthetic Q4_0 stream (mirrors qmatmul's test generator).
349    fn synth_q4_0(n_blocks: usize) -> Vec<u8> {
350        let mut out = Vec::with_capacity(n_blocks * 18);
351        let mut s = 0x12345678u32;
352        for b in 0..n_blocks {
353            let scale = burn::tensor::f16::from_f32(0.003 * ((b % 11) as f32 + 1.0));
354            out.extend_from_slice(&scale.to_le_bytes());
355            for _ in 0..16 {
356                s = s.wrapping_mul(1664525).wrapping_add(1013904223);
357                out.push((s >> 24) as u8);
358            }
359        }
360        out
361    }
362
363    /// burn 0.21 locks per-device default dtypes to whichever backend
364    /// touches the device first, and tests share one wgpu device — without
365    /// pinning, the f16 test can lock the device to F16 and the f32 test's
366    /// `from_data` would round its reference tensors through f16. Pin F32
367    /// defaults before any tensor exists; each test then casts explicitly
368    /// to the dtype it intends.
369    fn pin_device_dtypes() {
370        use std::sync::Once;
371        static PIN: Once = Once::new();
372        PIN.call_once(|| {
373            let device = WgpuDevice::default();
374            let _ = burn::tensor::set_default_dtypes::<UnfusedF32>(
375                &device,
376                FloatDType::F32,
377                burn::tensor::IntDType::I32,
378            );
379        });
380    }
381
382    fn quant_and_dense<B: Backend>(
383        device: &Device<B>,
384        n_out: usize,
385        k: usize,
386        dtype: FloatDType,
387    ) -> (Linear<B>, Linear<B>)
388    where
389        CubeQuantLinear: QuantLinearOp<B>,
390    {
391        let data = synth_q4_0(n_out * k / 32);
392        let client = <WgpuRuntime as Runtime>::client(&Default::default());
393        let w = Arc::new(
394            QuantWeight::from_quant_tensor(&client, QuantFormat::Q4_0, &data, n_out, k).unwrap(),
395        );
396        let quant = Linear::Quant(Box::new(CubeQuantLinear { w }) as Box<dyn QuantLinearOp<B>>);
397        let wf = combs_formats::quants::dequantize_q4_0(&data, n_out * k).unwrap();
398        let dense = Linear::Dense(
399            Tensor::<B, 2>::from_data(TensorData::new(wf, [n_out, k]), device).cast(dtype),
400        );
401        (quant, dense)
402    }
403
404    fn assert_close(got: &[f32], expect: &[f32], rel: f32) {
405        assert_eq!(got.len(), expect.len());
406        for (i, (g, e)) in got.iter().zip(expect.iter()).enumerate() {
407            let tol = rel * e.abs().max(1.0);
408            assert!((g - e).abs() <= tol, "[{i}]: got {g}, expect {e}");
409        }
410    }
411
412    /// The default engine backend: the quantized linear runs as a custom op
413    /// inside the fusion stream and must match the dense path, with a bias.
414    #[test]
415    fn fused_backend_matches_dense() {
416        if crate::skip_no_gpu() {
417            return;
418        }
419        pin_device_dtypes();
420        let device: Device<FusedF32> = Default::default();
421        let (n_out, k) = (48, 64);
422        let (quant, dense) = quant_and_dense::<FusedF32>(&device, n_out, k, FloatDType::F32);
423        assert_eq!(quant.dims(), [n_out, k]);
424
425        let x: Vec<f32> = (0..3 * k).map(|i| ((i % 32) as f32) / 16.0 - 1.0).collect();
426        let x = Tensor::<FusedF32, 3>::from_data(TensorData::new(x, [1, 3, k]), &device)
427            .cast(FloatDType::F32);
428        let b: Vec<f32> = (0..n_out).map(|i| (i as f32) / 100.0).collect();
429        let bias = Tensor::<FusedF32, 1>::from_data(TensorData::new(b, [n_out]), &device)
430            .cast(FloatDType::F32);
431
432        let got: Vec<f32> = quant
433            .forward(x.clone(), Some(&bias))
434            .into_data()
435            .to_vec()
436            .unwrap();
437        let expect: Vec<f32> = dense
438            .forward(x, Some(&bias))
439            .into_data()
440            .to_vec()
441            .unwrap();
442        assert_close(&got, &expect, 1e-4);
443    }
444
445    /// The f16 build: activation is cast around the f32 kernel; tolerance
446    /// covers the final f16 rounding of the output.
447    #[test]
448    fn f16_backend_matches_dense() {
449        if crate::skip_no_gpu() {
450            return;
451        }
452        pin_device_dtypes();
453        let device: Device<UnfusedF16> = Default::default();
454        let (n_out, k) = (48, 64);
455        let (quant, dense) = quant_and_dense::<UnfusedF16>(&device, n_out, k, FloatDType::F16);
456
457        let x: Vec<f32> = (0..3 * k).map(|i| ((i % 32) as f32) / 16.0 - 1.0).collect();
458        let x = Tensor::<UnfusedF16, 3>::from_data(TensorData::new(x, [1, 3, k]), &device)
459            .cast(FloatDType::F16);
460
461        let got: Vec<f32> = quant
462            .forward(x.clone(), None)
463            .into_data()
464            .convert::<f32>()
465            .to_vec()
466            .unwrap();
467        let expect: Vec<f32> = dense
468            .forward(x, None)
469            .into_data()
470            .convert::<f32>()
471            .to_vec()
472            .unwrap();
473        assert_close(&got, &expect, 1e-2);
474    }
475}