Skip to content

Stack promotion: short-lived objects on the stack under every memory model - #605

Merged
ASDAlexander77 merged 14 commits into
mainfrom
stack-promotion-design
Oct 18, 2026
Merged

ASDAlexander77 merged 14 commits into
mainfrom
stack-promotion-design

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Summary

An allocation whose object provably never outlives its function now goes in the function's stack frame instead of on the heap, under every memory model (gc, rc, own, none). It is automatic and silent: a site the analysis cannot prove safe stays on the heap exactly as before.

  • What is promoted: class instances (new C(), and C(), which now means exactly the same), object literals with methods, array literals that never change length, and closure capture boxes (gc/none).
  • How: a new StackPromotionPass runs at the affine level after own's inference. StackEscapeFacts computes, for every function in the module, whether each parameter may escape (to a fixpoint, with virtual calls resolved through the module's vtables). A promoted object is an entry-block block laid out like a heap block with an immortal header, so every retain, release and destroy leaves it alone. Under rc and own, the new ts.ReleaseContents op gives back what its fields own: at the allocation site with reset (for loop reuse) and before every return.
  • Stays on the heap: returned, thrown, stored or converted objects; calls into other modules; functions with try, async, generators and recursion (including recursion through function values); loop-carried objects; objects over 256 bytes or past a 4096-byte frame budget; under rc/own, objects owning memory in a module that uses Shared (because Shared.count would see the later release).
  • Flags: --no-stack-promotion, --stack-promotion-max-object=, --stack-promotion-max-frame=, --stack-promotion-report (per-site remark: stack, or heap: <reason>).

Design: tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md. Plan, with the deviations listed: tslang/docs/superpowers/plans/2026-10-18-stack-promotion.md. User docs: docs/memory-models.md.

Measured

Milliseconds, best of three, --opt --opt_level=3, --no-stack-promotion → default:

program gc rc own none
vector temporaries (5M iterations) 18 → 20 296 → 16 287 → 27 27 → 20
ray tracer 342 → 341 153 → 170 — 182 → 157

The ray tracer under rc is about 8% slower and this is not understood. Only two one-time setup objects are promoted, the hot functions' IR is identical, and a build that runs the analysis but promotes nothing matches --no-stack-promotion.

Tests

  • tslang/test/tester/stack-promotion/: a run test per program under all four models, plus a decision test per escape rule asserting the remark.
  • Full ctest suite: 4599/4599 with promotion on, and 4599/4599 with the new TSLANG_TEST_NO_STACK_PROMOTION=ON option (every runner test built with --no-stack-promotion).
  • Teeth: with the argument rule disabled, pass_to_keeper and ctor_keeps_this read dangling stack memory and fail under every model.

Follow-ups (not in this PR)

  • Early-return and break uses inside a loop count as loop-carried.
  • A directly called local closure keeps its heap box.
  • The try-region exclusion is unnecessary under gc/none.
  • Recursion detection should be one call-graph pass.
  • Unrelated bug found while testing: truthiness of an any holding a class instance throws "uncaught exception".

🤖 Generated with Claude Code

ASDAlexander77 and others added 14 commits October 18, 2026 17:29
Escape analysis picks stack or heap for every class, array, boxed object
literal and capture box allocation, under all memory models; Class() is
the same as new Class().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Calling a class without new went through a stack-allocation path that
rejected every class (all have a virtual table now). It is now the same
C..new call plus constructor call as new C().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--no-stack-promotion, --stack-promotion-max-object/-max-frame and
--stack-promotion-report; the pass runs under every model after own's
inference. test-runner takes -no-stack-promotion with its own script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A boxed object literal or a new C() whose object is only read and
written through its fields, and owns no heap memory, goes on the stack:
an entry-block block with the heap layout and an immortal header. A
C..new call is replaced by its body with the ts.New on the stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A per-parameter 'may escape' fact for every function body in the module,
to a fixpoint; virtual calls take every vtable slot of the same method,
and only for a class whose vtable is local to the module. Objects
followed through local slots; a site in a loop whose object may reach
the next iteration stays on the heap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ts.ReleaseContents gives back what a stack object's fields own without
freeing it. A promoted ts.New moves to the entry block, so the object
dominates every return; where the allocation was, ReleaseContents reset
releases the previous iteration's contents and zeroes the object, and
each return gives back the last one. Functions with a try region, and
under rc/own objects owning memory in a module that uses Shared, stay
on the heap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An object over --stack-promotion-max-object, or past the function's
--stack-promotion-max-frame (sites in loops first), stays on the heap;
so do sites in recursive functions, async functions and generator
bodies, and thrown objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An array literal that never changes length and never leaves the
function gets its header and elements in the frame; under rc and own
only when its elements own nothing. A closure's capture box goes in the
frame when its closures stay in the function, under gc and none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TSLANG_TEST_NO_STACK_PROMOTION passes -no-stack-promotion to every
test-runner test, so the suite can be compared both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/memory-models.md describes what is promoted, what stays on the
heap, the limits and flags, and measured times under every model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A virtual call's candidates are trusted only when every vtable holding
that slot is local to the module: an exported subclass may be extended
elsewhere. A call through a value the module cannot resolve may reach
any function whose address is taken, so recursion through a closure or
a function variable is seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit cadc62f into main Oct 18, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the stack-promotion-design branch October 18, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant