Skip to main content

fuel_dispatch/ranker/
placement_dp.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Carry-forward placement DP — planner Stage 3
3//! (`docs/session-prompts/load-time-incremental-planner.md`,
4//! architecture `04-optimization` §Load-time incremental planning,
5//! design pillars 3+4).
6//!
7//! Stage 2's greedy per-node ranking commits each node's winner
8//! given its producers' already-committed placements. That is
9//! conservative-correct for under-moving (it never makes an
10//! unjustified move) but has two structural biases:
11//!
12//! - **Under-moves**: the first op of a beneficial migration must
13//!   justify the device crossing alone — a segment of ops whose
14//!   *combined* win exceeds the crossing never migrates.
15//! - **Over-moves / stranding**: a locally-fast op (fused or not)
16//!   that wins on inbound pricing can strand residency that a later
17//!   segment needs back on the original device; the exit crossing
18//!   is unpriced at the op's own decision point.
19//!
20//! The DP fixes both by carrying accumulated cost forward instead of
21//! committing per node:
22//!
23//! - **State**: per frontier node, `best[d]` = lowest accumulated
24//!   wall-clock cost arriving here with output resident on device
25//!   `d`, for each device `d` that has at least one admissible
26//!   candidate at the node (devices with no candidate are absent
27//!   from the row).
28//! - **Extension**: `best_N[d] = min over d' of (best_{N-1}[d'] +
29//!   transfer(d'→d, boundary bytes) + cost(N on d))`, where `cost`
30//!   is the existing composite (Layer-1 static, Layer-2
31//!   Judge-refined) plus per-candidate inbound terms for inputs
32//!   whose residency is KNOWN-fixed at plan time (graph inputs,
33//!   consts, already-committed nodes).
34//! - **Exit**: terminal (still-open) states price
35//!   `transfer(d → realize_target)` so the return crossing greedy
36//!   ignored participates in the final argmin.
37//! - **Commit**: backtracking from the best terminal state stamps
38//!   one device per row; `compile_plan` prunes each row's
39//!   `AlternativeSet` to that device, preserving the Stage-2
40//!   "surviving set lives on ONE device" invariant the residency
41//!   stitch depends on.
42//!
43//! ## Fused jumps
44//!
45//! Fused ops are single graph nodes (`Op::Fused(FusedOpId, …)`), so
46//! they enter the recurrence as single states automatically — a
47//! locally-fast fused kernel that strands residency loses on
48//! accumulated + exit cost exactly like a primitive would. Pattern
49//! probing for *new* fusions during planning is Stage-4 driver
50//! territory, not this module's.
51//!
52//! ## Joins, fan-out, and the documented debt
53//!
54//! The recurrence is exact for the dominant chain of the topo
55//! order. DAG shapes are handled heuristically (architecture doc:
56//! "branch handling may start heuristic — merge to the cheaper
57//! producer device — and tighten later"):
58//!
59//! - **Joins** (a node with several open-row producers): every open
60//!   producer contributes `min over d' of (best_p[d'] +
61//!   transfer(d'→d))` independently per consumer device `d`, each
62//!   with its own backpointer. This is the "cheaper producer
63//!   device" merge, applied per arriving device. The producers'
64//!   choices are treated as independent given the consumer's
65//!   device, which is exact for tree-shaped prefixes and heuristic
66//!   when branches share ancestors.
67//! - **Fan-out** (an open row consumed by several nodes): the FIRST
68//!   consumer in topo order chains the row (closes it). Later
69//!   consumers see the row as residency-unknown and price no term
70//!   for that edge — conservative (an unpriceable edge neither
71//!   justifies nor penalizes a move), and the realize-level
72//!   correctness is unaffected because the bridge's residency
73//!   stitch inserts `Op::Copy` from the *final* committed
74//!   placements. Only optimality is at stake; tighten in Stage 4+.
75//! - **Diamonds**: the two branches chain independently (the second
76//!   branch starts a fresh chain at the shared ancestor's fan-out),
77//!   merge at the join per the rule above, and commit consistently
78//!   via backpointers — see `compile_plan`'s diamond test.
79//!
80//! ## Complexity
81//!
82//! Per node: O(producers × devices²) extension work with devices
83//! bounded by the topology (≤ 4 inline). Whole plan: O(nodes ×
84//! devices²). The 1000-node-chain perf test in `plan.rs` keeps this
85//! honest.
86//!
87//! No panics on production paths: defensive branches degrade to
88//! "commit at the row's own cheapest state" rather than unwrap.
89
90use std::collections::HashMap;
91
92use fuel_graph::NodeId;
93use fuel_ir::DeviceLocation;
94use smallvec::SmallVec;
95
96use super::cost::TransferEstimator;
97
98/// Inline capacity for per-row device state. CPU + CUDA + Vulkan +
99/// Metal is the realistic ceiling today; spill is harmless.
100type DeviceVec = SmallVec<[DeviceLocation; 4]>;
101type CostVec = SmallVec<[u64; 4]>;
102
103/// One chain edge group feeding a new row: an open producer row plus
104/// the boundary bytes of every graph edge from it into the consumer
105/// (duplicated inputs — `Add(x, x)` — carry one entry per edge,
106/// matching the Stage-2 convention of pricing per input occurrence).
107#[derive(Clone, Debug)]
108pub struct ChainInput {
109    /// The producer's row-bearing node (view aliases pre-resolved by
110    /// the caller).
111    pub producer: NodeId,
112    /// Boundary bytes per edge from `producer` into the consumer.
113    pub edge_bytes: SmallVec<[u64; 2]>,
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117enum RowStatus {
118    /// Frontier state — extendable by a consumer, exit-priced at
119    /// `finish` if nothing consumes it.
120    Open,
121    /// Chained into a downstream row; commits via that row's
122    /// backpointers.
123    Chained,
124    /// Device decided.
125    Committed,
126}
127
128#[derive(Clone, Debug)]
129struct DpRow {
130    node: NodeId,
131    /// Candidate devices, in first-seen candidate order (decision
132    /// device first — ties in every argmin break toward locality).
133    devices: DeviceVec,
134    /// `best[i]` = lowest accumulated cost arriving at this node
135    /// with output on `devices[i]`. Saturating arithmetic.
136    best: CostVec,
137    /// `back[i]` = per-producer device choices made when arriving on
138    /// `devices[i]`.
139    back: Vec<Vec<(NodeId, DeviceLocation)>>,
140    status: RowStatus,
141    committed: Option<DeviceLocation>,
142}
143
144/// The DP table `compile_plan` threads along its topo walk. See the
145/// module docs for the recurrence and the heuristics.
146#[derive(Debug, Default)]
147pub struct PlacementDp {
148    rows: Vec<DpRow>,
149    by_node: HashMap<NodeId, usize>,
150    /// View-shaped pass-through aliases (view node → row node),
151    /// flattened at insertion so resolution is O(1).
152    aliases: HashMap<NodeId, NodeId>,
153}
154
155impl PlacementDp {
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// True when no rows were ever opened — single-device plans pay
161    /// only this check.
162    pub fn is_empty(&self) -> bool {
163        self.rows.is_empty()
164    }
165
166    /// Resolve `id` through the view-alias map to its row-bearing
167    /// node (or itself when no alias exists).
168    pub fn resolve(&self, id: NodeId) -> NodeId {
169        self.aliases.get(&id).copied().unwrap_or(id)
170    }
171
172    /// Record a view-shaped pass-through: `view`'s bytes are
173    /// `src`'s bytes, so chain edges through `view` connect to
174    /// `src`'s row. `src` is resolved first (flattening), so chains
175    /// of views stay O(1) to resolve.
176    pub fn add_alias(&mut self, view: NodeId, src: NodeId) {
177        let root = self.resolve(src);
178        self.aliases.insert(view, root);
179    }
180
181    /// Whether `id` (already alias-resolved) has an OPEN row.
182    pub fn is_open(&self, id: NodeId) -> bool {
183        self.by_node
184            .get(&id)
185            .is_some_and(|&i| self.rows[i].status == RowStatus::Open)
186    }
187
188    /// The committed device for `id`'s row, if the row exists and
189    /// has been committed.
190    pub fn committed_device(&self, id: NodeId) -> Option<DeviceLocation> {
191        self.by_node.get(&id).and_then(|&i| self.rows[i].committed)
192    }
193
194    /// Open a new row for `node`.
195    ///
196    /// - `device_costs` — per candidate device, the node's own cost
197    ///   on that device (min composite over same-device candidates).
198    ///   Order is preserved; put the decision device first so ties
199    ///   break toward locality.
200    /// - `fixed_inputs` — `(residency, bytes)` per input edge whose
201    ///   residency is known-fixed at plan time.
202    /// - `chain_inputs` — open producer rows feeding this node; each
203    ///   is extended via the DP recurrence and marked `Chained`.
204    pub fn push_row(
205        &mut self,
206        node: NodeId,
207        device_costs: &[(DeviceLocation, u64)],
208        fixed_inputs: &[(DeviceLocation, u64)],
209        chain_inputs: &[ChainInput],
210        est: &dyn TransferEstimator,
211    ) {
212        let mut devices = DeviceVec::new();
213        let mut best = CostVec::new();
214        let mut back: Vec<Vec<(NodeId, DeviceLocation)>> = Vec::with_capacity(device_costs.len());
215
216        for &(d, kernel_ns) in device_costs {
217            let mut acc = kernel_ns;
218            for &(src, bytes) in fixed_inputs {
219                acc = acc.saturating_add(est.estimate_transfer_ns(src, d, bytes));
220            }
221            let mut bp = Vec::with_capacity(chain_inputs.len());
222            for ci in chain_inputs {
223                // Producer must have a row (caller checked is_open);
224                // defensive skip otherwise — an unpriceable edge.
225                let Some(&p_idx) = self.by_node.get(&ci.producer) else {
226                    continue;
227                };
228                let p = &self.rows[p_idx];
229                let mut choice: Option<(DeviceLocation, u64)> = None;
230                for (j, &pd) in p.devices.iter().enumerate() {
231                    let mut t = p.best[j];
232                    for &b in &ci.edge_bytes {
233                        t = t.saturating_add(est.estimate_transfer_ns(pd, d, b));
234                    }
235                    if choice.is_none_or(|(_, c)| t < c) {
236                        choice = Some((pd, t));
237                    }
238                }
239                if let Some((pd, c)) = choice {
240                    acc = acc.saturating_add(c);
241                    bp.push((ci.producer, pd));
242                }
243            }
244            devices.push(d);
245            best.push(acc);
246            back.push(bp);
247        }
248
249        // The consumed producers are no longer frontier states.
250        for ci in chain_inputs {
251            if let Some(&p_idx) = self.by_node.get(&ci.producer)
252                && self.rows[p_idx].status == RowStatus::Open
253            {
254                self.rows[p_idx].status = RowStatus::Chained;
255            }
256        }
257
258        let idx = self.rows.len();
259        self.rows.push(DpRow {
260            node,
261            devices,
262            best,
263            back,
264            status: RowStatus::Open,
265            committed: None,
266        });
267        self.by_node.insert(node, idx);
268    }
269
270    /// Close an OPEN row toward a consumer whose device is already
271    /// decided (greedy-finalized nodes, `Op::Copy`/`Op::Move`
272    /// targets): commit the row at
273    /// `argmin over d' of (best[d'] + Σ transfer(d'→consumer, bytes))`
274    /// and backtrack its chain. Returns every `(node, device)`
275    /// commitment made (the caller merges them into its residency
276    /// view). No-op for non-open rows.
277    pub fn close_toward(
278        &mut self,
279        row_node: NodeId,
280        consumer_device: DeviceLocation,
281        edge_bytes: &[u64],
282        est: &dyn TransferEstimator,
283    ) -> Vec<(NodeId, DeviceLocation)> {
284        let mut out = Vec::new();
285        let Some(&idx) = self.by_node.get(&row_node) else {
286            return out;
287        };
288        if self.rows[idx].status != RowStatus::Open {
289            return out;
290        }
291        let row = &self.rows[idx];
292        let mut choice: Option<(DeviceLocation, u64)> = None;
293        for (j, &d) in row.devices.iter().enumerate() {
294            let mut t = row.best[j];
295            for &b in edge_bytes {
296                t = t.saturating_add(est.estimate_transfer_ns(d, consumer_device, b));
297            }
298            if choice.is_none_or(|(_, c)| t < c) {
299                choice = Some((d, t));
300            }
301        }
302        if let Some((d, _)) = choice {
303            self.commit_row(idx, d, &mut out);
304        }
305        out
306    }
307
308    /// Commit every remaining row: open rows price the exit
309    /// crossing `transfer(d → exit_loc, exit_bytes(node))` (skipped
310    /// when no realize-target location is known) and commit at the
311    /// resulting argmin; their backpointers commit the chained rows
312    /// behind them. Returns every commitment made.
313    ///
314    /// Defensive tail: a chained row not reachable from any open
315    /// row's backtrack (which the chaining discipline should make
316    /// impossible) commits at its own cheapest state rather than
317    /// being left undecided — `compile_plan` errors typed if a row
318    /// somehow has no committed device, never panics.
319    pub fn finish(
320        &mut self,
321        exit_loc: Option<DeviceLocation>,
322        exit_bytes: impl Fn(NodeId) -> u64,
323        est: &dyn TransferEstimator,
324    ) -> Vec<(NodeId, DeviceLocation)> {
325        let mut out = Vec::new();
326        for idx in 0..self.rows.len() {
327            if self.rows[idx].status != RowStatus::Open {
328                continue;
329            }
330            let row = &self.rows[idx];
331            let mut choice: Option<(DeviceLocation, u64)> = None;
332            for (j, &d) in row.devices.iter().enumerate() {
333                let mut t = row.best[j];
334                if let Some(loc) = exit_loc {
335                    t = t.saturating_add(est.estimate_transfer_ns(d, loc, exit_bytes(row.node)));
336                }
337                if choice.is_none_or(|(_, c)| t < c) {
338                    choice = Some((d, t));
339                }
340            }
341            if let Some((d, _)) = choice {
342                self.commit_row(idx, d, &mut out);
343            }
344        }
345        // Defensive: commit any row the backtracks missed.
346        for idx in 0..self.rows.len() {
347            if self.rows[idx].status == RowStatus::Committed {
348                continue;
349            }
350            let row = &self.rows[idx];
351            let mut choice: Option<(DeviceLocation, u64)> = None;
352            for (j, &d) in row.devices.iter().enumerate() {
353                if choice.is_none_or(|(_, c)| row.best[j] < c) {
354                    choice = Some((d, row.best[j]));
355                }
356            }
357            if let Some((d, _)) = choice {
358                self.commit_row(idx, d, &mut out);
359            }
360        }
361        out
362    }
363
364    /// Iterative backtrack (chains can be thousands of nodes —
365    /// recursion would risk the stack): commit `start` at `device`,
366    /// then follow its backpointers, committing each producer at
367    /// the device the extension chose for this arrival device.
368    fn commit_row(
369        &mut self,
370        start: usize,
371        device: DeviceLocation,
372        out: &mut Vec<(NodeId, DeviceLocation)>,
373    ) {
374        let mut stack = vec![(start, device)];
375        while let Some((idx, dev)) = stack.pop() {
376            if self.rows[idx].status == RowStatus::Committed {
377                continue;
378            }
379            self.rows[idx].status = RowStatus::Committed;
380            self.rows[idx].committed = Some(dev);
381            out.push((self.rows[idx].node, dev));
382            let Some(di) = self.rows[idx].devices.iter().position(|&d| d == dev) else {
383                // Defensive: unknown arrival device → no backpointers
384                // to follow; the finish() tail commits the chain.
385                continue;
386            };
387            let back = self.rows[idx].back[di].clone();
388            for (p_node, p_dev) in back {
389                if let Some(&p_idx) = self.by_node.get(&p_node) {
390                    stack.push((p_idx, p_dev));
391                }
392            }
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use smallvec::smallvec;
401
402    const CPU: DeviceLocation = DeviceLocation::Cpu;
403    const GPU: DeviceLocation = DeviceLocation::Cuda { gpu_id: 0 };
404
405    /// latency + bytes·ns_per_byte; zero same-device.
406    struct Est {
407        latency: u64,
408        per_byte: u64,
409    }
410
411    impl TransferEstimator for Est {
412        fn estimate_transfer_ns(
413            &self,
414            src: DeviceLocation,
415            dst: DeviceLocation,
416            bytes: u64,
417        ) -> u64 {
418            if src == dst {
419                return 0;
420            }
421            self.latency
422                .saturating_add(bytes.saturating_mul(self.per_byte))
423        }
424    }
425
426    fn chain(producer: NodeId, bytes: u64) -> ChainInput {
427        ChainInput {
428            producer,
429            edge_bytes: smallvec![bytes],
430        }
431    }
432
433    #[test]
434    fn extension_carries_accumulated_cost_and_backpointers() {
435        let est = Est {
436            latency: 1000,
437            per_byte: 0,
438        };
439        let mut dp = PlacementDp::new();
440        // n0: CPU 800, GPU 100; input fixed on CPU.
441        dp.push_row(NodeId(0), &[(CPU, 800), (GPU, 100)], &[(CPU, 4)], &[], &est);
442        assert!(dp.is_open(NodeId(0)));
443        // n1 chains n0: CPU 800, GPU 100.
444        dp.push_row(
445            NodeId(1),
446            &[(CPU, 800), (GPU, 100)],
447            &[],
448            &[chain(NodeId(0), 4)],
449            &est,
450        );
451        assert!(
452            !dp.is_open(NodeId(0)),
453            "chained producer leaves the frontier"
454        );
455        assert!(dp.is_open(NodeId(1)));
456        // best_n0 = [800, 1100]; best_n1[CPU] = 800 + min(800, 2100)
457        // = 1600 (via CPU); best_n1[GPU] = 100 + min(1800, 1100) =
458        // 1200 (via GPU).
459        let commits = dp.finish(Some(CPU), |_| 4, &est);
460        // Exit to CPU: CPU 1600 vs GPU 1200 + 1000 = 2200 → CPU,
461        // backpointer commits n0 on CPU too.
462        assert_eq!(dp.committed_device(NodeId(1)), Some(CPU));
463        assert_eq!(dp.committed_device(NodeId(0)), Some(CPU));
464        assert_eq!(commits.len(), 2);
465    }
466
467    #[test]
468    fn exit_pricing_flips_the_local_winner() {
469        let est = Est {
470            latency: 1000,
471            per_byte: 0,
472        };
473        let mut dp = PlacementDp::new();
474        // Locally GPU wins: CPU 1050 vs GPU 100. Exit to CPU adds
475        // 1000 to GPU → CPU wins.
476        dp.push_row(NodeId(0), &[(CPU, 1050), (GPU, 100)], &[], &[], &est);
477        dp.finish(Some(CPU), |_| 4, &est);
478        assert_eq!(dp.committed_device(NodeId(0)), Some(CPU));
479
480        // Without an exit location the local winner stands.
481        let mut dp2 = PlacementDp::new();
482        dp2.push_row(NodeId(0), &[(CPU, 1050), (GPU, 100)], &[], &[], &est);
483        dp2.finish(None, |_| 4, &est);
484        assert_eq!(dp2.committed_device(NodeId(0)), Some(GPU));
485    }
486
487    #[test]
488    fn close_toward_prices_the_boundary_crossing() {
489        let est = Est {
490            latency: 10,
491            per_byte: 1,
492        };
493        let mut dp = PlacementDp::new();
494        // GPU locally cheaper (100 vs 300) but the consumer sits on
495        // CPU and the boundary is 1024 bytes: GPU 100 + 1034 = 1134
496        // vs CPU 300 → CPU.
497        dp.push_row(NodeId(0), &[(CPU, 300), (GPU, 100)], &[], &[], &est);
498        let commits = dp.close_toward(NodeId(0), CPU, &[1024], &est);
499        assert_eq!(commits, vec![(NodeId(0), CPU)]);
500        assert_eq!(dp.committed_device(NodeId(0)), Some(CPU));
501        // Closing again is a no-op.
502        assert!(dp.close_toward(NodeId(0), GPU, &[1024], &est).is_empty());
503    }
504
505    #[test]
506    fn duplicated_edges_price_per_occurrence() {
507        let est = Est {
508            latency: 1000,
509            per_byte: 0,
510        };
511        let mut dp = PlacementDp::new();
512        dp.push_row(NodeId(0), &[(CPU, 0), (GPU, 0)], &[], &[], &est);
513        // Add(x, x): two edges from the same producer.
514        let ci = ChainInput {
515            producer: NodeId(0),
516            edge_bytes: smallvec![4, 4],
517        };
518        dp.push_row(NodeId(1), &[(CPU, 0), (GPU, 0)], &[], &[ci], &est);
519        // Arriving GPU from a CPU producer pays two crossings
520        // (2000); same-device pays zero, so ties keep everything on
521        // the first-listed device after exit to CPU.
522        dp.finish(Some(CPU), |_| 4, &est);
523        assert_eq!(dp.committed_device(NodeId(1)), Some(CPU));
524        assert_eq!(dp.committed_device(NodeId(0)), Some(CPU));
525    }
526
527    #[test]
528    fn aliases_flatten_through_view_chains() {
529        let mut dp = PlacementDp::new();
530        let est = Est {
531            latency: 0,
532            per_byte: 0,
533        };
534        dp.push_row(NodeId(0), &[(CPU, 0)], &[], &[], &est);
535        dp.add_alias(NodeId(1), NodeId(0));
536        dp.add_alias(NodeId(2), NodeId(1));
537        assert_eq!(dp.resolve(NodeId(2)), NodeId(0));
538        assert_eq!(dp.resolve(NodeId(1)), NodeId(0));
539        assert_eq!(dp.resolve(NodeId(3)), NodeId(3), "no alias → identity");
540        assert!(dp.is_open(dp.resolve(NodeId(2))));
541    }
542
543    #[test]
544    fn saturating_costs_never_overflow() {
545        let est = Est {
546            latency: u64::MAX,
547            per_byte: u64::MAX,
548        };
549        let mut dp = PlacementDp::new();
550        dp.push_row(
551            NodeId(0),
552            &[(CPU, u64::MAX), (GPU, u64::MAX)],
553            &[(CPU, u64::MAX), (GPU, u64::MAX)],
554            &[],
555            &est,
556        );
557        dp.push_row(
558            NodeId(1),
559            &[(CPU, u64::MAX)],
560            &[],
561            &[chain(NodeId(0), u64::MAX)],
562            &est,
563        );
564        let commits = dp.finish(Some(GPU), |_| u64::MAX, &est);
565        assert_eq!(commits.len(), 2, "all rows commit despite saturated costs");
566    }
567}