Skip to main content

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}