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    let flops = m * n * k;
192    if !shared_b_path_available(flops) {
193        return gemm_blis(m, n, k, a, b, c, None);
194    }
195
196    let num_threads = shared_b_thread_count(flops).min(rayon::current_num_threads());
197    let blk = super::cache_topology::blocking_8x32();
198    let geo = SharedBGeometry {
199        mr: blk.mr, // 8
200        nr: blk.nr, // 32
201        mc: blk.mc.min(m),
202        nc: blk.nc.min(n),
203        kc: blk.kc,
204    };
205
206    // Shared packed B: one allocation for the largest B panel
207    let b_panels = geo.nc.div_ceil(geo.nr);
208    let mut packed_b = vec![0.0f32; b_panels * geo.nr * geo.kc];
209
210    let c_ptr = c.as_mut_ptr() as usize;
211
212    for jc in (0..n).step_by(geo.nc) {
213        let nc_block = geo.nc.min(n - jc);
214
215        for pc in (0..k).step_by(geo.kc) {
216            let kc_block = geo.kc.min(k - pc);
217
218            // Pack B ONCE (sequential) — shared by all threads
219            super::compute::pack_b_block_generic(
220                b,
221                n,
222                pc,
223                jc,
224                kc_block,
225                nc_block,
226                geo.nr,
227                &mut packed_b,
228            );
229            let block = SharedBBlock {
230                a,
231                k,
232                n,
233                c_ptr,
234                jc,
235                pc,
236                nc_block,
237                kc_block,
238                geo: &geo,
239                shared_b: &packed_b,
240            };
241
242            // Parallel ic loop: each thread gets a slice of M
243            let m_per_thread = m.div_ceil(num_threads).div_ceil(geo.mr) * geo.mr;
244
245            (0..num_threads).into_par_iter().for_each(|tid| {
246                let ic_start = tid * m_per_thread;
247                if ic_start < m {
248                    block.run_slice(ic_start, (ic_start + m_per_thread).min(m), m_per_thread);
249                }
250            });
251        }
252    }
253
254    Ok(())
255}
256
257/// Whether the shared-B path may run at all: big enough to pay for the
258/// packing, and on a target that has the 8×32 microkernel.
259///
260/// FALSIFY-SHARED-B-001 on aarch64 (gx10, 2026-09-12): the AVX-512 check used
261/// to exist only under `cfg(target_arch = "x86_64")`, and the microkernel call
262/// in the tile loop is `cfg(x86_64)` inside an `if` with no other arm — so on
263/// every other target the full 8×32 tiles were silently SKIPPED and only the
264/// edge tiles were computed: max diff 39.2 against the reference at 256³ (the
265/// 100×96 row passed only because it sits under the 8M-flop cut). The shared-B
266/// path is AVX-512-only by construction; everything else takes the same plain
267/// BLIS path a no-AVX-512 x86 box takes.
268#[cfg(feature = "parallel")]
269fn shared_b_path_available(flops: usize) -> bool {
270    if flops < 8_000_000 {
271        return false;
272    }
273    #[cfg(target_arch = "x86_64")]
274    {
275        std::arch::is_x86_feature_detected!("avx512f")
276    }
277    #[cfg(not(target_arch = "x86_64"))]
278    {
279        false
280    }
281}
282
283/// Thread budget by problem size. Shared-B means less L3 pressure per thread
284/// than the per-thread-B path, so the large tiers use phys_cores/2 (≥ 8).
285#[cfg(feature = "parallel")]
286fn shared_b_thread_count(flops: usize) -> usize {
287    let phys_cores = num_cpus::get_physical();
288    if flops < 64_000_000 {
289        2.min(phys_cores)
290    } else if flops < 512_000_000 {
291        4.min(phys_cores)
292    } else {
293        (phys_cores / 2).max(8).min(phys_cores)
294    }
295}
296
297/// The 8×32 blocking the shared-B path packs for.
298#[cfg(feature = "parallel")]
299struct SharedBGeometry {
300    mr: usize,
301    nr: usize,
302    mc: usize,
303    nc: usize,
304    kc: usize,
305}
306
307/// One (jc, pc) block: B is packed once and read by every thread; each thread
308/// packs its own A slice and writes a disjoint row range of C.
309#[cfg(feature = "parallel")]
310struct SharedBBlock<'a> {
311    a: &'a [f32],
312    k: usize,
313    n: usize,
314    /// `*mut f32` of C as usize so the block is `Sync`; every write lands in
315    /// this thread's own row range (see `run_slice`).
316    c_ptr: usize,
317    jc: usize,
318    pc: usize,
319    nc_block: usize,
320    kc_block: usize,
321    geo: &'a SharedBGeometry,
322    shared_b: &'a [f32],
323}
324
325#[cfg(feature = "parallel")]
326impl SharedBBlock<'_> {
327    /// This thread's M-slice `[ic_start, ic_end)`: pack A per mc block into a
328    /// thread-local buffer (reused across (jc, pc) iterations — no allocation
329    /// per iteration) and run the panel loop over it.
330    fn run_slice(&self, ic_start: usize, ic_end: usize, m_per_thread: usize) {
331        thread_local! {
332            static TL_A: std::cell::RefCell<Vec<f32>> =
333                const { std::cell::RefCell::new(Vec::new()) };
334        }
335        TL_A.with(|tl| {
336            let geo = self.geo;
337            let needed = m_per_thread.div_ceil(geo.mr) * geo.mr * self.kc_block;
338            let mut packed_a = tl.borrow_mut();
339            if packed_a.len() < needed {
340                packed_a.resize(needed, 0.0);
341            }
342
343            for ic in (ic_start..ic_end).step_by(geo.mc) {
344                let mc_block = geo.mc.min(ic_end - ic);
345                super::packing::pack_a_block(
346                    self.a,
347                    self.k,
348                    ic,
349                    self.pc,
350                    mc_block,
351                    self.kc_block,
352                    &mut packed_a,
353                );
354                self.run_panels(&packed_a, ic, mc_block);
355            }
356        });
357    }
358
359    /// Every (mr × nr) tile of one packed-A block against the shared B.
360    fn run_panels(&self, packed_a: &[f32], ic: usize, mc_block: usize) {
361        let geo = self.geo;
362        let panels_n = self.nc_block.div_ceil(geo.nr);
363        for ir_panel in 0..mc_block.div_ceil(geo.mr) {
364            let ir = ir_panel * geo.mr;
365            let mr_block = geo.mr.min(mc_block - ir);
366            for jr_panel in 0..panels_n {
367                let jr = jr_panel * geo.nr;
368                let nr_block = geo.nr.min(self.nc_block - jr);
369                let a_panel = &packed_a[ir_panel * geo.mr * self.kc_block..];
370                let b_panel = &self.shared_b[jr_panel * geo.nr * self.kc_block..];
371                self.tile(a_panel, b_panel, ic + ir, self.jc + jr, mr_block, nr_block);
372            }
373        }
374    }
375
376    /// One tile at C[row.., col..]: the AVX-512 microkernel for a full 8×32,
377    /// the scalar loop for every edge tile (and for every full tile on a
378    /// target without the microkernel — reachable only if the path guard is
379    /// ever widened).
380    fn tile(
381        &self,
382        a_panel: &[f32],
383        b_panel: &[f32],
384        row: usize,
385        col: usize,
386        mr_block: usize,
387        nr_block: usize,
388    ) {
389        #[cfg(target_arch = "x86_64")]
390        if mr_block == 8 && nr_block == 32 {
391            // SAFETY: the path guard proved avx512f; a_panel/b_panel hold
392            // kc_block × 8 and kc_block × 32 packed floats; (row, col) is
393            // inside this thread's disjoint row range of C, which outlives
394            // the block.
395            unsafe {
396                super::compute::avx512_microkernel_8x32_rowmajor(
397                    self.kc_block,
398                    a_panel.as_ptr(),
399                    b_panel.as_ptr(),
400                    (self.c_ptr as *mut f32).add(row * self.n + col),
401                    self.n,
402                );
403            }
404            return;
405        }
406        self.scalar_tile(a_panel, b_panel, row, col, mr_block, nr_block);
407    }
408
409    /// Scalar fallback for edge tiles.
410    fn scalar_tile(
411        &self,
412        a_panel: &[f32],
413        b_panel: &[f32],
414        row: usize,
415        col: usize,
416        mr_block: usize,
417        nr_block: usize,
418    ) {
419        let geo = self.geo;
420        for ir_local in 0..mr_block {
421            for jr_local in 0..nr_block {
422                let mut sum = 0.0f32;
423                for p in 0..self.kc_block {
424                    sum += a_panel[p * geo.mr + ir_local] * b_panel[p * geo.nr + jr_local];
425                }
426                // SAFETY: (row + ir_local, col + jr_local) is inside this
427                // thread's disjoint row range of C (see `run_slice`).
428                unsafe {
429                    let c = self.c_ptr as *mut f32;
430                    *c.add((row + ir_local) * self.n + (col + jr_local)) += sum;
431                }
432            }
433        }
434    }
435}
436
437/// Non-parallel fallback
438#[cfg(not(feature = "parallel"))]
439pub fn gemm_blis_parallel(
440    m: usize,
441    n: usize,
442    k: usize,
443    a: &[f32],
444    b: &[f32],
445    c: &mut [f32],
446) -> Result<(), TruenoError> {
447    gemm_blis(m, n, k, a, b, c, None)
448}
449
450/// Parallel BLIS GEMM with pre-packed B matrix.
451///
452/// Key optimization: the pre-packed B is shared immutably across all threads.
453/// Each thread only packs A (which differs per M partition). This eliminates
454/// N_threads × redundant B packings per GEMM call.
455///
456/// # WAPR-KAIZEN Cycle 12
457///
458/// For 16-thread encoder FFN: eliminates 15 redundant B packings per GEMM call
459/// (128 total across 2 GEMMs × 4 layers).
460#[cfg(feature = "parallel")]
461pub fn gemm_blis_parallel_with_prepacked_b(
462    m: usize,
463    n: usize,
464    k: usize,
465    a: &[f32],
466    prepacked_b: &PrepackedB,
467    c: &mut [f32],
468) -> Result<(), TruenoError> {
469    use rayon::prelude::*;
470
471    if a.len() != m * k || c.len() != m * n {
472        return Err(TruenoError::InvalidInput("Dimension mismatch".to_string()));
473    }
474    if prepacked_b.k != k || prepacked_b.n != n {
475        return Err(TruenoError::InvalidInput(format!(
476            "PrepackedB dimension mismatch: expected ({}, {}), got ({}, {})",
477            k, n, prepacked_b.k, prepacked_b.n
478        )));
479    }
480
481    // Small matrices: single-threaded
482    if m * n * k < 1_000_000 {
483        return gemm_blis_with_prepacked_b(m, n, k, a, prepacked_b, c, None);
484    }
485
486    let scheduler = HeijunkaScheduler::default();
487    let partitions = scheduler.partition_m(m, MC);
488
489    let c_ptr = c.as_mut_ptr() as usize;
490
491    // Key: prepacked_b is shared (immutable &) across all threads — zero redundant packing
492    partitions.into_par_iter().for_each(|m_range| {
493        let m_local = m_range.len();
494        let m_start = m_range.start;
495
496        let a_local = &a[m_start * k..(m_start + m_local) * k];
497
498        // SAFETY: Each thread accesses a disjoint row range of C.
499        // Partitions are non-overlapping by construction in HeijunkaScheduler::partition_m.
500        let c_local = unsafe {
501            let ptr = c_ptr as *mut f32;
502            std::slice::from_raw_parts_mut(ptr.add(m_start * n), m_local * n)
503        };
504
505        let _ = gemm_blis_with_prepacked_b(m_local, n, k, a_local, prepacked_b, c_local, None);
506    });
507
508    Ok(())
509}
510
511/// Non-parallel fallback for pre-packed B
512#[cfg(not(feature = "parallel"))]
513pub fn gemm_blis_parallel_with_prepacked_b(
514    m: usize,
515    n: usize,
516    k: usize,
517    a: &[f32],
518    prepacked_b: &PrepackedB,
519    c: &mut [f32],
520) -> Result<(), TruenoError> {
521    gemm_blis_with_prepacked_b(m, n, k, a, prepacked_b, c, None)
522}