fuel_dispatch/residency.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Residency analysis + eviction for the pipelined executor.
3//!
4//! Ported from `fuel-graph-router::{residency_planner,
5//! residency_eviction}` (executor-unification Session 6, 2026-06-11).
6//! The legacy shape was a `GraphMutatingSchedulerRule` that emitted a
7//! three-node `Op::Copy{Cpu}` + `Op::Release` + `Op::Copy{device}`
8//! chain for the legacy `GraphExecutor<Router>`; the pipelined port
9//! emits the fused two-node form — `Op::Move{Cpu}` + reload
10//! `Op::Copy{device}` — making this pass the first production emitter
11//! of [`WorkItemKind::Move`](crate::pipelined) (shipped `b93bdb82`).
12//!
13//! ## The two halves
14//!
15//! - **Analysis** — [`ResidencyPlanner::analyze`] walks a graph's topo
16//! order, tracks each node's live range (first use → last consumer),
17//! and computes the peak working-set size. Pure; no graph mutation.
18//! - **Transform** — [`insert_residency_evictions`] consumes the
19//! analysis and, while the peak exceeds a byte budget, inserts
20//! `Op::Move{Cpu}` + reload `Op::Copy{device}` chains around the
21//! highest-scoring candidates (`bytes × inactive_gap`). The
22//! `derive_ordering` half of `execution_plan` (run inside both
23//! pipelined realize paths) pins each Move after every
24//! non-destructive reader of its source, and the executor's
25//! `destructive_input` cleanup frees the device storage when the
26//! Move runs.
27//!
28//! ## Const-pool byte budget (deferred — planner program)
29//!
30//! The legacy executor's `with_const_pool_limit` LRU was the only
31//! other larger-than-VRAM mechanism, and it retired with the legacy
32//! executor. Per the 2026-06-11 re-audit (gap 7), the replacement is
33//! NOT an executor-side LRU: weight residency under a byte budget is
34//! a *planning* decision, and it folds into the load-time incremental
35//! planner's residency program (`docs/session-prompts/
36//! load-time-incremental-planner.md`) — the planner prices
37//! evict/reload chains with Stage 1 `TransferCalibration` data and
38//! emits the same `Op::Move`/`Op::Copy` primitives this module does.
39//! This module is that program's substrate, not its policy.
40//!
41//! ## Known limitations (follow-up work, inherited from the port)
42//!
43//! - Only one eviction per candidate (the largest gap). Multiple gaps
44//! would need multiple evict chains.
45//! - Greedy selection; no cost-awareness for the CPU↔device transfer
46//! overhead vs the VRAM savings (the planner program adds pricing).
47//! - No integration with the `VulkanBackend::evict` mmap-tiering path
48//! — that's a transparent runtime mechanism, distinct from
49//! graph-level planning.
50
51use crate::plan::backend_for_device;
52use fuel_graph::{Graph, NodeId, SharedGraph, opt, topo_order_multi};
53use fuel_ir::{DeviceLocation, Error, Result};
54// `Op` is named only by the test-module graph builders below (this module
55// emits `Op::Move`/`Op::Copy` through `opt`'s helpers, never by naming the
56// variant), so the import is `cfg(test)`-scoped to keep the lib build clean.
57#[cfg(test)]
58use fuel_graph::Op;
59use std::collections::HashMap;
60
61/// One tensor's residency span. `[first_use, last_use]` are op
62/// positions in a topo order (inclusive — the tensor is live during
63/// both). `bytes` is the output storage size. `inactive_gap` is the
64/// longest stretch of ops between consecutive reads where the tensor
65/// is resident but not accessed — a strong signal for eviction
66/// viability.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct LiveRange {
69 pub node: NodeId,
70 pub bytes: usize,
71 pub first_use: usize,
72 pub last_use: usize,
73 pub inactive_gap: usize,
74}
75
76/// Structured report on a graph's residency profile.
77#[derive(Debug, Clone)]
78pub struct ResidencyReport {
79 /// Total bytes of storage summed over every reachable op's output.
80 /// Not the peak — this is cumulative if nothing were freed.
81 pub total_bytes: usize,
82 /// Maximum bytes live simultaneously at any point during a
83 /// single-device execution of the graph. The minimum VRAM
84 /// (or "primary device") required to run without spilling.
85 pub peak_bytes: usize,
86 /// Op position (index in topo order) where the peak occurs.
87 pub peak_op_index: usize,
88 /// Per-tensor live range, sorted by `bytes * inactive_gap`
89 /// descending (biggest "wasted residency" candidates first).
90 /// [`insert_residency_evictions`] consumes this list.
91 pub eviction_candidates: Vec<LiveRange>,
92}
93
94impl ResidencyReport {
95 /// Does this graph fit in a given byte budget?
96 pub fn fits_in(&self, budget: usize) -> bool {
97 self.peak_bytes <= budget
98 }
99
100 /// Byte overage relative to a budget. Zero when the graph fits.
101 pub fn overage(&self, budget: usize) -> usize {
102 self.peak_bytes.saturating_sub(budget)
103 }
104}
105
106/// Analysis-only residency planner. Computes live ranges and peak
107/// bytes for a graph, emits a [`ResidencyReport`]. Does not mutate
108/// the graph. Takes `&Graph` — the caller holds whatever lock it
109/// already has; no hidden lock acquisition.
110pub struct ResidencyPlanner;
111
112impl ResidencyPlanner {
113 /// Run the analysis. O(V + E) in the reachable subgraph.
114 pub fn analyze(g: &Graph, roots: &[NodeId]) -> ResidencyReport {
115 let order = topo_order_multi(g, roots);
116
117 // 1. For every node, compute its output byte size.
118 // 2. Determine first-use (= position of the producing op in
119 // topo order) and last-use (= position of the last
120 // consuming op in topo order) for every producer.
121 // Roots count as consumed at the walk's end.
122 let n = order.len();
123 let mut byte_of: HashMap<NodeId, usize> = HashMap::with_capacity(n);
124 let mut first_use: HashMap<NodeId, usize> = HashMap::with_capacity(n);
125 let mut last_use: HashMap<NodeId, usize> = HashMap::with_capacity(n);
126 // Record the set of reads per op position for inactive-gap
127 // computation below.
128 let mut reads_at: Vec<Vec<NodeId>> = vec![Vec::new(); n];
129
130 for (op_idx, &nid) in order.iter().enumerate() {
131 let node = g.node(nid);
132 let elems = node.shape.elem_count();
133 let bytes = elems * node.dtype.size_in_bytes();
134 byte_of.insert(nid, bytes);
135 first_use.entry(nid).or_insert(op_idx);
136
137 for &input in &node.inputs {
138 last_use.insert(input, op_idx);
139 reads_at[op_idx].push(input);
140 }
141 }
142 // Roots themselves need to stay live through their own
143 // position (they're the "output" the caller asked for).
144 for &r in roots {
145 if let Some(pos) = order.iter().position(|&x| x == r) {
146 last_use.entry(r).or_insert(pos);
147 }
148 }
149
150 // 3. Walk topo order, maintain live-set bytes. At each op:
151 // a. Add the new node's output bytes (it goes live).
152 // b. Record current bytes.
153 // c. Drop any nodes whose last_use == this position AFTER
154 // the op completes (they're freed once the op exits).
155 let mut live_bytes: usize = 0;
156 let mut peak_bytes: usize = 0;
157 let mut peak_op_index: usize = 0;
158 let mut total_bytes: usize = 0;
159
160 for (op_idx, &nid) in order.iter().enumerate() {
161 let bytes = *byte_of.get(&nid).unwrap_or(&0);
162 live_bytes += bytes;
163 total_bytes += bytes;
164 if live_bytes > peak_bytes {
165 peak_bytes = live_bytes;
166 peak_op_index = op_idx;
167 }
168 // Post-op: free anything whose last_use is this op.
169 // We only free *inputs* that are done, not the node we
170 // just produced (that's live until IT is consumed).
171 for &input in &reads_at[op_idx] {
172 if last_use.get(&input) == Some(&op_idx) {
173 let b = *byte_of.get(&input).unwrap_or(&0);
174 live_bytes = live_bytes.saturating_sub(b);
175 }
176 }
177 }
178
179 // 4. Compute per-node inactive_gap: longest span of ops between
180 // consecutive reads where the tensor is live but not touched.
181 // For eviction, nodes with big gap × bytes are the best
182 // candidates (lots of wasted residency).
183 let mut gap_of: HashMap<NodeId, usize> = HashMap::with_capacity(n);
184 for (nid, &first) in &first_use {
185 // Collect positions where this node is read.
186 let reads: Vec<usize> = reads_at
187 .iter()
188 .enumerate()
189 .filter_map(|(i, rs)| if rs.contains(nid) { Some(i) } else { None })
190 .collect();
191 // Build the gap sequence: between first (produced) and each
192 // read, between consecutive reads, and after last read till
193 // last_use.
194 let last = last_use.get(nid).copied().unwrap_or(first);
195 let mut points = vec![first];
196 points.extend(reads.iter().copied());
197 points.push(last);
198 points.sort();
199 let max_gap = points.windows(2).map(|w| w[1] - w[0]).max().unwrap_or(0);
200 gap_of.insert(*nid, max_gap);
201 }
202
203 // 5. Build + sort eviction candidates.
204 let mut cands: Vec<LiveRange> = order
205 .iter()
206 .map(|&nid| LiveRange {
207 node: nid,
208 bytes: *byte_of.get(&nid).unwrap_or(&0),
209 first_use: *first_use.get(&nid).unwrap_or(&0),
210 last_use: *last_use.get(&nid).unwrap_or(&0),
211 inactive_gap: *gap_of.get(&nid).unwrap_or(&0),
212 })
213 .collect();
214 // Rank by bytes × inactive_gap, descending. Ties: larger
215 // bytes first (more reclaim per evict).
216 cands.sort_by(|a, b| {
217 let a_score = (a.bytes as u128) * (a.inactive_gap as u128);
218 let b_score = (b.bytes as u128) * (b.inactive_gap as u128);
219 b_score.cmp(&a_score).then(b.bytes.cmp(&a.bytes))
220 });
221 // Filter out zero-score candidates (hot tensors with no gap).
222 cands.retain(|c| c.bytes > 0 && c.inactive_gap > 0);
223 // Cap to a reasonable count for downstream consumers.
224 cands.truncate(32);
225
226 ResidencyReport {
227 total_bytes,
228 peak_bytes,
229 peak_op_index,
230 eviction_candidates: cands,
231 }
232 }
233}
234
235/// One evict/reload chain emitted by [`insert_residency_evictions`].
236/// `move_node` is the `Op::Move { target: Cpu }` (destructive stage-
237/// to-host); `reload` is the `Op::Copy { target: src_device }` the
238/// post-gap consumers were rewired to.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct EvictReload {
241 pub candidate: NodeId,
242 pub move_node: NodeId,
243 pub reload: NodeId,
244 pub src_device: DeviceLocation,
245}
246
247/// Graph-mutating eviction pass: while the graph's peak residency
248/// exceeds `budget_bytes`, pick the top candidate (by `bytes ×
249/// inactive_gap`) that has a realizable inactive gap and insert an
250/// `Op::Move{Cpu}` + reload `Op::Copy{device}` chain around it
251/// (via [`fuel_graph::opt::insert_evict_reload`]).
252///
253/// `placement_of` reports each candidate's resident
254/// [`DeviceLocation`] — callers on the pipelined path derive it from
255/// `graph.placement(id)` / the plan's stamped winners; `None` falls
256/// back to `Cpu` (the chain is then a same-device spill, correct but
257/// pure overhead — mirrors the legacy rule's default). The pass
258/// stamps `target_backend` on both emitted nodes (`Move` runs on the
259/// source device's backend, the reload's transfer kernel on `Cpu` —
260/// the staged copy's residency), so the chain compiles on the
261/// pipelined executor without a separate prepare pass.
262///
263/// Returns the inserted chains. Idempotent once the budget is met or
264/// no candidate has a gap ≥ 2 op positions; capped at `max_rounds`
265/// (16 is the legacy default) to prevent pathological loops.
266pub fn insert_residency_evictions(
267 graph: &SharedGraph,
268 roots: &[NodeId],
269 budget_bytes: usize,
270 max_rounds: usize,
271 placement_of: impl Fn(NodeId) -> Option<DeviceLocation>,
272) -> Result<Vec<EvictReload>> {
273 // Track candidates we've already evicted this run. Without this
274 // guard, a just-evicted candidate's new consumers (its own Move)
275 // show up as post-gap consumers in the next round, triggering
276 // infinite re-eviction.
277 let mut evicted: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
278 let mut chains: Vec<EvictReload> = Vec::new();
279
280 for _round in 0..max_rounds {
281 let report = {
282 let g = read_graph(graph)?;
283 ResidencyPlanner::analyze(&g, roots)
284 };
285 if report.fits_in(budget_bytes) {
286 return Ok(chains);
287 }
288 // Pick the top candidate that still has a realizable gap AND
289 // hasn't been evicted already in this run.
290 let mut progressed = false;
291 for cand in &report.eviction_candidates {
292 if evicted.contains(&cand.node) {
293 continue;
294 }
295
296 let post_gap_consumers = {
297 let g = read_graph(graph)?;
298 match find_gap_consumers(&g, cand.node) {
299 Some((_pre_gap, post)) if !post.is_empty() => post,
300 _ => continue,
301 }
302 };
303
304 let src_device = placement_of(cand.node).unwrap_or(DeviceLocation::Cpu);
305
306 let (move_id, reload_id) =
307 opt::insert_evict_reload(graph, cand.node, src_device, &post_gap_consumers);
308
309 // Stamp the transfer pair so the pipelined executor's
310 // Op::Move / Op::Copy compile arms (which require an
311 // explicit target_backend = "the backend whose kernel
312 // runs the transfer") resolve without a prepare pass:
313 // the Move downloads from src_device's backend; the
314 // reload uploads from the staged CPU copy.
315 {
316 let mut g = write_graph(graph)?;
317 g.set_target_backend(move_id, backend_for_device(src_device));
318 g.set_target_backend(reload_id, backend_for_device(DeviceLocation::Cpu));
319 g.set_placement(move_id, DeviceLocation::Cpu);
320 g.set_placement(reload_id, src_device);
321 }
322
323 evicted.insert(cand.node);
324 chains.push(EvictReload {
325 candidate: cand.node,
326 move_node: move_id,
327 reload: reload_id,
328 src_device,
329 });
330
331 progressed = true;
332 break;
333 }
334 if !progressed {
335 return Ok(chains);
336 }
337 }
338 Ok(chains)
339}
340
341/// Find the pre-gap and post-gap consumers of `candidate` — i.e., the
342/// consumer that reads it just before its longest inactive stretch,
343/// and the consumer(s) that read it after.
344///
345/// Returns `None` if the candidate has fewer than two consumers, or if
346/// the largest gap is < 2 op positions (no meaningful window to evict
347/// into).
348fn find_gap_consumers(g: &Graph, candidate: NodeId) -> Option<(NodeId, Vec<NodeId>)> {
349 // Walk EVERY node as a synthetic root set — the pass needs to see
350 // ALL consumers of a candidate, not just those on a specific
351 // root's path.
352 let all: Vec<NodeId> = (0..g.len()).map(NodeId).collect();
353 let order = topo_order_multi(g, &all);
354
355 // Positions of candidate's consumers in topo order.
356 let mut positions: Vec<(usize, NodeId)> = Vec::new();
357 for (i, &nid) in order.iter().enumerate() {
358 if g.node(nid).inputs.contains(&candidate) {
359 positions.push((i, nid));
360 }
361 }
362
363 if positions.len() < 2 {
364 return None;
365 }
366
367 // Find the largest gap between consecutive consumer positions.
368 let mut best_gap = 0usize;
369 let mut gap_idx = 0usize;
370 for i in 0..positions.len() - 1 {
371 let gap = positions[i + 1].0 - positions[i].0;
372 if gap > best_gap {
373 best_gap = gap;
374 gap_idx = i;
375 }
376 }
377
378 if best_gap < 2 {
379 return None;
380 }
381
382 let last_before = positions[gap_idx].1;
383 let after_gap: Vec<NodeId> = positions[gap_idx + 1..].iter().map(|(_, n)| *n).collect();
384
385 Some((last_before, after_gap))
386}
387
388fn read_graph(graph: &SharedGraph) -> Result<std::sync::RwLockReadGuard<'_, Graph>> {
389 graph
390 .read()
391 .map_err(|_| Error::Msg("residency: graph RwLock poisoned".into()).bt())
392}
393
394fn write_graph(graph: &SharedGraph) -> Result<std::sync::RwLockWriteGuard<'_, Graph>> {
395 graph
396 .write()
397 .map_err(|_| Error::Msg("residency: graph RwLock poisoned".into()).bt())
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::pipelined::{PipelinedExecutor, StorageCache};
404 use fuel_graph::Node;
405 use fuel_ir::{DType, Shape, probe::BackendId};
406 use std::sync::{Arc, RwLock};
407
408 fn shared() -> SharedGraph {
409 Arc::new(RwLock::new(Graph::new()))
410 }
411
412 fn push(g: &mut Graph, op: Op, inputs: Vec<NodeId>, dims: &[usize]) -> NodeId {
413 g.push(Node {
414 op,
415 inputs,
416 shape: Shape::from_dims(dims),
417 dtype: DType::F32,
418 })
419 }
420
421 fn count_op(graph: &SharedGraph, pred: impl Fn(&Op) -> bool) -> usize {
422 let g = graph.read().unwrap();
423 (0..g.len())
424 .filter(|i| pred(&g.node(NodeId(*i)).op))
425 .count()
426 }
427
428 // ---- analysis (ported from fuel-graph-router residency_planner) ----
429
430 /// A graph with no shared consumption: each intermediate is used
431 /// exactly once by its direct successor. Peak bytes should equal
432 /// the maximum of (input + output) at any op.
433 #[test]
434 fn chain_graph_peak_is_two_live_tensors() {
435 // a = const[1024] 1024 * 4 = 4096 bytes
436 // b = relu(a) 4096 bytes
437 // c = neg(b) 4096 bytes
438 // Peak: when c is produced, a is already dead (last-use was
439 // relu), so live set is {b, c} = 8192 bytes.
440 let graph = shared();
441 let c = {
442 let mut g = graph.write().unwrap();
443 let a = push(&mut g, Op::Const, vec![], &[1024]);
444 let b = push(&mut g, Op::Relu, vec![a], &[1024]);
445 push(&mut g, Op::Neg, vec![b], &[1024])
446 };
447 let g = graph.read().unwrap();
448 let report = ResidencyPlanner::analyze(&g, &[c]);
449 assert_eq!(report.total_bytes, 4096 * 3);
450 assert_eq!(report.peak_bytes, 4096 * 2);
451 }
452
453 #[test]
454 fn shared_const_stays_live_across_multiple_uses() {
455 // a is consumed twice — by add(a, b) and (later) by neg(a) —
456 // so it stays live across the gap and shows up as an eviction
457 // candidate with a non-zero inactive_gap.
458 let graph = shared();
459 let (a, sum) = {
460 let mut g = graph.write().unwrap();
461 let a = push(&mut g, Op::Const, vec![], &[256]);
462 let b = push(&mut g, Op::Const, vec![], &[256]);
463 let ab = push(&mut g, Op::Add, vec![a, b], &[256]);
464 let na = push(&mut g, Op::Neg, vec![a], &[256]);
465 let sum = push(&mut g, Op::Add, vec![ab, na], &[256]);
466 (a, sum)
467 };
468 let g = graph.read().unwrap();
469 let report = ResidencyPlanner::analyze(&g, &[sum]);
470 let a_range = report.eviction_candidates.iter().find(|c| c.node == a);
471 assert!(
472 a_range.is_some() || report.peak_bytes > 0,
473 "expected `a` tracked with a gap OR at least some peak bytes",
474 );
475 }
476
477 #[test]
478 fn report_fits_in_and_overage() {
479 let graph = shared();
480 let b = {
481 let mut g = graph.write().unwrap();
482 let a = push(&mut g, Op::Const, vec![], &[256]);
483 push(&mut g, Op::Relu, vec![a], &[256])
484 };
485 let g = graph.read().unwrap();
486 let report = ResidencyPlanner::analyze(&g, &[b]);
487 assert!(report.fits_in(10_000));
488 assert!(!report.fits_in(1));
489 assert_eq!(report.overage(10_000), 0);
490 assert!(report.overage(1) > 0);
491 }
492
493 // ---- transform (ported from residency_eviction, Move-shaped) ----
494
495 /// Build the canonical over-budget graph used by the legacy
496 /// rule's tests:
497 /// a = const[4] (two consumers, gap in between)
498 /// b = relu(a)
499 /// pad = neg(b)
500 /// pad2 = neg(pad)
501 /// c = mul(pad2, a) (reads a AGAIN, after the gap)
502 /// out = add(b, c)
503 /// All compute nodes stamped Cpu so the pipelined executor can
504 /// compile them.
505 fn over_budget_graph() -> (SharedGraph, NodeId, NodeId) {
506 let graph = shared();
507 let (a, out) = {
508 let mut g = graph.write().unwrap();
509 let a = push(&mut g, Op::Const, vec![], &[4]);
510 let b = push(&mut g, Op::Relu, vec![a], &[4]);
511 let pad = push(&mut g, Op::Neg, vec![b], &[4]);
512 let pad2 = push(&mut g, Op::Neg, vec![pad], &[4]);
513 let c = push(&mut g, Op::Mul, vec![pad2, a], &[4]);
514 let out = push(&mut g, Op::Add, vec![b, c], &[4]);
515 for n in [b, pad, pad2, c, out] {
516 g.set_target_backend(n, BackendId::Cpu);
517 }
518 (a, out)
519 };
520 (graph, a, out)
521 }
522
523 #[test]
524 fn eviction_is_noop_under_budget() {
525 let (graph, _a, out) = over_budget_graph();
526 let moves_before = count_op(&graph, |op| matches!(op, Op::Move { .. }));
527 let copies_before = count_op(&graph, |op| matches!(op, Op::Copy { .. }));
528
529 let chains = insert_residency_evictions(&graph, &[out], 1_000_000_000, 16, |_| None)
530 .expect("eviction pass");
531 assert!(
532 chains.is_empty(),
533 "under-budget graph should emit no chains"
534 );
535 assert_eq!(
536 count_op(&graph, |op| matches!(op, Op::Move { .. })),
537 moves_before
538 );
539 assert_eq!(
540 count_op(&graph, |op| matches!(op, Op::Copy { .. })),
541 copies_before
542 );
543 }
544
545 #[test]
546 fn eviction_emits_move_reload_chain_when_over_budget() {
547 let (graph, a, out) = over_budget_graph();
548
549 // Budget: 1 byte (effectively zero).
550 let chains =
551 insert_residency_evictions(&graph, &[out], 1, 16, |_| Some(DeviceLocation::Cpu))
552 .expect("eviction pass");
553
554 assert!(
555 !chains.is_empty(),
556 "over-budget graph should emit at least one chain"
557 );
558 assert!(
559 count_op(&graph, |op| matches!(op, Op::Move { .. })) >= 1,
560 "over-budget graph should emit at least one Op::Move",
561 );
562 assert!(
563 count_op(&graph, |op| matches!(op, Op::Copy { .. })) >= 1,
564 "each chain carries a reload Op::Copy",
565 );
566
567 // The top-scoring candidate is `a` (biggest gap × bytes among
568 // multi-consumer nodes); its chain must be Move(a) → Copy.
569 let chain = chains
570 .iter()
571 .find(|c| c.candidate == a)
572 .unwrap_or(&chains[0]);
573 let g = graph.read().unwrap();
574 assert!(matches!(
575 g.node(chain.move_node).op,
576 Op::Move {
577 target: DeviceLocation::Cpu
578 },
579 ));
580 assert_eq!(g.node(chain.reload).inputs, vec![chain.move_node]);
581 // Both stamped so the pipelined executor can compile them.
582 assert_eq!(g.target_backend(chain.move_node), Some(BackendId::Cpu));
583 assert_eq!(g.target_backend(chain.reload), Some(BackendId::Cpu));
584 }
585
586 /// Correctness invariant: graph surgery for residency must be
587 /// transparent to the result. Realize the same graph WITHOUT and
588 /// WITH the eviction pass through the PRODUCTION executor and
589 /// compare bit-exactly. (The legacy twin realized through
590 /// `GraphExecutor<CpuBackend>`; this is the pipelined port.)
591 #[test]
592 fn eviction_preserves_correctness_end_to_end_on_pipelined() {
593 // a = [-1, 2, -3, 4]
594 // b = relu(a) = [0, 2, 0, 4]
595 // pad = neg(b) = [0, -2, 0, -4]
596 // pad2 = neg(pad) = [0, 2, 0, 4]
597 // c = pad2 * a = [0, 4, 0, 16]
598 // out = b + c = [0, 6, 0, 20]
599 let realize = |evict: bool| -> Vec<f32> {
600 let (graph, a, out) = over_budget_graph();
601 if evict {
602 let chains = insert_residency_evictions(&graph, &[out], 1, 16, |_| {
603 Some(DeviceLocation::Cpu)
604 })
605 .expect("eviction pass");
606 assert!(!chains.is_empty(), "budget=1 must trigger eviction");
607 }
608 let mut inputs = StorageCache::new();
609 inputs.insert(
610 a,
611 Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[
612 -1.0_f32, 2.0, -3.0, 4.0,
613 ]))),
614 );
615 let (storage, _layout) =
616 PipelinedExecutor::realize(graph, out, inputs).expect("realize");
617 let guard = storage.read().unwrap();
618 let fuel_memory::BackendStorage::Cpu(c) = &guard.inner else {
619 panic!("expected CPU output");
620 };
621 c.as_slice::<f32>().unwrap().to_vec()
622 };
623
624 let baseline = realize(false);
625 let evicted = realize(true);
626 assert_eq!(
627 baseline, evicted,
628 "eviction must preserve the output bit-exactly",
629 );
630 assert_eq!(baseline, vec![0.0_f32, 6.0, 0.0, 20.0]);
631 }
632
633 /// Idempotence guard: re-running the pass after the budget can't
634 /// be met must not grow the graph unboundedly (the `evicted` set
635 /// + `progressed` exit cover it within a run; `max_rounds` caps
636 ///
637 /// across rounds).
638 #[test]
639 fn eviction_terminates_when_budget_unreachable() {
640 let (graph, _a, out) = over_budget_graph();
641 let chains =
642 insert_residency_evictions(&graph, &[out], 0, 16, |_| None).expect("eviction pass");
643 let len_after = graph.read().unwrap().len();
644 // A second run finds the same candidates already evicted (or
645 // gapless) and makes no further progress.
646 let chains2 = insert_residency_evictions(&graph, &[out], 0, 16, |_| None)
647 .expect("second eviction pass");
648 let len_final = graph.read().unwrap().len();
649 assert!(chains.len() <= 16);
650 // The post-surgery graph rewires post-gap consumers onto the
651 // reload, so re-analysis finds at most a bounded set of new
652 // multi-consumer candidates; the key invariant is termination
653 // with bounded growth, not zero growth.
654 assert!(chains2.len() <= 16);
655 assert!(len_final >= len_after);
656 assert!(len_final - len_after <= 2 * chains2.len());
657 }
658}