Skip to main content

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}