From e68f228a5d7f0f971231005a23f1811dcdffdfa4 Mon Sep 17 00:00:00 2001 From: Peter Lukka Date: Thu, 11 Jun 2026 05:26:05 +0000 Subject: [PATCH] fix(codegen): hoist loop-body allocas to the function entry block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Stmt::Let` emitted `build_alloca` at the builder's current insertion point, so a `let` binding inside a loop body placed its alloca inside the loop block (e.g. `while_body`). An LLVM alloca in a non-entry block re-executes every iteration — a fresh dynamic stack allocation that 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 optimization. This overflowed the 8 MB main-thread stack in Olorin's `log_level_scan.ea` on >=1 MB inputs (~8 bytes of stack per input byte) once its SIMD body carried ~50+ u8x16 `let` bindings. At -O0 the kernel emitted 68 allocas inside `while_body71`; only the 7 params were in the entry block. Add `CodeGenerator::entry_block_alloca`, which builds the alloca with a throwaway builder positioned before the entry block's first instruction (the caller's insertion point is left untouched). Apply it to `Stmt::Let` and the struct-literal `struct_tmp`. `Stmt::ForEach` already implemented this inline ("Alloca in function entry block ... to avoid stack growth at O0 where mem2reg does not run") and is consolidated onto the helper. Regression test `tests/loop_alloca_hoisting.rs` asserts every alloca in the unoptimized frontend IR lives in the entry block. Verified by a -O0 + `ulimit -s 2048` runtime A/B on `log_level_scan.ea`: pre-fix SIGSEGV on a 4 MB input, post-fix clean exit on 4 MB and 20 MB. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/codegen/mod.rs | 30 +++++++++++ src/codegen/statements.rs | 25 +++------- src/codegen/structs.rs | 7 ++- tests/loop_alloca_hoisting.rs | 94 +++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 tests/loop_alloca_hoisting.rs diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index bf051d9..0bef2fb 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -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> { + 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, diff --git a/src/codegen/statements.rs b/src/codegen/statements.rs index c50b324..47bd1f9 100644 --- a/src/codegen/statements.rs +++ b/src/codegen/statements.rs @@ -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) @@ -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 diff --git a/src/codegen/structs.rs b/src/codegen/structs.rs index 2db99d8..8640759 100644 --- a/src/codegen/structs.rs +++ b/src/codegen/structs.rs @@ -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 diff --git a/tests/loop_alloca_hoisting.rs b/tests/loop_alloca_hoisting.rs new file mode 100644 index 0000000..bce326e --- /dev/null +++ b/tests/loop_alloca_hoisting.rs @@ -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 `