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 select;
13mod storage;
14
15#[cfg(test)]
16#[macro_use]
17pub mod tests;
18
19use crate::multithread::Executor;
20use std::borrow::Cow;
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 select::*;
32pub use storage::*;
33
34pub fn no_prefetch(_ptr: *const u8, _len: usize) {}
35
36pub trait MatMatMul: Debug + dyn_clone::DynClone + Send + Sync + std::any::Any {
37    fn name(&self) -> &str;
38    fn mr(&self) -> usize;
39    fn nr(&self) -> usize;
40
41    /// Architecture this kernel is written for, `None` for the generic Rust every target
42    /// builds. What [`retain_best`] compares before anything else: a kernel written for the
43    /// machine at hand supersedes a portable one whatever their instruction sets.
44    fn arch(&self) -> Option<crate::isa::Arch>;
45
46    /// Whether the kernel computes its accumulator type by converting every operation to
47    /// another type, for a machine whose hardware has none. Orders of magnitude off a real
48    /// kernel, and always the only thing on offer where it is declared, so selection ignores
49    /// it and benches skip it.
50    fn emulated(&self) -> bool;
51
52    /// The preference this kernel's author spelled out, before the instruction-set default.
53    fn boost(&self) -> isize;
54
55    /// Where this kernel sits against the siblings of its own kind, which [`retain_best`]
56    /// weighs once [`Self::arch`] has not separated them: the level of the instruction set it
57    /// is written for, plus whatever a measurement said that level gets wrong. A kernel
58    /// written for a more capable set outranks one written for a less capable one by default;
59    /// a declared boost is how an exception is spelled, and must be big enough to cross the
60    /// levels it disagrees with. Never encode a preference in [`Self::runnable`] — that
61    /// silently skips the kernel's tests as well.
62    fn preference(&self) -> isize;
63
64    /// Whether a machine with this instruction set could execute the kernel: the architecture is
65    /// the one it is written for, and the set offers every feature it declares. Takes the machine
66    /// rather than reading the host, so the same question serves dispatch and an audit of what
67    /// another architecture would run.
68    ///
69    /// It says nothing about whether this build assembled the body — see [`Self::built`]. A
70    /// kernel can be runnable on a machine and still be a stub here.
71    fn runnable_on(&self, isa: &crate::isa::IsaSet) -> bool;
72
73    /// Whether this kernel can be executed here at all: this build compiled it
74    /// ([`Self::built`]) and the running CPU has the instruction set it declares
75    /// ([`Self::runnable_on`] against the probed set).
76    ///
77    /// Runnability only, never preference: this answers "would executing the kernel fault",
78    /// and the mmm test bodies gate on it, so a kernel that lies here has no test coverage at
79    /// all on the hosts it lies on. Say a kernel is worse than its sibling with
80    /// [`Self::preference`] instead.
81    fn runnable(&self) -> bool;
82
83    /// Whether this build compiled the kernel's body at all. False for a foreign arch's
84    /// kernel, which is metadata around a stub that bails when called.
85    fn built(&self) -> bool;
86
87    /// What the instruction set must offer for this kernel to run here.
88    fn isa(&self) -> crate::isa::IsaReq;
89
90    #[allow(clippy::type_complexity)]
91    fn packings(&self) -> &[(Box<dyn MMMInputFormat>, Box<dyn MMMInputFormat>)];
92
93    fn internal_type(&self) -> DatumType;
94
95    unsafe fn c_view(&self, m_axis: Option<usize>, n_axis: Option<usize>) -> OutputStoreSpec;
96    unsafe fn c_from_data_and_strides(
97        &self,
98        item_size: usize,
99        row_stride: isize,
100        col_stride: isize,
101    ) -> OutputStoreSpec;
102
103    fn can_fuse(&self, spec: &FusedSpec) -> bool;
104
105    fn stores(&self) -> Cow<'_, [DatumType]>;
106
107    unsafe fn run(&self, m: usize, n: usize, non_linear: &[FusedSpec]) -> TractResult<()> {
108        unsafe {
109            let mut scratch = self.allocate_scratch_space();
110            self.run_with_scratch_space(m, n, &mut *scratch, non_linear)
111        }
112    }
113
114    unsafe fn allocate_scratch_space(&self) -> Box<dyn ScratchSpace>;
115    unsafe fn can_use_scratch_space(&self, scratch: &dyn ScratchSpace) -> bool;
116    unsafe fn run_with_scratch_space(
117        &self,
118        m: usize,
119        n: usize,
120        scratch: &mut dyn ScratchSpace,
121        non_linear: &[FusedSpec],
122    ) -> TractResult<()>;
123}
124
125dyn_clone::clone_trait_object!(MatMatMul);
126
127impl PartialEq for Box<dyn MatMatMul> {
128    fn eq(&self, other: &Box<dyn MatMatMul>) -> bool {
129        self.name() == other.name()
130    }
131}
132impl Eq for Box<dyn MatMatMul> {}
133
134impl std::hash::Hash for Box<dyn MatMatMul> {
135    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
136        self.name().hash(state)
137    }
138}
139
140impl<K: MatMatMulKer> MatMatMul for K {
141    fn name(&self) -> &str {
142        self.name()
143    }
144    fn mr(&self) -> usize {
145        self.mr()
146    }
147    fn nr(&self) -> usize {
148        self.nr()
149    }
150
151    fn arch(&self) -> Option<crate::isa::Arch> {
152        MatMatMulKer::arch(self)
153    }
154
155    fn emulated(&self) -> bool {
156        MatMatMulKer::emulated(self)
157    }
158
159    fn boost(&self) -> isize {
160        MatMatMulKer::boost(self)
161    }
162
163    fn preference(&self) -> isize {
164        MatMatMulKer::preference(self)
165    }
166
167    fn runnable_on(&self, isa: &crate::isa::IsaSet) -> bool {
168        MatMatMulKer::runnable_on(self, isa)
169    }
170
171    fn runnable(&self) -> bool {
172        MatMatMulKer::runnable(self)
173    }
174
175    fn built(&self) -> bool {
176        MatMatMulKer::built(self)
177    }
178
179    fn isa(&self) -> crate::isa::IsaReq {
180        MatMatMulKer::isa(self)
181    }
182
183    fn packings(&self) -> &[(Box<dyn MMMInputFormat>, Box<dyn MMMInputFormat>)] {
184        self.packings()
185    }
186
187    fn internal_type(&self) -> DatumType {
188        K::Acc::datum_type()
189    }
190
191    fn can_fuse(&self, spec: &FusedSpec) -> bool {
192        self.can_fuse(spec)
193    }
194
195    unsafe fn c_view(&self, m_axis: Option<usize>, n_axis: Option<usize>) -> OutputStoreSpec {
196        OutputStoreSpec::View { m_axis, n_axis, mr: self.mr(), nr: self.nr() }
197    }
198
199    unsafe fn c_from_data_and_strides(
200        &self,
201        item_size: usize,
202        row_stride: isize,
203        col_stride: isize,
204    ) -> OutputStoreSpec {
205        OutputStoreSpec::Strides {
206            row_byte_stride: row_stride * item_size as isize,
207            col_byte_stride: col_stride * item_size as isize,
208            mr: self.mr(),
209            nr: self.nr(),
210        }
211    }
212
213    fn stores(&self) -> Cow<'_, [DatumType]> {
214        self.stores()
215    }
216
217    unsafe fn allocate_scratch_space(&self) -> Box<dyn ScratchSpace> {
218        Box::<ScratchSpaceImpl<K::Acc>>::default()
219    }
220
221    unsafe fn can_use_scratch_space(&self, scratch: &dyn ScratchSpace) -> bool {
222        scratch.downcast_ref::<ScratchSpaceImpl<K::Acc>>().is_some()
223    }
224
225    unsafe fn run_with_scratch_space(
226        &self,
227        m: usize,
228        n: usize,
229        scratch: &mut dyn ScratchSpace,
230        non_linear: &[FusedSpec],
231    ) -> TractResult<()> {
232        // Every AddMatMul must pass panels packed the way the named packing index
233        // expects; a mismatch reads the panels at the wrong stride and runs off the
234        // buffer. Guard it here so any caller — not just OptMatMul — is caught.
235        #[cfg(debug_assertions)]
236        {
237            use crate::pack::PackedFormat;
238            // Only raw PackedFormat panels can be read at the wrong stride; exotic
239            // inputs (lazy im2col, block-quant) materialise panels in the kernel's
240            // format via panel_bytes, so a differing wrapper type is fine. When both
241            // sides are PackedFormat, require same element type and row count
242            // (tolerating alignment/padding, but not an f16-vs-f32 element-size swap).
243            fn compatible(expected: &dyn MMMInputFormat, got: &dyn MMMInputFormat) -> bool {
244                if expected.dyn_eq(got) {
245                    return true;
246                }
247                match (expected.downcast_ref::<PackedFormat>(), got.downcast_ref::<PackedFormat>())
248                {
249                    (Some(e), Some(g)) => e.dt == g.dt && e.r == g.r,
250                    _ => true,
251                }
252            }
253            for spec in non_linear {
254                if let FusedSpec::AddMatMul { a, b, packing } = spec {
255                    let (pa, pb) = &self.packings()[*packing];
256                    debug_assert!(
257                        compatible(&**pa, a.format()),
258                        "A packed as {:?} but {} packing {packing} expects {pa:?}",
259                        a.format(),
260                        self.name(),
261                    );
262                    debug_assert!(
263                        compatible(&**pb, b.format()),
264                        "B packed as {:?} but {} packing {packing} expects {pb:?}",
265                        b.format(),
266                        self.name(),
267                    );
268                }
269            }
270        }
271        unsafe {
272            let scratch = scratch
273                .downcast_mut::<ScratchSpaceImpl<K::Acc>>()
274                .context("Wrong scratch space type")?;
275            scratch.prepare(self, m, n, non_linear)?;
276            if n == 1 && self.nr() == 1 {
277                run_with_scratch_space_vec(self, m, scratch, non_linear)
278            } else {
279                let (mut prefer_col, mut prefer_row) = (0, 0);
280                for uop in non_linear.iter() {
281                    if let Some(col) = uop.prefer_col_outer() {
282                        prefer_col = col as usize;
283                        prefer_row = (!col) as usize;
284                    }
285                }
286                // k drives the cache-block size; read it from the first
287                // AddMatMul's packed input (0 if none → max block).
288                let k = matmul_k(non_linear);
289                #[cfg(target_arch = "riscv64")]
290                crate::riscv64::refresh_tlb_before_matmul(m * n * k);
291                run_with_scratch_space_2d(
292                    self,
293                    m,
294                    n,
295                    k,
296                    prefer_col > prefer_row,
297                    scratch,
298                    non_linear,
299                )
300            }
301        }
302    }
303}
304
305/// `k` of the first `AddMatMul` in `non_linear`, or 0 if there is none.
306fn matmul_k(non_linear: &[FusedSpec]) -> usize {
307    non_linear
308        .iter()
309        .find_map(|f| match f {
310            FusedSpec::AddMatMul { a, .. } => Some(a.k()),
311            _ => None,
312        })
313        .unwrap_or(0)
314}
315
316unsafe fn run_with_scratch_space_vec<K: MatMatMulKer>(
317    ker: &K,
318    m: usize,
319    scratch: &mut ScratchSpaceImpl<K::Acc>,
320    non_linear: &[FusedSpec],
321) -> TractResult<()> {
322    unsafe {
323        #[cfg(feature = "multithread-mm")]
324        let k = matmul_k(non_linear);
325        match crate::multithread::current_tract_executor() {
326            Executor::SingleThread => scratch.run_in_tls_scope(|scratch, tls| {
327                for ia in 0..m.divceil(ker.mr()) {
328                    scratch.run_one_tile(ker, non_linear, tls, ia, 0)?;
329                }
330                TractResult::Ok(())
331            }),
332            #[cfg(feature = "multithread-mm")]
333            Executor::MultiThread(pool) => chunked_dispatch_rayon(
334                Some(&pool),
335                m.divceil(ker.mr()),
336                1,
337                ker.mr(),
338                ker.nr(),
339                // A footprint of zero, which asks the chunk gate for the full
340                // slack. The gate exists to price the re-reads extra chunks cost,
341                // and on a single column of panels there are none: each chunk
342                // covers a disjoint band of A, and the one B panel every chunk
343                // re-reads is a vector.
344                0,
345                0,
346                m.saturating_mul(k),
347                |ia_start, ia_end, _, _, _| {
348                    scratch.run_in_tls_scope(|scratch, tls| {
349                        for ia in ia_start..ia_end {
350                            scratch.run_one_tile(ker, non_linear, tls, ia, 0)?;
351                        }
352                        TractResult::Ok(())
353                    })
354                },
355            ),
356            #[cfg(feature = "multithread-mm")]
357            Executor::RayonGlobal => chunked_dispatch_rayon(
358                None,
359                m.divceil(ker.mr()),
360                1,
361                ker.mr(),
362                ker.nr(),
363                // A footprint of zero, which asks the chunk gate for the full
364                // slack. The gate exists to price the re-reads extra chunks cost,
365                // and on a single column of panels there are none: each chunk
366                // covers a disjoint band of A, and the one B panel every chunk
367                // re-reads is a vector.
368                0,
369                0,
370                m.saturating_mul(k),
371                |ia_start, ia_end, _, _, _| {
372                    scratch.run_in_tls_scope(|scratch, tls| {
373                        for ia in ia_start..ia_end {
374                            scratch.run_one_tile(ker, non_linear, tls, ia, 0)?;
375                        }
376                        TractResult::Ok(())
377                    })
378                },
379            ),
380        }
381    }
382}
383
384/// Upper bound on the inner (L2-resident) panel-block edge.
385const BLK_MAX: usize = 16;
386
387/// Upper bound on the outer (L3-resident) super-block edge. 4× the inner cap so
388/// an L3 several times larger than L2 can hold a meaningfully bigger super-block.
389const BLK_L3_MAX: usize = 64;
390
391/// Panel-block working-set budget (bytes) from a detected cache size: a fraction
392/// `num/den` of the cache (leaving room for the C accumulator tile + packing
393/// metadata), clamped to a sane range. `0` (cache unknown) ⇒ `fallback`, which
394/// is kept small so the block ≈ the naive loop and can never over-block a cache
395/// it can't see. Sizes come from the shared [`crate::cache`] probe.
396fn tier_budget_bytes(cache_bytes: usize, num: usize, den: usize, fallback: usize) -> usize {
397    if cache_bytes == 0 {
398        fallback
399    } else {
400        (cache_bytes * num / den).clamp(64 * 1024, 64 * 1024 * 1024)
401    }
402}
403
404/// Inner tier: ~a third of L2 (private per perf-core), 256 KiB fallback.
405fn l2_block_budget_bytes() -> usize {
406    tier_budget_bytes(crate::cache::cache_info().l2, 1, 3, 256 * 1024)
407}
408
409/// Outer tier: `(llc_bytes, budget_bytes)` — the raw last-level-cache size and the
410/// fraction of it the outer super-block may budget — but only when an L3/LLC larger
411/// than L2 is detected (otherwise an outer tier just duplicates the inner one).
412/// `None` ⇒ no outer tier; the walk stays single-level. The raw size is returned
413/// alongside the budget so the caller can check whether the working set even
414/// spills the cache before blocking. Both numbers are for the *whole* cache;
415/// concurrent walkers each get a share (see [`outer_block_edge`]).
416fn l3_block_budget_bytes() -> Option<(usize, usize)> {
417    use crate::cache::LlcKind;
418    let (bytes, kind) = crate::cache::last_level_cache()?;
419    // Dedicated cluster L3: ~half. A shared System-Level Cache is contended by the
420    // GPU/NPU/display, so we can't assume residency of lines they keep evicting —
421    // budget it to ~a quarter.
422    let (num, den) = match kind {
423        LlcKind::Dedicated => (1, 2),
424        LlcKind::SystemLevel => (1, 4),
425    };
426    Some((bytes, tier_budget_bytes(bytes, num, den, 0)))
427}
428
429/// Cache-adaptive panel-block edge for a given byte budget: large enough to
430/// amortise streaming, small enough that the block's A+B sub-panels
431/// (`~blk·(mr+nr)·k·elem_bytes`) stay cache-resident at the given `k`. Capped at
432/// `cap`; the floor of 1 degrades exactly to the naive loop, so an unknown/small
433/// cache can never over-block (regression-safe).
434#[inline]
435fn block_edge_for(
436    budget: usize,
437    mr: usize,
438    nr: usize,
439    k: usize,
440    elem_bytes: usize,
441    cap: usize,
442) -> usize {
443    if k == 0 {
444        return cap;
445    }
446    let per_blk = ((mr + nr) * k * elem_bytes.max(1)).max(1);
447    (budget / per_blk).clamp(1, cap)
448}
449
450/// Whether inner (L2) blocking captures reuse the naive stream cannot, given the
451/// operand the walk re-streams — A (the m side, `panels·r = m_panels·mr`) for a
452/// column-outer order, B (the n side) for a row-outer one. If that streamed
453/// operand already fits L2 it is re-read from cache, not DRAM, so reordering
454/// tiles buys no reuse and only disturbs the prefetchers; only when it spills L2
455/// does the block save re-fetches. Mirrors [`outer_tier_pays`] for the inner
456/// tier, keyed on the streamed operand rather than the whole working set.
457fn inner_tier_pays(panels: usize, r: usize, k: usize, elem_bytes: usize, l2_bytes: usize) -> bool {
458    let streamed = panels.saturating_mul(r).saturating_mul(k).saturating_mul(elem_bytes);
459    l2_bytes > 0 && streamed > l2_bytes
460}
461
462/// Inner (L2) panel-block edge, or `usize::MAX` (single block, i.e. the naive
463/// stream) when the streamed operand already fits L2 (see [`inner_tier_pays`]).
464/// The budget is **cache-size derived** (not a hard-coded constant), so it is
465/// correct across hardware.
466///
467/// `l2_share` is how many rectangles concurrently share this L2, so each walker
468/// may only assume its slice of it — like [`outer_block_edge`]'s `llc_share`, but
469/// bounded by the L2's physical sharing degree rather than the thread count. On a
470/// core-private L2 (`l2_share == 1`) this is the whole cache, unchanged; on a
471/// cluster-shared L2 (Cortex-A9/A53) it prevents sibling rectangles from evicting
472/// one another's blocks.
473#[inline]
474#[allow(clippy::too_many_arguments)]
475fn inner_block_edge(
476    mr: usize,
477    nr: usize,
478    k: usize,
479    elem_bytes: usize,
480    m_panels: usize,
481    n_panels: usize,
482    col_outer: bool,
483    l2_share: usize,
484) -> usize {
485    let (panels, r) = if col_outer { (m_panels, mr) } else { (n_panels, nr) };
486    let share = l2_share.max(1);
487    if !inner_tier_pays(panels, r, k, elem_bytes, crate::cache::cache_info().l2 / share) {
488        return usize::MAX;
489    }
490    block_edge_for(l2_block_budget_bytes() / share, mr, nr, k, elem_bytes, BLK_MAX)
491}
492
493/// Whether an L3 outer super-block can capture reuse the inner (L2) tier cannot.
494/// It only can when the packed working set (`A + B ≈ (m·mr + n·nr)·k·elem`)
495/// actually spills the last-level cache: if both operands already fit, they stay
496/// resident across the sweep regardless of traversal order, so the reorder buys
497/// no reuse and only disturbs the hardware prefetchers — a measured net loss on
498/// small models that never leave L3 (voicecom_float on jetson-orin-nx, +15.6%).
499/// This is exactly the precondition the outer tier was introduced for ("a grid
500/// that exceeds L2 still re-fetches A/B from DRAM"); without the check the tier
501/// also engages on grids that never leave the LLC.
502fn outer_tier_pays(
503    m_panels: usize,
504    n_panels: usize,
505    mr: usize,
506    nr: usize,
507    k: usize,
508    elem_bytes: usize,
509    llc_bytes: usize,
510) -> bool {
511    let working_set = m_panels
512        .saturating_mul(mr)
513        .saturating_add(n_panels.saturating_mul(nr))
514        .saturating_mul(k)
515        .saturating_mul(elem_bytes);
516    llc_bytes > 0 && working_set > llc_bytes
517}
518
519/// Outer (L3) super-block edge, or `usize::MAX` (one block over the whole
520/// rectangle, i.e. no outer tier) when no usable L3 is detected or the working set
521/// already fits it (see [`outer_tier_pays`]). Never smaller than the inner edge
522/// `inner`.
523///
524/// `llc_share` is how many rectangles are walked concurrently: the LLC is shared,
525/// so each walker may only assume its slice of it. Sizing every chunk of a
526/// multi-threaded dispatch against the whole LLC would have them evict each
527/// other's super-blocks.
528#[inline]
529#[allow(clippy::too_many_arguments)]
530fn outer_block_edge(
531    mr: usize,
532    nr: usize,
533    k: usize,
534    elem_bytes: usize,
535    inner: usize,
536    m_panels: usize,
537    n_panels: usize,
538    llc_share: usize,
539) -> usize {
540    let Some((llc, budget)) = l3_block_budget_bytes() else { return usize::MAX };
541    let share = llc_share.max(1);
542    if !outer_tier_pays(m_panels, n_panels, mr, nr, k, elem_bytes, llc / share) {
543        return usize::MAX;
544    }
545    block_edge_for(budget / share, mr, nr, k, elem_bytes, BLK_L3_MAX).max(inner)
546}
547
548/// Visit every `(ia, ib)` tile of the `m × n` panel rectangle exactly once,
549/// blocked two levels deep: an outer `blk_outer` super-block (L3-resident) holds
550/// inner `blk` blocks (L2-resident). `col_outer` selects the within-block inner
551/// order (B-reuse vs A-reuse). When `blk_outer` spans the whole rectangle the
552/// outer loop runs once and this is exactly the single-level inner walk. Pure
553/// tile reordering ⇒ no result changes; extracted so the nesting can be
554/// unit-tested independently of the kernel.
555#[inline]
556fn for_each_blocked_tile(
557    m: Range<usize>,
558    n: Range<usize>,
559    blk: usize,
560    blk_outer: usize,
561    col_outer: bool,
562    mut f: impl FnMut(usize, usize) -> TractResult<()>,
563) -> TractResult<()> {
564    let blk = blk.max(1);
565    let blk_outer = blk_outer.max(blk);
566    let mut jb3 = n.start;
567    while jb3 < n.end {
568        let jb3_end = jb3.saturating_add(blk_outer).min(n.end);
569        let mut ja3 = m.start;
570        while ja3 < m.end {
571            let ja3_end = ja3.saturating_add(blk_outer).min(m.end);
572            let mut jb = jb3;
573            while jb < jb3_end {
574                let jb_end = jb.saturating_add(blk).min(jb3_end);
575                let mut ja = ja3;
576                while ja < ja3_end {
577                    let ja_end = ja.saturating_add(blk).min(ja3_end);
578                    if col_outer {
579                        for ib in jb..jb_end {
580                            for ia in ja..ja_end {
581                                f(ia, ib)?;
582                            }
583                        }
584                    } else {
585                        for ia in ja..ja_end {
586                            for ib in jb..jb_end {
587                                f(ia, ib)?;
588                            }
589                        }
590                    }
591                    ja = ja_end;
592                }
593                jb = jb_end;
594            }
595            ja3 = ja3_end;
596        }
597        jb3 = jb3_end;
598    }
599    Ok(())
600}
601
602/// Tile walk over one panel rectangle — the whole grid on the single-thread
603/// path, one dispatch chunk on the rayon path — blocked into cache-sized panel
604/// blocks for locality (the naive nested loop re-streams the whole inner operand
605/// per outer panel at large k). Two tiers: an inner L2-resident block and, where
606/// an L3 is detected, an outer L3-resident super-block sized for one of
607/// `llc_share` concurrent walkers. Both tiers are gated on the rectangle's own
608/// extents, so a rectangle whose streamed operand already fits cache walks
609/// exactly the naive order. Reordering independent tiles changes no result —
610/// bit-exact with the naive loop at any chunking.
611#[inline]
612#[allow(clippy::too_many_arguments)]
613unsafe fn run_blocked<K: MatMatMulKer>(
614    ker: &K,
615    m: Range<usize>,
616    n: Range<usize>,
617    k: usize,
618    col_outer: bool,
619    llc_share: usize,
620    scratch: &ScratchSpaceImpl<K::Acc>,
621    non_linear: &[FusedSpec],
622) -> TractResult<()> {
623    unsafe {
624        let elem = K::Acc::datum_type().size_of();
625        let (mr, nr) = (ker.mr(), ker.nr());
626        let (m_panels, n_panels) = (m.len(), n.len());
627        let l2_share = llc_share.min(crate::cache::cache_info().l2_sharers_or_one());
628        let blk = inner_block_edge(mr, nr, k, elem, m_panels, n_panels, col_outer, l2_share);
629        let blk_outer = outer_block_edge(mr, nr, k, elem, blk, m_panels, n_panels, llc_share);
630        scratch.run_in_tls_scope(|scratch, tls| {
631            for_each_blocked_tile(m, n, blk, blk_outer, col_outer, |ia, ib| {
632                scratch.run_one_tile(ker, non_linear, tls, ia, ib)
633            })
634        })
635    }
636}
637
638/// Run the whole `m × n` output over the executor currently installed: as one
639/// rectangle when single-threaded, else split into the chunk grid
640/// [`chunk_grid`] picks. `col_outer` selects the tile order inside a rectangle
641/// (B-reuse vs A-reuse), from the fused ops' preference. `k` is used to size the
642/// cache blocking and the packed-operand footprint the chunk gate reads.
643unsafe fn run_with_scratch_space_2d<K: MatMatMulKer>(
644    ker: &K,
645    m: usize,
646    n: usize,
647    k: usize,
648    col_outer: bool,
649    scratch: &ScratchSpaceImpl<K::Acc>,
650    non_linear: &[FusedSpec],
651) -> TractResult<()> {
652    unsafe {
653        let (m_panels, n_panels) = (m.divceil(ker.mr()), n.divceil(ker.nr()));
654        #[cfg(feature = "multithread-mm")]
655        let chunk = |ia_start, ia_end, ib_start, ib_end, concurrency| {
656            run_blocked(
657                ker,
658                ia_start..ia_end,
659                ib_start..ib_end,
660                k,
661                col_outer,
662                concurrency,
663                scratch,
664                non_linear,
665            )
666        };
667        match crate::multithread::current_tract_executor() {
668            Executor::SingleThread => {
669                run_blocked(ker, 0..m_panels, 0..n_panels, k, col_outer, 1, scratch, non_linear)
670            }
671            #[cfg(feature = "multithread-mm")]
672            Executor::MultiThread(pool) => chunked_dispatch_rayon(
673                Some(&pool),
674                m_panels,
675                n_panels,
676                ker.mr(),
677                ker.nr(),
678                k,
679                K::Acc::datum_type().size_of(),
680                0,
681                chunk,
682            ),
683            #[cfg(feature = "multithread-mm")]
684            Executor::RayonGlobal => chunked_dispatch_rayon(
685                None,
686                m_panels,
687                n_panels,
688                ker.mr(),
689                ker.nr(),
690                k,
691                K::Acc::datum_type().size_of(),
692                0,
693                chunk,
694            ),
695        }
696    }
697}
698
699/// Chunks per thread the 2D dispatch aims for when the extra chunks are worth
700/// their re-reads (see [`chunks_per_thread`]). Above one, rayon can steal work
701/// when threads progress unevenly — a contended core, an E-core on a big.LITTLE
702/// part, a chunk carrying more border tiles — so a straggler costs at most its
703/// share rather than the whole grid's tail. Each extra chunk also re-reads a band
704/// of the packed operands, and that cost grows as `sqrt(chunks)` against a linear
705/// gain in slack, which is what keeps this small.
706#[cfg(feature = "multithread-mm")]
707const CHUNKS_PER_THREAD: usize = 4;
708
709/// Last-level cache above which the slack is always worth taking, whatever the
710/// problem: a part with this much LLC absorbs the re-reads of anything the
711/// dispatch is likely to see, so the gate below never fires and the chunk grid is
712/// bit-for-bit what it was before this gate existed.
713#[cfg(feature = "multithread-mm")]
714const CHUNK_SLACK_LLC_BYTES: usize = 2 * 1024 * 1024;
715
716/// Largest packed-operand footprint whose re-reads a small-cache part still
717/// absorbs. Below it the extra chunks re-read something the memory system is
718/// plausibly still holding and the slack is close to free; above it each extra
719/// chunk is a fresh DRAM stream.
720///
721/// A slider, not a separator. The two models that disagree about the slack do
722/// **not** occupy disjoint footprint ranges — over their threaded matmuls at
723/// 12x8/f32, InceptionV3 spans 0.16-23.8 MB (median 0.54) and MobileNet v2
724/// 0.20-6.95 MB (median 0.88) — so no boundary sorts one model from the other.
725/// What the boundary does is set the share of each model's FLOPs that keeps the
726/// slack, and the a53's InceptionV3 win and the a7/a9/beaglev MobileNet
727/// regression both scale with that share:
728///
729/// ```text
730///   boundary   incep FLOPs at cpt 1 -> a53      mobilenet at cpt 1 -> a7/a9/bv
731///     0.5 MB           88.5%          -7.3%          79.7%           +5.6%
732///     1.0 MB           85.9%          -7.0%          53.5%           +3.7%
733///     1.5 MB           76.9%          -6.3%          21.5%           +1.5%
734///     2.0 MB           62.0%          -5.1%          18.9%           +1.3%
735///     4.0 MB           41.5%          -3.4%           0.3%            0.0%
736/// ```
737///
738/// Predicted linearly from the two measured endpoints (all-cpt-1: a53 -8.2%,
739/// MobileNet +6.0..8.0%), which reproduce the measured 2 MB point to within half
740/// a point on both models. 1.5 MB is the knee: MobileNet's share falls off a
741/// cliff between 1.5 and 1.0 MB while InceptionV3's barely moves, so it buys most
742/// of the a53 win before the regression comes back.
743///
744/// `TRACT_MMM_CHUNK_SLACK_BYTES` moves it, which is how the table above would be
745/// measured rather than predicted.
746#[cfg(feature = "multithread-mm")]
747const CHUNK_SLACK_OPERAND_BYTES: usize = 3 * 512 * 1024;
748
749/// Machine facts the chunk gate reads, resolved once: `(llc_bytes, slack_bytes,
750/// override)`. Memoised like the cache probe underneath it — this sits on the
751/// per-matmul dispatch path, and neither the env vars nor the cache geometry
752/// change under a running process.
753#[cfg(feature = "multithread-mm")]
754fn chunk_gate_env() -> (usize, usize, Option<usize>) {
755    use std::sync::OnceLock;
756    static ENV: OnceLock<(usize, usize, Option<usize>)> = OnceLock::new();
757    *ENV.get_or_init(|| {
758        let llc = crate::cache::last_level_cache()
759            .map(|(bytes, _)| bytes)
760            .unwrap_or_else(|| crate::cache::cache_info().l2);
761        let slack =
762            crate::knobs::TRACT_MMM_CHUNK_SLACK_BYTES.get().unwrap_or(CHUNK_SLACK_OPERAND_BYTES);
763        (llc, slack, crate::knobs::TRACT_MMM_CHUNKS_PER_THREAD.get())
764    })
765}
766
767/// Chunks per thread [`chunk_grid`] aims for, for a dispatch whose packed
768/// operands occupy `packed_bytes`.
769///
770/// An extra chunk buys load-balance slack and costs one more pass over a packed
771/// operand. Whether that pass is worth taking is a property of the *problem* as
772/// much as of the machine: on a part with a large last-level cache every pass is
773/// a cache hit, so the slack is always taken; on a small-cache part it depends on
774/// whether the operands are small enough for the memory system to still be
775/// holding them. Gating on the machine alone takes the slack away from the small
776/// problems that were paying nothing for it.
777///
778/// `TRACT_MMM_CHUNKS_PER_THREAD` overrides the choice outright, and
779/// `TRACT_MMM_CHUNK_SLACK_BYTES` moves the footprint boundary. Both are resolved
780/// once, like the cache probe they sit next to.
781#[cfg(feature = "multithread-mm")]
782fn chunks_per_thread(packed_bytes: usize) -> usize {
783    let (llc, slack, over) = chunk_gate_env();
784    resolve_chunks_per_thread(over, llc, slack, packed_bytes)
785}
786
787/// Pure resolution of [`chunks_per_thread`] (factored out so the gate is testable
788/// without the host's cache geometry, which decides it otherwise).
789#[cfg(feature = "multithread-mm")]
790fn resolve_chunks_per_thread(
791    override_cpt: Option<usize>,
792    llc: usize,
793    slack_bytes: usize,
794    packed_bytes: usize,
795) -> usize {
796    if let Some(n) = override_cpt {
797        return n.max(1);
798    }
799    // A big enough LLC absorbs the re-reads whatever the problem: unchanged.
800    if llc >= CHUNK_SLACK_LLC_BYTES {
801        return CHUNKS_PER_THREAD;
802    }
803    if packed_bytes <= slack_bytes { CHUNKS_PER_THREAD } else { 1 }
804}
805
806/// Bytes the packed operands of a dispatch occupy: A is `n_panels_m·mr` rows and
807/// B `n_panels_n·nr` columns, both over `k`, both padded to whole panels — which
808/// is exactly what the packers wrote and the chunks re-read.
809#[cfg(feature = "multithread-mm")]
810fn packed_operand_bytes(
811    n_panels_m: usize,
812    n_panels_n: usize,
813    mr: usize,
814    nr: usize,
815    k: usize,
816    elem: usize,
817) -> usize {
818    (n_panels_m.saturating_mul(mr).saturating_add(n_panels_n.saturating_mul(nr)))
819        .saturating_mul(k)
820        .saturating_mul(elem)
821}
822
823/// Chunk grid for the 2D dispatch: `(nchunks_m, nchunks_n, dr_m, dr_n)`.
824///
825/// Aims for `cpt · nth` chunks — see [`chunks_per_thread`] for where `cpt` comes
826/// from — and shapes them to minimise how
827/// often the packed operands are re-read: a chunk covering `dr_m × dr_n` panels
828/// reads `dr_m·mr·k` of A and `dr_n·nr·k` of B, so over the whole grid A is read
829/// `nchunks_n` times and B `nchunks_m` times. Minimising
830/// `nchunks_n·m + nchunks_m·n` at a fixed chunk count puts
831/// `nchunks_m = sqrt(chunks · m / n)` — chunks as square as the operands'
832/// extents, rather than a band across one axis.
833///
834/// Cache locality *inside* a chunk is [`run_blocked`]'s job, not this function's;
835/// chunk *shape* therefore tracks the operands' extents and chunk *count* the
836/// thread count, with the cache entering only through `cpt`.
837///
838/// Both panel counts must be non-zero; the dispatcher returns early on an empty
839/// grid.
840#[cfg(feature = "multithread-mm")]
841fn chunk_grid(
842    n_panels_m: usize,
843    n_panels_n: usize,
844    mr: usize,
845    nr: usize,
846    nth: usize,
847    cpt: usize,
848) -> (usize, usize, usize, usize) {
849    let chunks = (cpt * nth).max(1);
850    let (m, n) = (n_panels_m * mr, (n_panels_n * nr).max(1));
851    let nchunks_m = (chunks.saturating_mul(m) / n).isqrt().clamp(1, n_panels_m);
852    let nchunks_n = (chunks / nchunks_m).clamp(1, n_panels_n);
853    let nchunks_m = (chunks / nchunks_n).clamp(1, n_panels_m);
854    let dr_m = n_panels_m.div_ceil(nchunks_m);
855    let dr_n = n_panels_n.div_ceil(nchunks_n);
856    // Recount from the edges: `div_ceil` can make the last chunk of an axis land
857    // entirely outside the grid, and an empty work item is a wasted dispatch.
858    (n_panels_m.div_ceil(dr_m), n_panels_n.div_ceil(dr_n), dr_m, dr_n)
859}
860
861/// Dispatch the `m_panels × n_panels` panel grid across the rayon path, split into
862/// the 2D chunk grid [`chunk_grid`] picks. Grids below
863/// [`crate::multithread::current_threading_panel_threshold`] run whole on the
864/// calling thread instead.
865///
866/// The closure receives **chunk bounds** (`ia_start, ia_end, ib_start, ib_end`)
867/// plus the number of chunks running concurrently, not per-tile indices. Chunk
868/// bounds let it amortise per-worker setup (e.g.
869/// `ScratchSpaceImpl::run_in_tls_scope`) over all the tiles in the chunk; the
870/// concurrency lets it size shared-cache blocking against the share it actually
871/// gets. The closure is invoked exactly once per rayon work item, and once in
872/// total with a concurrency of 1 on the below-threshold path.
873///
874/// `pool`:
875///   * `Some(p)` with `p.current_num_threads() > 1` → scoped via `p.install`
876///     (native, custom pool path).
877///   * `Some(p)` with single-thread pool, or `None` → dispatched via
878///     `into_par_iter` directly, which uses rayon's GLOBAL pool. This is
879///     the only working path on `wasm32-unknown-unknown` via
880///     `wasm_bindgen_rayon::init_thread_pool`.
881#[cfg(feature = "multithread-mm")]
882#[allow(clippy::too_many_arguments)]
883unsafe fn chunked_dispatch_rayon<F>(
884    pool: Option<&rayon::ThreadPool>,
885    n_panels_m: usize,
886    n_panels_n: usize,
887    mr: usize,
888    nr: usize,
889    k: usize,
890    elem: usize,
891    macs: usize,
892    run_chunk: F,
893) -> TractResult<()>
894where
895    F: Fn(usize, usize, usize, usize, usize) -> TractResult<()> + Sync,
896{
897    use rayon::prelude::*;
898    if n_panels_m == 0 || n_panels_n == 0 {
899        return Ok(());
900    }
901    if n_panels_m * n_panels_n < crate::multithread::current_threading_panel_threshold()
902        && macs < crate::multithread::current_threading_mac_threshold()
903    {
904        // Below the threading threshold: run the whole grid as a single chunk
905        // on the calling thread. Closure handles its own TLS scope.
906        return run_chunk(0, n_panels_m, 0, n_panels_n, 1);
907    }
908    let use_global = pool.is_none_or(|p| p.current_num_threads() <= 1);
909    let cpt = chunks_per_thread(packed_operand_bytes(n_panels_m, n_panels_n, mr, nr, k, elem));
910    let body = || {
911        let nth = rayon::current_num_threads();
912        let (nchunks_m, nchunks_n, dr_m, dr_n) =
913            chunk_grid(n_panels_m, n_panels_n, mr, nr, nth, cpt);
914        let total = nchunks_m * nchunks_n;
915        let concurrency = nth.min(total);
916        (0..total).into_par_iter().try_for_each(|idx| {
917            let im = idx % nchunks_m;
918            let in_ = idx / nchunks_m;
919            let ia_start = im * dr_m;
920            let ia_end = (ia_start + dr_m).min(n_panels_m);
921            let ib_start = in_ * dr_n;
922            let ib_end = (ib_start + dr_n).min(n_panels_n);
923            run_chunk(ia_start, ia_end, ib_start, ib_end, concurrency)
924        })
925    };
926    if use_global { body() } else { pool.unwrap().install(body) }
927}
928
929#[cfg(test)]
930mod blocked_walk_tests {
931    use super::*;
932    use std::collections::HashSet;
933
934    fn collect(
935        m: Range<usize>,
936        n: Range<usize>,
937        blk: usize,
938        blk_outer: usize,
939        col_outer: bool,
940    ) -> Vec<(usize, usize)> {
941        let mut v = Vec::new();
942        for_each_blocked_tile(m, n, blk, blk_outer, col_outer, |ia, ib| {
943            v.push((ia, ib));
944            Ok(())
945        })
946        .unwrap();
947        v
948    }
949
950    /// Every tile of the rectangle is visited exactly once, for both inner orders
951    /// and a range of (blk, blk_outer) — single-tier (outer = MAX), two-tier, and
952    /// degenerate edges. Coverage being a permutation is what makes the walk
953    /// bit-exact with the naive loop. Offset rectangles are the dispatch chunks.
954    #[test]
955    fn covers_every_tile_once() {
956        for &(m, n) in &[(1, 1), (3, 5), (16, 16), (40, 7), (7, 40), (80, 80)] {
957            for &(m0, n0) in &[(0, 0), (3, 11)] {
958                // usize::MAX is how both tiers say "do not block"; on an offset
959                // rectangle the edge arithmetic must not overflow past the end.
960                for &blk in &[1, 3, 16, usize::MAX] {
961                    for &blk_outer in &[blk, blk.saturating_add(1), 64, usize::MAX] {
962                        for &col_outer in &[false, true] {
963                            let tiles = collect(m0..m0 + m, n0..n0 + n, blk, blk_outer, col_outer);
964                            assert_eq!(
965                                tiles.len(),
966                                m * n,
967                                "m={m} n={n} blk={blk} outer={blk_outer}"
968                            );
969                            let set: HashSet<_> = tiles.iter().copied().collect();
970                            assert_eq!(
971                                set.len(),
972                                m * n,
973                                "duplicate tiles m={m} n={n} blk={blk} outer={blk_outer}"
974                            );
975                            for ia in m0..m0 + m {
976                                for ib in n0..n0 + n {
977                                    assert!(set.contains(&(ia, ib)), "missing ({ia},{ib})");
978                                }
979                            }
980                        }
981                    }
982                }
983            }
984        }
985    }
986
987    /// With no outer tier (blk_outer = MAX) the two-tier walk must emit the exact
988    /// same order as the original single-level blocked loop — guarantees the L3
989    /// path is a pure no-op on hardware without a detectable L3.
990    #[test]
991    fn outer_max_matches_single_level() {
992        for &(m, n) in &[(40, 7), (80, 80), (13, 29)] {
993            for &blk in &[1, 4, 16] {
994                for &col_outer in &[false, true] {
995                    let two_tier = collect(0..m, 0..n, blk, usize::MAX, col_outer);
996                    let mut single = Vec::new();
997                    let mut jb = 0;
998                    while jb < n {
999                        let jb_end = (jb + blk).min(n);
1000                        let mut ja = 0;
1001                        while ja < m {
1002                            let ja_end = (ja + blk).min(m);
1003                            if col_outer {
1004                                for ib in jb..jb_end {
1005                                    for ia in ja..ja_end {
1006                                        single.push((ia, ib));
1007                                    }
1008                                }
1009                            } else {
1010                                for ia in ja..ja_end {
1011                                    for ib in jb..jb_end {
1012                                        single.push((ia, ib));
1013                                    }
1014                                }
1015                            }
1016                            ja = ja_end;
1017                        }
1018                        jb = jb_end;
1019                    }
1020                    assert_eq!(two_tier, single, "m={m} n={n} blk={blk} col_outer={col_outer}");
1021                }
1022            }
1023        }
1024    }
1025
1026    /// The outer tier engages only when the packed working set spills the LLC.
1027    /// A grid that already fits stays single-level (the reorder buys no reuse and
1028    /// only hurts prefetch — the voicecom_float/Orin regression).
1029    #[test]
1030    fn outer_tier_gated_on_working_set_spilling_llc() {
1031        let llc = 2 * 1024 * 1024; // 2 MiB, f32 (elem = 4)
1032        // Small grid: (64·8 + 8·8)·64·4 ≈ 144 KiB ⇒ fits ⇒ no outer tier.
1033        assert!(!outer_tier_pays(64, 8, 8, 8, 64, 4, llc));
1034        // Large grid: (256·8 + 256·8)·256·4 ≈ 4 MiB ⇒ spills ⇒ engage.
1035        assert!(outer_tier_pays(256, 256, 8, 8, 256, 4, llc));
1036        // A boundary working set equal to the LLC does not spill it.
1037        assert!(!outer_tier_pays(1, 0, llc, 0, 1, 1, llc));
1038        // Unknown LLC (0) never engages, whatever the grid.
1039        assert!(!outer_tier_pays(4096, 4096, 8, 8, 4096, 4, 0));
1040        // k = 0 (empty reduction) has no working set ⇒ never engages.
1041        assert!(!outer_tier_pays(4096, 4096, 8, 8, 0, 4, llc));
1042    }
1043
1044    /// Inner blocking engages only when the operand the walk re-streams — A for a
1045    /// column-outer order, B for a row-outer one — spills L2. A streamed operand
1046    /// that fits is re-read from cache, so blocking only hurts prefetch.
1047    #[test]
1048    fn inner_tier_gated_on_streamed_operand_spilling_l2() {
1049        let l2 = 1024 * 1024; // 1 MiB, f32 (elem = 4)
1050        // inception Conv2d_4a_3x3 grid (16×12 kernel), k=720.
1051        // col_outer streams A (m side, panels=12 r=16): 12·16·720·4 ≈ 540 KiB ⇒ fits.
1052        assert!(!inner_tier_pays(12, 16, 720, 4, l2));
1053        // row_outer streams B (n side, panels=421 r=12): 421·12·720·4 ≈ 14.5 MiB ⇒ spills.
1054        assert!(inner_tier_pays(421, 12, 720, 4, l2));
1055        // A large square (m side, panels=256 r=16, k=512): 256·16·512·4 ≈ 8 MiB ⇒ spills.
1056        assert!(inner_tier_pays(256, 16, 512, 4, l2));
1057        // Undetectable L2 (0) never engages — degrades to the naive loop.
1058        assert!(!inner_tier_pays(4096, 16, 4096, 4, 0));
1059        // k = 0 (empty reduction) has no working set.
1060        assert!(!inner_tier_pays(4096, 16, 0, 4, l2));
1061    }
1062
1063    /// Grids, kernel aspect ratios and thread counts worth checking the chunk
1064    /// grid against: skewed both ways, square, prime-ish, and the degenerate
1065    /// single-panel cases.
1066    #[cfg(feature = "multithread-mm")]
1067    const GRIDS: &[(usize, usize)] = &[
1068        (1, 1),
1069        (1, 5),
1070        (5, 1),
1071        (2, 3),
1072        (3, 3),
1073        (16, 96),
1074        (96, 16),
1075        (17, 17),
1076        (64, 64),
1077        (32, 384),
1078        (128, 128),
1079        (1, 4096),
1080        (4096, 1),
1081        (9, 1000),
1082    ];
1083
1084    #[cfg(feature = "multithread-mm")]
1085    const RATIOS: &[(usize, usize)] = &[(8, 8), (16, 4), (32, 32), (64, 1)];
1086
1087    /// The four numbers `chunk_grid` returns must tile the panel grid exactly:
1088    /// `chunked_dispatch_rayon` turns them into work items, so an empty chunk is
1089    /// a wasted dispatch, an overlap would double-compute a tile, and a gap would
1090    /// leave part of C uninitialised.
1091    #[cfg(feature = "multithread-mm")]
1092    #[test]
1093    fn chunk_grid_tiles_the_panel_grid() {
1094        for &(m, n) in GRIDS {
1095            for &(mr, nr) in RATIOS {
1096                for nth in [1usize, 2, 3, 4, 6, 8, 16, 64] {
1097                    for cpt in [1, CHUNKS_PER_THREAD] {
1098                        let (cm, cn, dr_m, dr_n) = chunk_grid(m, n, mr, nr, nth, cpt);
1099                        let ctx =
1100                            format!("{m}x{n} panels, {mr}x{nr} kernel, {nth} threads, cpt {cpt}");
1101                        let mut seen = vec![false; m * n];
1102                        for idx in 0..cm * cn {
1103                            let (im, in_) = (idx % cm, idx / cm);
1104                            let (a0, a1) = (im * dr_m, (im * dr_m + dr_m).min(m));
1105                            let (b0, b1) = (in_ * dr_n, (in_ * dr_n + dr_n).min(n));
1106                            assert!(a0 < a1 && b0 < b1, "empty chunk {idx} in {ctx}");
1107                            for ia in a0..a1 {
1108                                for ib in b0..b1 {
1109                                    assert!(!seen[ia * n + ib], "tile ({ia},{ib}) twice in {ctx}");
1110                                    seen[ia * n + ib] = true;
1111                                }
1112                            }
1113                        }
1114                        assert!(seen.iter().all(|s| *s), "tile left out in {ctx}");
1115                    }
1116                }
1117            }
1118        }
1119    }
1120
1121    /// Enough chunks to keep every thread fed, whenever the grid has that many
1122    /// panels to go round. Recounting the chunks from the edges is what makes this
1123    /// hold: naming more chunks than the edges cover would idle the difference.
1124    ///
1125    /// Only at the slack count. At `cpt == 1` the dispatch asks for exactly `nth`
1126    /// chunks and the `div_ceil` rounding can only give back fewer — a 1x5 panel
1127    /// grid over 4 threads lands 3 chunks — so a gated dispatch can leave a thread
1128    /// idle. That is the price the gate pays, and it buys the operand re-reads it
1129    /// saves; [`chunks_per_thread`] only takes it where those re-reads cost more.
1130    #[cfg(feature = "multithread-mm")]
1131    #[test]
1132    fn chunk_grid_feeds_every_thread() {
1133        for &(m, n) in GRIDS {
1134            for &(mr, nr) in RATIOS {
1135                for nth in [1usize, 2, 3, 4, 6, 8, 16, 64] {
1136                    let (cm, cn, ..) = chunk_grid(m, n, mr, nr, nth, CHUNKS_PER_THREAD);
1137                    assert!(
1138                        cm * cn >= nth.min(m * n),
1139                        "{cm}x{cn} chunks for {nth} threads on {m}x{n} panels"
1140                    );
1141                }
1142            }
1143        }
1144    }
1145
1146    /// A GEMV under the panel gate threads once its multiply-accumulates clear
1147    /// the MAC threshold, and stays inline below it. 32 panels of a 64x1 kernel
1148    /// is a 2048-row projection.
1149    #[cfg(all(feature = "multithread-mm", not(target_family = "wasm")))]
1150    #[test]
1151    fn long_k_gemv_threads_below_the_panel_gate() {
1152        use std::sync::atomic::AtomicUsize;
1153        use std::sync::atomic::Ordering;
1154        let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
1155        let work_items = |k: usize| {
1156            let calls = AtomicUsize::new(0);
1157            unsafe {
1158                chunked_dispatch_rayon(
1159                    Some(&pool),
1160                    32,
1161                    1,
1162                    64,
1163                    1,
1164                    0,
1165                    0,
1166                    2048 * k,
1167                    |_, _, _, _, _| {
1168                        calls.fetch_add(1, Ordering::Relaxed);
1169                        Ok(())
1170                    },
1171                )
1172            }
1173            .unwrap();
1174            calls.into_inner()
1175        };
1176        assert_eq!(work_items(64), 1);
1177        assert!(work_items(2048) > 1);
1178    }
1179
1180    /// The gate is a property of the problem, not only of the machine. On a part
1181    /// too small to absorb the re-reads, a dispatch whose packed operands stay
1182    /// under the slack budget still gets the full chunk count — that is the
1183    /// MobileNet case, which paid a regression when the gate looked at the
1184    /// machine alone — while one above it drops to one chunk per thread, which is
1185    /// the InceptionV3 case the gate exists for.
1186    #[cfg(feature = "multithread-mm")]
1187    #[test]
1188    fn chunk_gate_reads_the_problem_not_just_the_machine() {
1189        let small_llc = 512 * 1024;
1190        let slack = CHUNK_SLACK_OPERAND_BYTES;
1191        // MobileNet v2 pointwise, f32: 96x16x12544 packs to well under a megabyte.
1192        assert_eq!(
1193            resolve_chunks_per_thread(None, small_llc, slack, 806 * 1024),
1194            CHUNKS_PER_THREAD
1195        );
1196        // InceptionV3 conv, f32: 384x4032x64 packs to 7.2 MB.
1197        assert_eq!(resolve_chunks_per_thread(None, small_llc, slack, 7 * 1024 * 1024), 1);
1198        // A machine with cache to spare takes the slack either way, which is what
1199        // keeps this inert on the m1-max / i9 / Orin numbers.
1200        for bytes in [806 * 1024, 7 * 1024 * 1024] {
1201            let big_llc = CHUNK_SLACK_LLC_BYTES;
1202            assert_eq!(resolve_chunks_per_thread(None, big_llc, slack, bytes), CHUNKS_PER_THREAD);
1203        }
1204    }
1205
1206    /// An undetected cache reads as 0, which must not read as "small problem":
1207    /// the gate has to fall to one chunk per thread there, as it would on the
1208    /// smallest part it can see.
1209    #[cfg(feature = "multithread-mm")]
1210    #[test]
1211    fn chunk_gate_overrides_and_undetected_cache() {
1212        let slack = CHUNK_SLACK_OPERAND_BYTES;
1213        assert_eq!(resolve_chunks_per_thread(None, 0, slack, 7 * 1024 * 1024), 1);
1214        // The override wins over both, and never names zero chunks.
1215        assert_eq!(resolve_chunks_per_thread(Some(1), CHUNK_SLACK_LLC_BYTES, slack, 0), 1);
1216        assert_eq!(resolve_chunks_per_thread(Some(8), 0, slack, usize::MAX), 8);
1217        assert_eq!(resolve_chunks_per_thread(Some(0), 0, slack, usize::MAX), 1);
1218    }
1219
1220    /// The footprint the gate reads is what the packers actually wrote: both
1221    /// operands padded out to whole panels, over the full depth.
1222    #[cfg(feature = "multithread-mm")]
1223    #[test]
1224    fn packed_operand_bytes_counts_whole_panels() {
1225        // 32x8 panels of a 12x8 kernel over k=4032, f32.
1226        assert_eq!(packed_operand_bytes(32, 8, 12, 8, 4032, 4), (32 * 12 + 8 * 8) * 4032 * 4);
1227        // Saturating, so a degenerate shape gates conservatively rather than wrapping.
1228        assert_eq!(packed_operand_bytes(usize::MAX, 1, 12, 8, 4032, 4), usize::MAX);
1229    }
1230
1231    /// The grid is shaped to minimise packed-operand re-reads: A is read once per
1232    /// column of chunks and B once per row, so `nchunks_n·m + nchunks_m·n` is what
1233    /// the shape trades off. It must never cost more than a band across either
1234    /// axis at the same chunk count, which on a square grid costs 1.5x.
1235    #[cfg(feature = "multithread-mm")]
1236    #[test]
1237    fn chunk_grid_shape_beats_a_band_on_operand_traffic() {
1238        let traffic = |cm: usize, cn: usize, m: usize, n: usize| cn * m + cm * n;
1239        for &(m, n) in GRIDS {
1240            for &(mr, nr) in RATIOS {
1241                for nth in [2usize, 4, 8, 16] {
1242                    let (cm, cn, ..) = chunk_grid(m, n, mr, nr, nth, CHUNKS_PER_THREAD);
1243                    let chunks = cm * cn;
1244                    // Only a band that fits along the axis is a real alternative:
1245                    // one clamped shorter would be a different chunk count, and a
1246                    // smaller chunk count trivially re-reads less.
1247                    if chunks > m || chunks > n {
1248                        continue;
1249                    }
1250                    let (m_ext, n_ext) = (m * mr, n * nr);
1251                    let ours = traffic(cm, cn, m_ext, n_ext);
1252                    let band_m = traffic(chunks, 1, m_ext, n_ext);
1253                    let band_n = traffic(1, chunks, m_ext, n_ext);
1254                    assert!(
1255                        ours <= band_m.min(band_n),
1256                        "{cm}x{cn} costs {ours}, bands cost {band_m}/{band_n} \
1257                         on {m}x{n} panels, {mr}x{nr} kernel, {nth} threads"
1258                    );
1259                }
1260            }
1261        }
1262    }
1263}