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}