Skip to main content

faer/utils/
cost.rs

1//! The parallel rule: a region runs in parallel when its serial time is at least `PAYS` times
2//! the dispatch cost of every section it opens (design record: `threadteam`
3//! `docs/design/thresholds.md`).
4//!
5//! The dispatch cost belongs to the token ([`ParShape::dispatch_cost`]); the serial time of a
6//! kernel family is its own throughput constant, below. With the `threadteam` feature the rule
7//! and the costs are threadteam's; without it the Rayon cost and the rule are the same numbers
8//! kept here, so a build without the feature decides identically.
9
10use crate::ParShape;
11use core::num::NonZeroUsize;
12use core::time::Duration;
13
14#[cfg(feature = "threadteam")]
15pub use threadteam::{PAYS, pays_at, width_for_by};
16
17/// The multiple of the stack (mirrors `threadteam::PAYS`).
18#[cfg(not(feature = "threadteam"))]
19pub const PAYS: f64 = 3.0;
20
21/// `estimate >= PAYS * sections * dispatch_cost` (mirrors `threadteam::pays_at`).
22#[cfg(not(feature = "threadteam"))]
23pub fn pays_at(estimate: Duration, sections: u32, dispatch_cost: Duration) -> bool {
24	if dispatch_cost == Duration::MAX {
25		return false;
26	}
27	if sections == 0 {
28		return true;
29	}
30	estimate.as_secs_f64() >= PAYS * f64::from(sections) * dispatch_cost.as_secs_f64()
31}
32
33/// The class of the machine, keyed by the kernel dispatch it will take, not by the CPU model.
34/// Every table of this module (the serial rates, the Rayon dispatch costs) has one row per
35/// class. The same enum and the same detection are kept in sync by hand with
36/// `threadteam::HostClass` (faer also builds without the `threadteam` feature); a test compares
37/// the two when the feature is on.
38///
39/// Rates and dispatch costs of one row must come from the same machine in the same clock state:
40/// switching the boost on moves both by about 1.66 times with a stable ratio (within a few
41/// percent), so a row that mixes boosted rates with unboosted costs, or the reverse, shifts every
42/// floor.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum HostClass {
45	/// `x86_64` with AVX-512F at run time (`x86_64-avx512`).
46	X86_64Avx512,
47	/// `x86_64` with AVX2 and FMA, without AVX-512F (`x86_64-avx2`).
48	X86_64Avx2,
49	/// Any other `x86_64` (`x86_64-scalar`).
50	X86_64Scalar,
51	/// `aarch64` on macOS (`aarch64-apple`).
52	Aarch64Apple,
53	/// Any other `aarch64` (`aarch64`).
54	Aarch64,
55	/// Everything else (`generic`).
56	Generic,
57}
58
59impl HostClass {
60	/// Every class, in declaration order.
61	pub const ALL: [HostClass; 6] = [
62		HostClass::X86_64Avx512,
63		HostClass::X86_64Avx2,
64		HostClass::X86_64Scalar,
65		HostClass::Aarch64Apple,
66		HostClass::Aarch64,
67		HostClass::Generic,
68	];
69
70	/// The class of this host, detected at the first call and cached. With `std`, `x86_64` uses
71	/// run time feature detection; without it the target features of the build decide.
72	pub fn detect() -> Self {
73		use core::sync::atomic::{AtomicU8, Ordering};
74		static CLASS: AtomicU8 = AtomicU8::new(0);
75		let c = CLASS.load(Ordering::Relaxed);
76		if c != 0 {
77			return Self::ALL[c as usize - 1];
78		}
79		let class = Self::detect_uncached();
80		let idx = Self::ALL.iter().position(|&k| k == class).unwrap();
81		CLASS.store(idx as u8 + 1, Ordering::Relaxed);
82		class
83	}
84
85	#[cfg(all(target_arch = "x86_64", feature = "std"))]
86	fn detect_uncached() -> Self {
87		if std::arch::is_x86_feature_detected!("avx512f") {
88			HostClass::X86_64Avx512
89		} else if std::arch::is_x86_feature_detected!("avx2") && std::arch::is_x86_feature_detected!("fma") {
90			HostClass::X86_64Avx2
91		} else {
92			HostClass::X86_64Scalar
93		}
94	}
95
96	#[cfg(all(target_arch = "x86_64", not(feature = "std")))]
97	fn detect_uncached() -> Self {
98		if cfg!(target_feature = "avx512f") {
99			HostClass::X86_64Avx512
100		} else if cfg!(all(target_feature = "avx2", target_feature = "fma")) {
101			HostClass::X86_64Avx2
102		} else {
103			HostClass::X86_64Scalar
104		}
105	}
106
107	#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
108	fn detect_uncached() -> Self {
109		HostClass::Aarch64Apple
110	}
111
112	#[cfg(all(target_arch = "aarch64", not(target_os = "macos")))]
113	fn detect_uncached() -> Self {
114		HostClass::Aarch64
115	}
116
117	#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
118	fn detect_uncached() -> Self {
119		HostClass::Generic
120	}
121
122	/// The class name used in the docs and the tables.
123	pub const fn name(self) -> &'static str {
124		match self {
125			HostClass::X86_64Avx512 => "x86_64-avx512",
126			HostClass::X86_64Avx2 => "x86_64-avx2",
127			HostClass::X86_64Scalar => "x86_64-scalar",
128			HostClass::Aarch64Apple => "aarch64-apple",
129			HostClass::Aarch64 => "aarch64",
130			HostClass::Generic => "generic",
131		}
132	}
133}
134
135/// Cost of one section started from outside the pool (`threadteam::DispatchKind::Outside`) at
136/// width `w` on the host class `class`: the Rayon route of faer enters the global or installed
137/// pool from the calling thread. The row of the class, in nanoseconds at widths 2, 4, 6 (the
138/// model of threadteam: linear between the anchors, flat below 2 and above 6):
139///
140/// * `x86_64-avx512`: 9.7, 10.1, 8.7 us (AMD Ryzen AI 9 HX 470, boost off, 8 threads);
141/// * `aarch64-apple`: 14, 18, 19 us (Apple M5 Max);
142/// * every other class: 40 us at every width (conservative, unmeasured).
143///
144/// Mirrors `threadteam::DispatchCosts::for_class(class)` for builds without the `threadteam`
145/// feature (a test compares the two when it is on).
146#[cfg(any(all(feature = "rayon", not(feature = "threadteam")), test))]
147#[cfg_attr(feature = "threadteam", allow(dead_code))]
148pub(crate) fn outside_cost_of(class: HostClass, w: NonZeroUsize) -> Duration {
149	width_model(outside_anchors(class), w)
150}
151
152#[cfg(any(all(feature = "rayon", not(feature = "threadteam")), test))]
153#[cfg_attr(feature = "threadteam", allow(dead_code))]
154const fn outside_anchors(class: HostClass) -> [u64; 3] {
155	match class {
156		HostClass::X86_64Avx512 => [9_700, 10_100, 8_700],
157		HostClass::Aarch64Apple => [14_000, 18_000, 19_000],
158		_ => [40_000; 3],
159	}
160}
161
162/// Cost of one section started by a worker of the Rayon pool
163/// (`threadteam::DispatchKind::InPool`) at width `w` on the host class `class`: nothing is
164/// installed, the caller joins its own pool. A caller that holds the spindle group is such a
165/// worker (`spindle::with_lock` runs its closure on a pool thread, and `for_each` then reuses
166/// the group's spinning workers: 2.5 us at width 8 and 1.4 us at width 4 for an empty section,
167/// against 6.8 and 4.6 us from outside, measured on the Zen 5 below). The row of the class, in
168/// nanoseconds at widths 2, 4, 6:
169///
170/// * `x86_64-avx512`: 3.0, 3.6, 3.5 us;
171/// * `aarch64-apple`: 6.0, 11, 5.5 us;
172/// * every other class: 15 us at every width.
173///
174/// Mirrors `threadteam::DispatchCosts::for_class(class)` for builds without the `threadteam`
175/// feature (a test compares the two when it is on).
176#[cfg(any(all(feature = "rayon", not(feature = "threadteam")), test))]
177#[cfg_attr(feature = "threadteam", allow(dead_code))]
178pub(crate) fn in_pool_cost_of(class: HostClass, w: NonZeroUsize) -> Duration {
179	width_model(in_pool_anchors(class), w)
180}
181
182#[cfg(any(all(feature = "rayon", not(feature = "threadteam")), test))]
183#[cfg_attr(feature = "threadteam", allow(dead_code))]
184const fn in_pool_anchors(class: HostClass) -> [u64; 3] {
185	match class {
186		HostClass::X86_64Avx512 => [3_000, 3_600, 3_500],
187		HostClass::Aarch64Apple => [6_000, 11_000, 5_500],
188		_ => [15_000; 3],
189	}
190}
191
192/// The width model of the tables: linear between the anchors at widths 2, 4 and 6, flat below 2
193/// and above 6.
194#[cfg(any(all(feature = "rayon", not(feature = "threadteam")), test))]
195#[cfg_attr(feature = "threadteam", allow(dead_code))]
196fn width_model(anchors: [u64; 3], w: NonZeroUsize) -> Duration {
197	let w = w.get() as i64;
198	let ns = if w <= 2 {
199		anchors[0] as i64
200	} else if w >= 6 {
201		anchors[2] as i64
202	} else {
203		let i = (w > 4) as usize;
204		let (w0, w1) = (2 + 2 * i as i64, 4 + 2 * i as i64);
205		let (c0, c1) = (anchors[i] as i64, anchors[i + 1] as i64);
206		c0 + (c1 - c0) * (w - w0) / (w1 - w0)
207	};
208	Duration::from_nanos(ns as u64)
209}
210
211/// Whether the calling thread is a worker of the Rayon pool, where a section of the Rayon route
212/// costs the `InPool` row and not the `Outside` one. This is the state of a caller inside
213/// `spindle::with_lock` (the benchmark harness, and every caller that holds the group around a
214/// run of factorizations), and of a faer call made from a task of a Rayon pool.
215#[cfg(feature = "rayon")]
216#[inline]
217pub(crate) fn caller_in_pool() -> bool {
218	rayon::current_thread_index().is_some()
219}
220
221/// [`outside_cost_of`] or [`in_pool_cost_of`] for the class of this host, by where the caller is.
222#[cfg(all(feature = "rayon", not(feature = "threadteam")))]
223pub(crate) fn rayon_cost(w: NonZeroUsize) -> Duration {
224	if caller_in_pool() {
225		in_pool_cost_of(HostClass::detect(), w)
226	} else {
227		outside_cost_of(HostClass::detect(), w)
228	}
229}
230
231/// The cheapest [`rayon_cost`] over all widths: the model is linear between its anchors, so the
232/// cheapest width is one of them (the tables are not monotonic: the `x86_64-avx512` outside row is
233/// cheapest at 6 and above, the `aarch64-apple` in pool row at 6 too).
234#[cfg(all(feature = "rayon", not(feature = "threadteam")))]
235pub(crate) fn min_rayon_cost() -> Duration {
236	let class = HostClass::detect();
237	let anchors = if caller_in_pool() { in_pool_anchors(class) } else { outside_anchors(class) };
238	Duration::from_nanos(anchors[0].min(anchors[1]).min(anchors[2]))
239}
240
241/// Serial throughput of the GEMM kernel, nanoseconds per multiply-add of the dtype (a complex
242/// one for `c32`/`c64`), of the host class ([`rates`]).
243pub mod ns_per_mac {
244	use super::rates;
245	/// `f64`.
246	pub fn f64() -> f64 {
247		rates().gemm_mac.real
248	}
249	/// `c64`.
250	pub fn c64() -> f64 {
251		rates().gemm_mac.cplx
252	}
253	/// `f32`: measured on the classes that have a measurement, half of `f64` on the others.
254	pub fn f32() -> f64 {
255		rates().gemm_single.real
256	}
257	/// `c32`: measured on the classes that have a measurement, half of `c64` on the others.
258	pub fn c32() -> f64 {
259		rates().gemm_single.cplx
260	}
261}
262
263/// Serial time of one row swap of one column in the LU swap stage on the Apple M5 Max,
264/// nanoseconds (the `aarch64-apple` value of [`Rates::lu_swap`]; the other classes have their own): random pivot
265/// rows, columns of 64 to 1024 `f64`, cache resident, median of 0.31 to 1.0 over
266/// `m` 128 to 1024 and 16 to 128 swaps per column (Apple M5 Max, one thread; memory bound, so
267/// the same for every dtype of the same element width).
268pub const NS_PER_SWAP: f64 = 0.55;
269
270/// Floor of the parallel regions of a factorization family: the one type of every
271/// `par_threshold` field (LU, full-pivoting LU, Bunch-Kaufman, QR with column pivoting,
272/// bidiagonalization, Hessenberg and tridiagonal reductions).
273///
274/// The default is [`Derived`](Self::Derived): each region is decided by [`ParShape::pays`], its
275/// serial time (units of work times the family's serial throughput, [`rate`]) against the
276/// dispatch cost of the parallelism it would use and the number of sections it opens.
277#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
278pub enum ParThreshold {
279	/// Derive the floor of every region from its serial time and the dispatch cost.
280	#[default]
281	Derived,
282	/// A fixed floor in the work unit of the family (documented where the family declares its
283	/// gate: elements of the updated trailing block for the reductions, `f64` elements for LU), a
284	/// region runs in parallel from that many units. `Fixed(0)` and `Fixed(1)` make every region
285	/// parallel that has any work.
286	Fixed(usize),
287	/// Never run a region in parallel.
288	Never,
289}
290
291/// The integer form used by the C API: `0` is [`Derived`](ParThreshold::Derived),
292/// `usize::MAX` is [`Never`](ParThreshold::Never), any other value is
293/// [`Fixed`](ParThreshold::Fixed).
294impl From<usize> for ParThreshold {
295	fn from(value: usize) -> Self {
296		match value {
297			0 => ParThreshold::Derived,
298			usize::MAX => ParThreshold::Never,
299			n => ParThreshold::Fixed(n),
300		}
301	}
302}
303
304/// See [`From<usize>`](ParThreshold#impl-From<usize>-for-ParThreshold); `Fixed(0)` is
305/// written as `1`, the same behaviour, because `0` means derived.
306impl From<ParThreshold> for usize {
307	fn from(value: ParThreshold) -> Self {
308		match value {
309			ParThreshold::Derived => 0,
310			ParThreshold::Fixed(n) => n.max(1),
311			ParThreshold::Never => usize::MAX,
312		}
313	}
314}
315
316impl ParThreshold {
317	/// Whether a region of `units` of work in the unit of `rate` that opens `sections`
318	/// sections runs in parallel on `shape`, at the full degree of the shape.
319	///
320	/// [`Derived`](Self::Derived) is [`ParShape::pays_full`] of `units * rate`; an explicit
321	/// [`Fixed`](Self::Fixed) floor keeps the previous rule, `units >= floor`.
322	#[cfg_attr(feature = "par-trace", track_caller)]
323	pub(crate) fn region_pays<T: crate::ComplexField>(
324		self,
325		shape: ParShape,
326		units: usize,
327		rate: Rate,
328		sections: u32,
329	) -> bool {
330		// a sequential shape, or a token of one participant (a `Team` of size 1), never pays:
331		// skip the estimate (a QR 128 asks hundreds of times)
332		if shape.degree() < 2 && !matches!(self, Self::Fixed(_)) {
333			return false;
334		}
335		let r = self.region_pays_impl::<T>(shape, units, rate, sections);
336		#[cfg(feature = "par-trace")]
337		trace_decision(shape, shape.degree(), units, rate.of::<T>(), sections, r, core::panic::Location::caller());
338		r
339	}
340
341	fn region_pays_impl<T: crate::ComplexField>(
342		self,
343		shape: ParShape,
344		units: usize,
345		rate: Rate,
346		sections: u32,
347	) -> bool {
348		match self {
349			Self::Never => false,
350			Self::Fixed(floor) => units >= floor,
351			Self::Derived => {
352				shape.pays_full(serial_time(units, rate.of::<T>()), sections)
353			},
354		}
355	}
356}
357
358/// Serial throughput of a kernel family, nanoseconds per unit of work for the real and the
359/// complex native scalars, per host class ([`Rates`]). Measured with one thread, L2 resident
360/// shapes; `f32` and `c32` take half of the `f64` and `c64` value (twice the lanes per register
361/// and half the bytes; checked for the GEMM only, where the Zen 5 measurement agrees to 0.2
362/// percent), a scalar that is not native takes the `f64` or `c64` value.
363#[derive(Copy, Clone, Debug, PartialEq)]
364pub struct Rate {
365	/// `f64` (and, halved, `f32`).
366	pub real: f64,
367	/// `c64` (and, halved, `c32`).
368	pub cplx: f64,
369}
370
371impl Rate {
372	/// Nanoseconds per unit for the scalar `T`.
373	pub fn of<T: crate::ComplexField>(self) -> f64 {
374		if const { T::IS_NATIVE_F32 } {
375			self.real / 2.0
376		} else if const { T::IS_NATIVE_C32 } {
377			self.cplx / 2.0
378		} else if const { T::IS_REAL } {
379			self.real
380		} else {
381			self.cplx
382		}
383	}
384}
385
386/// The serial throughput constants of the kernel families of one host class, one per family
387/// (design record: `threadteam` `docs/design/thresholds.md`, section 2.3). The unit is what the
388/// gate of the family counts. A step of a factorization is timed inside the whole serial
389/// factorization, so the constant includes the serial parts of a step (pivot search, Householder
390/// generation) and slightly overestimates the time of the part that is split.
391///
392/// The row of a class is [`Rates::for_class`]; [`rates`] is the row of the host. Sources:
393///
394/// * `x86_64-avx512`: one thread on an AMD Ryzen AI 9 HX 470 (Zen 5) at 2.0 GHz, boost off,
395///   median over shapes and rounds (`faer-benchmark`
396///   `data/probes/20261026-awake-team-linux/tables-consts-exec3.md`); the divide and conquer and
397///   sparse product from the region constants of the same file (`raw-exec3/rconsts-*.csv`).
398/// * `aarch64-apple`: one thread on an Apple M5 Max, QoS `UserInitiated`, L2 resident shapes,
399///   median over the shapes 96 to 768 and two processes
400///   (`data/probes/20261026-threshold-recalibration`).
401/// * every other class is unmeasured and takes the `generic` row: per entry the smaller of the
402///   measured rows, so that an unknown host keeps work serial a little longer than it must
403///   rather than dispatching too early.
404///
405/// A row's rates and the dispatch costs of the same class must come from the same machine and
406/// clock state ([`HostClass`]).
407#[derive(Copy, Clone, Debug, PartialEq)]
408pub struct Rates {
409	/// GEMM and every kernel counted in multiply-adds (triangular products and solves), per
410	/// multiply-add.
411	pub gemm_mac: Rate,
412	/// GEMM in single precision, per multiply-add: `real` is `f32`, `cplx` is `c32`.
413	pub gemm_single: Rate,
414	/// The multiply-adds of the block Householder apply, per multiply-add.
415	pub block_apply_mac: Rate,
416	/// Full-pivoting LU, one step: rank-one update fused with the pivot search, per element of
417	/// the trailing block `(m-k-1)*(n-k-1)`.
418	pub lu_full_pivot: Rate,
419	/// Bunch-Kaufman with full pivoting, one step: rank-1 or rank-2 update fused with the pivot
420	/// search, per element of the trailing block `rem*rem`.
421	pub bunch_kaufman: Rate,
422	/// QR with column pivoting, one step: fused update of the trailing block and the norms, per
423	/// element `(m-k-1)*(n-k-1)`.
424	pub qr_col_pivot: Rate,
425	/// Bidiagonalization, one step: the fused two-sided update and the matrix-vector product, per
426	/// element `(m-k-1)*(n-k-1)`.
427	pub bidiag: Rate,
428	/// Hessenberg reduction (unblocked), one step: two matrix-vector products and a rank-one
429	/// update, per element `n*n` of the trailing block.
430	pub hessenberg: Rate,
431	/// Tridiagonalization, one step: the symmetric fused update, per element `rem*rem/2` of the
432	/// triangle.
433	pub tridiag: Rate,
434	/// Matrix-vector product, per matrix element (the mean of the column major and row major
435	/// products).
436	pub matvec: Rate,
437	/// Divide and conquer bidiagonal SVD (`bidiag_svd`, both singular vectors, non deflating
438	/// data), the whole serial solve of size `m` per `m^2`; the join of size `n` has the two
439	/// halves as work, `2 (n/2)^2`. The `m^2` term (secular equation, deflation, permutations)
440	/// is larger than the merge products below `m` about 1900, so the unit is `m^2`; the
441	/// constant is the median over `m` 64 to 512 (it is size dependent: 48 to 62 on the M5 fit,
442	/// 145 to 178 on the Zen 5). Real types only (the complex value repeats the real one).
443	pub bidiag_svd_dc: Rate,
444	/// Divide and conquer symmetric tridiagonal EVD (`tridiag_evd`, eigenvectors on), the whole
445	/// serial solve of size `m` per `m^2`, used as `bidiag_svd_dc` (88 to 102 on the Zen 5 over
446	/// `m` 64 to 512). Real types only.
447	pub tridiag_evd_dc: Rate,
448	/// Triangular inverse (`invert_*_triangular`), the whole serial inverse of size `m` per
449	/// `m^3`; the join of size `n` has the two halves as work, `2 (n/2)^3`. The constant is the
450	/// median over `m` 64 to 512 and falls with the size (on the Zen 5, `f64` 0.047 per `m^3`
451	/// at 64 to 0.0145 at 512), so a join near the floor is a little overestimated.
452	pub triangular_inverse: Rate,
453	/// Sparse by sparse numeric product, per multiply-add of the symbolic flop count
454	/// (`SparseMatMulInfo`, free to read); the median over the measured densities and sizes.
455	pub sparse_matmul: Rate,
456	/// Rank-one Householder apply (`N == 1`), per element of the target `M*K`.
457	pub householder_rank_one: Rate,
458	/// One row swap of one column in the LU swap stage (random pivot rows, columns of 64 to 1024
459	/// elements, cache resident, one thread; memory bound, so the `c64` value is the larger
460	/// element width, not four times the `f64` one).
461	pub lu_swap: Rate,
462}
463
464const fn fmin(a: f64, b: f64) -> f64 {
465	if b < a { b } else { a }
466}
467
468const fn rmin(a: Rate, b: Rate) -> Rate {
469	Rate { real: fmin(a.real, b.real), cplx: fmin(a.cplx, b.cplx) }
470}
471
472const fn r(real: f64, cplx: f64) -> Rate {
473	Rate { real, cplx }
474}
475
476impl Rates {
477	/// Per entry the smaller value of the two rows.
478	const fn min_of(a: &Rates, b: &Rates) -> Rates {
479		Rates {
480			gemm_mac: rmin(a.gemm_mac, b.gemm_mac),
481			gemm_single: rmin(a.gemm_single, b.gemm_single),
482			block_apply_mac: rmin(a.block_apply_mac, b.block_apply_mac),
483			lu_full_pivot: rmin(a.lu_full_pivot, b.lu_full_pivot),
484			bunch_kaufman: rmin(a.bunch_kaufman, b.bunch_kaufman),
485			qr_col_pivot: rmin(a.qr_col_pivot, b.qr_col_pivot),
486			bidiag: rmin(a.bidiag, b.bidiag),
487			hessenberg: rmin(a.hessenberg, b.hessenberg),
488			tridiag: rmin(a.tridiag, b.tridiag),
489			matvec: rmin(a.matvec, b.matvec),
490			bidiag_svd_dc: rmin(a.bidiag_svd_dc, b.bidiag_svd_dc),
491			tridiag_evd_dc: rmin(a.tridiag_evd_dc, b.tridiag_evd_dc),
492			triangular_inverse: rmin(a.triangular_inverse, b.triangular_inverse),
493			sparse_matmul: rmin(a.sparse_matmul, b.sparse_matmul),
494			householder_rank_one: rmin(a.householder_rank_one, b.householder_rank_one),
495			lu_swap: rmin(a.lu_swap, b.lu_swap),
496		}
497	}
498
499	/// `x86_64-avx512`: Zen 5 at 2.0 GHz, boost off (see the type docs).
500	pub const X86_64_AVX512: Rates = Rates {
501		gemm_mac: r(0.0641, 0.2565),
502		gemm_single: r(0.0320, 0.1283),
503		block_apply_mac: r(0.0676, 0.2685),
504		lu_full_pivot: r(0.4011, 0.8679),
505		bunch_kaufman: r(0.8295, 1.3904),
506		qr_col_pivot: r(0.3645, 1.0320),
507		bidiag: r(1.0427, 2.1997),
508		hessenberg: r(0.8576, 2.2212),
509		tridiag: r(0.7695, 1.9252),
510		// mean of matvec_colmajor (0.1743, 0.3373) and matvec_rowmajor (0.1565, 0.5057)
511		matvec: r(0.1654, 0.4215),
512		bidiag_svd_dc: r(152.9, 152.9),
513		tridiag_evd_dc: r(97.4, 97.4),
514		triangular_inverse: r(0.01936, 0.07228),
515		// median over spmm_{f64,c64}_n{2000,4000,8000,20000}_d{4,6,8,12,16,40}
516		sparse_matmul: r(4.1315, 5.2342),
517		householder_rank_one: r(0.4783, 1.0303),
518		// Zen 5 at 2.0 GHz, one thread, a scratch probe of the swap loop: swaps of 64 to 256 rows over 768 to 960 columns, one
519		// thread: 0.98 (`f64`) and 1.30 (`c64`) ns, 1.0 at 256 rows; the 0.55 of the M5 was
520		// used for every class and made the swap stage of a 64 x 1024 LU 1.7 times too cheap
521		lu_swap: r(0.98, 1.30),
522	};
523
524	/// `aarch64-apple`: Apple M5 Max (the constants of the first calibration).
525	pub const AARCH64_APPLE: Rates = Rates {
526		gemm_mac: r(0.037, 0.165),
527		gemm_single: r(0.037 / 2.0, 0.165 / 2.0),
528		block_apply_mac: r(0.037, 0.165),
529		lu_full_pivot: r(0.216, 0.6),
530		bunch_kaufman: r(0.238, 0.38),
531		qr_col_pivot: r(0.202, 0.713),
532		bidiag: r(0.48, 1.36),
533		hessenberg: r(0.451, 1.57),
534		tridiag: r(0.339, 1.16),
535		matvec: r(0.1, 0.34),
536		bidiag_svd_dc: r(53.0, 53.0),
537		tridiag_evd_dc: r(31.0, 31.0),
538		triangular_inverse: r(0.0096, 0.0344),
539		sparse_matmul: r(1.5, 1.8),
540		householder_rank_one: r(0.212, 0.725),
541		lu_swap: r(NS_PER_SWAP, NS_PER_SWAP),
542	};
543
544	/// `generic` (every unmeasured class): per entry the smaller of the measured rows.
545	pub const GENERIC: Rates = Rates::min_of(&Rates::X86_64_AVX512, &Rates::AARCH64_APPLE);
546
547	/// The row of a host class.
548	pub fn for_class(class: HostClass) -> &'static Rates {
549		static AVX512: Rates = Rates::X86_64_AVX512;
550		static APPLE: Rates = Rates::AARCH64_APPLE;
551		static GENERIC: Rates = Rates::GENERIC;
552		match class {
553			HostClass::X86_64Avx512 => &AVX512,
554			HostClass::Aarch64Apple => &APPLE,
555			HostClass::X86_64Avx2 | HostClass::X86_64Scalar | HostClass::Aarch64 | HostClass::Generic => &GENERIC,
556		}
557	}
558}
559
560/// The rates of the host class of this process ([`HostClass::detect`]).
561pub fn rates() -> &'static Rates {
562	Rates::for_class(HostClass::detect())
563}
564
565/// The serial throughput constants of the host, one function per family ([`Rates`] has the
566/// units and the sources).
567pub mod rate {
568	use super::{Rate, rates};
569	macro_rules! families {
570		($($name:ident),* $(,)?) => {$(
571			#[doc = core::concat!("`Rates::", core::stringify!($name), "` of the host class.")]
572			pub fn $name() -> Rate { rates().$name }
573		)*};
574	}
575	families!(
576		gemm_mac,
577		block_apply_mac,
578		lu_full_pivot,
579		bunch_kaufman,
580		qr_col_pivot,
581		bidiag,
582		hessenberg,
583		tridiag,
584		matvec,
585		bidiag_svd_dc,
586		tridiag_evd_dc,
587		triangular_inverse,
588		sparse_matmul,
589		householder_rank_one,
590		lu_swap,
591	);
592}
593
594/// Nanoseconds per multiply-add of `T` for a GEMM, by the table above. Non-native scalars use
595/// the `f64` rate for real types and the `c64` rate for complex ones, a lower bound of their
596/// time, so such a region stays serial a little longer rather than dispatching too early.
597pub(crate) fn gemm_ns_per_mac<T: crate::ComplexField>() -> f64 {
598	if const { T::IS_NATIVE_F32 } {
599		ns_per_mac::f32()
600	} else if const { T::IS_NATIVE_C32 } {
601		ns_per_mac::c32()
602	} else if const { T::IS_REAL } {
603		ns_per_mac::f64()
604	} else {
605		ns_per_mac::c64()
606	}
607}
608
609
610/// Decision trace of the gates, for probes (feature `par-trace`). A gate calls
611/// [`trace::shape`] with the dimensions it decides on, and the next decision on the thread
612/// records them together with the units, the section count, the estimate and the answer.
613#[cfg(feature = "par-trace")]
614pub mod trace {
615	use std::sync::Mutex;
616	use std::sync::atomic::{AtomicBool, Ordering};
617	/// One decision.
618	#[derive(Clone, Debug)]
619	pub struct Record {
620		/// source line of the gate that asked (`#[track_caller]`)
621		pub site: &'static str,
622		/// the dimensions the gate named with [`shape`]
623		pub dims: [usize; 3],
624		/// units of work passed to the rule
625		pub units: usize,
626		/// sections passed to the rule
627		pub sections: u32,
628		/// estimated serial time in ns
629		pub estimate_ns: f64,
630		/// dispatch cost of one section at the shape's width, in ns
631		pub dispatch_ns: f64,
632		/// the decision
633		pub parallel: bool,
634	}
635	/// Override of the block apply column split gate, for probes: 0 the rule, 1 the previous
636	/// rule (`M * K * 4 * M >= gemm threshold`), 2 always parallel, 3 never.
637	pub static N_TASKS_MODE: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
638	static ON: AtomicBool = AtomicBool::new(false);
639	static LOG: Mutex<Vec<Record>> = Mutex::new(Vec::new());
640	std::thread_local! {
641		static DIMS: core::cell::Cell<(&'static str, [usize; 3])> = const { core::cell::Cell::new(("", [0; 3])) };
642	}
643	/// Start or stop recording; starting clears the log.
644	pub fn enable(on: bool) {
645		if on {
646			LOG.lock().unwrap().clear();
647		}
648		ON.store(on, Ordering::SeqCst);
649	}
650	/// Take the log.
651	pub fn take() -> Vec<Record> {
652		core::mem::take(&mut *LOG.lock().unwrap())
653	}
654	/// Name the dimensions of the next decision of this thread.
655	pub fn shape(label: &'static str, a: usize, b: usize, c: usize) {
656		DIMS.with(|d| d.set((label, [a, b, c])));
657	}
658	pub(crate) fn record(
659		loc: &'static core::panic::Location<'static>,
660		units: usize,
661		sections: u32,
662		estimate_ns: f64,
663		dispatch_ns: f64,
664		parallel: bool,
665	) {
666		if !ON.load(Ordering::Relaxed) {
667			return;
668		}
669		let (label, dims) = DIMS.with(|d| d.replace(("", [0; 3])));
670		let site: &'static str = if label.is_empty() {
671			Box::leak(format!("{}:{}", loc.file().rsplit('/').next().unwrap(), loc.line()).into_boxed_str())
672		} else {
673			label
674		};
675		LOG.lock().unwrap().push(Record { site, dims, units, sections, estimate_ns, dispatch_ns, parallel });
676	}
677}
678
679/// [`ParShape::pays`] of `par` at `width` participants, without building the shape. A gate is asked
680/// hundreds of times by a small factorization, and the shape of a team token copies its whole cost
681/// table on every call (`Par::shape`: 15 ns, the decision itself 40 ns, against 12 ns here, where the
682/// token answers from its own table). `width` is clamped to the degree, below 2 never pays.
683pub(crate) fn par_pays_time(par: &crate::Par, estimate: Duration, sections: u32, width: usize) -> bool {
684	let width = width.min(par.degree());
685	let Some(width) = NonZeroUsize::new(width).filter(|w| w.get() >= 2) else {
686		return false;
687	};
688	match par {
689		crate::Par::Seq => false,
690		#[cfg(feature = "threadteam")]
691		crate::Par::Team(team) => team.pays(estimate, sections, width),
692		other => other.shape().pays(estimate, sections, width),
693	}
694}
695
696/// The kind of section the Rayon route starts on the calling thread.
697#[cfg(all(feature = "rayon", feature = "threadteam"))]
698fn rayon_kind() -> threadteam::DispatchKind {
699	if caller_in_pool() {
700		threadteam::DispatchKind::InPool
701	} else {
702		threadteam::DispatchKind::Outside
703	}
704}
705
706/// The host table, a constant: built once, not on every gate.
707#[cfg(all(feature = "rayon", feature = "threadteam"))]
708fn host_costs() -> &'static threadteam::DispatchCosts {
709	static HOST: std::sync::OnceLock<threadteam::DispatchCosts> = std::sync::OnceLock::new();
710	HOST.get_or_init(threadteam::DispatchCosts::host_default)
711}
712
713/// Whether a region of serial time `estimate` that opens `sections` sections can pay on `par` at
714/// *some* width: `estimate >= PAYS * sections * min over widths of the cost` ([`ParShape::min_dispatch_cost`]).
715/// "Does not pay at the cheapest width" is the rejection that is right for any shape of table (a
716/// rising one included); it says nothing about the width a region then chooses.
717pub(crate) fn par_can_pay_time(par: &crate::Par, estimate: Duration, sections: u32) -> bool {
718	if par.degree() < 2 {
719		return false;
720	}
721	let min = match par {
722		crate::Par::Seq => Duration::MAX,
723		#[cfg(feature = "threadteam")]
724		crate::Par::Team(team) => return par_can_pay_time_team(team, estimate, sections),
725		other => other.shape().min_dispatch_cost(),
726	};
727	pays_at(estimate, sections, min)
728}
729
730/// [`par_can_pay_time`] for a team token.
731#[cfg(feature = "threadteam")]
732pub(crate) fn par_can_pay_time_team(team: &threadteam::TeamExec<'_>, estimate: Duration, sections: u32) -> bool {
733	team.size().get() >= 2
734		&& pays_at(estimate, sections, team.dispatch_costs().min_cost(threadteam::DispatchKind::Team))
735}
736
737/// Whether a region of `units` of work at `rate` that opens `sections` sections pays on `par`,
738/// at the full degree of `par` ([`ParShape::pays_full`]).
739#[cfg_attr(feature = "par-trace", track_caller)]
740pub(crate) fn par_pays<T: crate::ComplexField>(
741	par: &crate::Par,
742	units: usize,
743	rate: Rate,
744	sections: u32,
745) -> bool {
746	// a sequential token, or one of a single participant (a `Team` of size 1: it behaves exactly
747	// like `Par::Seq`), never pays: skip the estimate (a QR 128 asks 500 times)
748	if par.degree() < 2 {
749		return false;
750	}
751	let r = par_pays_time(par, serial_time(units, rate.of::<T>()), sections, par.degree());
752	#[cfg(feature = "par-trace")]
753	trace_decision(par.shape(), par.shape().degree(), units, rate.of::<T>(), sections, r, core::panic::Location::caller());
754	r
755}
756
757#[cfg(feature = "par-trace")]
758fn trace_decision(
759	shape: ParShape,
760	width: usize,
761	units: usize,
762	ns: f64,
763	sections: u32,
764	r: bool,
765	loc: &'static core::panic::Location<'static>,
766) {
767	let d = NonZeroUsize::new(width)
768		.map(|w| shape.dispatch_cost(w).as_secs_f64() * 1e9)
769		.unwrap_or(f64::INFINITY);
770	trace::record(loc, units, sections, units as f64 * ns, d, r);
771}
772
773/// Whether a join of two halves pays: one section of width 2 (the two halves are the
774/// participants; the budget is split between them, it is not a flat section over all of it), so
775/// the dispatch cost is that of the shape at width 2 (`Team` 3.0 us, `Rayon` from outside 14 us
776/// on the M5 table, against 1.4 and 19 at width 6). `units` of work at `rate`, `sections` joins.
777#[cfg_attr(feature = "par-trace", track_caller)]
778pub(crate) fn join_pays<T: crate::ComplexField>(
779	par: &crate::Par,
780	units: usize,
781	rate: Rate,
782	sections: u32,
783) -> bool {
784	if par.degree() < 2 {
785		return false;
786	}
787	let r = par_pays_time(par, serial_time(units, rate.of::<T>()), sections, 2);
788	#[cfg(feature = "par-trace")]
789	trace_decision(par.shape(), 2, units, rate.of::<T>(), sections, r, core::panic::Location::caller());
790	r
791}
792
793/// Whether the join of a recursion that halves a problem of size `n` pays: its work is the two
794/// halves, `2 (n/2)^power` units of `rate`, one join ([`join_pays`]). The rate is that of a whole serial
795/// solve of size `m` per `m^power`, so no recurrence is assumed beyond the power: the work of
796/// the subproblems at this level is what the two calls below it cost, and the merge after the
797/// join is not counted (it is not parallel through this join). `power` is 3 for the triangular
798/// inverse and 2 for the divide and conquer solves, whose measured time is `a m^2 + b m^3`
799/// with the first term dominating up to `m` about 1900.
800pub(crate) fn halves_pays<T: crate::ComplexField>(
801	par: &crate::Par,
802	n: usize,
803	rate: Rate,
804	power: u32,
805) -> bool {
806	let h = n / 2;
807	join_pays::<T>(par, h.saturating_pow(power).saturating_mul(2), rate, 1)
808}
809
810/// Estimated serial time of a block Householder apply with an `m x n` basis on an `m x k`
811/// target: the two products of the update, `products * m * n * k` multiply-adds at the GEMM rate
812/// (`products` is 2, or 1 when the row split already did the reduction), plus a streaming term
813/// per target element (the temporaries and the triangular steps, `m * k` elements at the rank one
814/// apply rate). Fitted over `m` 24 to 512, `n` 2 to 32 with one thread: the median error is 7
815/// percent (f64), 4 percent (c64); a single rate per multiply-add was 1.0 to 2.7 times too low
816/// for the narrow bases (`n` 2 to 8).
817pub(crate) fn block_apply_estimate<T: crate::ComplexField>(
818	m: usize,
819	n: usize,
820	k: usize,
821	products: usize,
822) -> Duration {
823	block_apply_estimate_with::<T>(rates(), m, n, k, products)
824}
825
826/// [`block_apply_estimate`] on the row `rt`.
827pub(crate) fn block_apply_estimate_with<T: crate::ComplexField>(
828	rt: &Rates,
829	m: usize,
830	n: usize,
831	k: usize,
832	products: usize,
833) -> Duration {
834	let macs = m.saturating_mul(n).saturating_mul(k).saturating_mul(products);
835	// summed in nanoseconds: one `Duration`, not two and an addition (a gate asked per block apply)
836	let ns = macs as f64 * rt.block_apply_mac.of::<T>() + m.saturating_mul(k) as f64 * rt.householder_rank_one.of::<T>();
837	Duration::from_secs_f64(ns * 1e-9)
838}
839
840/// Serial time of `count` units at `ns` nanoseconds each.
841pub(crate) fn serial_time(count: usize, ns: f64) -> Duration {
842	Duration::from_secs_f64(count as f64 * ns * 1e-9)
843}
844
845impl ParShape {
846	/// The cost of one parallel section of `width` participants on this shape, in the steady
847	/// state (sections back to back); [`Duration::MAX`] for a sequential shape or `width < 2`.
848	/// `width` is clamped to the degree of the shape.
849	///
850	/// * `Team`: the cost of the team token the shape was taken from (threadteam's
851	///   `DispatchKind::Team`, with the costs of that token's pool).
852	/// * `Rayon`: the cost of the Rayon route at the place the calling thread is: `InPool` when
853	///   it is a worker of the pool (a caller inside `spindle::with_lock` is one), `Outside`
854	///   otherwise (the rows of the host table of threadteam, or the same numbers without the
855	///   `threadteam` feature). The shape is read on the thread that decides, so the answer
856	///   follows the caller, not the token.
857	pub fn dispatch_cost(self, width: NonZeroUsize) -> Duration {
858		let width = width.min(NonZeroUsize::new(self.degree().max(1)).unwrap());
859		if width.get() < 2 {
860			return Duration::MAX;
861		}
862		match self {
863			Self::Seq => Duration::MAX,
864			#[cfg(feature = "rayon")]
865			Self::Rayon(_) => {
866				#[cfg(feature = "threadteam")]
867				{
868					host_costs().cost(rayon_kind(), width)
869				}
870				#[cfg(not(feature = "threadteam"))]
871				{
872					rayon_cost(width)
873				}
874			},
875			#[cfg(feature = "threadteam")]
876			Self::Team(_, costs) => costs.cost(threadteam::DispatchKind::Team, width),
877		}
878	}
879
880	/// The cheapest cost of one section over all widths of this shape (a lower bound of
881	/// [`dispatch_cost`](Self::dispatch_cost) at every width), [`Duration::MAX`] for a shape of
882	/// degree below 2.
883	pub fn min_dispatch_cost(self) -> Duration {
884		if self.degree() < 2 {
885			return Duration::MAX;
886		}
887		match self {
888			Self::Seq => Duration::MAX,
889			#[cfg(feature = "rayon")]
890			Self::Rayon(_) => {
891				#[cfg(feature = "threadteam")]
892				{
893					host_costs().min_cost(rayon_kind())
894				}
895				#[cfg(not(feature = "threadteam"))]
896				{
897					min_rayon_cost()
898				}
899			},
900			#[cfg(feature = "threadteam")]
901			Self::Team(_, costs) => costs.min_cost(threadteam::DispatchKind::Team),
902		}
903	}
904
905	/// Whether a region of serial time `estimate` that opens `sections` sections of `width`
906	/// participants pays on this shape: `estimate >= PAYS * sections * dispatch_cost(width)`.
907	/// `sections` is the number of dispatches the region really performs.
908	pub fn pays(self, estimate: Duration, sections: u32, width: NonZeroUsize) -> bool {
909		pays_at(estimate, sections, self.dispatch_cost(width))
910	}
911
912	/// [`pays`](Self::pays) at the full degree of the shape.
913	pub fn pays_full(self, estimate: Duration, sections: u32) -> bool {
914		match NonZeroUsize::new(self.degree()) {
915			Some(width) => self.pays(estimate, sections, width),
916			None => false,
917		}
918	}
919}
920
921#[cfg(test)]
922mod tests {
923	use super::*;
924	use crate::c64;
925
926	fn nz(n: usize) -> NonZeroUsize {
927		NonZeroUsize::new(n).unwrap()
928	}
929
930	#[test]
931	fn seq_never_pays() {
932		assert_eq!(ParShape::Seq.dispatch_cost(nz(4)), Duration::MAX);
933		assert!(!ParShape::Seq.pays(Duration::from_secs(10), 1, nz(4)));
934		assert!(!ParShape::Seq.pays_full(Duration::from_secs(10), 1));
935	}
936
937	#[test]
938	#[cfg(feature = "threadteam")]
939	fn min_dispatch_cost_is_the_cheapest_width() {
940		use threadteam::{DispatchCosts, DispatchKind, WidthCosts};
941		let us = Duration::from_micros;
942		// falling, flat and rising tables
943		for t in [[us(9), us(5), us(3)], [us(4), us(4), us(4)], [us(2), us(7), us(12)]] {
944			let costs = DispatchCosts::for_class(threadteam::HostClass::Generic).with(DispatchKind::Team, WidthCosts::new(t));
945			let s = ParShape::Team(nz(6), costs);
946			let want = (2..=6).map(|w| s.dispatch_cost(nz(w))).min().unwrap();
947			assert_eq!(s.min_dispatch_cost(), want, "{t:?}");
948		}
949		assert_eq!(ParShape::Seq.min_dispatch_cost(), Duration::MAX);
950		assert_eq!(ParShape::Team(nz(1), DispatchCosts::for_class(threadteam::HostClass::Generic)).min_dispatch_cost(), Duration::MAX);
951	}
952
953	#[test]
954	fn pays_is_the_multiple_of_the_cost() {
955		let d = Duration::from_micros(2);
956		assert!(pays_at(Duration::from_micros(6), 1, d));
957		assert!(!pays_at(Duration::from_nanos(5999), 1, d));
958		assert!(!pays_at(Duration::from_micros(11), 2, d));
959		assert!(pays_at(Duration::from_micros(12), 2, d));
960		assert!(!pays_at(Duration::from_secs(1), 1, Duration::MAX));
961	}
962
963	#[cfg(feature = "rayon")]
964	#[test]
965	fn rayon_uses_the_outside_cost() {
966		let s = ParShape::Rayon(nz(6));
967		assert_eq!(s.dispatch_cost(nz(1)), Duration::MAX);
968		let d6 = s.dispatch_cost(nz(6));
969		// the table is the host's (threadteam's Linux row since the `586738b` pin, the M5 values
970		// before it), so the test states the shape, not a number: a finite, positive cost
971		assert!(d6 > Duration::ZERO && d6 < Duration::MAX);
972		// width is clamped to the degree
973		assert_eq!(s.dispatch_cost(nz(60)), d6);
974		// 3 times the cost is the floor: the cost itself does not pay, four times does
975		assert!(!s.pays(d6, 1, nz(6)));
976		assert!(s.pays(d6 * 4, 1, nz(6)));
977	}
978
979	#[cfg(feature = "threadteam")]
980	#[test]
981	fn team_shape_carries_the_costs_of_its_token() {
982		use threadteam::{DispatchCosts, DispatchKind, WidthCosts};
983		let costs = DispatchCosts::for_class(threadteam::HostClass::Generic)
984			.with(DispatchKind::Team, WidthCosts::flat(Duration::from_micros(5)));
985		let s = ParShape::Team(nz(4), costs);
986		assert_eq!(s.dispatch_cost(nz(4)), Duration::from_micros(5));
987		assert_eq!(s.dispatch_cost(nz(1)), Duration::MAX);
988		assert!(s.pays(Duration::from_micros(15), 1, nz(4)));
989		assert!(!s.pays(Duration::from_micros(14), 1, nz(4)));
990	}
991
992	#[test]
993	fn gemm_rates_are_per_dtype() {
994		assert_eq!(gemm_ns_per_mac::<f64>(), ns_per_mac::f64());
995		assert_eq!(gemm_ns_per_mac::<crate::c64>(), ns_per_mac::c64());
996		assert_eq!(gemm_ns_per_mac::<f32>(), ns_per_mac::f32());
997		assert_eq!(gemm_ns_per_mac::<crate::c32>(), ns_per_mac::c32());
998		// the complex rate is 4.5 times the real one, not 1: the floor bug of the team route
999		assert!(ns_per_mac::c64() > 4.0 * ns_per_mac::f64());
1000	}
1001
1002	/// Smallest `units` (doubling, then bisection) for which the region pays on `shape`.
1003	fn floor_units<T: crate::ComplexField>(
1004		shape: ParShape,
1005		r: Rate,
1006		sections: u32,
1007	) -> usize {
1008		let pays = |u: usize| {
1009			ParThreshold::Derived.region_pays::<T>(shape, u, r, sections)
1010		};
1011		let mut hi = 1usize;
1012		while !pays(hi) {
1013			hi *= 2;
1014			assert!(hi < 1 << 40, "never pays");
1015		}
1016		let mut lo = hi / 2;
1017		while hi - lo > 1 {
1018			let mid = (lo + hi) / 2;
1019			if pays(mid) {
1020				hi = mid;
1021			} else {
1022				lo = mid;
1023			}
1024		}
1025		hi
1026	}
1027
1028	/// (name, rate, sections as counted in the code of the gate), on the row `rt`
1029	fn gates(rt: &Rates) -> [(&'static str, Rate, u32); 10] {
1030		[
1031			("lu_full_pivot", rt.lu_full_pivot, 1),
1032			("bunch_kaufman", rt.bunch_kaufman, 1),
1033			("qr_col_pivot", rt.qr_col_pivot, 1),
1034			("bidiag", rt.bidiag, 2),
1035			("hessenberg", rt.hessenberg, 2),
1036			("tridiag", rt.tridiag, 1),
1037			("matvec", rt.matvec, 1),
1038			("householder_rank_one", rt.householder_rank_one, 1),
1039			("gemm_mac", rt.gemm_mac, 1),
1040			("sparse_matmul", rt.sparse_matmul, 1),
1041		]
1042	}
1043
1044	/// The M5 row: the tests that state numbers of the Apple team table use it with that table
1045	/// (rates and costs of one row).
1046	const M5: &Rates = &Rates::AARCH64_APPLE;
1047
1048	/// Every entry of a row, as `(name, Rate)`.
1049	fn entries(rt: &Rates) -> [(&'static str, Rate); 16] {
1050		[
1051			("gemm_mac", rt.gemm_mac),
1052			("gemm_single", rt.gemm_single),
1053			("block_apply_mac", rt.block_apply_mac),
1054			("lu_full_pivot", rt.lu_full_pivot),
1055			("bunch_kaufman", rt.bunch_kaufman),
1056			("qr_col_pivot", rt.qr_col_pivot),
1057			("bidiag", rt.bidiag),
1058			("hessenberg", rt.hessenberg),
1059			("tridiag", rt.tridiag),
1060			("matvec", rt.matvec),
1061			("bidiag_svd_dc", rt.bidiag_svd_dc),
1062			("tridiag_evd_dc", rt.tridiag_evd_dc),
1063			("triangular_inverse", rt.triangular_inverse),
1064			("sparse_matmul", rt.sparse_matmul),
1065			("householder_rank_one", rt.householder_rank_one),
1066			("lu_swap", rt.lu_swap),
1067		]
1068	}
1069
1070	#[test]
1071	fn every_class_resolves_to_its_table() {
1072		assert_eq!(Rates::for_class(HostClass::X86_64Avx512), &Rates::X86_64_AVX512);
1073		assert_eq!(Rates::for_class(HostClass::Aarch64Apple), &Rates::AARCH64_APPLE);
1074		for c in [HostClass::X86_64Avx2, HostClass::X86_64Scalar, HostClass::Aarch64, HostClass::Generic] {
1075			assert_eq!(Rates::for_class(c), &Rates::GENERIC, "{}", c.name());
1076		}
1077		// the rows are the measured ones
1078		assert_eq!(Rates::X86_64_AVX512.gemm_mac, Rate { real: 0.0641, cplx: 0.2565 });
1079		assert_eq!(Rates::X86_64_AVX512.gemm_single, Rate { real: 0.0320, cplx: 0.1283 });
1080		assert_eq!(Rates::AARCH64_APPLE.gemm_mac, Rate { real: 0.037, cplx: 0.165 });
1081		assert_eq!(Rates::AARCH64_APPLE.bidiag_svd_dc, Rate { real: 53.0, cplx: 53.0 });
1082		// the single precision GEMM entry of the first row agrees with half of the double one
1083		let g = Rates::X86_64_AVX512;
1084		assert!((g.gemm_single.real / (g.gemm_mac.real / 2.0) - 1.0).abs() < 0.01);
1085		assert!((g.gemm_single.cplx / (g.gemm_mac.cplx / 2.0) - 1.0).abs() < 0.01);
1086	}
1087
1088	#[test]
1089	fn generic_is_the_per_entry_minimum_of_the_measured_rows() {
1090		let (a, b, g) = (entries(&Rates::X86_64_AVX512), entries(&Rates::AARCH64_APPLE), entries(&Rates::GENERIC));
1091		for i in 0..a.len() {
1092			let (name, ga) = (a[i].0, a[i].1);
1093			assert_eq!(g[i].1.real, ga.real.min(b[i].1.real), "{name} real");
1094			assert_eq!(g[i].1.cplx, ga.cplx.min(b[i].1.cplx), "{name} cplx");
1095			assert!(g[i].1.real <= ga.real && g[i].1.real <= b[i].1.real, "{name}");
1096		}
1097	}
1098
1099	#[test]
1100	fn rows_are_positive() {
1101		for c in HostClass::ALL {
1102			for (name, r) in entries(Rates::for_class(c)) {
1103				assert!(r.real > 0.0 && r.cplx > 0.0, "{} {name}", c.name());
1104			}
1105		}
1106	}
1107
1108	#[test]
1109	fn detection_is_stable_and_the_host_row_is_its_class() {
1110		let c = HostClass::detect();
1111		for _ in 0..4 {
1112			assert_eq!(HostClass::detect(), c);
1113		}
1114		assert_eq!(HostClass::detect_uncached(), c);
1115		assert_eq!(rates(), Rates::for_class(c));
1116		assert_eq!(rate::bidiag(), rates().bidiag);
1117		#[cfg(all(target_arch = "x86_64", feature = "std"))]
1118		if std::arch::is_x86_feature_detected!("avx512f") {
1119			assert_eq!(c, HostClass::X86_64Avx512);
1120		}
1121		#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
1122		assert_eq!(c, HostClass::Aarch64Apple);
1123	}
1124
1125	#[test]
1126	fn outside_rows_follow_the_classes() {
1127		let us = |c, w: usize| outside_cost_of(c, nz(w)).as_nanos();
1128		assert_eq!((us(HostClass::X86_64Avx512, 2), us(HostClass::X86_64Avx512, 4), us(HostClass::X86_64Avx512, 6)), (9_700, 10_100, 8_700));
1129		assert_eq!((us(HostClass::Aarch64Apple, 2), us(HostClass::Aarch64Apple, 4), us(HostClass::Aarch64Apple, 6)), (14_000, 18_000, 19_000));
1130		assert_eq!(us(HostClass::Aarch64Apple, 3), 16_000);
1131		assert_eq!(us(HostClass::Aarch64Apple, 5), 18_500);
1132		for c in [HostClass::X86_64Avx2, HostClass::X86_64Scalar, HostClass::Aarch64, HostClass::Generic] {
1133			for w in [1, 2, 3, 6, 12] {
1134				assert_eq!(us(c, w), 40_000);
1135			}
1136		}
1137		// flat above the last anchor
1138		assert_eq!(us(HostClass::Aarch64Apple, 12), 19_000);
1139	}
1140
1141	#[test]
1142	fn min_rayon_cost_is_the_cheapest_width() {
1143		for c in HostClass::ALL {
1144			let widths = |f: fn(HostClass, NonZeroUsize) -> Duration| (2..=12).map(|w| f(c, nz(w))).min().unwrap();
1145			assert_eq!(widths(outside_cost_of), Duration::from_nanos(outside_anchors(c).into_iter().min().unwrap()));
1146			assert_eq!(widths(in_pool_cost_of), Duration::from_nanos(in_pool_anchors(c).into_iter().min().unwrap()));
1147		}
1148	}
1149
1150	#[test]
1151	fn in_pool_rows_follow_the_classes() {
1152		let us = |c, w: usize| in_pool_cost_of(c, nz(w)).as_nanos();
1153		assert_eq!((us(HostClass::X86_64Avx512, 2), us(HostClass::X86_64Avx512, 4), us(HostClass::X86_64Avx512, 6)), (3_000, 3_600, 3_500));
1154		assert_eq!((us(HostClass::Aarch64Apple, 2), us(HostClass::Aarch64Apple, 4), us(HostClass::Aarch64Apple, 6)), (6_000, 11_000, 5_500));
1155		assert_eq!(us(HostClass::Generic, 8), 15_000);
1156		// a worker of the pool never pays more than a caller outside it, at any width
1157		for c in HostClass::ALL {
1158			for w in 2..=12 {
1159				assert!(in_pool_cost_of(c, nz(w)) <= outside_cost_of(c, nz(w)), "{} width {w}", c.name());
1160			}
1161		}
1162	}
1163
1164	/// The caller of a `spindle::with_lock` is a worker of the pool, so a section of its Rayon
1165	/// route costs the in pool row: the QR of a `64 x 64` `c64` matrix opens its first two block
1166	/// applies (23 and 18 us serial, 9 and 7 us parallel in the held group) only on this row.
1167	#[cfg(feature = "rayon")]
1168	#[test]
1169	fn a_held_spindle_group_is_a_worker_of_the_pool() {
1170		let s = ParShape::Rayon(nz(8));
1171		let outside = s.dispatch_cost(nz(8));
1172		assert!(!caller_in_pool());
1173		let held = spindle::with_lock(8, || (caller_in_pool(), s.dispatch_cost(nz(8)), s.min_dispatch_cost()));
1174		assert!(held.0);
1175		assert!(held.1 <= outside && held.2 <= s.min_dispatch_cost());
1176		if HostClass::detect() == HostClass::X86_64Avx512 {
1177			assert!(held.1 < outside);
1178		}
1179	}
1180
1181	/// faer keeps its own `HostClass` and its own Rayon table so that it builds without the
1182	/// feature; with the feature on they must agree with threadteam's.
1183	#[cfg(feature = "threadteam")]
1184	#[test]
1185	fn host_class_and_outside_rows_agree_with_threadteam() {
1186		use threadteam::{DispatchCosts, DispatchKind};
1187		let tt = |c: HostClass| match c {
1188			HostClass::X86_64Avx512 => threadteam::HostClass::X86_64Avx512,
1189			HostClass::X86_64Avx2 => threadteam::HostClass::X86_64Avx2,
1190			HostClass::X86_64Scalar => threadteam::HostClass::X86_64Scalar,
1191			HostClass::Aarch64Apple => threadteam::HostClass::Aarch64Apple,
1192			HostClass::Aarch64 => threadteam::HostClass::Aarch64,
1193			HostClass::Generic => threadteam::HostClass::Generic,
1194		};
1195		assert_eq!(tt(HostClass::detect()), threadteam::HostClass::detect());
1196		for c in HostClass::ALL {
1197			assert_eq!(c.name(), tt(c).name());
1198			let costs = DispatchCosts::for_class(tt(c));
1199			for w in 2..=12 {
1200				assert_eq!(outside_cost_of(c, nz(w)), costs.cost(DispatchKind::Outside, nz(w)), "{} width {w}", c.name());
1201				assert_eq!(in_pool_cost_of(c, nz(w)), costs.cost(DispatchKind::InPool, nz(w)), "{} width {w}", c.name());
1202			}
1203		}
1204	}
1205
1206	#[test]
1207	fn fixed_and_never_keep_their_meaning() {
1208		let s = ParShape::Rayon(nz(6));
1209		let r = rate::lu_full_pivot();
1210		assert!(!ParThreshold::Never.region_pays::<f64>(s, usize::MAX, r, 1));
1211		assert!(ParThreshold::Fixed(100).region_pays::<f64>(ParShape::Seq, 100, r, 1));
1212		assert!(!ParThreshold::Fixed(100).region_pays::<f64>(s, 99, r, 1));
1213		assert!(ParThreshold::Fixed(100).region_pays::<f64>(s, 100, r, 1));
1214		// the integer form of the C API
1215		assert_eq!(ParThreshold::from(0), ParThreshold::Derived);
1216		assert_eq!(ParThreshold::from(usize::MAX), ParThreshold::Never);
1217		assert_eq!(ParThreshold::from(7), ParThreshold::Fixed(7));
1218		assert_eq!(usize::from(ParThreshold::Fixed(0)), 1);
1219	}
1220
1221	#[test]
1222	fn derived_gates_never_pay_on_a_sequential_shape() {
1223		for (_, r, sections) in gates(rates()) {
1224			assert!(!ParThreshold::Derived.region_pays::<f64>(
1225				ParShape::Seq,
1226				usize::MAX / 2,
1227				r,
1228				sections
1229			));
1230			assert!(!ParThreshold::Derived.region_pays::<c64>(
1231				ParShape::Seq,
1232				usize::MAX / 2,
1233				r,
1234				sections
1235			));
1236		}
1237	}
1238
1239	#[cfg(feature = "threadteam")]
1240	#[test]
1241	fn derived_floors_on_a_team_follow_the_rule() {
1242		use threadteam::DispatchCosts;
1243		let team = ParShape::Team(nz(6), DispatchCosts::for_class(threadteam::HostClass::Aarch64Apple));
1244		let d = team.dispatch_cost(nz(6)).as_secs_f64() * 1e9;
1245		for (name, r, sections) in gates(M5) {
1246			for (ty, ns) in [("f64", r.real), ("c64", r.cplx)] {
1247				let floor = if ty == "f64" {
1248					floor_units::<f64>(team, r, sections)
1249				} else {
1250					floor_units::<c64>(team, r, sections)
1251				};
1252				// the floor is PAYS * sections * D / rate, within the rounding of a `Duration` (nanoseconds)
1253				let want = PAYS * f64::from(sections) * d / ns;
1254				assert!(
1255					(floor as f64 - want).abs() <= 4.0 + want * 2e-3,
1256					"{name} {ty}: floor {floor}, rule {want}"
1257				);
1258			}
1259		}
1260		// representative sizes at width 6 on the M5 table (D = 1.4 us, 4.2 us per section)
1261		let pays = |u: usize, r: Rate, f: u32| {
1262			ParThreshold::Derived.region_pays::<f64>(team, u, r, f)
1263		};
1264		// full pivoting LU: a 100 x 100 trailing block stays serial, 200 x 200 is parallel
1265		assert!(!pays(100 * 100, M5.lu_full_pivot, 1));
1266		assert!(pays(200 * 200, M5.lu_full_pivot, 1));
1267		// bidiagonalization counts two sections: it needs twice the work of a one-section step
1268		assert!(floor_units::<f64>(team, M5.bidiag, 2) >= 2 * floor_units::<f64>(team, M5.bidiag, 1) - 2);
1269		// a 256 x 256 matrix-vector product (65536 elements) pays on a team, not below 100 x 100
1270		assert!(pays(256 * 256, M5.matvec, 1));
1271		assert!(!pays(64 * 64, M5.matvec, 1));
1272		// the old fixed floors were 192*256 = 49152 elements for every one of these steps; the
1273		// derived floors differ per family (the faster the kernel, the larger the floor)
1274		let lu = floor_units::<f64>(team, M5.lu_full_pivot, 1);
1275		let bidiag = floor_units::<f64>(team, M5.bidiag, 1);
1276		assert!(lu > bidiag);
1277	}
1278
1279	#[cfg(all(feature = "rayon", target_os = "macos", target_arch = "aarch64"))]
1280	#[test]
1281	fn derived_floors_outside_the_pool_are_larger_than_on_a_team() {
1282		let rayon = ParShape::Rayon(nz(6));
1283		for (name, r, sections) in gates(M5) {
1284			let outside = floor_units::<f64>(rayon, r, sections);
1285			#[cfg(feature = "threadteam")]
1286			{
1287				use threadteam::DispatchCosts;
1288				let team = ParShape::Team(nz(6), DispatchCosts::for_class(threadteam::HostClass::Aarch64Apple));
1289				let t = floor_units::<f64>(team, r, sections);
1290				assert!(outside > 8 * t, "{name}: outside {outside}, team {t}");
1291			}
1292			// 57 us of serial work per section at width 6
1293			let want = PAYS * f64::from(sections) * 19_000.0 / r.real;
1294			assert!((outside as f64 - want).abs() <= 4.0 + want * 2e-3, "{name}");
1295		}
1296		// the old fixed floor of the reductions (192 * 256) would have been 10 us of
1297		// bidiagonalization and runs 5 times slower than serial from outside the pool
1298		assert!(floor_units::<f64>(rayon, M5.bidiag, 2) > 192 * 256);
1299	}
1300
1301	#[cfg(feature = "rayon")]
1302	#[test]
1303	fn par_pays_reads_the_degree_of_the_token() {
1304		let seq = crate::Par::Seq;
1305		assert!(!par_pays::<f64>(&seq, usize::MAX / 2, rate::matvec(), 1));
1306		let par = crate::Par::rayon(6);
1307		assert!(par_pays::<f64>(&par, 10_000_000, rate::matvec(), 1));
1308		assert!(!par_pays::<f64>(&par, 1_000, rate::matvec(), 1));
1309		let one = crate::Par::rayon(1);
1310		assert!(!par_pays::<f64>(&one, 10_000_000, rate::matvec(), 1));
1311	}
1312
1313	#[test]
1314	fn rates_are_per_dtype() {
1315		let r = rate::bidiag();
1316		assert_eq!(r.of::<f64>(), r.real);
1317		assert_eq!(r.of::<c64>(), r.cplx);
1318		assert_eq!(r.of::<f32>(), r.real / 2.0);
1319		assert_eq!(r.of::<crate::c32>(), r.cplx / 2.0);
1320		assert_eq!(rate::gemm_mac().of::<f64>(), ns_per_mac::f64());
1321		assert_eq!(rate::gemm_mac().of::<c64>(), ns_per_mac::c64());
1322	}
1323
1324	/// `Fixed(1)` makes every step of every reduction parallel, and the factors must equal the
1325	/// serial ones: the `Derived` default keeps the sizes of the other tests below the floors, so
1326	/// the parallel branches of the gated steps are exercised here.
1327	#[cfg(feature = "rayon")]
1328	mod forced_parallel {
1329		use super::*;
1330		use crate::dyn_stack::{MemBuffer, MemStack};
1331		use crate::internal_prelude::*;
1332		use crate::linalg::cholesky::lblt::factor as bk;
1333		use crate::linalg::evd::{hessenberg, tridiag};
1334		use crate::linalg::lu::full_pivoting::factor as fplu;
1335		use crate::linalg::qr::col_pivoting::factor as cpqr;
1336		use crate::linalg::svd::bidiag;
1337		use crate::{Auto, Mat, MatRef, Par};
1338
1339		const N: usize = 96;
1340
1341		fn fill<T: crate::ComplexField>(n: usize, seed: usize) -> Mat<T> {
1342			Mat::from_fn(n, n, |i, j| {
1343				let h = (i * 2654435761usize)
1344					.wrapping_add(j * 40503)
1345					.wrapping_add(seed * 977)
1346					% 1009;
1347				from_f64::<T>(h as f64 / 504.0 - 1.0)
1348			})
1349		}
1350		fn close<T: crate::ComplexField<Real = f64>>(a: MatRef<'_, T>, b: MatRef<'_, T>) {
1351			let mut err = 0.0f64;
1352			for j in 0..a.ncols() {
1353				for i in 0..a.nrows() {
1354					err = err.max((a[(i, j)].clone() - b[(i, j)].clone()).abs());
1355				}
1356			}
1357			core::assert!(err <= 1e-9, "error {err}");
1358		}
1359
1360		fn check<T: crate::ComplexField<Real = f64>>() {
1361			let a0 = fill::<T>(N, 1);
1362			let sym = &a0 + a0.adjoint();
1363			// results with a given par and `Fixed(1)`
1364			let lu = |par: Par| {
1365				let mut a = a0.clone();
1366				let mut p = vec![0usize; 4 * N];
1367				let (r, rest) = p.split_at_mut(N);
1368				let (ri, rest) = rest.split_at_mut(N);
1369				let (c, ci) = rest.split_at_mut(N);
1370				let mut params: fplu::FullPivLuParams = Auto::<T>::auto();
1371				params.par_threshold = ParThreshold::Fixed(1);
1372				let mut mem = MemBuffer::new(fplu::lu_in_place_scratch::<usize, T>(
1373					N,
1374					N,
1375					par.shape(),
1376					params.into(),
1377				));
1378				fplu::lu_in_place(
1379					a.as_mut(), r, ri, c, ci, par, MemStack::new(&mut mem), params.into(),
1380				);
1381				a
1382			};
1383			close(lu(Par::Seq).as_ref(), lu(Par::rayon(4)).as_ref());
1384			let bunch = |par: Par| {
1385				let mut a = sym.clone();
1386				let mut perm = vec![0usize; N];
1387				let mut perm_inv = vec![0usize; N];
1388				let mut sub = Mat::<T>::zeros(N, 1);
1389				let mut params: bk::LbltParams = Auto::<T>::auto();
1390				params.pivoting = bk::PivotingStrategy::Full;
1391				params.par_threshold = ParThreshold::Fixed(1);
1392				let mut mem = MemBuffer::new(bk::cholesky_in_place_scratch::<usize, T>(
1393					N,
1394					par.shape(),
1395					params.into(),
1396				));
1397				bk::cholesky_in_place(
1398					a.as_mut(),
1399					sub.as_mut().col_mut(0).as_diagonal_mut(),
1400					&mut perm,
1401					&mut perm_inv,
1402					par,
1403					MemStack::new(&mut mem),
1404					params.into(),
1405				);
1406				a
1407			};
1408			close(bunch(Par::Seq).as_ref(), bunch(Par::rayon(4)).as_ref());
1409			let qr = |par: Par| {
1410				let mut a = a0.clone();
1411				let mut h = Mat::<T>::zeros(16, N);
1412				let mut p = vec![0usize; N];
1413				let mut pi = vec![0usize; N];
1414				let mut params: cpqr::ColPivQrParams = Auto::<T>::auto();
1415				params.par_threshold = ParThreshold::Fixed(1);
1416				let mut mem = MemBuffer::new(cpqr::qr_in_place_scratch::<usize, T>(
1417					N,
1418					N,
1419					16,
1420					par.shape(),
1421					params.into(),
1422				));
1423				cpqr::qr_in_place(
1424					a.as_mut(), h.as_mut(), &mut p, &mut pi, par, MemStack::new(&mut mem), params.into(),
1425				);
1426				a
1427			};
1428			close(qr(Par::Seq).as_ref(), qr(Par::rayon(4)).as_ref());
1429			let bid = |par: Par| {
1430				let mut a = a0.clone();
1431				let mut hl = Mat::<T>::zeros(8, N);
1432				let mut hr = Mat::<T>::zeros(8, N - 1);
1433				let mut params: bidiag::BidiagParams = Auto::<T>::auto();
1434				params.par_threshold = ParThreshold::Fixed(1);
1435				let mut mem = MemBuffer::new(bidiag::bidiag_in_place_scratch::<T>(
1436					N,
1437					N,
1438					par.shape(),
1439					params.into(),
1440				));
1441				bidiag::bidiag_in_place(
1442					a.as_mut(), hl.as_mut(), hr.as_mut(), par, MemStack::new(&mut mem), params.into(),
1443				);
1444				a
1445			};
1446			close(bid(Par::Seq).as_ref(), bid(Par::rayon(4)).as_ref());
1447			let hess = |par: Par| {
1448				let mut a = a0.clone();
1449				let mut h = Mat::<T>::zeros(8, N - 1);
1450				let mut params: hessenberg::HessenbergParams = Auto::<T>::auto();
1451				params.par_threshold = ParThreshold::Fixed(1);
1452				params.blocking_threshold = usize::MAX;
1453				let mut mem = MemBuffer::new(hessenberg::hessenberg_in_place_scratch::<T>(
1454					N,
1455					8,
1456					par.shape(),
1457					params.into(),
1458				));
1459				hessenberg::hessenberg_in_place(
1460					a.as_mut(), h.as_mut(), par, MemStack::new(&mut mem), params.into(),
1461				);
1462				a
1463			};
1464			close(hess(Par::Seq).as_ref(), hess(Par::rayon(4)).as_ref());
1465			let tri = |par: Par| {
1466				let mut a = sym.clone();
1467				let mut h = Mat::<T>::zeros(8, N - 1);
1468				let mut params: tridiag::TridiagParams = Auto::<T>::auto();
1469				params.par_threshold = ParThreshold::Fixed(1);
1470				let mut mem = MemBuffer::new(tridiag::tridiag_in_place_scratch::<T>(
1471					N,
1472					par.shape(),
1473					params.into(),
1474				));
1475				tridiag::tridiag_in_place(
1476					a.as_mut(), h.as_mut(), par, MemStack::new(&mut mem), params.into(),
1477				);
1478				a
1479			};
1480			close(tri(Par::Seq).as_ref(), tri(Par::rayon(4)).as_ref());
1481		}
1482
1483		#[test]
1484		fn forced_parallel_steps_equal_serial_f64() {
1485			check::<f64>();
1486		}
1487
1488		#[test]
1489		fn forced_parallel_steps_equal_serial_c64() {
1490			check::<c64>();
1491		}
1492	}
1493
1494	/// the join gates: the work of a join of size `n` is two halves, `2 (n/2)^3` units
1495	#[test]
1496	fn halves_join_gates_follow_the_rule() {
1497		let seq = crate::Par::Seq;
1498		for r in [rate::bidiag_svd_dc(), rate::tridiag_evd_dc(), rate::triangular_inverse()] {
1499			assert!(!halves_pays::<f64>(&seq, 1 << 20, r, 2));
1500		}
1501		#[cfg(feature = "rayon")]
1502		{
1503			let par = crate::Par::rayon(6);
1504			for r in [rate::bidiag_svd_dc(), rate::tridiag_evd_dc(), rate::triangular_inverse()] {
1505				// the smallest size whose join pays is the one the rule gives, and it is monotone
1506				let power = if r == rate::triangular_inverse() { 3 } else { 2 };
1507				let first = (2..4096).find(|&n| halves_pays::<f64>(&par, n, r, power)).expect("never pays");
1508				assert!(first > 8, "{first}");
1509				assert!(!halves_pays::<f64>(&par, first - 1, r, power));
1510				assert!(halves_pays::<f64>(&par, first, r, power));
1511				assert!(halves_pays::<f64>(&par, 2 * first, r, power));
1512				// work is the power of half the size, two halves
1513				let h = first / 2;
1514				let work_ns = 2.0 * (h as f64).powi(power as i32) * r.real;
1515				let want = PAYS * par.shape().dispatch_cost(nz(2)).as_secs_f64() * 1e9;
1516				assert!(work_ns >= want * 0.999, "{work_ns} {want}");
1517			}
1518		}
1519	}
1520
1521	/// The column split of the block apply at the shapes of a `128 x 128` QR (block 8, target
1522	/// `K = M - 8`) on a team of 6. The serial time of the apply is the GEMM work at the GEMM rate
1523	/// plus a streaming term per target element (temporaries, triangular steps) at the rank one
1524	/// apply rate; measured with one thread: 2.75 us (`M = 56`) to 11.9 us (`M = 128`), the
1525	/// estimate is 0.78 to 1.03 of it. The split starts at `M = 80`, where the estimate first
1526	/// reaches `3 * 1.4 us`.
1527	#[cfg(feature = "threadteam")]
1528	#[test]
1529	fn block_apply_split_starts_where_the_estimate_pays() {
1530		use threadteam::DispatchCosts;
1531		let team = ParShape::Team(nz(6), DispatchCosts::for_class(threadteam::HostClass::Aarch64Apple));
1532		let pays = |m: usize| {
1533			let (n, k) = (8, m - 8);
1534			pays_at(block_apply_estimate_with::<f64>(M5, m, n, k, 2), 1, team.dispatch_cost(nz(6)))
1535		};
1536		assert!(!pays(72));
1537		assert!(pays(80));
1538		// the GEMM term alone was 0.6 to 0.8 of the measured time and started at M = 96
1539		let gemm_only = |m: usize| {
1540			pays_at(serial_time(2 * m * 8 * (m - 8), M5.gemm_mac.real), 1, team.dispatch_cost(nz(6)))
1541		};
1542		assert!(!gemm_only(88) && gemm_only(96));
1543		// complex values: the streaming term is 0.54 ns per element, the GEMM 0.162 per multiply-add
1544		let c = block_apply_estimate_with::<c64>(M5, 100, 8, 92, 2).as_secs_f64() * 1e9;
1545		let want = 2.0 * 100.0 * 8.0 * 92.0 * M5.block_apply_mac.cplx + 100.0 * 92.0 * M5.householder_rank_one.cplx;
1546		assert!((c - want).abs() < 2.0, "{c} {want}");
1547	}
1548
1549	#[cfg(feature = "threadteam")]
1550	#[test]
1551	fn join_gates_on_a_team_follow_the_width_two_cost() {
1552		use threadteam::DispatchCosts;
1553		let team = ParShape::Team(nz(6), DispatchCosts::for_class(threadteam::HostClass::Aarch64Apple));
1554		let rayon = ParShape::Rayon(nz(6));
1555		for (name, r, sections) in gates(M5) {
1556			let t = floor_units::<f64>(team, r, sections);
1557			let o = floor_units::<f64>(rayon, r, sections);
1558			assert!(t < o, "{name}: team {t}, outside {o}");
1559		}
1560		// a join is a section of width 2: 3.0 us on the team table, so 9 us of work
1561		let d2 = team.dispatch_cost(nz(2));
1562		assert_eq!(d2, Duration::from_micros(3));
1563		let par = |ns: f64| pays_at(Duration::from_secs_f64(ns * 1e-9), 1, d2);
1564		assert!(par(9000.0) && !par(8900.0));
1565		// representative joins on the M5 team table: the divide and conquer joins pay from a size
1566		// of about 20 (far below their recursion thresholds, 128 and 64), the triangular inverse
1567		// joins from about 150 (`2 (n/2)^3` at 0.0096 ns against 9 us)
1568		for (r, power, yes, no) in [
1569			(M5.bidiag_svd_dc, 2u32, 128usize, 8usize),
1570			(M5.tridiag_evd_dc, 2, 64, 8),
1571			(M5.triangular_inverse, 3, 256, 64),
1572		] {
1573			let pays = |n: usize| {
1574				let h = n / 2;
1575				pays_at(
1576					serial_time(2 * h.pow(power), r.real),
1577					1,
1578					d2,
1579				)
1580			};
1581			assert!(pays(yes));
1582			assert!(!pays(no));
1583		}
1584		// a sparse product of 1e6 multiply-adds is above the floor, 1e3 below
1585		assert!(ParThreshold::Derived.region_pays::<f64>(team, 1_000_000, M5.sparse_matmul, 1));
1586		assert!(!ParThreshold::Derived.region_pays::<f64>(team, 1_000, M5.sparse_matmul, 1));
1587	}
1588}
1589
1590#[cfg(feature = "par-trace")]
1591#[doc(hidden)]
1592pub fn trace_pub(
1593	units: usize,
1594	sections: u32,
1595	estimate: Duration,
1596	dispatch: Duration,
1597	r: bool,
1598	loc: &'static core::panic::Location<'static>,
1599) {
1600	trace::record(loc, units, sections, estimate.as_secs_f64() * 1e9, dispatch.as_secs_f64() * 1e9, r);
1601}
1602
1603#[cfg(all(test, feature = "rayon", feature = "sparse-linalg"))]
1604mod join_results {
1605	//! The parallel routes behind the join gates give the serial results, at sizes above the
1606	//! floor of a plain token from outside the pool (`Par::rayon(4)`).
1607	use crate::dyn_stack::{MemBuffer, MemStack};
1608	use crate::linalg::evd::{self, ComputeEigenvectors};
1609	use crate::linalg::svd;
1610	use crate::linalg::triangular_inverse::{invert_lower_triangular, invert_unit_lower_triangular};
1611	use crate::{Accum, Mat, Par, Spec};
1612
1613	struct Rng(u64);
1614	impl Rng {
1615		fn next(&mut self) -> f64 {
1616			self.0 ^= self.0 << 13;
1617			self.0 ^= self.0 >> 7;
1618			self.0 ^= self.0 << 17;
1619			(self.0 >> 11) as f64 / (1u64 << 53) as f64 - 0.5
1620		}
1621	}
1622
1623	fn close(a: &[f64], b: &[f64], tol: f64) {
1624		let scale = a.iter().fold(1.0f64, |m, x| m.max(x.abs()));
1625		for (x, y) in a.iter().zip(b) {
1626			assert!((x - y).abs() <= tol * scale, "{x} {y}");
1627		}
1628	}
1629
1630	#[test]
1631	fn triangular_inverse_agrees_with_serial() {
1632		let n = 512;
1633		let mut r = Rng(3);
1634		let mut src = Mat::<f64>::from_fn(n, n, |_, _| r.next() * 0.1);
1635		for i in 0..n {
1636			src[(i, i)] = 2.0 + src[(i, i)].abs();
1637		}
1638		for unit in [false, true] {
1639			let mut a = Mat::<f64>::zeros(n, n);
1640			let mut b = Mat::<f64>::zeros(n, n);
1641			for (dst, par) in [(&mut a, Par::Seq), (&mut b, Par::rayon(4))] {
1642				if unit {
1643					invert_unit_lower_triangular(dst.as_mut(), src.as_ref(), par);
1644				} else {
1645					invert_lower_triangular(dst.as_mut(), src.as_ref(), par);
1646				}
1647			}
1648			for j in 0..n {
1649				close(
1650					&(0..n).map(|i| a[(i, j)]).collect::<Vec<_>>(),
1651					&(0..n).map(|i| b[(i, j)]).collect::<Vec<_>>(),
1652					1e-9,
1653				);
1654			}
1655		}
1656	}
1657
1658	#[test]
1659	fn bidiagonal_svd_agrees_with_serial() {
1660		let n = 512;
1661		let mut r = Rng(5);
1662		let d0: Vec<f64> = (0..n).map(|_| r.next() + 1.0).collect();
1663		let e: Vec<f64> = (0..n - 1).map(|_| r.next()).collect();
1664		let mut out = vec![];
1665		for par in [Par::Seq, Par::rayon(4)] {
1666			let mut d = d0.clone();
1667			let mut u = Mat::<f64>::zeros(n, n);
1668			let mut v = Mat::<f64>::zeros(n, n);
1669			let mut mem = MemBuffer::new(svd::bidiagonal_svd_scratch::<f64>(
1670				n,
1671				par.shape(),
1672				Spec::default(),
1673			));
1674			svd::bidiagonal_svd(
1675				&mut d,
1676				&e,
1677				true,
1678				u.as_mut(),
1679				v.as_mut(),
1680				par,
1681				MemStack::new(&mut mem),
1682				Spec::default(),
1683			)
1684			.unwrap();
1685			out.push(d);
1686		}
1687		close(&out[0], &out[1], 1e-10);
1688	}
1689
1690	#[test]
1691	fn tridiagonal_evd_agrees_with_serial() {
1692		let n = 512;
1693		let mut r = Rng(6);
1694		let d0: Vec<f64> = (0..n).map(|_| r.next()).collect();
1695		let mut e0: Vec<f64> = (0..n - 1).map(|_| r.next()).collect();
1696		e0.push(0.0);
1697		let mut out = vec![];
1698		for par in [Par::Seq, Par::rayon(4)] {
1699			let mut u = Mat::<f64>::zeros(n, n);
1700			let mut s = Mat::<f64>::zeros(n, 1);
1701			let mut mem = MemBuffer::new(evd::tridiagonal_self_adjoint_evd_scratch::<f64>(
1702				n,
1703				ComputeEigenvectors::Yes,
1704				par.shape(),
1705				Spec::default(),
1706			));
1707			let d = crate::col::ColRef::from_slice(&d0);
1708			let e = crate::col::ColRef::from_slice(&e0);
1709			evd::tridiagonal_self_adjoint_evd(
1710				d.as_diagonal(),
1711				e.as_diagonal(),
1712				s.as_mut().col_mut(0).as_diagonal_mut(),
1713				Some(u.as_mut()),
1714				par,
1715				MemStack::new(&mut mem),
1716				Spec::default(),
1717			)
1718			.unwrap();
1719			out.push((0..n).map(|i| s[(i, 0)]).collect::<Vec<_>>());
1720		}
1721		close(&out[0], &out[1], 1e-10);
1722	}
1723
1724	#[test]
1725	fn sparse_matmul_agrees_with_serial() {
1726		use crate::sparse::SparseColMat;
1727		use crate::sparse::Triplet;
1728		use crate::sparse::linalg::matmul::sparse_sparse_matmul;
1729		let n = 6000;
1730		let mk = |seed: u64| {
1731			let mut r = Rng(seed);
1732			let mut t = vec![];
1733			for j in 0..n {
1734				for _ in 0..24 {
1735					let i = ((r.next() + 0.5) * n as f64) as usize % n;
1736					t.push(Triplet::new(i, j, r.next()));
1737				}
1738			}
1739			SparseColMat::<usize, f64>::try_new_from_triplets(n, n, &t).unwrap()
1740		};
1741		let (a, b) = (mk(1), mk(2));
1742		let s = sparse_sparse_matmul(a.as_ref(), b.as_ref(), 1.0, Par::Seq).unwrap();
1743		let p = sparse_sparse_matmul(a.as_ref(), b.as_ref(), 1.0, Par::rayon(4)).unwrap();
1744		close(s.val(), p.val(), 1e-12);
1745		let _ = Accum::Add;
1746	}
1747}