ftts_kernels/packed_gemm.rs
1//! Register-tiled, panel-packed f32 GEMM — the BLAS-shaped dense route for hosts with no BLAS.
2//!
3//! # Why this exists
4//!
5//! On macOS the f32 dense path issues the reference's own Accelerate SGEMM and is exact against
6//! the oracle. Off that platform — Linux, and above all **wasm, where no BLAS exists at all** —
7//! the same call degrades to a dot-product loop: for every output element, walk `k` and reduce.
8//! That formulation re-reads the entire activation row once per output column and gets no reuse
9//! out of the weights, which is why the browser codec measured 89.1 s of a 97.3 s frame (92%).
10//!
11//! This is the standard answer, and it is what every serious GEMM does: hold an `MR x NR` tile of
12//! the output in registers, stream one packed `k`-panel of the weights past it, and pay for each
13//! loaded weight `MR` times instead of once.
14//!
15//! # Why it is plain scalar Rust with no intrinsics
16//!
17//! Doctrine #3: hand-rolled wide SIMD over scalar inner loops measured ~5x SLOWER than LLVM
18//! autovectorization in the sibling repos. The inner loop below is a fixed-size `[[f32; NR]; MR]`
19//! accumulator updated by a broadcast scalar — precisely the shape LLVM turns into `NR/4` v128
20//! multiply-adds per row with no help. The structure is the lever; the instruction selection is
21//! the compiler's job.
22//!
23//! Ported from `franken_numpy/crates/fnp-linalg/src/lib.rs` (`packed_gemm_serial_tiled`), f64 to
24//! f32, with the packing adapted to this project's `[n, k]` weight layout.
25//!
26//! # Exactness
27//!
28//! **Bit-identical to the scalar reference**, and that is a design constraint rather than a happy
29//! accident. Each output element accumulates over ascending `k` into its own slot, one `f32` add
30//! at a time — the same values in the same order as [`crate::f32ref`]'s scalar dot product. No
31//! partial-sum splitting, no reassociation, no fused multiply-add. Blocking and packing change
32//! only WHICH element is computed WHEN, never how any single element is summed.
33//!
34//! That matters here more than speed: the current wasm path uses eight independent partial chains
35//! (a different, non-reference reduction order), so adopting this kernel moves the codec CLOSER to
36//! the reference while making it faster. `packed_matches_scalar_bit_for_bit` pins the claim.
37
38/// Rows of the output tile held in registers.
39///
40/// Four rows of eight `f32` is 32 accumulators — eight v128 registers on wasm, which fits the
41/// 16-register file with room for the operands. Wider tiles spill. Row remainders shorter than
42/// `MR` run one row at a time (`accumulate_tile::<1>`); there is no intermediate 2-row tile.
43const MR: usize = 4;
44
45/// Columns of the output tile held in registers: two full v128 lanes of `f32`.
46const NR: usize = 8;
47
48/// Target bytes for one packed weight panel, sized to sit in L2 alongside the activation rows.
49const PANEL_BYTES: usize = 256 * 1024;
50
51/// `out[m, n] = x[m, k] @ weight[n, k]^T + bias[n]`.
52///
53/// `weight` is the checkpoint's native `[out_channels, in_channels]` layout — each output row
54/// contiguous — so no transpose is ever materialized, matching the project's one GEMM contract.
55///
56/// # Panics
57///
58/// If the slice lengths disagree with `m`, `k`, `n`.
59pub fn linear_packed(
60 x: &[f32],
61 weight: &[f32],
62 bias: Option<&[f32]>,
63 m: usize,
64 k: usize,
65 n: usize,
66 out: &mut [f32],
67) {
68 assert_eq!(x.len(), m * k, "x must be [m, k]");
69 assert_eq!(weight.len(), n * k, "weight must be [n, k]");
70 assert_eq!(out.len(), m * n, "out must be [m, n]");
71 if let Some(bias) = bias {
72 assert_eq!(bias.len(), n, "bias must be [n]");
73 }
74 // SAFETY: `out` is a `&mut [f32]` of exactly `m * n`, and the full column range is requested,
75 // so every write below lands inside it. The borrow checker guarantees no other alias.
76 unsafe {
77 linear_packed_range(x, weight, bias, m, k, n, 0, n, out.as_mut_ptr());
78 }
79}
80
81/// Computes only output columns `col_start..col_end`, writing into a `[m, n]` buffer.
82///
83/// This is the shape the [`crate::team`] needs: each worker owns a disjoint column stripe and
84/// writes it in place, so no partition ever touches another's elements and no reduction is split
85/// across partitions. The result is bit-identical to the serial whole-matrix call, which is why
86/// threading this changes speed only — pinned by `column_partitions_are_bit_identical_to_the_whole`.
87///
88/// # Safety
89///
90/// `out` must be valid for writes of `m * n` floats, and no other reference may alias the columns
91/// `col_start..col_end` for the duration of the call.
92// The argument count is the GEMM contract itself — operands, the (m, k, n) shape, the column
93// stripe, and the destination. Bundling them into a struct would add a layer between the caller
94// and the hot loop without removing a single value, so the lint is allowed here deliberately,
95// matching `f32ref::gqa_attention_head_range_into`.
96#[allow(clippy::too_many_arguments)]
97// SAFETY: discharged by both callers. `linear_packed` passes the pointer of a `&mut [f32]` it
98// holds exclusively, with the full column range. The team passes one worker's disjoint stripe of a
99// buffer the dispatcher owns and blocks on until every partition reports done, so the allocation
100// outlives all writes and no two stripes address the same element.
101pub(crate) unsafe fn linear_packed_range(
102 x: &[f32],
103 weight: &[f32],
104 bias: Option<&[f32]>,
105 m: usize,
106 k: usize,
107 n: usize,
108 col_start: usize,
109 col_end: usize,
110 out: *mut f32,
111) {
112 // Seed this stripe with the bias so the tile accumulates in place.
113 for row in 0..m {
114 for column in col_start..col_end {
115 // SAFETY: `row < m` and `column < n` by the caller's contract.
116 unsafe {
117 *out.add(row * n + column) = bias.map_or(0.0, |values| values[column]);
118 }
119 }
120 }
121
122 if m == 0 || k == 0 || col_start >= col_end {
123 return; // bias-only result, already written
124 }
125
126 let columns = col_end - col_start;
127 let m_full = m - m % MR;
128 let n_full = col_start + columns - columns % NR;
129
130 // Columns per L2 panel block, sized so one packed panel plus its consumers stay resident; at
131 // least one panel always, however large `k` is. Named distinctly from the `columns` above,
132 // which is this stripe's WIDTH — reusing that name read as though a panel spanned the stripe.
133 let panel_columns = {
134 let fitting = PANEL_BYTES / (k.max(1) * size_of::<f32>());
135 (fitting / NR).max(1) * NR
136 };
137
138 // Thread-local scratch instead of a per-dispatch `vec!`: this function runs once per
139 // stripe per dispatch on the steady-state decode path, and the doctrine pins "no
140 // allocator activity in steady-state decode" as load-bearing. Each team worker (and the
141 // dispatcher) owns its thread's buffer, so there is no sharing to reason about; the
142 // buffer only ever grows, to the largest `k * NR` this thread has seen.
143 thread_local! {
144 static PANEL_SCRATCH: std::cell::RefCell<Vec<f32>> =
145 const { std::cell::RefCell::new(Vec::new()) };
146 }
147 PANEL_SCRATCH.with(|scratch| {
148 let mut panel_guard = scratch.borrow_mut();
149 if panel_guard.len() < k * NR {
150 panel_guard.resize(k * NR, 0.0);
151 }
152 let panel = &mut panel_guard[..k * NR];
153
154 let mut jc = col_start;
155 while jc < n_full {
156 let jc_end = (jc + panel_columns).min(n_full);
157 let mut j0 = jc;
158 while j0 < jc_end {
159 // Pack NR weight columns into k-major order.
160 //
161 // This is the one place the `[n, k]` layout costs something: the reference kernel
162 // copies a contiguous run, while here each of the NR sources is a separate row and the
163 // gather has stride `k`. It is paid once per panel and amortized over every one of the
164 // `m` rows that consume it, which is the entire point of packing.
165 for (column, offset) in (j0..j0 + NR).enumerate() {
166 let source = &weight[offset * k..offset * k + k];
167 for (depth, &value) in source.iter().enumerate() {
168 panel[depth * NR + column] = value;
169 }
170 }
171
172 let mut i0 = 0;
173 while i0 < m_full {
174 // SAFETY: rows `i0..i0+MR` are below `m` and columns `j0..j0+NR` are inside the
175 // caller's stripe, so every write lands within the `m * n` buffer.
176 unsafe { accumulate_tile::<MR>(x, panel, out, i0, j0, k, n) };
177 i0 += MR;
178 }
179 // Rows below the last full tile still benefit from the packed panel; run them one row
180 // at a time rather than dropping to the unpacked tail path.
181 for row in m_full..m {
182 // SAFETY: as above, with a single row.
183 unsafe { accumulate_tile::<1>(x, panel, out, row, j0, k, n) };
184 }
185 j0 += NR;
186 }
187 jc += panel_columns;
188 }
189
190 // Remainder columns: fewer than NR left over, so there is no panel to amortize and the plain
191 // ascending-k dot is both simplest and exact.
192 for row in 0..m {
193 let x_row = &x[row * k..row * k + k];
194 for column in n_full..col_end {
195 let w_row = &weight[column * k..column * k + k];
196 let mut sum = 0.0_f32;
197 for depth in 0..k {
198 sum += x_row[depth] * w_row[depth];
199 }
200 // SAFETY: `row < m`, `column < n`, inside the caller's buffer and stripe.
201 unsafe { *out.add(row * n + column) += sum };
202 }
203 }
204 });
205}
206
207/// Accumulates one `ROWS x NR` output tile from a packed weight panel.
208///
209/// Generic over `ROWS` so the full-tile and single-row cases share one body and one reduction
210/// order; a const generic keeps the accumulator a fixed-size array, which is what lets LLVM keep
211/// it in registers and vectorize the inner update.
212///
213/// # Safety
214///
215/// `out` must be valid for writes covering rows `i0..i0+ROWS` and columns `j0..j0+NR` of an
216/// `[m, n]` matrix.
217// SAFETY: both call sites sit inside `linear_packed_range`'s loops, where `i0 + ROWS <= m` and
218// `j0 + NR <= n_full <= col_end` hold by the loop bounds, so every tile lies inside the caller's
219// stripe and therefore inside its `m * n` buffer.
220#[inline]
221unsafe fn accumulate_tile<const ROWS: usize>(
222 x: &[f32],
223 panel: &[f32],
224 out: *mut f32,
225 i0: usize,
226 j0: usize,
227 k: usize,
228 n: usize,
229) {
230 let mut acc = [[0.0_f32; NR]; ROWS];
231 for depth in 0..k {
232 let weights = &panel[depth * NR..depth * NR + NR];
233 for (row, slots) in acc.iter_mut().enumerate() {
234 // One activation value, broadcast across NR weights: the multiply-add LLVM widens.
235 let value = x[(i0 + row) * k + depth];
236 for (slot, &weight) in slots.iter_mut().zip(weights) {
237 *slot += value * weight;
238 }
239 }
240 }
241 for (row, slots) in acc.iter().enumerate() {
242 let base = (i0 + row) * n + j0;
243 for (column, &value) in slots.iter().enumerate() {
244 // SAFETY: the caller guarantees this tile lies inside the output matrix.
245 unsafe { *out.add(base + column) += value };
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 /// The reference this kernel must reproduce exactly: ascending-k scalar dot per element.
255 fn scalar_reference(
256 x: &[f32],
257 weight: &[f32],
258 bias: Option<&[f32]>,
259 m: usize,
260 k: usize,
261 n: usize,
262 ) -> Vec<f32> {
263 let mut out = vec![0.0_f32; m * n];
264 for row in 0..m {
265 for column in 0..n {
266 let mut sum = 0.0_f32;
267 for depth in 0..k {
268 sum += x[row * k + depth] * weight[column * k + depth];
269 }
270 out[row * n + column] = bias.map_or(sum, |b| sum + b[column]);
271 }
272 }
273 out
274 }
275
276 fn deterministic(count: usize, seed: u64) -> Vec<f32> {
277 let mut state = seed | 1;
278 (0..count)
279 .map(|_| {
280 state ^= state << 13;
281 state ^= state >> 7;
282 state ^= state << 17;
283 // Spread across a wide exponent range so any reassociation would show up: f32
284 // addition is only non-associative when magnitudes differ.
285 ((state >> 40) as f32 / 2048.0) - 0.5
286 })
287 .collect()
288 }
289
290 #[test]
291 fn packed_matches_scalar_bit_for_bit() {
292 // Shapes chosen to exercise every boundary the blocking can get wrong: m and n both above
293 // and below the tile, exact multiples, one-off remainders, k = 0 and k = 1, and a k large
294 // enough to force more than one column panel.
295 let shapes = [
296 (1, 1, 1),
297 (1, 16, 8),
298 (3, 5, 7),
299 (4, 8, 8),
300 (5, 9, 9),
301 (8, 64, 16),
302 (7, 128, 13),
303 (16, 512, 32),
304 (2, 0, 4),
305 (4, 1, 8),
306 (9, 1024, 24),
307 ];
308 for (index, &(m, k, n)) in shapes.iter().enumerate() {
309 let x = deterministic(m * k, 0x51ED_0000 + index as u64);
310 let weight = deterministic(n * k, 0xA113_0000 + index as u64);
311 let bias = deterministic(n, 0xB1A5_0000 + index as u64);
312
313 for carry_bias in [None, Some(&bias[..])] {
314 let expected = scalar_reference(&x, &weight, carry_bias, m, k, n);
315 let mut actual = vec![0.0_f32; m * n];
316 linear_packed(&x, &weight, carry_bias, m, k, n, &mut actual);
317 assert_eq!(
318 actual.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
319 expected.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
320 "m={m} k={k} n={n} bias={}: packed GEMM diverged from the scalar reference",
321 carry_bias.is_some()
322 );
323 }
324 }
325 }
326
327 /// Every partition count reproduces the serial bits at real codec geometry.
328 ///
329 /// This is the law the team dispatch rests on. It runs the SAME stripe function the workers
330 /// run, at the codec's binding worst case (`block_00`, 1024 -> 1536 with kernel 7, so
331 /// K = 7168), and at the transformer's shapes — because a partitioning that is exact at toy
332 /// sizes and wrong at NR boundaries is exactly the bug that would ship.
333 #[test]
334 fn every_partition_count_reproduces_the_serial_bits() {
335 let shapes = [
336 (32, 7168, 1536),
337 (72, 512, 512),
338 (48, 512, 1024),
339 (17, 96, 40),
340 ];
341 for (index, &(m, k, n)) in shapes.iter().enumerate() {
342 let x = deterministic(m * k, 0x9E11_0000 + index as u64);
343 let weight = deterministic(n * k, 0x7A31_0000 + index as u64);
344 let bias = deterministic(n, 0x1CE5_0000 + index as u64);
345
346 let mut serial = vec![0.0_f32; m * n];
347 linear_packed(&x, &weight, Some(&bias), m, k, n, &mut serial);
348
349 for partitions in [1, 2, 3, 5, 6, 8] {
350 let mut parallel = vec![0.0_f32; m * n];
351 // Exactly the stripe arithmetic in `run_f32_linear_partition`.
352 let chunk = n.div_ceil(partitions).next_multiple_of(NR);
353 for worker in 0..partitions {
354 let start = (worker * chunk).min(n);
355 let end = ((worker + 1) * chunk).min(n);
356 if start >= end {
357 continue;
358 }
359 // SAFETY: stripes are disjoint and inside the m*n buffer.
360 unsafe {
361 linear_packed_range(
362 &x,
363 &weight,
364 Some(&bias),
365 m,
366 k,
367 n,
368 start,
369 end,
370 parallel.as_mut_ptr(),
371 );
372 }
373 }
374 assert_eq!(
375 parallel.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
376 serial.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
377 "m={m} k={k} n={n} partitions={partitions}"
378 );
379 }
380 }
381 }
382
383 #[test]
384 fn column_partitions_are_bit_identical_to_the_whole() {
385 // The property the KernelTeam relies on: computing a disjoint column range in isolation
386 // yields exactly the bits the full call would have written there. True because no
387 // reduction crosses a column.
388 let (m, k, n) = (6, 96, 24);
389 let x = deterministic(m * k, 0xC0F1);
390 let weight = deterministic(n * k, 0xD00D);
391 let mut whole = vec![0.0_f32; m * n];
392 linear_packed(&x, &weight, None, m, k, n, &mut whole);
393
394 for split in [8, 16] {
395 let columns = split;
396 let slice: Vec<f32> = weight[..columns * k].to_vec();
397 let mut part = vec![0.0_f32; m * columns];
398 linear_packed(&x, &slice, None, m, k, columns, &mut part);
399 for row in 0..m {
400 for column in 0..columns {
401 assert_eq!(
402 part[row * columns + column].to_bits(),
403 whole[row * n + column].to_bits(),
404 "split={split} row={row} column={column}"
405 );
406 }
407 }
408 }
409 }
410}