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