fix(ir): release loop-carried locals whose slot widens to boxed storage after their store was lowered#535
Open
mirchaemanuel wants to merge 1 commit into
Conversation
…heir store was lowered (illegalstudio#534) A local stored inside a loop while its slot still looked like a plain scalar skipped the release-old path, but a later store on a back-edge path (e.g. the inner for counter's checked-add update) could widen the slot to boxed Mixed storage. Each outer iteration's re-initialization then orphaned the previous iteration's box, leaking exactly N_outer-1 blocks. Lowering now emits a deferred release_local_slot before such stores; the backend releases the occupant using the slot's FINAL widened storage type (null-guarded, so the zero-initialized first iteration is safe), and lowering prunes the op to a nop when the slot never widens so scalar slots stay eligible for dead-store elimination. Claude-Session: https://claude.ai/code/session_01Jfn7uqEH5Dog1nBzTM5DCW
This was referenced Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #534 — with the issue's mechanism hypothesis refuted and the real one found at the IR level.
Root cause (not what the issue guessed)
The leaked value was never the assigned local: diffing
--emit-irof the leaky vs clean programs shows the nested case does emit release-old for$s. The real culprit is the inner loop counter:$k++lowers through the checked-add path whose result is boxedMixed(overflow-to-float), which widens the counter's frame slot storage Int→Mixed. But the outer body's$k = 0re-initialization was lowered earlier, while the slot storage still readInt, sorelease_stored_local_value()early-returned on the stale type and emitted no release. At runtime every store into the (final) Mixed slot boxes — so each outer iteration's re-init orphaned the previous iteration's box: exactlyN_outer − 1leaks, independent of the inner trip count and of the loop body entirely. Proof: an int-only nested body leaks the identical 999 × 40 B on baseline.Flags isolation confirmed core lowering (leak unchanged under
--ir-opt=off,--regalloc=stack, and both).The fix: defer the release decision until the slot's final storage type exists
The correct decision needs the slot's final storage type, which only exists after the whole body is lowered. So:
ReleaseLocalSlot(src/ir/instr.rs, validator entry): void,LocalSlotimmediate,READS_LOCAL | WRITES_HEAP | REFCOUNT_OP.src/ir_lower/context.rs—release_stored_local_value_before_retaining_store(): when the slot storage is already lifetime-tracked, the eager load+release is emitted exactly as before (IR unchanged for every previously-correct case). When it is not, and the store is inside a loop (only there can a later store widen it through a back-edge), the deferred op is emitted instead. Ref-bound locals excluded (they release through the ref-cell owner machinery). All three release-old paths instore_localroute through it.src/ir/builder.rs+src/ir_lower/function.rs— post-lowering prune rewrites the op toNopwhen the slot never widened, so scalar slots stay eligible for DSE/load-forwarding (verified in IR: a never-widened slot's op becomesnop; the widened counter's op survives).src/codegen/lower_inst.rs—lower_release_local_slot(): skips ref-cell-pointer/promoted slots (defense in depth with the lowering-side exclusion), then releases the occupant typed by the final storage via the existing null-guarded epilogue cleanup emitters (emit_main_string_cleanup/emit_main_refcounted_cleanup, madepub(super)) — target-aware for ARM64 and x86_64, null-safe on the zero-initialized first iteration.src/ir_passes/peephole/load_store.rs: op added to the escaping-slots set (slot memory is read directly).docs/internals/memory-model.md.Balance argument: the deferred op makes the same decision the eager path makes for tracked slots — release the previous occupant of a slot a retaining store is about to overwrite — just at the point where the storage type is finally known. The slot is either zero (prologue-initialized; the helpers skip null) or holds the previous retaining store's owned value.
Evidence (base
cf47ee101, macOS arm64; every output equal to PHP 8.4.23 pre/post)Post-fix
--ir-opt=offand--regalloc=stackalso clean.Real-world gates (the #516 app):
--web, measured with per-request gc counters on a local merge of fix(codegen): release the inner-container temporary of a chained subscript read #524+fix(ir_lower): release the nullable-access hidden temp after its consuming chained read #531+fix(ir_lower): release owned Mixed sources after string coercion (foreach element leak) #532+this: net leak 6,252 → ~31 blocks/request (−99.5%); the latency ramp that reached 1,233 ms by request 90 on main stays under ~75 ms through request 160. (A separate byte-heavy residual — few large blocks, ~370 KB/request — still exhausts a 64 MB heap around request ~175 without recycling; being tracked in --web: linear per-worker latency ramp is a per-request refcount leak (chained subscript reads on nested arrays); --max-requests recycles miscounted by the crash-loop guard #516.)Tests
4 new regression tests in
tests/codegen/runtime_gc/regressions.rs(concat, array element, int-counter, conditional+triple-nesting-in-function): 0/4 pass on a stashed-src baseline build, 4/4 post-fix. Focused suites, 0 failures: loops 37, while 20, foreach 103, runtime_gc 126, nested 124, strings 225, arrays 337, for 446 — 1,418 passed. Zero warnings in both build profiles; debug-profile IR validator clean on all repros and the harness; asm-comment checker andgit diff --checkclean.Notes for the maintainer
src/ir_lower/context.rsandsrc/codegen/frame.rsin hunks disjoint from fix(ir_lower): release the nullable-access hidden temp after its consuming chained read #531/fix(ir_lower): release owned Mixed sources after string coercion (foreach element leak) #532/fix(codegen): guard container consumers against the null-container sentinel (chained-read miss segfault) #533/fix(codegen): release the inner-container temporary of a chained subscript read #524;tests/codegen/runtime_gc/regressions.rswill union-conflict with the other pending PRs (all sides append tests — resolution is keep-both; verified compiling and passing on the local merge of all of them).