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}