fix: emit lifetime markers for lowered call temporaries - #1803
Conversation
Build Artifacts🐧 Linux
From workflow run 🪟 Windows
From workflow run |
|
ui .... bit unusual to see such an optimization in the frontend, but the complexity is surprisingly low. On the other hand, aren't both strategies (lifetimes and tmp-slots) try to solve the exact same problem? do the lifetimes still have any effect if you optimize already in the compiler-frontend? |
... I just re-read my comment and I have to add - the complexity is lower than I expected, but it is still very unusual complexity in the compiler frontend 😆 |
So i wanted to only do the lifetimes, but the problem is that the builder we are shipping in the IDE builds with |
Every call to an aggregate-returning function allocates a caller-side temporary sized to the declared return type (STRING[2048] for the stdlib string functions). These allocas carried no lifetime information, so LLVM kept one dead slot per call site alive for the whole function: string-heavy POU bodies easily exceed 100 KB of frame and overflow constrained runtime task stacks. Bracket each lowered statement's temporaries with llvm.lifetime.start/end so LLVM's stack coloring can overlap slots of consecutive statements; a string-heavy test body shrinks from ~111 KB to ~10 KB of frame at the default optimization level. Unoptimized builds are unchanged: stack coloring is a machine pass that only runs when the backend optimizes. Temps whose address outlives their statement are pinned instead (Allocation::statement_scoped = false) and get no markers: ADR/REF arguments, REF= right-hand sides, and interface fat-pointer captures (the polymorphism lowering wraps those in ADR before this pass runs). Loop bookkeeping temporaries keep their function-long storage for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9d1d102 to
e1a8cda
Compare
Backport of #1803 to `release/1.0.x`. ## Problem Calling a function that returns an aggregate (string, struct) allocates a caller-side temporary sized to the *declared* return type — `STRING[2048]` for every stdlib string function — with no lifetime information attached, so LLVM keeps every slot alive for the whole function. A body with ~50 string operations already needs >100 KB of frame and overflows small task stacks at function entry. ## Fix Emit `llvm.lifetime.start`/`end` around each lowered statement's call temporaries so LLVM's stack coloring can overlap slots whose lifetimes don't intersect. Temporaries whose address escapes their statement (`ADR`/`REF` arguments, `REF=` right-hand sides, interface fat-pointer captures) are pinned and keep their function-long slot; loop bookkeeping temporaries are likewise unaffected. The markers only take effect when the LLVM backend optimizes: stack coloring is a machine pass that does not run at `-Onone`. A companion change for this lane enabling that pass for `-Onone` builds follows separately. Differences from the master commit are conflict resolution only: this lane's `AggregateTypeLowerer` predates the #1794 rewrite, so the address-context tracking is applied to the older structure. ## Testing Full unit suite, snapshot tests (no drift against this lane's baseline) and the lit end-to-end suite pass on the branch; includes the new marker-placement, pinning, and unassigned-return tests from #1803. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Problem
Calling a function that returns an aggregate (string, struct) allocates a caller-side temporary to hold the result. Each call site gets its own temporary sized to the declared return type —
STRING[2048]for every stdlib string function (CONCAT,*_TO_STRING, …) — with no lifetime information attached, so LLVM keeps every slot alive for the whole function. A body with ~50 string operations already needs >100 KB of frame and overflows small task stacks at function entry.Fix
Emit
llvm.lifetime.start/endaround each lowered statement's call temporaries, so LLVM's stack coloring can overlap slots whose lifetimes don't intersect. The frame then grows with the deepest single statement instead of the number of call sites: a string-heavy test body drops from ~111 KB to ~10 KB of frame at the default optimization level.Temporaries whose address escapes their statement must not be marked dead at statement end — stack coloring could hand their slot to a later statement while a pointer still refers to it. These are pinned (
Allocation::statement_scoped = false, no markers):ADR/REFarguments,REF=right-hand sides, and interface fat-pointer captures (already wrapped inADRby the polymorphism lowering). Loop bookkeeping temporaries keep their function-long storage since their values cross iterations.Scope
This only benefits builds where the LLVM backend optimizes: stack coloring is a machine pass that does not run at
-Onone, so unoptimized builds keep their current frame sizes. Planned follow-ups (separate PRs):writeonlywould let LLVM eliminate those memsetsTesting
string_testsADRare pinned and later statements get their own slot🤖 Generated with Claude Code