Skip to main content

synth_core/
stack_depth.rs

1//! RQ-71-STACKDEPTH (#1341) types: the per-function stack profile and the
2//! settled per-export answer.
3//!
4//! These live in synth-core for the same reason `WcetIntermediate` does —
5//! `CompiledFunction` carries one, and synth-core cannot depend on the
6//! backend. The ANALYSIS (which needs `ArmInstruction` and the tripwired
7//! `may_move_sp` enumeration) stays in `synth_backend::stack_depth`.
8//!
9//! The composition rule is a MAX over the call tree, not a trip-weighted
10//! sum: a callee invoked 1000 times in a loop costs 1000x the cycles and
11//! ONCE the stack. See the backend module's docs for why that distinction
12//! is what makes this a sibling of the WCET pass rather than a field on it.
13
14use serde::{Deserialize, Serialize};
15
16/// Why no finite stack bound exists. Deliberately NOT `WcetDecline`: the two
17/// sets differ (see the module docs), and sharing the enum would silently tie
18/// a stack refusal to a cycle-counting one.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub enum StackDecline {
21    /// A cycle in the direct call graph — no finite depth exists.
22    Recursion,
23    /// `call_indirect`: the callee set is not statically known, so neither is
24    /// the depth below this point.
25    IndirectCall,
26    /// A direct call to a function outside this module (an import). Its frame
27    /// belongs to code synth did not emit.
28    ExternalCall,
29    /// A callee declined, and a decline propagates UP: a caller cannot be
30    /// bounded by an unbounded subtree.
31    CalleeUnbounded,
32    /// An instruction moves SP in a way this walker cannot price. The
33    /// give-up direction is a DECLINE, never a lower bound.
34    UnknownSpMove,
35}
36
37impl StackDecline {
38    /// The machine-readable reason string carried into the sidecar. The
39    /// `stack_depth_1341.py` oracle asserts on these exact spellings.
40    pub fn as_str(&self) -> &'static str {
41        match self {
42            StackDecline::Recursion => "recursion",
43            StackDecline::IndirectCall => "call_indirect",
44            StackDecline::ExternalCall => "external-call",
45            StackDecline::CalleeUnbounded => "callee-unbounded",
46            StackDecline::UnknownSpMove => "unknown-sp-move",
47        }
48    }
49}
50
51/// One function's own stack behaviour, before composition.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct StackFrame {
54    pub name: String,
55    /// Maximum bytes below entry SP reached ANYWHERE in this function's own
56    /// body — prologue plus any transient push inside an expansion. Excludes
57    /// callees; composition adds the deepest one.
58    pub own_bytes: u64,
59    /// Direct-call labels (`func_<idx>`), in program order. Duplicates are kept:
60    /// resolving them is the composer's job, and the MAX is insensitive to
61    /// repeats anyway.
62    pub calls: Vec<String>,
63    /// Set when this function alone cannot be bounded.
64    pub decline: Option<StackDecline>,
65}
66
67/// The settled answer for one function.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum StackResult {
70    Bounded { name: String, bytes: u64 },
71    Declined { name: String, reason: StackDecline },
72}
73
74/// The `synth-stack-v1` sidecar: one entry per compiled function, each either a
75/// bounded maximum native stack depth or a NAMED decline.
76///
77/// Keyed `exports` rather than `functions` because the number an embedder needs
78/// is per ENTRY POINT — it sizes the region a call into that export will use.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct StackReport {
81    pub schema: String,
82    pub module: String,
83    pub exports: Vec<StackEntry>,
84}
85
86/// One export's settled answer, flattened for the sidecar.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct StackEntry {
89    pub name: String,
90    /// `"bounded"` or `"declined"`.
91    pub status: String,
92    /// Present when bounded: the maximum bytes of native stack a call into this
93    /// export can consume, across the whole direct call tree.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub bytes: Option<u64>,
96    /// Present when declined: the machine-readable reason.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub reason: Option<String>,
99}
100
101impl StackReport {
102    pub fn new(module: impl Into<String>) -> Self {
103        StackReport {
104            schema: "synth-stack-v1".to_string(),
105            module: module.into(),
106            exports: Vec::new(),
107        }
108    }
109
110    /// `<output>.stack.json`, beside the object — the same shape `--emit-wcet`
111    /// uses, so a build step finds both the same way.
112    pub fn sidecar_path(output: &std::path::Path) -> std::path::PathBuf {
113        let mut s = output.as_os_str().to_os_string();
114        s.push(".stack.json");
115        std::path::PathBuf::from(s)
116    }
117
118    pub fn to_json(&self) -> Result<String, serde_json::Error> {
119        serde_json::to_string_pretty(self)
120    }
121}
122
123impl From<StackResult> for StackEntry {
124    fn from(r: StackResult) -> Self {
125        match r {
126            StackResult::Bounded { name, bytes } => StackEntry {
127                name,
128                status: "bounded".to_string(),
129                bytes: Some(bytes),
130                reason: None,
131            },
132            StackResult::Declined { name, reason } => StackEntry {
133                name,
134                status: "declined".to_string(),
135                bytes: None,
136                reason: Some(reason.as_str().to_string()),
137            },
138        }
139    }
140}