Skip to main content

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}