Skip to main content

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}