Refactor stack allocator to reuse freed slots

Introduce `free_slots` in `StackAllocator` to keep track of physical
slots that have been freed. When mapping a new virtual slot, the
allocator first attempts to reuse a slot from the `free_slots` vector
before allocating a new one.

This change also includes a new function `collect_scope_locals` that
identifies non-captured local variables defined within a block. After
these expressions are lowered, the allocator reclaims the physical slots
associated with these locals, making them available for reuse by
subsequent blocks.

A new test `test_slot_reuse_non_overlapping_scopes` is added to verify
that distinct scopes correctly reuse stack slots, ensuring optimal stack
usage.
This commit is contained in:
2026-03-26 14:01:46 +01:00
parent 78813e7197
commit 0088a644eb
2 changed files with 102 additions and 3 deletions
+66 -3
View File
@@ -8,6 +8,8 @@ use std::rc::Rc;
struct StackAllocator { struct StackAllocator {
mapping: HashMap<u32, u32>, mapping: HashMap<u32, u32>,
/// Physical slots that have been freed and can be reused.
free_slots: Vec<u32>,
next_slot: u32, next_slot: u32,
} }
@@ -15,15 +17,21 @@ impl StackAllocator {
fn new() -> Self { fn new() -> Self {
Self { Self {
mapping: HashMap::new(), mapping: HashMap::new(),
free_slots: Vec::new(),
next_slot: 0, next_slot: 0,
} }
} }
fn map_slot(&mut self, slot: VirtualId) -> StackOffset { fn map_slot(&mut self, slot: VirtualId) -> StackOffset {
let entry = self.mapping.entry(slot.0).or_insert_with(|| { let entry = self.mapping.entry(slot.0).or_insert_with(|| {
let s = self.next_slot; // Reuse a freed slot before growing the frame.
self.next_slot += 1; if let Some(recycled) = self.free_slots.pop() {
s recycled
} else {
let s = self.next_slot;
self.next_slot += 1;
s
}
}); });
StackOffset(*entry) StackOffset(*entry)
} }
@@ -35,6 +43,15 @@ impl StackAllocator {
Address::Global(idx) => Address::Global(idx), Address::Global(idx) => Address::Global(idx),
} }
} }
/// Releases the physical slot of `vid` back to the free-list.
/// If `vid` was never mapped (e.g. a dead def removed by the optimizer)
/// this is a no-op.
fn free_slot(&mut self, vid: VirtualId) {
if let Some(&physical) = self.mapping.get(&vid.0) {
self.free_slots.push(physical);
}
}
} }
pub struct Lowering; pub struct Lowering;
@@ -116,6 +133,12 @@ impl Lowering {
if exprs.is_empty() { if exprs.is_empty() {
NodeKind::Block { exprs: vec![] } NodeKind::Block { exprs: vec![] }
} else { } else {
// Collect VirtualIds of non-captured locals before
// transforming. After all exprs are lowered their
// physical slots can be returned to the free-list,
// allowing subsequent blocks to reuse them.
let scope_locals = Self::collect_scope_locals(exprs);
let last_idx = exprs.len() - 1; let last_idx = exprs.len() - 1;
let mut new_exprs = Vec::with_capacity(exprs.len()); let mut new_exprs = Vec::with_capacity(exprs.len());
@@ -127,6 +150,13 @@ impl Lowering {
allocator, allocator,
))); )));
} }
// Release slots — variables defined here are no longer
// live after this block ends.
for vid in scope_locals {
allocator.free_slot(vid);
}
NodeKind::Block { exprs: new_exprs } NodeKind::Block { exprs: new_exprs }
} }
} }
@@ -268,4 +298,37 @@ impl Lowering {
comments: node.comments.clone(), comments: node.comments.clone(),
} }
} }
/// Collects the `VirtualId`s of all non-captured locals defined at the
/// top level of `exprs`. These slots are safe to reclaim once the block
/// ends. Captured locals (upvalues) are excluded — closures that outlive
/// the block still hold live references to those physical slots.
fn collect_scope_locals(exprs: &[Rc<AnalyzedNode>]) -> Vec<VirtualId> {
let mut locals = Vec::new();
for expr in exprs {
if let NodeKind::Def { pattern, info, .. } = &expr.kind
&& info.captured_by.is_empty()
{
Self::collect_pattern_vids(pattern, &mut locals);
}
}
locals
}
/// Recursively extracts `VirtualId`s from declaration identifiers in a
/// binding pattern. Handles flat (`x`) and destructuring (`[x y]`) forms.
fn collect_pattern_vids(pattern: &AnalyzedNode, out: &mut Vec<VirtualId>) {
match &pattern.kind {
NodeKind::Identifier {
binding: IdentifierBinding::Declaration { addr: Address::Local(vid), .. },
..
} => out.push(*vid),
NodeKind::Tuple { elements } => {
for e in elements {
Self::collect_pattern_vids(e, out);
}
}
_ => {}
}
}
} }
+36
View File
@@ -180,3 +180,39 @@ fn test_reproduce_inlining_slot_clash_crash() {
let res = env.run_script(source); let res = env.run_script(source);
assert!(res.is_ok(), "Inlining slot clash triggered: {:?}", res.err()); assert!(res.is_ok(), "Inlining slot clash triggered: {:?}", res.err());
} }
// ── Slot reuse ────────────────────────────────────────────────────────────────
#[test]
fn test_slot_reuse_non_overlapping_scopes() {
let mut env = Environment::new();
env.optimization = true;
// Two sequential inner blocks each bind a mutable local.
// assign prevents the optimizer from inlining the variables away,
// so both slots survive into the lowering pass.
// x lives only in the first block; once that block ends its slot is free.
// y is defined in the second block and must reuse x's slot.
// Expected result: the function returns 3 (y = 2+1).
let source = r#"
(fn []
(do
(do (def x 1) (assign x (+ x 1)) x)
(do (def y 2) (assign y (+ y 1)) y)))
"#;
// Correctness: calling the function must still produce the right value.
let result = env
.run_script("((fn [] (do (do (def x 1) (assign x (+ x 1)) x) (do (def y 2) (assign y (+ y 1)) y))))")
.unwrap();
assert_eq!(format!("{}", result), "3");
// Stack efficiency: with slot reuse the Lambda's stack_size must be 1,
// not 2. The dump format is "stack_size: N" in the Lambda metadata line.
let dump = env.dump_ast(source).unwrap();
assert!(
dump.contains("stack_size: 1"),
"Non-overlapping scopes must reuse the same slot (expected stack_size: 1). Dump:\n{}",
dump
);
}