Skip to main content

aver/ir/
alloc_info.rs

1//! Per-fn heap-allocation analysis.
2//!
3//! Detects which user functions never allocate on the heap when executed
4//! under a backend-specific [`AllocPolicy`]. Backends use the resulting
5//! flag to skip GC-related framing (boundary heap_ptr save / rt_truncate
6//! call on the WASM side, GC pressure tracking on the VM side) for
7//! functions that are guaranteed not to produce garbage.
8//!
9//! "Allocates" is policy-driven: a `Result.Ok(Int)` value, for example,
10//! allocates a heap wrapper under the WASM ABI but stays inline in the
11//! VM's NaN-boxed representation. Each backend supplies its own
12//! [`AllocPolicy`]; the shared analysis below walks expression trees and
13//! reaches a fixpoint over the call graph against that policy.
14//!
15//! A function is marked allocating if it
16//! - has a non-empty `effects` declaration (effects routinely heap-marshal
17//!   their arguments — conservative default),
18//! - directly allocates via a literal shape (`List`, `Tuple`, `MapLiteral`,
19//!   `RecordCreate`, `RecordUpdate`, `InterpolatedStr`, `IndependentProduct`),
20//! - calls a built-in or constructor whose policy says it allocates, or
21//! - calls a user fn already classified as allocating.
22//!
23//! Pure-no-alloc fns end up with `false`.
24
25use std::collections::HashMap;
26
27use crate::ast::{Expr, FnBody, FnDef, Stmt, StrPart};
28
29use super::calls::{expr_to_dotted_name, is_builtin_namespace};
30
31/// Backend-specific allocation policy.
32///
33/// Different backends treat builtins and ADT constructors differently
34/// (NaN-boxing vs heap wrappers vs Rust enum variants), so the per-fn
35/// classification is parameterised by a policy object.
36pub trait AllocPolicy {
37    /// Whether a call to a builtin (e.g. `List.prepend`, `Int.toString`)
38    /// allocates a heap object on this backend.
39    fn builtin_allocates(&self, name: &str) -> bool;
40
41    /// Whether a constructor application (e.g. `Result.Ok(x)`,
42    /// `Shape.Circle(r)`) allocates. `has_payload` is true when the
43    /// constructor was applied to at least one argument; nullary
44    /// constructors (`Option.None`) never allocate.
45    fn constructor_allocates(&self, name: &str, has_payload: bool) -> bool;
46}
47
48/// Walks an expression. Returns true if any sub-expression allocates
49/// directly (via shape) or transitively (via a call to a fn already
50/// classified as allocating in `user_allocates`).
51fn expr_allocates<P: AllocPolicy>(
52    expr: &Expr,
53    user_allocates: &HashMap<String, bool>,
54    policy: &P,
55) -> bool {
56    match expr {
57        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => false,
58        Expr::Constructor(_, None) => false,
59
60        // Direct-allocation shapes — the syntactic shape itself constructs
61        // a heap object regardless of children.
62        Expr::List(_)
63        | Expr::Tuple(_)
64        | Expr::MapLiteral(_)
65        | Expr::RecordCreate { .. }
66        | Expr::RecordUpdate { .. }
67        | Expr::IndependentProduct(_, _) => true,
68        Expr::InterpolatedStr(parts) => {
69            // An interpolated string with no `Parsed` parts is a static
70            // string literal — same as `Literal(Str)`, no allocation.
71            // Anything else builds a fresh string at runtime.
72            parts.iter().any(|p| matches!(p, StrPart::Parsed(_)))
73                || expr_children_allocate(expr, user_allocates, policy)
74        }
75        Expr::Constructor(name, Some(payload)) => {
76            policy.constructor_allocates(name, true)
77                || expr_allocates(&payload.node, user_allocates, policy)
78        }
79
80        // Calls — dispatch on builtin vs user.
81        Expr::FnCall(callee, args) => {
82            if let Some(name) = expr_to_dotted_name(&callee.node) {
83                let ns = name.split('.').next().unwrap_or("");
84                if is_builtin_namespace(ns) {
85                    if policy.builtin_allocates(&name) {
86                        return true;
87                    }
88                } else if let Some(&true) = user_allocates.get(&name) {
89                    return true;
90                }
91            }
92            args.iter()
93                .any(|a| expr_allocates(&a.node, user_allocates, policy))
94        }
95        Expr::TailCall(data) => {
96            if let Some(&true) = user_allocates.get(&data.target) {
97                return true;
98            }
99            data.args
100                .iter()
101                .any(|a| expr_allocates(&a.node, user_allocates, policy))
102        }
103
104        // Recurse-only nodes.
105        Expr::Attr(base, _) | Expr::ErrorProp(base) => {
106            expr_allocates(&base.node, user_allocates, policy)
107        }
108        Expr::BinOp(_, l, r) => {
109            expr_allocates(&l.node, user_allocates, policy)
110                || expr_allocates(&r.node, user_allocates, policy)
111        }
112        Expr::Neg(inner) => expr_allocates(&inner.node, user_allocates, policy),
113        Expr::Match { subject, arms } => {
114            expr_allocates(&subject.node, user_allocates, policy)
115                || arms
116                    .iter()
117                    .any(|a| expr_allocates(&a.body.node, user_allocates, policy))
118        }
119    }
120}
121
122/// Helper for `InterpolatedStr` recursion into parsed parts.
123fn expr_children_allocate<P: AllocPolicy>(
124    expr: &Expr,
125    user_allocates: &HashMap<String, bool>,
126    policy: &P,
127) -> bool {
128    if let Expr::InterpolatedStr(parts) = expr {
129        return parts.iter().any(|p| match p {
130            StrPart::Literal(_) => false,
131            StrPart::Parsed(e) => expr_allocates(&e.node, user_allocates, policy),
132        });
133    }
134    false
135}
136
137/// Walks a function body.
138fn body_allocates<P: AllocPolicy>(
139    body: &FnBody,
140    user_allocates: &HashMap<String, bool>,
141    policy: &P,
142) -> bool {
143    body.stmts().iter().any(|s| match s {
144        Stmt::Binding(_, _, e) | Stmt::Expr(e) => expr_allocates(&e.node, user_allocates, policy),
145    })
146}
147
148/// Count the number of *directly* allocating expression nodes in a fn
149/// body under `policy`. Distinct from `compute_alloc_info` (which is a
150/// transitive yes/no via the call graph): this is a per-fn IR-level
151/// metric. Children of an allocating node are recursed into, so a
152/// `List([List([])])` counts as 2 sites (outer + inner). Calls to user
153/// fns don't count — that user fn carries its own count.
154///
155/// Drives `aver bench`'s `compiler_visible_allocs` field — a backend-
156/// stable regression metric ("did this hot fn get more alloc sites than
157/// last release?").
158pub fn count_alloc_sites_in_fn<P: AllocPolicy>(fd: &FnDef, policy: &P) -> usize {
159    let FnBody::Block(stmts) = fd.body.as_ref();
160    let mut acc = 0;
161    for stmt in stmts {
162        match stmt {
163            Stmt::Binding(_, _, e) | Stmt::Expr(e) => {
164                count_expr_alloc_sites(&e.node, policy, &mut acc)
165            }
166        }
167    }
168    acc
169}
170
171fn count_expr_alloc_sites<P: AllocPolicy>(expr: &Expr, policy: &P, acc: &mut usize) {
172    match expr {
173        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
174        Expr::Constructor(_, None) => {}
175
176        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
177            *acc += 1;
178            for item in items {
179                count_expr_alloc_sites(&item.node, policy, acc);
180            }
181        }
182        Expr::MapLiteral(entries) => {
183            *acc += 1;
184            for (k, v) in entries {
185                count_expr_alloc_sites(&k.node, policy, acc);
186                count_expr_alloc_sites(&v.node, policy, acc);
187            }
188        }
189        Expr::RecordCreate { fields, .. } => {
190            *acc += 1;
191            for (_, v) in fields {
192                count_expr_alloc_sites(&v.node, policy, acc);
193            }
194        }
195        Expr::RecordUpdate { base, updates, .. } => {
196            *acc += 1;
197            count_expr_alloc_sites(&base.node, policy, acc);
198            for (_, v) in updates {
199                count_expr_alloc_sites(&v.node, policy, acc);
200            }
201        }
202        Expr::InterpolatedStr(parts) => {
203            // Treated as a single allocation site when it has any parsed
204            // parts (the lowering pass should have rewritten it before
205            // this is called, but the count stays well-defined either way).
206            if parts.iter().any(|p| matches!(p, StrPart::Parsed(_))) {
207                *acc += 1;
208            }
209            for part in parts {
210                if let StrPart::Parsed(e) = part {
211                    count_expr_alloc_sites(&e.node, policy, acc);
212                }
213            }
214        }
215        Expr::Constructor(name, Some(payload)) => {
216            if policy.constructor_allocates(name, true) {
217                *acc += 1;
218            }
219            count_expr_alloc_sites(&payload.node, policy, acc);
220        }
221        Expr::FnCall(callee, args) => {
222            if let Some(name) = expr_to_dotted_name(&callee.node) {
223                let ns = name.split('.').next().unwrap_or("");
224                if is_builtin_namespace(ns) && policy.builtin_allocates(&name) {
225                    *acc += 1;
226                }
227            }
228            count_expr_alloc_sites(&callee.node, policy, acc);
229            for a in args {
230                count_expr_alloc_sites(&a.node, policy, acc);
231            }
232        }
233        Expr::TailCall(data) => {
234            for a in &data.args {
235                count_expr_alloc_sites(&a.node, policy, acc);
236            }
237        }
238        Expr::Attr(base, _) | Expr::ErrorProp(base) => {
239            count_expr_alloc_sites(&base.node, policy, acc);
240        }
241        Expr::BinOp(_, l, r) => {
242            count_expr_alloc_sites(&l.node, policy, acc);
243            count_expr_alloc_sites(&r.node, policy, acc);
244        }
245        Expr::Neg(inner) => count_expr_alloc_sites(&inner.node, policy, acc),
246        Expr::Match { subject, arms } => {
247            count_expr_alloc_sites(&subject.node, policy, acc);
248            for arm in arms {
249                count_expr_alloc_sites(&arm.body.node, policy, acc);
250            }
251        }
252    }
253}
254
255/// Sum of [`count_alloc_sites_in_fn`] across every FnDef in `items` under
256/// `policy`. A scenario-level rollup for bench reporting.
257pub fn count_alloc_sites_in_program<P: AllocPolicy>(
258    items: &[crate::ast::TopLevel],
259    policy: &P,
260) -> usize {
261    items
262        .iter()
263        .filter_map(|it| match it {
264            crate::ast::TopLevel::FnDef(fd) => Some(count_alloc_sites_in_fn(fd, policy)),
265            _ => None,
266        })
267        .sum()
268}
269
270/// Compute per-fn allocation status for every fn in `fns` under `policy`.
271///
272/// Iterates to fixpoint: a fn is marked allocating if it has effects, if
273/// its body contains a direct allocation, or if it calls (transitively,
274/// via the existing `info` map) a user fn already classified as
275/// allocating. Mutual recursion is handled by repeating the pass until
276/// nothing changes.
277///
278/// Once a fn flips to `true` it never reverts, so the loop is monotone
279/// and converges in at most `fns.len()` iterations.
280pub fn compute_alloc_info<P: AllocPolicy>(fns: &[&FnDef], policy: &P) -> HashMap<String, bool> {
281    let mut info: HashMap<String, bool> = fns
282        .iter()
283        .map(|fd| {
284            // Effects declaration → conservative "allocates".
285            (fd.name.clone(), !fd.effects.is_empty())
286        })
287        .collect();
288
289    loop {
290        let mut changed = false;
291        for fd in fns {
292            if *info.get(&fd.name).unwrap_or(&false) {
293                continue;
294            }
295            if body_allocates(&fd.body, &info, policy) {
296                info.insert(fd.name.clone(), true);
297                changed = true;
298            }
299        }
300        if !changed {
301            break;
302        }
303    }
304
305    info
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::ast::{BinOp, FnDef, Literal, Spanned};
312    use std::sync::Arc;
313
314    /// Test policy: every builtin in the `Map.*` namespace allocates,
315    /// `Int.toString` allocates, everything else doesn't. No constructors
316    /// allocate.
317    struct TestPolicy;
318
319    impl AllocPolicy for TestPolicy {
320        fn builtin_allocates(&self, name: &str) -> bool {
321            name.starts_with("Map.") || name == "String.fromInt"
322        }
323        fn constructor_allocates(&self, _name: &str, _has_payload: bool) -> bool {
324            false
325        }
326    }
327
328    fn sp<T>(value: T) -> Spanned<T> {
329        Spanned::new(value, 1)
330    }
331
332    fn lit_int(n: i64) -> Spanned<Expr> {
333        sp(Expr::Literal(Literal::Int(n)))
334    }
335
336    fn fn_def_pure(name: &str, body: Expr) -> FnDef {
337        FnDef {
338            name: name.to_string(),
339            line: 1,
340            params: vec![],
341            return_type: "Int".into(),
342            effects: vec![],
343            desc: None,
344            body: Arc::new(FnBody::from_expr(sp(body))),
345            resolution: None,
346        }
347    }
348
349    #[test]
350    fn pure_arithmetic_does_not_allocate() {
351        let fd = fn_def_pure(
352            "addOne",
353            Expr::BinOp(BinOp::Add, Box::new(lit_int(1)), Box::new(lit_int(2))),
354        );
355        let info = compute_alloc_info(&[&fd], &TestPolicy);
356        assert_eq!(info.get("addOne"), Some(&false));
357    }
358
359    #[test]
360    fn list_literal_allocates() {
361        let fd = fn_def_pure("makeList", Expr::List(vec![lit_int(1), lit_int(2)]));
362        let info = compute_alloc_info(&[&fd], &TestPolicy);
363        assert_eq!(info.get("makeList"), Some(&true));
364    }
365
366    #[test]
367    fn allocating_builtin_call_allocates() {
368        // String.fromInt(42)
369        let call = Expr::FnCall(
370            Box::new(sp(Expr::Attr(
371                Box::new(sp(Expr::Ident("String".into()))),
372                "fromInt".into(),
373            ))),
374            vec![lit_int(42)],
375        );
376        let fd = fn_def_pure("stringify", call);
377        let info = compute_alloc_info(&[&fd], &TestPolicy);
378        assert_eq!(info.get("stringify"), Some(&true));
379    }
380
381    #[test]
382    fn pure_builtin_call_does_not_allocate() {
383        // Int.abs(-5) — Int.abs is not in the test policy's alloc list.
384        let call = Expr::FnCall(
385            Box::new(sp(Expr::Attr(
386                Box::new(sp(Expr::Ident("Int".into()))),
387                "abs".into(),
388            ))),
389            vec![lit_int(-5)],
390        );
391        let fd = fn_def_pure("absVal", call);
392        let info = compute_alloc_info(&[&fd], &TestPolicy);
393        assert_eq!(info.get("absVal"), Some(&false));
394    }
395
396    #[test]
397    fn effects_force_allocating() {
398        let mut fd = fn_def_pure("logIt", Expr::Literal(Literal::Int(0)));
399        fd.effects = vec![sp("Console.print".into())];
400        let info = compute_alloc_info(&[&fd], &TestPolicy);
401        assert_eq!(info.get("logIt"), Some(&true));
402    }
403
404    #[test]
405    fn transitive_user_call_propagates() {
406        // makeListInner allocates (list literal).
407        // wrapperFn calls makeListInner — also allocates by transitivity.
408        let inner = fn_def_pure("makeListInner", Expr::List(vec![lit_int(1)]));
409
410        let call = Expr::FnCall(Box::new(sp(Expr::Ident("makeListInner".into()))), vec![]);
411        let wrapper = fn_def_pure("wrapperFn", call);
412
413        let info = compute_alloc_info(&[&inner, &wrapper], &TestPolicy);
414        assert_eq!(info.get("makeListInner"), Some(&true));
415        assert_eq!(info.get("wrapperFn"), Some(&true));
416    }
417
418    #[test]
419    fn mutual_recursion_pure_stays_pure() {
420        // f calls g, g calls f, both pure (no allocation anywhere).
421        let f = fn_def_pure(
422            "f",
423            Expr::FnCall(Box::new(sp(Expr::Ident("g".into()))), vec![lit_int(1)]),
424        );
425        let g = fn_def_pure(
426            "g",
427            Expr::FnCall(Box::new(sp(Expr::Ident("f".into()))), vec![lit_int(2)]),
428        );
429        let info = compute_alloc_info(&[&f, &g], &TestPolicy);
430        assert_eq!(info.get("f"), Some(&false));
431        assert_eq!(info.get("g"), Some(&false));
432    }
433
434    #[test]
435    fn mutual_recursion_one_allocates_taints_the_group() {
436        // f calls g, g allocates. Both end up allocating.
437        let f = fn_def_pure(
438            "f",
439            Expr::FnCall(Box::new(sp(Expr::Ident("g".into()))), vec![lit_int(1)]),
440        );
441        let g = fn_def_pure("g", Expr::List(vec![lit_int(0)]));
442        let info = compute_alloc_info(&[&f, &g], &TestPolicy);
443        assert_eq!(info.get("f"), Some(&true));
444        assert_eq!(info.get("g"), Some(&true));
445    }
446}