Skip to main content

kopitiam_runtime/
gpu_offload.rs

1//! Optional GPU offload for the **output projection**, the single most
2//! expensive matmul in a decode step.
3//!
4//! # Why only this one matmul
5//!
6//! Measured on an Intel integrated GPU against 14 CPU cores at SmolLM2-360M's
7//! real shapes (`kopitiam-gpu/tests/matmul_timing.rs`):
8//!
9//! ```text
10//! decode  attn q/o     (1 tok)   cpu   443µs   gpu 1.88ms   0.24x   LOSS
11//! decode  mlp gate/up  (1 tok)   cpu  1.28ms   gpu 3.20ms   0.40x   LOSS
12//! output head, quantized + resident        cpu 26.12ms  gpu 8.55ms   3.06x  WIN
13//! ```
14//!
15//! A decode step is a single row of activations — far too little arithmetic to
16//! pay for moving a weight matrix across the bus. Offloading the small
17//! projections would make chat 2–4x *slower*. The output head is different on
18//! two counts: it is `vocab x hidden` (49152 x 960 for SmolLM2-360M), so it
19//! costs more per token than every other decode matmul combined; and its weight
20//! can stay **resident** on the device, so only the activation row crosses the
21//! bus per token.
22//!
23//! # Why the weight has to stay quantized, not merely resident
24//!
25//! As `f32` that weight is 188 MB, against a 128 MB
26//! `max_storage_buffer_binding_size` on the maintainer's adapter — it cannot be
27//! bound at all. Held as block-scaled int8 it is ~47 MB of quants plus ~6 MB of
28//! scales. So "quantized" is not an optimisation here, it is the difference
29//! between running on the GPU and not.
30//!
31//! # The tied-embedding wrinkle, which is also a CPU-side finding
32//!
33//! Neither SmolLM2 release ships a separate `output.weight`; both tie it to
34//! `token_embd.weight`. [`crate::weights::ModelWeights`] loads `token_embd`
35//! through `load_tensor_f32` (the embedding lookup needs real floats), and the
36//! tied fallback clones that — so **the biggest matmul in the model has been
37//! running against a fully dequantized f32 weight**, skipping the fused
38//! quantized kernel every other projection gets. This module reads the original
39//! quantized bytes back off the model instead.
40
41use kopitiam_core::{Error, Result};
42use kopitiam_gpu::{Executor, ResidentBlockQ8Weight, BLOCK};
43use kopitiam_loader::LoadedModel;
44
45/// Bytes per Q8_0 block: one `f16` scale followed by 32 `i8` quants.
46/// Matches `kopitiam_tensor`'s `dequant_q8_0`, which is itself verified against
47/// ggml's `ggml-quants.c`.
48const Q8_0_BLOCK_BYTES: usize = 2 + BLOCK;
49
50/// Splits raw GGUF Q8_0 bytes into the separated `(scales, quants)` layout
51/// `kopitiam-gpu` wants.
52///
53/// `kopitiam-gpu` is domain-agnostic parallel-compute infrastructure and does
54/// not know what a GGUF is — it takes generic block-scaled int8. Translating
55/// one to the other is format knowledge, so it lives here.
56///
57/// `rows * cols` elements are expected, `cols` a multiple of [`BLOCK`].
58/// Returns `(scales, quants)` with `rows * cols / BLOCK` scales and
59/// `rows * cols` quants, both in row-major order.
60///
61/// # Errors
62///
63/// [`Error::MalformedModel`] if `cols` is not a whole number of blocks or the
64/// byte length does not match — both of which mean the tensor is not the Q8_0
65/// shape claimed, and guessing would produce a plausible wrong weight.
66pub fn split_q8_0(bytes: &[u8], rows: usize, cols: usize) -> Result<(Vec<f32>, Vec<i8>)> {
67    if cols == 0 || !cols.is_multiple_of(BLOCK) {
68        return Err(Error::MalformedModel {
69            format: "q8_0-split",
70            reason: format!("cols ({cols}) must be a non-zero multiple of {BLOCK}"),
71        });
72    }
73    let blocks = rows * (cols / BLOCK);
74    let expected = blocks * Q8_0_BLOCK_BYTES;
75    if bytes.len() < expected {
76        return Err(Error::MalformedModel {
77            format: "q8_0-split",
78            reason: format!("expected {expected} bytes for {rows}x{cols} Q8_0, got {}", bytes.len()),
79        });
80    }
81
82    let mut scales = Vec::with_capacity(blocks);
83    let mut quants = Vec::with_capacity(rows * cols);
84    for b in 0..blocks {
85        let block = &bytes[b * Q8_0_BLOCK_BYTES..(b + 1) * Q8_0_BLOCK_BYTES];
86        scales.push(f16_to_f32(u16::from_le_bytes([block[0], block[1]])));
87        quants.extend(block[2..].iter().map(|&byte| byte as i8));
88    }
89    Ok((scales, quants))
90}
91
92/// Minimal IEEE-754 half -> single conversion, matching `kopitiam_tensor`'s
93/// `read_f16`. Duplicated rather than exported from there because it is four
94/// lines and a cross-crate `pub` for it would widen that crate's API surface
95/// for one caller.
96fn f16_to_f32(bits: u16) -> f32 {
97    let sign = ((bits >> 15) & 1) as u32;
98    let exp = ((bits >> 10) & 0x1F) as u32;
99    let frac = (bits & 0x3FF) as u32;
100    let out = match exp {
101        0 if frac == 0 => sign << 31,
102        // Subnormal: normalise into a single-precision exponent.
103        0 => {
104            let mut e = -1i32;
105            let mut f = frac;
106            while f & 0x400 == 0 {
107                f <<= 1;
108                e -= 1;
109            }
110            let exp32 = (127 - 15 + e) as u32;
111            (sign << 31) | (exp32 << 23) | ((f & 0x3FF) << 13)
112        }
113        0x1F => (sign << 31) | (0xFF << 23) | (frac << 13),
114        _ => (sign << 31) | ((exp + 127 - 15) << 23) | (frac << 13),
115    };
116    f32::from_bits(out)
117}
118
119/// The output projection, resident on the GPU.
120///
121/// `None` from [`Self::try_build`] is the ordinary case, not a failure: no GPU,
122/// a weight the kernel does not handle, or a device that cannot bind it. The
123/// caller keeps using the CPU path.
124pub struct GpuOutputHead {
125    weight: ResidentBlockQ8Weight,
126}
127
128impl GpuOutputHead {
129    /// Uploads the model's output projection if — and only if — every condition
130    /// for it to be a win holds.
131    ///
132    /// Returns `None` (never an error) when offload is simply not available:
133    /// there is no GPU, the tensor is not Q8_0 (the 1.7B ships Q6_K, which this
134    /// kernel does not decode), the tensor is missing, or the adapter cannot
135    /// bind it. Every one of those is a normal machine configuration, not a
136    /// fault, and the CPU path is always correct.
137    #[must_use]
138    pub fn try_build(model: &LoadedModel, executor: &Executor) -> Option<Self> {
139        let ctx = executor.gpu_context()?;
140
141        // Tied embeddings are the norm: neither SmolLM2 ships `output.weight`.
142        let name = if model.tensor("output.weight").is_some() {
143            "output.weight"
144        } else {
145            "token_embd.weight"
146        };
147        let entry = model.tensor(name)?;
148        if entry.dtype != kopitiam_core::DType::Q8_0 {
149            return None;
150        }
151        let dims = entry.shape.dims();
152        if dims.len() != 2 {
153            return None;
154        }
155        let (rows, cols) = (dims[0], dims[1]);
156
157        let bytes = model.tensor_bytes(name).ok()?;
158        let (scales, quants) = split_q8_0(bytes, rows, cols).ok()?;
159        let weight = ResidentBlockQ8Weight::upload(ctx, &quants, &scales, rows, cols).ok()?;
160        Some(Self { weight })
161    }
162
163    /// `logits = x @ w^T` for `m` rows of hidden states.
164    ///
165    /// # Errors
166    ///
167    /// Any GPU failure, which the caller should treat as "use the CPU path this
168    /// time" rather than as fatal.
169    pub fn forward(&self, executor: &Executor, x: &[f32], m: usize) -> Option<Vec<f32>> {
170        let ctx = executor.gpu_context()?;
171        self.weight.matmul_nt(ctx, x, m).ok()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn f16_conversion_matches_known_values() {
181        assert_eq!(f16_to_f32(0x0000), 0.0);
182        assert_eq!(f16_to_f32(0x3C00), 1.0);
183        assert_eq!(f16_to_f32(0xBC00), -1.0);
184        assert_eq!(f16_to_f32(0x4000), 2.0);
185        assert!((f16_to_f32(0x3555) - 0.333_251).abs() < 1e-5);
186    }
187
188    /// The split must agree with the dequantizer that is already verified
189    /// against ggml: rebuilding `scale * quant` has to reproduce what
190    /// `kopitiam_tensor` produces from the same bytes. This is the check that
191    /// makes the GPU weight trustworthy — the kernel can only be as right as
192    /// the numbers handed to it.
193    #[test]
194    fn split_then_recombine_matches_the_verified_dequantizer() {
195        // Two blocks: scale 1.0 and 0.5, quants spanning the signed range.
196        let mut bytes = Vec::new();
197        for (scale_bits, base) in [(0x3C00u16, 0i32), (0x3800u16, 1)] {
198            bytes.extend_from_slice(&scale_bits.to_le_bytes());
199            for t in 0..BLOCK {
200                bytes.push((((t as i32 * 17 + base) % 255) - 128) as i8 as u8);
201            }
202        }
203        let (scales, quants) = split_q8_0(&bytes, 1, BLOCK * 2).expect("split");
204        assert_eq!(scales.len(), 2);
205        assert_eq!(quants.len(), BLOCK * 2);
206        assert_eq!(scales[0], 1.0);
207        assert_eq!(scales[1], 0.5);
208
209        let dequantized = kopitiam_tensor::Tensor::from_quantized(
210            kopitiam_core::DType::Q8_0,
211            bytes.clone(),
212            [1, BLOCK * 2],
213        )
214        .expect("reference tensor")
215        .to_dtype(kopitiam_core::DType::F32)
216        .expect("reference dequantize")
217        .to_vec_f32()
218        .expect("reference values");
219        for i in 0..BLOCK * 2 {
220            let ours = scales[i / BLOCK] * f32::from(quants[i]);
221            assert!(
222                (ours - dequantized[i]).abs() < 1e-6,
223                "element {i}: split gives {ours}, reference dequantizer gives {}",
224                dequantized[i]
225            );
226        }
227    }
228
229    #[test]
230    fn a_column_count_that_is_not_a_whole_number_of_blocks_is_refused() {
231        let err = split_q8_0(&[0u8; 100], 1, 40).unwrap_err();
232        assert!(matches!(err, Error::MalformedModel { .. }));
233    }
234
235    #[test]
236    fn truncated_bytes_are_refused_rather_than_padded() {
237        let err = split_q8_0(&[0u8; 10], 1, BLOCK).unwrap_err();
238        assert!(matches!(err, Error::MalformedModel { .. }));
239    }
240}