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}