Skip to main content

fuel_dispatch/ranker/
alternative_set.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `AlternativeSet` — bounded collection of [`Candidate`]s at one
3//! graph decision point.
4//!
5//! Phase 1.1 of the picker-work arc. The set is what
6//! [`apply_filter_chain`] mutates and what `rank_by_cost` orders.
7//! The newtype exists so we have somewhere to hang the
8//! per-decision-point invariants — retention, filter application,
9//! eventual coupling resolution — without exposing a bare `Vec`.
10//!
11//! # Retention: per-ending-device Pareto frontier + crowding cap
12//!
13//! Phase B PR-B2 of the "plan IS the graph" rebuild **retires the
14//! fixed top-N** retention (`DEFAULT_MAX_N` / `truncate_to_top_n`) and
15//! replaces it with [`AlternativeSet::retain_per_device_frontier`],
16//! per `docs/architecture/04-optimization.md` §"Bounding the frontier:
17//! Pareto per device + crowding cap" and decisions-log #8.
18//!
19//! The bound is **not** a single small N across all devices (the old
20//! "default N=3" framing strands slow devices — the scalar-top-N
21//! failure). Instead, retention:
22//!
23//! 1. buckets candidates by **ending device** ([`Candidate::device`]);
24//! 2. within each device bucket keeps the **Pareto-optimal**
25//!    candidates (non-dominated under [`CostVector::dominates`]),
26//!    dropping dominated ones — lossless because a path dominated on
27//!    the same ending device can never beat its dominator downstream;
28//! 3. **never strands the last `(device, backend)`** path — a
29//!    `(device, backend)` pair always keeps at least one candidate,
30//!    even if globally dominated (the constitution forbids stranding a
31//!    device);
32//! 4. backstops each device bucket with an **NSGA-II crowding-distance
33//!    cap** of `keep`/device ([`KEEP_PER_DEVICE`] ≈ 32 from the
34//!    frontier prototype) — when a bucket's frontier exceeds `keep`,
35//!    the most-crowded entries are dropped (boundary points get
36//!    infinity, interior points the sum of normalized neighbor gaps
37//!    per [`CostVector`] axis) until the bucket is ≤ `keep`.
38//!
39//! Invariants (asserted + tested): **≥1 path per device** survives,
40//! the total retained is **≤ `keep` × (#devices)**, the last
41//! `(device, backend)` is never stranded, and the **arm-0 winner**
42//! (the time-first candidate `rank_by_cost` puts at index 0) is always
43//! retained — so realize behavior is preserved.
44//!
45//! [`apply_filter_chain`]: super::apply_filter_chain
46//! [`CostVector::dominates`]: super::cost_vector::CostVector::dominates
47
48use fuel_ir::dispatch::{OpKind, SizeClass};
49use fuel_ir::probe::BackendId;
50use fuel_ir::{DType, DeviceLocation};
51use smallvec::SmallVec;
52
53use super::candidate::Candidate;
54use super::cost_vector::CostVector;
55
56/// Plan-time identity of the decision point an [`AlternativeSet`]
57/// belongs to — the `(op, principal dtype, size class)` triple the
58/// Judge keys its measurements on.
59///
60/// Stamped by `compile_plan` so dispatch-time selectors (Picker 2)
61/// can re-query the [`super::JudgeOracle`] per candidate without
62/// being constructed per node. The derivation matches
63/// [`super::cost::compute_static_costs`]'s Layer-2 lookup exactly:
64///
65/// - `principal_dtype` — the first entry of the node's lookup
66///   dtypes (first input's dtype by `build_lookup_dtypes`
67///   convention).
68/// - `size_class` — [`SizeClass::from_elem_count`] of the FIRST
69///   input's shape (`SizeClass(0)` for nullary ops), matching the
70///   Judge profiler's bucketing.
71///
72/// A set without a context (`AlternativeSet::context() == None`)
73/// simply disables judge re-querying in context-aware selectors —
74/// they fall back to the candidates' static costs.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct DecisionContext {
77    /// Dispatch OpKind of the decision point.
78    pub op: OpKind,
79    /// Principal dtype (first lookup dtype) — the Judge's dtype axis.
80    pub principal_dtype: DType,
81    /// Size bucket of the first input — the Judge's size axis.
82    pub size_class: SizeClass,
83}
84
85/// Crowding-distance cap **per ending device** — the real retention
86/// knob (Phase B PR-B2), per `docs/architecture/04-optimization.md`
87/// §"The tunable: `keep` per device". The whole surviving frontier is
88/// bounded by `keep × devices`, so cost scales with the bounded
89/// frontier, not an unbounded N × M.
90///
91/// `32` matches the frontier prototype (`C:/Projects/frontier-prototype`),
92/// which found `keep ≈ 32`/device reproduces the no-cap optimum on
93/// every runtime query (fastest / most-precise / least-memory /
94/// balanced) and bounds even adversarial continuous-axis cases. It is
95/// emphatically **not** the old "default N=3 across all devices": that
96/// stranded slow devices (the scalar-top-N failure the per-device
97/// frontier exists to avoid).
98pub const KEEP_PER_DEVICE: usize = 32;
99
100/// Bounded collection of [`Candidate`]s at one decision point. The
101/// optimizer ranker constructs one of these per kernel-bearing graph
102/// node, runs the filter chain, ranks survivors by composite cost,
103/// and retains the **per-ending-device Pareto frontier** (capped at
104/// [`KEEP_PER_DEVICE`]/device by crowding distance) for storage on the
105/// optimized graph — see [`Self::retain_per_device_frontier`].
106///
107/// Stored as a `SmallVec` because:
108///
109/// - Inline capacity 4 covers every decision point that exists today
110///   (CPU + CUDA + Vulkan + one cuBLAS/CUTLASS sibling once that
111///   registers).
112/// - Spilling to heap only happens when a decision point genuinely
113///   has more than 4 alternatives — rare, and the cost is amortized
114///   over a one-time enumeration anyway.
115#[derive(Clone, Debug)]
116pub struct AlternativeSet {
117    candidates: SmallVec<[Candidate; 4]>,
118    /// Decision-point identity for dispatch-time Judge re-queries.
119    /// `None` until `compile_plan` stamps it (or for hand-built
120    /// test sets) — context-aware selectors then skip the Judge leg.
121    context: Option<DecisionContext>,
122}
123
124impl AlternativeSet {
125    /// Build an empty set. Tests + the candidate enumerator both use
126    /// this. Retention (the per-device Pareto frontier + crowding cap)
127    /// is applied later via [`Self::retain_per_device_frontier`], not
128    /// at construction.
129    pub fn empty() -> Self {
130        Self {
131            candidates: SmallVec::new(),
132            context: None,
133        }
134    }
135
136    /// Build a set from a pre-collected list of candidates. The
137    /// enumerator path; retention (per-device Pareto frontier +
138    /// crowding cap) happens after the filter chain + cost rank via
139    /// [`Self::retain_per_device_frontier`], not here.
140    pub fn from_candidates(candidates: Vec<Candidate>) -> Self {
141        Self {
142            candidates: SmallVec::from_vec(candidates),
143            context: None,
144        }
145    }
146
147    /// Stamp the decision-point identity. `compile_plan` calls this
148    /// once per kernel-bearing node; the context survives filtering,
149    /// ranking, and truncation untouched (it describes the decision
150    /// point, not the candidates).
151    pub fn set_context(&mut self, ctx: DecisionContext) {
152        self.context = Some(ctx);
153    }
154
155    /// The decision-point identity, if stamped. Dispatch-time
156    /// selectors use this to key [`super::JudgeOracle`] lookups.
157    pub fn context(&self) -> Option<&DecisionContext> {
158        self.context.as_ref()
159    }
160
161    /// Append one candidate. Used by the enumerator as it walks
162    /// `(backend, device)` combinations.
163    pub fn push(&mut self, c: Candidate) {
164        self.candidates.push(c);
165    }
166
167    /// How many alternatives remain. Drives the filter chain's
168    /// hard-vs-soft decision and (in later phases) the executor's
169    /// "we have N to choose from" telemetry.
170    pub fn len(&self) -> usize {
171        self.candidates.len()
172    }
173
174    /// Is the set empty? (i.e., no admissible alternative).
175    pub fn is_empty(&self) -> bool {
176        self.candidates.is_empty()
177    }
178
179    /// Borrow the full candidate list. Read-only — the filter chain
180    /// uses this to compute the keep-mask without mutating.
181    pub fn alternatives(&self) -> &[Candidate] {
182        &self.candidates
183    }
184
185    /// Retain only the entries at `indices` (must be sorted ascending,
186    /// distinct, and in-range). The filter-chain pipeline produces a
187    /// `Vec<usize>` from each filter and calls this to apply it.
188    ///
189    /// Panics in debug builds if `indices` is mis-shaped (out-of-range
190    /// or unsorted). Release builds skip the check — the producer is
191    /// always internal to the ranker.
192    pub fn retain_indices(&mut self, indices: &[usize]) {
193        debug_assert!(
194            indices.windows(2).all(|w| w[0] < w[1]),
195            "retain_indices: input must be sorted strictly ascending; got {indices:?}",
196        );
197        debug_assert!(
198            indices.iter().all(|&i| i < self.candidates.len()),
199            "retain_indices: out-of-range index in {indices:?} (len={})",
200            self.candidates.len(),
201        );
202        // Build a new vec in-place via the standard retain pattern.
203        // SmallVec doesn't have retain_indices, so we mark-and-sweep.
204        let mut iter = indices.iter().copied().peekable();
205        let mut idx = 0;
206        self.candidates.retain(|_| {
207            let keep = matches!(iter.peek(), Some(&i) if i == idx);
208            if keep {
209                iter.next();
210            }
211            idx += 1;
212            keep
213        });
214    }
215
216    /// Retain the **per-ending-device Pareto frontier**, backstopped by
217    /// an NSGA-II crowding-distance cap of `keep`/device — Phase B
218    /// PR-B2's retention policy, replacing the retired fixed top-N
219    /// truncation.
220    ///
221    /// Production callers pass [`KEEP_PER_DEVICE`]. The set is expected
222    /// to have been ranked ([`Self::rank_by_cost`]) so the arm-0 winner
223    /// (lowest central `time`) is at index 0; this method preserves
224    /// that winner and re-ranks the survivors so the winner stays at
225    /// index 0 (realize follows arm-0, so its identity must not change).
226    ///
227    /// # Algorithm
228    ///
229    /// 1. **Bucket by ending device** ([`Candidate::device`]).
230    /// 2. **Per-device Pareto frontier:** within each bucket keep the
231    ///    candidates not [`dominated`](CostVector::dominates) by any
232    ///    other candidate in the *same* bucket; drop the dominated.
233    /// 3. **Never strand the last `(device, backend)`:** for every
234    ///    distinct `(device, backend)` present in the input, if the
235    ///    Pareto filter dropped all of its candidates, re-admit its
236    ///    time-best one. A `(device, backend)` with a single candidate
237    ///    therefore always survives, even if globally dominated.
238    /// 4. **Crowding cap:** if a device bucket still exceeds `keep`,
239    ///    drop the lowest-crowding-distance entries (NSGA-II: boundary
240    ///    points per axis get `f64::INFINITY`, interior points the sum
241    ///    of normalized neighbor gaps over the [`CostVector`] axes)
242    ///    until the bucket is `≤ keep` — but never drop a candidate
243    ///    that is the *sole* survivor of its `(device, backend)`, and
244    ///    never drop the global arm-0 winner.
245    ///
246    /// # Invariants (asserted)
247    ///
248    /// - **≥1 path per device** survives (each device bucket is
249    ///   non-empty after retention).
250    /// - **Total ≤ `keep × devices`.**
251    /// - The arm-0 winner is retained.
252    ///
253    /// A `keep` of 0 is treated as 1 (a device bucket can never be
254    /// emptied — that would strand the device).
255    pub fn retain_per_device_frontier(&mut self, keep: usize) {
256        let keep = keep.max(1);
257        let n = self.candidates.len();
258        if n == 0 {
259            return;
260        }
261
262        // Index 0 is the arm-0 winner (caller ranked); it must survive.
263        let winner_device = self.candidates[0].device;
264
265        // Precompute each candidate's cost vector once.
266        let vectors: Vec<CostVector> = self
267            .candidates
268            .iter()
269            .map(CostVector::from_candidate)
270            .collect();
271
272        // (1) Bucket candidate indices by ending device. Use a Vec of
273        //     (device, indices) to keep deterministic ordering (devices
274        //     appear in first-seen order, which keeps the winner's
275        //     device first since index 0 is the winner).
276        let mut device_buckets: Vec<(DeviceLocation, Vec<usize>)> = Vec::new();
277        for i in 0..n {
278            let dev = self.candidates[i].device;
279            if let Some(entry) = device_buckets.iter_mut().find(|(d, _)| *d == dev) {
280                entry.1.push(i);
281            } else {
282                device_buckets.push((dev, vec![i]));
283            }
284        }
285
286        // The final keep-set of original indices.
287        let mut keep_set: Vec<usize> = Vec::with_capacity(n);
288
289        for (_dev, bucket) in &device_buckets {
290            // (2) Per-device Pareto frontier: keep indices not dominated
291            //     by any other index in the SAME bucket.
292            let mut frontier: Vec<usize> = bucket
293                .iter()
294                .copied()
295                .filter(|&i| {
296                    !bucket
297                        .iter()
298                        .any(|&j| j != i && vectors[j].dominates(&vectors[i]))
299                })
300                .collect();
301
302            // (3) Never strand the last (device, backend): for each
303            //     distinct backend in this bucket, ensure ≥1 of its
304            //     candidates is on the frontier; otherwise re-admit its
305            //     time-best (lowest total_order_key) candidate.
306            let mut backends_in_bucket: Vec<BackendId> = Vec::new();
307            for &i in bucket {
308                let b = self.candidates[i].backend;
309                if !backends_in_bucket.contains(&b) {
310                    backends_in_bucket.push(b);
311                }
312            }
313            for &b in &backends_in_bucket {
314                let on_frontier = frontier.iter().any(|&i| self.candidates[i].backend == b);
315                if !on_frontier
316                    && let Some(&best) = bucket
317                        .iter()
318                        .filter(|&&i| self.candidates[i].backend == b)
319                        .min_by_key(|&&i| vectors[i].total_order_key())
320                {
321                    frontier.push(best);
322                }
323            }
324
325            // The global winner must never be dropped from its bucket.
326            if !self.candidates.is_empty() && _dev == &winner_device && !frontier.contains(&0) {
327                frontier.push(0);
328            }
329
330            // (4) Crowding cap: reduce this bucket's frontier to `keep`
331            //     by NSGA-II crowding distance, protecting the sole
332            //     survivor of each (device, backend) and the arm-0
333            //     winner.
334            if frontier.len() > keep {
335                crowding_cap(
336                    &mut frontier,
337                    &vectors,
338                    &self.candidates,
339                    keep,
340                    winner_device,
341                );
342            }
343
344            keep_set.extend(frontier);
345        }
346
347        // Sort the keep-set ascending so `retain_indices`' contract
348        // (sorted, distinct) holds; the subsequent re-rank restores the
349        // winner-first order.
350        keep_set.sort_unstable();
351        keep_set.dedup();
352
353        // Invariants — assert before we mutate (the producer is
354        // internal, so a violation is a bug, not a user error).
355        debug_assert!(
356            keep_set.contains(&0),
357            "retain_per_device_frontier: arm-0 winner must be retained",
358        );
359        debug_assert!(
360            keep_set.len() <= keep.saturating_mul(device_buckets.len()),
361            "retain_per_device_frontier: total {} exceeds keep×devices = {}×{}",
362            keep_set.len(),
363            keep,
364            device_buckets.len(),
365        );
366
367        self.retain_indices(&keep_set);
368
369        // Re-rank so the arm-0 winner is back at index 0 (retention may
370        // have reordered nothing, but `retain_indices` preserves the
371        // pre-existing order, which for a ranked set already had the
372        // winner first; re-rank is defensive + cheap).
373        self.rank_by_cost();
374    }
375
376    /// Rank candidates by the per-path **cost vector**
377    /// ([`super::cost_vector::CostVector`]) — Phase B PR-B1.
378    ///
379    /// The vector's axes are one central `time` metric, per-tier
380    /// memory, and discrete precision/accuracy (see the cost_vector
381    /// module docs). Ranking uses
382    /// [`CostVector::total_order_key`], which keeps the **winner
383    /// time-first**: the lowest-central-`time` candidate sorts to
384    /// index 0 (arm-0), exactly preserving the old `composite_ns`
385    /// winner, with ties broken **precision → accuracy → memory**
386    /// (the constitution's order).
387    ///
388    /// Why time-first for the winner while [`CostVector::dominates`]
389    /// exists: realize follows arm-0, so the *winner* must stay the
390    /// lowest-time candidate to preserve realize behavior. Pareto
391    /// dominance is the relation the *frontier retention*
392    /// ([`Self::retain_per_device_frontier`], PR-B2) uses — that
393    /// retired the top-N truncation for a per-device Pareto frontier +
394    /// crowding cap.
395    ///
396    /// Stable sort — equal-key candidates keep their relative order,
397    /// which matters when registration order is the residual
398    /// tie-breaker (and, post-Stage-2, when decision-device
399    /// candidates are enumerated ahead of off-device ones).
400    ///
401    /// [`CostVector::total_order_key`]: super::cost_vector::CostVector::total_order_key
402    /// [`CostVector::dominates`]: super::cost_vector::CostVector::dominates
403    pub fn rank_by_cost(&mut self) {
404        self.candidates
405            .sort_by_key(|c| CostVector::from_candidate(c).total_order_key());
406    }
407
408    /// Sort candidates ascending by their composite static cost
409    /// (Layer-1 score; see [`super::cost::composite_ns`]) plus the
410    /// Stage-2 inbound-transfer term
411    /// ([`Candidate::inbound_transfer_ns`]).
412    ///
413    /// As of Phase B PR-B1 this delegates to [`Self::rank_by_cost`]:
414    /// the cost VECTOR is now the ranking primitive, and its
415    /// `total_order_key` is time-first, so for a single-device,
416    /// single-precision candidate set (the common case + the entire
417    /// CPU `--lib` suite) the winner is unchanged — lowest central
418    /// `time` is exactly the old `composite_ns` winner. Retained as a
419    /// named alias so existing callers (`compile_plan`,
420    /// `compile_run_view`) and tests don't churn.
421    ///
422    /// Phase 1.4 of the picker-work arc. Composite cost is
423    /// `max(compute_ns, memory_ns) + overhead_ns`, treating
424    /// compute and memory as parallel (roofline model). Layer-2
425    /// Judge data refines `static_cost` before this call
426    /// (`compute_static_costs`); the transfer term is added
427    /// serially — the bytes must land before the kernel can run.
428    pub fn rank_by_composite_cost(&mut self) {
429        self.rank_by_cost();
430    }
431
432    /// Set the `static_cost` field of the candidate at `index`.
433    /// Used by [`super::cost::compute_static_costs`] to populate
434    /// the field after enumeration. Panics in debug builds if
435    /// `index >= len`.
436    pub fn set_static_cost(&mut self, index: usize, cost: crate::fused::CostEstimate) {
437        debug_assert!(
438            index < self.candidates.len(),
439            "set_static_cost: index {index} out of range (len={})",
440            self.candidates.len(),
441        );
442        self.candidates[index].static_cost = cost;
443    }
444
445    /// Set the Stage-2 inbound-transfer term of the candidate at
446    /// `index`. Used by
447    /// [`super::cost::apply_inbound_transfer_costs`]. Panics in
448    /// debug builds if `index >= len`.
449    pub fn set_inbound_transfer_ns(&mut self, index: usize, ns: u64) {
450        debug_assert!(
451            index < self.candidates.len(),
452            "set_inbound_transfer_ns: index {index} out of range (len={})",
453            self.candidates.len(),
454        );
455        self.candidates[index].inbound_transfer_ns = ns;
456    }
457
458    /// The current top candidate (first entry). After the full
459    /// pipeline — filter → rank → retain — this is the runtime
460    /// selector's (Picker 2) default pick. `None` if the set is
461    /// empty.
462    pub fn winner(&self) -> Option<&Candidate> {
463        self.candidates.first()
464    }
465}
466
467/// Reduce `frontier` (original-index references into `candidates`) to
468/// `keep` entries by **NSGA-II crowding distance** over the
469/// [`CostVector`] axes, dropping the most-crowded first.
470///
471/// Crowding distance per axis: candidates are sorted on that axis, the
472/// two boundary points get `f64::INFINITY` (so per-axis extremes — the
473/// spread — always survive), and each interior point accrues the
474/// normalized gap between its two neighbors. A candidate's total
475/// crowding distance is the sum over all axes. Lowest distance =
476/// most-crowded = dropped first.
477///
478/// Two entries are protected from being dropped: (a) the global arm-0
479/// winner (index 0, when this is the winner's device bucket), and
480/// (b) the sole survivor of any `(device, backend)` in this bucket —
481/// dropping it would strand that backend (never-strand-last invariant).
482fn crowding_cap(
483    frontier: &mut Vec<usize>,
484    vectors: &[CostVector],
485    candidates: &[Candidate],
486    keep: usize,
487    winner_device: DeviceLocation,
488) {
489    // The crowding distance is computed over the CURRENT frontier
490    // membership and recomputed as we drop, so the surviving spread is
491    // re-measured each time (standard NSGA-II truncation).
492    while frontier.len() > keep {
493        let dist = crowding_distances(frontier, vectors);
494
495        // Find the protected set: arm-0 winner (in its own device
496        // bucket) + any backend with exactly one representative left.
497        let winner_present = frontier.contains(&0);
498        let mut backend_counts: std::collections::HashMap<BackendId, usize> =
499            std::collections::HashMap::new();
500        for &i in frontier.iter() {
501            *backend_counts.entry(candidates[i].backend).or_insert(0) += 1;
502        }
503
504        // Drop the droppable entry with the lowest crowding distance.
505        let mut victim: Option<(usize, f64)> = None; // (position in frontier, distance)
506        for (pos, &i) in frontier.iter().enumerate() {
507            let is_winner = i == 0 && winner_present && candidates[i].device == winner_device;
508            let is_sole_backend = backend_counts
509                .get(&candidates[i].backend)
510                .copied()
511                .unwrap_or(0)
512                <= 1;
513            if is_winner || is_sole_backend {
514                continue;
515            }
516            let d = dist[pos];
517            match victim {
518                Some((_, best)) if best <= d => {}
519                _ => victim = Some((pos, d)),
520            }
521        }
522
523        match victim {
524            Some((pos, _)) => {
525                frontier.remove(pos);
526            }
527            // Everything remaining is protected (all distinct backends,
528            // and the winner) — can't cap below the protected floor
529            // without stranding a backend. Stop.
530            None => break,
531        }
532    }
533}
534
535/// Per-entry NSGA-II crowding distance over the [`CostVector`] axes,
536/// indexed parallel to `frontier`. Boundary points per axis get
537/// `f64::INFINITY`; interior points accrue normalized neighbor gaps.
538///
539/// Axes (all treated as numeric for spread purposes): central `time`,
540/// host-RAM bytes, device-VRAM bytes, `precision` digits, `accuracy`
541/// rank. Orientation doesn't matter for crowding — only the spread
542/// along each axis does.
543fn crowding_distances(frontier: &[usize], vectors: &[CostVector]) -> Vec<f64> {
544    let m = frontier.len();
545    let mut dist = vec![0.0f64; m];
546    if m <= 2 {
547        // Every point is a boundary point — all maximally spread.
548        return vec![f64::INFINITY; m];
549    }
550
551    // Each axis as an f64 extractor.
552    let axes: [fn(&CostVector) -> f64; 5] = [
553        |v| v.time as f64,
554        |v| v.memory.host_ram_bytes as f64,
555        |v| v.memory.device_vram_bytes as f64,
556        |v| v.precision as f64,
557        |v| v.accuracy.rank() as f64,
558    ];
559
560    for axis in axes {
561        // Sort frontier positions by this axis value.
562        let mut order: Vec<usize> = (0..m).collect();
563        order.sort_by(|&a, &b| {
564            let va = axis(&vectors[frontier[a]]);
565            let vb = axis(&vectors[frontier[b]]);
566            va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
567        });
568
569        let min = axis(&vectors[frontier[order[0]]]);
570        let max = axis(&vectors[frontier[order[m - 1]]]);
571        let range = max - min;
572
573        // Boundary points get infinity (preserve per-axis extremes).
574        dist[order[0]] = f64::INFINITY;
575        dist[order[m - 1]] = f64::INFINITY;
576
577        if range <= 0.0 {
578            // Degenerate axis (all equal) contributes nothing to the
579            // interior; boundaries already pinned to infinity.
580            continue;
581        }
582
583        for k in 1..(m - 1) {
584            let prev = axis(&vectors[frontier[order[k - 1]]]);
585            let next = axis(&vectors[frontier[order[k + 1]]]);
586            dist[order[k]] += (next - prev) / range;
587        }
588    }
589
590    dist
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::fused::{CostEstimate, PrecisionGuarantee};
597    use crate::kernel::{KernelCaps, OpParams};
598    use fuel_ir::probe::BackendId;
599    use fuel_ir::{DeviceLocation, Layout, Result};
600    use fuel_memory::Storage;
601    use std::sync::{Arc, RwLock};
602
603    fn noop(
604        _i: &[Arc<RwLock<Storage>>],
605        _o: &mut [Arc<RwLock<Storage>>],
606        _l: &[Layout],
607        _p: &OpParams,
608    ) -> Result<()> {
609        Ok(())
610    }
611
612    fn dummy_candidate(flops: u64) -> Candidate {
613        Candidate {
614            kernel: noop,
615            caps: KernelCaps::empty(),
616            backend: BackendId::Cpu,
617            device: DeviceLocation::Cpu,
618            precision: PrecisionGuarantee::PRIMITIVE_DETERMINISTIC_CPU,
619            static_cost: CostEstimate {
620                flops,
621                bytes_moved: 0,
622                kernel_overhead_ns: 0,
623            },
624            inbound_transfer_ns: 0,
625            op_params: OpParams::None,
626            coupling: Vec::new(),
627            kernel_source: "",
628            kernel_revision_hash: 0,
629        }
630    }
631
632    /// A candidate with explicit device + backend + cost knobs, for the
633    /// PR-B2 per-device-frontier tests. `time` is set via `flops`
634    /// (composite_ns ≈ flops here since bytes/overhead are 0).
635    fn cand(
636        device: DeviceLocation,
637        backend: BackendId,
638        flops: u64,
639        bytes: u64,
640        precision: PrecisionGuarantee,
641    ) -> Candidate {
642        Candidate {
643            backend,
644            device,
645            precision,
646            static_cost: CostEstimate {
647                flops,
648                bytes_moved: bytes,
649                kernel_overhead_ns: 0,
650            },
651            ..dummy_candidate(0)
652        }
653    }
654
655    fn cuda(id: usize) -> DeviceLocation {
656        DeviceLocation::Cuda { gpu_id: id }
657    }
658
659    /// Phase B PR-B1 behavior preservation: `rank_by_cost` on a
660    /// single-precision, multi-candidate set yields the SAME winner
661    /// (and full order) as ranking by the old `composite_ns +
662    /// inbound_transfer` scalar. This is the safety contract — the
663    /// CPU `--lib` suite is exactly this case.
664    #[test]
665    fn rank_by_cost_preserves_single_precision_winner() {
666        use super::super::cost::{composite_ns, default_backend_rates};
667
668        let mk = |flops: u64, bytes: u64, overhead: u32, inbound: u64| Candidate {
669            inbound_transfer_ns: inbound,
670            static_cost: CostEstimate {
671                flops,
672                bytes_moved: bytes,
673                kernel_overhead_ns: overhead,
674            },
675            ..dummy_candidate(0)
676        };
677        let cands = vec![
678            mk(300, 0, 0, 0),
679            mk(100, 0, 0, 50),   // 150 total
680            mk(200, 800, 0, 0),  // max(200,200)=200
681            mk(0, 4000, 10, 90), // 1000+10+90=1100
682            mk(120, 0, 0, 0),
683        ];
684
685        // Expected order from the scalar key — mirrors
686        // `CostVector::from_candidate`, which resolves the per-backend
687        // throughput prior from the candidate's backend. (All `mk`
688        // candidates are `dummy_candidate` = CPU, so this is the neutral
689        // 1.0/4.0 prior and the expected order is unchanged.)
690        let mut expected = cands.clone();
691        expected.sort_by_key(|c| {
692            let (cr, bw) = default_backend_rates(c.backend);
693            composite_ns(&c.static_cost, cr, bw).saturating_add(c.inbound_transfer_ns)
694        });
695        let expected_flops: Vec<u64> = expected.iter().map(|c| c.static_cost.flops).collect();
696
697        // Order from the NEW cost-vector rank.
698        let mut set = AlternativeSet::from_candidates(cands);
699        set.rank_by_cost();
700        let got_flops: Vec<u64> = set
701            .alternatives()
702            .iter()
703            .map(|c| c.static_cost.flops)
704            .collect();
705
706        assert_eq!(
707            got_flops, expected_flops,
708            "single-precision cost-vector rank matches the old composite scalar order",
709        );
710    }
711
712    /// Rank composes the inbound-transfer term serially with the
713    /// composite kernel cost.
714    #[test]
715    fn rank_includes_inbound_transfer_term() {
716        let mut s = AlternativeSet::from_candidates(vec![
717            Candidate {
718                inbound_transfer_ns: 5_000,
719                ..dummy_candidate(100)
720            },
721            dummy_candidate(200),
722        ]);
723        s.rank_by_composite_cost();
724        assert_eq!(
725            s.winner().unwrap().static_cost.flops,
726            200,
727            "200 ns total beats 100 ns kernel + 5 µs transfer",
728        );
729    }
730
731    #[test]
732    fn empty_has_no_winner() {
733        let s = AlternativeSet::empty();
734        assert!(s.is_empty());
735        assert_eq!(s.len(), 0);
736        assert!(s.winner().is_none());
737    }
738
739    #[test]
740    fn push_grows_and_first_is_winner() {
741        let mut s = AlternativeSet::empty();
742        s.push(dummy_candidate(10));
743        s.push(dummy_candidate(20));
744        assert_eq!(s.len(), 2);
745        assert_eq!(s.winner().unwrap().static_cost.flops, 10);
746    }
747
748    #[test]
749    fn from_candidates_seeds_and_preserves_order() {
750        let s = AlternativeSet::from_candidates(vec![
751            dummy_candidate(1),
752            dummy_candidate(2),
753            dummy_candidate(3),
754        ]);
755        assert_eq!(s.len(), 3);
756        let flops: Vec<u64> = s
757            .alternatives()
758            .iter()
759            .map(|c| c.static_cost.flops)
760            .collect();
761        assert_eq!(flops, vec![1, 2, 3]);
762    }
763
764    #[test]
765    fn retain_indices_keeps_selected_entries() {
766        let mut s = AlternativeSet::from_candidates((0..5).map(dummy_candidate).collect());
767        s.retain_indices(&[0, 2, 4]);
768        let flops: Vec<u64> = s
769            .alternatives()
770            .iter()
771            .map(|c| c.static_cost.flops)
772            .collect();
773        assert_eq!(flops, vec![0, 2, 4]);
774    }
775
776    #[test]
777    fn retain_indices_empty_clears_set() {
778        let mut s = AlternativeSet::from_candidates(vec![dummy_candidate(1), dummy_candidate(2)]);
779        s.retain_indices(&[]);
780        assert!(s.is_empty());
781    }
782
783    #[test]
784    fn retain_indices_keep_all_is_identity() {
785        let mut s = AlternativeSet::from_candidates(vec![
786            dummy_candidate(1),
787            dummy_candidate(2),
788            dummy_candidate(3),
789        ]);
790        s.retain_indices(&[0, 1, 2]);
791        assert_eq!(s.len(), 3);
792    }
793
794    #[test]
795    #[should_panic(expected = "sorted strictly ascending")]
796    fn retain_indices_unsorted_panics_in_debug() {
797        let mut s = AlternativeSet::from_candidates(vec![dummy_candidate(1), dummy_candidate(2)]);
798        s.retain_indices(&[1, 0]);
799    }
800
801    #[test]
802    #[should_panic(expected = "out-of-range")]
803    fn retain_indices_out_of_range_panics_in_debug() {
804        let mut s = AlternativeSet::from_candidates(vec![dummy_candidate(1)]);
805        s.retain_indices(&[5]);
806    }
807
808    /// Context defaults to None, round-trips through `set_context`,
809    /// and survives retain (it describes the decision point, not the
810    /// candidates).
811    #[test]
812    fn context_round_trips_and_survives_mutation() {
813        use fuel_ir::DType;
814        use fuel_ir::dispatch::{OpKind, SizeClass};
815
816        let mut s = AlternativeSet::from_candidates(vec![
817            dummy_candidate(1),
818            dummy_candidate(2),
819            dummy_candidate(3),
820        ]);
821        assert!(s.context().is_none(), "fresh sets carry no context");
822
823        let ctx = DecisionContext {
824            op: OpKind::MatMul,
825            principal_dtype: DType::F32,
826            size_class: SizeClass(16),
827        };
828        s.set_context(ctx);
829        assert_eq!(s.context(), Some(&ctx));
830
831        s.retain_indices(&[0, 2]);
832        assert_eq!(s.context(), Some(&ctx), "context survives retain",);
833    }
834
835    // ===== Phase B PR-B2: per-ending-device Pareto frontier +
836    //        crowding cap (retiring the fixed top-N). =====
837
838    /// (a) A multi-device set retains the **per-device Pareto
839    /// frontier**, NOT the global top-3: a slow-but-only-CUDA candidate
840    /// survives even when several CPU candidates are all faster. The
841    /// old `DEFAULT_MAX_N = 3` truncation on a global time sort would
842    /// have stranded the CUDA device entirely.
843    #[test]
844    fn retains_per_device_frontier_not_global_top_n() {
845        // 3 fast CPU candidates + 1 slow CUDA candidate. A global
846        // top-3 keeps only the 3 CPU ones (CUDA stranded). The
847        // per-device frontier must keep ≥1 CPU and the 1 CUDA.
848        let cands = vec![
849            cand(
850                DeviceLocation::Cpu,
851                BackendId::Cpu,
852                100,
853                0,
854                PrecisionGuarantee::REFERENCE,
855            ),
856            cand(
857                DeviceLocation::Cpu,
858                BackendId::Cpu,
859                200,
860                0,
861                PrecisionGuarantee::REFERENCE,
862            ),
863            cand(
864                DeviceLocation::Cpu,
865                BackendId::Cpu,
866                300,
867                0,
868                PrecisionGuarantee::REFERENCE,
869            ),
870            cand(
871                cuda(0),
872                BackendId::Cuda,
873                10_000,
874                0,
875                PrecisionGuarantee::REFERENCE,
876            ),
877        ];
878        let mut set = AlternativeSet::from_candidates(cands);
879        set.rank_by_cost();
880        set.retain_per_device_frontier(KEEP_PER_DEVICE);
881
882        let has_cuda = set
883            .alternatives()
884            .iter()
885            .any(|c| matches!(c.device, DeviceLocation::Cuda { .. }));
886        let has_cpu = set
887            .alternatives()
888            .iter()
889            .any(|c| c.device == DeviceLocation::Cpu);
890        assert!(
891            has_cuda,
892            "slow CUDA path must NOT be stranded by a global top-N"
893        );
894        assert!(has_cpu, "CPU path survives");
895
896        // Arm-0 winner is the fastest overall (CPU flops=100).
897        assert_eq!(set.winner().unwrap().static_cost.flops, 100);
898    }
899
900    /// (b) Within a device, a dominated candidate is dropped. Two CPU
901    /// candidates where one is strictly worse on every axis → only the
902    /// dominator survives on that device.
903    #[test]
904    fn drops_within_device_dominated_candidate() {
905        // dominator: fast + reference precision.
906        // dominated: slower + lower precision (UNAUDITED) — strictly
907        //            worse on time AND precision AND accuracy.
908        let cands = vec![
909            cand(
910                DeviceLocation::Cpu,
911                BackendId::Cpu,
912                100,
913                0,
914                PrecisionGuarantee::REFERENCE,
915            ),
916            cand(
917                DeviceLocation::Cpu,
918                BackendId::Cpu,
919                500,
920                0,
921                PrecisionGuarantee::UNAUDITED,
922            ),
923        ];
924        let mut set = AlternativeSet::from_candidates(cands);
925        set.rank_by_cost();
926        set.retain_per_device_frontier(KEEP_PER_DEVICE);
927
928        // Both are the SAME (device, backend), so never-strand doesn't
929        // force the dominated one back — the dominator alone survives.
930        assert_eq!(set.len(), 1, "dominated same-backend candidate dropped");
931        assert_eq!(set.winner().unwrap().static_cost.flops, 100);
932    }
933
934    /// (c) A device frontier larger than `keep` is crowding-capped to
935    /// exactly `keep`, keeping the spread (the per-axis boundary points
936    /// — fastest + slowest along the time axis — survive).
937    #[test]
938    fn crowding_caps_oversized_frontier_keeping_spread() {
939        // Build a single-device, single-backend Pareto front along the
940        // time↔precision tradeoff: faster ⇒ lower precision, so every
941        // candidate is mutually non-dominated (a genuine frontier).
942        let keep = 4usize;
943        let mut cands = Vec::new();
944        let n = 10u64;
945        for k in 0..n {
946            // time INcreases with k AND precision INcreases with k (a
947            // genuine speed↔precision tradeoff) → every pair is
948            // mutually non-dominated, so the Pareto frontier == all 10.
949            let rel = 10f64.powi(-(2 + k as i32)); // higher k → tighter
950            let p = PrecisionGuarantee {
951                bit_stable_on_same_hardware: true,
952                max_ulp: None,
953                max_relative: Some(rel),
954                max_absolute: None,
955                notes: "frontier",
956            };
957            cands.push(cand(
958                DeviceLocation::Cpu,
959                BackendId::Cpu,
960                100 + k * 100,
961                0,
962                p,
963            ));
964        }
965        let fastest_time_flops = 100; // k=0
966        let slowest_time_flops = 100 + (n - 1) * 100; // k=9 → 1000
967
968        let mut set = AlternativeSet::from_candidates(cands);
969        set.rank_by_cost();
970        // Sanity: all mutually non-dominated → frontier == all 10.
971        set.retain_per_device_frontier(keep);
972
973        assert_eq!(set.len(), keep, "capped to exactly keep");
974
975        // Spread preserved: the time-axis extremes survive.
976        let times: Vec<u64> = set
977            .alternatives()
978            .iter()
979            .map(|c| c.static_cost.flops)
980            .collect();
981        assert!(
982            times.contains(&fastest_time_flops),
983            "fastest (time-axis min) boundary survives crowding; got {times:?}",
984        );
985        assert!(
986            times.contains(&slowest_time_flops),
987            "slowest (time-axis max) boundary survives crowding; got {times:?}",
988        );
989    }
990
991    /// (d) ≥1 per device survives + total ≤ keep × devices.
992    #[test]
993    fn at_least_one_per_device_and_total_bounded() {
994        let keep = 3usize;
995        // 3 devices, each with several candidates.
996        let mut cands = Vec::new();
997        for d in 0..3usize {
998            let dev = if d == 0 { DeviceLocation::Cpu } else { cuda(d) };
999            let backend = if d == 0 {
1000                BackendId::Cpu
1001            } else {
1002                BackendId::Cuda
1003            };
1004            for k in 0..6u64 {
1005                // mutually non-dominated within a device (time↔precision)
1006                let rel = 10f64.powi(-(2 + (6 - k) as i32));
1007                let p = PrecisionGuarantee {
1008                    bit_stable_on_same_hardware: true,
1009                    max_ulp: None,
1010                    max_relative: Some(rel),
1011                    max_absolute: None,
1012                    notes: "x",
1013                };
1014                cands.push(cand(dev, backend, 100 + k * 50 + d as u64, 0, p));
1015            }
1016        }
1017        let n_devices = 3;
1018        let mut set = AlternativeSet::from_candidates(cands);
1019        set.rank_by_cost();
1020        set.retain_per_device_frontier(keep);
1021
1022        // ≥1 per device.
1023        for d in 0..3usize {
1024            let dev = if d == 0 { DeviceLocation::Cpu } else { cuda(d) };
1025            let count = set
1026                .alternatives()
1027                .iter()
1028                .filter(|c| c.device == dev)
1029                .count();
1030            assert!(count >= 1, "device {dev:?} must keep ≥1 path");
1031        }
1032        // total ≤ keep × devices.
1033        assert!(
1034            set.len() <= keep * n_devices,
1035            "total {} ≤ keep×devices = {}",
1036            set.len(),
1037            keep * n_devices,
1038        );
1039    }
1040
1041    /// Never-strand-last `(device, backend)`: a globally-dominated
1042    /// candidate that is the SOLE representative of its (device,
1043    /// backend) is retained anyway. Here a Vulkan candidate dominated
1044    /// on every axis by a CPU one survives because it is Vulkan's only
1045    /// path (and Vulkan is its own device bucket — the per-device
1046    /// frontier already keeps it; this also exercises a same-device
1047    /// distinct-backend case).
1048    #[test]
1049    fn never_strands_last_device_backend() {
1050        // CPU dominator (fast, reference) vs a Vulkan candidate that is
1051        // slower + unaudited. Different device ⇒ different bucket ⇒
1052        // Vulkan's bucket keeps it by the per-device frontier.
1053        let cands = vec![
1054            cand(
1055                DeviceLocation::Cpu,
1056                BackendId::Cpu,
1057                100,
1058                0,
1059                PrecisionGuarantee::REFERENCE,
1060            ),
1061            cand(
1062                DeviceLocation::Vulkan { gpu_id: 0 },
1063                BackendId::Vulkan,
1064                9_999,
1065                0,
1066                PrecisionGuarantee::UNAUDITED,
1067            ),
1068        ];
1069        let mut set = AlternativeSet::from_candidates(cands);
1070        set.rank_by_cost();
1071        set.retain_per_device_frontier(KEEP_PER_DEVICE);
1072
1073        let has_vulkan = set
1074            .alternatives()
1075            .iter()
1076            .any(|c| matches!(c.device, DeviceLocation::Vulkan { .. }));
1077        assert!(
1078            has_vulkan,
1079            "last Vulkan (device, backend) must never be stranded"
1080        );
1081    }
1082
1083    /// A fully-dominated device bucket whose sole candidate is globally
1084    /// dominated on every axis is still retained — the per-device
1085    /// frontier never empties a device, and the `(device, backend)`
1086    /// re-admission backstops it. Here the Metal candidate is worse on
1087    /// every axis than the CPU one but is its (device, backend)'s only
1088    /// path.
1089    #[test]
1090    fn never_strands_globally_dominated_sole_path() {
1091        let cands = vec![
1092            cand(
1093                DeviceLocation::Cpu,
1094                BackendId::Cpu,
1095                100,
1096                0,
1097                PrecisionGuarantee::REFERENCE,
1098            ),
1099            cand(
1100                DeviceLocation::Metal { gpu_id: 0 },
1101                BackendId::Metal,
1102                9_999,
1103                999_999,
1104                PrecisionGuarantee::UNAUDITED,
1105            ),
1106        ];
1107        let mut set = AlternativeSet::from_candidates(cands);
1108        set.rank_by_cost();
1109        set.retain_per_device_frontier(KEEP_PER_DEVICE);
1110
1111        let backends: Vec<BackendId> = set.alternatives().iter().map(|c| c.backend).collect();
1112        assert!(backends.contains(&BackendId::Cpu), "CPU dominator kept");
1113        assert!(
1114            backends.contains(&BackendId::Metal),
1115            "dominated-but-sole Metal (device, backend) never stranded; got {backends:?}",
1116        );
1117    }
1118
1119    /// (e) The arm-0 winner is unchanged vs a plain B1 rank: retention
1120    /// keeps the same index-0 candidate the rank produced.
1121    #[test]
1122    fn arm0_winner_unchanged_by_retention() {
1123        let cands = vec![
1124            cand(
1125                DeviceLocation::Cpu,
1126                BackendId::Cpu,
1127                300,
1128                0,
1129                PrecisionGuarantee::REFERENCE,
1130            ),
1131            cand(
1132                cuda(0),
1133                BackendId::Cuda,
1134                100,
1135                0,
1136                PrecisionGuarantee::REFERENCE,
1137            ),
1138            cand(
1139                DeviceLocation::Cpu,
1140                BackendId::Cpu,
1141                200,
1142                0,
1143                PrecisionGuarantee::REFERENCE,
1144            ),
1145        ];
1146        // B1 winner: lowest time (flops=100 on CUDA).
1147        let mut b1 = AlternativeSet::from_candidates(cands.clone());
1148        b1.rank_by_cost();
1149        let b1_winner = (b1.winner().unwrap().backend, b1.winner().unwrap().device);
1150
1151        let mut set = AlternativeSet::from_candidates(cands);
1152        set.rank_by_cost();
1153        set.retain_per_device_frontier(KEEP_PER_DEVICE);
1154        let b2_winner = (set.winner().unwrap().backend, set.winner().unwrap().device);
1155
1156        assert_eq!(
1157            b1_winner, b2_winner,
1158            "arm-0 winner unchanged by B2 retention"
1159        );
1160        assert_eq!(set.winner().unwrap().static_cost.flops, 100);
1161    }
1162
1163    /// A single-device, single-candidate set is unchanged (the common
1164    /// CPU `--lib` case): retention keeps the one candidate.
1165    #[test]
1166    fn single_candidate_passthrough() {
1167        let mut set = AlternativeSet::from_candidates(vec![dummy_candidate(42)]);
1168        set.rank_by_cost();
1169        set.retain_per_device_frontier(KEEP_PER_DEVICE);
1170        assert_eq!(set.len(), 1);
1171        assert_eq!(set.winner().unwrap().static_cost.flops, 42);
1172    }
1173}