tcontract_exec/batch.rs
1//! Batch lane policy: the shared vocabulary and the one decision function.
2//!
3//! Three libraries decide how a batch of independent problems is split over a
4//! thread budget (tcontract's packed driver and faer strategy, cpueinsum's
5//! grouped GEMM, tlinalg's lane plan). This module gives them one vocabulary
6//! and one pure function, [`batch_decision`], so a caller can force a route and
7//! two crates cannot drift on the same decision. The types are plain data with
8//! no scheduler dependency; they live apart from [`Exec`] so moving
9//! them later is a re-export, not a redesign.
10//!
11//! # Strategies
12//!
13//! [`BatchStrategy::Auto`] is the only heuristic. Every other variant forces a
14//! route and never falls back: the route either honors it (clamped to the
15//! items and the budget) or the library reports
16//! [`crate::ExecError::StrategyUnavailable`].
17//!
18//! Forced strategies are evaluated against the **original host budget**. Only
19//! `Auto` narrows, because the packed driver narrows its `Exec` twice (see
20//! below).
21//!
22//! # What `Auto` computes
23//!
24//! `Auto` has two forms, chosen by the query. This is how one formula serves
25//! both the shared work model and the packed driver's legacy rule:
26//!
27//! * Without `balanced_lanes`, the issue's generic formula: estimate
28//! `item_ns` and `total_ns`, cap the lanes at `total_ns /
29//! min_work_per_lane_ns`, and fan out only when `lanes >= 2`, `items >=
30//! min_items` and `items >= lanes * min_items_per_lane`. Each lane runs
31//! items serially; with one lane, `item_threads` is the width of one item, so
32//! a route that permits it (faer) can parallelize inside the item.
33//! * With `balanced_lanes`, the packed two-stage rule below, which the default
34//! [`WorkModel`] reproduces to the lane.
35//!
36//! # Widths come from the token
37//!
38//! `Auto` never owns a width model. Every width it needs is the token's:
39//! [`Exec::width_for`](crate::Exec::width_for) turns the estimated serial time of
40//! the region and the number of sections (dispatches) it performs into the width at
41//! which the region pays, using the dispatch cost of the token's kind (team, plain
42//! token inside its pool, plain token outside it; `threadteam` owns the table and
43//! the `PAYS` multiple). A batch has two such regions: the whole batch spread over
44//! lanes (one section) and one item on a team ([`BatchQuery::item_sections`]
45//! sections). The rules below combine them:
46//!
47//! | Rule | Source | Encoding here |
48//! |---|---|---|
49//! | packed balanced lanes | `crates/tcontract/src/driver/batch.rs:38`, `plan/mod.rs:641`, `driver/route.rs:49` | `Auto` + `balanced_lanes` + the default [`WorkModel`] (its item cost is `ns_per_flop()`) |
50//! | faer item-width-1 | `crates/tcontract/src/strategy/faer.rs:243` | `Auto` + `item_parallel`; a decision with `item_threads > 1` is the inner route, `lanes > 1` with item threads one is the outer route |
51//! | `contract_batched` item count | `crates/tcontract/src/batch.rs:140` | forced strategies, not `Auto`: [`BatchStrategy::Outer`] when the items fill the budget; below that one lane, the caller running each item on the whole budget (`Sequential`/`Inner`). It has no work threshold. |
52//!
53//! The packed rule is two-stage. `plan/mod.rs` first narrows the `Exec` to the
54//! width the whole batch pays for, and `driver/batch.rs` recomputes the width
55//! against that narrowed budget and only then applies its *tiny* (one item alone
56//! does not pay a team) and *balanced* (every lane non-empty, the split near-even,
57//! the batch alone fills the budget) tests. `Auto` reproduces exactly that: it
58//! narrows `budget` with the token, then applies the tiny/balanced tests when the
59//! query says `balanced_lanes`, and reports the narrowed width as `item_threads`
60//! when it keeps one lane.
61//!
62//! # Examples
63//!
64//! ```
65//! use tcontract_exec::batch::{batch_decision, BatchPolicy, BatchQuery, BatchStrategy};
66//! use tcontract_exec::{Exec, Pool};
67//!
68//! let sp = threadteam::Pool::builder().threads(8).build().unwrap();
69//! let pool = Pool::borrow(&sp);
70//! let exec = Exec::new(&pool, 8).unwrap();
71//! let query = BatchQuery::new(16, 64.0);
72//!
73//! // A forced route is clamped, never a fallback.
74//! let outer = BatchPolicy::new(BatchStrategy::Outer);
75//! let d = batch_decision(&outer, &exec, &query);
76//! assert_eq!(d.lanes, exec.budget().min(8));
77//! assert_eq!(d.item_threads, 1);
78//! ```
79
80use core::num::NonZeroUsize;
81
82use crate::Exec;
83
84/// How the items of a batch are split over a thread budget.
85///
86/// # Examples
87///
88/// ```
89/// use tcontract_exec::batch::BatchStrategy;
90/// assert_eq!(BatchStrategy::default(), BatchStrategy::Auto);
91/// ```
92#[non_exhaustive]
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub enum BatchStrategy {
95 /// Choose from the [`WorkModel`] and the query's route capabilities.
96 #[default]
97 Auto,
98 /// One lane, one item thread: no parallelism at all.
99 Sequential,
100 /// `lanes = min(budget, items)`, each item serial.
101 Outer,
102 /// One lane; the item kernel gets the whole budget.
103 Inner,
104 /// Exactly `lanes` lanes (clamped to the items and the budget), the rest
105 /// of the budget split evenly as item threads. A route that cannot run item
106 /// threads inside a lane refuses the resulting decision with
107 /// [`crate::ExecError::StrategyUnavailable`] before any write; it never silently
108 /// runs the items serially (see [`BatchDecision`]'s route contract).
109 Split {
110 /// Requested lane count.
111 lanes: NonZeroUsize,
112 },
113}
114
115/// The cost and fan-out model [`BatchStrategy::Auto`] applies.
116///
117/// The item rate is the serial GEMM throughput of the host class (`host::muladds_per_ns`: 27
118/// multiply-adds per nanosecond on `aarch64-apple` and the `generic` row, 15.6 on `x86_64-avx512`;
119/// see `crates/tcontract-exec/src/host.rs` and `docs/design/thresholds.md` of threadteam-rs);
120/// the width a team pays for comes from the token, not from this model.
121///
122/// The three item-cost fields follow tenferro's restored
123/// `CpuBatchThresholds`: one item costs roughly `item_overhead_ns` plus one
124/// nanosecond per `muladds_per_ns` multiply-adds, a lane needs
125/// `min_work_per_lane_ns` of estimated work, and a fan-out needs `min_items`
126/// items, at least `min_items_per_lane` per lane.
127///
128/// # Examples
129///
130/// ```
131/// use tcontract_exec::batch::WorkModel;
132///
133/// let model = WorkModel::default().with_min_items(4).with_muladds_per_ns(32.0);
134/// assert_eq!(model.min_items, 4);
135/// assert_eq!(model.muladds_per_ns, 32.0);
136/// ```
137#[non_exhaustive]
138#[derive(Clone, Copy, Debug, PartialEq)]
139pub struct WorkModel {
140 /// Estimated fixed cost of one item, in nanoseconds.
141 ///
142 /// The default is zero so that [`Default`] reproduces the packed
143 /// `ns_per_flop()` estimate.
144 pub item_overhead_ns: f64,
145 /// Estimated multiply-adds per nanosecond on one lane.
146 ///
147 /// `1.0 / ns_per_flop() / 2.0` (27 on `aarch64-apple` and the
148 /// `generic` row, 15.6 on `x86_64-avx512`) reproduces
149 /// `2 * macs * ns_per_flop()`: the packed driver counts two flops per
150 /// multiply-add. A value `<= 0` is treated as one multiply-add per
151 /// nanosecond.
152 pub muladds_per_ns: f64,
153 /// Least estimated work a lane must receive, in nanoseconds; `0` disables
154 /// the work gate (the packed default).
155 pub min_work_per_lane_ns: f64,
156 /// Fewest items for which `Auto` fans out at all.
157 pub min_items: usize,
158 /// Fewest items each lane must receive.
159 pub min_items_per_lane: usize,
160}
161
162impl Default for WorkModel {
163 fn default() -> Self {
164 Self {
165 // Reproduce `driver/batch.rs`: `item_ns = 2 * m * n * k * ns_per_flop()`.
166 item_overhead_ns: 0.0,
167 muladds_per_ns: crate::host::muladds_per_ns(),
168 min_work_per_lane_ns: 0.0,
169 min_items: 2,
170 min_items_per_lane: 1,
171 }
172 }
173}
174
175impl WorkModel {
176 /// Return this model with a different per-item overhead.
177 #[must_use]
178 pub fn with_item_overhead_ns(mut self, nanoseconds: f64) -> Self {
179 self.item_overhead_ns = nanoseconds;
180 self
181 }
182
183 /// Return this model with a different lane throughput.
184 ///
185 /// # Examples
186 ///
187 /// ```
188 /// use tcontract_exec::batch::WorkModel;
189 /// assert_eq!(WorkModel::default().with_muladds_per_ns(16.0).muladds_per_ns, 16.0);
190 /// ```
191 #[must_use]
192 pub fn with_muladds_per_ns(mut self, muladds_per_ns: f64) -> Self {
193 self.muladds_per_ns = muladds_per_ns;
194 self
195 }
196
197 /// Return this model with a different minimum work per lane.
198 ///
199 /// # Examples
200 ///
201 /// ```
202 /// use tcontract_exec::batch::WorkModel;
203 /// assert_eq!(WorkModel::default().with_min_work_per_lane_ns(8_000.0).min_work_per_lane_ns, 8_000.0);
204 /// ```
205 #[must_use]
206 pub fn with_min_work_per_lane_ns(mut self, nanoseconds: f64) -> Self {
207 self.min_work_per_lane_ns = nanoseconds;
208 self
209 }
210
211 /// Return this model with a different minimum fan-out item count.
212 #[must_use]
213 pub fn with_min_items(mut self, items: usize) -> Self {
214 self.min_items = items;
215 self
216 }
217
218 /// Return this model with a different minimum item count per lane.
219 #[must_use]
220 pub fn with_min_items_per_lane(mut self, items: usize) -> Self {
221 self.min_items_per_lane = items;
222 self
223 }
224}
225
226/// A strategy and the model its `Auto` arm applies.
227///
228/// # Examples
229///
230/// ```
231/// use tcontract_exec::batch::{BatchPolicy, BatchStrategy};
232/// assert_eq!(BatchPolicy::default().strategy, BatchStrategy::Auto);
233/// ```
234#[derive(Clone, Copy, Debug, Default, PartialEq)]
235pub struct BatchPolicy {
236 /// The route selector.
237 pub strategy: BatchStrategy,
238 /// The cost model `Auto` applies.
239 pub model: WorkModel,
240}
241
242impl BatchPolicy {
243 /// A policy with `strategy` and the default model.
244 #[must_use]
245 pub fn new(strategy: BatchStrategy) -> Self {
246 Self {
247 strategy,
248 model: WorkModel::default(),
249 }
250 }
251
252 /// Return this policy with a different strategy.
253 #[must_use]
254 pub fn with_strategy(mut self, strategy: BatchStrategy) -> Self {
255 self.strategy = strategy;
256 self
257 }
258
259 /// Return this policy with a different model.
260 #[must_use]
261 pub fn with_model(mut self, model: WorkModel) -> Self {
262 self.model = model;
263 self
264 }
265}
266
267/// What one batch of one route looks like to [`batch_decision`].
268///
269/// Work is described by an estimate, not by dimensions: each family supplies
270/// the multiply-adds of **one** item (GEMM `mnk`, LU `n^3/3`, solve `n^2 k`,
271/// QR `2mn^2/3`, with complex elements weighing four). The booleans and
272/// `balanced_lanes` say what the route permits; they are how the legacy rules
273/// above become inputs instead of hidden constants.
274///
275/// # Examples
276///
277/// ```
278/// use tcontract_exec::batch::BatchQuery;
279///
280/// let query = BatchQuery::new(32, 4096.0).with_item_parallel(false);
281/// assert_eq!(query.items, 32);
282/// assert!(!query.item_parallel);
283/// ```
284#[non_exhaustive]
285#[derive(Clone, Copy, Debug, PartialEq)]
286pub struct BatchQuery {
287 /// Number of independent items; `0` means there is no work.
288 pub items: usize,
289 /// Multiply-adds of one item.
290 pub item_muladds: f64,
291 /// The route permits item-parallel width greater than one, i.e.
292 /// `item_threads > 1` is meaningful. `Auto` keeps item threads one when
293 /// this is false.
294 pub item_parallel: bool,
295 /// `Auto` may only keep lanes when every lane is non-empty, the split is
296 /// near-even and the batch alone fills the narrowed budget: the packed
297 /// driver's balanced-lanes rule.
298 pub balanced_lanes: bool,
299 /// `Auto` prefers the inner route (one lane, the item kernel gets a team) and
300 /// fans out over items only when one item alone cannot use a team: the faer
301 /// batched-GEMM rule. The item width is then the width of one item, not the
302 /// whole budget.
303 pub outer_only_when_item_serial: bool,
304 /// Sections (dispatches) one item performs when it runs on a team: 1 for a
305 /// kernel that publishes its region once (the packed driver's grid, one faer
306 /// GEMM). A kernel that dispatches `s` times per item declares `s`, and the
307 /// token charges `s` dispatch costs against the item's serial time.
308 pub item_sections: u32,
309}
310
311impl BatchQuery {
312 /// A query for `items` items of `item_muladds` multiply-adds each, on a
313 /// route that permits item parallelism and has no vendor batch route.
314 #[must_use]
315 pub fn new(items: usize, item_muladds: f64) -> Self {
316 Self {
317 items,
318 item_muladds,
319 item_parallel: true,
320 balanced_lanes: false,
321 outer_only_when_item_serial: false,
322 item_sections: 1,
323 }
324 }
325
326 /// Return this query with the number of sections one item performs.
327 #[must_use]
328 pub fn with_item_sections(mut self, item_sections: u32) -> Self {
329 self.item_sections = item_sections.max(1);
330 self
331 }
332
333 /// Return this query with the item-parallel capability set.
334 #[must_use]
335 pub fn with_item_parallel(mut self, item_parallel: bool) -> Self {
336 self.item_parallel = item_parallel;
337 self
338 }
339
340 /// Return this query with the balanced-lanes capability set.
341 #[must_use]
342 pub fn with_balanced_lanes(mut self, balanced_lanes: bool) -> Self {
343 self.balanced_lanes = balanced_lanes;
344 self
345 }
346
347 /// Return this query with the "outer only when one item cannot use a team"
348 /// capability set (the faer batched-GEMM rule).
349 #[must_use]
350 pub fn with_outer_only_when_item_serial(mut self, value: bool) -> Self {
351 self.outer_only_when_item_serial = value;
352 self
353 }
354}
355
356/// The route [`batch_decision`] selected.
357///
358/// # The route contract
359///
360/// A decision is the **whole** concurrency of one call, and a route must never
361/// discard half of it: `lanes` outer lanes run a contiguous share of the items,
362/// each lane may run its items on `item_threads` threads, and `batch_decision`
363/// keeps `lanes * item_threads <= budget`. A route that cannot run an item on a
364/// sub-team inside a lane either honours both axes or refuses a decision that
365/// asks for both — only a forced [`BatchStrategy::Split`] produces `lanes > 1`
366/// together with `item_threads > 1` — with
367/// [`crate::ExecError::StrategyUnavailable`], before it writes any output. Silently
368/// running the items serially inside the lanes is not a route.
369///
370/// The routes in tcontract, and where each one stands:
371///
372/// * the faer strategy honours both axes: every item runs with
373/// `item_threads` slots as the width it hands faer through `Exec::external`
374/// inside its lane's share of the token, which nests there (`crates/tcontract/src/strategy/faer.rs`);
375/// * the packed driver refuses it
376/// (`crates/tcontract/src/driver/route.rs`): its inner parallelism is a
377/// co-scheduled, barrier-bearing team, and a lane worker cannot join one
378/// without blocking on an outer barrier;
379/// * `contract_batched` refuses it too (`crates/tcontract/src/batch.rs`): its
380/// schedule is one item per lane, or the items one after another on the whole
381/// context, as the decision log records.
382///
383/// # Examples
384///
385/// ```
386/// use tcontract_exec::batch::BatchDecision;
387/// let d = BatchDecision { lanes: 2, item_threads: 1 };
388/// assert_eq!(d.lanes * d.item_threads, 2);
389/// ```
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub struct BatchDecision {
392 /// Outer lanes; each lane runs a contiguous share of the items.
393 pub lanes: usize,
394 /// Threads one item may use. `1` for a route that runs items serially inside
395 /// each lane; a [`BatchStrategy::Split`] asks for both lanes and item threads,
396 /// and a route that cannot run item threads inside lanes refuses it (see the
397 /// type's route contract).
398 pub item_threads: usize,
399}
400
401/// Decide the route for one batch on the budget and the dispatch costs of `exec`
402/// under `policy`.
403///
404/// Forced strategies are clamped (`lanes <= min(items, budget)`,
405/// `lanes * item_threads <= budget`) but never fall back. `Auto` narrows the
406/// budget first (see the module docs) and uses the original budget only for the
407/// forced strategies.
408///
409/// Whether a *route* can run the returned decision is the route's own
410/// preflight: a decision with both lanes and item threads is honoured or
411/// refused before any output write, never partially applied (see
412/// [`BatchDecision`]'s route contract).
413///
414/// # Examples
415///
416/// ```
417/// use std::num::NonZeroUsize;
418/// use tcontract_exec::batch::{batch_decision, BatchPolicy, BatchQuery, BatchStrategy};
419/// use tcontract_exec::{Exec, Pool};
420///
421/// let sp = threadteam::Pool::builder().threads(4).build().unwrap();
422/// let pool = Pool::borrow(&sp);
423/// let exec = Exec::new(&pool, 4).unwrap();
424/// let query = BatchQuery::new(2, 1024.0);
425/// let split = BatchPolicy::new(BatchStrategy::Split {
426/// lanes: NonZeroUsize::new(8).unwrap(),
427/// });
428/// let d = batch_decision(&split, &exec, &query);
429/// // Clamped to the items and the budget.
430/// assert_eq!(d.lanes * d.item_threads, exec.budget());
431/// assert_eq!(d.lanes, 2.min(exec.budget()));
432/// ```
433pub fn batch_decision(policy: &BatchPolicy, exec: &Exec<'_>, query: &BatchQuery) -> BatchDecision {
434 batch_decision_within(policy, exec, exec.budget(), query)
435}
436
437/// [`batch_decision`] for a stage that was narrowed to `budget` slots (at most the
438/// token's own) without narrowing the token: the widths minimise over `1..=budget`
439/// with the token's dispatch costs ([`Exec::width_for_within`]).
440///
441/// # Examples
442///
443/// ```
444/// use tcontract_exec::batch::{batch_decision_within, BatchPolicy, BatchQuery};
445/// use tcontract_exec::Exec;
446///
447/// let d = batch_decision_within(&BatchPolicy::default(), &Exec::serial(), 4, &BatchQuery::new(8, 1e6));
448/// assert_eq!((d.lanes, d.item_threads), (1, 1));
449/// ```
450pub fn batch_decision_within(
451 policy: &BatchPolicy,
452 exec: &Exec<'_>,
453 budget: usize,
454 query: &BatchQuery,
455) -> BatchDecision {
456 let budget = budget.max(1).min(exec.budget());
457 decide(
458 policy,
459 budget,
460 &|ns, sections| exec.width_for_within(budget, ns, sections),
461 query,
462 )
463}
464
465/// The width in `1..=budget` at which `serial_ns` of work in `sections` sections pays.
466type Widths<'a> = &'a dyn Fn(f64, u32) -> usize;
467
468/// [`batch_decision`] over an explicit width rule: the pure body, so a test can
469/// give it a rule of its own.
470pub(crate) fn decide(
471 policy: &BatchPolicy,
472 budget: usize,
473 widths: Widths<'_>,
474 query: &BatchQuery,
475) -> BatchDecision {
476 match policy.strategy {
477 BatchStrategy::Auto => auto_decision(&policy.model, budget, widths, query),
478 BatchStrategy::Sequential => BatchDecision {
479 lanes: 1,
480 item_threads: 1,
481 },
482 BatchStrategy::Outer => BatchDecision {
483 lanes: clamp_lanes(budget, query.items),
484 item_threads: 1,
485 },
486 BatchStrategy::Inner => BatchDecision {
487 lanes: 1,
488 item_threads: budget.max(1),
489 },
490 BatchStrategy::Split { lanes } => {
491 let lanes = clamp_lanes(budget, query.items).min(lanes.get());
492 BatchDecision {
493 lanes,
494 item_threads: budget / lanes,
495 }
496 }
497 }
498}
499
500/// `lanes = min(budget, items)`, never below one.
501fn clamp_lanes(budget: usize, items: usize) -> usize {
502 items.clamp(1, budget.max(1))
503}
504
505/// The `Auto` arm.
506///
507/// Without `balanced_lanes` this is the issue's generic formula: estimate the
508/// work, cap the lanes by the work model, and fan out when the `min_items` and
509/// `min_items_per_lane` gates pass. With `balanced_lanes` it is the packed
510/// driver's two-stage narrowing, which is what the default [`WorkModel`]
511/// reproduces to the lane.
512fn auto_decision(
513 model: &WorkModel,
514 budget: usize,
515 widths: Widths<'_>,
516 query: &BatchQuery,
517) -> BatchDecision {
518 let items = query.items;
519 if items == 0 || budget < 2 {
520 return one_lane(1);
521 }
522 let item_ns = item_ns(model, query.item_muladds);
523 let total_ns = item_ns * items as f64;
524 if query.outer_only_when_item_serial {
525 faer_auto(widths, budget, query, item_ns, total_ns)
526 } else if query.balanced_lanes {
527 packed_auto(model, widths, query, item_ns, total_ns)
528 } else {
529 generic_auto(model, widths, budget, query, item_ns, total_ns)
530 }
531}
532
533/// The faer batched-GEMM rule: prefer the inner route, and fan out over items
534/// only when one item alone is too small for a team (`width_for(item_ns) == 1`)
535/// and there are at least two items with work enough to leave the caller.
536///
537/// This is the item-width-1 rule of `strategy/faer.rs`: a large item gets the
538/// whole team, a tiny one gets lanes so the team would not pay barriers for
539/// nothing.
540fn faer_auto(
541 widths: Widths<'_>,
542 budget: usize,
543 query: &BatchQuery,
544 item_ns: f64,
545 total_ns: f64,
546) -> BatchDecision {
547 // A route without item parallelism (a faer item on a token without a team)
548 // has no inner width: the items take the lanes.
549 let inner = if query.item_parallel {
550 widths(item_ns, query.item_sections)
551 } else {
552 1
553 };
554 if inner > 1 {
555 return one_lane(inner);
556 }
557 // The lanes are one fork over the items: one section.
558 let total = widths(total_ns, 1);
559 if total > 1 && query.items > 1 {
560 let lanes = budget.min(query.items).min(total);
561 return BatchDecision {
562 lanes,
563 item_threads: 1,
564 };
565 }
566 one_lane(1)
567}
568
569/// The issue's generic `auto_lanes`: lanes from the work model alone.
570fn generic_auto(
571 model: &WorkModel,
572 widths: Widths<'_>,
573 budget: usize,
574 query: &BatchQuery,
575 item_ns: f64,
576 total_ns: f64,
577) -> BatchDecision {
578 let items = query.items;
579 let by_work = work_lane_cap(model, total_ns);
580 let lanes = budget.min(items).min(by_work);
581 if lanes >= 2
582 && items >= model.min_items
583 && items >= lanes.saturating_mul(model.min_items_per_lane)
584 {
585 return BatchDecision {
586 lanes,
587 item_threads: 1,
588 };
589 }
590 // One lane: the inner route is the width of one item, when the route
591 // permits item parallelism (the faer rule).
592 let threads = if query.item_parallel {
593 widths(item_ns, query.item_sections)
594 } else {
595 1
596 };
597 one_lane(threads)
598}
599
600/// The packed driver's two-stage narrowing: `plan/mod.rs:641` narrows the
601/// budget over the whole batch, `driver/batch.rs:38` then applies the
602/// tiny/balanced rule against the narrowed budget.
603fn packed_auto(
604 model: &WorkModel,
605 widths: Widths<'_>,
606 query: &BatchQuery,
607 item_ns: f64,
608 total_ns: f64,
609) -> BatchDecision {
610 let items = query.items;
611 let narrowed = widths(total_ns, query.item_sections);
612 let one_lane_threads = if query.item_parallel { narrowed } else { 1 };
613 if narrowed < 2 {
614 return one_lane(one_lane_threads);
615 }
616 let mut lanes = items.min(narrowed).min(work_lane_cap(model, total_ns));
617 let gates_pass = lanes >= 2
618 && items >= model.min_items
619 && items >= lanes.saturating_mul(model.min_items_per_lane);
620 if !gates_pass {
621 return one_lane(one_lane_threads);
622 }
623 // Tiny items keep lanes (a team would pay barriers for nothing); otherwise
624 // the batch alone must fill the narrowed budget and split near-evenly.
625 let tiny = widths(item_ns, query.item_sections) < 2;
626 let balanced = items.saturating_mul(10)
627 >= 9usize
628 .saturating_mul(lanes)
629 .saturating_mul(items.div_ceil(lanes));
630 if !(tiny || (items >= narrowed && balanced)) {
631 lanes = 1;
632 }
633 BatchDecision {
634 lanes,
635 item_threads: if lanes > 1 { 1 } else { one_lane_threads },
636 }
637}
638
639/// Most lanes the estimated total work can fill, given
640/// [`WorkModel::min_work_per_lane_ns`]. Zero disables the gate.
641fn work_lane_cap(model: &WorkModel, total_ns: f64) -> usize {
642 if model.min_work_per_lane_ns > 0.0 {
643 let raw = total_ns / model.min_work_per_lane_ns;
644 if raw.is_finite() && raw >= 1.0 {
645 raw.floor() as usize
646 } else {
647 1
648 }
649 } else {
650 usize::MAX
651 }
652}
653
654/// One lane carrying `threads` item threads.
655fn one_lane(threads: usize) -> BatchDecision {
656 BatchDecision {
657 lanes: 1,
658 item_threads: threads.max(1),
659 }
660}
661
662/// Estimated cost of one item in nanoseconds.
663fn item_ns(model: &WorkModel, item_muladds: f64) -> f64 {
664 let rate = if model.muladds_per_ns > 0.0 {
665 model.muladds_per_ns
666 } else {
667 1.0
668 };
669 model.item_overhead_ns + item_muladds / rate
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn budget(n: usize) -> NonZeroUsize {
677 NonZeroUsize::new(n).unwrap()
678 }
679
680 /// Dispatch cost of the test rule: 500 ns per participant, so wider teams cost
681 /// more and a batch does not always fill the budget.
682 fn test_cost_ns(width: usize) -> u64 {
683 500 * width as u64
684 }
685
686 /// The test width rule: `threadteam`'s own pure rule over a flat cost.
687 fn test_width(serial_ns: f64, sections: u32, budget: usize) -> usize {
688 threadteam::width_for_by(
689 core::time::Duration::from_nanos(serial_ns.max(0.0) as u64),
690 sections,
691 NonZeroUsize::new(budget.max(1)).unwrap(),
692 |w| core::time::Duration::from_nanos(test_cost_ns(w.get())),
693 )
694 .get()
695 }
696
697 /// `batch_decision` over the test rule, on `budget` slots.
698 fn bd(policy: &BatchPolicy, budget: NonZeroUsize, query: &BatchQuery) -> BatchDecision {
699 let b = budget.get();
700 decide(
701 policy,
702 b,
703 &|ns, sections| test_width(ns, sections, b),
704 query,
705 )
706 }
707
708 /// Independent copy of the legacy packed computation.
709 ///
710 /// `plan/mod.rs:641` narrows the `Exec` with `width_for(total_ns)`, then
711 /// `driver/batch.rs:38` recomputes the width against that narrowed budget
712 /// and applies the tiny/balanced rule. Returns `(lanes, item_threads)` where
713 /// `item_threads` is the narrowed team width the route uses with one lane.
714 fn legacy_packed(items: usize, item_muladds: f64, host_budget: usize) -> (usize, usize) {
715 // `ns_per_flop()` applies to flops, and the packed driver counts two flops
716 // per multiply-add.
717 let item_ns = item_muladds / crate::host::muladds_per_ns();
718 let total_ns = item_ns * items as f64;
719 let narrowed = legacy_width_for(total_ns, host_budget);
720 if items < 2 || narrowed < 2 {
721 return (1, narrowed);
722 }
723 let width = legacy_width_for(total_ns, narrowed);
724 if width < 2 {
725 return (1, narrowed);
726 }
727 let tiny = legacy_width_for(item_ns, host_budget) < 2;
728 let lanes = items.min(width);
729 let balanced = 10 * items >= 9 * lanes * items.div_ceil(lanes);
730 let lanes = if tiny || (items >= narrowed && balanced) {
731 lanes
732 } else {
733 1
734 };
735 (lanes, if lanes > 1 { 1 } else { narrowed })
736 }
737
738 /// The test rule, written out: the least objective among widths that pay.
739 fn legacy_width_for(serial_ns: f64, budget: usize) -> usize {
740 let mut best = (1usize, serial_ns);
741 for k in 2..=budget {
742 let d = test_cost_ns(k) as f64;
743 if serial_ns >= threadteam::PAYS * d {
744 let obj = d + serial_ns / k as f64;
745 if obj < best.1 {
746 best = (k, obj);
747 }
748 }
749 }
750 best.0
751 }
752
753 #[test]
754 fn auto_matches_legacy_packed_table() {
755 let policy = BatchPolicy::default();
756 let items = [0usize, 1, 2, 3, 7, 8, 9, 16, 64, 512];
757 let muladds = [
758 1.0,
759 64.0,
760 4_096.0,
761 32_768.0,
762 262_144.0,
763 1_000_000.0,
764 8_000_000.0,
765 ];
766 let budgets = [1usize, 2, 3, 4, 8, 16];
767 for &h in &items {
768 for &m in &muladds {
769 for &b in &budgets {
770 let query = BatchQuery::new(h, m).with_balanced_lanes(true);
771 let got = bd(&policy, budget(b), &query);
772 let want = legacy_packed(h, m, b);
773 assert_eq!(
774 (got.lanes, got.item_threads),
775 (want.0, want.1),
776 "items={h} item_muladds={m} budget={b}"
777 );
778 }
779 }
780 }
781 }
782
783 #[test]
784 fn generic_auto_matches_tenferro_auto_lanes_table() {
785 // tenferro's `CpuBatchThresholds` defaults: 50 ns per item, 16
786 // multiply-adds per ns, 8 us per lane. This is the table at
787 // tenferro-rs `dot_runtime/tests.rs:3025` (`auto_lane_count_needs_enough_work_per_lane`).
788 let model = WorkModel::default()
789 .with_item_overhead_ns(50.0)
790 .with_muladds_per_ns(16.0)
791 .with_min_work_per_lane_ns(8_000.0);
792 let policy = BatchPolicy::new(BatchStrategy::Auto).with_model(model);
793 let lanes = |item_muladds: f64, batch: usize, threads: usize| {
794 bd(
795 &policy,
796 budget(threads),
797 &BatchQuery::new(batch, item_muladds),
798 )
799 .lanes
800 };
801 // 4^3 = 64 multiply-adds: per-call overhead dominates, so many items
802 // fill a lane.
803 assert_eq!(lanes(64.0, 1024, 4), 4);
804 assert_eq!(lanes(64.0, 64, 4), 1);
805 // Larger items need fewer per lane (16^3 is 306 ns, so 27 per lane);
806 // lanes never exceed the threads.
807 assert_eq!(lanes(32_768.0, 8, 16), 2);
808 assert_eq!(lanes(4_096.0, 128, 8), 4);
809 // One lane of work is no fan-out.
810 assert_eq!(lanes(4_096.0, 32, 8), 1);
811 // Two items of tiny work still pass the item gate but not the work
812 // gate: zero minimum work lets the item thresholds decide alone.
813 let no_work_gate =
814 BatchPolicy::new(BatchStrategy::Auto).with_model(model.with_min_work_per_lane_ns(0.0));
815 assert_eq!(
816 bd(&no_work_gate, budget(4), &BatchQuery::new(64, 64.0)).lanes,
817 4
818 );
819 assert_eq!(
820 bd(&no_work_gate, budget(4), &BatchQuery::new(1, 64.0)).lanes,
821 1
822 );
823 }
824
825 /// Independent copy of the pre-#123 faer schedule.
826 ///
827 /// `strategy/faer.rs` computed `inner = width_for(item_ns)` and
828 /// `total = width_for(item_ns * count)` and picked outer lanes only when
829 /// `inner == 1 && total > 1 && count > 1`. Returns `(lanes, item_threads)`.
830 fn legacy_faer(count: usize, item_muladds: f64, budget: usize) -> (usize, usize) {
831 let item_ns = item_muladds / crate::host::muladds_per_ns();
832 let total_ns = item_ns * count as f64;
833 let inner = legacy_width_for(item_ns, budget);
834 let total = legacy_width_for(total_ns, budget);
835 if inner == 1 && total > 1 && count > 1 {
836 (total.min(count), 1)
837 } else {
838 (1, inner)
839 }
840 }
841
842 #[test]
843 fn auto_matches_legacy_faer_table() {
844 let policy = BatchPolicy::default();
845 let items = [1usize, 2, 3, 8, 16, 64, 512];
846 let muladds = [
847 1.0,
848 64.0,
849 4_096.0,
850 32_768.0,
851 262_144.0,
852 1_000_000.0,
853 8_000_000.0,
854 ];
855 let budgets = [1usize, 2, 3, 4, 8, 16];
856 for &count in &items {
857 for &m in &muladds {
858 for &b in &budgets {
859 let query = BatchQuery::new(count, m).with_outer_only_when_item_serial(true);
860 let got = bd(&policy, budget(b), &query);
861 let want = legacy_faer(count, m, b);
862 assert_eq!(
863 (got.lanes, got.item_threads),
864 (want.0, want.1),
865 "count={count} item_muladds={m} budget={b}"
866 );
867 }
868 }
869 }
870 }
871
872 #[test]
873 fn faer_auto_keeps_one_item_on_the_team() {
874 // A large item: the inner route, with the width of one item.
875 let query = BatchQuery::new(4, 1_000_000.0).with_outer_only_when_item_serial(true);
876 let d = bd(&BatchPolicy::default(), budget(8), &query);
877 assert!(d.item_threads > 1, "{d:?}");
878 assert_eq!(d.lanes, 1);
879 }
880
881 #[test]
882 fn exec_carries_the_policy_and_budget_keeps_it() {
883 let sp = threadteam::Pool::builder().threads(4).build().unwrap();
884 let pool = crate::Pool::borrow(&sp);
885 let exec = crate::Exec::new(&pool, 4)
886 .unwrap()
887 .with_batch_policy(BatchPolicy::new(BatchStrategy::Outer));
888 assert_eq!(exec.batch_policy().strategy, BatchStrategy::Outer);
889 let narrowed = exec.limit(2.try_into().unwrap());
890 assert_eq!(narrowed.budget(), 2);
891 assert_eq!(narrowed.batch_policy().strategy, BatchStrategy::Outer);
892 assert_eq!(
893 crate::Exec::serial().batch_policy().strategy,
894 BatchStrategy::Auto
895 );
896 }
897
898 #[test]
899 fn forced_strategies_are_clamped_not_fallen_back() {
900 let query = BatchQuery::new(3, 1_000.0);
901 // Sequential ignores the budget and the item count.
902 assert_eq!(
903 bd(
904 &BatchPolicy::new(BatchStrategy::Sequential),
905 budget(8),
906 &query
907 ),
908 BatchDecision {
909 lanes: 1,
910 item_threads: 1
911 }
912 );
913 // Outer uses min(items, budget), each item serial.
914 assert_eq!(
915 bd(&BatchPolicy::new(BatchStrategy::Outer), budget(8), &query),
916 BatchDecision {
917 lanes: 3,
918 item_threads: 1
919 }
920 );
921 // Inner is one lane with the whole budget.
922 assert_eq!(
923 bd(&BatchPolicy::new(BatchStrategy::Inner), budget(8), &query),
924 BatchDecision {
925 lanes: 1,
926 item_threads: 8
927 }
928 );
929 // Split is clamped to the items and the budget, then shares the rest.
930 assert_eq!(
931 bd(
932 &BatchPolicy::new(BatchStrategy::Split { lanes: budget(8) }),
933 budget(8),
934 &query
935 ),
936 BatchDecision {
937 lanes: 3,
938 item_threads: 2
939 }
940 );
941 // A split wider than the budget clamps to the budget.
942 let many = BatchQuery::new(64, 1_000.0);
943 assert_eq!(
944 bd(
945 &BatchPolicy::new(BatchStrategy::Split { lanes: budget(64) }),
946 budget(4),
947 &many
948 ),
949 BatchDecision {
950 lanes: 4,
951 item_threads: 1
952 }
953 );
954 // Empty items clamp to one lane, never zero.
955 let empty = BatchQuery::new(0, 1.0);
956 assert_eq!(
957 bd(&BatchPolicy::new(BatchStrategy::Outer), budget(4), &empty),
958 BatchDecision {
959 lanes: 1,
960 item_threads: 1
961 }
962 );
963 assert_eq!(
964 bd(
965 &BatchPolicy::new(BatchStrategy::Split { lanes: budget(2) }),
966 budget(4),
967 &empty
968 ),
969 BatchDecision {
970 lanes: 1,
971 item_threads: 4
972 }
973 );
974 }
975
976 #[test]
977 fn every_decision_fits_the_budget() {
978 // A route that honours a decision runs `lanes` lanes of `item_threads`
979 // item threads (faer does), so the split must never exceed the budget the
980 // route was given: that is what keeps the call inside the host's pool.
981 let budgets = [1usize, 2, 3, 4, 8, 16];
982 let items = [0usize, 1, 2, 3, 7, 8, 16, 64];
983 let policies = [
984 BatchStrategy::Auto,
985 BatchStrategy::Sequential,
986 BatchStrategy::Outer,
987 BatchStrategy::Inner,
988 BatchStrategy::Split { lanes: budget(1) },
989 BatchStrategy::Split { lanes: budget(2) },
990 BatchStrategy::Split { lanes: budget(8) },
991 ];
992 for &b in &budgets {
993 for &n in &items {
994 for strategy in policies {
995 let d = bd(
996 &BatchPolicy::new(strategy),
997 budget(b),
998 &BatchQuery::new(n, 512.0),
999 );
1000 assert!(
1001 d.lanes >= 1 && d.lanes * d.item_threads <= b,
1002 "strategy {strategy:?}, budget {b}, items {n}: {d:?}"
1003 );
1004 }
1005 }
1006 }
1007 }
1008
1009 #[test]
1010 fn forced_strategies_use_the_original_budget() {
1011 // Auto narrows the whole batch to five lanes (the width its total work
1012 // pays for), but Outer is evaluated against the original host budget of eight.
1013 // The item is 40 000 multiply-adds at the 27 per ns of the `generic` row (about 1.5 us),
1014 // scaled to this host class's rate so that its serial time does not depend on the class.
1015 let query = BatchQuery::new(9, 40_000.0 * crate::host::muladds_per_ns() / 27.0)
1016 .with_balanced_lanes(true);
1017 let auto = bd(&BatchPolicy::default(), budget(8), &query);
1018 assert_eq!((auto.lanes, auto.item_threads), (5, 1));
1019 let outer = bd(&BatchPolicy::new(BatchStrategy::Outer), budget(8), &query);
1020 assert_eq!((outer.lanes, outer.item_threads), (8, 1));
1021 }
1022
1023 #[test]
1024 fn item_parallel_capability_keeps_item_threads_one() {
1025 // A moderate single item widens the narrow route to the team budget; a
1026 // route that forbids item parallelism keeps one thread.
1027 let query = BatchQuery::new(1, 1_000_000.0);
1028 let allowed = bd(&BatchPolicy::default(), budget(8), &query);
1029 assert!(allowed.item_threads > 1, "{allowed:?}");
1030 let forbidden = query.with_item_parallel(false);
1031 let d = bd(&BatchPolicy::default(), budget(8), &forbidden);
1032 assert_eq!((d.lanes, d.item_threads), (1, 1));
1033 }
1034
1035 #[test]
1036 fn work_gate_reduces_lanes_and_disabling_it_does_not() {
1037 let query = BatchQuery::new(8, 8_000_000.0).with_balanced_lanes(true);
1038 let disabled = WorkModel::default();
1039 let enabled = WorkModel::default().with_min_work_per_lane_ns(1_000_000.0);
1040 let d0 = bd(
1041 &BatchPolicy::new(BatchStrategy::Auto).with_model(disabled),
1042 budget(8),
1043 &query,
1044 );
1045 let d1 = bd(
1046 &BatchPolicy::new(BatchStrategy::Auto).with_model(enabled),
1047 budget(8),
1048 &query,
1049 );
1050 assert!(d0.lanes > d1.lanes, "{d0:?} vs {d1:?}");
1051 }
1052
1053 #[test]
1054 fn balanced_lanes_can_hold_a_lane_back() {
1055 // Nine items on eight lanes leave one lane with two: the packed rule
1056 // keeps the team instead of cutting an unbalanced batch.
1057 let query = BatchQuery::new(9, 1_000_000.0).with_item_parallel(true);
1058 let packed = bd(
1059 &BatchPolicy::new(BatchStrategy::Auto).with_model(WorkModel::default()),
1060 budget(8),
1061 &query.with_balanced_lanes(true),
1062 );
1063 let generic = bd(
1064 &BatchPolicy::new(BatchStrategy::Auto).with_model(WorkModel::default()),
1065 budget(8),
1066 &query.with_balanced_lanes(false),
1067 );
1068 assert_eq!(packed.lanes, 1);
1069 assert!(generic.lanes > 1, "{generic:?}");
1070 }
1071}