Skip to main content

rlx_runtime/
cost.rs

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