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}