Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,36 @@ impl<'ctx> CodeGenerator<'ctx> {
}
}

/// Allocate a stack slot in the function's entry block, regardless of where
/// the builder is currently positioned.
///
/// `alloca` re-executes every time control reaches it, so an alloca emitted
/// inside a loop body allocates fresh stack on each iteration and is never
/// reclaimed until the function returns — the stack grows without bound.
/// mem2reg/SROA only seed promotion from the entry block, so such allocas
/// also survive optimization. Hoisting every alloca to the entry block (the
/// standard "static alloca" frontend pattern) makes it a single fixed slot
/// that promotes reliably at every opt level. Uses a throwaway builder so
/// the caller's insertion point is left untouched.
pub(crate) fn entry_block_alloca(
&self,
function: FunctionValue<'ctx>,
llvm_ty: BasicTypeEnum<'ctx>,
name: &str,
) -> crate::error::Result<PointerValue<'ctx>> {
let builder = self.context.create_builder();
let entry = function.get_first_basic_block().ok_or_else(|| {
CompileError::codegen_error("function has no entry block for alloca".to_string())
})?;
match entry.get_first_instruction() {
Some(first) => builder.position_before(&first),
None => builder.position_at_end(entry),
}
builder
.build_alloca(llvm_ty, name)
.map_err(|e| CompileError::codegen_error(e.to_string()))
}

pub(crate) fn type_alignment(ty: &Type) -> u32 {
match ty {
Type::Bool | Type::I8 | Type::U8 => 1,
Expand Down
25 changes: 6 additions & 19 deletions src/codegen/statements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ impl<'ctx> CodeGenerator<'ctx> {
let declared = Self::resolve_annotation(ty);
self.validate_type_for_target(&declared)?;
let llvm_ty = self.llvm_type(&declared);
let alloca = self
.builder
.build_alloca(llvm_ty, name)
.map_err(|e| CompileError::codegen_error(e.to_string()))?;
// Hoist to the entry block so a `let` inside a loop body does
// not allocate fresh stack on every iteration. See
// CodeGenerator::entry_block_alloca.
let alloca = self.entry_block_alloca(function, llvm_ty, name)?;
let val = self.compile_expr_typed(value, Some(&declared), function)?;
self.builder
.build_store(alloca, val)
Expand Down Expand Up @@ -287,21 +287,8 @@ impl<'ctx> CodeGenerator<'ctx> {
// Alloca in function entry block (not loop block) to avoid
// stack growth at O0 where mem2reg does not run.
let i32_type = self.context.i32_type();
let fn_entry = function.get_first_basic_block().unwrap();
let alloca = if let Some(first_instr) = fn_entry.get_first_instruction() {
self.builder.position_before(&first_instr);
let a = self
.builder
.build_alloca(i32_type, var)
.map_err(|e| CompileError::codegen_error(e.to_string()))?;
self.builder.position_at_end(cond_bb);
a
} else {
self.builder.position_at_end(cond_bb);
self.builder
.build_alloca(i32_type, var)
.map_err(|e| CompileError::codegen_error(e.to_string()))?
};
let alloca = self.entry_block_alloca(function, i32_type.into(), var)?;
self.builder.position_at_end(cond_bb);

// Condition block with phi node
let phi = self
Expand Down
7 changes: 3 additions & 4 deletions src/codegen/structs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@ impl<'ctx> CodeGenerator<'ctx> {
let field_map = self.struct_fields.get(name).cloned().ok_or_else(|| {
CompileError::codegen_error(format!("unknown struct fields for '{name}'"))
})?;
let alloca = self
.builder
.build_alloca(struct_type, "struct_tmp")
.map_err(|e| CompileError::codegen_error(e.to_string()))?;
// Entry-block alloca so a struct literal inside a loop body does not
// leak stack per iteration. See CodeGenerator::entry_block_alloca.
let alloca = self.entry_block_alloca(function, struct_type.into(), "struct_tmp")?;

for (field_name, field_expr) in fields {
let (idx, field_type) = field_map
Expand Down
94 changes: 94 additions & 0 deletions tests/loop_alloca_hoisting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Regression: `let` bindings inside a loop body must alloca in the function
// entry block, not the loop body. An alloca in a loop body is a dynamic stack
// allocation that re-executes every iteration and is never reclaimed until the
// function returns, so the stack grows per iteration. mem2reg/SROA only seed
// promotion from the entry block, so loop-body allocas also survive past -O0.
//
// This is the root cause of the eacompute stack-overflow found via Olorin's
// log_level_scan.ea: a SIMD loop body with ~50 u8x16 `let` bindings overflowed
// the 8 MB main-thread stack on >=~1 MB inputs (~8 bytes of stack per input
// byte). The frontend emits unoptimized IR here, so the assertion is exact and
// opt-level independent.

#[cfg(feature = "llvm")]
mod common;

#[cfg(feature = "llvm")]
mod tests {
use super::common::*;

/// Assert that every `alloca` in `ir` lives in a basic block named `entry`.
/// Tracks the current block label while scanning the IR text.
fn assert_all_allocas_in_entry(ir: &str) {
let mut current_block = String::new();
for line in ir.lines() {
let trimmed = line.trim();
// Basic-block label lines look like `while_body71:` or
// `while_body71: ; preds = ...`. Strip any `; ...` comment first
// (the `preds = ...` comment contains `=`), then match `<label>:`
// with nothing but whitespace before the colon.
let code = trimmed.split(';').next().unwrap_or("").trim();
if let Some(label) = code.strip_suffix(':') {
let is_label = !label.is_empty()
&& label
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.');
if is_label {
current_block = label.to_string();
continue;
}
}
if trimmed.contains("= alloca") {
assert_eq!(
current_block, "entry",
"alloca emitted in block `{current_block}`, expected `entry`:\n {trimmed}\n\nFull IR:\n{ir}"
);
}
}
}

#[test]
fn let_bindings_in_while_body_alloca_in_entry() {
// Mirrors the log_level_scan.ea pattern: many vector `let`s per chunk.
let source = r#"
export func k(text: *u8, n: i32, out: *mut i32) {
let mut i: i32 = 0
while i + 16 <= n {
let a: u8x16 = load(text, i)
let b: u8x16 = load(text, i + 1)
let c: u8x16 = a .& b
let d: u8x16 = a .| b
let e: u8x16 = select(a .== b, c, d)
let f: u8x16 = c .& d
out[0] = out[0] + to_i32(reduce_add(e))
out[1] = out[1] + to_i32(reduce_add(f))
i = i + 16
}
}
"#;
let ir = compile_to_ir(source);
assert_all_allocas_in_entry(&ir);
}

#[test]
fn nested_while_let_bindings_alloca_in_entry() {
// A `let` inside a nested loop is the worst case: per-iteration growth
// multiplied across both loop trip counts.
let source = r#"
export func k(text: *u8, n: i32, out: *mut i32) {
let mut i: i32 = 0
while i + 16 <= n {
let mut j: i32 = 0
while j < 4 {
let v: u8x16 = load(text, i)
out[0] = out[0] + to_i32(reduce_add(v))
j = j + 1
}
i = i + 16
}
}
"#;
let ir = compile_to_ir(source);
assert_all_allocas_in_entry(&ir);
}
}
Loading