1use crate::Device;
17use rlx_ir::{Graph, Node, Op};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub enum CostCalibration {
35 Measured,
38 ArchDefault,
42 Uncalibrated,
45}
46
47impl CostCalibration {
48 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
62pub trait BackendCostModel: Send + Sync {
64 fn device(&self) -> Device;
66
67 fn calibration(&self) -> CostCalibration {
73 CostCalibration::ArchDefault
74 }
75
76 fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64;
80
81 fn dispatch_overhead_ns(&self) -> f64;
83
84 fn roundtrip_overhead_ns(&self) -> f64;
87
88 fn memory_bw(&self) -> f64;
90
91 fn host_readback_bw(&self) -> f64 {
93 self.memory_bw()
94 }
95
96 fn unified_memory(&self) -> bool {
98 false
99 }
100
101 fn num_threads(&self) -> usize;
103}
104
105pub 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
112pub 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 _ => {
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
161pub 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 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 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
222pub fn fastest_device_for(graph: &Graph) -> Device {
224 fastest_device_for_with_policy(graph, &crate::device_policy::DevicePolicy::default())
225}
226
227pub fn fastest_device_for_with_policy(
229 graph: &Graph,
230 policy: &crate::device_policy::DevicePolicy,
231) -> Device {
232 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 #[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#[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 CostCalibration::ArchDefault
333 }
334 fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64 {
335 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#[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 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 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 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 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 } }
435
436#[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 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 CostCalibration::Uncalibrated
486 }
487 fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64 {
488 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 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#[cfg(feature = "cuda")]
516pub struct CudaCostModel {
517 sgemm_gflops: f64,
518 roundtrip_ns: f64,
519 memory_bw: f64,
520 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 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#[cfg(feature = "rocm")]
583pub struct RocmCostModel {
584 sgemm_gflops: f64,
585 roundtrip_ns: f64,
586 memory_bw: f64,
587 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 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#[cfg(feature = "gpu")]
650pub struct WgpuCostModel {
651 sgemm_gflops: f64,
652 roundtrip_ns: f64,
653 memory_bw: f64,
654 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 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 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 #[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 #[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 #[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 #[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 #[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 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}