1use crate::Device;
28use rlx_ir::{Graph, Node, Op};
29
30pub trait BackendCostModel: Send + Sync {
32 fn device(&self) -> Device;
34
35 fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64;
39
40 fn dispatch_overhead_ns(&self) -> f64;
42
43 fn roundtrip_overhead_ns(&self) -> f64;
46
47 fn memory_bw(&self) -> f64;
49
50 fn host_readback_bw(&self) -> f64 {
52 self.memory_bw()
53 }
54
55 fn unified_memory(&self) -> bool {
57 false
58 }
59
60 fn num_threads(&self) -> usize;
62}
63
64pub 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
71pub 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 _ => {
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
120pub 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
132pub fn fastest_device_for(graph: &Graph) -> Device {
134 fastest_device_for_with_policy(graph, &crate::device_policy::DevicePolicy::default())
135}
136
137pub fn fastest_device_for_with_policy(
139 graph: &Graph,
140 policy: &crate::device_policy::DevicePolicy,
141) -> Device {
142 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#[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 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#[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 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 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 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 } }
326
327#[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 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 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 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#[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#[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#[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}