Skip to main content

rlx_runtime/
cost.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Cross-backend cost interface.
6//!
7//! Each backend implements `BackendCostModel` to expose its execution
8//! characteristics (kernel throughput, dispatch overhead, memory bw).
9//! The runtime can then estimate the cost of running a graph on each
10//! available backend and pick the fastest.
11//!
12//! This is what enables "auto device" — given a graph, pick CPU or
13//! Metal automatically based on which is faster for THIS workload on
14//! THIS hardware.
15
16use crate::Device;
17use rlx_ir::{Graph, Node, Op};
18
19/// Where a cost model's numbers actually came from.
20///
21/// The distinction is load-bearing and rlx used to erase it. `RocmCostModel`
22/// falls back to `sgemm_gflops: 10_000.0` when ROCm is absent; `MetalHwModel`
23/// falls back to `AppleGpuFamily::Unknown => (300e9, 180e9, 60e9)`. Both then
24/// feed [`fastest_device_for`], which ranks devices against each other — so on an
25/// unrecognized GPU rlx would confidently place a graph using invented
26/// throughput, and say nothing.
27///
28/// CAKE B.5 states the rule this implements: *"Timing-model coverage is
29/// separately evidence-gated: B200 is the measured baseline, H100 is calibrated
30/// independently, and other targets report a coverage limitation instead of
31/// inheriting estimates."* Reporting a coverage limitation is strictly more
32/// useful than a confident guess, because a caller can act on it.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub enum CostCalibration {
35    /// Measured on *this* machine (a calibration cache exists, or the model
36    /// probed the device). Comparisons across such models are meaningful.
37    Measured,
38    /// Compile-time constants chosen for this specific detected architecture.
39    /// Reasonable, but not measured here — treat cross-device comparisons as
40    /// indicative rather than authoritative.
41    ArchDefault,
42    /// The architecture was not recognized, so the numbers are a generic guess.
43    /// **Not** a basis for ranking one device against another.
44    Uncalibrated,
45}
46
47impl CostCalibration {
48    /// True when this model's numbers may be compared against another device's.
49    pub fn is_rankable(&self) -> bool {
50        matches!(self, Self::Measured | Self::ArchDefault)
51    }
52
53    pub fn as_str(&self) -> &'static str {
54        match self {
55            Self::Measured => "measured",
56            Self::ArchDefault => "arch-default",
57            Self::Uncalibrated => "uncalibrated",
58        }
59    }
60}
61
62/// Hardware-aware cost characteristics for a backend on the current machine.
63pub trait BackendCostModel: Send + Sync {
64    /// Identify which device this model is for.
65    fn device(&self) -> Device;
66
67    /// Provenance of this model's numbers — see [`CostCalibration`].
68    ///
69    /// Defaults to [`CostCalibration::ArchDefault`]: a model that has not been
70    /// audited claims neither measurement nor ignorance. Every in-tree impl
71    /// overrides this with the truth.
72    fn calibration(&self) -> CostCalibration {
73        CostCalibration::ArchDefault
74    }
75
76    /// Effective f32 sgemm throughput in GFLOP/s for the most-used kernel
77    /// path at the given dimensions. Backends should return their best
78    /// sustained rate (not peak).
79    fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64;
80
81    /// Cost to dispatch one kernel (function call, BLAS setup, etc.) in ns.
82    fn dispatch_overhead_ns(&self) -> f64;
83
84    /// Cost to commit + wait for a command buffer / forward pass in ns.
85    /// Roughly amortized per-forward overhead independent of kernel count.
86    fn roundtrip_overhead_ns(&self) -> f64;
87
88    /// Memory bandwidth in bytes/ns (== GB/s).
89    fn memory_bw(&self) -> f64;
90
91    /// Effective host readback bandwidth (bytes/ns). Defaults to [`Self::memory_bw`].
92    fn host_readback_bw(&self) -> f64 {
93        self.memory_bw()
94    }
95
96    /// True when device and host share the same physical memory (Apple Silicon Metal).
97    fn unified_memory(&self) -> bool {
98        false
99    }
100
101    /// Number of compute threads available.
102    fn num_threads(&self) -> usize;
103}
104
105/// Estimate forward-pass time (ns) for a graph on the given backend.
106/// Uses node-level cost contributions; conservative — actual time may
107/// be lower due to hardware parallelism we don't model.
108pub fn estimate_graph_cost(graph: &Graph, model: &dyn BackendCostModel) -> f64 {
109    estimate_graph_cost_with_io(graph, model, &crate::graph_io::profile_graph_io(graph))
110}
111
112/// IO-aware cost: compute + device traffic + host readback + sync points.
113pub fn estimate_graph_cost_with_io(
114    graph: &Graph,
115    model: &dyn BackendCostModel,
116    io: &crate::graph_io::GraphIoProfile,
117) -> f64 {
118    let mut total = model.roundtrip_overhead_ns();
119    for node in graph.nodes() {
120        total += node_cost(node, graph, model);
121    }
122    total += io.device_traffic_bytes as f64 / model.memory_bw().max(1.0);
123    total +=
124        io.host_readback_bytes(model.unified_memory()) as f64 / model.host_readback_bw().max(1.0);
125    total += io.sync_points as f64 * model.roundtrip_overhead_ns();
126    total
127}
128
129fn node_cost(node: &Node, graph: &Graph, model: &dyn BackendCostModel) -> f64 {
130    let dispatch = model.dispatch_overhead_ns();
131    match &node.op {
132        Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => 0.0,
133        Op::MatMul | Op::FusedMatMulBiasAct { .. } => {
134            let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
135            let total = node.shape.num_elements().unwrap_or(0);
136            let m = total / n.max(1);
137            let a_total = graph.node(node.inputs[0]).shape.num_elements().unwrap_or(0);
138            let k = a_total / m.max(1);
139            let flops = 2.0 * m as f64 * k as f64 * n as f64;
140            flops / (model.sgemm_gflops(m, k, n) + 1.0) + dispatch
141        }
142        Op::Attention {
143            num_heads,
144            head_dim,
145            ..
146        } => {
147            let q_shape = &graph.node(node.inputs[0]).shape;
148            let seq = q_shape.dim(q_shape.rank() - 2).unwrap_static();
149            let batch = q_shape.num_elements().unwrap_or(0) / (seq * num_heads * head_dim).max(1);
150            let flops = (batch * num_heads * seq * seq * head_dim * 2) as f64;
151            flops / (model.sgemm_gflops(seq, *head_dim, seq) + 1.0) + dispatch
152        }
153        // Element-wise + small ops: bounded by memory bandwidth.
154        _ => {
155            let bytes = node.shape.num_elements().unwrap_or(0) * 4;
156            (bytes as f64) / model.memory_bw().max(1.0) + dispatch
157        }
158    }
159}
160
161/// Pick the device with the lowest predicted cost for this graph.
162///
163/// **Uncalibrated models are excluded from the ranking, not silently trusted.**
164/// A model whose numbers are a generic guess ([`CostCalibration::Uncalibrated`])
165/// cannot be meaningfully compared against a measured one — and the guesses are
166/// optimistic, so including them systematically hands the graph to whichever
167/// device rlx knows *least* about. That is the failure mode CAKE B.5 names:
168/// inheriting estimates rather than reporting a coverage limitation.
169///
170/// If *every* model is uncalibrated there is nothing to compare, so the lowest
171/// cost is as good a tiebreak as any and we take it — but say so under
172/// `RLX_VERBOSE`, because a caller seeing an unexpected placement deserves to
173/// know the ranking had no evidence behind it.
174pub fn pick_best_device(graph: &Graph, models: &[&dyn BackendCostModel]) -> Device {
175    let rankable: Vec<&&dyn BackendCostModel> = models
176        .iter()
177        .filter(|m| m.calibration().is_rankable())
178        .collect();
179
180    let (pool, blind): (&[&dyn BackendCostModel], bool) = if rankable.is_empty() {
181        (models, true)
182    } else if rankable.len() == models.len() {
183        (models, false)
184    } else {
185        // Some were dropped — report which, then rank the rest.
186        if rlx_ir::env::flag("RLX_VERBOSE") {
187            for m in models {
188                if !m.calibration().is_rankable() {
189                    eprintln!(
190                        "rlx: excluding {:?} from device ranking — cost model is {}",
191                        m.device(),
192                        m.calibration().as_str()
193                    );
194                }
195            }
196        }
197        // Re-borrow as a slice of trait objects.
198        let kept: Vec<&dyn BackendCostModel> = rankable.iter().map(|m| **m).collect();
199        return pick_lowest(graph, &kept);
200    };
201
202    if blind && rlx_ir::env::flag("RLX_VERBOSE") {
203        eprintln!(
204            "rlx: every candidate cost model is uncalibrated — device ranking has \
205             no measured basis on this host"
206        );
207    }
208    pick_lowest(graph, pool)
209}
210
211fn pick_lowest(graph: &Graph, models: &[&dyn BackendCostModel]) -> Device {
212    let mut best = (Device::Cpu, f64::INFINITY);
213    for &m in models {
214        let cost = estimate_graph_cost(graph, m);
215        if cost < best.1 {
216            best = (m.device(), cost);
217        }
218    }
219    best.0
220}
221
222/// Pick the fastest backend for `graph` on this host.
223pub fn fastest_device_for(graph: &Graph) -> Device {
224    fastest_device_for_with_policy(graph, &crate::device_policy::DevicePolicy::default())
225}
226
227/// Like [`fastest_device_for`] but respects a [`crate::DevicePolicy`] allow-list.
228pub fn fastest_device_for_with_policy(
229    graph: &Graph,
230    policy: &crate::device_policy::DevicePolicy,
231) -> Device {
232    // Hard override: `RLX_FORCE_DEVICE` pins the backend regardless of the cost
233    // model or policy. Without this, a small graph whose GPU dispatch cost
234    // exceeds CPU is silently placed on CPU even when the caller asked for a
235    // GPU backend — so a `--features gpu` run can be a CPU run in disguise,
236    // masking real GPU failures. Honored at the top of the single chokepoint
237    // so EVERY `fastest_device_for*` caller respects it.
238    if let Some(s) = rlx_ir::env::var("RLX_FORCE_DEVICE") {
239        if let Ok(dev) = crate::parse_device(&s) {
240            return dev;
241        }
242    }
243    let candidates = crate::device_policy::devices_for_with_policy(graph, policy);
244    if candidates.is_empty() {
245        return crate::device_ext::fastest_among(&policy.apply(crate::available_devices()));
246    }
247
248    #[cfg(feature = "cpu")]
249    let cpu = CpuCostModel::new();
250    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
251    let metal = MetalCostModel::new();
252    #[cfg(all(feature = "mlx", rlx_mlx_host))]
253    let mlx = MlxCostModel::new();
254    #[cfg(feature = "cuda")]
255    let cuda = CudaCostModel::new();
256    #[cfg(feature = "rocm")]
257    let rocm = RocmCostModel::new();
258    #[cfg(feature = "gpu")]
259    let wgpu = WgpuCostModel::new();
260
261    // Every push below is backend-cfg-gated; the backend-less build has none.
262    #[cfg_attr(not(feature = "cpu"), allow(unused_mut))]
263    let mut models: Vec<&dyn BackendCostModel> = Vec::new();
264    #[cfg(feature = "cpu")]
265    if candidates.contains(&Device::Cpu) {
266        models.push(&cpu);
267    }
268    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
269    if candidates.contains(&Device::Metal) {
270        models.push(&metal);
271    }
272    #[cfg(all(feature = "mlx", rlx_mlx_host))]
273    if candidates.contains(&Device::Mlx) {
274        models.push(&mlx);
275    }
276    #[cfg(feature = "cuda")]
277    if candidates.contains(&Device::Cuda) {
278        models.push(&cuda);
279    }
280    #[cfg(feature = "rocm")]
281    if candidates.contains(&Device::Rocm) {
282        models.push(&rocm);
283    }
284    #[cfg(feature = "gpu")]
285    if candidates.contains(&Device::Gpu) {
286        models.push(&wgpu);
287    }
288
289    if models.len() >= 2 {
290        pick_best_device(graph, &models)
291    } else if let Some(m) = models.first() {
292        m.device()
293    } else {
294        crate::device_ext::fastest_among(&candidates)
295    }
296}
297
298// ── Backend adapters (plan #29) ─────────────────────────────────
299//
300// The CPU and Metal crates own their own internal cost models for
301// kernel-selection decisions. These thin adapters wrap them in
302// `BackendCostModel` so `pick_best_device` can compare both with a
303// single uniform interface.
304
305/// `BackendCostModel` impl backed by `rlx_cpu::cost::HwModel`.
306#[cfg(feature = "cpu")]
307pub struct CpuCostModel(rlx_cpu::cost::HwModel);
308
309#[cfg(feature = "cpu")]
310impl CpuCostModel {
311    pub fn new() -> Self {
312        let cfg = rlx_cpu::config::RuntimeConfig::global();
313        Self(rlx_cpu::cost::HwModel::from_config(cfg))
314    }
315}
316
317#[cfg(feature = "cpu")]
318impl Default for CpuCostModel {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324#[cfg(feature = "cpu")]
325impl BackendCostModel for CpuCostModel {
326    fn device(&self) -> Device {
327        Device::Cpu
328    }
329    fn calibration(&self) -> CostCalibration {
330        // `rlx_cpu::cost::HwModel` is built from detected CPU features and
331        // per-arch compile-time constants — real for the arch, not measured here.
332        CostCalibration::ArchDefault
333    }
334    fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64 {
335        // Take the better of NEON / BLAS at this shape.
336        let flops = 2.0 * m as f64 * k as f64 * n as f64;
337        let neon_time = flops / self.0.neon_flops.max(1.0);
338        let blas_time = flops / self.0.blas_flops.max(1.0);
339        let pick = neon_time.min(blas_time);
340        if pick > 0.0 {
341            flops / (pick * 1e9)
342        } else {
343            0.0
344        }
345    }
346    fn dispatch_overhead_ns(&self) -> f64 {
347        self.0.blas_overhead_ns
348    }
349    fn roundtrip_overhead_ns(&self) -> f64 {
350        self.0.par_for_overhead_ns
351    }
352    fn memory_bw(&self) -> f64 {
353        self.0.mem_bw
354    }
355    fn num_threads(&self) -> usize {
356        self.0.num_threads
357    }
358}
359
360/// `BackendCostModel` impl backed by `rlx_metal::cost`. Reads from
361/// the on-disk calibration cache so the numbers reflect what this
362/// machine actually measured.
363#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
364pub struct MetalCostModel {
365    sgemm_gflops_avg: f64,
366    roundtrip_ns: f64,
367    memory_bw: f64,
368}
369
370#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
371impl MetalCostModel {
372    pub fn new() -> Self {
373        let cal = rlx_metal::calibrate::Calibration::load_or_measure();
374        // Effective single-shape sgemm: best of the calibrated paths.
375        let best = cal
376            .sgemm_simd_4x4_flops
377            .max(cal.sgemm_simd_flops)
378            .max(cal.sgemm_padded_flops);
379        Self {
380            sgemm_gflops_avg: best,
381            roundtrip_ns: cal.roundtrip_overhead_ns,
382            // Apple Silicon unified memory bandwidth (rough): ~200 GB/s
383            // on M-series base, much higher on Pro/Max. The calibrator
384            // doesn't measure pure mem-bw yet, so we hard-code a
385            // floor that makes mem-bound ops not look free.
386            memory_bw: 200.0,
387        }
388    }
389}
390
391#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
392impl Default for MetalCostModel {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
399impl BackendCostModel for MetalCostModel {
400    fn device(&self) -> Device {
401        Device::Metal
402    }
403    fn calibration(&self) -> CostCalibration {
404        // `load_or_measure` really does measure when no cache exists, so the
405        // sgemm figure is measured — but `memory_bw` is a hard-coded 200 GB/s
406        // floor (the calibrator does not probe bandwidth yet) and the Apple GPU
407        // family may be `Unknown`. Claiming Measured would overstate both, so
408        // report the weaker of the two: arch-default.
409        if rlx_metal::cost::hw_model().gpu_family == rlx_metal::cost::AppleGpuFamily::Unknown {
410            CostCalibration::Uncalibrated
411        } else {
412            CostCalibration::ArchDefault
413        }
414    }
415    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
416        self.sgemm_gflops_avg
417    }
418    fn dispatch_overhead_ns(&self) -> f64 {
419        // Per-kernel encode cost — small relative to the round-trip.
420        2_000.0
421    }
422    fn roundtrip_overhead_ns(&self) -> f64 {
423        self.roundtrip_ns
424    }
425    fn memory_bw(&self) -> f64 {
426        self.memory_bw
427    }
428    fn unified_memory(&self) -> bool {
429        true
430    }
431    fn num_threads(&self) -> usize {
432        1
433    } // single command queue
434}
435
436/// `BackendCostModel` impl backed by `rlx_mlx::calibrate`. Reads from
437/// the on-disk MLX calibration cache. The first construction on a
438/// fresh machine pays a one-time measurement cost (tens of ms);
439/// subsequent constructions read the cache.
440#[cfg(all(feature = "mlx", rlx_mlx_host))]
441pub struct MlxCostModel {
442    sgemm_large_flops: f64,
443    sgemm_small_flops: f64,
444    roundtrip_ns: f64,
445    memory_bw: f64,
446}
447
448#[cfg(all(feature = "mlx", rlx_mlx_host))]
449impl MlxCostModel {
450    pub fn new() -> Self {
451        let cal = rlx_mlx::calibrate::Calibration::load_or_measure();
452        // Use measured memory bandwidth when available (post-PR16
453        // calibrators record it); fall back to the Apple-Silicon
454        // unified-memory floor otherwise so old caches still produce
455        // sane numbers.
456        let memory_bw = if cal.memory_bw_gbps > 0.0 {
457            cal.memory_bw_gbps
458        } else {
459            200.0
460        };
461        Self {
462            sgemm_large_flops: cal.sgemm_large_flops,
463            sgemm_small_flops: cal.sgemm_small_flops,
464            roundtrip_ns: cal.roundtrip_overhead_ns,
465            memory_bw,
466        }
467    }
468}
469
470#[cfg(all(feature = "mlx", rlx_mlx_host))]
471impl Default for MlxCostModel {
472    fn default() -> Self {
473        Self::new()
474    }
475}
476
477#[cfg(all(feature = "mlx", rlx_mlx_host))]
478impl BackendCostModel for MlxCostModel {
479    fn device(&self) -> Device {
480        Device::Mlx
481    }
482    fn calibration(&self) -> CostCalibration {
483        // A crossover heuristic with hand-picked constants — no measurement and
484        // no per-arch table behind it.
485        CostCalibration::Uncalibrated
486    }
487    fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64 {
488        // Crossover heuristic: small shapes pay the per-op overhead;
489        // large shapes hit the optimized path. The cutoff is rough —
490        // matches the calibrator's "small" / "large" probe sizes.
491        let total = m as f64 * k as f64 * n as f64;
492        if total < 32_768.0 {
493            self.sgemm_small_flops
494        } else {
495            self.sgemm_large_flops
496        }
497    }
498    fn dispatch_overhead_ns(&self) -> f64 {
499        // MLX's lazy-eval keeps per-op encode cost low; trace
500        // construction in Rust is the dominant per-op cost.
501        2_000.0
502    }
503    fn roundtrip_overhead_ns(&self) -> f64 {
504        self.roundtrip_ns
505    }
506    fn memory_bw(&self) -> f64 {
507        self.memory_bw
508    }
509    fn num_threads(&self) -> usize {
510        1
511    }
512}
513
514/// Heuristic CUDA cost model until a dedicated calibrator lands.
515#[cfg(feature = "cuda")]
516pub struct CudaCostModel {
517    sgemm_gflops: f64,
518    roundtrip_ns: f64,
519    memory_bw: f64,
520    /// Whether the numbers above came from a real calibration on this
521    /// machine or from the hardcoded no-device fallback below.
522    calibration: CostCalibration,
523}
524
525#[cfg(feature = "cuda")]
526impl CudaCostModel {
527    pub fn new() -> Self {
528        if crate::is_available(crate::Device::Cuda) {
529            let cal = rlx_cuda::calibrate::Calibration::load_or_measure();
530            return Self {
531                sgemm_gflops: cal.sgemm_gflops,
532                roundtrip_ns: cal.roundtrip_overhead_ns,
533                memory_bw: cal.memory_bw_gbps,
534                calibration: CostCalibration::Measured,
535            };
536        }
537        // No CUDA device: these are invented numbers for a device that is not
538        // here. Marked Uncalibrated so `pick_best_device` refuses to rank on them
539        // rather than "discovering" that an absent GPU is fastest.
540        Self {
541            sgemm_gflops: 12_000.0,
542            roundtrip_ns: 35_000.0,
543            memory_bw: 900.0,
544            calibration: CostCalibration::Uncalibrated,
545        }
546    }
547}
548
549#[cfg(feature = "cuda")]
550impl Default for CudaCostModel {
551    fn default() -> Self {
552        Self::new()
553    }
554}
555
556#[cfg(feature = "cuda")]
557impl BackendCostModel for CudaCostModel {
558    fn device(&self) -> Device {
559        Device::Cuda
560    }
561    fn calibration(&self) -> CostCalibration {
562        self.calibration
563    }
564    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
565        self.sgemm_gflops
566    }
567    fn dispatch_overhead_ns(&self) -> f64 {
568        3_000.0
569    }
570    fn roundtrip_overhead_ns(&self) -> f64 {
571        self.roundtrip_ns
572    }
573    fn memory_bw(&self) -> f64 {
574        self.memory_bw
575    }
576    fn num_threads(&self) -> usize {
577        1
578    }
579}
580
581/// Heuristic ROCm cost model (same class as CUDA until calibrated).
582#[cfg(feature = "rocm")]
583pub struct RocmCostModel {
584    sgemm_gflops: f64,
585    roundtrip_ns: f64,
586    memory_bw: f64,
587    /// Whether the numbers above came from a real calibration on this
588    /// machine or from the hardcoded no-device fallback below.
589    calibration: CostCalibration,
590}
591
592#[cfg(feature = "rocm")]
593impl RocmCostModel {
594    pub fn new() -> Self {
595        if crate::is_available(crate::Device::Rocm) {
596            let cal = rlx_rocm::calibrate::Calibration::load_or_measure();
597            return Self {
598                sgemm_gflops: cal.sgemm_gflops,
599                roundtrip_ns: cal.roundtrip_overhead_ns,
600                memory_bw: cal.memory_bw_gbps,
601                calibration: CostCalibration::Measured,
602            };
603        }
604        // No ROCm device — see the CUDA note. The old comment on this struct read
605        // "same class as CUDA until calibrated", which is exactly the inherited
606        // estimate CAKE B.5 warns against; now it is labelled instead.
607        Self {
608            sgemm_gflops: 10_000.0,
609            roundtrip_ns: 40_000.0,
610            memory_bw: 800.0,
611            calibration: CostCalibration::Uncalibrated,
612        }
613    }
614}
615
616#[cfg(feature = "rocm")]
617impl Default for RocmCostModel {
618    fn default() -> Self {
619        Self::new()
620    }
621}
622
623#[cfg(feature = "rocm")]
624impl BackendCostModel for RocmCostModel {
625    fn device(&self) -> Device {
626        Device::Rocm
627    }
628    fn calibration(&self) -> CostCalibration {
629        self.calibration
630    }
631    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
632        self.sgemm_gflops
633    }
634    fn dispatch_overhead_ns(&self) -> f64 {
635        3_000.0
636    }
637    fn roundtrip_overhead_ns(&self) -> f64 {
638        self.roundtrip_ns
639    }
640    fn memory_bw(&self) -> f64 {
641        self.memory_bw
642    }
643    fn num_threads(&self) -> usize {
644        1
645    }
646}
647
648/// Heuristic wgpu (`Device::Gpu`) cost model.
649#[cfg(feature = "gpu")]
650pub struct WgpuCostModel {
651    sgemm_gflops: f64,
652    roundtrip_ns: f64,
653    memory_bw: f64,
654    /// Whether the numbers above came from a real calibration on this machine
655    /// or from the hardcoded no-adapter fallback below.
656    calibration: CostCalibration,
657}
658
659#[cfg(feature = "gpu")]
660impl WgpuCostModel {
661    pub fn new() -> Self {
662        if rlx_wgpu::is_available() {
663            let cal = rlx_wgpu::calibrate::Calibration::load_or_measure();
664            return Self {
665                sgemm_gflops: cal.sgemm_gflops,
666                roundtrip_ns: cal.roundtrip_overhead_ns,
667                memory_bw: cal.memory_bw_gbps,
668                calibration: CostCalibration::Measured,
669            };
670        }
671        // No wgpu adapter — invented numbers for a device that is not here.
672        Self {
673            sgemm_gflops: 2_500.0,
674            roundtrip_ns: 80_000.0,
675            memory_bw: 120.0,
676            calibration: CostCalibration::Uncalibrated,
677        }
678    }
679}
680
681#[cfg(feature = "gpu")]
682impl Default for WgpuCostModel {
683    fn default() -> Self {
684        Self::new()
685    }
686}
687
688#[cfg(feature = "gpu")]
689impl BackendCostModel for WgpuCostModel {
690    fn device(&self) -> Device {
691        Device::Gpu
692    }
693    fn calibration(&self) -> CostCalibration {
694        self.calibration
695    }
696    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
697        self.sgemm_gflops
698    }
699    fn dispatch_overhead_ns(&self) -> f64 {
700        5_000.0
701    }
702    fn roundtrip_overhead_ns(&self) -> f64 {
703        self.roundtrip_ns
704    }
705    fn memory_bw(&self) -> f64 {
706        self.memory_bw
707    }
708    fn num_threads(&self) -> usize {
709        1
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use rlx_ir::{DType, Graph, Shape};
717
718    /// A cost model with dictated numbers and provenance, for testing the
719    /// ranking policy without needing real hardware.
720    struct FakeModel {
721        device: Device,
722        gflops: f64,
723        calibration: CostCalibration,
724    }
725
726    impl BackendCostModel for FakeModel {
727        fn device(&self) -> Device {
728            self.device
729        }
730        fn calibration(&self) -> CostCalibration {
731            self.calibration
732        }
733        fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
734            self.gflops
735        }
736        fn dispatch_overhead_ns(&self) -> f64 {
737            100.0
738        }
739        fn roundtrip_overhead_ns(&self) -> f64 {
740            1_000.0
741        }
742        fn memory_bw(&self) -> f64 {
743            100.0
744        }
745        fn num_threads(&self) -> usize {
746            1
747        }
748    }
749
750    fn big_matmul() -> Graph {
751        let mut g = Graph::new("mm");
752        let x = g.input("x", Shape::new(&[512, 512], DType::F32));
753        let w = g.param("w", Shape::new(&[512, 512], DType::F32));
754        let y = g.matmul(x, w, Shape::new(&[512, 512], DType::F32));
755        g.set_outputs(vec![y]);
756        g
757    }
758
759    /// The core guarantee: an uncalibrated model cannot win the ranking, however
760    /// good its invented numbers look. Before this, `RocmCostModel`'s no-device
761    /// fallback claimed 10 TFLOP/s and would beat a measured CPU every time.
762    #[test]
763    fn uncalibrated_model_cannot_win_the_ranking() {
764        let g = big_matmul();
765        let measured_slow = FakeModel {
766            device: Device::Cpu,
767            gflops: 100.0,
768            calibration: CostCalibration::Measured,
769        };
770        let guessed_fast = FakeModel {
771            device: Device::Rocm,
772            gflops: 10_000.0,
773            calibration: CostCalibration::Uncalibrated,
774        };
775        let models: Vec<&dyn BackendCostModel> = vec![&measured_slow, &guessed_fast];
776        assert_eq!(
777            pick_best_device(&g, &models),
778            Device::Cpu,
779            "a guessed 100x-faster device must not be picked over a measured one"
780        );
781    }
782
783    /// …but a *measured* faster device must still win, or the guard would have
784    /// disabled device selection instead of correcting it.
785    #[test]
786    fn measured_faster_device_still_wins() {
787        let g = big_matmul();
788        let cpu = FakeModel {
789            device: Device::Cpu,
790            gflops: 100.0,
791            calibration: CostCalibration::Measured,
792        };
793        let gpu = FakeModel {
794            device: Device::Cuda,
795            gflops: 10_000.0,
796            calibration: CostCalibration::Measured,
797        };
798        let models: Vec<&dyn BackendCostModel> = vec![&cpu, &gpu];
799        assert_eq!(pick_best_device(&g, &models), Device::Cuda);
800    }
801
802    /// ArchDefault is rankable — per-arch constants are evidence, just weaker
803    /// than a measurement. Excluding them would strand every backend whose
804    /// calibrator has not run.
805    #[test]
806    fn arch_default_is_rankable() {
807        assert!(CostCalibration::Measured.is_rankable());
808        assert!(CostCalibration::ArchDefault.is_rankable());
809        assert!(!CostCalibration::Uncalibrated.is_rankable());
810
811        let g = big_matmul();
812        let cpu = FakeModel {
813            device: Device::Cpu,
814            gflops: 100.0,
815            calibration: CostCalibration::ArchDefault,
816        };
817        let gpu = FakeModel {
818            device: Device::Metal,
819            gflops: 5_000.0,
820            calibration: CostCalibration::ArchDefault,
821        };
822        let models: Vec<&dyn BackendCostModel> = vec![&cpu, &gpu];
823        assert_eq!(pick_best_device(&g, &models), Device::Metal);
824    }
825
826    /// When nothing is calibrated there is no evidence either way, so ranking
827    /// still has to return something — it must not panic or drop to a fixed
828    /// device.
829    #[test]
830    fn all_uncalibrated_still_returns_a_device() {
831        let g = big_matmul();
832        let a = FakeModel {
833            device: Device::Cpu,
834            gflops: 100.0,
835            calibration: CostCalibration::Uncalibrated,
836        };
837        let b = FakeModel {
838            device: Device::Gpu,
839            gflops: 9_000.0,
840            calibration: CostCalibration::Uncalibrated,
841        };
842        let models: Vec<&dyn BackendCostModel> = vec![&a, &b];
843        assert_eq!(pick_best_device(&g, &models), Device::Gpu);
844    }
845
846    /// Every cost model compiled into this build must state its provenance —
847    /// the trait default exists for out-of-tree impls, not as a way for an
848    /// in-tree one to stay silent.
849    #[test]
850    fn in_tree_models_declare_their_provenance() {
851        #[allow(unused_mut)]
852        let mut checked = 0usize;
853        #[cfg(feature = "cpu")]
854        {
855            let m = CpuCostModel::new();
856            assert_eq!(m.calibration(), CostCalibration::ArchDefault);
857            checked += 1;
858        }
859        #[cfg(feature = "gpu")]
860        {
861            let m = WgpuCostModel::new();
862            // Either is honest; what matters is that it is not a silent guess
863            // dressed as a measurement when no adapter exists.
864            assert!(
865                m.calibration() == CostCalibration::Measured
866                    || m.calibration() == CostCalibration::Uncalibrated
867            );
868            checked += 1;
869        }
870        assert!(checked > 0, "no cost model was compiled in to check");
871    }
872
873    #[test]
874    fn fastest_device_for_falls_back_to_cpu_for_simple_graph() {
875        let mut g = Graph::new("mm");
876        let x = g.input("x", Shape::new(&[4, 4], DType::F32));
877        let w = g.param("w", Shape::new(&[4, 4], DType::F32));
878        let y = g.matmul(x, w, Shape::new(&[4, 4], DType::F32));
879        g.set_outputs(vec![y]);
880        let pick = fastest_device_for(&g);
881        assert!(crate::is_available(pick));
882        assert!(crate::devices_for(&g).contains(&pick));
883    }
884}