Skip to main content

tract_linalg/frame/mmm/
mod.rs

1#[macro_use]
2mod macros;
3
4pub mod cost_model;
5#[macro_use]
6pub(crate) mod fuse;
7pub(crate) mod input_store;
8pub(crate) mod kernel;
9#[macro_use]
10pub(crate) mod panel_extract;
11mod scratch;
12mod storage;
13
14#[cfg(test)]
15#[macro_use]
16pub mod tests;
17
18use crate::multithread::Executor;
19use std::borrow::Cow;
20use std::cmp::Ordering;
21use std::fmt::Debug;
22use std::ops::Range;
23use tract_data::internal::*;
24
25pub use cost_model::*;
26pub use fuse::*;
27pub use input_store::*;
28pub use kernel::*;
29pub use panel_extract::*;
30pub use scratch::*;
31pub use storage::*;
32
33pub fn no_prefetch(_ptr: *const u8, _len: usize) {}
34
35#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
36pub enum ImplementationQuality {
37    /// Individual operations are emulated by individual conversion (f16->f32->f16)
38    Dreadful,
39    /// Rust scalar operation (with whatever optimisation the compiler manages)
40    Generic,
41    /// Implicit vectorization (e.g. Rust code, some unrolled loops, explicit template instantiations for small constant)
42    RustOptimized,
43    /// Explicit vectorization (e.g. intrinsics vector code)
44    TargetOptimized,
45    /// Hand optimized (assembly)
46    ManuallyOptimized,
47}
48
49impl ImplementationQuality {
50    pub fn best_to_worst() -> &'static [ImplementationQuality] {
51        use ImplementationQuality::*;
52        &[ManuallyOptimized, TargetOptimized, RustOptimized, Generic, Dreadful]
53    }
54
55    pub fn cost(&self) -> usize {
56        ImplementationQuality::best_to_worst().iter().position(|x| x == self).unwrap()
57    }
58}
59
60impl PartialOrd for ImplementationQuality {
61    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
62        Some(usize::from(*self).cmp(&usize::from(*other)))
63    }
64}
65
66impl From<ImplementationQuality> for usize {
67    fn from(value: ImplementationQuality) -> Self {
68        value.cost()
69    }
70}
71
72pub trait MatMatMul: Debug + dyn_clone::DynClone + Send + Sync + std::any::Any {
73    fn name(&self) -> &str;
74    fn mr(&self) -> usize;
75    fn nr(&self) -> usize;
76
77    fn quality(&self) -> ImplementationQuality;
78    fn dynamic_boost(&self) -> isize;
79
80    /// Whether this kernel is runnable on the current CPU (platform feature
81    /// gate, e.g. FEAT_DotProd for the SDOT i8 kernel).
82    fn is_supported_here(&self) -> bool;
83
84    #[allow(clippy::type_complexity)]
85    fn packings(&self) -> &[(Box<dyn MMMInputFormat>, Box<dyn MMMInputFormat>)];
86
87    fn internal_type(&self) -> DatumType;
88
89    unsafe fn c_view(&self, m_axis: Option<usize>, n_axis: Option<usize>) -> OutputStoreSpec;
90    unsafe fn c_from_data_and_strides(
91        &self,
92        item_size: usize,
93        row_stride: isize,
94        col_stride: isize,
95    ) -> OutputStoreSpec;
96
97    fn can_fuse(&self, spec: &FusedSpec) -> bool;
98
99    fn stores(&self) -> Cow<'_, [DatumType]>;
100
101    unsafe fn run(&self, m: usize, n: usize, non_linear: &[FusedSpec]) -> TractResult<()> {
102        unsafe {
103            let mut scratch = self.allocate_scratch_space();
104            self.run_with_scratch_space(m, n, &mut *scratch, non_linear)
105        }
106    }
107
108    unsafe fn allocate_scratch_space(&self) -> Box<dyn ScratchSpace>;
109    unsafe fn can_use_scratch_space(&self, scratch: &dyn ScratchSpace) -> bool;
110    unsafe fn run_with_scratch_space(
111        &self,
112        m: usize,
113        n: usize,
114        scratch: &mut dyn ScratchSpace,
115        non_linear: &[FusedSpec],
116    ) -> TractResult<()>;
117}
118
119dyn_clone::clone_trait_object!(MatMatMul);
120
121impl PartialEq for Box<dyn MatMatMul> {
122    fn eq(&self, other: &Box<dyn MatMatMul>) -> bool {
123        self.name() == other.name()
124    }
125}
126impl Eq for Box<dyn MatMatMul> {}
127
128impl std::hash::Hash for Box<dyn MatMatMul> {
129    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
130        self.name().hash(state)
131    }
132}
133
134impl<K: MatMatMulKer> MatMatMul for K {
135    fn name(&self) -> &str {
136        self.name()
137    }
138    fn mr(&self) -> usize {
139        self.mr()
140    }
141    fn nr(&self) -> usize {
142        self.nr()
143    }
144
145    fn quality(&self) -> ImplementationQuality {
146        MatMatMulKer::quality(self)
147    }
148
149    fn dynamic_boost(&self) -> isize {
150        MatMatMulKer::dynamic_boost(self)
151    }
152
153    fn is_supported_here(&self) -> bool {
154        MatMatMulKer::is_supported_here(self)
155    }
156
157    fn packings(&self) -> &[(Box<dyn MMMInputFormat>, Box<dyn MMMInputFormat>)] {
158        self.packings()
159    }
160
161    fn internal_type(&self) -> DatumType {
162        K::Acc::datum_type()
163    }
164
165    fn can_fuse(&self, spec: &FusedSpec) -> bool {
166        self.can_fuse(spec)
167    }
168
169    unsafe fn c_view(&self, m_axis: Option<usize>, n_axis: Option<usize>) -> OutputStoreSpec {
170        OutputStoreSpec::View { m_axis, n_axis, mr: self.mr(), nr: self.nr() }
171    }
172
173    unsafe fn c_from_data_and_strides(
174        &self,
175        item_size: usize,
176        row_stride: isize,
177        col_stride: isize,
178    ) -> OutputStoreSpec {
179        OutputStoreSpec::Strides {
180            row_byte_stride: row_stride * item_size as isize,
181            col_byte_stride: col_stride * item_size as isize,
182            mr: self.mr(),
183            nr: self.nr(),
184        }
185    }
186
187    fn stores(&self) -> Cow<'_, [DatumType]> {
188        self.stores()
189    }
190
191    unsafe fn allocate_scratch_space(&self) -> Box<dyn ScratchSpace> {
192        Box::<ScratchSpaceImpl<K::Acc>>::default()
193    }
194
195    unsafe fn can_use_scratch_space(&self, scratch: &dyn ScratchSpace) -> bool {
196        scratch.downcast_ref::<ScratchSpaceImpl<K::Acc>>().is_some()
197    }
198
199    unsafe fn run_with_scratch_space(
200        &self,
201        m: usize,
202        n: usize,
203        scratch: &mut dyn ScratchSpace,
204        non_linear: &[FusedSpec],
205    ) -> TractResult<()> {
206        // Every AddMatMul must pass panels packed the way the named packing index
207        // expects; a mismatch reads the panels at the wrong stride and runs off the
208        // buffer. Guard it here so any caller — not just OptMatMul — is caught.
209        #[cfg(debug_assertions)]
210        {
211            use crate::pack::PackedFormat;
212            // Only raw PackedFormat panels can be read at the wrong stride; exotic
213            // inputs (lazy im2col, block-quant) materialise panels in the kernel's
214            // format via panel_bytes, so a differing wrapper type is fine. When both
215            // sides are PackedFormat, require same element type and row count
216            // (tolerating alignment/padding, but not an f16-vs-f32 element-size swap).
217            fn compatible(expected: &dyn MMMInputFormat, got: &dyn MMMInputFormat) -> bool {
218                if expected.dyn_eq(got) {
219                    return true;
220                }
221                match (expected.downcast_ref::<PackedFormat>(), got.downcast_ref::<PackedFormat>())
222                {
223                    (Some(e), Some(g)) => e.dt == g.dt && e.r == g.r,
224                    _ => true,
225                }
226            }
227            for spec in non_linear {
228                if let FusedSpec::AddMatMul { a, b, packing } = spec {
229                    let (pa, pb) = &self.packings()[*packing];
230                    debug_assert!(
231                        compatible(&**pa, a.format()),
232                        "A packed as {:?} but {} packing {packing} expects {pa:?}",
233                        a.format(),
234                        self.name(),
235                    );
236                    debug_assert!(
237                        compatible(&**pb, b.format()),
238                        "B packed as {:?} but {} packing {packing} expects {pb:?}",
239                        b.format(),
240                        self.name(),
241                    );
242                }
243            }
244        }
245        unsafe {
246            let scratch = scratch
247                .downcast_mut::<ScratchSpaceImpl<K::Acc>>()
248                .context("Wrong scratch space type")?;
249            scratch.prepare(self, m, n, non_linear)?;
250            if n == 1 && self.nr() == 1 {
251                run_with_scratch_space_vec(self, m, scratch, non_linear)
252            } else {
253                let (mut prefer_col, mut prefer_row) = (0, 0);
254                for uop in non_linear.iter() {
255                    if let Some(col) = uop.prefer_col_outer() {
256                        prefer_col = col as usize;
257                        prefer_row = (!col) as usize;
258                    }
259                }
260                // k drives the cache-block size; read it from the first
261                // AddMatMul's packed input (0 if none → max block).
262                let k = non_linear
263                    .iter()
264                    .find_map(|f| match f {
265                        FusedSpec::AddMatMul { a, .. } => Some(a.k()),
266                        _ => None,
267                    })
268                    .unwrap_or(0);
269                run_with_scratch_space_2d(
270                    self,
271                    m,
272                    n,
273                    k,
274                    prefer_col > prefer_row,
275                    scratch,
276                    non_linear,
277                )
278            }
279        }
280    }
281}
282
283unsafe fn run_with_scratch_space_vec<K: MatMatMulKer>(
284    ker: &K,
285    m: usize,
286    scratch: &mut ScratchSpaceImpl<K::Acc>,
287    non_linear: &[FusedSpec],
288) -> TractResult<()> {
289    unsafe {
290        match crate::multithread::current_tract_executor() {
291            Executor::SingleThread => scratch.run_in_tls_scope(|scratch, tls| {
292                for ia in 0..m.divceil(ker.mr()) {
293                    scratch.run_one_tile(ker, non_linear, tls, ia, 0)?;
294                }
295                TractResult::Ok(())
296            }),
297            #[cfg(feature = "multithread-mm")]
298            Executor::MultiThread(pool) => chunked_dispatch_rayon(
299                Some(&pool),
300                m.divceil(ker.mr()),
301                1,
302                ker.mr(),
303                ker.nr(),
304                |ia_start, ia_end, _, _, _| {
305                    scratch.run_in_tls_scope(|scratch, tls| {
306                        for ia in ia_start..ia_end {
307                            scratch.run_one_tile(ker, non_linear, tls, ia, 0)?;
308                        }
309                        TractResult::Ok(())
310                    })
311                },
312            ),
313            #[cfg(feature = "multithread-mm")]
314            Executor::RayonGlobal => chunked_dispatch_rayon(
315                None,
316                m.divceil(ker.mr()),
317                1,
318                ker.mr(),
319                ker.nr(),
320                |ia_start, ia_end, _, _, _| {
321                    scratch.run_in_tls_scope(|scratch, tls| {
322                        for ia in ia_start..ia_end {
323                            scratch.run_one_tile(ker, non_linear, tls, ia, 0)?;
324                        }
325                        TractResult::Ok(())
326                    })
327                },
328            ),
329        }
330    }
331}
332
333/// Upper bound on the inner (L2-resident) panel-block edge.
334const BLK_MAX: usize = 16;
335
336/// Upper bound on the outer (L3-resident) super-block edge. 4× the inner cap so
337/// an L3 several times larger than L2 can hold a meaningfully bigger super-block.
338const BLK_L3_MAX: usize = 64;
339
340/// Panel-block working-set budget (bytes) from a detected cache size: a fraction
341/// `num/den` of the cache (leaving room for the C accumulator tile + packing
342/// metadata), clamped to a sane range. `0` (cache unknown) ⇒ `fallback`, which
343/// is kept small so the block ≈ the naive loop and can never over-block a cache
344/// it can't see. Sizes come from the shared [`crate::cache`] probe.
345fn tier_budget_bytes(cache_bytes: usize, num: usize, den: usize, fallback: usize) -> usize {
346    if cache_bytes == 0 {
347        fallback
348    } else {
349        (cache_bytes * num / den).clamp(64 * 1024, 64 * 1024 * 1024)
350    }
351}
352
353/// Inner tier: ~a third of L2 (private per perf-core), 256 KiB fallback.
354fn l2_block_budget_bytes() -> usize {
355    tier_budget_bytes(crate::cache::cache_info().l2, 1, 3, 256 * 1024)
356}
357
358/// Outer tier: `(llc_bytes, budget_bytes)` — the raw last-level-cache size and the
359/// fraction of it the outer super-block may budget — but only when an L3/LLC larger
360/// than L2 is detected (otherwise an outer tier just duplicates the inner one).
361/// `None` ⇒ no outer tier; the walk stays single-level. The raw size is returned
362/// alongside the budget so the caller can check whether the working set even
363/// spills the cache before blocking. Both numbers are for the *whole* cache;
364/// concurrent walkers each get a share (see [`outer_block_edge`]).
365fn l3_block_budget_bytes() -> Option<(usize, usize)> {
366    use crate::cache::LlcKind;
367    let (bytes, kind) = crate::cache::last_level_cache()?;
368    // Dedicated cluster L3: ~half. A shared System-Level Cache is contended by the
369    // GPU/NPU/display, so we can't assume residency of lines they keep evicting —
370    // budget it to ~a quarter.
371    let (num, den) = match kind {
372        LlcKind::Dedicated => (1, 2),
373        LlcKind::SystemLevel => (1, 4),
374    };
375    Some((bytes, tier_budget_bytes(bytes, num, den, 0)))
376}
377
378/// Cache-adaptive panel-block edge for a given byte budget: large enough to
379/// amortise streaming, small enough that the block's A+B sub-panels
380/// (`~blk·(mr+nr)·k·elem_bytes`) stay cache-resident at the given `k`. Capped at
381/// `cap`; the floor of 1 degrades exactly to the naive loop, so an unknown/small
382/// cache can never over-block (regression-safe).
383#[inline]
384fn block_edge_for(
385    budget: usize,
386    mr: usize,
387    nr: usize,
388    k: usize,
389    elem_bytes: usize,
390    cap: usize,
391) -> usize {
392    if k == 0 {
393        return cap;
394    }
395    let per_blk = ((mr + nr) * k * elem_bytes.max(1)).max(1);
396    (budget / per_blk).clamp(1, cap)
397}
398
399/// Whether inner (L2) blocking captures reuse the naive stream cannot, given the
400/// operand the walk re-streams — A (the m side, `panels·r = m_panels·mr`) for a
401/// column-outer order, B (the n side) for a row-outer one. If that streamed
402/// operand already fits L2 it is re-read from cache, not DRAM, so reordering
403/// tiles buys no reuse and only disturbs the prefetchers; only when it spills L2
404/// does the block save re-fetches. Mirrors [`outer_tier_pays`] for the inner
405/// tier, keyed on the streamed operand rather than the whole working set.
406fn inner_tier_pays(panels: usize, r: usize, k: usize, elem_bytes: usize, l2_bytes: usize) -> bool {
407    let streamed = panels.saturating_mul(r).saturating_mul(k).saturating_mul(elem_bytes);
408    l2_bytes > 0 && streamed > l2_bytes
409}
410
411/// Inner (L2) panel-block edge, or `usize::MAX` (single block, i.e. the naive
412/// stream) when the streamed operand already fits L2 (see [`inner_tier_pays`]).
413/// The budget is **cache-size derived** (not a hard-coded constant), so it is
414/// correct across hardware.
415///
416/// `l2_share` is how many rectangles concurrently share this L2, so each walker
417/// may only assume its slice of it — like [`outer_block_edge`]'s `llc_share`, but
418/// bounded by the L2's physical sharing degree rather than the thread count. On a
419/// core-private L2 (`l2_share == 1`) this is the whole cache, unchanged; on a
420/// cluster-shared L2 (Cortex-A9/A53) it prevents sibling rectangles from evicting
421/// one another's blocks.
422#[inline]
423#[allow(clippy::too_many_arguments)]
424fn inner_block_edge(
425    mr: usize,
426    nr: usize,
427    k: usize,
428    elem_bytes: usize,
429    m_panels: usize,
430    n_panels: usize,
431    col_outer: bool,
432    l2_share: usize,
433) -> usize {
434    let (panels, r) = if col_outer { (m_panels, mr) } else { (n_panels, nr) };
435    let share = l2_share.max(1);
436    if !inner_tier_pays(panels, r, k, elem_bytes, crate::cache::cache_info().l2 / share) {
437        return usize::MAX;
438    }
439    block_edge_for(l2_block_budget_bytes() / share, mr, nr, k, elem_bytes, BLK_MAX)
440}
441
442/// Whether an L3 outer super-block can capture reuse the inner (L2) tier cannot.
443/// It only can when the packed working set (`A + B ≈ (m·mr + n·nr)·k·elem`)
444/// actually spills the last-level cache: if both operands already fit, they stay
445/// resident across the sweep regardless of traversal order, so the reorder buys
446/// no reuse and only disturbs the hardware prefetchers — a measured net loss on
447/// small models that never leave L3 (voicecom_float on jetson-orin-nx, +15.6%).
448/// This is exactly the precondition the outer tier was introduced for ("a grid
449/// that exceeds L2 still re-fetches A/B from DRAM"); without the check the tier
450/// also engages on grids that never leave the LLC.
451fn outer_tier_pays(
452    m_panels: usize,
453    n_panels: usize,
454    mr: usize,
455    nr: usize,
456    k: usize,
457    elem_bytes: usize,
458    llc_bytes: usize,
459) -> bool {
460    let working_set = m_panels
461        .saturating_mul(mr)
462        .saturating_add(n_panels.saturating_mul(nr))
463        .saturating_mul(k)
464        .saturating_mul(elem_bytes);
465    llc_bytes > 0 && working_set > llc_bytes
466}
467
468/// Outer (L3) super-block edge, or `usize::MAX` (one block over the whole
469/// rectangle, i.e. no outer tier) when no usable L3 is detected or the working set
470/// already fits it (see [`outer_tier_pays`]). Never smaller than the inner edge
471/// `inner`.
472///
473/// `llc_share` is how many rectangles are walked concurrently: the LLC is shared,
474/// so each walker may only assume its slice of it. Sizing every chunk of a
475/// multi-threaded dispatch against the whole LLC would have them evict each
476/// other's super-blocks.
477#[inline]
478#[allow(clippy::too_many_arguments)]
479fn outer_block_edge(
480    mr: usize,
481    nr: usize,
482    k: usize,
483    elem_bytes: usize,
484    inner: usize,
485    m_panels: usize,
486    n_panels: usize,
487    llc_share: usize,
488) -> usize {
489    let Some((llc, budget)) = l3_block_budget_bytes() else { return usize::MAX };
490    let share = llc_share.max(1);
491    if !outer_tier_pays(m_panels, n_panels, mr, nr, k, elem_bytes, llc / share) {
492        return usize::MAX;
493    }
494    block_edge_for(budget / share, mr, nr, k, elem_bytes, BLK_L3_MAX).max(inner)
495}
496
497/// Visit every `(ia, ib)` tile of the `m × n` panel rectangle exactly once,
498/// blocked two levels deep: an outer `blk_outer` super-block (L3-resident) holds
499/// inner `blk` blocks (L2-resident). `col_outer` selects the within-block inner
500/// order (B-reuse vs A-reuse). When `blk_outer` spans the whole rectangle the
501/// outer loop runs once and this is exactly the single-level inner walk. Pure
502/// tile reordering ⇒ no result changes; extracted so the nesting can be
503/// unit-tested independently of the kernel.
504#[inline]
505fn for_each_blocked_tile(
506    m: Range<usize>,
507    n: Range<usize>,
508    blk: usize,
509    blk_outer: usize,
510    col_outer: bool,
511    mut f: impl FnMut(usize, usize) -> TractResult<()>,
512) -> TractResult<()> {
513    let blk = blk.max(1);
514    let blk_outer = blk_outer.max(blk);
515    let mut jb3 = n.start;
516    while jb3 < n.end {
517        let jb3_end = jb3.saturating_add(blk_outer).min(n.end);
518        let mut ja3 = m.start;
519        while ja3 < m.end {
520            let ja3_end = ja3.saturating_add(blk_outer).min(m.end);
521            let mut jb = jb3;
522            while jb < jb3_end {
523                let jb_end = jb.saturating_add(blk).min(jb3_end);
524                let mut ja = ja3;
525                while ja < ja3_end {
526                    let ja_end = ja.saturating_add(blk).min(ja3_end);
527                    if col_outer {
528                        for ib in jb..jb_end {
529                            for ia in ja..ja_end {
530                                f(ia, ib)?;
531                            }
532                        }
533                    } else {
534                        for ia in ja..ja_end {
535                            for ib in jb..jb_end {
536                                f(ia, ib)?;
537                            }
538                        }
539                    }
540                    ja = ja_end;
541                }
542                jb = jb_end;
543            }
544            ja3 = ja3_end;
545        }
546        jb3 = jb3_end;
547    }
548    Ok(())
549}
550
551/// Tile walk over one panel rectangle — the whole grid on the single-thread
552/// path, one dispatch chunk on the rayon path — blocked into cache-sized panel
553/// blocks for locality (the naive nested loop re-streams the whole inner operand
554/// per outer panel at large k). Two tiers: an inner L2-resident block and, where
555/// an L3 is detected, an outer L3-resident super-block sized for one of
556/// `llc_share` concurrent walkers. Both tiers are gated on the rectangle's own
557/// extents, so a rectangle whose streamed operand already fits cache walks
558/// exactly the naive order. Reordering independent tiles changes no result —
559/// bit-exact with the naive loop at any chunking.
560#[inline]
561#[allow(clippy::too_many_arguments)]
562unsafe fn run_blocked<K: MatMatMulKer>(
563    ker: &K,
564    m: Range<usize>,
565    n: Range<usize>,
566    k: usize,
567    col_outer: bool,
568    llc_share: usize,
569    scratch: &ScratchSpaceImpl<K::Acc>,
570    non_linear: &[FusedSpec],
571) -> TractResult<()> {
572    unsafe {
573        let elem = K::Acc::datum_type().size_of();
574        let (mr, nr) = (ker.mr(), ker.nr());
575        let (m_panels, n_panels) = (m.len(), n.len());
576        let l2_share = llc_share.min(crate::cache::cache_info().l2_sharers_or_one());
577        let blk = inner_block_edge(mr, nr, k, elem, m_panels, n_panels, col_outer, l2_share);
578        let blk_outer = outer_block_edge(mr, nr, k, elem, blk, m_panels, n_panels, llc_share);
579        scratch.run_in_tls_scope(|scratch, tls| {
580            for_each_blocked_tile(m, n, blk, blk_outer, col_outer, |ia, ib| {
581                scratch.run_one_tile(ker, non_linear, tls, ia, ib)
582            })
583        })
584    }
585}
586
587/// Run the whole `m × n` output over the executor currently installed: as one
588/// rectangle when single-threaded, else split into the chunk grid
589/// [`chunk_grid`] picks. `col_outer` selects the tile order inside a rectangle
590/// (B-reuse vs A-reuse), from the fused ops' preference. `k` is only used to size
591/// the cache blocking.
592unsafe fn run_with_scratch_space_2d<K: MatMatMulKer>(
593    ker: &K,
594    m: usize,
595    n: usize,
596    k: usize,
597    col_outer: bool,
598    scratch: &ScratchSpaceImpl<K::Acc>,
599    non_linear: &[FusedSpec],
600) -> TractResult<()> {
601    unsafe {
602        let (m_panels, n_panels) = (m.divceil(ker.mr()), n.divceil(ker.nr()));
603        #[cfg(feature = "multithread-mm")]
604        let chunk = |ia_start, ia_end, ib_start, ib_end, concurrency| {
605            run_blocked(
606                ker,
607                ia_start..ia_end,
608                ib_start..ib_end,
609                k,
610                col_outer,
611                concurrency,
612                scratch,
613                non_linear,
614            )
615        };
616        match crate::multithread::current_tract_executor() {
617            Executor::SingleThread => {
618                run_blocked(ker, 0..m_panels, 0..n_panels, k, col_outer, 1, scratch, non_linear)
619            }
620            #[cfg(feature = "multithread-mm")]
621            Executor::MultiThread(pool) => {
622                chunked_dispatch_rayon(Some(&pool), m_panels, n_panels, ker.mr(), ker.nr(), chunk)
623            }
624            #[cfg(feature = "multithread-mm")]
625            Executor::RayonGlobal => {
626                chunked_dispatch_rayon(None, m_panels, n_panels, ker.mr(), ker.nr(), chunk)
627            }
628        }
629    }
630}
631
632/// Chunks per thread the 2D dispatch aims for. Above one, rayon can steal work
633/// when threads progress unevenly — a contended core, an E-core on a big.LITTLE
634/// part, a chunk carrying more border tiles — so a straggler costs at most its
635/// share rather than the whole grid's tail. Each extra chunk also re-reads a band
636/// of the packed operands, and that cost grows as `sqrt(chunks)` against a linear
637/// gain in slack, which is what keeps this small.
638#[cfg(feature = "multithread-mm")]
639const CHUNKS_PER_THREAD: usize = 4;
640
641/// Chunk grid for the 2D dispatch: `(nchunks_m, nchunks_n, dr_m, dr_n)`.
642///
643/// Aims for [`CHUNKS_PER_THREAD`]`· nth` chunks and shapes them to minimise how
644/// often the packed operands are re-read: a chunk covering `dr_m × dr_n` panels
645/// reads `dr_m·mr·k` of A and `dr_n·nr·k` of B, so over the whole grid A is read
646/// `nchunks_n` times and B `nchunks_m` times. Minimising
647/// `nchunks_n·m + nchunks_m·n` at a fixed chunk count puts
648/// `nchunks_m = sqrt(chunks · m / n)` — chunks as square as the operands'
649/// extents, rather than a band across one axis.
650///
651/// Cache locality *inside* a chunk is [`run_blocked`]'s job, not this function's;
652/// chunk count therefore tracks the thread count and not a cache size.
653///
654/// Both panel counts must be non-zero; the dispatcher returns early on an empty
655/// grid.
656#[cfg(feature = "multithread-mm")]
657fn chunk_grid(
658    n_panels_m: usize,
659    n_panels_n: usize,
660    mr: usize,
661    nr: usize,
662    nth: usize,
663) -> (usize, usize, usize, usize) {
664    let chunks = (CHUNKS_PER_THREAD * nth).max(1);
665    let (m, n) = (n_panels_m * mr, (n_panels_n * nr).max(1));
666    let nchunks_m = (chunks.saturating_mul(m) / n).isqrt().clamp(1, n_panels_m);
667    let nchunks_n = (chunks / nchunks_m).clamp(1, n_panels_n);
668    let nchunks_m = (chunks / nchunks_n).clamp(1, n_panels_m);
669    let dr_m = n_panels_m.div_ceil(nchunks_m);
670    let dr_n = n_panels_n.div_ceil(nchunks_n);
671    // Recount from the edges: `div_ceil` can make the last chunk of an axis land
672    // entirely outside the grid, and an empty work item is a wasted dispatch.
673    (n_panels_m.div_ceil(dr_m), n_panels_n.div_ceil(dr_n), dr_m, dr_n)
674}
675
676/// Dispatch the `m_panels × n_panels` panel grid across the rayon path, split into
677/// the 2D chunk grid [`chunk_grid`] picks. Grids below
678/// [`crate::multithread::current_threading_panel_threshold`] run whole on the
679/// calling thread instead.
680///
681/// The closure receives **chunk bounds** (`ia_start, ia_end, ib_start, ib_end`)
682/// plus the number of chunks running concurrently, not per-tile indices. Chunk
683/// bounds let it amortise per-worker setup (e.g.
684/// `ScratchSpaceImpl::run_in_tls_scope`) over all the tiles in the chunk; the
685/// concurrency lets it size shared-cache blocking against the share it actually
686/// gets. The closure is invoked exactly once per rayon work item, and once in
687/// total with a concurrency of 1 on the below-threshold path.
688///
689/// `pool`:
690///   * `Some(p)` with `p.current_num_threads() > 1` → scoped via `p.install`
691///     (native, custom pool path).
692///   * `Some(p)` with single-thread pool, or `None` → dispatched via
693///     `into_par_iter` directly, which uses rayon's GLOBAL pool. This is
694///     the only working path on `wasm32-unknown-unknown` via
695///     `wasm_bindgen_rayon::init_thread_pool`.
696#[cfg(feature = "multithread-mm")]
697unsafe fn chunked_dispatch_rayon<F>(
698    pool: Option<&rayon::ThreadPool>,
699    n_panels_m: usize,
700    n_panels_n: usize,
701    mr: usize,
702    nr: usize,
703    run_chunk: F,
704) -> TractResult<()>
705where
706    F: Fn(usize, usize, usize, usize, usize) -> TractResult<()> + Sync,
707{
708    use rayon::prelude::*;
709    if n_panels_m == 0 || n_panels_n == 0 {
710        return Ok(());
711    }
712    if n_panels_m * n_panels_n < crate::multithread::current_threading_panel_threshold() {
713        // Below the threading threshold: run the whole grid as a single chunk
714        // on the calling thread. Closure handles its own TLS scope.
715        return run_chunk(0, n_panels_m, 0, n_panels_n, 1);
716    }
717    let use_global = pool.is_none_or(|p| p.current_num_threads() <= 1);
718    let body = || {
719        let nth = rayon::current_num_threads();
720        let (nchunks_m, nchunks_n, dr_m, dr_n) = chunk_grid(n_panels_m, n_panels_n, mr, nr, nth);
721        let total = nchunks_m * nchunks_n;
722        let concurrency = nth.min(total);
723        (0..total).into_par_iter().try_for_each(|idx| {
724            let im = idx % nchunks_m;
725            let in_ = idx / nchunks_m;
726            let ia_start = im * dr_m;
727            let ia_end = (ia_start + dr_m).min(n_panels_m);
728            let ib_start = in_ * dr_n;
729            let ib_end = (ib_start + dr_n).min(n_panels_n);
730            run_chunk(ia_start, ia_end, ib_start, ib_end, concurrency)
731        })
732    };
733    if use_global { body() } else { pool.unwrap().install(body) }
734}
735
736#[cfg(test)]
737mod blocked_walk_tests {
738    use super::*;
739    use std::collections::HashSet;
740
741    fn collect(
742        m: Range<usize>,
743        n: Range<usize>,
744        blk: usize,
745        blk_outer: usize,
746        col_outer: bool,
747    ) -> Vec<(usize, usize)> {
748        let mut v = Vec::new();
749        for_each_blocked_tile(m, n, blk, blk_outer, col_outer, |ia, ib| {
750            v.push((ia, ib));
751            Ok(())
752        })
753        .unwrap();
754        v
755    }
756
757    /// Every tile of the rectangle is visited exactly once, for both inner orders
758    /// and a range of (blk, blk_outer) — single-tier (outer = MAX), two-tier, and
759    /// degenerate edges. Coverage being a permutation is what makes the walk
760    /// bit-exact with the naive loop. Offset rectangles are the dispatch chunks.
761    #[test]
762    fn covers_every_tile_once() {
763        for &(m, n) in &[(1, 1), (3, 5), (16, 16), (40, 7), (7, 40), (80, 80)] {
764            for &(m0, n0) in &[(0, 0), (3, 11)] {
765                // usize::MAX is how both tiers say "do not block"; on an offset
766                // rectangle the edge arithmetic must not overflow past the end.
767                for &blk in &[1, 3, 16, usize::MAX] {
768                    for &blk_outer in &[blk, blk.saturating_add(1), 64, usize::MAX] {
769                        for &col_outer in &[false, true] {
770                            let tiles = collect(m0..m0 + m, n0..n0 + n, blk, blk_outer, col_outer);
771                            assert_eq!(
772                                tiles.len(),
773                                m * n,
774                                "m={m} n={n} blk={blk} outer={blk_outer}"
775                            );
776                            let set: HashSet<_> = tiles.iter().copied().collect();
777                            assert_eq!(
778                                set.len(),
779                                m * n,
780                                "duplicate tiles m={m} n={n} blk={blk} outer={blk_outer}"
781                            );
782                            for ia in m0..m0 + m {
783                                for ib in n0..n0 + n {
784                                    assert!(set.contains(&(ia, ib)), "missing ({ia},{ib})");
785                                }
786                            }
787                        }
788                    }
789                }
790            }
791        }
792    }
793
794    /// With no outer tier (blk_outer = MAX) the two-tier walk must emit the exact
795    /// same order as the original single-level blocked loop — guarantees the L3
796    /// path is a pure no-op on hardware without a detectable L3.
797    #[test]
798    fn outer_max_matches_single_level() {
799        for &(m, n) in &[(40, 7), (80, 80), (13, 29)] {
800            for &blk in &[1, 4, 16] {
801                for &col_outer in &[false, true] {
802                    let two_tier = collect(0..m, 0..n, blk, usize::MAX, col_outer);
803                    let mut single = Vec::new();
804                    let mut jb = 0;
805                    while jb < n {
806                        let jb_end = (jb + blk).min(n);
807                        let mut ja = 0;
808                        while ja < m {
809                            let ja_end = (ja + blk).min(m);
810                            if col_outer {
811                                for ib in jb..jb_end {
812                                    for ia in ja..ja_end {
813                                        single.push((ia, ib));
814                                    }
815                                }
816                            } else {
817                                for ia in ja..ja_end {
818                                    for ib in jb..jb_end {
819                                        single.push((ia, ib));
820                                    }
821                                }
822                            }
823                            ja = ja_end;
824                        }
825                        jb = jb_end;
826                    }
827                    assert_eq!(two_tier, single, "m={m} n={n} blk={blk} col_outer={col_outer}");
828                }
829            }
830        }
831    }
832
833    /// The outer tier engages only when the packed working set spills the LLC.
834    /// A grid that already fits stays single-level (the reorder buys no reuse and
835    /// only hurts prefetch — the voicecom_float/Orin regression).
836    #[test]
837    fn outer_tier_gated_on_working_set_spilling_llc() {
838        let llc = 2 * 1024 * 1024; // 2 MiB, f32 (elem = 4)
839        // Small grid: (64·8 + 8·8)·64·4 ≈ 144 KiB ⇒ fits ⇒ no outer tier.
840        assert!(!outer_tier_pays(64, 8, 8, 8, 64, 4, llc));
841        // Large grid: (256·8 + 256·8)·256·4 ≈ 4 MiB ⇒ spills ⇒ engage.
842        assert!(outer_tier_pays(256, 256, 8, 8, 256, 4, llc));
843        // A boundary working set equal to the LLC does not spill it.
844        assert!(!outer_tier_pays(1, 0, llc, 0, 1, 1, llc));
845        // Unknown LLC (0) never engages, whatever the grid.
846        assert!(!outer_tier_pays(4096, 4096, 8, 8, 4096, 4, 0));
847        // k = 0 (empty reduction) has no working set ⇒ never engages.
848        assert!(!outer_tier_pays(4096, 4096, 8, 8, 0, 4, llc));
849    }
850
851    /// Inner blocking engages only when the operand the walk re-streams — A for a
852    /// column-outer order, B for a row-outer one — spills L2. A streamed operand
853    /// that fits is re-read from cache, so blocking only hurts prefetch.
854    #[test]
855    fn inner_tier_gated_on_streamed_operand_spilling_l2() {
856        let l2 = 1024 * 1024; // 1 MiB, f32 (elem = 4)
857        // inception Conv2d_4a_3x3 grid (16×12 kernel), k=720.
858        // col_outer streams A (m side, panels=12 r=16): 12·16·720·4 ≈ 540 KiB ⇒ fits.
859        assert!(!inner_tier_pays(12, 16, 720, 4, l2));
860        // row_outer streams B (n side, panels=421 r=12): 421·12·720·4 ≈ 14.5 MiB ⇒ spills.
861        assert!(inner_tier_pays(421, 12, 720, 4, l2));
862        // A large square (m side, panels=256 r=16, k=512): 256·16·512·4 ≈ 8 MiB ⇒ spills.
863        assert!(inner_tier_pays(256, 16, 512, 4, l2));
864        // Undetectable L2 (0) never engages — degrades to the naive loop.
865        assert!(!inner_tier_pays(4096, 16, 4096, 4, 0));
866        // k = 0 (empty reduction) has no working set.
867        assert!(!inner_tier_pays(4096, 16, 0, 4, l2));
868    }
869
870    /// Grids, kernel aspect ratios and thread counts worth checking the chunk
871    /// grid against: skewed both ways, square, prime-ish, and the degenerate
872    /// single-panel cases.
873    #[cfg(feature = "multithread-mm")]
874    const GRIDS: &[(usize, usize)] = &[
875        (1, 1),
876        (1, 5),
877        (5, 1),
878        (2, 3),
879        (3, 3),
880        (16, 96),
881        (96, 16),
882        (17, 17),
883        (64, 64),
884        (32, 384),
885        (128, 128),
886        (1, 4096),
887        (4096, 1),
888        (9, 1000),
889    ];
890
891    #[cfg(feature = "multithread-mm")]
892    const RATIOS: &[(usize, usize)] = &[(8, 8), (16, 4), (32, 32), (64, 1)];
893
894    /// The four numbers `chunk_grid` returns must tile the panel grid exactly:
895    /// `chunked_dispatch_rayon` turns them into work items, so an empty chunk is
896    /// a wasted dispatch, an overlap would double-compute a tile, and a gap would
897    /// leave part of C uninitialised.
898    #[cfg(feature = "multithread-mm")]
899    #[test]
900    fn chunk_grid_tiles_the_panel_grid() {
901        for &(m, n) in GRIDS {
902            for &(mr, nr) in RATIOS {
903                for nth in [1usize, 2, 3, 4, 6, 8, 16, 64] {
904                    let (cm, cn, dr_m, dr_n) = chunk_grid(m, n, mr, nr, nth);
905                    let ctx = format!("{m}x{n} panels, {mr}x{nr} kernel, {nth} threads");
906                    let mut seen = vec![false; m * n];
907                    for idx in 0..cm * cn {
908                        let (im, in_) = (idx % cm, idx / cm);
909                        let (a0, a1) = (im * dr_m, (im * dr_m + dr_m).min(m));
910                        let (b0, b1) = (in_ * dr_n, (in_ * dr_n + dr_n).min(n));
911                        assert!(a0 < a1 && b0 < b1, "empty chunk {idx} in {ctx}");
912                        for ia in a0..a1 {
913                            for ib in b0..b1 {
914                                assert!(!seen[ia * n + ib], "tile ({ia},{ib}) twice in {ctx}");
915                                seen[ia * n + ib] = true;
916                            }
917                        }
918                    }
919                    assert!(seen.iter().all(|s| *s), "tile left out in {ctx}");
920                }
921            }
922        }
923    }
924
925    /// Enough chunks to keep every thread fed, whenever the grid has that many
926    /// panels to go round. Recounting the chunks from the edges is what makes this
927    /// hold: naming more chunks than the edges cover would idle the difference.
928    #[cfg(feature = "multithread-mm")]
929    #[test]
930    fn chunk_grid_feeds_every_thread() {
931        for &(m, n) in GRIDS {
932            for &(mr, nr) in RATIOS {
933                for nth in [1usize, 2, 3, 4, 6, 8, 16, 64] {
934                    let (cm, cn, ..) = chunk_grid(m, n, mr, nr, nth);
935                    assert!(
936                        cm * cn >= nth.min(m * n),
937                        "{cm}x{cn} chunks for {nth} threads on {m}x{n} panels"
938                    );
939                }
940            }
941        }
942    }
943
944    /// The grid is shaped to minimise packed-operand re-reads: A is read once per
945    /// column of chunks and B once per row, so `nchunks_n·m + nchunks_m·n` is what
946    /// the shape trades off. It must never cost more than a band across either
947    /// axis at the same chunk count, which on a square grid costs 1.5x.
948    #[cfg(feature = "multithread-mm")]
949    #[test]
950    fn chunk_grid_shape_beats_a_band_on_operand_traffic() {
951        let traffic = |cm: usize, cn: usize, m: usize, n: usize| cn * m + cm * n;
952        for &(m, n) in GRIDS {
953            for &(mr, nr) in RATIOS {
954                for nth in [2usize, 4, 8, 16] {
955                    let (cm, cn, ..) = chunk_grid(m, n, mr, nr, nth);
956                    let chunks = cm * cn;
957                    // Only a band that fits along the axis is a real alternative:
958                    // one clamped shorter would be a different chunk count, and a
959                    // smaller chunk count trivially re-reads less.
960                    if chunks > m || chunks > n {
961                        continue;
962                    }
963                    let (m_ext, n_ext) = (m * mr, n * nr);
964                    let ours = traffic(cm, cn, m_ext, n_ext);
965                    let band_m = traffic(chunks, 1, m_ext, n_ext);
966                    let band_n = traffic(1, chunks, m_ext, n_ext);
967                    assert!(
968                        ours <= band_m.min(band_n),
969                        "{cm}x{cn} costs {ours}, bands cost {band_m}/{band_n} \
970                         on {m}x{n} panels, {mr}x{nr} kernel, {nth} threads"
971                    );
972                }
973            }
974        }
975    }
976}