1use crate::judge::{Criterion, DispatchOptions, DispatchTable, Pick};
38use crate::judge::{Judge, OpKind, ProfileEntry, ProfileReport, SizeClass};
39use fuel_graph::{Graph, NodeId, Op};
40use fuel_hardware::probe::{HardwareChange, ProbeReport};
41use fuel_hardware::transfer_cost::BandwidthMatrix;
42use fuel_ir::probe::BackendId;
43use fuel_ir::{DType, DeviceLocation, Result};
44use std::collections::HashMap;
45use std::path::PathBuf;
46
47#[derive(Default)]
52pub struct ScheduleOptions {
53 pub probe_path: Option<PathBuf>,
55 pub profile_path: Option<PathBuf>,
57 pub bandwidth_path: Option<PathBuf>,
60 pub force_rejudge: bool,
65 pub force_remeasure_bandwidth: bool,
68 pub judge: Judge,
70 pub dispatch: DispatchOptions,
72}
73
74pub fn prepare_dispatch_table(opts: ScheduleOptions) -> Result<(DispatchTable, ProfileReport)> {
80 let probe_path = opts
81 .probe_path
82 .clone()
83 .or_else(fuel_hardware::probe::default_report_path);
84 let profile_path = opts
85 .profile_path
86 .clone()
87 .or_else(crate::judge::default_report_path);
88
89 let current_probe = ProbeReport::probe_all();
90
91 let mut reuse_profile = false;
93 if !opts.force_rejudge
94 && let Some(pp) = probe_path.as_ref()
95 && let Ok(Some(prior)) = ProbeReport::load(pp)
96 && matches!(current_probe.diff(&prior), HardwareChange::Unchanged)
97 {
98 reuse_profile = true;
99 }
100
101 let profile = if reuse_profile {
102 if let Some(pp) = profile_path.as_ref() {
103 let current: Vec<_> = current_probe.equivalence_classes().into_keys().collect();
104 match ProfileReport::load(pp, ¤t)? {
105 Some(r) => r,
106 None => run_and_persist(¤t_probe, &opts, &probe_path, &profile_path)?,
107 }
108 } else {
109 run_and_persist(¤t_probe, &opts, &probe_path, &profile_path)?
110 }
111 } else {
112 run_and_persist(¤t_probe, &opts, &probe_path, &profile_path)?
113 };
114
115 let table = DispatchTable::build_with(&profile, opts.dispatch);
116 Ok((table, profile))
117}
118
119fn op_to_kind(op: &Op) -> Option<OpKind> {
135 match op {
136 Op::MatMul => Some(OpKind::MatMul),
137 Op::Add => Some(OpKind::AddElementwise),
138 _ => None,
139 }
140}
141
142fn pick_to_location(pick: Pick) -> Option<DeviceLocation> {
144 match pick.backend {
145 BackendId::Cpu => Some(DeviceLocation::Cpu),
146 BackendId::Cuda => Some(DeviceLocation::Cuda {
147 gpu_id: pick.device_index as usize,
148 }),
149 BackendId::Vulkan => Some(DeviceLocation::Vulkan {
150 gpu_id: pick.device_index as usize,
151 }),
152 BackendId::Metal => Some(DeviceLocation::Metal {
153 gpu_id: pick.device_index as usize,
154 }),
155 _ => Some(DeviceLocation::Cpu),
158 }
159}
160
161pub fn recommend_placement(
188 graph: &Graph,
189 table: &DispatchTable,
190 criterion: Criterion,
191 fallback_device: DeviceLocation,
192) -> HashMap<NodeId, DeviceLocation> {
193 let mut plan = HashMap::with_capacity(graph.len());
194 for i in 0..graph.len() {
195 let id = NodeId(i);
196 let node = graph.node(id);
197 let device = recommend_for_node(node, table, criterion).unwrap_or(fallback_device);
198 plan.insert(id, device);
199 }
200 plan
201}
202
203pub fn recommend_for_node(
221 node: &fuel_graph::Node,
222 table: &DispatchTable,
223 criterion: Criterion,
224) -> Option<DeviceLocation> {
225 let kind = op_to_kind(&node.op)?;
226 if node.dtype != DType::F32 {
227 return None;
229 }
230 let size_class = SizeClass::from_elem_count(node.shape.elem_count());
231 let pick = table.pick_nearest(kind, node.dtype, size_class, criterion)?;
232 pick_to_location(pick)
233}
234
235pub fn apply_placement_plan(
246 graph: &fuel_graph::SharedGraph,
247 plan: &HashMap<NodeId, DeviceLocation>,
248) {
249 let mut g = graph.write().unwrap();
250 for (&id, &loc) in plan {
251 if g.placement(id).is_none() {
252 g.set_placement(id, loc);
253 }
254 }
255}
256
257pub fn force_apply_placement_plan(
262 graph: &fuel_graph::SharedGraph,
263 plan: &HashMap<NodeId, DeviceLocation>,
264) {
265 let mut g = graph.write().unwrap();
266 for (&id, &loc) in plan {
267 g.set_placement(id, loc);
268 }
269}
270
271pub fn auto_place_and_route(
282 graph: &fuel_graph::SharedGraph,
283 roots: &[NodeId],
284 table: &DispatchTable,
285 criterion: Criterion,
286 fallback_device: DeviceLocation,
287) -> Vec<NodeId> {
288 let plan = {
289 let g = graph.read().unwrap();
290 recommend_placement(&g, table, criterion, fallback_device)
291 };
292 apply_placement_plan(graph, &plan);
293 fuel_graph::opt::insert_copies(graph, roots)
294}
295
296fn default_bandwidth_path() -> Option<PathBuf> {
299 fuel_hardware::probe::default_report_path().and_then(|p| {
300 p.parent()
301 .map(|parent| parent.join(fuel_hardware::transfer_cost::BANDWIDTH_REPORT_FILENAME))
302 })
303}
304
305pub fn prepare_dp_inputs(
314 opts: ScheduleOptions,
315) -> Result<(ProbeReport, ProfileReport, BandwidthMatrix)> {
316 let probe_path = opts
317 .probe_path
318 .clone()
319 .or_else(fuel_hardware::probe::default_report_path);
320 let profile_path = opts
321 .profile_path
322 .clone()
323 .or_else(crate::judge::default_report_path);
324 let bandwidth_path = opts.bandwidth_path.clone().or_else(default_bandwidth_path);
325
326 let current_probe = ProbeReport::probe_all();
327
328 let mut hardware_unchanged = false;
331 if let Some(pp) = probe_path.as_ref()
332 && let Ok(Some(prior)) = ProbeReport::load(pp)
333 && matches!(current_probe.diff(&prior), HardwareChange::Unchanged)
334 {
335 hardware_unchanged = true;
336 }
337
338 let profile = if hardware_unchanged && !opts.force_rejudge {
340 if let Some(pp) = profile_path.as_ref() {
341 let current: Vec<_> = current_probe.equivalence_classes().into_keys().collect();
342 ProfileReport::load(pp, ¤t)?.unwrap_or_else(|| opts.judge.run(¤t_probe))
343 } else {
344 opts.judge.run(¤t_probe)
345 }
346 } else {
347 opts.judge.run(¤t_probe)
348 };
349
350 let bandwidth = if hardware_unchanged && !opts.force_remeasure_bandwidth {
352 if let Some(bp) = bandwidth_path.as_ref() {
353 BandwidthMatrix::load(bp)?.unwrap_or_else(|| BandwidthMatrix::measure(¤t_probe))
354 } else {
355 BandwidthMatrix::measure(¤t_probe)
356 }
357 } else {
358 BandwidthMatrix::measure(¤t_probe)
359 };
360
361 if let Some(p) = probe_path.as_ref() {
363 if let Some(parent) = p.parent() {
364 let _ = std::fs::create_dir_all(parent);
365 }
366 if let Err(e) = current_probe.save(p) {
367 eprintln!("fuel scheduling: failed to persist probe report to {p:?}: {e}");
368 }
369 }
370 if let Some(p) = profile_path.as_ref() {
371 if let Some(parent) = p.parent() {
372 let _ = std::fs::create_dir_all(parent);
373 }
374 if let Err(e) = profile.save(p) {
375 eprintln!("fuel scheduling: failed to persist profile report to {p:?}: {e}");
376 }
377 }
378 if let Some(p) = bandwidth_path.as_ref() {
379 if let Some(parent) = p.parent() {
380 let _ = std::fs::create_dir_all(parent);
381 }
382 if let Err(e) = bandwidth.save(p) {
383 eprintln!("fuel scheduling: failed to persist bandwidth report to {p:?}: {e}");
384 }
385 }
386
387 Ok((current_probe, profile, bandwidth))
388}
389
390pub fn auto_place_and_route_with_transfer_cost(
402 graph: &fuel_graph::SharedGraph,
403 roots: &[NodeId],
404 opts: ScheduleOptions,
405 fallback_device: DeviceLocation,
406) -> Result<Vec<NodeId>> {
407 let (probe, profile, bandwidth) = prepare_dp_inputs(opts)?;
408 let mut backends_seen: std::collections::HashSet<BackendId> = Default::default();
409 let mut available_backends: Vec<BackendId> = Vec::new();
410 for d in &probe.devices {
411 if backends_seen.insert(d.backend) {
412 available_backends.push(d.backend);
413 }
414 }
415 let plan = {
416 let g = graph.read().unwrap();
417 dp_plan(
418 &g,
419 roots,
420 &profile,
421 &bandwidth,
422 &available_backends,
423 fallback_device,
424 )
425 };
426 apply_placement_plan(graph, &plan);
427 Ok(fuel_graph::opt::insert_copies(graph, roots))
428}
429
430pub fn dp_plan(
457 graph: &fuel_graph::Graph,
458 roots: &[NodeId],
459 profile: &ProfileReport,
460 bandwidth: &fuel_hardware::transfer_cost::BandwidthMatrix,
461 available_backends: &[BackendId],
462 fallback_device: DeviceLocation,
463) -> HashMap<NodeId, DeviceLocation> {
464 use fuel_graph::topo_order_multi;
465 let order = topo_order_multi(graph, roots);
466
467 let mut cost: HashMap<(NodeId, BackendId), (f64, Vec<BackendId>)> = HashMap::new();
469
470 let backends = if available_backends.is_empty() {
471 vec![BackendId::Cpu]
475 } else {
476 available_backends.to_vec()
477 };
478
479 for &id in &order {
480 let node = graph.node(id);
481 let kind = op_to_kind(&node.op);
482 let size_class = SizeClass::from_elem_count(node.shape.elem_count());
483 let dtype = node.dtype;
484 let is_const = matches!(node.op, fuel_graph::Op::Const);
488
489 for &b in &backends {
490 if is_const && b != BackendId::Cpu {
494 cost.insert((id, b), (f64::INFINITY, vec![]));
495 continue;
496 }
497 let compute = if let Some(k) = kind {
505 if dtype == DType::F32 {
506 profile_lookup_latency_ns(profile, k, dtype, size_class, b)
507 .unwrap_or(UNPROFILED_BACKEND_PENALTY_NS)
508 } else {
509 UNPROFILED_BACKEND_PENALTY_NS
510 }
511 } else {
512 UNPROFILED_OP_COMPUTE_NS
516 };
517
518 let mut total_cost = compute;
520 let mut input_choices = Vec::with_capacity(node.inputs.len());
521 for &input in &node.inputs {
522 let input_node = graph.node(input);
523 let bytes = input_node.shape.elem_count() * dtype_size_bytes(input_node.dtype);
524 let mut best = f64::INFINITY;
525 let mut best_b = backends[0];
526 for &b_i in &backends {
527 let prior = cost
528 .get(&(input, b_i))
529 .map(|(c, _)| *c)
530 .unwrap_or(f64::INFINITY);
531 if !prior.is_finite() {
532 continue;
533 }
534 let xfer = transfer_cost_with_cpu_fallback(bandwidth, b_i, b, bytes);
535 let candidate = prior + xfer;
536 if candidate < best {
537 best = candidate;
538 best_b = b_i;
539 }
540 }
541 if best.is_finite() {
542 total_cost += best;
543 input_choices.push(best_b);
544 } else {
545 total_cost = f64::INFINITY;
549 break;
550 }
551 }
552 cost.insert((id, b), (total_cost, input_choices));
553 }
554 }
555
556 let mut placement: HashMap<NodeId, BackendId> = HashMap::new();
558 for &root in roots {
559 let mut best = f64::INFINITY;
560 let mut best_b = backends[0];
561 for &b in &backends {
562 if let Some(&(c, _)) = cost.get(&(root, b)).iter().next().copied()
563 && c < best
564 {
565 best = c;
566 best_b = b;
567 }
568 }
569 placement.insert(root, best_b);
570 }
571
572 let mut stack: Vec<NodeId> = roots.to_vec();
574 while let Some(id) = stack.pop() {
575 let chosen_b = match placement.get(&id) {
576 Some(&b) => b,
577 None => continue,
578 };
579 if let Some((_, inputs_chosen)) = cost.get(&(id, chosen_b)).cloned() {
580 let node = graph.node(id);
581 for (idx, &input) in node.inputs.iter().enumerate() {
582 if let Some(&b_i) = inputs_chosen.get(idx)
583 && let std::collections::hash_map::Entry::Vacant(e) = placement.entry(input)
584 {
585 e.insert(b_i);
586 stack.push(input);
587 }
588 }
589 }
590 }
591
592 placement
595 .into_iter()
596 .map(|(id, b)| {
597 let pick = crate::judge::Pick {
598 backend: b,
599 device_index: 0,
600 kernel_source: "",
601 kernel_revision_hash: 0,
602 };
603 (id, pick_to_location(pick).unwrap_or(fallback_device))
604 })
605 .collect()
606}
607
608fn transfer_cost_with_cpu_fallback(
612 bandwidth: &fuel_hardware::transfer_cost::BandwidthMatrix,
613 src: BackendId,
614 dst: BackendId,
615 bytes: usize,
616) -> f64 {
617 if src == dst {
618 return 0.0;
619 }
620 if let Some(t) = bandwidth.lookup(src, dst) {
621 return t.ns_per_byte * bytes as f64;
622 }
623 let leg1 = bandwidth.lookup(src, BackendId::Cpu).map(|t| t.ns_per_byte);
625 let leg2 = bandwidth.lookup(BackendId::Cpu, dst).map(|t| t.ns_per_byte);
626 match (leg1, leg2) {
627 (Some(a), Some(b)) => (a + b) * bytes as f64,
628 _ => f64::INFINITY, }
630}
631
632fn dtype_size_bytes(d: DType) -> usize {
633 match d {
634 DType::F32 | DType::U32 | DType::I32 => 4,
635 DType::F64 | DType::I64 => 8,
636 DType::F16 | DType::BF16 | DType::I16 => 2,
637 DType::U8 | DType::I8 | DType::F8E4M3 | DType::F8E5M2 | DType::F8E8M0 | DType::F8E6M2 => 1,
638 DType::Bool => 1, DType::F4 => 1, DType::F6E2M3 | DType::F6E3M2 => 1,
641 }
642}
643
644fn profile_lookup_latency_ns(
651 profile: &ProfileReport,
652 op: OpKind,
653 dtype: DType,
654 size_class: SizeClass,
655 backend: BackendId,
656) -> Option<f64> {
657 if let Some(e) = profile.entries.iter().find(|e| {
659 e.op == op && e.dtype == dtype && e.size_class == size_class && e.backend == backend
660 }) {
661 return Some(e.latency_ns as f64);
662 }
663 let candidates: Vec<&ProfileEntry> = profile
665 .entries
666 .iter()
667 .filter(|e| e.op == op && e.dtype == dtype && e.backend == backend)
668 .collect();
669 if candidates.is_empty() {
670 return None;
671 }
672 let target = size_class.0 as i32;
673 let nearest = candidates
677 .iter()
678 .min_by_key(|e| (e.size_class.0 as i32 - target).abs())?;
679 Some(nearest.latency_ns as f64)
680}
681
682const UNPROFILED_BACKEND_PENALTY_NS: f64 = 1_000_000_000.0; const UNPROFILED_OP_COMPUTE_NS: f64 = 1_000.0;
693
694fn run_and_persist(
695 probe: &ProbeReport,
696 opts: &ScheduleOptions,
697 probe_path: &Option<PathBuf>,
698 profile_path: &Option<PathBuf>,
699) -> Result<ProfileReport> {
700 let profile = opts.judge.run(probe);
701
702 if let Some(p) = probe_path {
706 if let Some(parent) = p.parent() {
707 let _ = std::fs::create_dir_all(parent);
708 }
709 if let Err(e) = probe.save(p) {
710 eprintln!("fuel scheduling: failed to persist probe report to {p:?}: {e}");
711 }
712 }
713 if let Some(p) = profile_path {
714 if let Some(parent) = p.parent() {
715 let _ = std::fs::create_dir_all(parent);
716 }
717 if let Err(e) = profile.save(p) {
718 eprintln!("fuel scheduling: failed to persist profile report to {p:?}: {e}");
719 }
720 }
721
722 Ok(profile)
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use crate::judge::test_equiv_key;
729 use crate::judge::{OpKind, OpSize};
730
731 #[test]
735 fn end_to_end_probe_judge_dispatch() {
736 let scratch =
737 std::env::temp_dir().join(format!("fuel-schedule-test-{}", std::process::id()));
738 let _ = std::fs::create_dir_all(&scratch);
739
740 let opts = ScheduleOptions {
741 probe_path: Some(scratch.join("probe.json")),
742 profile_path: Some(scratch.join("judge.json")),
743 bandwidth_path: Some(scratch.join("bandwidth.json")),
744 force_rejudge: true,
745 force_remeasure_bandwidth: true,
746 judge: Judge {
747 iterations: 3,
748 warmup: 1,
749 size_plan_override: Some(vec![(
750 OpKind::MatMul,
751 OpSize::MatMul {
752 m: 32,
753 n: 32,
754 k: 32,
755 },
756 )]),
757 fixtures: None,
758 },
759 dispatch: Default::default(),
760 };
761
762 let (table, profile) = prepare_dispatch_table(opts).expect("prepare");
763 assert!(
767 profile.entries.iter().any(|e| e.backend == BackendId::Cpu),
768 "profile should have at least one cpu entry, got {:?}",
769 profile.entries
770 );
771 assert!(
772 !table.is_empty(),
773 "dispatch table should have at least one entry"
774 );
775
776 assert!(scratch.join("probe.json").exists());
778 assert!(scratch.join("judge.json").exists());
779
780 let _ = std::fs::remove_dir_all(&scratch);
781 }
782
783 #[test]
789 fn reuses_profile_when_hardware_unchanged() {
790 let scratch =
791 std::env::temp_dir().join(format!("fuel-schedule-reuse-{}", std::process::id()));
792 let _ = std::fs::create_dir_all(&scratch);
793
794 let tiny_judge = || Judge {
795 iterations: 3,
796 warmup: 1,
797 size_plan_override: Some(vec![(
798 OpKind::MatMul,
799 OpSize::MatMul {
800 m: 16,
801 n: 16,
802 k: 16,
803 },
804 )]),
805 fixtures: None,
806 };
807
808 let first = prepare_dispatch_table(ScheduleOptions {
809 probe_path: Some(scratch.join("probe.json")),
810 profile_path: Some(scratch.join("judge.json")),
811 bandwidth_path: Some(scratch.join("bandwidth.json")),
812 force_rejudge: true,
813 force_remeasure_bandwidth: true,
814 judge: tiny_judge(),
815 dispatch: Default::default(),
816 })
817 .expect("first run");
818
819 let second = prepare_dispatch_table(ScheduleOptions {
820 probe_path: Some(scratch.join("probe.json")),
821 profile_path: Some(scratch.join("judge.json")),
822 bandwidth_path: Some(scratch.join("bandwidth.json")),
823 force_rejudge: false, force_remeasure_bandwidth: false,
825 judge: tiny_judge(),
826 dispatch: Default::default(),
827 })
828 .expect("second run");
829
830 assert_eq!(
834 first.1, second.1,
835 "hardware unchanged + no force_rejudge should yield identical profile"
836 );
837
838 let _ = std::fs::remove_dir_all(&scratch);
839 }
840
841 use crate::judge::Criterion;
842 use crate::judge::{PROFILE_REPORT_VERSION, ProfileEntry, ProfileReport};
843 use fuel_graph::NodeHandle;
844 use fuel_ir::Shape;
845 use std::sync::Arc;
846
847 #[test]
853 fn recommend_placement_routes_per_node() {
854 let mk = |backend: BackendId, size: u32, latency: u64| ProfileEntry {
856 op: OpKind::MatMul,
857 dtype: DType::F32,
858 size_class: SizeClass(size),
859 backend,
860 device_index: 0,
861 latency_ns: latency,
862 iterations: 1,
863 max_rel_error: 0.0,
864 kernel_source: String::new(),
865 kernel_revision_hash: 0,
866 device: test_equiv_key(backend),
867 };
868 let report = ProfileReport {
869 version: PROFILE_REPORT_VERSION,
870 entries: vec![
871 mk(BackendId::Cpu, 12, 10_000),
873 mk(BackendId::Cuda, 12, 2_000_000),
874 mk(BackendId::Cpu, 20, 50_000_000),
876 mk(BackendId::Cuda, 20, 5_000_000),
877 ],
878 };
879 let table = DispatchTable::build(&report);
880
881 let small_a = NodeHandle::from_f32(
884 vec![0.0_f32; 64 * 64],
885 Shape::from_dims(&[64, 64]),
886 crate::Device::cpu().as_dyn(),
887 )
888 .unwrap();
889 let small_b = small_a
890 .const_f32_like(
891 Arc::<[f32]>::from(vec![0.0_f32; 64 * 64]),
892 Shape::from_dims(&[64, 64]),
893 )
894 .unwrap();
895 let small_mm = small_a.matmul(&small_b); let big_a = small_a
899 .const_f32_like(
900 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
901 Shape::from_dims(&[1024, 1024]),
902 )
903 .unwrap();
904 let big_b = small_a
905 .const_f32_like(
906 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
907 Shape::from_dims(&[1024, 1024]),
908 )
909 .unwrap();
910 let big_mm = big_a.matmul(&big_b); let unprofiled = small_mm.sub(&small_b);
914
915 let graph = small_a.graph().read().unwrap();
919
920 let plan = recommend_placement(&graph, &table, Criterion::Fastest, DeviceLocation::Cpu);
921
922 assert_eq!(plan[&small_mm.id()], DeviceLocation::Cpu);
924 assert_eq!(plan[&big_mm.id()], DeviceLocation::Cuda { gpu_id: 0 });
926 assert_eq!(plan[&unprofiled.id()], DeviceLocation::Cpu);
928 assert!(matches!(plan[&small_a.id()], DeviceLocation::Cpu));
930 }
931
932 #[test]
936 fn apply_placement_plan_skips_pre_set_hints() {
937 let entries = vec![
938 ProfileEntry {
939 op: OpKind::MatMul,
940 dtype: DType::F32,
941 size_class: SizeClass(20),
942 backend: BackendId::Cpu,
943 device_index: 0,
944 latency_ns: 50_000_000,
945 iterations: 1,
946 max_rel_error: 0.0,
947 kernel_source: String::new(),
948 kernel_revision_hash: 0,
949 device: test_equiv_key(BackendId::Cpu),
950 },
951 ProfileEntry {
952 op: OpKind::MatMul,
953 dtype: DType::F32,
954 size_class: SizeClass(20),
955 backend: BackendId::Cuda,
956 device_index: 0,
957 latency_ns: 5_000_000,
958 iterations: 1,
959 max_rel_error: 0.0,
960 kernel_source: String::new(),
961 kernel_revision_hash: 0,
962 device: test_equiv_key(BackendId::Cuda),
963 },
964 ];
965 let report = ProfileReport {
966 version: PROFILE_REPORT_VERSION,
967 entries,
968 };
969 let table = DispatchTable::build(&report);
970
971 let a = NodeHandle::from_f32(
972 vec![0.0_f32; 1024 * 1024],
973 Shape::from_dims(&[1024, 1024]),
974 crate::Device::cpu().as_dyn(),
975 )
976 .unwrap();
977 let b = a
978 .const_f32_like(
979 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
980 Shape::from_dims(&[1024, 1024]),
981 )
982 .unwrap();
983 let mm = a.matmul(&b); a.graph()
987 .write()
988 .unwrap()
989 .set_placement(mm.id(), DeviceLocation::Cpu);
990
991 let plan = recommend_placement(
992 &a.graph().read().unwrap(),
993 &table,
994 Criterion::Fastest,
995 DeviceLocation::Cpu,
996 );
997 assert_eq!(plan[&mm.id()], DeviceLocation::Cuda { gpu_id: 0 });
999 apply_placement_plan(a.graph(), &plan);
1000 assert_eq!(
1001 a.graph().read().unwrap().placement(mm.id()),
1002 Some(DeviceLocation::Cpu),
1003 "user's explicit hint must not be overwritten by recommend_placement",
1004 );
1005 }
1006
1007 #[test]
1012 fn end_to_end_dp_orchestrator() {
1013 let scratch =
1014 std::env::temp_dir().join(format!("fuel-dp-orch-test-{}", std::process::id()));
1015 let _ = std::fs::create_dir_all(&scratch);
1016
1017 let a = NodeHandle::from_f32(
1019 vec![0.0_f32; 64 * 64],
1020 Shape::from_dims(&[64, 64]),
1021 crate::Device::cpu().as_dyn(),
1022 )
1023 .unwrap();
1024 let b = a
1025 .const_f32_like(
1026 Arc::<[f32]>::from(vec![0.0_f32; 64 * 64]),
1027 Shape::from_dims(&[64, 64]),
1028 )
1029 .unwrap();
1030 let mm = a.matmul(&b);
1031
1032 let opts = ScheduleOptions {
1033 probe_path: Some(scratch.join("probe.json")),
1034 profile_path: Some(scratch.join("judge.json")),
1035 bandwidth_path: Some(scratch.join("bandwidth.json")),
1036 force_rejudge: true,
1037 force_remeasure_bandwidth: true,
1038 judge: Judge {
1039 iterations: 3,
1040 warmup: 1,
1041 size_plan_override: Some(vec![(
1042 OpKind::MatMul,
1043 OpSize::MatMul {
1044 m: 32,
1045 n: 32,
1046 k: 32,
1047 },
1048 )]),
1049 fixtures: None,
1050 },
1051 dispatch: Default::default(),
1052 };
1053
1054 let new_roots = auto_place_and_route_with_transfer_cost(
1055 a.graph(),
1056 &[mm.id()],
1057 opts,
1058 DeviceLocation::Cpu,
1059 )
1060 .expect("orchestrator");
1061
1062 assert!(!new_roots.is_empty());
1065 assert!(scratch.join("probe.json").exists());
1066 assert!(scratch.join("judge.json").exists());
1067 assert!(scratch.join("bandwidth.json").exists());
1068
1069 let _ = std::fs::remove_dir_all(&scratch);
1070 }
1071
1072 #[test]
1078 fn dp_plan_avoids_costly_transfers() {
1079 use fuel_hardware::transfer_cost::{
1080 BANDWIDTH_REPORT_VERSION, BandwidthMatrix, TransferCost,
1081 };
1082 let entries = vec![
1087 ProfileEntry {
1088 op: OpKind::MatMul,
1089 dtype: DType::F32,
1090 size_class: SizeClass(12),
1091 backend: BackendId::Cpu,
1092 device_index: 0,
1093 latency_ns: 10_000,
1094 iterations: 1,
1095 max_rel_error: 0.0,
1096 kernel_source: String::new(),
1097 kernel_revision_hash: 0,
1098 device: test_equiv_key(BackendId::Cpu),
1099 },
1100 ProfileEntry {
1101 op: OpKind::MatMul,
1102 dtype: DType::F32,
1103 size_class: SizeClass(12),
1104 backend: BackendId::Cuda,
1105 device_index: 0,
1106 latency_ns: 5_000,
1107 iterations: 1,
1108 max_rel_error: 0.0,
1109 kernel_source: String::new(),
1110 kernel_revision_hash: 0,
1111 device: test_equiv_key(BackendId::Cuda),
1112 },
1113 ];
1114 let report = ProfileReport {
1115 version: PROFILE_REPORT_VERSION,
1116 entries,
1117 };
1118 let _table = DispatchTable::build(&report);
1119
1120 let bandwidth = BandwidthMatrix {
1125 version: BANDWIDTH_REPORT_VERSION,
1126 measurement_bytes: 1 << 24,
1127 entries: vec![
1128 TransferCost {
1129 src: BackendId::Cpu,
1130 dst: BackendId::Cpu,
1131 ns_per_byte: 0.05,
1132 },
1133 TransferCost {
1134 src: BackendId::Cpu,
1135 dst: BackendId::Cuda,
1136 ns_per_byte: 100.0,
1137 },
1138 TransferCost {
1139 src: BackendId::Cuda,
1140 dst: BackendId::Cpu,
1141 ns_per_byte: 100.0,
1142 },
1143 ],
1144 };
1145
1146 let small_a = NodeHandle::from_f32(
1147 vec![0.0_f32; 64 * 64],
1148 Shape::from_dims(&[64, 64]),
1149 crate::Device::cpu().as_dyn(),
1150 )
1151 .unwrap();
1152 let small_b = small_a
1153 .const_f32_like(
1154 Arc::<[f32]>::from(vec![0.0_f32; 64 * 64]),
1155 Shape::from_dims(&[64, 64]),
1156 )
1157 .unwrap();
1158 let mm = small_a.matmul(&small_b);
1159
1160 let plan = dp_plan(
1161 &small_a.graph().read().unwrap(),
1162 &[mm.id()],
1163 &report,
1164 &bandwidth,
1165 &[BackendId::Cpu, BackendId::Cuda],
1166 DeviceLocation::Cpu,
1167 );
1168
1169 assert_eq!(
1173 plan[&mm.id()],
1174 DeviceLocation::Cpu,
1175 "DP planner should pick CPU when transfer cost dominates"
1176 );
1177 }
1178
1179 #[test]
1183 fn dp_plan_picks_dispatch_winner_when_transfer_is_cheap() {
1184 use fuel_hardware::transfer_cost::{
1185 BANDWIDTH_REPORT_VERSION, BandwidthMatrix, TransferCost,
1186 };
1187 let entries = vec![
1188 ProfileEntry {
1189 op: OpKind::MatMul,
1190 dtype: DType::F32,
1191 size_class: SizeClass(20),
1192 backend: BackendId::Cpu,
1193 device_index: 0,
1194 latency_ns: 50_000_000,
1195 iterations: 1,
1196 max_rel_error: 0.0,
1197 kernel_source: String::new(),
1198 kernel_revision_hash: 0,
1199 device: test_equiv_key(BackendId::Cpu),
1200 },
1201 ProfileEntry {
1202 op: OpKind::MatMul,
1203 dtype: DType::F32,
1204 size_class: SizeClass(20),
1205 backend: BackendId::Cuda,
1206 device_index: 0,
1207 latency_ns: 5_000_000,
1208 iterations: 1,
1209 max_rel_error: 0.0,
1210 kernel_source: String::new(),
1211 kernel_revision_hash: 0,
1212 device: test_equiv_key(BackendId::Cuda),
1213 },
1214 ];
1215 let report = ProfileReport {
1216 version: PROFILE_REPORT_VERSION,
1217 entries,
1218 };
1219 let _table = DispatchTable::build(&report);
1220 let bandwidth = BandwidthMatrix {
1222 version: BANDWIDTH_REPORT_VERSION,
1223 measurement_bytes: 1 << 24,
1224 entries: vec![
1225 TransferCost {
1226 src: BackendId::Cpu,
1227 dst: BackendId::Cpu,
1228 ns_per_byte: 0.05,
1229 },
1230 TransferCost {
1231 src: BackendId::Cpu,
1232 dst: BackendId::Cuda,
1233 ns_per_byte: 0.15,
1234 },
1235 TransferCost {
1236 src: BackendId::Cuda,
1237 dst: BackendId::Cpu,
1238 ns_per_byte: 0.20,
1239 },
1240 ],
1241 };
1242
1243 let big_a = NodeHandle::from_f32(
1244 vec![0.0_f32; 1024 * 1024],
1245 Shape::from_dims(&[1024, 1024]),
1246 crate::Device::cpu().as_dyn(),
1247 )
1248 .unwrap();
1249 let big_b = big_a
1250 .const_f32_like(
1251 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
1252 Shape::from_dims(&[1024, 1024]),
1253 )
1254 .unwrap();
1255 let mm = big_a.matmul(&big_b);
1256
1257 let plan = dp_plan(
1258 &big_a.graph().read().unwrap(),
1259 &[mm.id()],
1260 &report,
1261 &bandwidth,
1262 &[BackendId::Cpu, BackendId::Cuda],
1263 DeviceLocation::Cpu,
1264 );
1265
1266 assert_eq!(plan[&mm.id()], DeviceLocation::Cuda { gpu_id: 0 });
1269 }
1270
1271 #[test]
1274 fn auto_place_and_route_inserts_copies_for_split_graph() {
1275 let entries = vec![
1276 ProfileEntry {
1277 op: OpKind::MatMul,
1278 dtype: DType::F32,
1279 size_class: SizeClass(12),
1280 backend: BackendId::Cpu,
1281 device_index: 0,
1282 latency_ns: 10_000,
1283 iterations: 1,
1284 max_rel_error: 0.0,
1285 kernel_source: String::new(),
1286 kernel_revision_hash: 0,
1287 device: test_equiv_key(BackendId::Cpu),
1288 },
1289 ProfileEntry {
1290 op: OpKind::MatMul,
1291 dtype: DType::F32,
1292 size_class: SizeClass(12),
1293 backend: BackendId::Cuda,
1294 device_index: 0,
1295 latency_ns: 2_000_000,
1296 iterations: 1,
1297 max_rel_error: 0.0,
1298 kernel_source: String::new(),
1299 kernel_revision_hash: 0,
1300 device: test_equiv_key(BackendId::Cuda),
1301 },
1302 ProfileEntry {
1303 op: OpKind::MatMul,
1304 dtype: DType::F32,
1305 size_class: SizeClass(20),
1306 backend: BackendId::Cpu,
1307 device_index: 0,
1308 latency_ns: 50_000_000,
1309 iterations: 1,
1310 max_rel_error: 0.0,
1311 kernel_source: String::new(),
1312 kernel_revision_hash: 0,
1313 device: test_equiv_key(BackendId::Cpu),
1314 },
1315 ProfileEntry {
1316 op: OpKind::MatMul,
1317 dtype: DType::F32,
1318 size_class: SizeClass(20),
1319 backend: BackendId::Cuda,
1320 device_index: 0,
1321 latency_ns: 5_000_000,
1322 iterations: 1,
1323 max_rel_error: 0.0,
1324 kernel_source: String::new(),
1325 kernel_revision_hash: 0,
1326 device: test_equiv_key(BackendId::Cuda),
1327 },
1328 ];
1329 let report = ProfileReport {
1330 version: PROFILE_REPORT_VERSION,
1331 entries,
1332 };
1333 let table = DispatchTable::build(&report);
1334
1335 let small_a = NodeHandle::from_f32(
1336 vec![0.0_f32; 64 * 64],
1337 Shape::from_dims(&[64, 64]),
1338 crate::Device::cpu().as_dyn(),
1339 )
1340 .unwrap();
1341 let small_b = small_a
1342 .const_f32_like(
1343 Arc::<[f32]>::from(vec![0.0_f32; 64 * 64]),
1344 Shape::from_dims(&[64, 64]),
1345 )
1346 .unwrap();
1347 let small_mm = small_a.matmul(&small_b); let big_a = small_a
1350 .const_f32_like(
1351 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
1352 Shape::from_dims(&[1024, 1024]),
1353 )
1354 .unwrap();
1355 let big_b = small_a
1356 .const_f32_like(
1357 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
1358 Shape::from_dims(&[1024, 1024]),
1359 )
1360 .unwrap();
1361 let big_mm = big_a.matmul(&big_b); let n_before = small_a.graph().read().unwrap().len();
1364 let _new_roots = auto_place_and_route(
1365 small_a.graph(),
1366 &[small_mm.id(), big_mm.id()],
1367 &table,
1368 Criterion::Fastest,
1369 DeviceLocation::Cpu,
1370 );
1371 let n_after = small_a.graph().read().unwrap().len();
1372
1373 let g = small_a.graph().read().unwrap();
1377 let mut cuda_copies = 0;
1378 for i in n_before..n_after {
1379 if let fuel_graph::Op::Copy { target } = &g.node(NodeId(i)).op
1380 && matches!(target, DeviceLocation::Cuda { .. })
1381 {
1382 cuda_copies += 1;
1383 }
1384 }
1385 assert!(
1386 cuda_copies >= 2,
1387 "auto_place_and_route should have inserted ≥2 Copy(_, Cuda) nodes; got {cuda_copies}"
1388 );
1389 }
1390
1391 #[test]
1395 fn apply_then_insert_copies_emits_copies_at_device_boundaries() {
1396 let entries = vec![
1398 ProfileEntry {
1399 op: OpKind::MatMul,
1400 dtype: DType::F32,
1401 size_class: SizeClass(12),
1402 backend: BackendId::Cpu,
1403 device_index: 0,
1404 latency_ns: 10_000,
1405 iterations: 1,
1406 max_rel_error: 0.0,
1407 kernel_source: String::new(),
1408 kernel_revision_hash: 0,
1409 device: test_equiv_key(BackendId::Cpu),
1410 },
1411 ProfileEntry {
1412 op: OpKind::MatMul,
1413 dtype: DType::F32,
1414 size_class: SizeClass(12),
1415 backend: BackendId::Cuda,
1416 device_index: 0,
1417 latency_ns: 2_000_000,
1418 iterations: 1,
1419 max_rel_error: 0.0,
1420 kernel_source: String::new(),
1421 kernel_revision_hash: 0,
1422 device: test_equiv_key(BackendId::Cuda),
1423 },
1424 ProfileEntry {
1425 op: OpKind::MatMul,
1426 dtype: DType::F32,
1427 size_class: SizeClass(20),
1428 backend: BackendId::Cpu,
1429 device_index: 0,
1430 latency_ns: 50_000_000,
1431 iterations: 1,
1432 max_rel_error: 0.0,
1433 kernel_source: String::new(),
1434 kernel_revision_hash: 0,
1435 device: test_equiv_key(BackendId::Cpu),
1436 },
1437 ProfileEntry {
1438 op: OpKind::MatMul,
1439 dtype: DType::F32,
1440 size_class: SizeClass(20),
1441 backend: BackendId::Cuda,
1442 device_index: 0,
1443 latency_ns: 5_000_000,
1444 iterations: 1,
1445 max_rel_error: 0.0,
1446 kernel_source: String::new(),
1447 kernel_revision_hash: 0,
1448 device: test_equiv_key(BackendId::Cuda),
1449 },
1450 ];
1451 let report = ProfileReport {
1452 version: PROFILE_REPORT_VERSION,
1453 entries,
1454 };
1455 let table = DispatchTable::build(&report);
1456
1457 let small_a = NodeHandle::from_f32(
1459 vec![0.0_f32; 64 * 64],
1460 Shape::from_dims(&[64, 64]),
1461 crate::Device::cpu().as_dyn(),
1462 )
1463 .unwrap();
1464 let small_b = small_a
1465 .const_f32_like(
1466 Arc::<[f32]>::from(vec![0.0_f32; 64 * 64]),
1467 Shape::from_dims(&[64, 64]),
1468 )
1469 .unwrap();
1470 let small_mm = small_a.matmul(&small_b); let big_a = small_a
1473 .const_f32_like(
1474 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
1475 Shape::from_dims(&[1024, 1024]),
1476 )
1477 .unwrap();
1478 let big_b = small_a
1479 .const_f32_like(
1480 Arc::<[f32]>::from(vec![0.0_f32; 1024 * 1024]),
1481 Shape::from_dims(&[1024, 1024]),
1482 )
1483 .unwrap();
1484 let big_mm = big_a.matmul(&big_b); let n_before = small_a.graph().read().unwrap().len();
1487
1488 let plan = recommend_placement(
1489 &small_a.graph().read().unwrap(),
1490 &table,
1491 Criterion::Fastest,
1492 DeviceLocation::Cpu,
1493 );
1494 apply_placement_plan(small_a.graph(), &plan);
1495
1496 let roots = vec![small_mm.id(), big_mm.id()];
1497 let _new_roots = fuel_graph::opt::insert_copies(small_a.graph(), &roots);
1498
1499 let g = small_a.graph().read().unwrap();
1500 let n_after = g.len();
1501
1502 let mut cuda_copies = 0;
1505 for i in n_before..n_after {
1506 if let fuel_graph::Op::Copy { target } = &g.node(NodeId(i)).op
1507 && matches!(target, DeviceLocation::Cuda { .. })
1508 {
1509 cuda_copies += 1;
1510 }
1511 }
1512 assert!(
1513 cuda_copies >= 2,
1514 "expected ≥2 Copy(_, Cuda) nodes for big_a + big_b; got {cuda_copies} \
1515 (n_before={n_before} n_after={n_after})"
1516 );
1517 }
1518}