Skip to main content

kopitiam_gpu/ops/
matmul_nt.rs

1//! `y = x @ w^T` — the matmul a transformer linear layer actually performs.
2//!
3//! `x` is `[m, k]`, `w` is `[n, k]` (GGUF's `[out_features, in_features]`
4//! row-major convention), `y` is `[m, n]`. This is the shape
5//! `kopitiam_runtime::linear` computes, and the reason it is worth a GPU kernel
6//! at all: the attention projections (q/k/v/o), the MLP's gate/up/down, and the
7//! output projection are where a decoder-only model spends nearly all of its
8//! time.
9//!
10//! # Where offload pays, and where it costs — measured, not assumed
11//!
12//! This op uploads `w` on **every call**, so whether it helps depends entirely
13//! on how much arithmetic that upload is amortised over. Measured on an Intel
14//! integrated GPU against 14 CPU cores, at SmolLM2-360M's real projection
15//! shapes (`tests/matmul_timing.rs`, timings INCLUDE upload and readback):
16//!
17//! ```text
18//! decode  attn q/o     (1 tok)   cpu   443µs   gpu 1.88ms   0.24x   LOSS
19//! decode  mlp gate/up  (1 tok)   cpu  1.28ms   gpu 3.20ms   0.40x   LOSS
20//! decode  mlp down     (1 tok)   cpu  1.29ms   gpu 2.86ms   0.45x   LOSS
21//! decode  output head  (1 tok)   cpu 25.67ms   n/a — exceeds binding limit
22//! prefill attn q/o    (33 tok)   cpu 12.89ms   gpu 3.72ms   3.46x   WIN
23//! prefill mlp gate/up (33 tok)   cpu 40.83ms   gpu 7.77ms   5.26x   WIN
24//! ```
25//!
26//! The split is the whole story: **a decode step is one row of activations**,
27//! far too little work to pay for moving a weight matrix, while a 33-token
28//! prefill does 33x the arithmetic against the same upload and wins outright.
29//! Anyone wiring this into a forward pass should offload prefill and leave
30//! decode alone; doing it uniformly would make chat 2-4x *slower*, which is the
31//! opposite of the thing that prompted the work.
32//!
33//! Two further facts that shape the real design:
34//!
35//! * **The most expensive decode op cannot run here at all.** The output head
36//!   is `49152 x 960` — 188 MB as `f32`, against a 128 MB
37//!   `max_storage_buffer_binding_size` on this adapter — and at 25.67 ms it
38//!   dominates every other decode matmul combined. Getting *it* onto the GPU
39//!   needs the weight kept **quantized** on the device (Q8_0 is ~47 MB, which
40//!   fits), not merely resident.
41//! * Making decode pay at all needs the weight resident across calls. That is
42//!   deliberately a separate change: a resident-weight cache is an
43//!   ownership/lifetime design question, not something to smuggle in alongside
44//!   a first kernel.
45//!
46//! So treat this as the correctness-checked kernel and building block, with the
47//! numbers above as the map of where to point it.
48
49use crate::context::GpuContext;
50use crate::executor::{ComputeOp, GpuOpError};
51use wgpu::util::DeviceExt;
52
53/// Must match `@workgroup_size(16, 16)` in `shaders/matmul_nt.wgsl`.
54const WORKGROUP_X: u32 = 16;
55const WORKGROUP_Y: u32 = 16;
56
57/// `x` `[m, k]` times the transpose of `w` `[n, k]`.
58///
59/// `k` is carried explicitly rather than inferred, because inferring it from
60/// `x.len() / m` would silently accept a ragged input and produce a plausible
61/// wrong answer instead of an error.
62pub struct MatmulNtInput<'a> {
63    pub x: &'a [f32],
64    pub w: &'a [f32],
65    pub m: usize,
66    pub k: usize,
67    pub n: usize,
68}
69
70/// `y = x @ w^T`. Zero-sized; it names the operation for the [`ComputeOp`] impl.
71pub struct MatmulNt;
72
73impl ComputeOp for MatmulNt {
74    type Input<'a> = MatmulNtInput<'a>;
75    type Output = Vec<f32>;
76
77    fn compute_gpu(
78        &self,
79        ctx: &GpuContext,
80        input: &Self::Input<'_>,
81    ) -> Result<Self::Output, GpuOpError> {
82        matmul_nt_gpu(ctx, input.x, input.w, input.m, input.k, input.n)
83    }
84
85    fn compute_cpu(&self, input: &Self::Input<'_>) -> Self::Output {
86        matmul_nt_cpu(input.x, input.w, input.m, input.k, input.n)
87    }
88}
89
90/// The pure-Rust twin, and the floor of the cascade.
91///
92/// Sums in index order, matching the WGSL exactly, so the two paths are
93/// comparable rather than merely both "about right". Returns an all-zero
94/// `[m, n]` if the inputs are too short — the GPU path rejects that case as
95/// [`GpuOpError::InvalidInput`], and callers should not rely on either
96/// behaviour; validate before calling.
97#[must_use]
98pub fn matmul_nt_cpu(x: &[f32], w: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> {
99    let mut y = vec![0f32; m * n];
100    if x.len() < m * k || w.len() < n * k {
101        return y;
102    }
103    for i in 0..m {
104        let x_row = &x[i * k..i * k + k];
105        for j in 0..n {
106            let w_row = &w[j * k..j * k + k];
107            let mut acc = 0f32;
108            for t in 0..k {
109                acc += x_row[t] * w_row[t];
110            }
111            y[i * n + j] = acc;
112        }
113    }
114    y
115}
116
117/// The wgpu compute path. Any wgpu-level failure returns `Err` so
118/// [`crate::Executor`] falls back to [`matmul_nt_cpu`].
119///
120/// Mirrors `shaders/matmul_nt.wgsl` binding for binding: `x`, `w`, `y`, and a
121/// `dims` uniform carrying `(m, k, n)`. The dispatch is
122/// `ceil(m/16) x ceil(n/16)` workgroups; the shader guards the rounded-up tail.
123pub fn matmul_nt_gpu(
124    ctx: &GpuContext,
125    x: &[f32],
126    w: &[f32],
127    m: usize,
128    k: usize,
129    n: usize,
130) -> Result<Vec<f32>, GpuOpError> {
131    if x.len() != m * k {
132        return Err(GpuOpError::InvalidInput(format!(
133            "x has {} elements, expected m*k = {}*{} = {}",
134            x.len(),
135            m,
136            k,
137            m * k
138        )));
139    }
140    if w.len() != n * k {
141        return Err(GpuOpError::InvalidInput(format!(
142            "w has {} elements, expected n*k = {}*{} = {}",
143            w.len(),
144            n,
145            k,
146            n * k
147        )));
148    }
149    // Zero-sized buffers are a validation error on some backends, and an empty
150    // dispatch computes nothing anyway.
151    if m == 0 || n == 0 || k == 0 {
152        return Ok(vec![0f32; m * n]);
153    }
154
155    let device = ctx.device();
156    let queue = ctx.queue();
157    let out_bytes = (m * n * std::mem::size_of::<f32>()) as wgpu::BufferAddress;
158
159    // Refuse anything the adapter cannot bind, BEFORE asking wgpu to do it.
160    //
161    // This is not defensive padding: wgpu treats an over-limit binding as a
162    // validation error and **panics** rather than returning `Err`, which would
163    // abort the process instead of cascading to the CPU twin. And the limit is
164    // reachable with ordinary weights — SmolLM2-360M's output head is
165    // 49152 x 960 f32 = 188 MB against a 128 MB
166    // `max_storage_buffer_binding_size` on this Intel adapter, so the single
167    // largest matmul in the model is exactly the one that blows it.
168    let limit = device.limits().max_storage_buffer_binding_size;
169    let biggest = [
170        (m * k * std::mem::size_of::<f32>()) as u64,
171        (n * k * std::mem::size_of::<f32>()) as u64,
172        out_bytes,
173    ]
174    .into_iter()
175    .max()
176    .unwrap_or(0);
177    if biggest > limit {
178        return Err(GpuOpError::InvalidInput(format!(
179            "matmul {m}x{k}x{n} needs a {biggest}-byte storage binding but this \
180             adapter's max_storage_buffer_binding_size is {limit}; falling back to CPU"
181        )));
182    }
183
184    let x_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
185        label: Some("matmul_nt.x"),
186        contents: bytemuck::cast_slice(x),
187        usage: wgpu::BufferUsages::STORAGE,
188    });
189    let w_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
190        label: Some("matmul_nt.w"),
191        contents: bytemuck::cast_slice(w),
192        usage: wgpu::BufferUsages::STORAGE,
193    });
194    // WGSL's `Dims` is four u32s; the fourth is padding so the struct meets the
195    // 16-byte alignment a uniform buffer requires.
196    let dims: [u32; 4] = [m as u32, k as u32, n as u32, 0];
197    let dims_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
198        label: Some("matmul_nt.dims"),
199        contents: bytemuck::cast_slice(&dims),
200        usage: wgpu::BufferUsages::UNIFORM,
201    });
202    let y_buf = device.create_buffer(&wgpu::BufferDescriptor {
203        label: Some("matmul_nt.y"),
204        size: out_bytes,
205        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
206        mapped_at_creation: false,
207    });
208    let staging = device.create_buffer(&wgpu::BufferDescriptor {
209        label: Some("matmul_nt.staging"),
210        size: out_bytes,
211        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
212        mapped_at_creation: false,
213    });
214
215    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
216        label: Some("matmul_nt.wgsl"),
217        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/matmul_nt.wgsl").into()),
218    });
219    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
220        label: Some("matmul_nt.pipeline"),
221        layout: None,
222        module: &shader,
223        entry_point: Some("main"),
224        compilation_options: wgpu::PipelineCompilationOptions::default(),
225        cache: None,
226    });
227
228    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
229        label: Some("matmul_nt.bind_group"),
230        layout: &pipeline.get_bind_group_layout(0),
231        entries: &[
232            wgpu::BindGroupEntry { binding: 0, resource: x_buf.as_entire_binding() },
233            wgpu::BindGroupEntry { binding: 1, resource: w_buf.as_entire_binding() },
234            wgpu::BindGroupEntry { binding: 2, resource: y_buf.as_entire_binding() },
235            wgpu::BindGroupEntry { binding: 3, resource: dims_buf.as_entire_binding() },
236        ],
237    });
238
239    let mut encoder =
240        device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("matmul_nt") });
241    {
242        let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
243            label: Some("matmul_nt.pass"),
244            timestamp_writes: None,
245        });
246        pass.set_pipeline(&pipeline);
247        pass.set_bind_group(0, &bind_group, &[]);
248        pass.dispatch_workgroups(
249            (m as u32).div_ceil(WORKGROUP_X),
250            (n as u32).div_ceil(WORKGROUP_Y),
251            1,
252        );
253    }
254    encoder.copy_buffer_to_buffer(&y_buf, 0, &staging, 0, out_bytes);
255    queue.submit(Some(encoder.finish()));
256
257    let slice = staging.slice(..);
258    let (tx, rx) = std::sync::mpsc::channel();
259    slice.map_async(wgpu::MapMode::Read, move |res| {
260        let _ = tx.send(res);
261    });
262    device
263        .poll(wgpu::PollType::wait_indefinitely())
264        .map_err(|e| GpuOpError::Backend(format!("device poll failed: {e:?}")))?;
265    rx.recv()
266        .map_err(|e| GpuOpError::Backend(format!("map callback dropped: {e}")))?
267        .map_err(|e| GpuOpError::Backend(format!("buffer map failed: {e:?}")))?;
268
269    let data = slice
270        .get_mapped_range()
271        .map_err(|e| GpuOpError::Backend(format!("get_mapped_range failed: {e:?}")))?;
272    let result: Vec<f32> = bytemuck::cast_slice(&data).to_vec();
273    drop(data);
274    staging.unmap();
275
276    Ok(result)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::Executor;
283
284    /// Deterministic pseudo-random fill — no `rand` dependency, and the same
285    /// values on every run so a failure is reproducible.
286    fn fill(n: usize, seed: f32) -> Vec<f32> {
287        (0..n).map(|i| (i as f32 * 0.37 + seed).sin()).collect()
288    }
289
290    #[test]
291    fn cpu_matches_a_hand_computed_product() {
292        // x = [[1, 2], [3, 4]]  (m=2, k=2)
293        // w = [[5, 6], [7, 8]]  (n=2, k=2)  -> w^T = [[5, 7], [6, 8]]
294        // y = [[1*5+2*6, 1*7+2*8], [3*5+4*6, 3*7+4*8]] = [[17, 23], [39, 53]]
295        let y = matmul_nt_cpu(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0], 2, 2, 2);
296        assert_eq!(y, vec![17.0, 23.0, 39.0, 53.0]);
297    }
298
299    /// A non-square case, because square shapes hide index-order bugs: a kernel
300    /// that transposed its output would still pass a symmetric test.
301    #[test]
302    fn cpu_handles_non_square_shapes() {
303        // x [2,3], w [4,3] -> y [2,4]
304        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
305        let w: Vec<f32> = (1..=12).map(|v| v as f32).collect();
306        let y = matmul_nt_cpu(&x, &w, 2, 3, 4);
307        assert_eq!(y.len(), 8);
308        // y[0][0] = 1*1 + 2*2 + 3*3 = 14; y[1][3] = 4*10 + 5*11 + 6*12 = 167
309        assert_eq!(y[0], 14.0);
310        assert_eq!(y[7], 167.0);
311    }
312
313    #[test]
314    fn gpu_rejects_ragged_inputs_instead_of_guessing() {
315        let Ok(ctx) = crate::GpuContext::new() else {
316            eprintln!("skipped: no GPU on this machine");
317            return;
318        };
319        let err = matmul_nt_gpu(&ctx, &[1.0, 2.0], &[1.0, 2.0], 2, 2, 1).unwrap_err();
320        assert!(matches!(err, GpuOpError::InvalidInput(_)), "got {err:?}");
321    }
322
323    /// The property that matters: the GPU kernel and the CPU twin agree, at the
324    /// shapes a real transformer uses, not just on a toy 2x2.
325    ///
326    /// Tolerance is relative and small but not zero — both paths sum in index
327    /// order, so they should agree closely, but a GPU may still contract
328    /// `a*b + c` into an FMA and change the last bit.
329    #[test]
330    fn gpu_matches_cpu_at_real_transformer_shapes() {
331        let Ok(ctx) = crate::GpuContext::new() else {
332            eprintln!("skipped: no GPU on this machine");
333            return;
334        };
335        // (m, k, n): a 33-token prefill and a 1-token decode step through
336        // SmolLM2-360M's actual projections — attention q/o (960x960), the MLP
337        // gate/up (960 -> 2560) and down (2560 -> 960).
338        for &(m, k, n) in &[(33usize, 960usize, 960usize), (1, 960, 2560), (33, 2560, 960), (1, 64, 64)] {
339            let x = fill(m * k, 0.1);
340            let w = fill(n * k, 0.7);
341            let gpu = matmul_nt_gpu(&ctx, &x, &w, m, k, n).expect("gpu matmul");
342            let cpu = matmul_nt_cpu(&x, &w, m, k, n);
343            assert_eq!(gpu.len(), cpu.len(), "shape {m}x{k}x{n}");
344
345            let mut worst = 0f32;
346            for (g, c) in gpu.iter().zip(&cpu) {
347                worst = worst.max((g - c).abs());
348            }
349            let scale = cpu.iter().fold(0f32, |a, v| a.max(v.abs())).max(1e-6);
350            assert!(
351                worst / scale < 1e-4,
352                "shape {m}x{k}x{n}: GPU and CPU disagree, worst {worst} (relative {})",
353                worst / scale
354            );
355        }
356    }
357
358    /// The cascade must produce the same answer whichever way it went, so a
359    /// machine with no GPU is not quietly running different maths.
360    #[test]
361    fn the_executor_cascade_agrees_with_the_forced_cpu_path() {
362        let (m, k, n) = (8usize, 64usize, 32usize);
363        let x = fill(m * k, 0.3);
364        let w = fill(n * k, 0.9);
365        let input = MatmulNtInput { x: &x, w: &w, m, k, n };
366
367        let cascade = Executor::new().run(&MatmulNt, &input);
368        let cpu_only = Executor::cpu_only().run(&MatmulNt, &input);
369        assert_eq!(cascade.len(), cpu_only.len());
370        let worst = cascade
371            .iter()
372            .zip(&cpu_only)
373            .fold(0f32, |acc, (a, b)| acc.max((a - b).abs()));
374        let scale = cpu_only.iter().fold(0f32, |a, v| a.max(v.abs())).max(1e-6);
375        assert!(worst / scale < 1e-4, "cascade disagreed with CPU: worst {worst}");
376    }
377}
378
379#[cfg(test)]
380mod limit_tests {
381    use super::*;
382
383    /// A weight too large for the adapter's binding limit must come back as an
384    /// error the cascade can catch — NOT a panic.
385    ///
386    /// wgpu reports an over-limit binding as a validation error and aborts the
387    /// process, so without the up-front check this exact shape (SmolLM2-360M's
388    /// output head, 49152 x 960 f32 = 188 MB) would kill the caller instead of
389    /// quietly running on the CPU. Found by benchmarking, not by review.
390    #[test]
391    fn an_oversized_weight_falls_back_instead_of_panicking() {
392        let Ok(ctx) = crate::GpuContext::new() else {
393            eprintln!("skipped: no GPU on this machine");
394            return;
395        };
396        let limit = ctx.device().limits().max_storage_buffer_binding_size as usize;
397        let k = 960usize;
398        // One row past the limit, so this is over on every adapter rather than
399        // relying on any particular device's numbers.
400        let n = limit / (k * std::mem::size_of::<f32>()) + 1;
401
402        // Allocating the host-side weight would cost the same memory, so assert
403        // on the guard's arithmetic via a deliberately short slice: the length
404        // check fires first for a ragged input, so pass a correctly-sized `x`
405        // and a `w` that is correctly sized but oversized for the device.
406        let x = vec![0.0f32; k];
407        let w = vec![0.0f32; n * k];
408        let err = matmul_nt_gpu(&ctx, &x, &w, 1, k, n).expect_err("must refuse, not panic");
409        assert!(
410            matches!(err, GpuOpError::InvalidInput(ref m) if m.contains("max_storage_buffer_binding_size")),
411            "expected a binding-size refusal, got {err:?}"
412        );
413
414        // And the cascade must then still produce the right answer on CPU.
415        let out = crate::Executor::new().run(&MatmulNt, &MatmulNtInput { x: &x, w: &w, m: 1, k, n });
416        assert_eq!(out.len(), n);
417    }
418}