Skip to main content

trueno/blis/
parallel.rs

1//! Parallel GEMM with Heijunka (load-leveling) scheduling.
2//!
3//! Uses Rayon for parallel execution when the `parallel` feature is enabled,
4//! with balanced M-dimension partitioning via [`HeijunkaScheduler`].
5
6use crate::error::TruenoError;
7
8use super::compute::{gemm_blis, gemm_blis_with_prepacked_b};
9use super::prepacked::PrepackedB;
10#[cfg(feature = "parallel")]
11use super::{MC, MR};
12
13/// Heijunka (load-leveling) scheduler for parallel GEMM
14#[derive(Debug, Clone)]
15pub struct HeijunkaScheduler {
16    /// Number of threads
17    pub num_threads: usize,
18    /// Target load variance threshold
19    pub variance_threshold: f32,
20}
21
22impl Default for HeijunkaScheduler {
23    fn default() -> Self {
24        #[cfg(feature = "parallel")]
25        let threads = rayon::current_num_threads();
26        #[cfg(not(feature = "parallel"))]
27        let threads = 1;
28
29        Self {
30            num_threads: threads,
31            variance_threshold: 0.05, // 5% variance target
32        }
33    }
34}
35
36impl HeijunkaScheduler {
37    /// Partition M dimension into balanced chunks
38    pub fn partition_m(&self, m: usize, mc: usize) -> Vec<std::ops::Range<usize>> {
39        let num_blocks = (m + mc - 1) / mc;
40        let blocks_per_thread = num_blocks / self.num_threads;
41        let remainder = num_blocks % self.num_threads;
42
43        let mut partitions = Vec::with_capacity(self.num_threads);
44        let mut start_block = 0;
45
46        for t in 0..self.num_threads {
47            let extra = if t < remainder { 1 } else { 0 };
48            let thread_blocks = blocks_per_thread + extra;
49
50            let start_row = start_block * mc;
51            let end_row = ((start_block + thread_blocks) * mc).min(m);
52
53            if start_row < end_row {
54                partitions.push(start_row..end_row);
55            }
56
57            start_block += thread_blocks;
58        }
59
60        partitions
61    }
62}
63
64/// Whether a GEMM of these dims should run serially instead of via the rayon
65/// parallel path. Pure + unit-testable so the dispatch policy can't silently
66/// regress. Serial when: tiny (`<8M` FLOP — rayon ~3µs dispatch dominates) OR a
67/// THIN NN-scale GEMM (`8M..64M` FLOP with `n < 192`), where the parallel path's
68/// per-thread B-packing + dispatch was measured 2.2x SLOWER than serial
69/// (2026-06-13, the `[1024x256]@[256x128]` MLP-layer shape — NN forward/backward
70/// is ~all such thin GEMMs). Square sub-64M (`n >= 192`) still parallelizes
71/// (~1.24x, cgp 2026-04-05). Falsifier: `tests::nn_thin_gemm_prefers_serial`.
72#[cfg(feature = "parallel")]
73pub(crate) fn gemm_should_run_serial(m: usize, n: usize, k: usize) -> bool {
74    let flops = m * n * k;
75    flops < 8_000_000 || (flops < 64_000_000 && n < 192)
76}
77
78/// Parallel BLIS GEMM using Rayon
79#[cfg(feature = "parallel")]
80pub fn gemm_blis_parallel(
81    m: usize,
82    n: usize,
83    k: usize,
84    a: &[f32],
85    b: &[f32],
86    c: &mut [f32],
87) -> Result<(), TruenoError> {
88    use rayon::prelude::*;
89    contract_pre_amdahl_speedup!();
90
91    // Dimension validation
92    if a.len() != m * k || b.len() != k * n || c.len() != m * n {
93        return Err(TruenoError::InvalidInput("Dimension mismatch".to_string()));
94    }
95
96    // Single-threaded threshold: 8M FLOPs ≈ 200³.
97    // Rayon dispatch costs ~3µs. For GEMM ≤128 (~4M FLOP, ~35µs compute),
98    // rayon overhead dominates. GEMM 256+ (33M FLOP, ~300µs) benefits.
99    let flops = m * n * k;
100    if gemm_should_run_serial(m, n, k) {
101        return gemm_blis(m, n, k, a, b, c, None);
102    }
103
104    // Scale thread count to problem size and cache topology.
105    // cgp profile scaling measurements (2026-04-05, Threadripper 7960X 24C/48T):
106    //
107    //   256x256: 1T=27.8, 2T=34.5 (peak), 4T=35.2 → cap at 2
108    //   512x512: 1T=82.6, 4T=176 (peak), 8T=158 → cap at 4
109    //   1024x1024: 1T=106, 8T=489 (peak), 12T=417, 16T=450, 24T=426 → cap at 8
110    //
111    // Root cause for small-problem regression: L3 contention and thread spawn
112    // overhead (~40µs per thread::scope) dominate when compute < 1ms.
113    // Root cause for 1024 12T regression: cross-CCD L3 thrashing. 8T fits
114    // in a single CCD (12 cores, 32MB L3). 12+ threads span both CCDs.
115    let phys_cores = num_cpus::get_physical();
116    let max_threads = if flops < 64_000_000 {
117        // 256³ and below: barely benefits from parallelism
118        2.min(phys_cores)
119    } else if flops < 512_000_000 {
120        // 512³ range: 4T is peak, >4 regresses due to L3 contention
121        4.min(phys_cores)
122    } else if flops < 4_000_000_000 {
123        // 1024³ range (~2B FLOPs): 8T is empirical peak (626 GFLOPS).
124        // 12T regresses to 559 GFLOPS due to cross-CCD L3 thrashing — each thread
125        // independently packs B, and 12 copies × ~1MB packed_b exceeds one CCD's
126        // 32MB L3 share. Capping at 8 keeps all threads on one CCD.
127        // Measured 2026-04-05 on Threadripper 7960X (2 CCDs × 12 cores).
128        8.min(phys_cores)
129    } else {
130        // Very large (>4B FLOPs): use phys_cores/2 (one thread per CCD core).
131        // Beyond phys_cores/2, SMT contention regresses AVX-512 throughput.
132        (phys_cores / 2).max(8).min(phys_cores)
133    };
134
135    let mut scheduler = HeijunkaScheduler::default();
136    scheduler.num_threads = scheduler.num_threads.min(max_threads);
137    let ps = if m <= MC { MR.max(m / scheduler.num_threads) } else { MC };
138    let partitions = scheduler.partition_m(m, ps);
139
140    // NEGATIVE RESULT (2026-04-06): shared-B per (jc,pc) block REGRESSED 597→318 GFLOPS.
141    // Root cause: Rayon barrier after each K-tile pack forces thread synchronization.
142    // With K=4 tiles for 1024×1024, threads stall 4× per GEMM waiting for B pack.
143    // Per-thread independent packing (below) avoids synchronization entirely.
144    // The 8× redundant B packing (~8MB) fits in L3 (64MB) and eliminates barriers.
145    // Future fix: producer-consumer B packing (one thread packs while others compute).
146    let c_ptr = c.as_mut_ptr() as usize;
147
148    partitions.into_par_iter().for_each(|m_range| {
149        let m_local = m_range.len();
150        let m_start = m_range.start;
151
152        let a_local = &a[m_start * k..(m_start + m_local) * k];
153
154        // SAFETY: Each thread accesses a disjoint row range of C.
155        let c_local = unsafe {
156            let ptr = c_ptr as *mut f32;
157            std::slice::from_raw_parts_mut(ptr.add(m_start * n), m_local * n)
158        };
159
160        let _ = gemm_blis(m_local, n, k, a_local, b, c_local, None);
161    });
162
163    Ok(())
164}
165
166/// Parallel GEMM with shared packed-B: pack B once per (jc,pc) block,
167/// distribute M-slices across threads. Each thread only packs its own A.
168/// This eliminates O(threads) redundant B packings.
169///
170/// BLIS loop structure:
171///   for jc (N tiles):      ← sequential
172///     for pc (K tiles):    ← sequential, pack B ONCE
173///       for ic (M tiles):  ← PARALLEL across threads
174///         pack A_local
175///         microkernel(packed_a, shared_packed_b, c_local)
176#[cfg(feature = "parallel")]
177pub fn gemm_blis_parallel_shared_b(
178    m: usize,
179    n: usize,
180    k: usize,
181    a: &[f32],
182    b: &[f32],
183    c: &mut [f32],
184) -> Result<(), TruenoError> {
185    use rayon::prelude::*;
186
187    if a.len() != m * k || b.len() != k * n || c.len() != m * n {
188        return Err(TruenoError::InvalidInput("Dimension mismatch".to_string()));
189    }
190
191    // For small problems, use single-thread path
192    let flops = m * n * k;
193    if flops < 8_000_000 {
194        return gemm_blis(m, n, k, a, b, c, None);
195    }
196
197    // Require AVX-512 for the 8×32 microkernel
198    #[cfg(target_arch = "x86_64")]
199    if !std::arch::is_x86_feature_detected!("avx512f") {
200        return gemm_blis(m, n, k, a, b, c, None);
201    }
202
203    let phys_cores = num_cpus::get_physical();
204    let max_threads = if flops < 64_000_000 {
205        2.min(phys_cores)
206    } else if flops < 512_000_000 {
207        4.min(phys_cores)
208    } else if flops < 4_000_000_000 {
209        // Shared-B means less L3 pressure per thread, so we can potentially
210        // use more threads than the per-thread-B path. Try phys_cores/2.
211        (phys_cores / 2).max(8).min(phys_cores)
212    } else {
213        (phys_cores / 2).max(8).min(phys_cores)
214    };
215
216    let blk = super::cache_topology::blocking_8x32();
217    let mr = blk.mr; // 8
218    let nr = blk.nr; // 32
219    let mc = blk.mc.min(m);
220    let nc = blk.nc.min(n);
221    let kc = blk.kc;
222
223    // Shared packed B: one allocation for the largest B panel
224    let b_panels = (nc + nr - 1) / nr;
225    let packed_b_size = b_panels * nr * kc;
226    let mut packed_b = vec![0.0f32; packed_b_size];
227
228    let c_ptr = c.as_mut_ptr() as usize;
229    let num_threads = max_threads.min(rayon::current_num_threads());
230
231    for jc in (0..n).step_by(nc) {
232        let nc_block = nc.min(n - jc);
233
234        for pc in (0..k).step_by(kc) {
235            let kc_block = kc.min(k - pc);
236
237            // Pack B ONCE (sequential) — shared by all threads
238            super::compute::pack_b_block_generic(
239                b,
240                n,
241                pc,
242                jc,
243                kc_block,
244                nc_block,
245                nr,
246                &mut packed_b,
247            );
248            let shared_b: &[f32] = &packed_b;
249
250            // Parallel ic loop: each thread gets a slice of M
251            let m_per_thread = ((m + num_threads - 1) / num_threads + mr - 1) / mr * mr;
252
253            (0..num_threads).into_par_iter().for_each(|tid| {
254                let ic_start = tid * m_per_thread;
255                if ic_start >= m {
256                    return;
257                }
258                let ic_end = (ic_start + m_per_thread).min(m);
259
260                // Thread-local packed A — reuse across (jc, pc) iterations
261                // via thread_local! to avoid heap allocation per iteration.
262                thread_local! {
263                    static TL_A: std::cell::RefCell<Vec<f32>> =
264                        const { std::cell::RefCell::new(Vec::new()) };
265                }
266                TL_A.with(|tl| {
267                    let a_panels = (m_per_thread + mr - 1) / mr;
268                    let needed = a_panels * mr * kc_block;
269                    let mut packed_a = tl.borrow_mut();
270                    if packed_a.len() < needed {
271                        packed_a.resize(needed, 0.0);
272                    }
273
274                    let panels_n = (nc_block + nr - 1) / nr;
275
276                    for ic in (ic_start..ic_end).step_by(mc) {
277                        let mc_block = mc.min(ic_end - ic);
278
279                        super::packing::pack_a_block(
280                            a,
281                            k,
282                            ic,
283                            pc,
284                            mc_block,
285                            kc_block,
286                            &mut packed_a,
287                        );
288
289                        let panels_m = (mc_block + mr - 1) / mr;
290
291                        for ir_panel in 0..panels_m {
292                            let ir = ir_panel * mr;
293                            let mr_block = mr.min(mc_block - ir);
294
295                            for jr_panel in 0..panels_n {
296                                let jr = jr_panel * nr;
297                                let nr_block = nr.min(nc_block - jr);
298
299                                let a_panel = &packed_a[ir_panel * mr * kc_block..];
300                                let b_panel = &shared_b[jr_panel * nr * kc_block..];
301
302                                if mr_block == 8 && nr_block == 32 {
303                                    #[cfg(target_arch = "x86_64")]
304                                    unsafe {
305                                        super::compute::avx512_microkernel_8x32_rowmajor(
306                                            kc_block,
307                                            a_panel.as_ptr(),
308                                            b_panel.as_ptr(),
309                                            (c_ptr as *mut f32).add((ic + ir) * n + (jc + jr)),
310                                            n,
311                                        );
312                                    }
313                                } else {
314                                    // Scalar fallback for edge tiles
315                                    for ir_local in 0..mr_block {
316                                        for jr_local in 0..nr_block {
317                                            let mut sum = 0.0f32;
318                                            for p in 0..kc_block {
319                                                sum += a_panel[p * mr + ir_local]
320                                                    * b_panel[p * nr + jr_local];
321                                            }
322                                            unsafe {
323                                                let c = c_ptr as *mut f32;
324                                                *c.add(
325                                                    (ic + ir + ir_local) * n + (jc + jr + jr_local),
326                                                ) += sum;
327                                            }
328                                        }
329                                    }
330                                }
331                            }
332                        }
333                    }
334                }); // TL_A.with
335            });
336        }
337    }
338
339    Ok(())
340}
341
342/// Non-parallel fallback
343#[cfg(not(feature = "parallel"))]
344pub fn gemm_blis_parallel(
345    m: usize,
346    n: usize,
347    k: usize,
348    a: &[f32],
349    b: &[f32],
350    c: &mut [f32],
351) -> Result<(), TruenoError> {
352    gemm_blis(m, n, k, a, b, c, None)
353}
354
355/// Parallel BLIS GEMM with pre-packed B matrix.
356///
357/// Key optimization: the pre-packed B is shared immutably across all threads.
358/// Each thread only packs A (which differs per M partition). This eliminates
359/// N_threads × redundant B packings per GEMM call.
360///
361/// # WAPR-KAIZEN Cycle 12
362///
363/// For 16-thread encoder FFN: eliminates 15 redundant B packings per GEMM call
364/// (128 total across 2 GEMMs × 4 layers).
365#[cfg(feature = "parallel")]
366pub fn gemm_blis_parallel_with_prepacked_b(
367    m: usize,
368    n: usize,
369    k: usize,
370    a: &[f32],
371    prepacked_b: &PrepackedB,
372    c: &mut [f32],
373) -> Result<(), TruenoError> {
374    use rayon::prelude::*;
375
376    if a.len() != m * k || c.len() != m * n {
377        return Err(TruenoError::InvalidInput("Dimension mismatch".to_string()));
378    }
379    if prepacked_b.k != k || prepacked_b.n != n {
380        return Err(TruenoError::InvalidInput(format!(
381            "PrepackedB dimension mismatch: expected ({}, {}), got ({}, {})",
382            k, n, prepacked_b.k, prepacked_b.n
383        )));
384    }
385
386    // Small matrices: single-threaded
387    if m * n * k < 1_000_000 {
388        return gemm_blis_with_prepacked_b(m, n, k, a, prepacked_b, c, None);
389    }
390
391    let scheduler = HeijunkaScheduler::default();
392    let partitions = scheduler.partition_m(m, MC);
393
394    let c_ptr = c.as_mut_ptr() as usize;
395
396    // Key: prepacked_b is shared (immutable &) across all threads — zero redundant packing
397    partitions.into_par_iter().for_each(|m_range| {
398        let m_local = m_range.len();
399        let m_start = m_range.start;
400
401        let a_local = &a[m_start * k..(m_start + m_local) * k];
402
403        // SAFETY: Each thread accesses a disjoint row range of C.
404        // Partitions are non-overlapping by construction in HeijunkaScheduler::partition_m.
405        let c_local = unsafe {
406            let ptr = c_ptr as *mut f32;
407            std::slice::from_raw_parts_mut(ptr.add(m_start * n), m_local * n)
408        };
409
410        let _ = gemm_blis_with_prepacked_b(m_local, n, k, a_local, prepacked_b, c_local, None);
411    });
412
413    Ok(())
414}
415
416/// Non-parallel fallback for pre-packed B
417#[cfg(not(feature = "parallel"))]
418pub fn gemm_blis_parallel_with_prepacked_b(
419    m: usize,
420    n: usize,
421    k: usize,
422    a: &[f32],
423    prepacked_b: &PrepackedB,
424    c: &mut [f32],
425) -> Result<(), TruenoError> {
426    gemm_blis_with_prepacked_b(m, n, k, a, prepacked_b, c, None)
427}