Skip to main content

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}