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, slots: &mut Vec<SlotInfo>, free: &mut Vec<SlotId>| -> SlotId {
155        // Track (free-list index, capacity, slot id) of the best fit so far.
156        let mut best: Option<(usize, usize, u32)> = None;
157        for (i, &sid) in free.iter().enumerate() {
158            let cap = slots[sid.0 as usize].capacity_bytes;
159            if cap < need {
160                continue;
161            }
162            let better = match best {
163                None => true,
164                Some((_, best_cap, best_id)) => {
165                    cap < best_cap || (cap == best_cap && sid.0 < best_id)
166                }
167            };
168            if better {
169                best = Some((i, cap, sid.0));
170            }
171        }
172        if let Some((idx, _, _)) = best {
173            free.remove(idx)
174        } else {
175            let sid = SlotId(slots.len() as u32);
176            slots.push(SlotInfo {
177                id: sid,
178                capacity_bytes: need,
179            });
180            sid
181        }
182    };
183
184    // Pre-allocate included graph inputs (owners with no producing node) before
185    // the node walk; they are "live from the start".
186    if options.include_graph_inputs {
187        let mut input_owners: Vec<ValueId> = live
188            .owners
189            .iter()
190            .copied()
191            .filter(|v| graph.value(*v).producer.is_none())
192            .collect();
193        input_owners.sort_by_key(|v| v.0);
194        for owner in input_owners {
195            let sid = allocate(sizes[&owner], &mut slots, &mut free);
196            assignments.insert(owner, sid);
197        }
198    }
199
200    // Walk nodes in topological order.
201    for (i, &node_id) in live.order.iter().enumerate() {
202        for &out in &graph.node(node_id).outputs {
203            if !live.intervals.contains_key(&out) {
204                continue; // view / weight / excluded output — no slot
205            }
206            let sid = allocate(sizes[&out], &mut slots, &mut free);
207            assignments.insert(out, sid);
208        }
209        // Retire owners whose last use is this node (after allocating outputs).
210        if let Some(retiring) = retire_at.get(&i) {
211            for owner in retiring {
212                if let Some(&sid) = assignments.get(owner) {
213                    free.push(sid);
214                }
215            }
216        }
217    }
218
219    let peak_bytes: usize = slots.iter().map(|s| s.capacity_bytes).sum();
220    let num_slots = slots.len();
221    let savings_ratio = if naive_bytes == 0 {
222        0.0
223    } else {
224        1.0 - (peak_bytes as f64 / naive_bytes as f64)
225    };
226
227    Ok(PlanStatus::Complete(ActivationPlan {
228        assignments,
229        slots,
230        peak_bytes,
231        num_slots,
232        naive_bytes,
233        savings_ratio,
234    }))
235}
236
237/// Convenience: plan directly from fully-static shapes.
238///
239/// Uses [`static_size_oracle`] internally, so a graph with any symbolic-shaped
240/// activation yields [`PlanStatus::Deferred`].
241pub fn plan_activations_static(
242    graph: &Graph,
243    view_map: &ViewMap,
244    options: &PlanOptions,
245) -> Result<PlanStatus, PlanError> {
246    let oracle = static_size_oracle(graph);
247    plan_activations(graph, view_map, oracle, options)
248}
249
250/// Plan activations with symbolic dimensions resolved to caller-supplied upper
251/// bounds, and report the peak footprint that must be reserved.
252///
253/// This is the entry point for **admission control**, which needs a ceiling
254/// rather than an exact size. Planning from static shapes alone yields
255/// [`PlanStatus::Deferred`] for any model with a dynamic sequence length —
256/// which is every LLM — and a deferred plan reserves nothing. A zero
257/// reservation is indistinguishable from a model that does not allocate.
258///
259/// Returns `Ok(None)` when planning is still deferred because some symbol was
260/// left unbound. That is reported rather than treated as zero: a caller that
261/// silently reserved nothing would be making exactly the mistake this exists
262/// to prevent.
263pub fn peak_activation_bytes_at_bounds(
264    graph: &Graph,
265    view_map: &ViewMap,
266    bounds: &std::collections::HashMap<onnx_runtime_ir::SymbolId, usize>,
267    options: &PlanOptions,
268) -> Result<Option<usize>, PlanError> {
269    let oracle = crate::oracle::bounded_size_oracle(graph, bounds);
270    Ok(match plan_activations(graph, view_map, oracle, options)? {
271        PlanStatus::Complete(plan) => Some(plan.peak_bytes),
272        PlanStatus::Deferred { .. } => None,
273    })
274}