1use crate::Device;
17use rlx_ir::{Graph, Node, Op};
18
19pub trait BackendCostModel: Send + Sync {
21 fn device(&self) -> Device;
23
24 fn sgemm_gflops(&self, m: usize, k: usize, n: usize) -> f64;
28
29 fn dispatch_overhead_ns(&self) -> f64;
31
32 fn roundtrip_overhead_ns(&self) -> f64;
35
36 fn memory_bw(&self) -> f64;
38
39 fn host_readback_bw(&self) -> f64 {
41 self.memory_bw()
42 }
43
44 fn unified_memory(&self) -> bool {
46 false
47 }
48
49 fn num_threads(&self) -> usize;
51}
52
53pub 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
60pub 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 _ => {
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
109pub 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
121pub fn fastest_device_for(graph: &Graph) -> Device {
123 fastest_device_for_with_policy(graph, &crate::device_policy::DevicePolicy::default())
124}
125
126pub fn fastest_device_for_with_policy(
128 graph: &Graph,
129 policy: &crate::device_policy::DevicePolicy,
130) -> Device {
131 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 #[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#[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 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#[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 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 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 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 } }
317
318#[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 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 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 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#[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#[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#[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}