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}