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