Skip to main content

strided_basic/
cost.rs

1//! Serial cost classes and the parallel gate of the strided kernels.
2//!
3//! A kernel does not own a minimum length. It states how long its region takes
4//! on one thread (elements times the serial throughput of its class) and asks
5//! the placement token whether that pays: a region runs in parallel when the
6//! serial time is at least [`threadteam::PAYS`] times the dispatch cost of every
7//! section it opens (`Exec::pays`). The cost of a section belongs to the token
8//! and the thread that calls, not to this crate; the design record is
9//! `threadteam`'s `docs/design/thresholds.md`.
10//!
11//! # The class table
12//!
13//! This table is the only place the throughputs live (the `Copy` row is owned by
14//! `strided-perm`, which sits below this crate). Every kernel of the workspace is
15//! one of four classes, and the rate of a class depends on the *host class*
16//! (`strided_perm::HostClass`, keyed by the kernel dispatch the machine takes,
17//! detected once at first use; the enum and `detect()` are kept in sync with
18//! `threadteam`'s). Serial, `f64`, in nanoseconds per element:
19//!
20//! | [`KernelClass`] | `x86_64-avx512` | `aarch64-apple` | `generic` (every other class) | kernels |
21//! | --- | --- | --- | --- | --- |
22//! | `Copy` | 0.171 | 0.090 | 0.090 | copies, fills, permutations, gathers, static indexing, transposes |
23//! | `Map` | 0.173 | 0.100 | 0.100 | element-wise map, zip, add, mul, axpy, fma, in-place update, fused arithmetic chains |
24//! | `Transcendental` | 5.60 | 1.38 | 1.38 | `exp`, `ln`, `tanh`, other libm-like functions (fused chains that contain one, softmax) |
25//! | `Reduce` | 0.104 | 0.081 | 0.081 | sum, dot, reduce, zip-reduce, axis reductions, norms, scans and arg-reductions |
26//!
27//! * `aarch64-apple`: one Apple M5 Max core, memory resident (thresholds.md
28//!   section 3.2).
29//! * `x86_64-avx512`: an AMD Ryzen AI 9 HX 470 at 2.0 GHz (boost off), from the
30//!   `seq` column of the strided probes (`copy`, `map`, `exp`, `sum`) in
31//!   `faer-benchmark` `data/probes/20261026-awake-team-linux/tables-confirm-exec3.md`
32//!   (width 8 table): the median over the L2-resident sizes 16384, 32768 and
33//!   65536 elements of `seq us * 1000 / elements`. Copy 2.80, 4.29, 13.17 us give
34//!   0.171, 0.131, 0.201; map 2.84, 5.21, 15.39 us give 0.173, 0.159, 0.235; exp
35//!   91.77, 180, 419 us give 5.60, 5.49, 6.39; sum 1.71, 3.55, 6.65 us give 0.104,
36//!   0.108, 0.102.
37//! * `generic` (`x86_64-avx2`, `x86_64-scalar`, `aarch64`, `generic`: no
38//!   measurement): the fastest known value of each row, the minimum over the
39//!   measured classes. It is the conservative choice: a rate that is too small
40//!   keeps work serial.
41//!
42//! The rates of a class and the dispatch costs of the same class in `threadteam`
43//! come from the same machine in the same clock state: boost on or off moves both
44//! by about 1.66x and leaves their ratio stable within a few percent
45//! (`raw-exec3-boost`, `tables-*-exec3-boost.md`).
46//!
47//! A reduction counts the source elements it reads (outputs times reduced
48//! elements). A kernel with a cost not in the table is classified by the nearest
49//! row, never given a constant of its own.
50//!
51//! # Sections
52//!
53//! `sections` is the number of dispatches the entry performs, counted truthfully:
54//!
55//! * A *flat* entry (`Exec::for_ranges`: the copy and fill helpers, the
56//!   permutation engine, the axis-chunked kernels) publishes the region once:
57//!   1 section at the chosen width.
58//! * A *tree* entry (the recursive halving of `parallel_map_reduce` and of the
59//!   dimension splitter) forks twice per level and the children fork again on
60//!   half of the budget, so the critical path holds `ceil(log2 width)` sections.
61//!   The splitter gates every node on its own fork (1 section at width 2) because
62//!   each child decides again for the work it has left.
63
64use core::num::NonZeroUsize;
65use core::time::Duration;
66use strided_perm::{copy_ns_per_element_of, HostClass};
67use threadteam::Exec;
68
69/// Serial cost class of a kernel; see the module documentation of `strided-basic/src/cost.rs` for the
70/// table.
71///
72/// # Examples
73///
74/// ```
75/// use strided_basic::execution::KernelClass;
76///
77/// assert!(KernelClass::Transcendental.ns_per_element() > KernelClass::Map.ns_per_element());
78/// // 1e6 elements at the rate of this host's class.
79/// let ns = KernelClass::Map.ns_per_element() * 1e6;
80/// assert_eq!(KernelClass::Map.estimate(1_000_000).as_nanos() as f64, ns.round());
81/// ```
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum KernelClass {
84    /// Copies, fills, permutations and index gathers.
85    Copy,
86    /// Element-wise arithmetic and fused arithmetic chains.
87    Map,
88    /// `exp`-like functions.
89    Transcendental,
90    /// Reductions and line kernels that read each source element once.
91    Reduce,
92}
93
94impl KernelClass {
95    /// Serial throughput of the class in nanoseconds per element on this host
96    /// ([`ns_per_element_of`](Self::ns_per_element_of) of `HostClass::detect()`).
97    pub fn ns_per_element(self) -> f64 {
98        self.ns_per_element_of(HostClass::detect())
99    }
100
101    /// Serial throughput of the class in nanoseconds per element on a host class
102    /// (the table in the module documentation).
103    pub const fn ns_per_element_of(self, host: HostClass) -> f64 {
104        match host {
105            HostClass::X86_64Avx512 => match self {
106                KernelClass::Copy => copy_ns_per_element_of(host),
107                KernelClass::Map => 0.173,
108                KernelClass::Transcendental => 5.60,
109                KernelClass::Reduce => 0.104,
110            },
111            HostClass::Aarch64Apple => match self {
112                KernelClass::Copy => copy_ns_per_element_of(host),
113                KernelClass::Map => 0.100,
114                KernelClass::Transcendental => 1.38,
115                KernelClass::Reduce => 0.081,
116            },
117            HostClass::X86_64Avx2
118            | HostClass::X86_64Scalar
119            | HostClass::Aarch64
120            | HostClass::Generic => match self {
121                KernelClass::Copy => copy_ns_per_element_of(HostClass::Generic),
122                KernelClass::Map => 0.100,
123                KernelClass::Transcendental => 1.38,
124                KernelClass::Reduce => 0.081,
125            },
126        }
127    }
128
129    /// Serial time of `elements` elements of this class.
130    pub fn estimate(self, elements: usize) -> Duration {
131        Duration::from_secs_f64(elements as f64 * self.ns_per_element() * 1e-9)
132    }
133}
134
135const ONE: NonZeroUsize = NonZeroUsize::MIN;
136
137/// Width of a flat entry (one section) that `exec` should use for `elements`
138/// elements of `class`: the width in `1..=budget` that minimises the section
139/// cost plus the share of the work, `1` when no width pays.
140///
141/// # Examples
142///
143/// ```
144/// use strided_basic::execution::{parallel_width, KernelClass};
145/// use strided_basic::Exec;
146///
147/// assert_eq!(parallel_width(&Exec::serial(), KernelClass::Copy, usize::MAX >> 8).get(), 1);
148/// ```
149pub fn parallel_width(exec: &Exec<'_>, class: KernelClass, elements: usize) -> NonZeroUsize {
150    if exec.is_serial() {
151        return ONE;
152    }
153    exec.width_for(class.estimate(elements), 1)
154}
155
156/// Width of a tree entry (`parallel_map_reduce`): the width in `1..=budget`
157/// that minimises `ceil(log2 k) * D(k) + estimate / k` among the widths at which
158/// `ceil(log2 k)` sections pay, `1` when none does.
159///
160/// # Examples
161///
162/// ```
163/// use strided_basic::execution::{tree_width, KernelClass};
164/// use strided_basic::Exec;
165///
166/// assert_eq!(tree_width(&Exec::serial(), KernelClass::Reduce, usize::MAX >> 8).get(), 1);
167/// ```
168pub fn tree_width(exec: &Exec<'_>, class: KernelClass, elements: usize) -> NonZeroUsize {
169    if exec.is_serial() {
170        return ONE;
171    }
172    let estimate = class.estimate(elements);
173    let mut best = (ONE, estimate);
174    for k in 2..=exec.budget().get() {
175        let width = NonZeroUsize::new(k).unwrap_or(ONE);
176        let sections = k.next_power_of_two().trailing_zeros();
177        if !exec.pays(estimate, sections, width) {
178            continue;
179        }
180        let time = estimate / u32::try_from(k).unwrap_or(u32::MAX)
181            + exec.dispatch_cost(width).saturating_mul(sections);
182        if time < best.1 {
183            best = (width, time);
184        }
185    }
186    best.0
187}
188
189/// Narrows `exec` to the width a flat entry over `len` elements of `class` justifies.
190pub(crate) fn for_len<'a>(exec: Exec<'a>, class: KernelClass, len: usize) -> Exec<'a> {
191    let width = parallel_width(&exec, class, len);
192    exec.limit(width)
193}
194
195/// Narrows `exec` to the width a tree entry over `len` elements of `class` justifies.
196pub(crate) fn for_len_tree<'a>(exec: Exec<'a>, class: KernelClass, len: usize) -> Exec<'a> {
197    let width = tree_width(&exec, class, len);
198    exec.limit(width)
199}
200
201/// Whether one fork of `exec` over `len` elements of `class` pays: the gate of a
202/// node of the dimension splitter and of the entries that call it.
203pub(crate) fn is_parallel(class: KernelClass, len: usize, exec: &Exec<'_>) -> bool {
204    if exec.is_serial() {
205        return false;
206    }
207    let two = NonZeroUsize::new(2).unwrap_or(ONE);
208    exec.pays(class.estimate(len), 1, two.min(exec.budget()))
209}