Skip to main content

onnx_runtime_memory/
plan.rs

1//! The activation allocation plan and the greedy, slot-reusing planner.
2
3use std::collections::HashMap;
4
5use onnx_runtime_ir::{Graph, ValueId};
6
7use crate::error::PlanError;
8use crate::liveness::compute_liveness;
9use crate::options::PlanOptions;
10use crate::oracle::static_size_oracle;
11use crate::view_map::ViewMap;
12
13/// Identifier of a reusable activation slot in an [`ActivationPlan`].
14#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)]
15pub struct SlotId(pub u32);
16
17/// A single reusable activation slot: an arena region the executor allocates
18/// once and hands, in turn, to every value assigned to it (their lifetimes are
19/// guaranteed disjoint).
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct SlotInfo {
22    pub id: SlotId,
23    /// Bytes the executor must allocate for this slot: the max size of any value
24    /// assigned to it.
25    pub capacity_bytes: usize,
26}
27
28/// A computed activation memory plan: which slot backs each value, and how big
29/// the whole activation arena must be.
30#[derive(Clone, Debug)]
31pub struct ActivationPlan {
32    /// Buffer owner value → the slot that backs it. Views are absent (they own
33    /// no slot; they alias their source's slot).
34    pub assignments: HashMap<ValueId, SlotId>,
35    /// Every slot and its capacity, in ascending id order.
36    pub slots: Vec<SlotInfo>,
37    /// Total bytes the executor must allocate = sum of slot capacities. This is
38    /// the *concurrent peak* activation footprint after buffer sharing.
39    pub peak_bytes: usize,
40    /// Number of distinct slots.
41    pub num_slots: usize,
42    /// Bytes the naive "one buffer per value forever" strategy would use = sum
43    /// of every owner's size. Exposed to quantify the win.
44    pub naive_bytes: usize,
45    /// Fraction of activation memory saved vs. naive: `1 - peak/naive` (in
46    /// `[0, 1]`; `0.0` when `naive_bytes == 0`).
47    pub savings_ratio: f64,
48}
49
50/// The outcome of a planning attempt.
51#[derive(Clone, Debug)]
52pub enum PlanStatus {
53    /// Every buffer owner had a known size; a full plan was produced.
54    Complete(ActivationPlan),
55    /// At least one owner's size is unknown (symbolic shape) at planning time.
56    /// The executor should re-plan once shapes resolve for the current run.
57    Deferred {
58        /// The owners whose sizes the oracle could not resolve.
59        unknown_sizes: Vec<ValueId>,
60    },
61}
62
63impl PlanStatus {
64    /// The plan, if this is [`PlanStatus::Complete`].
65    pub fn as_complete(&self) -> Option<&ActivationPlan> {
66        match self {
67            PlanStatus::Complete(p) => Some(p),
68            PlanStatus::Deferred { .. } => None,
69        }
70    }
71
72    /// Whether planning was deferred (unknown sizes).
73    pub fn is_deferred(&self) -> bool {
74        matches!(self, PlanStatus::Deferred { .. })
75    }
76
77    /// Unwrap the complete plan, panicking if deferred (test convenience).
78    pub fn unwrap_complete(self) -> ActivationPlan {
79        match self {
80            PlanStatus::Complete(p) => p,
81            PlanStatus::Deferred { unknown_sizes } => {
82                panic!("plan was deferred; unknown sizes for {unknown_sizes:?}")
83            }
84        }
85    }
86}
87
88/// Compute a liveness-based activation plan with slot reuse.
89///
90/// # Algorithm
91///
92/// 1. **Liveness** — order nodes topologically; each buffer owner's interval is
93///    `[def, use_end]`, with view consumers and graph-output status folded into
94///    the root owner (see [`compute_liveness`]).
95/// 2. **Size** — query the `size_oracle` for every owner. If any is unknown
96///    (symbolic shape), return [`PlanStatus::Deferred`] instead of guessing.
97/// 3. **Greedy allocation** — walk nodes in topological order. At each node,
98///    allocate slots for the node's owner outputs (best-fit reuse of a retired
99///    slot with `capacity >= size`, else open a new slot), then retire the
100///    slots of owners whose `use_end` is this node. Retiring *after* allocation
101///    guarantees a node's own inputs are never clobbered by its outputs, and a
102///    graph output (whose `use_end` is the last node) is never recycled.
103///
104/// The output is deterministic for identical input: node order, output order,
105/// slot-id assignment, and best-fit tie-breaking (smallest capacity, then
106/// smallest id) are all stable.
107pub fn plan_activations<F>(
108    graph: &Graph,
109    view_map: &ViewMap,
110    size_oracle: F,
111    options: &PlanOptions,
112) -> Result<PlanStatus, PlanError>
113where
114    F: Fn(ValueId) -> Option<usize>,
115{
116    let live = compute_liveness(graph, view_map, options)?;
117
118    // Resolve sizes; defer if any owner size is unknown.
119    let mut sizes: HashMap<ValueId, usize> = HashMap::new();
120    let mut unknown: Vec<ValueId> = Vec::new();
121    for &owner in &live.owners {
122        match size_oracle(owner) {
123            Some(bytes) => {
124                sizes.insert(owner, bytes);
125            }
126            None => unknown.push(owner),
127        }
128    }
129    if !unknown.is_empty() {
130        unknown.sort_by_key(|v| v.0);
131        return Ok(PlanStatus::Deferred {
132            unknown_sizes: unknown,
133        });
134    }
135
136    let naive_bytes: usize = live.owners.iter().map(|o| sizes[o]).sum();
137
138    // Which owners retire at each node index (their slot returns to the free
139    // list after that node's outputs are allocated).
140    let mut retire_at: HashMap<usize, Vec<ValueId>> = HashMap::new();
141    for (&owner, interval) in &live.intervals {
142        retire_at.entry(interval.use_end).or_default().push(owner);
143    }
144
145    let mut slots: Vec<SlotInfo> = Vec::new();
146    let mut free: Vec<SlotId> = Vec::new();
147    let mut assignments: HashMap<ValueId, SlotId> = HashMap::new();
148
149    // Best-fit allocation over the free list: smallest capacity that fits,
150    // tie-broken by lowest slot id for determinism. Opens a new slot if nothing
151    // free fits (a strict `>=` policy — a reused slot is never grown, keeping
152    // each slot's capacity fixed at its first occupant's size unless a larger
153    // owner is later assigned to a fresh slot).
154    let allocate = |need: usize,
155                    slots: &mut Vec<SlotInfo>,
156                    free: &mut Vec<SlotId>|
157     -> SlotId {
158        // Track (free-list index, capacity, slot id) of the best fit so far.
159        let mut best: Option<(usize, usize, u32)> = None;
160        for (i, &sid) in free.iter().enumerate() {
161            let cap = slots[sid.0 as usize].capacity_bytes;
162            if cap < need {
163                continue;
164            }
165            let better = match best {
166                None => true,
167                Some((_, best_cap, best_id)) => cap < best_cap || (cap == best_cap && sid.0 < best_id),
168            };
169            if better {
170                best = Some((i, cap, sid.0));
171            }
172        }
173        if let Some((idx, _, _)) = best {
174            free.remove(idx)
175        } else {
176            let sid = SlotId(slots.len() as u32);
177            slots.push(SlotInfo {
178                id: sid,
179                capacity_bytes: need,
180            });
181            sid
182        }
183    };
184
185    // Pre-allocate included graph inputs (owners with no producing node) before
186    // the node walk; they are "live from the start".
187    if options.include_graph_inputs {
188        let mut input_owners: Vec<ValueId> = live
189            .owners
190            .iter()
191            .copied()
192            .filter(|v| graph.value(*v).producer.is_none())
193            .collect();
194        input_owners.sort_by_key(|v| v.0);
195        for owner in input_owners {
196            let sid = allocate(sizes[&owner], &mut slots, &mut free);
197            assignments.insert(owner, sid);
198        }
199    }
200
201    // Walk nodes in topological order.
202    for (i, &node_id) in live.order.iter().enumerate() {
203        for &out in &graph.node(node_id).outputs {
204            if !live.intervals.contains_key(&out) {
205                continue; // view / weight / excluded output — no slot
206            }
207            let sid = allocate(sizes[&out], &mut slots, &mut free);
208            assignments.insert(out, sid);
209        }
210        // Retire owners whose last use is this node (after allocating outputs).
211        if let Some(retiring) = retire_at.get(&i) {
212            for owner in retiring {
213                if let Some(&sid) = assignments.get(owner) {
214                    free.push(sid);
215                }
216            }
217        }
218    }
219
220    let peak_bytes: usize = slots.iter().map(|s| s.capacity_bytes).sum();
221    let num_slots = slots.len();
222    let savings_ratio = if naive_bytes == 0 {
223        0.0
224    } else {
225        1.0 - (peak_bytes as f64 / naive_bytes as f64)
226    };
227
228    Ok(PlanStatus::Complete(ActivationPlan {
229        assignments,
230        slots,
231        peak_bytes,
232        num_slots,
233        naive_bytes,
234        savings_ratio,
235    }))
236}
237
238/// Convenience: plan directly from fully-static shapes.
239///
240/// Uses [`static_size_oracle`] internally, so a graph with any symbolic-shaped
241/// activation yields [`PlanStatus::Deferred`].
242pub fn plan_activations_static(
243    graph: &Graph,
244    view_map: &ViewMap,
245    options: &PlanOptions,
246) -> Result<PlanStatus, PlanError> {
247    let oracle = static_size_oracle(graph);
248    plan_activations(graph, view_map, oracle, options)
249}