feat: Add dead code elimination and improve optimization

Integrates dead code elimination (DCE) into the optimizer.
This phase removes expressions that have no side effects and are not the
result of the block.

Also includes several improvements to the existing optimization passes:
- DCE now correctly handles assignments to variables that are never used
  or captured.
- Explicitly tracks captured slots in the `SubstitutionMap` to prevent
  premature inlining.
- Introduces a `is_side_effect_free` helper function for more robust
  purity checks.
- Updates dependencies to include `insta` for snapshot testing.
This commit is contained in:
Michael Schimmel
2026-02-21 22:51:53 +01:00
parent b2e010e755
commit 1aa844db22
3 changed files with 150 additions and 11 deletions
Generated
+38
View File
@@ -749,6 +749,18 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -1025,6 +1037,12 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "endi" name = "endi"
version = "1.1.1" version = "1.1.1"
@@ -1660,6 +1678,19 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "insta"
version = "1.46.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
dependencies = [
"console",
"once_cell",
"serde",
"similar",
"tempfile",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -1884,6 +1915,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"eframe", "eframe",
"insta",
"regex", "regex",
] ]
@@ -2760,6 +2792,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
+3
View File
@@ -9,3 +9,6 @@ eframe = "0.33.3"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
chrono = "0.4" chrono = "0.4"
regex = "1.10" regex = "1.10"
[dev-dependencies]
insta = { version = "1.39", features = ["yaml"] }
+109 -11
View File
@@ -3,9 +3,9 @@ use crate::ast::compiler::bound_nodes::{BoundKind, TypedNode, Address};
use crate::ast::types::{Value, StaticType}; use crate::ast::types::{Value, StaticType};
use crate::ast::vm::Closure; use crate::ast::vm::Closure;
use crate::ast::nodes::Node; use crate::ast::nodes::Node;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
/// The Optimizer performs Phase 2 (Cracking) and Phase 2.5 (Aggressive Collapsing). /// The Optimizer performs Phase 2 (Cracking) and Phase 2.5/2.6 (Aggressive Collapsing & DCE).
pub struct Optimizer { pub struct Optimizer {
pub level: u32, pub level: u32,
max_passes: usize, max_passes: usize,
@@ -45,6 +45,7 @@ impl Optimizer {
kind: BoundKind::Constant(val.clone()), kind: BoundKind::Constant(val.clone()),
}; };
} }
sub.used_slots.insert(slot);
} }
Address::Upvalue(idx) => { Address::Upvalue(idx) => {
if let Some(val) = sub.upvalues.get(&idx) { if let Some(val) = sub.upvalues.get(&idx) {
@@ -148,6 +149,33 @@ impl Optimizer {
} }
if self.level >= 2 { if self.level >= 2 {
// Phase 2.6: Dead Code Elimination (DCE)
// Remove side-effect-free expressions from the block that are not the result.
if !new_exprs.is_empty() {
let last_idx = new_exprs.len() - 1;
let mut filtered = Vec::with_capacity(new_exprs.len());
for (i, e) in new_exprs.into_iter().enumerate() {
if i == last_idx {
filtered.push(e);
continue;
}
// A statement is removable if it's pure (no side effects)
// or if it's a local assignment to a variable that is never used or captured.
let removable = match &e.kind {
BoundKind::DefLocal { slot, .. } | BoundKind::Set { addr: Address::Local(slot), .. } => {
!sub.used_slots.contains(slot) && !sub.captured_slots.contains(slot) && self.is_side_effect_free(&e)
}
_ => self.is_side_effect_free(&e),
};
if !removable {
filtered.push(e);
}
}
new_exprs = filtered;
}
if new_exprs.is_empty() { if new_exprs.is_empty() {
return Node { identity: node.identity, kind: BoundKind::Nop, ty: StaticType::Void }; return Node { identity: node.identity, kind: BoundKind::Nop, ty: StaticType::Void };
} else if new_exprs.len() == 1 { } else if new_exprs.len() == 1 {
@@ -169,6 +197,8 @@ impl Optimizer {
match capture_addr { match capture_addr {
Address::Local(slot) => { Address::Local(slot) => {
if let Some(val) = sub.locals.get(slot) { inlined_val = Some(val.clone()); } if let Some(val) = sub.locals.get(slot) { inlined_val = Some(val.clone()); }
// Mark slot as captured in parent scope
sub.captured_slots.insert(*slot);
} }
Address::Upvalue(idx) => { Address::Upvalue(idx) => {
if let Some(val) = sub.upvalues.get(idx) { inlined_val = Some(val.clone()); } if let Some(val) = sub.upvalues.get(idx) { inlined_val = Some(val.clone()); }
@@ -203,6 +233,9 @@ impl Optimizer {
}, },
BoundKind::DefLocal { name, slot, value, captured_by } => { BoundKind::DefLocal { name, slot, value, captured_by } => {
if !captured_by.is_empty() {
sub.captured_slots.insert(slot);
}
let value = Box::new(self.visit_node(*value, sub)); let value = Box::new(self.visit_node(*value, sub));
if let BoundKind::Constant(val) = &value.kind { if let BoundKind::Constant(val) = &value.kind {
sub.add_local(slot, val.clone()); sub.add_local(slot, val.clone());
@@ -244,6 +277,21 @@ impl Optimizer {
Node { identity: node.identity, kind: new_kind, ty: new_ty } Node { identity: node.identity, kind: new_kind, ty: new_ty }
} }
fn is_side_effect_free(&self, node: &TypedNode) -> bool {
match &node.kind {
BoundKind::Constant(_) | BoundKind::Get { .. } | BoundKind::Parameter { .. } | BoundKind::Nop | BoundKind::Lambda { .. } => true,
BoundKind::Tuple { elements } => elements.iter().all(|e| self.is_side_effect_free(e)),
BoundKind::Record { fields } => fields.iter().all(|(k, v)| self.is_side_effect_free(k) && self.is_side_effect_free(v)),
BoundKind::If { cond, then_br, else_br } => {
self.is_side_effect_free(cond) && self.is_side_effect_free(then_br) && else_br.as_ref().is_none_or(|e| self.is_side_effect_free(e))
}
BoundKind::Block { exprs } => exprs.iter().all(|e| self.is_side_effect_free(e)),
BoundKind::Expansion { bound_expanded, .. } => self.is_side_effect_free(bound_expanded),
BoundKind::DefLocal { value, .. } | BoundKind::Set { value, .. } => self.is_side_effect_free(value),
_ => false, // Call and DefGlobal are considered impure
}
}
fn try_beta_reduce(&self, params: &TypedNode, args: &TypedNode, body: TypedNode) -> Option<TypedNode> { fn try_beta_reduce(&self, params: &TypedNode, args: &TypedNode, body: TypedNode) -> Option<TypedNode> {
if self.contains_def_local(&body) { if self.contains_def_local(&body) {
return None; return None;
@@ -354,11 +402,20 @@ struct SubstitutionMap {
locals: HashMap<u32, Value>, locals: HashMap<u32, Value>,
/// Mapping Address::Upvalue(idx) -> Value /// Mapping Address::Upvalue(idx) -> Value
upvalues: HashMap<u32, Value>, upvalues: HashMap<u32, Value>,
/// Tracking which local slots are actually used (not inlined)
used_slots: HashSet<u32>,
/// Tracking which local slots are captured by lambdas
captured_slots: HashSet<u32>,
} }
impl SubstitutionMap { impl SubstitutionMap {
fn new() -> Self { fn new() -> Self {
Self { locals: HashMap::new(), upvalues: HashMap::new() } Self {
locals: HashMap::new(),
upvalues: HashMap::new(),
used_slots: HashSet::new(),
captured_slots: HashSet::new(),
}
} }
fn add_local(&mut self, slot: u32, val: Value) { fn add_local(&mut self, slot: u32, val: Value) {
@@ -376,7 +433,7 @@ impl SubstitutionMap {
if let Some(res) = mapping.get(idx as usize) { if let Some(res) = mapping.get(idx as usize) {
match res { match res {
Some(new_idx) => (BoundKind::Get { addr: Address::Upvalue(*new_idx), name }, node.ty), Some(new_idx) => (BoundKind::Get { addr: Address::Upvalue(*new_idx), name }, node.ty),
None => (BoundKind::Get { addr: Address::Upvalue(idx), name }, node.ty), // Should have been inlined None => (BoundKind::Get { addr: Address::Upvalue(idx), name }, node.ty),
} }
} else { } else {
(BoundKind::Get { addr: Address::Upvalue(idx), name }, node.ty) (BoundKind::Get { addr: Address::Upvalue(idx), name }, node.ty)
@@ -384,8 +441,6 @@ impl SubstitutionMap {
}, },
BoundKind::Lambda { params, upvalues, body, positional_count } => { BoundKind::Lambda { params, upvalues, body, positional_count } => {
// IMPORTANT: If this nested lambda captures an upvalue from our current scope,
// we MUST re-index it in its own capture list!
let mut next_upvalues = Vec::new(); let mut next_upvalues = Vec::new();
for addr in upvalues { for addr in upvalues {
if let Address::Upvalue(idx) = addr if let Address::Upvalue(idx) = addr
@@ -393,18 +448,14 @@ impl SubstitutionMap {
if let Some(new_idx) = res { if let Some(new_idx) = res {
next_upvalues.push(Address::Upvalue(*new_idx)); next_upvalues.push(Address::Upvalue(*new_idx));
} }
continue; // Inlined or re-indexed continue;
} }
next_upvalues.push(addr); next_upvalues.push(addr);
} }
// Note: We don't recurse into the body with the SAME mapping,
// because nested Get(Upvalue) nodes refer to THIS lambda's capture list.
(BoundKind::Lambda { params, upvalues: next_upvalues, body, positional_count }, node.ty) (BoundKind::Lambda { params, upvalues: next_upvalues, body, positional_count }, node.ty)
}, },
BoundKind::If { cond, then_br, else_br } => { BoundKind::If { cond, then_br, else_br } => {
let cond = Box::new(self.reindex_upvalues(*cond, mapping)); let cond = Box::new(self.reindex_upvalues(*cond, mapping));
let then_br = Box::new(self.reindex_upvalues(*then_br, mapping)); let then_br = Box::new(self.reindex_upvalues(*then_br, mapping));
let else_br = else_br.map(|e| Box::new(self.reindex_upvalues(*e, mapping))); let else_br = else_br.map(|e| Box::new(self.reindex_upvalues(*e, mapping)));
@@ -441,3 +492,50 @@ impl SubstitutionMap {
Node { identity: node.identity, kind: new_kind, ty: new_ty } Node { identity: node.identity, kind: new_kind, ty: new_ty }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::environment::Environment;
fn get_optimized_dump(source: &str, level: u32) -> String {
let mut env = Environment::new();
env.optimization_level = level;
env.dump_ast(source).expect("Compilation failed during test")
}
#[test]
fn test_opt_folding() {
let dump = get_optimized_dump("(+ 10 20)", 2);
insta::assert_snapshot!(dump);
}
#[test]
fn test_opt_local_inlining() {
// Wrap in a do-block within a lambda to force DefLocal
let source = "(fn [] (do (def x 10) (+ x 5)))";
let dump = get_optimized_dump(source, 2);
insta::assert_snapshot!(dump);
}
#[test]
fn test_opt_dce_unused() {
let source = "(fn [] (do (def x 10) 42))";
let dump = get_optimized_dump(source, 2);
insta::assert_snapshot!(dump);
}
#[test]
fn test_opt_assignment_safety() {
let source = "(fn [] (do (def x 10) (assign x 20) x))";
let dump = get_optimized_dump(source, 2);
insta::assert_snapshot!(dump);
}
#[test]
fn test_opt_lambda_cracking() {
let source = "(fn [] (do (def x 10) (fn [] x)))";
let dump = get_optimized_dump(source, 2);
insta::assert_snapshot!(dump);
}
}