synth_backend/stack_depth.rs
1//! RQ-71-STACKDEPTH (#1341): a SOUND per-export maximum native stack depth for
2//! a `--relocatable` object.
3//!
4//! WHY THIS IS A SIBLING PASS AND NOT A FIELD ON THE WCET INTERMEDIATE. The
5//! reporter proposed reusing `--emit-wcet`'s traversal, and the TRAVERSAL is
6//! right — a memoized DFS over the direct call graph with honest declines for
7//! recursion, cycles, `call_indirect` and external callees. Two things are not:
8//!
9//! 1. THE ARITHMETIC. [`crate::wcet_compose`] composes
10//! `total = own + Σ_site multiplier × total(callee)`, where `multiplier` is
11//! the site's PROVEN EXECUTION COUNT. A callee invoked 1000 times inside a
12//! proven loop costs 1000× the cycles and **once** the stack: frames are
13//! popped between calls. Stack depth is a MAX over the call tree, never a
14//! trip-weighted sum. Reusing the composer unchanged would over-report by
15//! orders of magnitude, and over-reporting is not the safe direction — an
16//! embedder told to reserve 2 MB for a 2.3 KB requirement stops believing
17//! the number, which is its own failure.
18//!
19//! 2. THE DECLINE SET. `WcetDecline` includes `Loop`, `LoopedExpansion`,
20//! `UnmodeledOp` and `UnresolvedBranch`. None of those affect stack depth:
21//! a data-dependent loop is WCET-unbounded and perfectly stack-bounded,
22//! because the loop body's frame is the same frame every trip. Riding on the
23//! WCET decline would refuse a bound for most real modules — including the
24//! reporter's, whose control loop is exactly that shape.
25//!
26//! WHAT A FRAME ACTUALLY COSTS, measured rather than assumed. The prologue
27//! moves SP in up to three separate steps:
28//!
29//! ```text
30//! push.w { r4-r8, lr } 24 B DIRECT_PROLOGUE_BYTES — ALWAYS present
31//! vpush { d8-d15 } 64 B only when the AAPCS VFP half is in play
32//! sub.w sp, sp, #0x20 32 B only when frame_size > 0
33//! ```
34//!
35//! Summing only the `sub sp` — the obvious reading, and the one #1341 reports —
36//! misses the callee-saved push entirely AND misses every function whose
37//! `frame_size` is 0, which emits no `sub sp` at all while still consuming 8 or
38//! 24 bytes. On `stack_depth_branch_1341.wat` that method gives 96 B against a
39//! true 120 B: a 20 % UNDER-report, in the direction that corrupts memory.
40//! (The immediate is also printed in HEX — `#0x20` is 32, and a `#[0-9]+`
41//! regex silently reads it as 0.)
42//!
43//! So this pass walks the EMITTED STREAM — the artifact we ship, not a model of
44//! it — and tracks the running SP delta, taking the maximum over every
45//! instruction boundary. That covers transient `PUSH`/`POP` inside encoder
46//! expansions (`I64Popcnt`, `I64Rotl`, `I64Rotr` really do emit them) without
47//! enumerating which ops those are, and it is why the answer is a maximum
48//! rather than a prologue reading.
49//!
50//! ANY SP MOVEMENT THIS WALKER CANNOT PRICE DECLINES. The give-up direction is
51//! [`StackDecline::UnknownSpMove`]; a silent lower bound would reproduce the
52//! exact defect being reported.
53
54use crate::wcet_loops::may_move_sp;
55use std::collections::HashMap;
56pub use synth_core::stack_depth::{StackDecline, StackFrame, StackResult};
57use synth_synthesis::{ArmInstruction, ArmOp, Operand2, Reg};
58
59/// Bytes `VPUSH {d8-d15}` / `VPOP {d8-d15}` move SP by — 8 doubles × 8 bytes.
60/// Named here rather than spelled `64` at the use site so the reason travels
61/// with the number.
62const VFP_CALLEE_SAVED_BYTES: i64 = 8 * 8;
63
64/// Walk one function's FINAL emitted stream and record its own stack profile.
65///
66/// The running delta is signed because an epilogue `POP`/`ADD SP` legitimately
67/// brings it back up; the reported figure is the maximum depth reached, which
68/// is what an embedder must reserve.
69pub fn analyze_function(name: &str, instrs: &[ArmInstruction]) -> StackFrame {
70 let mut cur: i64 = 0;
71 let mut max: i64 = 0;
72 let mut calls = Vec::new();
73 let mut decline = None;
74
75 for ins in instrs {
76 match &ins.op {
77 ArmOp::Push { regs } => cur += 4 * regs.len() as i64,
78 ArmOp::Pop { regs } => cur -= 4 * regs.len() as i64,
79 ArmOp::VPushCalleeSavedVfp => cur += VFP_CALLEE_SAVED_BYTES,
80 ArmOp::VPopCalleeSavedVfp => cur -= VFP_CALLEE_SAVED_BYTES,
81 ArmOp::Sub {
82 rd: Reg::SP,
83 rn: Reg::SP,
84 op2: Operand2::Imm(n),
85 } => cur += i64::from(*n),
86 ArmOp::Add {
87 rd: Reg::SP,
88 rn: Reg::SP,
89 op2: Operand2::Imm(n),
90 } => cur -= i64::from(*n),
91 ArmOp::Bl { label } => calls.push(label.clone()),
92 // `call_indirect` reaches the stream as an indirect BRANCH, not as
93 // a `CallIndirect` pseudo-op: the direct selector lowers it to
94 // `ldr rN,[pc,#..]` + `blx rN` (measured on
95 // stack_depth_indirect_1341.wat). Both spellings are named here.
96 //
97 // Without this arm the catch-all below still DECLINES — `may_move_sp`
98 // answers true for an indirect branch — but with the reason
99 // `unknown-sp-move`, which is safe and wrong: it tells an embedder
100 // the walker got confused rather than that their module has an
101 // unresolvable call graph. The oracle demanded the accurate reason,
102 // which is how this arm came to exist.
103 ArmOp::CallIndirect { .. } | ArmOp::Blx { .. } | ArmOp::Bx { .. } => {
104 decline.get_or_insert(StackDecline::IndirectCall);
105 }
106 other => {
107 // The catch-all is SAFE here only because `may_move_sp` is an
108 // exhaustive, wildcard-free match over every `ArmOp` variant,
109 // pinned by `wcet_sp_no_wildcard_946.rs`. Anything it says can
110 // move SP and that the arms above did not price is a DECLINE.
111 // A new variant that moves SP therefore lands here as a loud
112 // refusal instead of a silent under-count.
113 if may_move_sp(other) {
114 decline.get_or_insert(StackDecline::UnknownSpMove);
115 }
116 }
117 }
118 if cur > max {
119 max = cur;
120 }
121 }
122
123 StackFrame {
124 name: name.to_string(),
125 own_bytes: max.max(0) as u64,
126 calls,
127 decline,
128 }
129}
130
131/// Compose per-function profiles into a per-function maximum over the call
132/// tree: `total(f) = own(f) + MAX over call sites of total(callee)`.
133///
134/// `index_by_label` maps a direct-call label (`func_<idx>`) to a position in
135/// `frames`; a label absent from it is an external callee and declines.
136pub fn compose(frames: &[StackFrame], index_by_label: &HashMap<String, usize>) -> Vec<StackResult> {
137 #[derive(Clone)]
138 enum State {
139 Pending,
140 OnStack,
141 Bounded(u64),
142 Declined(StackDecline),
143 }
144 let mut state = vec![State::Pending; frames.len()];
145
146 fn resolve(
147 i: usize,
148 frames: &[StackFrame],
149 index_by_label: &HashMap<String, usize>,
150 state: &mut Vec<State>,
151 ) -> State {
152 match &state[i] {
153 State::Bounded(b) => return State::Bounded(*b),
154 State::Declined(d) => return State::Declined(d.clone()),
155 // A back-edge: this node is already on the current DFS path, so the
156 // call graph has a cycle and no finite depth exists.
157 State::OnStack => return State::Declined(StackDecline::Recursion),
158 State::Pending => {}
159 }
160 if let Some(d) = &frames[i].decline {
161 let s = State::Declined(d.clone());
162 state[i] = s.clone();
163 return s;
164 }
165 state[i] = State::OnStack;
166 let mut deepest: u64 = 0;
167 for label in &frames[i].calls {
168 let Some(&j) = index_by_label.get(label) else {
169 let s = State::Declined(StackDecline::ExternalCall);
170 state[i] = s.clone();
171 return s;
172 };
173 match resolve(j, frames, index_by_label, state) {
174 State::Bounded(b) => deepest = deepest.max(b),
175 State::Declined(StackDecline::Recursion) => {
176 // Name the cycle as recursion at every node on it, rather
177 // than reporting the caller as merely "callee-unbounded".
178 let s = State::Declined(StackDecline::Recursion);
179 state[i] = s.clone();
180 return s;
181 }
182 State::Declined(_) => {
183 let s = State::Declined(StackDecline::CalleeUnbounded);
184 state[i] = s.clone();
185 return s;
186 }
187 State::Pending | State::OnStack => unreachable!("resolve always settles"),
188 }
189 }
190 let s = State::Bounded(frames[i].own_bytes.saturating_add(deepest));
191 state[i] = s.clone();
192 s
193 }
194
195 (0..frames.len())
196 .map(|i| match resolve(i, frames, index_by_label, &mut state) {
197 State::Bounded(b) => StackResult::Bounded {
198 name: frames[i].name.clone(),
199 bytes: b,
200 },
201 State::Declined(d) => StackResult::Declined {
202 name: frames[i].name.clone(),
203 reason: d,
204 },
205 State::Pending | State::OnStack => unreachable!("resolve always settles"),
206 })
207 .collect()
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn ins(op: ArmOp) -> ArmInstruction {
215 ArmInstruction {
216 op,
217 source_line: None,
218 }
219 }
220 fn push(n: usize) -> ArmInstruction {
221 ins(ArmOp::Push {
222 regs: vec![Reg::R4; n],
223 })
224 }
225 fn sub_sp(n: i32) -> ArmInstruction {
226 ins(ArmOp::Sub {
227 rd: Reg::SP,
228 rn: Reg::SP,
229 op2: Operand2::Imm(n),
230 })
231 }
232
233 #[test]
234 fn prologue_push_counts_even_with_no_sub_sp() {
235 // The `leaf` shape: frame_size 0 emits NO `sub sp`, yet the function
236 // still consumes its callee-saved push. Summing `sub sp` alone reports
237 // 0 here — the under-count that makes #1341's 1456 B look ample.
238 let f = analyze_function(
239 "leaf",
240 &[
241 push(2),
242 ins(ArmOp::Pop {
243 regs: vec![Reg::R4; 2],
244 }),
245 ],
246 );
247 assert_eq!(f.own_bytes, 8, "2 registers pushed = 8 bytes, not 0");
248 }
249
250 #[test]
251 fn frame_is_push_plus_sub_sp() {
252 let f = analyze_function("mid", &[push(6), sub_sp(32)]);
253 assert_eq!(f.own_bytes, 24 + 32);
254 }
255
256 #[test]
257 fn transient_push_inside_the_body_raises_the_maximum() {
258 // An encoder expansion (I64Popcnt/Rotl/Rotr really do this) pushes
259 // mid-body. The reported figure is the MAXIMUM depth, not the depth at
260 // the end, so a transient that is popped again still counts.
261 let f = analyze_function(
262 "t",
263 &[
264 push(6),
265 sub_sp(8),
266 push(2),
267 ins(ArmOp::Pop {
268 regs: vec![Reg::R4; 2],
269 }),
270 ],
271 );
272 assert_eq!(f.own_bytes, 24 + 8 + 8);
273 }
274
275 #[test]
276 fn composition_takes_the_max_over_branches_not_the_sum() {
277 // THE load-bearing test. top calls a and b; a and b are never live at
278 // the same time. A trip-weighted or summing composer reports 56+56+56;
279 // the truth is 56 + max(56, 56) = 112.
280 let frames = vec![
281 StackFrame {
282 name: "top".into(),
283 own_bytes: 56,
284 calls: vec!["func_1".into(), "func_2".into()],
285 decline: None,
286 },
287 StackFrame {
288 name: "a".into(),
289 own_bytes: 56,
290 calls: vec![],
291 decline: None,
292 },
293 StackFrame {
294 name: "b".into(),
295 own_bytes: 56,
296 calls: vec![],
297 decline: None,
298 },
299 ];
300 let idx = HashMap::from([("func_1".to_string(), 1), ("func_2".to_string(), 2)]);
301 let out = compose(&frames, &idx);
302 match &out[0] {
303 StackResult::Bounded { bytes, .. } => assert_eq!(
304 *bytes, 112,
305 "must be own + MAX(callees) = 112, not the sum 168"
306 ),
307 other => panic!("expected bounded, got {other:?}"),
308 }
309 }
310
311 #[test]
312 fn a_cycle_declines_recursion_rather_than_looping_forever() {
313 let frames = vec![
314 StackFrame {
315 name: "f".into(),
316 own_bytes: 8,
317 calls: vec!["func_1".into()],
318 decline: None,
319 },
320 StackFrame {
321 name: "g".into(),
322 own_bytes: 8,
323 calls: vec!["func_0".into()],
324 decline: None,
325 },
326 ];
327 let idx = HashMap::from([("func_0".to_string(), 0), ("func_1".to_string(), 1)]);
328 for r in compose(&frames, &idx) {
329 match r {
330 StackResult::Declined { reason, .. } => assert_eq!(reason, StackDecline::Recursion),
331 other => panic!("expected a recursion decline, got {other:?}"),
332 }
333 }
334 }
335
336 #[test]
337 fn an_unresolved_label_is_an_external_call_decline_not_a_zero() {
338 let frames = vec![StackFrame {
339 name: "f".into(),
340 own_bytes: 24,
341 calls: vec!["func_99".into()],
342 decline: None,
343 }];
344 match &compose(&frames, &HashMap::new())[0] {
345 StackResult::Declined { reason, .. } => assert_eq!(*reason, StackDecline::ExternalCall),
346 other => panic!("an unknown callee must DECLINE, not contribute 0: {other:?}"),
347 }
348 }
349
350 #[test]
351 fn a_decline_propagates_up_as_callee_unbounded() {
352 let frames = vec![
353 StackFrame {
354 name: "top".into(),
355 own_bytes: 24,
356 calls: vec!["func_1".into()],
357 decline: None,
358 },
359 StackFrame {
360 name: "bad".into(),
361 own_bytes: 24,
362 calls: vec![],
363 decline: Some(StackDecline::IndirectCall),
364 },
365 ];
366 let idx = HashMap::from([("func_1".to_string(), 1)]);
367 match &compose(&frames, &idx)[0] {
368 StackResult::Declined { reason, .. } => {
369 assert_eq!(*reason, StackDecline::CalleeUnbounded)
370 }
371 other => panic!("expected propagation, got {other:?}"),
372 }
373 }
374}