Skip to main content

fuel_tensor/
scheduling.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Orchestrator for the Phase 6b probe → judge → dispatch pipeline.
3//!
4//! Wraps the three pieces so callers get a ready-to-query
5//! [`DispatchTable`] from one call. Does the right thing re: reuse
6//! across restarts: if the persisted probe matches the current
7//! hardware and a persisted profile is present, the Judge is
8//! skipped entirely.
9//!
10//! # One-call API
11//!
12//! ```no_run
13//! use fuel_tensor::scheduling::{prepare_dispatch_table, ScheduleOptions};
14//! let (table, _report) = prepare_dispatch_table(ScheduleOptions::default())
15//!     .expect("prepare_dispatch_table");
16//! // `table` is now queryable with `.pick(op, dtype, size, criterion)`.
17//! ```
18//!
19//! # Reuse rules
20//!
21//! 1. Probe the current hardware.
22//! 2. If a persisted [`ProbeReport`] exists and `probe.diff(&prior)`
23//!    is [`HardwareChange::Unchanged`], look for a persisted
24//!    [`ProfileReport`].
25//! 3. If a persisted profile is present **and** its schema version
26//!    matches, **skip the Judge** and build the dispatch table from
27//!    the persisted profile. Save cost on every startup.
28//! 4. Otherwise re-run the Judge and persist both reports.
29//!
30//! # Where the reports live
31//!
32//! By default, both reports live under the OS cache dir
33//! (`%LOCALAPPDATA%\fuel\` on Windows, `$XDG_CACHE_HOME/fuel/` on
34//! Linux). Callers that want explicit paths (for CI, containers,
35//! per-user config) can override via [`ScheduleOptions`].
36
37use 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/// Options for [`prepare_dispatch_table`] and the Phase 6c
48/// [`prepare_dp_inputs`] / [`auto_place_and_route_with_transfer_cost`]
49/// — lets callers override paths, force re-measurement, and tune
50/// dispatch-table construction.
51#[derive(Default)]
52pub struct ScheduleOptions {
53    /// Explicit path for the probe report. `None` = OS cache default.
54    pub probe_path: Option<PathBuf>,
55    /// Explicit path for the profile report. `None` = OS cache default.
56    pub profile_path: Option<PathBuf>,
57    /// Explicit path for the bandwidth report (Phase 6c). `None` =
58    /// OS cache default (`bandwidth.json` next to probe.json).
59    pub bandwidth_path: Option<PathBuf>,
60    /// Force the Judge to re-run even if the persisted state is
61    /// otherwise reusable. Useful after toolchain upgrades where a
62    /// driver version bump didn't happen but compiler codegen
63    /// changed.
64    pub force_rejudge: bool,
65    /// Force the bandwidth matrix to be re-measured. Same use case
66    /// as `force_rejudge` for the transfer-cost half of Phase 6c.
67    pub force_remeasure_bandwidth: bool,
68    /// Judge config. Default = `Judge::default()`.
69    pub judge: Judge,
70    /// Dispatch table construction options.
71    pub dispatch: DispatchOptions,
72}
73
74/// Probe → [load / re-Judge] → build dispatch. Persists both reports
75/// on a fresh Judge run. Returns the dispatch table paired with the
76/// profile report it was built from (callers often want the raw
77/// measurements too — for logging, debugging, or a custom secondary
78/// dispatch table).
79pub 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    // Step 1: decide if the persisted profile is reusable.
92    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, &current)? {
105                Some(r) => r,
106                None => run_and_persist(&current_probe, &opts, &probe_path, &profile_path)?,
107            }
108        } else {
109            run_and_persist(&current_probe, &opts, &probe_path, &profile_path)?
110        }
111    } else {
112        run_and_persist(&current_probe, &opts, &probe_path, &profile_path)?
113    };
114
115    let table = DispatchTable::build_with(&profile, opts.dispatch);
116    Ok((table, profile))
117}
118
119// ---- Placement recommender ----------------------------------------------
120//
121// Phase 6b's last machinery piece: walk a graph, ask the dispatch
122// table where each node would run best, return a per-NodeId placement
123// plan. The plan is the input that feeds into
124// `fuel_graph::opt::insert_moves` (or future router-level placement
125// passes); this function does NOT mutate the graph itself.
126//
127// Today the dispatch table only knows about MatMul + AddElementwise
128// (the Judge's profile matrix). Every other op falls through to
129// `fallback_device`. As the Judge's op coverage grows the
130// recommender's coverage grows with it for free.
131
132/// Convert a `fuel_graph::Op` to the Judge's `OpKind`. Returns `None`
133/// for ops the Judge doesn't profile yet.
134fn 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
142/// Convert a dispatch-table [`Pick`] into the Fuel placement enum.
143fn 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        // BackendId is #[non_exhaustive]; future variants get the
156        // CPU fallback until they have a placement rule of their own.
157        _ => Some(DeviceLocation::Cpu),
158    }
159}
160
161/// Walk every node in `graph` and produce a recommended
162/// [`DeviceLocation`] per node. Ops the Judge profiled produce a
163/// dispatch-table-driven recommendation; ops it didn't profile fall
164/// back to `fallback_device` (typically the router's default).
165///
166/// The output is a `HashMap<NodeId, DeviceLocation>` ready to feed
167/// into `fuel_graph::opt::insert_moves` or any other placement-
168/// honouring pass.
169///
170/// # When to use
171///
172/// The recommender is intended for **realize-time** placement
173/// decisions, after the orchestrator has produced a [`DispatchTable`].
174/// It does not mutate the graph; it just produces a plan. Callers
175/// can override individual nodes (pin a specific tensor to CPU for
176/// debugging, force a backbone onto a GPU regardless of size, etc.)
177/// before passing the plan to `insert_moves`.
178///
179/// # Tolerance for unprofiled ops
180///
181/// Most ops in any real graph won't be profiled in v1 (Judge today
182/// covers MatMul + AddElementwise). Those nodes get
183/// `fallback_device`, which means the dispatch table is acting as a
184/// "preferred placement for hot ops" overlay rather than a hard
185/// scheduling decision. As the Judge's op coverage grows the overlay
186/// grows.
187pub 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
203/// Single-node recommendation. Public so callers that already have
204/// a node in hand (e.g. during a custom graph rewrite pass) can ask
205/// the dispatch table directly without building the full plan.
206///
207/// **SizeClass note (v4).** This legacy `DispatchTable` placement path
208/// keys every op — including matmul — on the OUTPUT node's
209/// `elem_count()` (`SizeClass::from_elem_count`), which is aspect-blind.
210/// The `fuel-dispatch` ranker and the `fuel-core` Judge now key matmul
211/// on the v4 aspect key (`SizeClass::matmul` / `for_op`, `(m,n,k)`
212/// packed). Matching them here needs the contraction dim `k`, which is
213/// only reachable from the input operand shapes — not the output shape
214/// this graph-less helper receives — so a matmul lookup against an
215/// aspect-keyed profile report misses and falls back to
216/// `pick_nearest` / `fallback_device`. Reconciling this transitional
217/// path (thread the operand shapes and call `SizeClass::for_op`) is a
218/// tracked follow-up; the primary matmul dispatch now flows through the
219/// ranker.
220pub 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        // Judge only covers F32 today. Other dtypes get fallback.
228        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
235/// Apply a placement plan to a [`Graph`] — set per-node placement
236/// hints for every entry **that doesn't already have one**. Skipping
237/// pre-set hints is the safe default: a user who explicitly pinned a
238/// node to a device shouldn't have that overridden by the dispatch
239/// table's recommendation. Use [`force_apply_placement_plan`] when
240/// the override is intentional.
241///
242/// After this, a follow-up [`fuel_graph::opt::insert_copies`] pass
243/// will see the per-node hints and insert `Op::Copy` nodes at the
244/// boundaries between devices that disagree.
245pub 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
257/// Like [`apply_placement_plan`] but overwrites existing hints.
258/// Useful when a re-Judge produces fresher recommendations or when
259/// a user wants to dispatch-route a graph that's been hand-pinned
260/// in an earlier pass.
261pub 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
271/// One-call Phase 6b auto-router: runs [`recommend_placement`],
272/// applies the resulting hints (skip-existing semantics), and runs
273/// [`fuel_graph::opt::insert_copies`] to materialise the cross-
274/// device transfers. Returns the new root IDs from `insert_copies`
275/// (which may have been remapped if any of `roots`'s ops were
276/// rewritten as part of inserting Copy nodes).
277///
278/// This is the "do the right thing" entry point for users who want
279/// the Phase 6b dispatch table to drive placement automatically.
280/// Users who want finer control compose the three steps manually.
281pub 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
296/// Default cache path for the bandwidth report — `bandwidth.json`
297/// next to `probe.json` in the OS cache dir.
298fn 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
305/// Probe → load-or-judge → load-or-measure-bandwidth, returning all
306/// three Phase 6c inputs in one call. Reuses persisted reports when
307/// the current probe matches the prior one (same hardware-change
308/// rule the Judge uses); re-measures any piece marked
309/// `force_*` in the options or that's missing a persisted file.
310///
311/// Both reports persist to disk on a fresh measurement so subsequent
312/// calls warm-start.
313pub 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    // Decide reuse eligibility once — same rule for both judge and
329    // bandwidth caches. Hardware-change diff is the source of truth.
330    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    // -- Profile report --
339    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, &current)?.unwrap_or_else(|| opts.judge.run(&current_probe))
343        } else {
344            opts.judge.run(&current_probe)
345        }
346    } else {
347        opts.judge.run(&current_probe)
348    };
349
350    // -- Bandwidth matrix --
351    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(&current_probe))
354        } else {
355            BandwidthMatrix::measure(&current_probe)
356        }
357    } else {
358        BandwidthMatrix::measure(&current_probe)
359    };
360
361    // -- Persist (best-effort) --
362    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
390/// One-call Phase 6c auto-router: probe → load-or-judge → load-or-
391/// measure-bandwidth → DP-plan → apply hints → insert_copies. Returns
392/// the new root IDs after `Op::Copy` insertion.
393///
394/// This is the transfer-cost-aware analogue of the Phase 6b
395/// [`auto_place_and_route`]. The DP planner accounts for both
396/// per-op compute cost (from the Judge) and per-edge transfer cost
397/// (from the bandwidth matrix), producing placements that minimise
398/// the total cost rather than just the sum of per-node winners.
399///
400/// Unprofiled-op nodes (Const, GELU, etc.) take `fallback_device`.
401pub 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
430// ---- Phase 6c: transfer-cost-aware DP planner ----------------------------
431
432/// Phase 6c DP-based placement planner. Produces a per-node placement
433/// that minimises **total cost = compute + transfer**, via forward
434/// dynamic programming over the topo-sorted graph.
435///
436/// For each node in topo order and for each candidate backend `b`,
437/// we compute:
438///
439/// ```text
440///   best_cost[node, b] = compute_cost(node, b) +
441///                        Σ_inputs min_{b_i} ( best_cost[input, b_i]
442///                                          + transfer_cost(b_i → b, input_bytes) )
443/// ```
444///
445/// After the forward pass, each root picks its min-cost backend, and
446/// a backtrack pass propagates the chosen backend to every reachable
447/// input. Nodes that aren't profiled (op kind not in the dispatch
448/// table, e.g. ConvNeXt's GELU today) get the `fallback_device` and
449/// don't contribute meaningful compute cost — but their inputs still
450/// pay transfer costs to wherever they end up.
451///
452/// Compared to [`recommend_placement`], the DP planner accounts for
453/// the cost of the inputs being on a different backend. That changes
454/// the right answer in cases where a fast backend's transfer cost
455/// exceeds the compute speedup.
456pub 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    // (node, backend) → (cumulative_cost_ns, [chosen_backend_per_input]).
468    let mut cost: HashMap<(NodeId, BackendId), (f64, Vec<BackendId>)> = HashMap::new();
469
470    let backends = if available_backends.is_empty() {
471        // Default: fall back to CPU only; gives sensible behaviour when
472        // the caller forgot to enumerate. Equivalent to recommend_placement
473        // with no GPU options.
474        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        // Const nodes live in host memory; consumers on non-CPU
485        // backends pay the upload. Pin them to CPU so the DP can't
486        // "place" a Const on CUDA for free.
487        let is_const = matches!(node.op, fuel_graph::Op::Const);
488
489        for &b in &backends {
490            // Const ops only have a finite cost on CPU. Non-CPU
491            // backends see Const-on-{CUDA, Vulkan, ...} as infinite,
492            // forcing consumers to transfer.
493            if is_const && b != BackendId::Cpu {
494                cost.insert((id, b), (f64::INFINITY, vec![]));
495                continue;
496            }
497            // Compute cost on backend b for this op. Read directly
498            // from the profile report — we want the actual measured
499            // latency for THIS (op, dtype, size, backend), not just
500            // "did this backend win the dispatch table." If the
501            // backend isn't in the profile (didn't measure it),
502            // fall back to a generic penalty so the DP can still
503            // make progress.
504            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                // Op not profiled (e.g. GELU, Mul). Assign a small
513                // backend-agnostic compute estimate so transfer
514                // costs dominate routing.
515                UNPROFILED_OP_COMPUTE_NS
516            };
517
518            // For each input, pick min over b_i of [input_cost(b_i) + transfer(b_i, b)].
519            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                    // No reachable input cost — this (node, b) is
546                    // unreachable. Mark with infinity so it loses
547                    // backtracking.
548                    total_cost = f64::INFINITY;
549                    break;
550                }
551            }
552            cost.insert((id, b), (total_cost, input_choices));
553        }
554    }
555
556    // Pick min-cost backend for each root, then backtrack.
557    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    // Backtrack: for each placed node, propagate input choices.
573    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    // Translate to DeviceLocation. Backends not in the dispatch's
593    // pick_to_location table fall back to `fallback_device`.
594    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
608/// Per-byte transfer cost from `src` to `dst`. If no direct entry,
609/// route via CPU as a two-hop transfer (the worst case is the
610/// Vulkan→CPU→CUDA pattern; CPU→CPU is essentially free).
611fn 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    // Two-hop via CPU.
624    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, // unreachable pair
629    }
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, // one byte per element (storage width == u8)
639        DType::F4 => 1,   // packed; per-element fractional, round up
640        DType::F6E2M3 | DType::F6E3M2 => 1,
641    }
642}
643
644/// Find the closest measured `(op, dtype, size_class, backend)`
645/// latency in the profile report. Returns `None` if no entry for
646/// the requested backend exists at any size class for this
647/// (op, dtype). Walks the profile linearly — fine for v1; a
648/// pre-built index would be cheap to add later if profiles get
649/// large.
650fn profile_lookup_latency_ns(
651    profile: &ProfileReport,
652    op: OpKind,
653    dtype: DType,
654    size_class: SizeClass,
655    backend: BackendId,
656) -> Option<f64> {
657    // Exact size-class match first.
658    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    // Nearest-size fallback (same op/dtype/backend, closest size).
664    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    // `candidates` is non-empty (checked above) so `min_by_key` yields Some —
674    // but this fn already returns `Option`, so `?` states that without a panic
675    // path and stays correct if the guard above ever moves.
676    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
682/// Penalty (in ns) for a backend that has no profile entry for the
683/// op at any size. Should be high enough that the DP avoids picking
684/// it when other backends DO have profiles, but not infinite — a
685/// genuine "no choice" case (every backend missing) should still
686/// produce a placement rather than panicking.
687const UNPROFILED_BACKEND_PENALTY_NS: f64 = 1_000_000_000.0; // 1 second
688
689/// Compute cost for ops whose op kind isn't profiled. Should be
690/// small relative to transfer costs so unprofiled ops route along
691/// with their inputs by default.
692const 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    // Best-effort persistence: if the parent dir doesn't exist or a
703    // write fails, log to stderr and continue. Dispatch decisions
704    // still work in-memory even when we can't write the reports.
705    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    /// Force-rejudge on a fresh scratch dir — verifies the full
732    /// orchestrator path end-to-end (no prior state, run everything,
733    /// persist, reload).
734    #[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        // Post Reference retirement (2026-06-07) CPU is the only
764        // unconditionally-present backend, so the only guaranteed
765        // profile entries are CPU ones.
766        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        // Both files should exist now.
777        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    /// Second call with matching hardware and no force_rejudge should
784    /// skip the Judge (observable as "profile entries come from the
785    /// persisted file, not a fresh measurement"). We can't assert
786    /// exact byte equality because the device's description timing
787    /// varies, but entry count should be stable.
788    #[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, // reuse eligible
824            force_remeasure_bandwidth: false,
825            judge: tiny_judge(),
826            dispatch: Default::default(),
827        })
828        .expect("second run");
829
830        // Second run's profile should equal first's (loaded from disk,
831        // not re-measured). Latency fields would differ on re-measure
832        // — identical if loaded from disk.
833        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    /// Hand-build a tiny dispatch table where CUDA wins MatMul at
848    /// large sizes and CPU wins at small. Then build a graph with
849    /// matmuls of varying sizes plus an unprofiled op (Mul) and
850    /// verify the recommender sends each node where the table
851    /// predicts.
852    #[test]
853    fn recommend_placement_routes_per_node() {
854        // -- 1) hand-craft a profile report with deliberate winners --
855        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                // size 12 (≈ 64×64 matmul): CPU wins (10μs vs 2ms CUDA launch overhead)
872                mk(BackendId::Cpu, 12, 10_000),
873                mk(BackendId::Cuda, 12, 2_000_000),
874                // size 20 (≈ 1024×1024 matmul): CUDA wins (5ms vs 50ms CPU)
875                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        // -- 2) build a graph with two matmuls + one unprofiled op
882        //       — all derived from a single root so they share a graph.
883        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); // size_class = 12 (4096 elements)
896
897        // Build the big matmul as constants on the SAME graph as small_a.
898        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); // size_class = 20
911
912        // Unprofiled op (Sub) on small tensors — should fall back.
913        let unprofiled = small_mm.sub(&small_b);
914
915        // The graph for the recommender is whichever one any of our
916        // tensors point into; they all share the same graph since
917        // they were built from the same `from_f32` root.
918        let graph = small_a.graph().read().unwrap();
919
920        let plan = recommend_placement(&graph, &table, Criterion::Fastest, DeviceLocation::Cpu);
921
922        // small matmul → CPU (size_class 12 winner)
923        assert_eq!(plan[&small_mm.id()], DeviceLocation::Cpu);
924        // big matmul → CUDA (size_class 20 winner)
925        assert_eq!(plan[&big_mm.id()], DeviceLocation::Cuda { gpu_id: 0 });
926        // Unprofiled sub-op → fallback (CPU)
927        assert_eq!(plan[&unprofiled.id()], DeviceLocation::Cpu);
928        // Const nodes → fallback (no Op::Const → OpKind mapping)
929        assert!(matches!(plan[&small_a.id()], DeviceLocation::Cpu));
930    }
931
932    /// Skip-existing semantics: if a node already has a placement
933    /// hint, the auto-router must not overwrite it. The user's
934    /// explicit `set_placement` call is authoritative.
935    #[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); // dispatch table would pick CUDA
984
985        // User pins it to CPU explicitly.
986        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        // Recommendation says CUDA, but apply_placement_plan must respect the existing CPU hint.
998        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    /// Phase 6c orchestrator end-to-end: probe → judge → bandwidth →
1008    /// dp_plan → apply → insert_copies. Verifies the full chain
1009    /// completes without panicking on the dev rig and that the
1010    /// persisted reports are written.
1011    #[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        // Build a small graph the planner will actually route.
1018        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        // We don't assert specific placement (depends on rig) — just
1063        // that the orchestrator runs without panic and emits roots.
1064        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    /// Phase 6c: a transfer-dominated single-op graph routes to CPU
1073    /// even though the dispatch table picks CUDA — because the input
1074    /// is on CPU and the H2D + D2H round-trip costs more than the
1075    /// compute saving. Without transfer cost the planner would pick
1076    /// CUDA; with it, CPU wins.
1077    #[test]
1078    fn dp_plan_avoids_costly_transfers() {
1079        use fuel_hardware::transfer_cost::{
1080            BANDWIDTH_REPORT_VERSION, BandwidthMatrix, TransferCost,
1081        };
1082        // CUDA is the winner of dispatch (small compute cost penalty)
1083        // but every byte costs 100ns to upload + 100ns to download.
1084        // Even a tiny tensor crosses the threshold where keeping it
1085        // on CPU is cheaper.
1086        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        // Punitive bandwidth: 100 ns/byte each way. A 64×64 f32
1121        // matmul output is 64*64*4 = 16384 bytes = 1.6ms one-way
1122        // transfer = 3.2ms round-trip. The compute saving on CUDA
1123        // is 5µs vs 10µs CPU — only 5µs. Transfer cost dwarfs it.
1124        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        // The dispatch table's first-choice would have been CUDA
1170        // (`recommend_placement` produces CUDA here too). The DP
1171        // planner accounts for the transfer cost and picks CPU.
1172        assert_eq!(
1173            plan[&mm.id()],
1174            DeviceLocation::Cpu,
1175            "DP planner should pick CPU when transfer cost dominates"
1176        );
1177    }
1178
1179    /// Phase 6c: when transfer is cheap enough, the DP planner DOES
1180    /// route to the dispatch winner. Sanity check that the planner
1181    /// isn't pessimistically pinning everything to CPU.
1182    #[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        // Realistic-ish PCIe bandwidth: 0.15 ns/byte (≈6.5 GB/s).
1221        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        // 1024² f32 = 4 MiB transfer ≈ 0.6ms each way = 1.2ms round-trip;
1267        // CUDA saves 45ms vs CPU. CUDA wins easily.
1268        assert_eq!(plan[&mm.id()], DeviceLocation::Cuda { gpu_id: 0 });
1269    }
1270
1271    /// `auto_place_and_route` does the full dance in one call:
1272    /// recommend → apply (skip-existing) → insert_copies.
1273    #[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); // → CPU
1348
1349        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); // → CUDA
1362
1363        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        // Same expectation as the manual-pipeline test: ≥2 Copy nodes
1374        // targeting CUDA appeared at the boundary between the
1375        // CPU-placed sub-graph and the CUDA-placed sub-graph.
1376        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    /// Full pipeline: recommend_placement → apply_placement_plan →
1392    /// fuel_graph::opt::insert_copies. Verifies Copy nodes appear at
1393    /// the boundary between CPU and CUDA placements.
1394    #[test]
1395    fn apply_then_insert_copies_emits_copies_at_device_boundaries() {
1396        // Hand-craft a dispatch table: size 12 → CPU, size 20 → CUDA.
1397        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        // Build the heterogeneous graph (small + big matmul).
1458        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); // size 12 → CPU
1471
1472        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); // size 20 → CUDA
1485
1486        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        // big_a + big_b are placeless Const inputs feeding into big_mm
1503        // which is placed on CUDA → 2 Copy nodes get inserted.
1504        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}