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}