threadteam/cost.rs
1//! Dispatch costs of the three kinds of token and the rule that uses them.
2//!
3//! A token states what one parallel section costs it; a kernel states how long
4//! its region takes on one thread; the region runs in parallel when the serial
5//! time is at least [`PAYS`] times the dispatch cost of every section it opens.
6//! The design record is `docs/design/thresholds.md`.
7
8use core::num::NonZeroUsize;
9use core::time::Duration;
10use std::sync::OnceLock;
11
12/// The one multiple of the stack: a region runs in parallel when its serial
13/// estimate is at least `PAYS * sections * dispatch_cost`.
14///
15/// At the threshold a region that scales three times on six threads is 1.5 times
16/// faster (`3 / (3/3 + 1)`) and one that scales only twice is not slower.
17pub const PAYS: f64 = 3.0;
18
19/// Which kind of token a dispatch cost belongs to.
20///
21/// The kind is read from the token and the calling thread when the cost is
22/// asked for.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum DispatchKind {
25 /// A token with authority over reserved members (team): a section is a
26 /// publication to spinning members plus a join.
27 Team,
28 /// A plain token used by a worker of its own pool: the join tree runs on the
29 /// caller, nothing is installed.
30 InPool,
31 /// A plain token used by a thread outside the pool: one install, a wake of
32 /// the workers and a wait.
33 Outside,
34}
35
36/// Dispatch cost of one kind of token, stored as the measured anchors.
37///
38/// The model at width `w`: the value at the first anchor below it, linear
39/// interpolation between two anchors, and **linear extrapolation above the last
40/// anchor** `w_m`, `ns[w_m] * w / w_m`, saturating at `u64::MAX`. Extrapolation
41/// never falls below the last anchor and no width above it is charged a flat
42/// cost: a wide section pays at least in proportion to its width, which is the
43/// conservative reading of the measurements.
44///
45/// The anchors are the widths a row was measured at. [`new`](Self::new) and
46/// [`flat`](Self::flat) carry widths 2, 4 and 6;
47/// [`from_anchors`](Self::from_anchors) takes any strictly increasing widths from
48/// 2 to `u16::MAX`, at most [`MAX_ANCHORS`](Self::MAX_ANCHORS) of them, so a row
49/// measured on a many-core host replaces the extrapolation where it has data.
50/// The values are integer nanoseconds and a lookup scans at most
51/// [`MAX_ANCHORS`](Self::MAX_ANCHORS) entries, so it allocates nothing and the
52/// comparisons of the rule stay exact. Width 1 never reaches the model: a token
53/// of one slot has no parallel section, see
54/// [`Exec::dispatch_cost`](crate::Exec::dispatch_cost).
55///
56/// # Examples
57///
58/// ```
59/// use std::num::NonZeroUsize;
60/// use std::time::Duration;
61/// use threadteam::WidthCosts;
62///
63/// let us = Duration::from_micros;
64/// let c = WidthCosts::new([us(3), us(2), us(1)]);
65/// assert_eq!(c.at(NonZeroUsize::new(2).unwrap()), us(3));
66/// assert_eq!(c.at(NonZeroUsize::new(3).unwrap()), Duration::from_nanos(2500));
67/// assert_eq!(c.at(NonZeroUsize::new(6).unwrap()), us(1));
68/// // Above the last anchor (6) the cost grows with the width.
69/// assert_eq!(c.at(NonZeroUsize::new(12).unwrap()), us(2));
70/// assert_eq!(c.at(NonZeroUsize::new(48).unwrap()), us(8));
71/// assert_eq!(c.min(), us(1));
72/// ```
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct WidthCosts {
75 /// The measured widths, strictly increasing, in `widths[..len]`.
76 widths: [u16; CAPACITY],
77 /// The cost in nanoseconds at each anchor, in `ns[..len]`.
78 ns: [u64; CAPACITY],
79 /// The number of anchors. The slots past it are zeroed by construction, so
80 /// the derived `PartialEq` compares only the anchors.
81 len: u8,
82}
83
84/// The capacity of a row: enough for a measurement every power of two from 2 to
85/// 4096 plus the widths a many-core campaign reports.
86const CAPACITY: usize = 12;
87
88const fn to_ns(d: Duration) -> u64 {
89 let n = d.as_nanos();
90 if n > u64::MAX as u128 {
91 u64::MAX
92 } else {
93 n as u64
94 }
95}
96
97/// Linear interpolation between two anchors in `i128`, clamped at zero as the
98/// model it replaces was.
99#[inline]
100fn interpolate(c0: u64, c1: u64, w0: u16, w1: u16, w: usize) -> u64 {
101 // 64-bit fast path (a 128-bit division is a library call); the same
102 // truncating division, so the value is identical to the 128-bit one.
103 let dw = (w - usize::from(w0)) as i64;
104 if let (Ok(a), Ok(b)) = (i64::try_from(c0), i64::try_from(c1)) {
105 if let Some(t) = (b - a).checked_mul(dw) {
106 let v = a + t / i64::from(w1 - w0);
107 return if v < 0 { 0 } else { v as u64 };
108 }
109 }
110 let (c0, c1) = (i128::from(c0), i128::from(c1));
111 let v = c0 + (c1 - c0) * (w - usize::from(w0)) as i128 / i128::from(w1 - w0);
112 if v < 0 {
113 0
114 } else {
115 v as u64
116 }
117}
118
119impl WidthCosts {
120 /// The largest number of anchors a row holds.
121 pub const MAX_ANCHORS: usize = CAPACITY;
122
123 /// The costs measured at widths 2, 4 and 6.
124 pub const fn new(at_2_4_6: [Duration; 3]) -> Self {
125 Self::store(&[(2, at_2_4_6[0]), (4, at_2_4_6[1]), (6, at_2_4_6[2])])
126 }
127
128 /// The same cost at every anchor 2, 4 and 6 (the model of an unmeasured
129 /// host). Above width 6 the row grows with the width like every other row; a
130 /// host that measured equal costs out to a wide width says so with
131 /// [`from_anchors`](Self::from_anchors).
132 pub const fn flat(cost: Duration) -> Self {
133 WidthCosts::new([cost; 3])
134 }
135
136 /// A row from measured anchors `(width, cost)`: widths 2 to `u16::MAX`,
137 /// strictly increasing, at most [`MAX_ANCHORS`](Self::MAX_ANCHORS) of them.
138 ///
139 /// `None` for empty input, for more than [`MAX_ANCHORS`](Self::MAX_ANCHORS)
140 /// anchors, for a width below 2 or above `u16::MAX`, and for widths that are
141 /// not strictly increasing. No panic, no allocation, usable in a `const`
142 /// item.
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use std::num::NonZeroUsize;
148 /// use std::time::Duration;
149 /// use threadteam::WidthCosts;
150 ///
151 /// let us = Duration::from_micros;
152 /// let a = WidthCosts::from_anchors(&[(2, us(3)), (4, us(2)), (8, us(1))]).unwrap();
153 /// assert_eq!(a.at(NonZeroUsize::new(6).unwrap()), Duration::from_nanos(1500));
154 /// // Above the last anchor (8) the cost grows with the width.
155 /// assert_eq!(a.at(NonZeroUsize::new(16).unwrap()), us(2));
156 /// assert!(WidthCosts::from_anchors(&[]).is_none());
157 /// assert!(WidthCosts::from_anchors(&[(1, us(3))]).is_none());
158 /// ```
159 pub const fn from_anchors(anchors: &[(usize, Duration)]) -> Option<Self> {
160 let n = anchors.len();
161 if n == 0 || n > CAPACITY {
162 return None;
163 }
164 let mut k = 0;
165 while k < n {
166 let w = anchors[k].0;
167 if w < 2 || w > u16::MAX as usize {
168 return None;
169 }
170 if k > 0 && w <= anchors[k - 1].0 {
171 return None;
172 }
173 k += 1;
174 }
175 Some(Self::store(anchors))
176 }
177
178 /// Write the anchors into the fixed-capacity arrays; unused slots stay zero
179 /// so that the derived `PartialEq` sees only the anchors. The caller
180 /// validates.
181 const fn store(anchors: &[(usize, Duration)]) -> Self {
182 let mut widths = [0u16; CAPACITY];
183 let mut ns = [0u64; CAPACITY];
184 let mut k = 0;
185 while k < CAPACITY {
186 if k < anchors.len() {
187 widths[k] = anchors[k].0 as u16;
188 ns[k] = to_ns(anchors[k].1);
189 }
190 k += 1;
191 }
192 WidthCosts {
193 widths,
194 ns,
195 len: anchors.len() as u8,
196 }
197 }
198
199 /// Nanoseconds of a section of `width` participants: a forward scan over at
200 /// most `len` anchors.
201 #[inline]
202 pub(crate) fn ns_at(&self, width: NonZeroUsize) -> u64 {
203 let w = width.get();
204 let len = usize::from(self.len);
205 if w <= usize::from(self.widths[0]) {
206 return self.ns[0];
207 }
208 let mut i = 0;
209 while i + 1 < len {
210 let w1 = self.widths[i + 1];
211 if w <= usize::from(w1) {
212 return interpolate(self.ns[i], self.ns[i + 1], self.widths[i], w1, w);
213 }
214 i += 1;
215 }
216 // Above the last anchor: at least linear in the width, saturating
217 // (64-bit when the product fits, as above).
218 let (last, w_m) = (self.ns[len - 1], u64::from(self.widths[len - 1]));
219 if let Some(p) = last.checked_mul(w as u64) {
220 return p / w_m;
221 }
222 let v = u128::from(last) * w as u128 / u128::from(w_m);
223 if v > u64::MAX as u128 {
224 u64::MAX
225 } else {
226 v as u64
227 }
228 }
229
230 /// The cost of a section of `width` participants, by the model above.
231 #[inline]
232 pub fn at(&self, width: NonZeroUsize) -> Duration {
233 Duration::from_nanos(self.ns_at(width))
234 }
235
236 /// The cheapest cost over all widths: the minimum over the anchors (an
237 /// interpolated value lies between its two anchors, and the extrapolation
238 /// never falls below the last anchor). A region whose serial estimate is below `PAYS * min()` pays at no
239 /// width, which is the cheap rejection before a per-width search.
240 #[inline]
241 pub fn min(&self) -> Duration {
242 let len = usize::from(self.len);
243 let mut m = self.ns[0];
244 let mut k = 1;
245 while k < len {
246 if self.ns[k] < m {
247 m = self.ns[k];
248 }
249 k += 1;
250 }
251 Duration::from_nanos(m)
252 }
253}
254
255/// The class of the machine, keyed by the kernel dispatch it will take, not by
256/// the CPU model. Detected once per process by [`HostClass::detect`].
257///
258/// The same enum and the same `detect` are kept in sync by hand in
259/// `strided-rs`, `tcontract-rs` and `faer`'s cost tables: each crate owns a
260/// table of its own kernels keyed by this class.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
262pub enum HostClass {
263 /// `x86_64` with AVX-512F at run time (`x86_64-avx512`).
264 X86_64Avx512,
265 /// `x86_64` with AVX2 and FMA, without AVX-512F (`x86_64-avx2`).
266 X86_64Avx2,
267 /// Any other `x86_64` (`x86_64-scalar`).
268 X86_64Scalar,
269 /// `aarch64` on macOS (`aarch64-apple`).
270 Aarch64Apple,
271 /// Any other `aarch64` (`aarch64`).
272 Aarch64,
273 /// Everything else (`generic`).
274 Generic,
275}
276
277impl HostClass {
278 /// The class of this host, detected at the first call and cached
279 /// (`OnceLock`; `is_x86_feature_detected!` on x86_64).
280 pub fn detect() -> Self {
281 static CLASS: OnceLock<HostClass> = OnceLock::new();
282 *CLASS.get_or_init(Self::detect_uncached)
283 }
284
285 #[cfg(target_arch = "x86_64")]
286 fn detect_uncached() -> Self {
287 if std::arch::is_x86_feature_detected!("avx512f") {
288 HostClass::X86_64Avx512
289 } else if std::arch::is_x86_feature_detected!("avx2")
290 && std::arch::is_x86_feature_detected!("fma")
291 {
292 HostClass::X86_64Avx2
293 } else {
294 HostClass::X86_64Scalar
295 }
296 }
297
298 #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
299 fn detect_uncached() -> Self {
300 HostClass::Aarch64Apple
301 }
302
303 #[cfg(all(target_arch = "aarch64", not(target_os = "macos")))]
304 fn detect_uncached() -> Self {
305 HostClass::Aarch64
306 }
307
308 #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
309 fn detect_uncached() -> Self {
310 HostClass::Generic
311 }
312
313 /// The class name used in the docs and the tables: `x86_64-avx512`,
314 /// `x86_64-avx2`, `x86_64-scalar`, `aarch64-apple`, `aarch64`, `generic`.
315 pub const fn name(self) -> &'static str {
316 match self {
317 HostClass::X86_64Avx512 => "x86_64-avx512",
318 HostClass::X86_64Avx2 => "x86_64-avx2",
319 HostClass::X86_64Scalar => "x86_64-scalar",
320 HostClass::Aarch64Apple => "aarch64-apple",
321 HostClass::Aarch64 => "aarch64",
322 HostClass::Generic => "generic",
323 }
324 }
325}
326
327/// Dispatch costs of a pool's three kinds of token.
328///
329/// [`for_class`](DispatchCosts::for_class) holds measured rows for the host
330/// classes it knows (`x86_64-avx512`; `aarch64-apple`, QoS `UserInitiated`, the
331/// pool default; the team kind of `x86_64-avx2`) and a conservative generic row
332/// for everything else;
333/// [`host_default`](DispatchCosts::host_default) picks the row of the detected
334/// class. A pool owner that measured its own host overrides them with
335/// [`PoolBuilder::dispatch_costs`](crate::PoolBuilder::dispatch_costs).
336///
337/// The values are *steady state*: sections back to back, with shares that carry
338/// work. A section after the members parked costs more (about 27 us on the
339/// measured host; after a gap of a millisecond or more 6 us on a team, and 30 to
340/// 45 us for a plain token from outside). That penalty is documented, not part
341/// of the values: a rule that read the state would keep sparse callers serial
342/// and never warm the team.
343///
344/// # Examples
345///
346/// ```
347/// use std::num::NonZeroUsize;
348/// use std::time::Duration;
349/// use threadteam::{DispatchCosts, DispatchKind, WidthCosts};
350///
351/// let costs = DispatchCosts::host_default()
352/// .with(DispatchKind::Outside, WidthCosts::flat(Duration::from_micros(50)));
353/// let w6 = NonZeroUsize::new(6).unwrap();
354/// assert_eq!(costs.cost(DispatchKind::Outside, w6), Duration::from_micros(50));
355/// ```
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub struct DispatchCosts {
358 team: WidthCosts,
359 in_pool: WidthCosts,
360 outside: WidthCosts,
361}
362
363const fn us_f(x: f64) -> Duration {
364 // Microseconds with one decimal, for the tables below.
365 Duration::from_nanos((x * 1000.0) as u64)
366}
367
368/// A measured row built at compile time: an invalid anchor list fails the
369/// build, never a caller.
370const fn anchored(anchors: &[(usize, Duration)]) -> WidthCosts {
371 match WidthCosts::from_anchors(anchors) {
372 Some(costs) => costs,
373 None => panic!("invalid measured anchors"),
374 }
375}
376
377/// `x86_64-avx512` team row (post-fix run, with its width 8 anchor).
378const AVX512_TEAM: WidthCosts = anchored(&[
379 (2, us_f(0.6)),
380 (4, us_f(0.7)),
381 (6, us_f(0.9)),
382 (8, us_f(0.98)),
383]);
384
385/// `x86_64-avx2` team row: broccoli (AMD EPYC 7702P, Zen2), pool 64,
386/// `team/load10us-minus-10us`, nanoseconds rounded to the nearest
387/// (`docs/design/thresholds-data/raw/broccoli-dispatch-20261029.csv`).
388const AVX2_TEAM: WidthCosts = anchored(&[
389 (2, Duration::from_nanos(1423)),
390 (4, Duration::from_nanos(1992)),
391 (6, Duration::from_nanos(2329)),
392 (8, Duration::from_nanos(2738)),
393 (12, Duration::from_nanos(3558)),
394 (16, Duration::from_nanos(4076)),
395 (24, Duration::from_nanos(4292)),
396 (32, Duration::from_nanos(4838)),
397 (48, Duration::from_nanos(6402)),
398 (64, Duration::from_nanos(9333)),
399]);
400
401impl DispatchCosts {
402 /// Costs of the three kinds.
403 pub fn new(team: WidthCosts, in_pool: WidthCosts, outside: WidthCosts) -> Self {
404 DispatchCosts {
405 team,
406 in_pool,
407 outside,
408 }
409 }
410
411 /// The row of a host class. For `x86_64-avx512` and `aarch64-apple` the
412 /// dispatch costs and the rates of the consumer crates' tables for the same
413 /// class come from one machine in one clock state. The `x86_64-avx2` team
414 /// row is the exception: it is measured (on a Zen2 host, below) while the
415 /// consumer crates' rates for that class are still their generic rows, so
416 /// the gate compares a measured dispatch cost with a conservative rate.
417 ///
418 /// * [`HostClass::X86_64Avx512`]: measured on an AMD Ryzen AI 9 HX 470
419 /// under Linux, 8 threads on cores 4-11, boost off (2.0 GHz), from the
420 /// back to back sections whose shares carry 10 us of work (`faer-benchmark`
421 /// `data/probes/20261026-awake-team-linux`, `docs/design/thresholds.md`
422 /// section 3.6), in microseconds at widths 2, 4, 6: team 0.6, 0.7, 0.9;
423 /// in pool 3.0, 3.6, 3.5; outside 9.7, 10.1, 8.7. The team row is the one
424 /// measured after the per-member parker fix and carries the measured width
425 /// 8 anchor 0.98 us; in pool and outside keep the three widths of the same
426 /// run (their width 8 values are from the pre-fix run and are not used).
427 /// * [`HostClass::Aarch64Apple`]: measured on an Apple M5 Max, 6 threads,
428 /// QoS `UserInitiated`, from the back to back sections whose shares
429 /// carry 10 us of work (`docs/design/thresholds-data/`), at widths 2, 4,
430 /// 6: team 3.0, 2.3, 1.4; in pool 6.0, 11, 5.5; outside 14, 18, 19.
431 /// * [`HostClass::X86_64Avx2`]: the team row measured on `broccoli`, an
432 /// AMD EPYC 7702P (Zen2, 64 cores, SMT off), boost off, governor
433 /// `performance`, pool 64 pinned to CPUs 0-63, with the Linux port of
434 /// `benches/dispatch_cost.rs` in `faer-benchmark` (back to back sections
435 /// whose shares carry 10 us of work, threadteam 0.1.0;
436 /// tensor4all/threadteam-rs#6, `docs/design/thresholds.md` section 3.7), in microseconds at widths 2, 4, 6, 8,
437 /// 12, 16, 24, 32, 48, 64: 1.42, 1.99, 2.33, 2.74, 3.56, 4.08, 4.29,
438 /// 4.84, 6.40, 9.33. In pool and outside deliberately keep the generic
439 /// row. The class covers every AVX2 host, also small desktops, so the
440 /// team row is a representative default for the class, not a guarantee
441 /// for a given topology; a pool owner overrides it with
442 /// [`PoolBuilder::dispatch_costs`](crate::PoolBuilder::dispatch_costs).
443 /// * Every other class has no measurement and takes the conservative
444 /// generic row: 3 us for a team, 15 us in the pool and 40 us from
445 /// outside at the anchors 2, 4 and 6, and above width 6 the width
446 /// proportion (32, 160 and 427 us at width 64). It errs towards staying
447 /// serial.
448 ///
449 /// Above the largest anchor of a row the cost grows with the width
450 /// ([`WidthCosts`]); [`WidthCosts::from_anchors`] replaces that
451 /// extrapolation where a host has measured anchors beyond width 6.
452 pub fn for_class(class: HostClass) -> Self {
453 match class {
454 HostClass::X86_64Avx512 => DispatchCosts {
455 team: AVX512_TEAM,
456 in_pool: WidthCosts::new([us_f(3.0), us_f(3.6), us_f(3.5)]),
457 outside: WidthCosts::new([us_f(9.7), us_f(10.1), us_f(8.7)]),
458 },
459 HostClass::Aarch64Apple => DispatchCosts {
460 team: WidthCosts::new([us_f(3.0), us_f(2.3), us_f(1.4)]),
461 in_pool: WidthCosts::new([us_f(6.0), us_f(11.0), us_f(5.5)]),
462 outside: WidthCosts::new([us_f(14.0), us_f(18.0), us_f(19.0)]),
463 },
464 HostClass::X86_64Avx2 => DispatchCosts {
465 team: AVX2_TEAM,
466 in_pool: WidthCosts::flat(us_f(15.0)),
467 outside: WidthCosts::flat(us_f(40.0)),
468 },
469 HostClass::X86_64Scalar | HostClass::Aarch64 | HostClass::Generic => DispatchCosts {
470 team: WidthCosts::flat(us_f(3.0)),
471 in_pool: WidthCosts::flat(us_f(15.0)),
472 outside: WidthCosts::flat(us_f(40.0)),
473 },
474 }
475 }
476
477 /// The row of the host class this process runs on
478 /// (`for_class(HostClass::detect())`).
479 pub fn host_default() -> Self {
480 Self::for_class(HostClass::detect())
481 }
482
483 /// These costs with the table of `kind` replaced.
484 #[must_use]
485 pub fn with(mut self, kind: DispatchKind, costs: WidthCosts) -> Self {
486 match kind {
487 DispatchKind::Team => self.team = costs,
488 DispatchKind::InPool => self.in_pool = costs,
489 DispatchKind::Outside => self.outside = costs,
490 }
491 self
492 }
493
494 /// The cheapest cost of `kind` over all widths ([`WidthCosts::min`]): a
495 /// region below `PAYS * min_cost(kind)` pays at no width of that kind.
496 pub fn min_cost(&self, kind: DispatchKind) -> Duration {
497 match kind {
498 DispatchKind::Team => self.team.min(),
499 DispatchKind::InPool => self.in_pool.min(),
500 DispatchKind::Outside => self.outside.min(),
501 }
502 }
503
504 /// The cost of one section of `width` participants on a token of `kind`.
505 /// Measured anchors keep their values, widths between two anchors are
506 /// interpolated, and widths above the largest anchor of the row grow with
507 /// the width ([`WidthCosts`]).
508 #[inline]
509 pub fn cost(&self, kind: DispatchKind, width: NonZeroUsize) -> Duration {
510 match kind {
511 DispatchKind::Team => self.team.at(width),
512 DispatchKind::InPool => self.in_pool.at(width),
513 DispatchKind::Outside => self.outside.at(width),
514 }
515 }
516}
517
518impl Default for DispatchCosts {
519 fn default() -> Self {
520 DispatchCosts::host_default()
521 }
522}
523
524/// [`PAYS`] as the integer the comparison uses (checked equal in the tests).
525const PAYS_INT: u128 = 3;
526
527/// `est_ns >= PAYS * sections * cost_ns` in integers; `cost_ns` must not be the
528/// saturated serial cost.
529#[inline]
530fn pays_ns(est_ns: u64, sections: u32, cost_ns: u64) -> bool {
531 u128::from(est_ns) >= PAYS_INT * u128::from(sections) * u128::from(cost_ns)
532}
533
534/// The one rule: parallel exactly when
535/// `estimate >= PAYS * sections * dispatch_cost`, compared in integer
536/// nanoseconds.
537///
538/// `dispatch_cost` comes from [`Exec::dispatch_cost`](crate::Exec::dispatch_cost)
539/// (or a consumer's own token type that delegates to it). `Duration::MAX`
540/// (a serial token) never pays; `sections == 0` always pays (nothing to
541/// dispatch). `sections` is the real number of dispatches the region performs,
542/// not a tuning knob.
543///
544/// # Examples
545///
546/// ```
547/// use std::time::Duration;
548/// use threadteam::pays_at;
549///
550/// let d = Duration::from_micros(2);
551/// assert!(pays_at(Duration::from_micros(6), 1, d));
552/// assert!(!pays_at(Duration::from_micros(5), 1, d));
553/// assert!(!pays_at(Duration::from_micros(6), 2, d)); // two sections need 12 us
554/// assert!(!pays_at(Duration::from_secs(1), 1, Duration::MAX));
555/// ```
556pub fn pays_at(estimate: Duration, sections: u32, dispatch_cost: Duration) -> bool {
557 if dispatch_cost == Duration::MAX {
558 return false;
559 }
560 if sections == 0 {
561 return true;
562 }
563 pays_ns(to_ns(estimate), sections, to_ns(dispatch_cost))
564}
565
566/// The width in `1..=budget` that minimises `sections * D(k) + estimate / k`,
567/// where `D` is `cost` and a width only counts when the region [`pays_at`] it;
568/// 1 when no width does. Ties go to the smaller width. Allocation-free.
569///
570/// # Examples
571///
572/// ```
573/// use std::num::NonZeroUsize;
574/// use std::time::Duration;
575/// use threadteam::width_for_by;
576///
577/// let budget = NonZeroUsize::new(6).unwrap();
578/// let flat = |_| Duration::from_micros(2);
579/// assert_eq!(width_for_by(Duration::from_micros(1), 1, budget, flat).get(), 1);
580/// assert_eq!(width_for_by(Duration::from_millis(1), 1, budget, flat).get(), 6);
581/// ```
582pub fn width_for_by(
583 estimate: Duration,
584 sections: u32,
585 budget: NonZeroUsize,
586 cost: impl Fn(NonZeroUsize) -> Duration,
587) -> NonZeroUsize {
588 let est_ns = to_ns(estimate);
589 let est = est_ns as f64;
590 let mut best = NonZeroUsize::MIN;
591 let mut best_obj = est;
592 for k in 2..=budget.get() {
593 let Some(width) = NonZeroUsize::new(k) else {
594 continue;
595 };
596 let d = cost(width);
597 if d == Duration::MAX {
598 continue;
599 }
600 let d_ns = to_ns(d);
601 if sections != 0 && !pays_ns(est_ns, sections, d_ns) {
602 continue;
603 }
604 let obj = f64::from(sections) * d_ns as f64 + est / k as f64;
605 if obj < best_obj {
606 best = width;
607 best_obj = obj;
608 }
609 }
610 best
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 fn nz(n: usize) -> NonZeroUsize {
618 NonZeroUsize::new(n).unwrap()
619 }
620 fn us(n: u64) -> Duration {
621 Duration::from_micros(n)
622 }
623
624 #[test]
625 fn anchors_are_returned_exactly() {
626 let c = DispatchCosts::for_class(HostClass::Aarch64Apple);
627 assert_eq!(c.cost(DispatchKind::Team, nz(2)), us_f(3.0));
628 assert_eq!(c.cost(DispatchKind::Team, nz(4)), us_f(2.3));
629 assert_eq!(c.cost(DispatchKind::Team, nz(6)), us_f(1.4));
630 assert_eq!(c.cost(DispatchKind::Outside, nz(6)), us_f(19.0));
631 }
632
633 #[test]
634 fn interpolation_and_extrapolation() {
635 let c = WidthCosts::new([us(3), us(2), us(1)]);
636 assert_eq!(c.at(nz(1)), us(3));
637 assert_eq!(c.at(nz(3)), Duration::from_nanos(2500));
638 assert_eq!(c.at(nz(5)), Duration::from_nanos(1500));
639 // Above the last anchor the cost grows with the width.
640 assert_eq!(c.at(nz(8)), Duration::from_nanos(1333));
641 assert_eq!(c.at(nz(12)), us(2));
642 // Non-monotone anchors interpolate without underflow.
643 let n = WidthCosts::new([us(1), us(5), us(0)]);
644 assert_eq!(n.at(nz(5)), Duration::from_nanos(2500));
645 assert_eq!(n.at(nz(12)), Duration::ZERO);
646 }
647
648 #[test]
649 fn a_flat_row_extrapolates_in_proportion_to_the_width() {
650 let c = WidthCosts::flat(us(1));
651 assert_eq!(c.at(nz(6)), us(1));
652 assert_eq!(c.at(nz(12)), us(2));
653 assert_eq!(c.at(nz(24)), us(4));
654 assert_eq!(c.at(nz(48)), us(8));
655 assert_eq!(c.at(nz(64)), Duration::from_nanos(10_666));
656 assert_eq!(c.min(), us(1));
657 }
658
659 /// Above the last anchor `w_m` the cost never falls and is at least
660 /// `cost(w_m) * w / w_m`.
661 #[test]
662 fn above_the_last_anchor_is_non_decreasing_and_at_least_linear() {
663 let rows: [[Duration; 3]; 5] = [
664 [us_f(0.6), us_f(0.7), us_f(0.9)],
665 [us_f(3.0), us_f(3.6), us_f(3.5)],
666 [us_f(9.7), us_f(10.1), us_f(8.7)],
667 [us(3), us(2), us(1)],
668 [us(1), us(5), us(0)],
669 ];
670 for row in rows {
671 let c = WidthCosts::new(row);
672 let cm = u128::from(to_ns(c.at(nz(6))));
673 let mut prev = c.at(nz(6));
674 for w in 7..=64u64 {
675 let v = c.at(nz(w as usize));
676 assert!(v >= prev, "{row:?}: width {w} fell");
677 assert!(
678 u128::from(to_ns(v)) >= cm * u128::from(w) / 6,
679 "{row:?}: width {w} below the linear bound"
680 );
681 prev = v;
682 }
683 }
684 }
685
686 #[test]
687 fn anchors_beyond_six_interpolate_and_extrapolate() {
688 let c = WidthCosts::from_anchors(&[
689 (2, Duration::from_nanos(8000)),
690 (4, Duration::from_nanos(4000)),
691 (8, Duration::from_nanos(2000)),
692 (16, Duration::from_nanos(1000)),
693 (32, Duration::from_nanos(500)),
694 (64, Duration::from_nanos(500)),
695 ])
696 .unwrap();
697 let ns = |w: usize| c.at(nz(w)).as_nanos() as u64;
698 assert_eq!(ns(2), 8000);
699 assert_eq!(ns(3), 6000); // between 2 and 4
700 assert_eq!(ns(6), 3000); // between 4 and 8, past the old last anchor
701 assert_eq!(ns(12), 1500); // between 8 and 16
702 assert_eq!(ns(24), 750); // between 16 and 32
703 assert_eq!(ns(48), 500); // between 32 and 64
704 assert_eq!(ns(64), 500); // the last anchor
705 assert_eq!(ns(96), 750); // above the last anchor
706 assert_eq!(ns(128), 1000);
707 assert_eq!(c.min(), Duration::from_nanos(500));
708 }
709
710 #[test]
711 fn from_anchors_rejects_invalid_rows() {
712 assert!(WidthCosts::from_anchors(&[]).is_none());
713 assert!(WidthCosts::from_anchors(&[(0, us(1))]).is_none());
714 assert!(WidthCosts::from_anchors(&[(1, us(1)), (2, us(1))]).is_none());
715 assert!(WidthCosts::from_anchors(&[(2, us(1)), (2, us(1))]).is_none());
716 assert!(WidthCosts::from_anchors(&[(4, us(1)), (2, us(1))]).is_none());
717 let too_wide = [(usize::from(u16::MAX) + 1, us(1))];
718 assert!(WidthCosts::from_anchors(&too_wide).is_none());
719 assert!(WidthCosts::from_anchors(&[(usize::from(u16::MAX), us(1))]).is_some());
720 // MAX_ANCHORS anchors fit, one more is rejected.
721 let mut many = [(0usize, Duration::ZERO); WidthCosts::MAX_ANCHORS + 1];
722 for (k, a) in many.iter_mut().enumerate() {
723 *a = (k + 2, us(1));
724 }
725 assert!(WidthCosts::from_anchors(&many[..WidthCosts::MAX_ANCHORS]).is_some());
726 assert!(WidthCosts::from_anchors(&many).is_none());
727 }
728
729 /// `from_anchors` is usable in a `const` item and the unused capacity does
730 /// not take part in equality.
731 #[test]
732 fn from_anchors_is_const_and_capacity_is_invisible() {
733 const ROW: WidthCosts = match WidthCosts::from_anchors(&[
734 (2, Duration::from_micros(3)),
735 (4, Duration::from_micros(2)),
736 (8, Duration::from_micros(1)),
737 ]) {
738 Some(c) => c,
739 None => panic!("valid anchors"),
740 };
741 assert_eq!(WidthCosts::MAX_ANCHORS, 12);
742 assert_eq!(ROW.at(nz(6)), Duration::from_nanos(1500));
743 assert_eq!(ROW.at(nz(16)), us(2));
744 assert_eq!(ROW.min(), us(1));
745
746 let three = WidthCosts::from_anchors(&[(2, us(3)), (4, us(2)), (6, us(1))]).unwrap();
747 assert_eq!(three, WidthCosts::new([us(3), us(2), us(1)]));
748 let four =
749 WidthCosts::from_anchors(&[(2, us(3)), (4, us(2)), (6, us(1)), (8, us(1))]).unwrap();
750 assert_ne!(three, four);
751 }
752
753 #[test]
754 fn a_duration_max_row_never_pays() {
755 // `Duration::MAX` saturates to `u64::MAX` nanoseconds in a row.
756 let cap = Duration::from_nanos(u64::MAX);
757 let c = WidthCosts::flat(Duration::MAX);
758 assert_eq!(c.at(nz(6)), cap);
759 assert_eq!(c.at(nz(64)), cap); // the extrapolation saturates
760 assert_eq!(c.min(), cap);
761 assert!(!pays_at(cap, 1, c.at(nz(64))));
762 assert!(!pays_at(cap, 2, c.at(nz(64))));
763 let mixed = WidthCosts::from_anchors(&[(2, Duration::ZERO), (4, Duration::MAX)]).unwrap();
764 assert_eq!(mixed.at(nz(64)), cap);
765 assert_eq!(mixed.min(), Duration::ZERO);
766 }
767
768 /// A wide region selects a useful interior width above six, not always six
769 /// or the budget.
770 #[test]
771 fn a_wide_region_selects_an_interior_width_above_six() {
772 let row = WidthCosts::flat(us(1));
773 let d = |k: NonZeroUsize| row.at(k);
774 let budget = nz(64);
775 // obj(k) = D(k) + 24 us / k with D(k) = 1 us * k / 6 above 6: obj(12) =
776 // 2 + 2 = 4 us against obj(6) = 1 + 4 = 5 us, obj(11) = 1.833 + 2.18 and
777 // obj(13) = 2.167 + 1.85.
778 assert_eq!(width_for_by(us(24), 1, budget, d).get(), 12);
779 // A large enough estimate pushes the optimum to the budget.
780 assert_eq!(width_for_by(us(1_000_000), 1, budget, d).get(), 64);
781 // A region that does not pay at width 6 does not pay at 64 either.
782 let below = Duration::from_nanos(3 * 1000 - 1);
783 assert!(!pays_at(below, 1, row.at(nz(6))));
784 assert!(!pays_at(below, 1, row.at(nz(64))));
785 assert_eq!(width_for_by(below, 1, budget, d).get(), 1);
786 }
787
788 /// The model as it was before the anchors were stored: i128 interpolation on
789 /// every call, flat above width 6.
790 fn old_at(ns: [u64; 3], w: u64) -> u64 {
791 if w <= 2 {
792 ns[0]
793 } else if w >= 6 {
794 ns[2]
795 } else {
796 let i = usize::from(w > 4);
797 let (w0, w1) = ([2u64, 4, 6][i], [2u64, 4, 6][i + 1]);
798 let (c0, c1) = (i128::from(ns[i]), i128::from(ns[i + 1]));
799 (c0 + (c1 - c0) * i128::from(w - w0) / i128::from(w1 - w0)).max(0) as u64
800 }
801 }
802
803 #[test]
804 fn rows_keep_the_old_values_inside_the_anchors() {
805 let tables = [
806 [3000, 2300, 1400],
807 [2800, 2200, 1700],
808 [14000, 18000, 19000],
809 [9500, 10100, 8800],
810 [3100, 3600, 3500],
811 [1000, 5000, 0],
812 [0, 0, 0],
813 [7, 3, 11],
814 ];
815 for ns in tables {
816 let c = WidthCosts::new(ns.map(Duration::from_nanos));
817 for w in 1..=6u64 {
818 assert_eq!(
819 c.at(nz(w as usize)),
820 Duration::from_nanos(old_at(ns, w)),
821 "{ns:?} width {w}"
822 );
823 }
824 }
825 // The minimum over the anchors is the minimum over all widths: neither
826 // interpolation nor the extrapolation can undershoot its endpoint.
827 for d in [
828 DispatchCosts::for_class(HostClass::Aarch64Apple),
829 DispatchCosts::for_class(HostClass::X86_64Avx512),
830 DispatchCosts::for_class(HostClass::Generic),
831 ] {
832 for kind in [
833 DispatchKind::Team,
834 DispatchKind::InPool,
835 DispatchKind::Outside,
836 ] {
837 let m = (1..=64).map(|w| d.cost(kind, nz(w))).min().unwrap();
838 assert_eq!(d.min_cost(kind), m);
839 }
840 }
841 }
842
843 /// The measured rows at widths 1 to 6 as the pre-anchor model gave them, so
844 /// this change moves no decision inside the anchors.
845 #[test]
846 fn measured_rows_are_pinned_at_widths_one_to_six() {
847 let avx512 = DispatchCosts::for_class(HostClass::X86_64Avx512);
848 let apple = DispatchCosts::for_class(HostClass::Aarch64Apple);
849 let generic = DispatchCosts::for_class(HostClass::Generic);
850 for (costs, kind, expected) in [
851 (
852 avx512,
853 DispatchKind::Team,
854 [600u64, 600, 650, 700, 800, 900],
855 ),
856 (
857 avx512,
858 DispatchKind::InPool,
859 [3000, 3000, 3300, 3600, 3550, 3500],
860 ),
861 (
862 avx512,
863 DispatchKind::Outside,
864 [9700, 9700, 9900, 10100, 9400, 8700],
865 ),
866 (
867 apple,
868 DispatchKind::Team,
869 [3000, 3000, 2650, 2300, 1850, 1400],
870 ),
871 (
872 apple,
873 DispatchKind::InPool,
874 [6000, 6000, 8500, 11000, 8250, 5500],
875 ),
876 (
877 apple,
878 DispatchKind::Outside,
879 [14000, 14000, 16000, 18000, 18500, 19000],
880 ),
881 (generic, DispatchKind::Team, [3000; 6]),
882 (generic, DispatchKind::InPool, [15000; 6]),
883 (generic, DispatchKind::Outside, [40000; 6]),
884 ] {
885 for (k, want) in expected.into_iter().enumerate() {
886 assert_eq!(
887 costs.cost(kind, nz(k + 1)).as_nanos() as u64,
888 want,
889 "{kind:?} width {}",
890 k + 1
891 );
892 }
893 }
894 // The measured width-8 team anchor replaces the extrapolation there;
895 // width 7 interpolates between the width-6 and width-8 team anchors.
896 assert_eq!(avx512.cost(DispatchKind::Team, nz(8)), us_f(0.98));
897 assert_eq!(
898 avx512.cost(DispatchKind::Team, nz(7)),
899 Duration::from_nanos(940)
900 );
901 assert_eq!(
902 avx512.cost(DispatchKind::Team, nz(64)),
903 Duration::from_nanos(7_840)
904 );
905 // The width-8 values in pool and outside come from the pre-fix run and
906 // are not anchors: the extrapolation applies to them.
907 assert_eq!(
908 avx512.cost(DispatchKind::InPool, nz(8)),
909 Duration::from_nanos(4666)
910 );
911 assert_eq!(
912 avx512.cost(DispatchKind::Outside, nz(8)),
913 Duration::from_nanos(11_600)
914 );
915 }
916
917 #[test]
918 fn min_is_right_for_a_rising_table_and_a_falling_one() {
919 let rising = WidthCosts::new([us(2), us(5), us(9)]);
920 assert_eq!(rising.min(), us(2));
921 let falling = WidthCosts::new([us(9), us(5), us(2)]);
922 assert_eq!(falling.min(), us(2));
923 let dip = WidthCosts::new([us(9), us(1), us(9)]);
924 assert_eq!(dip.min(), us(1));
925 assert_eq!(WidthCosts::flat(us(4)).min(), us(4));
926 }
927
928 #[test]
929 fn pays_matches_the_float_rule() {
930 assert_eq!(PAYS, PAYS_INT as f64);
931 for cost in [1u64, 700, 1700, 2800, 19_000] {
932 for sections in [1u32, 2, 5] {
933 let edge = 3 * u64::from(sections) * cost;
934 for est in [edge - 1, edge, edge + 1] {
935 let f = est as f64 >= PAYS * f64::from(sections) * cost as f64;
936 assert_eq!(
937 pays_at(
938 Duration::from_nanos(est),
939 sections,
940 Duration::from_nanos(cost)
941 ),
942 f
943 );
944 }
945 }
946 }
947 }
948
949 #[test]
950 fn pays_is_the_multiple_of_the_cost() {
951 let d = us(2);
952 assert!(pays_at(us(6), 1, d));
953 assert!(!pays_at(Duration::from_nanos(5999), 1, d));
954 assert!(pays_at(us(12), 2, d));
955 assert!(!pays_at(us(11), 2, d));
956 assert!(!pays_at(us(1_000_000), 1, Duration::MAX));
957 assert!(pays_at(Duration::ZERO, 0, d));
958 }
959
960 #[test]
961 fn width_for_minimises_the_model() {
962 let budget = nz(6);
963 let d = |k: NonZeroUsize| us(2) * k.get() as u32;
964 // 2 us * k + est / k: for est = 200 us the optimum is k = 10 clamped to
965 // the budget; for est = 50 us it is k = 5 (obj 20) against k = 4 (20.5)
966 // and k = 6 (20.3).
967 assert_eq!(width_for_by(us(200), 1, budget, d).get(), 6);
968 assert_eq!(width_for_by(us(50), 1, budget, d).get(), 5);
969 // Below the multiple at every width: serial.
970 assert_eq!(width_for_by(us(5), 1, budget, d).get(), 1);
971 // More sections move the optimum down.
972 assert!(width_for_by(us(200), 4, budget, d).get() < 6);
973 // A serial cost never pays.
974 assert_eq!(
975 width_for_by(us(1_000_000), 1, budget, |_| Duration::MAX).get(),
976 1
977 );
978 assert_eq!(width_for_by(us(1_000_000), 1, nz(1), d).get(), 1);
979 }
980
981 #[test]
982 fn wide_values_take_the_128_bit_path_with_the_same_result() {
983 // Values whose products overflow 64 bits use the 128-bit fallback; it
984 // must give what the 128-bit formula gives.
985 let big = Duration::from_nanos(u64::MAX / 3);
986 let huge = Duration::from_nanos(u64::MAX / 2);
987 let c = WidthCosts::from_anchors(&[(2, big), (64, huge)]).unwrap();
988 let (c0, c1) = (i128::from(u64::MAX / 3), i128::from(u64::MAX / 2));
989 for w in [3usize, 17, 33, 63] {
990 let want = c0 + (c1 - c0) * (w as i128 - 2) / 62;
991 assert_eq!(u128::from(c.ns_at(nz(w))), want as u128, "width {w}");
992 }
993 // Above the last anchor the product saturates instead of wrapping.
994 assert_eq!(c.ns_at(nz(200)), u64::MAX);
995 let want = u128::from(u64::MAX / 2) * 100 / 64;
996 assert_eq!(u128::from(c.ns_at(nz(100))), want.min(u128::from(u64::MAX)));
997 }
998
999 #[test]
1000 fn falling_interpolation_truncates_toward_zero() {
1001 // 4 + (0 - 4) * 1 / 3 = 4 - 1.33..: truncation toward zero gives 3, floor
1002 // rounding would give 2. Pins the old model's integer semantics.
1003 let ns = Duration::from_nanos;
1004 let c = WidthCosts::from_anchors(&[(2, ns(4)), (5, ns(0))]).unwrap();
1005 assert_eq!(c.at(nz(3)), ns(3));
1006 assert_eq!(c.at(nz(4)), ns(2));
1007 }
1008}