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/// Hardware-aware cost characteristics for a backend on the current machine.
20pub trait BackendCostModel: Send + Sync {
21    /// Identify which device this model is for.
22    fn device(&self) -> Device;
23
24    /// Effective f32 sgemm throughput in GFLOP/s for the most-used kernel
25    /// path at the given dimensions. Backends should return their best
26    /// sustained rate (not peak).
27    fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64;
28
29    /// Cost to dispatch one kernel (function call, BLAS setup, etc.) in ns.
30    fn dispatch_overhead_ns(&self) -> f64;
31
32    /// Cost to commit + wait for a command buffer / forward pass in ns.
33    /// Roughly amortized per-forward overhead independent of kernel count.
34    fn roundtrip_overhead_ns(&self) -> f64;
35
36    /// Memory bandwidth in bytes/ns (== GB/s).
37    fn memory_bw(&self) -> f64;
38
39    /// Effective host readback bandwidth (bytes/ns). Defaults to [`Self::memory_bw`].
40    fn host_readback_bw(&self) -> f64 {
41        self.memory_bw()
42    }
43
44    /// True when device and host share the same physical memory (Apple Silicon Metal).
45    fn unified_memory(&self) -> bool {
46        false
47    }
48
49    /// Number of compute threads available.
50    fn num_threads(&self) -> usize;
51}
52
53/// Estimate forward-pass time (ns) for a graph on the given backend.
54/// Uses node-level cost contributions; conservative — actual time may
55/// be lower due to hardware parallelism we don't model.
56pub fn estimate_graph_cost(graph: &Graph, model: &dyn BackendCostModel) -> f64 {
57    estimate_graph_cost_with_io(graph, model, &crate::graph_io::profile_graph_io(graph))
58}
59
60/// IO-aware cost: compute + device traffic + host readback + sync points.
61pub fn estimate_graph_cost_with_io(
62    graph: &Graph,
63    model: &dyn BackendCostModel,
64    io: &crate::graph_io::GraphIoProfile,
65) -> f64 {
66    let mut total = model.roundtrip_overhead_ns();
67    for node in graph.nodes() {
68        total += node_cost(node, graph, model);
69    }
70    total += io.device_traffic_bytes as f64 / model.memory_bw().max(1.0);
71    total +=
72        io.host_readback_bytes(model.unified_memory()) as f64 / model.host_readback_bw().max(1.0);
73    total += io.sync_points as f64 * model.roundtrip_overhead_ns();
74    total
75}
76
77fn node_cost(node: &Node, graph: &Graph, model: &dyn BackendCostModel) -> f64 {
78    let dispatch = model.dispatch_overhead_ns();
79    match &node.op {
80        Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => 0.0,
81        Op::MatMul | Op::FusedMatMulBiasAct { .. } => {
82            let n = node.shape.dim(node.shape.rank() - 1).unwrap_static();
83            let total = node.shape.num_elements().unwrap_or(0);
84            let m = total / n.max(1);
85            let a_total = graph.node(node.inputs[0]).shape.num_elements().unwrap_or(0);
86            let k = a_total / m.max(1);
87            let flops = 2.0 * m as f64 * k as f64 * n as f64;
88            flops / (model.sgemm_gflops(m, k, n) + 1.0) + dispatch
89        }
90        Op::Attention {
91            num_heads,
92            head_dim,
93            ..
94        } => {
95            let q_shape = &graph.node(node.inputs[0]).shape;
96            let seq = q_shape.dim(q_shape.rank() - 2).unwrap_static();
97            let batch = q_shape.num_elements().unwrap_or(0) / (seq * num_heads * head_dim).max(1);
98            let flops = (batch * num_heads * seq * seq * head_dim * 2) as f64;
99            flops / (model.sgemm_gflops(seq, *head_dim, seq) + 1.0) + dispatch
100        }
101        // Element-wise + small ops: bounded by memory bandwidth.
102        _ => {
103            let bytes = node.shape.num_elements().unwrap_or(0) * 4;
104            (bytes as f64) / model.memory_bw().max(1.0) + dispatch
105        }
106    }
107}
108
109/// Pick the device with the lowest predicted cost for this graph.
110pub fn pick_best_device(graph: &Graph, models: &[&dyn BackendCostModel]) -> Device {
111    let mut best = (Device::Cpu, f64::INFINITY);
112    for &m in models {
113        let cost = estimate_graph_cost(graph, m);
114        if cost < best.1 {
115            best = (m.device(), cost);
116        }
117    }
118    best.0
119}
120
121/// Pick the fastest backend for `graph` on this host.
122pub fn fastest_device_for(graph: &Graph) -> Device {
123    fastest_device_for_with_policy(graph, &crate::device_policy::DevicePolicy::default())
124}
125
126/// Like [`fastest_device_for`] but respects a [`crate::DevicePolicy`] allow-list.
127pub fn fastest_device_for_with_policy(
128    graph: &Graph,
129    policy: &crate::device_policy::DevicePolicy,
130) -> Device {
131    // Hard override: `RLX_FORCE_DEVICE` pins the backend regardless of the cost
132    // model or policy. Without this, a small graph whose GPU dispatch cost
133    // exceeds CPU is silently placed on CPU even when the caller asked for a
134    // GPU backend — so a `--features gpu` run can be a CPU run in disguise,
135    // masking real GPU failures. Honored at the top of the single chokepoint
136    // so EVERY `fastest_device_for*` caller respects it.
137    if let Ok(s) = std::env::var("RLX_FORCE_DEVICE") {
138        if let Ok(dev) = crate::parse_device(&s) {
139            return dev;
140        }
141    }
142    let candidates = crate::device_policy::devices_for_with_policy(graph, policy);
143    if candidates.is_empty() {
144        return crate::device_ext::fastest_among(&policy.apply(crate::available_devices()));
145    }
146
147    #[cfg(feature = "cpu")]
148    let cpu = CpuCostModel::new();
149    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
150    let metal = MetalCostModel::new();
151    #[cfg(all(feature = "mlx", rlx_mlx_host))]
152    let mlx = MlxCostModel::new();
153    #[cfg(feature = "cuda")]
154    let cuda = CudaCostModel::new();
155    #[cfg(feature = "rocm")]
156    let rocm = RocmCostModel::new();
157    #[cfg(feature = "gpu")]
158    let wgpu = WgpuCostModel::new();
159
160    // Every push below is backend-cfg-gated; the backend-less build has none.
161    #[cfg_attr(not(feature = "cpu"), allow(unused_mut))]
162    let mut models: Vec<&dyn BackendCostModel> = Vec::new();
163    #[cfg(feature = "cpu")]
164    if candidates.contains(&Device::Cpu) {
165        models.push(&cpu);
166    }
167    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
168    if candidates.contains(&Device::Metal) {
169        models.push(&metal);
170    }
171    #[cfg(all(feature = "mlx", rlx_mlx_host))]
172    if candidates.contains(&Device::Mlx) {
173        models.push(&mlx);
174    }
175    #[cfg(feature = "cuda")]
176    if candidates.contains(&Device::Cuda) {
177        models.push(&cuda);
178    }
179    #[cfg(feature = "rocm")]
180    if candidates.contains(&Device::Rocm) {
181        models.push(&rocm);
182    }
183    #[cfg(feature = "gpu")]
184    if candidates.contains(&Device::Gpu) {
185        models.push(&wgpu);
186    }
187
188    if models.len() >= 2 {
189        pick_best_device(graph, &models)
190    } else if let Some(m) = models.first() {
191        m.device()
192    } else {
193        crate::device_ext::fastest_among(&candidates)
194    }
195}
196
197// ── Backend adapters (plan #29) ─────────────────────────────────
198//
199// The CPU and Metal crates own their own internal cost models for
200// kernel-selection decisions. These thin adapters wrap them in
201// `BackendCostModel` so `pick_best_device` can compare both with a
202// single uniform interface.
203
204/// `BackendCostModel` impl backed by `rlx_cpu::cost::HwModel`.
205#[cfg(feature = "cpu")]
206pub struct CpuCostModel(rlx_cpu::cost::HwModel);
207
208#[cfg(feature = "cpu")]
209impl CpuCostModel {
210    pub fn new() -> Self {
211        let cfg = rlx_cpu::config::RuntimeConfig::global();
212        Self(rlx_cpu::cost::HwModel::from_config(cfg))
213    }
214}
215
216#[cfg(feature = "cpu")]
217impl Default for CpuCostModel {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223#[cfg(feature = "cpu")]
224impl BackendCostModel for CpuCostModel {
225    fn device(&self) -> Device {
226        Device::Cpu
227    }
228    fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64 {
229        // Take the better of NEON / BLAS at this shape.
230        let flops = 2.0 * m as f64 * k as f64 * n as f64;
231        let neon_time = flops / self.0.neon_flops.max(1.0);
232        let blas_time = flops / self.0.blas_flops.max(1.0);
233        let pick = neon_time.min(blas_time);
234        if pick > 0.0 {
235            flops / (pick * 1e9)
236        } else {
237            0.0
238        }
239    }
240    fn dispatch_overhead_ns(&self) -> f64 {
241        self.0.blas_overhead_ns
242    }
243    fn roundtrip_overhead_ns(&self) -> f64 {
244        self.0.par_for_overhead_ns
245    }
246    fn memory_bw(&self) -> f64 {
247        self.0.mem_bw
248    }
249    fn num_threads(&self) -> usize {
250        self.0.num_threads
251    }
252}
253
254/// `BackendCostModel` impl backed by `rlx_metal::cost`. Reads from
255/// the on-disk calibration cache so the numbers reflect what this
256/// machine actually measured.
257#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
258pub struct MetalCostModel {
259    sgemm_gflops_avg: f64,
260    roundtrip_ns: f64,
261    memory_bw: f64,
262}
263
264#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
265impl MetalCostModel {
266    pub fn new() -> Self {
267        let cal = rlx_metal::calibrate::Calibration::load_or_measure();
268        // Effective single-shape sgemm: best of the calibrated paths.
269        let best = cal
270            .sgemm_simd_4x4_flops
271            .max(cal.sgemm_simd_flops)
272            .max(cal.sgemm_padded_flops);
273        Self {
274            sgemm_gflops_avg: best,
275            roundtrip_ns: cal.roundtrip_overhead_ns,
276            // Apple Silicon unified memory bandwidth (rough): ~200 GB/s
277            // on M-series base, much higher on Pro/Max. The calibrator
278            // doesn't measure pure mem-bw yet, so we hard-code a
279            // floor that makes mem-bound ops not look free.
280            memory_bw: 200.0,
281        }
282    }
283}
284
285#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
286impl Default for MetalCostModel {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
293impl BackendCostModel for MetalCostModel {
294    fn device(&self) -> Device {
295        Device::Metal
296    }
297    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
298        self.sgemm_gflops_avg
299    }
300    fn dispatch_overhead_ns(&self) -> f64 {
301        // Per-kernel encode cost — small relative to the round-trip.
302        2_000.0
303    }
304    fn roundtrip_overhead_ns(&self) -> f64 {
305        self.roundtrip_ns
306    }
307    fn memory_bw(&self) -> f64 {
308        self.memory_bw
309    }
310    fn unified_memory(&self) -> bool {
311        true
312    }
313    fn num_threads(&self) -> usize {
314        1
315    } // single command queue
316}
317
318/// `BackendCostModel` impl backed by `rlx_mlx::calibrate`. Reads from
319/// the on-disk MLX calibration cache. The first construction on a
320/// fresh machine pays a one-time measurement cost (tens of ms);
321/// subsequent constructions read the cache.
322#[cfg(all(feature = "mlx", rlx_mlx_host))]
323pub struct MlxCostModel {
324    sgemm_large_flops: f64,
325    sgemm_small_flops: f64,
326    roundtrip_ns: f64,
327    memory_bw: f64,
328}
329
330#[cfg(all(feature = "mlx", rlx_mlx_host))]
331impl MlxCostModel {
332    pub fn new() -> Self {
333        let cal = rlx_mlx::calibrate::Calibration::load_or_measure();
334        // Use measured memory bandwidth when available (post-PR16
335        // calibrators record it); fall back to the Apple-Silicon
336        // unified-memory floor otherwise so old caches still produce
337        // sane numbers.
338        let memory_bw = if cal.memory_bw_gbps > 0.0 {
339            cal.memory_bw_gbps
340        } else {
341            200.0
342        };
343        Self {
344            sgemm_large_flops: cal.sgemm_large_flops,
345            sgemm_small_flops: cal.sgemm_small_flops,
346            roundtrip_ns: cal.roundtrip_overhead_ns,
347            memory_bw,
348        }
349    }
350}
351
352#[cfg(all(feature = "mlx", rlx_mlx_host))]
353impl Default for MlxCostModel {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359#[cfg(all(feature = "mlx", rlx_mlx_host))]
360impl BackendCostModel for MlxCostModel {
361    fn device(&self) -> Device {
362        Device::Mlx
363    }
364    fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64 {
365        // Crossover heuristic: small shapes pay the per-op overhead;
366        // large shapes hit the optimized path. The cutoff is rough —
367        // matches the calibrator's "small" / "large" probe sizes.
368        let total = m as f64 * k as f64 * n as f64;
369        if total < 32_768.0 {
370            self.sgemm_small_flops
371        } else {
372            self.sgemm_large_flops
373        }
374    }
375    fn dispatch_overhead_ns(&self) -> f64 {
376        // MLX's lazy-eval keeps per-op encode cost low; trace
377        // construction in Rust is the dominant per-op cost.
378        2_000.0
379    }
380    fn roundtrip_overhead_ns(&self) -> f64 {
381        self.roundtrip_ns
382    }
383    fn memory_bw(&self) -> f64 {
384        self.memory_bw
385    }
386    fn num_threads(&self) -> usize {
387        1
388    }
389}
390
391/// Heuristic CUDA cost model until a dedicated calibrator lands.
392#[cfg(feature = "cuda")]
393pub struct CudaCostModel {
394    sgemm_gflops: f64,
395    roundtrip_ns: f64,
396    memory_bw: f64,
397}
398
399#[cfg(feature = "cuda")]
400impl CudaCostModel {
401    pub fn new() -> Self {
402        if crate::is_available(crate::Device::Cuda) {
403            let cal = rlx_cuda::calibrate::Calibration::load_or_measure();
404            return Self {
405                sgemm_gflops: cal.sgemm_gflops,
406                roundtrip_ns: cal.roundtrip_overhead_ns,
407                memory_bw: cal.memory_bw_gbps,
408            };
409        }
410        Self {
411            sgemm_gflops: 12_000.0,
412            roundtrip_ns: 35_000.0,
413            memory_bw: 900.0,
414        }
415    }
416}
417
418#[cfg(feature = "cuda")]
419impl Default for CudaCostModel {
420    fn default() -> Self {
421        Self::new()
422    }
423}
424
425#[cfg(feature = "cuda")]
426impl BackendCostModel for CudaCostModel {
427    fn device(&self) -> Device {
428        Device::Cuda
429    }
430    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
431        self.sgemm_gflops
432    }
433    fn dispatch_overhead_ns(&self) -> f64 {
434        3_000.0
435    }
436    fn roundtrip_overhead_ns(&self) -> f64 {
437        self.roundtrip_ns
438    }
439    fn memory_bw(&self) -> f64 {
440        self.memory_bw
441    }
442    fn num_threads(&self) -> usize {
443        1
444    }
445}
446
447/// Heuristic ROCm cost model (same class as CUDA until calibrated).
448#[cfg(feature = "rocm")]
449pub struct RocmCostModel {
450    sgemm_gflops: f64,
451    roundtrip_ns: f64,
452    memory_bw: f64,
453}
454
455#[cfg(feature = "rocm")]
456impl RocmCostModel {
457    pub fn new() -> Self {
458        if crate::is_available(crate::Device::Rocm) {
459            let cal = rlx_rocm::calibrate::Calibration::load_or_measure();
460            return Self {
461                sgemm_gflops: cal.sgemm_gflops,
462                roundtrip_ns: cal.roundtrip_overhead_ns,
463                memory_bw: cal.memory_bw_gbps,
464            };
465        }
466        Self {
467            sgemm_gflops: 10_000.0,
468            roundtrip_ns: 40_000.0,
469            memory_bw: 800.0,
470        }
471    }
472}
473
474#[cfg(feature = "rocm")]
475impl Default for RocmCostModel {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481#[cfg(feature = "rocm")]
482impl BackendCostModel for RocmCostModel {
483    fn device(&self) -> Device {
484        Device::Rocm
485    }
486    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
487        self.sgemm_gflops
488    }
489    fn dispatch_overhead_ns(&self) -> f64 {
490        3_000.0
491    }
492    fn roundtrip_overhead_ns(&self) -> f64 {
493        self.roundtrip_ns
494    }
495    fn memory_bw(&self) -> f64 {
496        self.memory_bw
497    }
498    fn num_threads(&self) -> usize {
499        1
500    }
501}
502
503/// Heuristic wgpu (`Device::Gpu`) cost model.
504#[cfg(feature = "gpu")]
505pub struct WgpuCostModel {
506    sgemm_gflops: f64,
507    roundtrip_ns: f64,
508    memory_bw: f64,
509}
510
511#[cfg(feature = "gpu")]
512impl WgpuCostModel {
513    pub fn new() -> Self {
514        if rlx_wgpu::is_available() {
515            let cal = rlx_wgpu::calibrate::Calibration::load_or_measure();
516            return Self {
517                sgemm_gflops: cal.sgemm_gflops,
518                roundtrip_ns: cal.roundtrip_overhead_ns,
519                memory_bw: cal.memory_bw_gbps,
520            };
521        }
522        Self {
523            sgemm_gflops: 2_500.0,
524            roundtrip_ns: 80_000.0,
525            memory_bw: 120.0,
526        }
527    }
528}
529
530#[cfg(feature = "gpu")]
531impl Default for WgpuCostModel {
532    fn default() -> Self {
533        Self::new()
534    }
535}
536
537#[cfg(feature = "gpu")]
538impl BackendCostModel for WgpuCostModel {
539    fn device(&self) -> Device {
540        Device::Gpu
541    }
542    fn sgemm_gflops(&self, _m: usize, _k: usize, _n: usize) -> f64 {
543        self.sgemm_gflops
544    }
545    fn dispatch_overhead_ns(&self) -> f64 {
546        5_000.0
547    }
548    fn roundtrip_overhead_ns(&self) -> f64 {
549        self.roundtrip_ns
550    }
551    fn memory_bw(&self) -> f64 {
552        self.memory_bw
553    }
554    fn num_threads(&self) -> usize {
555        1
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use rlx_ir::{DType, Graph, Shape};
563
564    #[test]
565    fn fastest_device_for_falls_back_to_cpu_for_simple_graph() {
566        let mut g = Graph::new("mm");
567        let x = g.input("x", Shape::new(&[4, 4], DType::F32));
568        let w = g.param("w", Shape::new(&[4, 4], DType::F32));
569        let y = g.matmul(x, w, Shape::new(&[4, 4], DType::F32));
570        g.set_outputs(vec![y]);
571        let pick = fastest_device_for(&g);
572        assert!(crate::is_available(pick));
573        assert!(crate::devices_for(&g).contains(&pick));
574    }
575}