diff --git a/docs/memory-models.md b/docs/memory-models.md index 29926ffaa..f3ef84846 100644 --- a/docs/memory-models.md +++ b/docs/memory-models.md @@ -105,6 +105,67 @@ than falling back to another model's copy — that would link and then misbehave Build them with the default library's `build.bat` (`build.sh` on Linux), which produces all three. +## Stack promotion + +Under every model, an object that provably never outlives the function that made it is put in +that function's stack frame instead of on the heap. This happens automatically, and you never +see it: the program does exactly what it did before, only with fewer heap allocations. `new C()` +and `C()` are the same thing, and either may end up on the stack. + +The compiler promotes: + +- **class instances** and **object literals with methods**, when the object is only read and + written through its fields, has its methods called, is compared, or is passed to functions in + the same module that do not keep it; +- **array literals** that never change length (no `push`, `pop`, `splice`, `length =`) and are + never handed somewhere that might change or keep them; under `-mm=rc` and `-mm=own`, only when + the elements own no memory (numbers, booleans); +- a **closure's capture box**, when the closure never leaves the function; under `-mm=gc` and + `-mm=none` only. + +Anything the compiler cannot prove safe stays on the heap, silently. In this version that +includes an object that is: + +- returned, thrown, stored in a field, array, global or captured variable, or converted to + `any`, an interface or a union; +- passed to a function in another module, including the default library; +- made in a function that has a `try`, or is `async`, a generator, or recursive; +- still in use when the next iteration of its loop makes a new one. + +Under `-mm=rc` and `-mm=own`, an object that owns memory stays on the heap in a module that uses +`Shared`, because `Shared.count` would see its contents given back later. + +Two limits keep frames small: one object may take at most 256 bytes +(`--stack-promotion-max-object=`), and one function at most 4096 bytes in all +(`--stack-promotion-max-frame=`). These are estimates, made before the exact layout is known. + +`--no-stack-promotion` turns the feature off. `--stack-promotion-report` prints, for every +allocation, whether it went on the stack and, if not, why: + +``` +p.ts:11:19: remark: stack-promotion: stack +p.ts:8:15: remark: stack-promotion: heap: returned +``` + +Measured, best of three, ahead-of-time build with `--opt --opt_level=3`, in milliseconds. Each +cell is `--no-stack-promotion` → default: + +| program | `-mm=gc` | `-mm=rc` | `-mm=own` | `-mm=none` | +| --- | --- | --- | --- | --- | +| vector temporaries, 5 million iterations | 18 → 20 | 296 → 16 | 287 → 27 | 27 → 20 | +| ray tracer | 342 → 341 | 153 → 170 | — | 182 → 157 | + +The gain is where a heap allocation is expensive: under `-mm=rc` and `-mm=own`, an object made +and dropped in a loop costs a `malloc` and a `free` per iteration, and promotion removes both. +Under `-mm=gc` the optimiser already deletes an allocation nothing reads, so there is little left +to gain. `—`: the program does not compile under `-mm=own`. + +The ray tracer under `-mm=rc` is slower with promotion, although only two objects in its one-time +scene setup are promoted, and the code of its hot loop is identical either way. A build that runs +the analysis but promotes nothing times the same as `--no-stack-promotion`, so the difference +follows the two promotions themselves, most likely because two fewer heap allocations at start-up +move where later objects land. It is not understood beyond that. + ## Other limits of `-mm=rc` - **Objects crossing between differently-managed modules are never freed.** If you link a diff --git a/tslang/docs/superpowers/plans/2026-10-18-stack-promotion.md b/tslang/docs/superpowers/plans/2026-10-18-stack-promotion.md new file mode 100644 index 000000000..acb58f4e8 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-18-stack-promotion.md @@ -0,0 +1,244 @@ +# Stack Promotion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allocate class instances, boxed object literals, array headers, fixed arrays and capture boxes on the stack when an escape analysis proves they never outlive their function, under every memory model, silently falling back to the heap otherwise. + +**Architecture:** A new module pass, `StackPromotionPass`, runs after the affine lowering (and after own's inference), computes per-function "parameter may escape" facts over the call graph, then marks allocation sites whose results provably do not escape with a `stackAlloc` attribute. An allocation site is a `ts.New` (boxed object literal, class expression) or, for `new C()`, the call of the class's generated `C..new` method (see "How a class allocation site looks" below); a promoted `C..new` call is replaced by an inline `ts.New stackAlloc` plus the vtable store. LLVM lowering turns a marked allocation into an entry-block `alloca` laid out exactly like a heap block (header word = `HEAP_BLOCK_IMMORTAL`, then payload). Under rc and own, the pass inserts a new `ts.ReleaseContents` op so what the object owns is given back without freeing the object. + +**Tech Stack:** C++17, MLIR (TypeScript dialect, LLVM dialect), LLVM, CMake/ctest, `test-runner`. + +**Spec:** `tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md` + +## Global Constraints + +- Default limits: at most **256 bytes** per promoted object, at most **4096 bytes** promoted per function. Only compile-time-known sizes qualify. +- On by default at every optimisation level, including `-O0`; `--no-stack-promotion` turns it off. +- Unknown callee (declared, imported, indirect, virtual with any candidate not defined in this module) = escape. Anything the analysis cannot classify = escape. +- No promotion inside async functions or generators (version 1). +- No promotion in a function on a direct call-graph cycle in its module (recursion). +- No clone, no copy, no warning, no error: a site that escapes is an ordinary heap site. +- `Class()` means exactly `new Class()`. +- Output of every existing test must be identical with and without `--no-stack-promotion`, under gc, rc, own and none. +- All paths in this plan are relative to `tslang/` unless absolute. One exception: the user docs live at the repository root, `I:\TypeScriptCompiler\docs\memory-models.md` (`../docs/memory-models.md` from `tslang/`); there is no `tslang/docs/memory-models.md`. +- Sources are CRLF; edit with the Edit tool, never `sed -i`. +- `--verify-ownership` runs **before** own's inference and therefore before this pass (`tslang/transform.cpp:136-150`: the verifier pairs rc's acquisitions with their releases, and inference erases the acquisitions it proves are the one owner's, so the verifier cannot run after inference). The verifier never sees promoted IR. No test in this plan claims it does; teaching it `ReleaseContents` and scheduling a second run is a follow-up. + +## How a class allocation site looks + +`new C()` does **not** put a `ts.New` in the caller. With `USE_NEW_AS_METHOD` (`include/TypeScript/Config.h:28`, always on), `NewClassInstanceAsMethodCallOp` (`lib/TypeScript/MLIRGenAccessCall.cpp:2554-2584`) emits a call of the class's generated static method `C..new` (`NEW_METHOD_NAME` = `.new`, built in `MLIRGenClasses.cpp:1795-1840`), whose body is `return new this` with the constructor call suppressed: a `ts.New`, the vtable store (`mlirGenSetVTableToInstance`: `ts.AddressOf` of the class vtable + field ref + `ts.Store`), then the return. The caller then calls the constructor with the result as `this`. The call result carries `__owned_result` (`OWNED_RESULT_ATTR_NAME`): `C..new`'s return path retains, so the instance arrives owned, and `OwnedReturnConsumptionPass` (before the affine lowering) has already decided who consumed it by the time this pass runs. After Task 1 the old `Class()` path is gone, so the only inline class `ts.New` in a user function is a class expression (`MLIRGenExpressions.cpp:2145`); boxed object literals (`ts.New` of a `ValueRefType`) are inline. The pass runs before the inliner, so `--opt` does not change this. + +The pass therefore recognises **two kinds of site**: + +1. a `ts.New` op (object literal box, class expression); +2. a zero-operand `ts.SymbolCallInternal` (or `ts.Invoke`, which only occurs in a try region and is excluded anyway) whose callee, via `own_facts::directCallee`, is a `ts.Func` named `..new` **defined in this module** with a body (an imported class has a declaration only: unknown callee, heap). + +The value under analysis is the call result; the constructor call that follows is an ordinary call with the object as `this`, judged by Task 4's facts. When a kind-2 site is promoted, the pass replaces the call: it creates `ts.New stackAlloc = true` of the class type at the call, clones the vtable-store ops from the first block of `C..new`'s body (everything between its `ts.New` and the store into its return slot; the body has no arguments, so the mapping is the cloned `ts.New` only), copies every attribute of the call op onto the new `ts.New` (so `__owned_result` and the `__owned_consumed` on the receiving slot stay consistent; no LLVM lowering reads `__owned_result`), and replaces the call result. If the clone turns out brittle (the `.new` body shape changes), the fallback is MLIRGen emitting `ts.New` + vtable store inline for a class defined in the current module and the `C..new` call only for a declaration; that is a larger, rc-visible change and is not the plan's choice. + +## Calls this plan makes that differ from the spec's wording + +- Functions with a try/catch/finally region promote nothing in version 1 (spec §6 has the scope-exit action run on unwind paths too). +- A separate `StackEscapeFacts` analysis instead of running `OwnershipSignaturePass` under every model (spec §5.1 left the choice to the builder; own's facts assume a closed world and retain ops, which gc IR does not have). +- Every conversion to `any`, an interface, a union or `unknown` is an escape (spec §5.2 only counts it when the converted value escapes). + +## Review Focus + +1. **An object stored into a local that outlives a loop iteration** (`let keep; for (...) { const p = new P(); if (c) keep = p; }`) - must go to the heap, because the one entry-block slot is reused next iteration. Test: Task 4 `loop_carried.ts`. +2. **A method that stores `this`** (`register() { registry.push(this); }`) called on a local - `this` is a parameter; the call is an escape. Test: Task 4 `method_keeps_this.ts`. +3. **A virtual call where one override keeps its argument** - the base method borrows, the derived one stores. Must be heap. Test: Task 4 `virtual_one_keeps.ts`. +4. **rc/own: an object whose field is overwritten several times, then the function returns** - every old field value released once, the last one released at exit, nothing released twice. Test: Task 5 `field_overwrite.ts` under rc and own (run tests; the verifier cannot check this, see Global Constraints). +5. **A promoted object compared, `instanceof`-tested, or printed through its own `toString`** - none of these escape; output identical to the heap version. Test: Task 4 `borrow_uses.ts`. +6. **A `new C()` site is a `C..new` call, not a `ts.New`** - the pass must recognise the call (see "How a class allocation site looks") and the replacement must leave rc/own bookkeeping balanced. Test: Task 3 `leaf_local.ts` under all four models. +7. **`return p.s` from a promoted object under rc and own** - the returned string must be retained before the object's contents are released. Test: Task 5 `return_field.ts`. + +--- + +## File Structure + +| file | responsibility | +| --- | --- | +| Create `lib/TypeScript/StackPromotionPass.cpp` | the pass: escape facts, per-site decision, limits, report remarks, `ReleaseContents` insertion | +| Create `lib/TypeScript/StackEscapeFacts.h` | the per-function "parameter may escape" fact computation (module fixpoint), separate so it can later be read from declarations (spec §8.2) | +| Modify `include/TypeScript/Passes.h`, `lib/TypeScript/CMakeLists.txt` | declare and build `createStackPromotionPass(const CompileOptions &, StackPromotionLimits)` | +| Modify `include/TypeScript/TypeScriptOps.td` | `stackAlloc` on `NewArrayOp`/`CreateArrayOp`; new `ReleaseContentsOp` | +| Modify `lib/TypeScript/LowerToLLVM.cpp` | stack lowering of `NewOp`, array ops, captured `VariableOp`; `ReleaseContentsOpLowering` | +| Modify `include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h` | `StackBlockAlloc(mlir::Type storageType)` returning a payload pointer | +| Modify `include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h` | `emitReleaseContents(mlir::Type storageType, mlir::Value payloadPtr)` | +| Modify `lib/TypeScript/MLIRGenAccessCall.cpp` | `Class()` = `new Class()` | +| Modify `lib/TypeScript/OwnershipFacts.h` | virtual-candidate resolution and `followClosure` moved here if they are private to the own passes (Tasks 4, 7) | +| Modify `tslang/tslang.cpp`, `tslang/transform.cpp`, `tslang/jitcache.cpp` | flags, pipeline, JIT cache key | +| Modify `test/tester/test-runner.cpp` | `-no-stack-promotion` pass-through, with its own cached-script suffix | +| Create `test/tester/stack-promotion/*.ts` | decision and behaviour tests | +| Modify `test/tester/CMakeLists.txt` | register them | +| Modify `../docs/memory-models.md` (repository root) | user-facing section | + +## Test conventions used below + +- **Run test** = `tslang_add_test(NAME test-jit--stack- COMMAND test-runner -jit -mm= )` and the `test-compile-` twin without `-jit`, for each `mm` in `gc rc own none`, in one `foreach`. A program asserts with `assert(...)` and prints; exit code 0 is a pass. test-runner always passes `--no-default-lib` (`test-runner.cpp:91,96`), so `-mm=own` needs no own DefaultLib build; test programs must not import the default library. +- **Decision test** = `add_test(... COMMAND $ --emit=mlir-affine --no-default-lib -mm= --stack-promotion-report )` with `PASS_REGULAR_EXPRESSION` on the remark and `FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed"`. +- Remark format (pinned here, used by every decision test): `::: remark: stack-promotion: stack` or `... remark: stack-promotion: heap: `, where `` is one of `returned`, `stored`, `captured`, `argument`, `unknown-callee`, `converted`, `thrown`, `yielded`, `loop-carried`, `too-big`, `frame-budget`, `recursive`, `coroutine`, `try-region`, `dynamic-size`, `growable`, `unclassified`. +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner`; run: `ctest --test-dir __build/tslang/windows-msbuild-2026-release -C Release -R --output-on-failure` (from `I:\TypeScriptCompiler`). + +--- + +### Task 1: `Class()` is `new Class()` + +**Files:** +- Modify: `lib/TypeScript/MLIRGenAccessCall.cpp` (`NewClassInstance`, the `Class()` caller near line 2063) +- Create: `test/tester/stack-promotion/class_call_same_as_new.ts` +- Modify: `test/tester/CMakeLists.txt` + +**Interfaces:** +- Produces: nothing new; `NewClassInstance(..., bool onStack)` loses `onStack` (all callers pass the heap path). + +- [ ] **Step 1: Write the failing test** `class_call_same_as_new.ts`: a class `P { x: number; y: number; constructor(x, y) }` with a `toString()`; `main` builds `P(1, 2)` and `new P(3, 4)`, a function `make()` returning `P(5, 6)`, and asserts `a.x + a.y == 3`, `make().y == 6`, `a instanceof P`. Register as a run test for all four models. +- [ ] **Step 2: Run, expect FAIL** with `can't instantiate new instance of P which has 'virtual table' on stack`. +- [ ] **Step 3: Implement**: `Class()` goes through the same path as `new Class()` - `NewClassInstanceOnStack` (`MLIRGenAccessCall.cpp:2056-2064`) becomes a call of `NewClassInstance(..., onStack = false)`, i.e. the `C..new` call plus the constructor call; delete the "virtual table on stack" error, the `onStack` parameter and the `TODO`. `NewClassInstanceLogicAsOp`'s `stackAlloc` parameter stays (the pass sets the attribute later, not MLIRGen) but every MLIRGen caller passes `false`. +- [ ] **Step 4: Run, expect PASS** under all four models. +- [ ] **Step 5: Commit** `Class() is new Class()`. + +### Task 2: Flags, pipeline slot, and an empty pass + +**Files:** +- Create: `lib/TypeScript/StackPromotionPass.cpp` +- Modify: `include/TypeScript/Passes.h`, `lib/TypeScript/CMakeLists.txt`, `tslang/tslang.cpp`, `tslang/transform.cpp`, `tslang/jitcache.cpp`, `test/tester/test-runner.cpp`, `test/tester/CMakeLists.txt` + +**Interfaces:** +- Produces: + - `struct StackPromotionLimits { unsigned maxObjectBytes = 256; unsigned maxFrameBytes = 4096; bool report = false; };` in `Passes.h` + - `std::unique_ptr mlir::typescript::createStackPromotionPass(CompileOptions &compileOptions, StackPromotionLimits limits);` - a `ModuleOp` pass. + - `cl::opt noStackPromotion("no-stack-promotion")`, `cl::opt stackPromotionMaxObject("stack-promotion-max-object", init 256)`, `cl::opt stackPromotionMaxFrame("stack-promotion-max-frame", init 4096)`, `cl::opt stackPromotionReport("stack-promotion-report")`, all in `TypeScriptCompilerCategory`. + - test-runner accepts `-no-stack-promotion` and appends `--no-stack-promotion` to `tslang_opt` (same mechanism as `-mm=`, `test-runner.cpp:790-799`), **and** adds `nosp` to `optVariantSuffix()` (`test-runner.cpp:155-158`) so the cached `jit*.bat`/`compile*.bat` scripts of a `-no-stack-promotion` run are their own files. The scripts embed `tslang_opt` at creation time and are shared by every parallel runner with the same name; without the suffix, whichever runner created `jitgc.bat` first would decide for everyone and Task 8's equivalence run would compare promotion-on with promotion-on. Delete stale `__build/.../compile*.bat` and `jit*.bat` after changing the runner. + +- [ ] **Step 1: Write the failing tests**: + - `test-stack-promotion-report-reaches-stderr`: `tslang --emit=mlir-affine --no-default-lib -mm=none --stack-promotion-report class_call_same_as_new.ts`, `PASS_REGULAR_EXPRESSION "remark: stack-promotion: pass-ran"`. This proves both that remarks print and that the pass runs before `--emit=mlir-affine` stops the pipeline; every later decision test depends on it. If the pass is scheduled after that emit point, move the decision tests to whichever `--emit=` level does run it and update the convention above. + - `test-stack-promotion-off`: same command with `--no-stack-promotion` added, `FAIL_REGULAR_EXPRESSION "pass-ran"`, `PASS_REGULAR_EXPRESSION "ts\\.Func"`. + - a run test `test-jit-gc-stack-off-class-call` using `test-runner -jit -mm=gc -no-stack-promotion`. +- [ ] **Step 2: Run, expect FAIL** (unknown option). +- [ ] **Step 3: Implement**: flags; the pass, whose `runOnOperation` for now only emits `remark: stack-promotion: pass-ran` on each `ts.Func` when `limits.report` (removed in Task 3); schedule it in `transform.cpp` right after the `MemoryModelOwn` inference block and before `#ifdef ENABLE_OPT_PASSES`, unless `noStackPromotion` (that slot is inside `if (isLoweringToAffine)`, which `--emit=mlir-affine` enters, so the decision tests see the pass; remarks reach stderr through the postponed-diagnostics handler in `runMLIRPasses`, which keeps every severity); add the four flags to the JIT cache key line in `jitcache.cpp` (next to `verify-ownership=`); the runner flag and suffix above. +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** `Stack promotion: flags and an empty pass`. + +### Task 3: Stack lowering of `ts.New`, `C..new` sites, and promoting leaf objects with no uses but field access + +Scope, in this order: first boxed object literals (`ts.New` of a `ValueRefType`, inline in the user function), then class instances through the `C..new` call site (see "How a class allocation site looks"). Both only when the storage owns no heap memory (`OwnershipRoutineLogic::ownsHeapMemory(storageType) == false`) and the only uses of the object are as the base of field/property reads and writes, and retain/release ops. A derived reference (the result of a property/field ref op on the object) is itself checked with the same whitelist: consumed only as the address of a load or store, never a call operand, never the stored value - otherwise `p.inner.method()` or a ref passed as `this` slips through as "field access". Everything else: heap, reason `unclassified`. In particular a call operand is `unclassified` here, so a class **with a constructor** (the constructor call takes the object as `this`) cannot be promoted until Task 4's call facts exist; this task's class test uses a class with no constructor and no field initializers (`mlirGenCallConstructor`, `MLIRGenAccessCall.cpp:2330-2365`, emits no call for one). This makes the mechanism work end to end before the analysis grows. + +**Files:** +- Modify: `include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h`, `lib/TypeScript/LowerToLLVM.cpp` (`NewOpLowering`), `lib/TypeScript/StackPromotionPass.cpp` +- Create: `test/tester/stack-promotion/literal_boxed_local.ts`, `leaf_local.ts`, `leaf_returned.ts` + +**Interfaces:** +- Produces: + - `mlir::Value LLVMCodeHelperBase::StackBlockAlloc(mlir::Type storageType)` - entry-block `alloca` of `headerSize + sizeof(storage)` bytes, aligned to the pointer size, created through the existing `Alloca(mlir::Type, int)` helper (it inserts at the front of the entry block, which `verifyAlloca` in `LowerToLLVM.cpp:7956` requires of every constant-size alloca outside a stacksave/restore pair); stores `HEAP_BLOCK_IMMORTAL` in the header and zero-fills the payload **at the allocation site** (not the entry block, so a loop re-zeroes); returns the payload pointer. + - In the pass: `bool isStackCandidate(mlir::Operation *siteOp, StringRef &reason)` over both site kinds; on success sets `stackAlloc = true` on a `ts.New`, or performs the `C..new` call replacement and sets it on the new `ts.New`. When `limits.report`, `mlir::emitRemark(siteOp->getLoc())` with the pinned format, at the location of the site in the user function (the call's location, not `.new`'s body). + +- [ ] **Step 1: Write the failing tests**: `literal_boxed_local.ts` - an object literal with a method and two `number` fields, used locally (fields read and written, no method call yet) - decision `stack` under all four models, run test passes under all four. `leaf_local.ts` (a class `P { x: number; y: number; }` with **no constructor**, `const p = new P(); p.x = i; p.y = i + 1;` summed in a loop of 1000, asserts the sum) - decision `stack`, run test passes under all four; confirm first with `--emit=mlir-affine` that the site is a `ts.SymbolCallInternal @P..new` with no constructor call after it (if a constructor call is emitted anyway, this test moves to Task 4 and only the literal test stays here). `leaf_returned.ts` (`return new P()`) - decision `heap: returned`, run test passes. +- [ ] **Step 2: Run, expect FAIL** (no remark). +- [ ] **Step 3: Implement** `StackBlockAlloc`, the `stackAlloc` branch in `NewOpLowering` calling it (replacing the header-less `ch.Alloca`), the candidate check for a `ts.New` site, then the `C..new` site: recognition, the clone of the vtable store from `.new`'s body, the attribute copy, the replacement. The cloned vtable store writes through the payload pointer, as it does for a heap block. Under rc and own the replaced call result's retains/releases are untouched in this task (they run against the immortal header; Task 5 erases them). +- [ ] **Step 4: Run, expect PASS**; also run the whole suite under `-mm=gc` once to confirm no output change (`ctest -C Release -R "test-(jit|compile)-" -j 12`), and compile `leaf_local.ts` with `--di` to confirm debug info still verifies. `GCNewExplicitlyTypedOp` is not handled: it is only emitted under `ENABLE_TYPED_GC`, which is `false` (`include/TypeScript/Config.h:62`; `LLVMCodeHelperBase.h` heap block header comment). +- [ ] **Step 5: Commit** `Stack promotion: leaf objects with field-only uses`. + +### Task 4: Escape facts for calls, methods and `this` + +**Files:** +- Create: `lib/TypeScript/StackEscapeFacts.h` +- Modify: `lib/TypeScript/StackPromotionPass.cpp` +- Create tests: `ctor_local.ts`, `borrow_uses.ts`, `pass_to_reader.ts`, `pass_to_keeper.ts`, `method_keeps_this.ts`, `virtual_one_keeps.ts`, `ctor_keeps_this.ts`, `to_interface.ts`, `to_any.ts`, `pass_to_print.ts`, `loop_carried.ts` + +**Interfaces:** +- Consumes: `own_facts::directCallee(Operation*)`, `own_facts::calleeValue(Operation*)` from `lib/TypeScript/OwnershipFacts.h`; the virtual-candidate resolution `OwnershipSignaturePass` uses (move it into `OwnershipFacts.h` if it is private to that pass). +- Produces: + - `class StackEscapeFacts { public: explicit StackEscapeFacts(mlir::ModuleOp); bool paramMayEscape(mlir::Operation *callOp, unsigned operandIndex) const; bool isRecursive(mlir::FunctionOpInterface) const; };` + - A parameter (including `this`) "may escape" if its value, or a value derived from it, reaches any escape in spec §5.2 inside the callee body, or is passed to a parameter that may escape. Fixpoint over the module call graph, starting from "no escape" for defined functions and "escape" for every unknown callee, iterating until no function's facts change (mutual recursion included). Not the closed-world own facts: this is a property of the callee body, valid for any caller. + - Conversions to `any`, interface, union or `unknown` are escapes in version 1 (stricter than spec §5.2 allows; our call - simpler and safe). + +- [ ] **Step 1: Write the failing tests** (decision + run, all four models): + - `ctor_local.ts`: the class from Task 3 with a `constructor(x, y)` that only assigns fields, built in a loop → `stack` (the constructor is a call with `this` as its first operand; `paramMayEscape` is false for it). + - `borrow_uses.ts`: method call on the object, `===` between two locals, `instanceof`, `print(p.toString())` → `stack`. (`===` on two class values lowers to a raw pointer compare, `UnaryBinLogicalOrHelper.h:240-270`, `isNullableTypeNoUnion` branch: `PtrToInt` + `icmp`, no conversion to `any`; so the comparison is a borrow, not `converted`.) + - `pass_to_reader.ts`: a module function `len(p: P) { return p.x * p.x + p.y * p.y; }` → `stack`. + - `pass_to_keeper.ts`: a function storing its parameter in a global array → `heap: argument`. + - `method_keeps_this.ts`: `register() { registry.push(this); }` → `heap: argument`. + - `virtual_one_keeps.ts`: base `visit(x)` reads, override stores `x` in a field of a global → `heap: argument`. + - `ctor_keeps_this.ts`: constructor stores `this` in a global → `heap: argument`. + - `to_interface.ts`, `to_any.ts`: assigned to an interface / `any` local → `heap: converted`. + - `pass_to_print.ts`: `print(p)` where `P` has `toString` → `stack`. `print` of a class instance is not a runtime call with the object: MLIRGen casts every `print` operand to `string` first, and for a class that is a call of `get_[Symbol.toStringTag]` or `toString` with the object as `this` (`MLIRGenCast.cpp:952-962`, `castToStringSpecialCases`); `PrintOpLowering` asserts every item is already a string pointer (`LowerToLLVM.cpp:162-195`). So the object only reaches its own method, which borrows `this`. The test pins this so a change in how `print` takes a class shows up as a test change. + - `loop_carried.ts`: `let keep: P; for (...) { const p = new P(i); if (i == 3) keep = p; }` then reads `keep.x` after the loop and asserts 3 → `heap: loop-carried`. It lands here, not in Task 6, because following values through local slots (this task) is what would otherwise promote it and reuse its slot. +- [ ] **Step 2: Run, expect FAIL** (all `unclassified`). +- [ ] **Step 3: Implement** `StackEscapeFacts` and extend `isStackCandidate` to accept call operands whose `paramMayEscape` is false, comparisons, and `instanceof`/type-tag reads; follow the value through local `ts.Variable` slots (store into a non-captured local slot, loads from it) with the same rules applied to every load. With slot-following comes the loop-carried rule: an object whose value may be live at the allocation op's next execution (stored into a slot defined outside the innermost loop containing the allocation, or loaded after the loop's back edge) → `heap: loop-carried`. Loops are a CFG matter after the affine lowering: use `mlir::CFGLoopInfo` (`mlir/Analysis/CFGLoopInfo.h`, present in this LLVM) over the function's dominator tree to find the innermost loop containing the site and its back edges. A value that reaches a block argument (`cf.br ^bb(%p)`) is `unclassified` in this version. +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** `Stack promotion: call and method escape facts`. + +### Task 5: Objects that own heap memory under rc and own + +**Files:** +- Modify: `include/TypeScript/TypeScriptOps.td` (`ReleaseContentsOp`), `include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h`, `lib/TypeScript/LowerToLLVM.cpp`, `lib/TypeScript/StackPromotionPass.cpp` +- Create tests: `fields_string.ts`, `fields_object.ts`, `field_overwrite.ts`, `return_field.ts`, `try_region.ts`, `conditional_alloc.ts` + +**Interfaces:** +- Produces: + - `def TypeScript_ReleaseContentsOp : TypeScript_Op<"ReleaseContents"> { let arguments = (ins TypeScript_AnyRefLike:$instance, TypeAttr:$storageType); }` + - `void OwnershipRoutineLogic::emitReleaseContents(mlir::Type storageType, mlir::Value payloadPtr)` = `releaseFields(storageType, payloadPtr)` then zero the payload's owned fields (so a second call, or the next iteration's call, releases nothing). Lowers to nothing under gc and none. +- Rules (the pass, rc and own only): + - insert `ts.ReleaseContents` immediately **before** each promoted allocation op (frees the previous iteration's contents) and once at function exit: the affine lowering gives every function a single return block (`LowerToAffineLoops.cpp:55-80`; each `ts.ReturnVal` stores into the return slot and branches there), so the exit insertion point is **immediately before the single `ts.ReturnInternal`** of that block, one per promoted site. The retain of a returned value happens at the `ts.ReturnVal` site, before the branch into the return block, so `return p.s` has retained the string before the object's contents are released; `return_field.ts` pins that order. A function whose return block is reached through a `cleanup` block (finally) has a try region and is excluded anyway; + - **zero the promoted block's payload in the entry block as well** (in addition to the allocation-site zero-fill from Task 3). Without it, an allocation inside an `if` that was not taken leaves stack garbage in the slot, and the `ReleaseContents` at the return releases garbage pointers. Test: `conditional_alloc.ts` below. Confirm `GCPass` does not strip these memsets (it only strips memsets that zero a GC allocation, `GCPass.cpp:94-131`). + - erase `ts.Retain`/`ts.Release`/`ts.RetainSlot`/`ts.ReleaseSlot` whose operand is the promoted object itself (they are no-ops on an immortal header; erasing them is the performance point); + - a function containing a try/catch/finally region promotes nothing: reason `try-region` (version 1; unwind paths would need their own `ReleaseContents`). + - `--verify-ownership` cannot check any of this (it runs before own's inference and before this pass; see Global Constraints). The checks are run tests under rc and own, where a missed release is a leak the test cannot see but a double release or a release of garbage is a wrong value or a crash; the Debug build's CRT asserts (`_CrtSetReportMode`, PR #276) catch heap corruption on the compile twin. + +- [ ] **Step 1: Write the failing tests** (decision tests, and run tests under all four models): + - `fields_string.ts`: class with a `string` field built from concatenation in a loop of 10000 → `stack`; output identical. + - `fields_object.ts`: field holds a heap object → `stack`; the inner object stays heap (`heap: stored`). + - `field_overwrite.ts`: field assigned three times from fresh strings (`p.s = "v" + i`), then return → `stack`; before each overwrite the current value is printed **directly** (`print(p.s)`), never read through a second local after the overwrite: under own a local that took `p.s` borrows a field the overwrite has just destroyed, so such a read would be wrong by own's own rules, not because of promotion. A double release shows up as a wrong value or a crash on the next concatenation. + - `return_field.ts`: `function f(i: number) { const p = new P("v" + i); return p.s; }` called 1000 times, the results concatenated and printed → `stack`; run tests under rc and own especially (the string must be retained before `ReleaseContents` runs; see the insertion rule). + - `try_region.ts`: a local inside a function with `try` → `heap: try-region`. + - `conditional_alloc.ts`: `function f(c: boolean) { if (c) { const p = new P("s" + 1); print(p.s); } return 1; }` called with `false` then `true`, 1000 times each → `stack`; no crash under rc and own. +- [ ] **Step 2: Run, expect FAIL.** +- [ ] **Step 3: Implement** the op, its lowering, `emitReleaseContents`, the insertion rules, and drop the `ownsHeapMemory == false` restriction from Task 3. +- [ ] **Step 4: Run, expect PASS**; full suite under `-mm=rc` and `-mm=own`, compare with `-no-stack-promotion` (Task 8 step 1 recipe). +- [ ] **Step 5: Commit** `Stack promotion: objects that own memory, under rc and own`. + +### Task 6: Limits, recursion, coroutines, throw + +**Files:** +- Modify: `lib/TypeScript/StackPromotionPass.cpp` +- Create tests: `too_big.ts`, `frame_budget.ts`, `recursive.ts`, `async_local.ts`, `generator_local.ts`, `thrown.ts` + +**Interfaces:** +- Consumes: `StackEscapeFacts::isRecursive`, `StackPromotionLimits`. +- Rules: storage size > `maxObjectBytes` → `too-big`; sites past `maxFrameBytes` in the function (the budget counts header + payload per site), sites inside loops kept first (a site is "inside a loop" when `mlir::CFGLoopInfo` puts its block in any loop), then source order → `frame-budget`; `isRecursive` → `recursive`; coroutines → `coroutine`; operand of `ts.Throw` → `thrown`. +- Coroutine detection (there is no "async" or "generator" attribute on a function to read): a function is a coroutine when (a) it is a `ts.Func` whose body contains an `async.execute` op - that is what an `await` expands to (`MLIRGenExpressions.cpp:447`), and the async lowering later outlines those regions; (b) it is any `func::FuncOp` - the only `func.func`s in the module at this point are the continuations the async lowering outlined, which is why own's inference nests on `func::FuncOp` as well as `ts.Func` (`transform.cpp:147-150`); (c) it is the `.step` method (`GENERATOR_STEP`, `Defines.h:175`) that `buildGeneratorWrapperDeclaration` (`MLIRGenFunctions.cpp:860-910`) makes out of a generator body - the body's locals that live across a `yield` are captured cells of the generator's state object, which the `captured` rule already rejects, and the rest sit in a method whose frame is re-entered per `next()`. Confirm the `.step` symbol name once with `--emit=mlir` on a generator before pinning the check. + +- [ ] **Step 1: Write the failing tests**: one per rule above, each a decision test for the expected reason plus a run test; `too_big.ts` uses a class of 40 `number` fields (320 bytes) and also checks `--stack-promotion-max-object=512` gives `stack`; `frame_budget.ts` has 20 locals of 32-field classes (256-byte payload each, under the object limit; 20 of them exceed the 4096-byte frame budget); `recursive.ts` is a recursive tree sum building a temporary per call → `heap: recursive`, run under all four models at a depth that passes **today**: no stack-size setting exists anywhere in tslang, the runner or the test CMake (Windows' default main-thread stack is 1 MB), so first run the test on `main` with `--no-stack-promotion` at depth 10000 and lower the depth until it passes there; the promoted build must pass at the same depth. The test exists to show promotion adds no frame to a recursive function, not to find the platform's limit. +- [ ] **Step 2: Run, expect FAIL.** +- [ ] **Step 3: Implement** the rules. +- [ ] **Step 4: Run, expect PASS.** +- [ ] **Step 5: Commit** `Stack promotion: limits, recursion, coroutines, throw`. + +### Task 7: Arrays and capture boxes + +**Files:** +- Modify: `include/TypeScript/TypeScriptOps.td` (`stackAlloc` OptionalAttr on `CreateArrayOp`, `NewArrayOp`), `include/TypeScript/LowerToLLVM/ArrayLayout.h`, `lib/TypeScript/LowerToLLVM.cpp` (`CreateArrayOpLowering`, `NewArrayOpLowering`, `VariableOpLowering` for captured variables), `lib/TypeScript/StackPromotionPass.cpp`, `OwnershipRoutineLogic.h` +- Create tests: `array_local_fixed.ts`, `array_local_push.ts`, `array_returned.ts`, `array_dynamic_length.ts`, `closure_local.ts`, `closure_escapes.ts` + +**Interfaces:** +- Array header `{data, length, capacity}` goes in a `StackBlockAlloc` block. The data block goes on the stack only if the length is a compile-time constant, its byte size fits the object limit, and no use may grow it (`push`, `unshift`, `splice`, `length` store, or any call argument whose `paramMayEscape` is true); otherwise the data block is heap (reason on the remark for the data: `growable` or `dynamic-size`) while the header can still be stack. +- `emitReleaseContents` for an array header: release elements `[0, length)`, then free the data block if it is a heap block (check its header: `emitIsMortal`), then zero the header. +- Capture box (`ts.Variable` with `CAPTURE_BOX_ATTR_NAME`): stack when every closure built over it (`ts.CreateBoundFunction` / `ts.CreateClosure` users) satisfies Task 4's rules as a value; reuse the closure-escape walk in `OwnershipInferencePass` (`followClosure`) by moving it into `OwnershipFacts.h` if needed. + +- [ ] **Step 1: Write the failing tests**: `array_local_fixed.ts` (`const v = [x, y, z]` where `x`, `y`, `z` are the function's parameters, summed in a loop) → header `stack`, data `stack` - the elements must **not** be constants: an array literal of constants is a static literal global since #479 (no allocation, no site, no remark); `array_local_push.ts` → header `stack`, data `heap: growable`; `array_returned.ts` → `heap: returned`; `array_dynamic_length.ts` (`new Array(n)`) → data `heap: dynamic-size`; `closure_local.ts` (`[1,2,3].forEach` style with a local arrow capturing a counter, closure passed to a module function that only calls it) → box `stack`; `closure_escapes.ts` → box `heap: captured`. +- [ ] **Step 2: Run, expect FAIL.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run, expect PASS**; full suite under all four models. +- [ ] **Step 5: Commit** `Stack promotion: arrays and capture boxes`. + +### Task 8: Whole-suite equivalence, benchmarks, docs + +**Files:** +- Modify: `../docs/memory-models.md` (repository root: `I:\TypeScriptCompiler\docs\memory-models.md`), `test/tester/CMakeLists.txt` +- Create: `test/tester/stack-promotion/bench_small_class.ts`, `bench_vector_math.ts` + +- [ ] **Step 1: Equivalence run**: full ctest suite under each of gc, rc, own, none, once as is and once with the runner's `-no-stack-promotion` (add a CMake option `TSLANG_TEST_NO_STACK_PROMOTION` that appends it to every `tslang_add_test`, same mechanism as the x86 twins, default OFF). The `nosp` script suffix from Task 2 is what makes the two runs really differ; check one generated `jitgcnosp.bat` contains `--no-stack-promotion` before trusting the comparison. Expected: identical pass/fail lists; compare against the known-failure list for the Debug suite. +- [ ] **Step 2: Teeth**: temporarily make `paramMayEscape` return `false` always; confirm `pass_to_keeper.ts` or `method_keeps_this.ts` fails at run time. A dangling stack read is not reliably wrong - the dead frame keeps its bytes until something reuses them - so the teeth test must **overwrite the frame first**: after the keeper function returns, call another module function that allocates two or three promoted objects of the same class with different field values and writes them, and only then read the kept object's fields and assert the original values. Revert; commit nothing from this step. +- [ ] **Step 3: Benchmarks**: `bench_small_class.ts` (10^7 iterations building and summing a 2-field class), `bench_vector_math.ts` (3-component vector temporaries), plus the ray tracer and n-body used in `../docs/memory-models.md`; `--emit=exe --opt --opt_level=3`, each model, with and without `--no-stack-promotion`; record time, peak working set, allocation count in a table. +- [ ] **Step 4: Docs**: a "Stack promotion" section in `../docs/memory-models.md`: what it does, that it is automatic and silent, the two limits and their flags, `--no-stack-promotion`, `--stack-promotion-report`, the version-1 exclusions (calls into other modules, async/generators, recursion, try regions), and the benchmark table. +- [ ] **Step 5: Commit** `Stack promotion: suite equivalence, benchmarks, docs`. diff --git a/tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md b/tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md new file mode 100644 index 000000000..bd1e778db --- /dev/null +++ b/tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md @@ -0,0 +1,216 @@ +# Stack promotion: short-lived objects on the stack + +Status: design, 2026-10-18. Not implemented. + +## 1. Intent + +Pure performance. Short-lived objects - a temporary class instance, a small array, a boxed +object literal, a closure's capture box - should cost a stack slot, not a heap allocation, a +collector's attention, or a retain/release pair. The motivating case: code like a number-to-string +conversion that builds a helper object, uses it, and drops it should never touch the heap. + +The program's meaning does not change. Only speed and memory use do. + +## 2. Decisions (the user's) + +- **The compiler decides where an object lives.** An escape analysis looks at every allocation + site. A site whose object provably never outlives its function, and is small enough, allocates on + the stack. Every other site allocates on the heap exactly as today. +- **Escapes are silent.** No error, no warning: a site that escapes is simply a heap site. There is + no clone-on-escape - an object is never copied, so reference identity always holds. +- **`Class()` and `new Class()` are the same.** `Class()` stays accepted, as today, and means + exactly `new Class()`: same constructor call, same analysis. It is no longer a request for the + stack. +- **Every kind of heap allocation the compiler makes for a value is covered**, not only classes: + class instances, arrays, boxed object literals, closure capture boxes. +- **Too big goes to the heap.** +- **All memory models**: `gc`, `rc`, `own`, `none`. + +## 3. Goals, non-goals, anti-goals + +Goals +- Fewer heap allocations for short-lived objects, measured (§9). +- No behavioural change under any memory model; the existing suite passes unchanged under all four. + +Non-goals +- A user-visible annotation or keyword for stack allocation. +- Copying or moving objects between stack and heap at run time. +- Stack promotion of objects allocated in one function and dying in another (§8.1). + +Anti-goals (a failure even if it "works") +- Any dangling stack pointer. When the analysis is unsure, the answer is heap. +- Stack overflow in programs that run today (deep recursion with promoted frames). +- A noticeable compile-time cost. + +## 4. Feasibility notes that shaped the design + +### 4.1 Why not LLVM address spaces + +The first idea was to tag stack pointers with an LLVM address space. Rejected as the safety +mechanism (builder may still use one as an internal aid, see §7): + +- An address space is a static tag on the pointer type. LLVM diagnoses nothing; an + `addrspacecast` to address space 0 is just a cast. "Protection" would be a pass we write that + treats the cast as an escape - i.e. the escape analysis itself, run after lowering has erased + the facts it needs (field store vs call argument vs capture vs return). +- Every method's `this`, every runtime and DefaultLib function takes an address-space-0 pointer, + so under "cast = escape" `a.method()` and `print(a)` are escapes. Any useful analysis needs + parameter borrow facts - which `-mm=own` already computes in MLIR. +- `alloca` returns address space 0 on x86/ARM/wasm; wasm gives meaning to address spaces 1, 10, + 20 and x86 to 256-258 and 270-272; every pattern lowering a class pointer would have to accept a + second pointer type. +- Prior art (Julia's address spaces 10-13, LLVM statepoint GC's address space 1) uses address + spaces as optimizer bookkeeping stripped before codegen, not as the safety check. + +### 4.2 What exists today + +- `ts.New` has a `stackAlloc` attribute; `Class()` sets it + (`MLIRGenAccessCall.cpp`, `NewClassInstance`) and `NewOpLowering` emits an `alloca` + (`LowerToLLVM.cpp`). The `alloca` has **no block header**, and `Class()` is rejected for any + class with a virtual table - which is now every class - so the path is dead. There is a `TODO` + on it. +- `LLVMCodeHelperBase::Alloca` already places allocas in the function's entry block. +- Heap blocks carry a leading header word; `HEAP_BLOCK_IMMORTAL` in it makes rc's retain/release + and own's destroy skip the block (literals and `typeof` tags use this). +- `OwnershipSignaturePass` (module pass) computes per-function facts, notably `__own_params` + (parameters the body stores into a container or returns) and `__own_result_borrows`. + `OwnershipInferencePass` follows def-use chains per function, including closure escapes. Both run + only under `-mm=own`, after `LowerToAffine*` and before the optimiser (`tslang/transform.cpp`). + +## 5. The analysis + +### 5.1 Where it runs + +A new pass, `StackPromotionPass`, per function, under **every** memory model, at the same level +as the ownership passes (after `LowerToAffine*`, before the optimiser): control flow is a plain CFG +with unwind edges, and every allocation and ownership op is still present. + +It needs `OwnershipSignaturePass`'s per-function parameter facts, so that pass runs under every +model when promotion is on (it is analysis only; it pins attributes). Under own it already runs +first. The builder decides whether the fact computation is shared or factored out. + +### 5.2 What counts as an escape + +A site escapes - and stays on the heap - if its value, or anything derived from it, may be: + +- returned, yielded, or thrown; +- stored into a field, array element, global, `captured` variable, or any slot not itself a local + of this function that dies with it; +- captured by a closure that escapes; (a non-escaping closure capturing it is fine); +- passed to a parameter the callee's facts do not show as borrowed. **Unknown callee = escape**: + declared/external functions, imports without facts, virtual calls whose candidates disagree, + indirect calls through values. (Note this is the *opposite* default from own's + "unknown callee borrows" - own's default is leak-safe, not stack-safe.) +- stored as `this` by its own constructor (the constructor is a call with `this` as an argument; + it follows the rule above); +- converted to `any`, an interface, a union, or `unknown` where the converted value escapes by + the same rules (an interface value carries the object pointer); +- live across an `await` or `yield` in a coroutine. Simplest correct rule (builder's choice to + refine): **no promotion inside async functions and generators** in the first version. +- returned through a call whose result borrows from it (`__own_result_borrows`) where that result + escapes. + +Anything the analysis cannot classify is an escape. + +### 5.3 What is covered + +| allocation | stack when | +| --- | --- | +| class instance (`ts.New`, and the gc typed `GCNewExplicitlyTypedOp`) | does not escape, size ≤ limit | +| array header | does not escape | +| array data block | header on stack **and** length is a compile-time constant **and** nothing may grow it (`push`, `unshift`, `splice`, `length =`, anything passed the array where the callee's facts do not rule it out). Otherwise heap even when the header is on the stack. | +| boxed object literal | does not escape, size ≤ limit. Object literals that are plain tuple values are unchanged. | +| closure capture box | the closure does not escape | + +Strings are not in the first version (they are produced by runtime/DefaultLib calls, see §8.1). + +### 5.4 Size limits + +- Only compile-time-known sizes qualify. +- Default: at most 256 bytes for one object, at most 4 KB promoted per function. (Our call; + Windows' default stack is 1 MB and recursion multiplies a frame. Go allows 64 KB implicit.) +- Both adjustable by flag. The builder picks names; suggested `--stack-promotion-max-object=` and + `--stack-promotion-max-frame=`. +- When the per-function budget is exceeded, sites are kept in order of expected benefit (sites in + loops first); the builder decides the heuristic. +- **Recursion (our call).** A function on a direct call-graph cycle in its module (computed from + the call graph `OwnershipSignaturePass` already walks) gets no promotion. Cycles only through + virtual or indirect calls are not detected; for those the per-function budget is the guard. + Promoted slots live in the entry block, so every call pays for them even on a cold path - one + more reason the defaults are small. + +### 5.5 Loops + +All promoted allocas go in the entry block (already what `Alloca` does). A site inside a loop +reuses one slot per iteration, which is correct only if the object does not live into the next +iteration: a value from iteration *n* still reachable in iteration *n+1* (stored in a local +declared outside the loop, for instance) is an escape for this purpose. + +## 6. Per memory model + +The stack object has the same layout as a heap block: header word, then payload. The pointer the +rest of the compiler sees addresses the payload, as for heap blocks. The header is set to +`HEAP_BLOCK_IMMORTAL`, so all existing code that inspects headers treats it as not-to-free. + +| model | allocation | at scope exit | +| --- | --- | --- | +| gc | alloca; vtable store as today; no type descriptor | nothing. Boehm scans the stack conservatively, so heap objects held by the stack object's fields stay alive; it ignores pointers into non-heap memory. | +| rc | alloca, header = immortal | **release the object's fields** (and array elements) without freeing the object. Today's release routine does "release fields + free" in one; it needs a "release contents" half callable on its own. Retains/releases of the object itself become no-ops (immortal) - the builder should delete them in the pass rather than leave the runtime check. | +| own | alloca, header = immortal | destroy contents without free; the scope is the owner. `--verify-ownership` must accept promoted sites. | +| none | alloca | nothing. | + +Exceptions: the scope-exit action must also run on the unwind path, using the same machinery +that releases locals during unwind today. + +## 7. Flags and diagnostics + +- On by default at every optimisation level, including `-O0` (our call): one behaviour everywhere, + and the Debug suite exercises it. Debugger display of promoted objects must keep working (the + payload pointer is what debug info describes, as for heap objects). +- `--no-stack-promotion` turns it off - for bisecting miscompiles and A/B measurement. +- A report flag (name: builder) prints, per allocation site, stack or heap, and for heap the + first escape reason. Off by default. Tests use it to assert decisions. +- Optional, builder's call: in debug builds, tag promoted pointers with a reserved address space + in the LLVM IR and verify no `ptrtoint`/store of one reaches memory not on the stack - a + checker, never the mechanism. + +## 8. Scope decisions (the user's, 2026-10-18) + +### 8.1 Allocations in a callee that die in the caller: not in version 1 + +Promotion is per allocation site, and a site can only use its own function's stack. An object a +callee allocates and returns - number-to-string inside the DefaultLib, for instance - stays on the +heap in version 1. Inlining (same-module only) and a caller-provided result slot (like C++ +return-value optimisation / LLVM `sret`) were considered and deferred. + +### 8.2 Calls into other modules are escapes in version 1 + +"Unknown callee = escape" stands: every call into DefaultLib or a `-shared` library is an escape +for its arguments. Future direction, not version 1: describe callee behaviour in declarations - +per-parameter "borrowed, not kept" facts, either exported automatically with the cross-module +declarations or written as attributes on declarations - so an object can stay on the stack across +such a call. Version 1 should keep its parameter facts in a form that can later be read from a +declaration as easily as computed from a body. + +### 8.3 On by default + +The feature ships on by default. The §9 benchmarks are reported, not a gate. + +## 9. Testing and measurement + +- Suite: the full ctest suite under gc, rc, own and none with promotion on; the same with + `--no-stack-promotion` must stay identical in output. +- Decision tests: small programs plus the report flag, one per escape rule in §5.2, each asserting + stack or heap. Escape rules are exactly where a bug becomes a dangling pointer, so every rule + gets a positive and a negative test. +- Teeth: per the project's habit, temporarily disable one escape rule and confirm a test fails + (the dangling case should read a value overwritten by a later call). +- Benchmarks, before/after, each model: a tight loop building and dropping a small class; vector + math with temporaries; the ray tracer and n-body from `docs/memory-models.md`. Report time, + peak working set, and allocation count. + +## 10. Left to the builder + +Pass structure and sharing with the ownership passes; the exact op list each rule matches; flag +names; the budget heuristic; how `GCNewExplicitlyTypedOp` and `ts.New` converge; removing the old +`stackAlloc` path and the "virtual table on stack" error; phasing into PRs. diff --git a/tslang/include/TypeScript/Defines.h b/tslang/include/TypeScript/Defines.h index 0fcf48bdb..9ba2f9668 100644 --- a/tslang/include/TypeScript/Defines.h +++ b/tslang/include/TypeScript/Defines.h @@ -97,6 +97,11 @@ // so would be given a frame's reference it has no owner for. A box's owner is the closure. #define CAPTURE_BOX_ATTR_NAME "__capture_box" +// On a `ts.CreateArray` or a capture box's `ts.Variable` that stack promotion put on the stack +// (docs/superpowers/specs/2026-10-18-stack-promotion-design.md): its blocks go in the frame, with +// immortal headers, instead of on the heap. +#define STACK_ALLOC_ATTR_NAME "__stack_alloc" + // On a `ts.Try`: the statement has a catch clause. Its catches region may still hold nothing but // the terminator - an untyped `catch { }` emits no ops - and without this marker it would be // indistinguishable from the catch-less TryOps that only wrap a scope for its cleanup, so the diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h index d02706ad4..a141db943 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h @@ -285,6 +285,35 @@ class LLVMCodeHelperBase return allocated; } + // A promoted object (docs/superpowers/specs/2026-10-18-stack-promotion-design.md section 6): + // a block in the function's frame laid out exactly as a heap block - the header word, then the + // payload - so everything that reads a header reads one. The header is HEAP_BLOCK_IMMORTAL, so + // no retain, release or destroy ever counts or frees it. The slot is in the entry block; it is + // initialised here, at the allocation, so a loop gets a fresh object every iteration, as `new` + // gives it. Returns the payload pointer, as MemoryAlloc does. + mlir::Value StackBlockAlloc(mlir::Type llvmStorageType) + { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + + auto loc = op->getLoc(); + auto llvmIndexType = tch.convertType(th.getIndexType()); + + // packed: the payload starts right after the header word, where a heap block's does + auto blockType = LLVM::LLVMStructType::getLiteral(rewriter.getContext(), {llvmIndexType, llvmStorageType}, + /*isPacked=*/true); + auto blockPtr = Alloca(blockType, 1); + // what malloc guarantees a heap block, so the payload's alignment is the same as on the heap + cast(blockPtr.getDefiningOp()).setAlignment(2 * getHeapBlockHeaderSize()); + + rewriter.create( + loc, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)), + blockPtr); + auto payloadPtr = getPayloadPtrFromBlockPtr(loc, blockPtr, llvmIndexType); + rewriter.create(loc, rewriter.create(loc, llvmStorageType), payloadPtr); + return payloadPtr; + } + mlir::Value Alloca(mlir::Type elementType, mlir::Value count, bool inalloca = false) { auto location = op->getLoc(); diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index b52389bc4..8ec8ea599 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -231,6 +231,13 @@ class OwnershipRoutineLogic FlatSymbolRefAttr::get(rewriter.getContext(), wrapperName), ValueRange{value}); } + // What destroying a block of `storageType` at `payloadPtr` releases, without freeing the block: + // a promoted object's fields when its frame is done with it (ts.ReleaseContents). + void emitReleaseContents(mlir::Type storageType, mlir::Value payloadPtr) + { + releaseFields(storageType, payloadPtr); + } + // Drops one reference held by the value in `slotPtr`, whose TypeScript type is `type`. // Emits nothing when the type owns no heap memory. // diff --git a/tslang/include/TypeScript/Passes.h b/tslang/include/TypeScript/Passes.h index 953ca5d43..56708e661 100644 --- a/tslang/include/TypeScript/Passes.h +++ b/tslang/include/TypeScript/Passes.h @@ -45,6 +45,22 @@ std::unique_ptr createOwnershipSignaturePass(); /// needs every function to be present - see docs/reference-counting-evaluation.md section 9.27. std::unique_ptr createOwnedReturnConsumptionPass(CompileOptions&); +/// The limits stack promotion works within, and whether it reports its decisions. See +/// docs/superpowers/specs/2026-10-18-stack-promotion-design.md section 5.4. +struct StackPromotionLimits +{ + // the most one object may take on the stack, header included + unsigned maxObjectBytes = 256; + // the most one function may promote, all its sites together + unsigned maxFrameBytes = 4096; + // a remark at every allocation site: stack, or heap and why + bool report = false; +}; + +/// Puts allocations that provably never outlive their function on the stack, under every memory +/// model. Runs on the whole module at the affine level, after own's inference. +std::unique_ptr createStackPromotionPass(CompileOptions &, StackPromotionLimits); + /// GC Pass to replace malloc, realloc, free with GC_malloc, GC_realloc, GC_free std::unique_ptr createGCPass(CompileOptions&); std::unique_ptr createProcessHeapPass(); diff --git a/tslang/include/TypeScript/TypeScriptOps.td b/tslang/include/TypeScript/TypeScriptOps.td index 92cb2a11d..543032808 100644 --- a/tslang/include/TypeScript/TypeScriptOps.td +++ b/tslang/include/TypeScript/TypeScriptOps.td @@ -611,6 +611,20 @@ def TypeScript_ReleaseCellOp : TypeScript_Op<"ReleaseCell"> { let arguments = (ins TypeScript_Ref:$slot); } +def TypeScript_ReleaseContentsOp : TypeScript_Op<"ReleaseContents"> { + let summary = "give back what a stack object owns, keeping the object"; + let description = [{ + A promoted object (docs/superpowers/specs/2026-10-18-stack-promotion-design.md) lives in its + function's frame, so nothing frees it, and its immortal header keeps every release from + destroying it. This gives back what its fields own - under rc and own, where something does; + nothing under gc and none - and leaves the object in place. With `reset`, it also zeroes the + object, making it a fresh one: that is what stands where the allocation was, once the + allocation itself is moved to the entry block. + }]; + + let arguments = (ins AnyType:$instance, UnitAttr:$reset); +} + def TypeScript_SizeOfOp : TypeScript_Op<"SizeOf", [Pure]> { let summary = "size of type"; let description = [{ diff --git a/tslang/lib/TypeScript/CMakeLists.txt b/tslang/lib/TypeScript/CMakeLists.txt index 327b906a4..2009f5700 100644 --- a/tslang/lib/TypeScript/CMakeLists.txt +++ b/tslang/lib/TypeScript/CMakeLists.txt @@ -35,6 +35,7 @@ add_mlir_dialect_library(MLIRTypeScript OwnershipInferencePass.cpp OwnershipSignaturePass.cpp OwnedReturnConsumptionPass.cpp + StackPromotionPass.cpp GCPass.cpp ProcessHeapPass.cpp InlinedLocationPass.cpp diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 4fd934b13..80c09d15c 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -484,6 +484,51 @@ class ReleaseOpLowering : public TsLlvmPattern } }; +// A promoted object's fields given back without freeing the object, and with `reset` the object +// zeroed - a fresh one where its allocation was. +class ReleaseContentsOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::ReleaseContentsOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + TypeConverterHelper tch(getTypeConverter()); + + auto instanceType = op.getInstance().getType(); + mlir::Type storageType; + if (auto classType = dyn_cast(instanceType)) + { + storageType = classType.getStorageType(); + } + else if (auto valueRefType = dyn_cast(instanceType)) + { + storageType = valueRefType.getElementType(); + } + else + { + return op.emitError("ts.ReleaseContents of a type with no storage: ") << instanceType; + } + + if (tsLlvmContext->compileOptions.tracksOwnership()) + { + OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitReleaseContents(storageType, transformed.getInstance()); + } + + if (op.getReset()) + { + auto llvmStorageType = tch.convertType(storageType); + rewriter.create(op.getLoc(), rewriter.create(op.getLoc(), llvmStorageType), + transformed.getInstance()); + } + + rewriter.eraseOp(op); + return mlir::success(); + } +}; + // The slot-addressed forms. Same erasure rule, and erasing one of these takes the whole // access with it - there is no load to leave behind in a collected build. class RetainSlotOpLowering : public TsLlvmPattern @@ -3098,6 +3143,11 @@ struct VariableOpLowering : public TsLlvmPattern allocated = ch.Alloca(storageType, count); } + else if (varOp->hasAttr(STACK_ALLOC_ATTR_NAME)) + { + // a capture box stack promotion put in the frame: its closures never leave it + allocated = ch.StackBlockAlloc(storageType); + } else { @@ -3325,7 +3375,7 @@ struct NewOpLowering : public TsLlvmPattern mlir::Value value; if (newOp.getStackAlloc().has_value() && newOp.getStackAlloc().value()) { - value = ch.Alloca(tch.convertType(storageType), 1); + value = ch.StackBlockAlloc(tch.convertType(storageType)); } else { @@ -3466,7 +3516,11 @@ struct CreateArrayOpLowering : public TsLlvmPattern auto multSizeOfTypeValue = rewriter.create(loc, llvmIndexType, ValueRange{sizeOfTypeValue, newCountAsIndexType}); - auto allocated = ch.MemoryAlloc(multSizeOfTypeValue); + // a promoted literal: the elements in a block in the frame, immortal like a heap block that + // is never freed (StackBlockAlloc) + auto onStack = createArrayOp->hasAttr(STACK_ALLOC_ATTR_NAME); + auto allocated = onStack ? ch.StackBlockAlloc(LLVM::LLVMArrayType::get(llvmElementType, createArrayOp.getItems().size())) + : ch.MemoryAlloc(multSizeOfTypeValue); mlir::Value index = clh.createIndexConstantOf(llvmIndexType, 0); auto next = false; @@ -3499,6 +3553,17 @@ struct CreateArrayOpLowering : public TsLlvmPattern } ArrayLayout layout(createArrayOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + if (onStack) + { + // its header too: nothing counts either block, nothing frees them + auto header = ch.StackBlockAlloc(layout.headerType()); + rewriter.create(loc, allocated, layout.dataAddress(arrayType, header)); + rewriter.create(loc, newCountAsIndexType, layout.lengthAddress(arrayType, header)); + rewriter.create(loc, newCountAsIndexType, layout.capacityAddress(arrayType, header)); + rewriter.replaceOp(createArrayOp, ValueRange{header}); + return success(); + } + auto structValue3 = layout.make(arrayType, allocated, newCountAsIndexType); rewriter.replaceOp(createArrayOp, ValueRange{structValue3}); @@ -8582,7 +8647,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() PointerOffsetRefOpLowering, LogicalBinaryOpLowering, NullOpLowering, NewOpLowering, CreateTupleOpLowering, DeconstructTupleOpLowering, CreateArrayOpLowering, NewEmptyArrayOpLowering, NewArrayOpLowering, ArrayPushOpLowering, ArrayPopOpLowering, ArrayUnshiftOpLowering, ArrayShiftOpLowering, ArraySpliceOpLowering, ArrayViewOpLowering, DeleteOpLowering, - ParseFloatOpLowering, ParseIntOpLowering, IsNaNOpLowering, PrintOpLowering, ConvertFOpLowering, StoreOpLowering, SizeOfOpLowering, TypeDescriptorOpLowering, RetainOpLowering, ReleaseOpLowering, RetainSlotOpLowering, ReleaseSlotOpLowering, RetainCellOpLowering, ReleaseCellOpLowering, + ParseFloatOpLowering, ParseIntOpLowering, IsNaNOpLowering, PrintOpLowering, ConvertFOpLowering, StoreOpLowering, SizeOfOpLowering, TypeDescriptorOpLowering, RetainOpLowering, ReleaseOpLowering, RetainSlotOpLowering, ReleaseSlotOpLowering, RetainCellOpLowering, ReleaseCellOpLowering, ReleaseContentsOpLowering, InsertPropertyOpLowering, LengthOfOpLowering, SetLengthOfOpLowering, StringLengthOpLowering, StringResizeOpLowering, StringConcatOpLowering, StringCopyOpLowering, SharedNewOpLowering, SharedValueRefOpLowering, SharedCountOpLowering, StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering, diff --git a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp index 5b39e1940..9a3ab1802 100644 --- a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp +++ b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp @@ -2053,29 +2053,13 @@ namespace mlirgen return V(result); } - ValueOrLogicalResult MLIRGenImpl::NewClassInstanceOnStack(mlir::Location location, mlir_ts::ClassType classType, - SmallVector &operands, const GenContext &genContext) - { - // seems we are calling type constructor - // TODO: review it, really u should forbid to use "a = Class1();" to allocate in stack, or finish it - // using Class..new(true) method - - return NewClassInstance(location, classType, operands, genContext, true /*on stack*/); - } - ValueOrLogicalResult MLIRGenImpl::NewClassInstance(mlir::Location location, mlir_ts::ClassType classType, - SmallVector &operands, const GenContext &genContext, bool onStack) + SmallVector &operands, const GenContext &genContext) { + // `C(...)` and `new C(...)` are one thing: where the object lives is the stack promotion + // pass's decision, not the spelling's (docs/superpowers/specs/2026-10-18-stack-promotion-design.md) auto classInfo = getClassInfoByFullName(classType.getName().getValue()); - if (onStack && classInfo->hasVirtualTable) - { - emitError(location, "") << "can't instantiate new instance of " << to_print(classType) << " which has 'virtual table' on stack"; - return mlir::failure(); - } - - auto newOp = onStack - ? NewClassInstanceLogicAsOp(location, classType, onStack, genContext) - : ValueOrLogicalResult(NewClassInstanceAsMethodCallOp(location, classInfo, true, genContext)); + auto newOp = ValueOrLogicalResult(NewClassInstanceAsMethodCallOp(location, classInfo, true, genContext)); EXIT_IF_FAILED_OR_NO_VALUE(newOp) if (mlir::failed(mlirGenCallConstructor(location, classInfo, V(newOp), operands, false, genContext))) { @@ -2172,7 +2156,7 @@ namespace mlirgen } }) .Case([&](auto classType) { - value = NewClassInstanceOnStack(location, classType, operands, genContext); + value = NewClassInstance(location, classType, operands, genContext); }) .Case([&](auto classStorageType) { MLIRCodeLogic mcl(builder, compileOptions); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 802feb51a..1b968d96e 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -8676,11 +8676,8 @@ class MLIRGenImpl NodeArray typeArguments, SmallVector &operands, const GenContext &genContext); - ValueOrLogicalResult NewClassInstanceOnStack(mlir::Location location, mlir_ts::ClassType classType, - SmallVector &operands, const GenContext &genContext); - ValueOrLogicalResult NewClassInstance(mlir::Location location, mlir_ts::ClassType classType, - SmallVector &operands, const GenContext &genContext, bool onStack = false); + SmallVector &operands, const GenContext &genContext); ValueOrLogicalResult mlirGenCall(mlir::Location location, mlir::Value funcRefValue, SmallVector &operands, const GenContext &genContext); diff --git a/tslang/lib/TypeScript/StackEscapeFacts.h b/tslang/lib/TypeScript/StackEscapeFacts.h new file mode 100644 index 000000000..a0f22f467 --- /dev/null +++ b/tslang/lib/TypeScript/StackEscapeFacts.h @@ -0,0 +1,510 @@ +#ifndef MLIR_TYPESCRIPT_STACKESCAPEFACTS_H +#define MLIR_TYPESCRIPT_STACKESCAPEFACTS_H + +#include "TypeScript/TypeScriptOps.h" + +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/Interfaces/CallInterfaces.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SmallPtrSet.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/StringMap.h" +#include "llvm/ADT/TypeSwitch.h" + +#include + +// Whether an object's pointer may outlive the function holding it - the question stack promotion +// asks of every allocation, and of every parameter of every function an object is passed to (spec +// docs/superpowers/specs/2026-10-18-stack-promotion-design.md section 5.2). +// +// A parameter's answer is a property of the callee's body, valid whoever calls it: unlike own's +// facts it needs no closed world. It is computed for the whole module up front, to a fixpoint, +// because a parameter passed on to another function escapes when that one's does. A callee this +// module has no body for - declared, imported, reached through a value - may keep anything. +// +// Kept apart from the pass so a later version can read the same facts from a declaration +// (section 8.2) as easily as compute them from a body. +namespace stack_escape +{ + +namespace mlir_ts = mlir::typescript; + +class StackEscapeFacts +{ + public: + explicit StackEscapeFacts(mlir::ModuleOp module) : module(module) + { + collectVTables(); + + llvm::SmallVector> params; + module.walk([&](mlir_ts::FuncOp funcOp) { + if (funcOp.getBody().empty()) + { + return; + } + + for (auto index : llvm::seq(0, funcOp.getBody().getNumArguments())) + { + params.push_back({funcOp, index}); + paramEscapes[{funcOp, index}] = false; + } + }); + + // facts only go from "kept inside" to "may escape", so this ends + for (auto changed = true; changed;) + { + changed = false; + for (auto [funcOp, index] : params) + { + if (paramEscapes[{funcOp, index}]) + { + continue; + } + + Walk walk; + if (!escapes(funcOp.getBody().getArgument(index), walk).empty()) + { + paramEscapes[{funcOp, index}] = true; + changed = true; + } + } + } + } + + // What one question visited: the values that are the object (aliases through casts, slot loads + // and `this`), and the local slots it was stored into. The pass's loop rule reads both. + struct Walk + { + llvm::SmallPtrSet aliases; + llvm::SmallPtrSet slots; + }; + + // Empty when nothing done with `value` lets it outlive the function; otherwise why it may. + llvm::StringRef escapes(mlir::Value value, Walk &walk) + { + if (!walk.aliases.insert(value).second) + { + return ""; + } + + for (auto &use : value.getUses()) + { + auto reason = useEscapes(value, use, walk); + if (!reason.empty()) + { + return reason; + } + } + + return ""; + } + + // On a cycle of the module's call graph - calls by name, and virtual calls through every + // method they may reach. + bool isRecursive(mlir_ts::FuncOp funcOp) + { + auto it = recursive.find(funcOp); + if (it != recursive.end()) + { + return it->second; + } + + llvm::SmallPtrSet seen; + llvm::SmallVector work{funcOp}; + auto found = false; + while (!work.empty() && !found) + { + auto current = work.pop_back_val(); + for (auto callee : calleesOfFunction(current)) + { + auto calleeOp = module.lookupSymbol(callee); + if (!calleeOp) + { + continue; + } + + if (calleeOp == funcOp) + { + found = true; + break; + } + + if (seen.insert(calleeOp).second) + { + work.push_back(calleeOp); + } + } + } + + recursive[funcOp] = found; + return found; + } + + private: + mlir::ModuleOp module; + llvm::DenseMap recursive; + + // What a function may call. A call through a value the module cannot resolve may reach any + // function whose address is taken somewhere in it - a closure, a function stored in a variable - + // which is how a recursion through `const g = f; g()` or a closure is still seen. + llvm::SmallVector calleesOfFunction(mlir_ts::FuncOp funcOp) + { + llvm::SmallVector callees; + auto throughValue = false; + funcOp.walk([&](mlir::Operation *op) { + if (auto callOp = dyn_cast(op)) + { + callees.push_back(callOp.getCalleeAttr()); + } + else if (auto callOp = dyn_cast(op)) + { + throughValue |= !calleesOf(callOp->getOperand(0), callees); + } + else if (isa(op)) + { + throughValue = true; + } + }); + + if (throughValue) + { + llvm::append_range(callees, addressTaken()); + } + + return callees; + } + + // The functions whose address a function of the module takes: closures, function values. + llvm::ArrayRef addressTaken() + { + if (!addressTakenFunctions) + { + addressTakenFunctions.emplace(); + llvm::SmallPtrSet seen; + module.walk([&](mlir_ts::FuncOp funcOp) { + funcOp.walk([&](mlir_ts::SymbolRefOp symbolRefOp) { + auto target = module.lookupSymbol(symbolRefOp.getIdentifierAttr()); + if (target && seen.insert(target).second) + { + addressTakenFunctions->push_back(symbolRefOp.getIdentifierAttr()); + } + }); + }); + } + + return *addressTakenFunctions; + } + + std::optional> addressTakenFunctions; + llvm::DenseMap, bool> paramEscapes; + + struct VTable + { + bool local = false; + llvm::DenseMap slots; + }; + llvm::StringMap vtables; + + // `..vtbl`: which method each slot holds, and whether the class is this module's alone. + // A vtable visible outside the module may have subclasses elsewhere, overriding anything. + void collectVTables() + { + for (auto globalOp : module.getOps()) + { + auto name = globalOp.getSymName(); + if (!name.ends_with("..vtbl")) + { + continue; + } + + auto &vtable = vtables[name.drop_back(strlen("..vtbl"))]; + auto linkage = globalOp->getAttrOfType("linkage"); + vtable.local = linkage && (linkage.getLinkage() == mlir::LLVM::Linkage::Private || + linkage.getLinkage() == mlir::LLVM::Linkage::Internal); + globalOp.walk([&](mlir_ts::InsertPropertyOp insertOp) { + auto symbolRef = insertOp.getValue().getDefiningOp(); + if (symbolRef && insertOp.getPosition().size() == 1) + { + vtable.slots[insertOp.getPosition()[0]] = symbolRef.getIdentifierAttr(); + } + }); + } + } + + static llvm::StringRef methodName(llvm::StringRef symbol) + { + return symbol.substr(symbol.rfind('.') + 1); + } + + // The functions a virtual call through slot `index` of `className`'s vtable may run: that + // slot's method in every vtable of the module holding a method of the same name there - the + // class's own and its subclasses', and possibly unrelated classes', which only costs precision. + bool virtualCandidates(llvm::StringRef className, int64_t index, llvm::StringRef method, + llvm::SmallVectorImpl &candidates) + { + auto it = vtables.find(className); + if (it == vtables.end() || !it->second.local) + { + return false; + } + + for (auto &entry : vtables) + { + auto slot = entry.second.slots.find(index); + if (slot != entry.second.slots.end() && methodName(slot->second.getValue()) == method) + { + // a class visible outside the module - a subclass of the receiver's, exported - may + // be extended there, and the override there is not one this module can see + if (!entry.second.local) + { + return false; + } + + candidates.push_back(slot->second); + } + } + + return !candidates.empty(); + } + + // Whether argument `argIndex` of a call through `candidates` may outlive the call. + llvm::StringRef argumentEscapes(llvm::ArrayRef candidates, unsigned argIndex) + { + for (auto candidate : candidates) + { + auto funcOp = module.lookupSymbol(candidate); + if (!funcOp || funcOp.getBody().empty() || argIndex >= funcOp.getBody().getNumArguments()) + { + return "unknown-callee"; + } + + if (paramEscapes.lookup({funcOp, argIndex})) + { + return "argument"; + } + } + + return ""; + } + + // The functions a call's callee value may be, when the module can tell. + bool calleesOf(mlir::Value callee, llvm::SmallVectorImpl &candidates) + { + if (auto symbolRefOp = callee.getDefiningOp()) + { + candidates.push_back(symbolRefOp.getIdentifierAttr()); + return true; + } + + auto getMethodOp = callee.getDefiningOp(); + if (!getMethodOp) + { + return false; + } + + auto boundMethod = getMethodOp->getOperand(0); + if (auto thisSymbolRefOp = boundMethod.getDefiningOp()) + { + candidates.push_back(thisSymbolRefOp.getIdentifierAttr()); + return true; + } + + if (auto virtualRefOp = boundMethod.getDefiningOp()) + { + auto classType = dyn_cast(virtualRefOp.getThisVal().getType()); + return classType && virtualCandidates(classType.getName().getValue(), virtualRefOp.getIndex(), + methodName(virtualRefOp.getIdentifier()), candidates); + } + + return false; + } + + // A bound method made from the object (`p.m` before `p.m()`): its `this` is the object, its + // method is called. Kept to those two uses, it lets nothing out by itself. + llvm::StringRef boundMethodEscapes(mlir::Value boundMethod, Walk &walk) + { + for (auto *user : boundMethod.getUsers()) + { + if (auto getThisOp = dyn_cast(user)) + { + auto reason = escapes(getThisOp.getResult(), walk); + if (!reason.empty()) + { + return reason; + } + + continue; + } + + if (isa(user)) + { + continue; + } + + return "unclassified"; + } + + return ""; + } + + // A local slot the object was stored into: it is the object wherever it is loaded. A slot a + // closure captures, or one whose address is handed on, is beyond what is followed here. + llvm::StringRef slotEscapes(mlir_ts::VariableOp slot, Walk &walk) + { + if (slot.getCaptured().has_value() && slot.getCaptured().value()) + { + return "captured"; + } + + if (!walk.slots.insert(slot).second) + { + return ""; + } + + for (auto &use : slot.getResult().getUses()) + { + auto *user = use.getOwner(); + if (auto loadOp = dyn_cast(user)) + { + auto reason = escapes(loadOp.getResult(), walk); + if (!reason.empty()) + { + return reason; + } + + continue; + } + + if (auto storeOp = dyn_cast(user); storeOp && storeOp.getValue() != slot.getResult()) + { + continue; + } + + if (isa(user)) + { + continue; + } + + // an array changed in place through the slot holding it + if (isGrowth(user) && use.getOperandNumber() == 0) + { + return "growable"; + } + + return "unclassified"; + } + + return ""; + } + + // A reference into the object: only loaded through or stored through. Anything else - a call + // taking it, a store of the reference itself - lets the object's address out. + static llvm::StringRef fieldRefEscapes(mlir::Value fieldRef) + { + for (auto *user : fieldRef.getUsers()) + { + // a release of the old value before a store, a retain of what it holds + if (isa(user)) + { + continue; + } + + if (auto storeOp = dyn_cast(user); + storeOp && storeOp.getReference() == fieldRef && storeOp.getValue() != fieldRef) + { + continue; + } + + return "unclassified"; + } + + return ""; + } + + // What may change an array's length - and so move its elements to a block of another size. + static bool isGrowth(mlir::Operation *op) + { + return isa(op); + } + + llvm::StringRef useEscapes(mlir::Value value, mlir::OpOperand &use, Walk &walk) + { + if (isGrowth(use.getOwner())) + { + // the array itself, or a value going into it + return use.getOperandNumber() == 0 ? "growable" : "stored"; + } + + auto *user = use.getOwner(); + return llvm::TypeSwitch(user) + .Case([&](auto propertyRefOp) { return fieldRefEscapes(propertyRefOp.getResult()); }) + .Case([&](auto storeOp) -> llvm::StringRef { + // into the object's own storage + if (storeOp.getReference() == value && storeOp.getValue() != value) + { + return ""; + } + + if (auto slot = storeOp.getReference().template getDefiningOp()) + { + return slotEscapes(slot, walk); + } + + return "stored"; + }) + .Case([&](auto slot) { return slotEscapes(slot, walk); }) + .Case([&](auto) { return ""; }) + .Case([&](auto elementRefOp) -> llvm::StringRef { + return use.getOperandNumber() == 0 ? fieldRefEscapes(elementRefOp.getResult()) : "unclassified"; + }) + .Case([&](auto) { return ""; }) + .Case([&](auto) { return ""; }) + .Case([&](auto castOp) -> llvm::StringRef { + // a view of the same object - a boxed literal seen as an object, a class as its + // own type - is the object; anything else converts it + if (isa(castOp.getType())) + { + return escapes(castOp.getResult(), walk); + } + + return "converted"; + }) + .Case([&](auto) { return "converted"; }) + .Case([&](auto refOp) -> llvm::StringRef { + if (use.getOperandNumber() != 0) + { + return "unclassified"; + } + + return boundMethodEscapes(refOp.getResult(), walk); + }) + .Case([&](auto callOp) { + return argumentEscapes({callOp.getCalleeAttr()}, use.getOperandNumber()); + }) + .Case([&](auto callOp) -> llvm::StringRef { + // a closure being called: running it hands its capture box to nothing + if (use.getOperandNumber() == 0) + { + return ""; + } + + llvm::SmallVector candidates; + if (!calleesOf(callOp->getOperand(0), candidates)) + { + return "unknown-callee"; + } + + return argumentEscapes(candidates, use.getOperandNumber() - 1); + }) + .Case([&](auto) { return "returned"; }) + .Case([&](auto) { return "thrown"; }) + .Default([&](auto) { return "unclassified"; }); + } +}; + +} // namespace stack_escape + +#endif // MLIR_TYPESCRIPT_STACKESCAPEFACTS_H diff --git a/tslang/lib/TypeScript/StackPromotionPass.cpp b/tslang/lib/TypeScript/StackPromotionPass.cpp new file mode 100644 index 000000000..b0b9a79c6 --- /dev/null +++ b/tslang/lib/TypeScript/StackPromotionPass.cpp @@ -0,0 +1,739 @@ +#include "mlir/Pass/Pass.h" +#include "mlir/IR/IRMapping.h" +#include "mlir/IR/Dominance.h" +#include "mlir/Analysis/CFGLoopInfo.h" + +#include "TypeScript/TypeScriptDialect.h" +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/Passes.h" +#include "TypeScript/Defines.h" +#include "TypeScript/MLIRLogic/MLIRTypeHelper.h" + +#include "StackEscapeFacts.h" + +#include "llvm/ADT/MapVector.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/TypeSwitch.h" + +#define DEBUG_TYPE "stack-promotion" + +namespace mlir_ts = mlir::typescript; + +namespace +{ + +// The suffix of the static method every class gets for `new C()`: its body allocates the object +// and stores the vtable; the caller then calls the constructor with the result as `this`. +constexpr llvm::StringLiteral NewMethodSuffix("..new"); + +// Stack promotion (docs/superpowers/specs/2026-10-18-stack-promotion-design.md): an allocation +// whose object provably never outlives its function, and is small enough, goes on the stack. +// Every other allocation stays on the heap, silently. Runs at the affine level, where control +// flow is an ordinary CFG and every allocation and ownership op is still present. +// +// An allocation site is a `ts.New` (a boxed object literal, a class expression) or a call of a +// class's `C..new` defined in this module, which is how `new C()` reaches the caller. A promoted +// `C..new` call is replaced by its body: a `ts.New` on the stack plus the vtable store. +class StackPromotionPass : public mlir::PassWrapper> +{ + CompileOptions compileOptions; + mlir_ts::StackPromotionLimits limits; + stack_escape::StackEscapeFacts *facts = nullptr; + // shared: a pass is copied when the pass manager clones it + llvm::DenseMap> loopInfos; + llvm::DenseMap tryRegions; + llvm::DenseMap coroutines; + bool moduleUsesShared = false; + + public: + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(StackPromotionPass) + + StackPromotionPass(CompileOptions &compileOptions, mlir_ts::StackPromotionLimits limits) + : compileOptions(compileOptions), limits(limits) + { + } + + void runOnOperation() override + { + auto module = getOperation(); + stack_escape::StackEscapeFacts moduleFacts(module); + facts = &moduleFacts; + moduleUsesShared = module + .walk([](mlir::Operation *op) { + auto isShared = [](mlir::Type type) { return isa(type); }; + return isa(op) || + llvm::any_of(op->getResultTypes(), isShared) || + llvm::any_of(op->getOperandTypes(), isShared) + ? mlir::WalkResult::interrupt() + : mlir::WalkResult::advance(); + }) + .wasInterrupted(); + + llvm::SmallVector sites; + module.walk([&](mlir_ts::FuncOp funcOp) { + // `C..new` is the allocation template every `new C()` calls, not a site of its own + if (funcOp.getName().ends_with(NewMethodSuffix)) + { + return; + } + + funcOp.walk([&](mlir::Operation *op) { + if (isSite(op)) + { + sites.push_back(op); + } + }); + }); + + // every site decided before any is promoted: a promotion rewrites the IR the others read + llvm::SmallVector reasons; + for (auto *site : sites) + { + reasons.push_back(decide(site)); + } + + applyFrameBudget(sites, reasons); + + for (auto [site, reason] : llvm::zip(sites, reasons)) + { + if (limits.report) + { + if (reason.empty()) + { + mlir::emitRemark(site->getLoc()) << "stack-promotion: stack"; + } + else + { + mlir::emitRemark(site->getLoc()) << "stack-promotion: heap: " << reason; + } + } + + if (reason.empty()) + { + promote(site); + } + } + } + + private: + bool isSite(mlir::Operation *op) + { + if (auto newOp = dyn_cast(op)) + { + return !(newOp.getStackAlloc().has_value() && newOp.getStackAlloc().value()); + } + + if (isa(op)) + { + return !op->hasAttr(STACK_ALLOC_ATTR_NAME); + } + + if (isa(op) && op->hasAttr(CAPTURE_BOX_ATTR_NAME)) + { + return !op->hasAttr(STACK_ALLOC_ATTR_NAME); + } + + return static_cast(newMethodOf(op)); + } + + // The `C..new` a call allocates through, when it is one defined here with a body. A class + // imported as a declaration has none: an unknown callee, so a heap site. + mlir_ts::FuncOp newMethodOf(mlir::Operation *op) + { + auto callOp = dyn_cast(op); + if (!callOp || !callOp.getCallOperands().empty() || callOp->getNumResults() != 1 || + !isa(callOp->getResult(0).getType()) || + !callOp.getCallee().ends_with(NewMethodSuffix)) + { + return {}; + } + + auto funcOp = getOperation().lookupSymbol(callOp.getCalleeAttr()); + if (!funcOp || funcOp.getBody().empty()) + { + return {}; + } + + return funcOp; + } + + // The storage the object's block holds, which is what goes on the stack. + static mlir::Type storageTypeOf(mlir::Type objectType) + { + if (auto classType = dyn_cast(objectType)) + { + return classType.getStorageType(); + } + + if (auto valueRefType = dyn_cast(objectType)) + { + return valueRefType.getElementType(); + } + + return {}; + } + + // What every kind of site is refused for by where it is, not by what it does. + llvm::StringRef frameReason(mlir_ts::FuncOp funcOp) + { + // version 1: an unwind path would need its own ts.ReleaseContents + if (hasTryRegion(funcOp)) + { + return "try-region"; + } + + // version 1: a frame that is left and re-entered - at an await, at a yield - is not one + // stack frame + if (isCoroutine(funcOp)) + { + return "coroutine"; + } + + // every frame of a recursion would carry the object + if (facts->isRecursive(funcOp)) + { + return "recursive"; + } + + return ""; + } + + // An array literal: its header and its elements on the stack, when the elements are known now, + // the array never changes length, and - under rc and own - its elements own nothing, so that + // nothing has to be given back when the function returns (version 1). + llvm::StringRef decideArray(mlir_ts::CreateArrayOp arrayOp) + { + auto reason = frameReason(arrayOp->getParentOfType()); + if (!reason.empty()) + { + return reason; + } + + auto elementType = cast(arrayOp.getType()).getElementType(); + MLIRTypeHelper mth(&getContext(), compileOptions); + if (compileOptions.tracksOwnership() && mth.ownsHeapMemory(arrayOp.getLoc(), elementType)) + { + return "owning-elements"; + } + + if (arrayDataBytes(arrayOp) > limits.maxObjectBytes) + { + return "too-big"; + } + + stack_escape::StackEscapeFacts::Walk walk; + reason = facts->escapes(arrayOp.getResult(), walk); + if (!reason.empty()) + { + return reason; + } + + return loopCarried(arrayOp, walk) ? "loop-carried" : ""; + } + + // A closure's capture box: on the stack when the closure never leaves the function. Only where + // nothing counts (version 1): under rc and own the box's release routine is what gives back + // the cells it holds, and an immortal box would never run it. + llvm::StringRef decideBox(mlir_ts::VariableOp boxOp) + { + if (compileOptions.tracksOwnership()) + { + return "counted-capture"; + } + + auto reason = frameReason(boxOp->getParentOfType()); + if (!reason.empty()) + { + return reason; + } + + auto storageType = boxOp.getType().getElementType(); + if (blockBytes(storageType) - headerBytes() > limits.maxObjectBytes) + { + return "too-big"; + } + + // filled through its fields, then made into closures: the closures are what may escape + stack_escape::StackEscapeFacts::Walk walk; + for (auto &use : boxOp.getResult().getUses()) + { + auto *user = use.getOwner(); + if (auto propertyRefOp = dyn_cast(user)) + { + for (auto *fieldUser : propertyRefOp.getResult().getUsers()) + { + auto storeOp = dyn_cast(fieldUser); + if (!isa(fieldUser) && !(storeOp && storeOp.getReference() == propertyRefOp.getResult())) + { + return "unclassified"; + } + } + + continue; + } + + if (auto boundOp = dyn_cast(user); boundOp && use.getOperandNumber() == 0) + { + reason = facts->escapes(boundOp.getResult(), walk); + if (!reason.empty()) + { + return reason; + } + + continue; + } + + return "unclassified"; + } + + return loopCarried(boxOp, walk) ? "loop-carried" : ""; + } + + uint64_t arrayDataBytes(mlir_ts::CreateArrayOp arrayOp) + { + llvm::SmallPtrSet visiting; + auto elementType = cast(arrayOp.getType()).getElementType(); + return arrayOp.getItems().size() * ((estimateBytes(elementType, visiting) + 7) / 8 * 8); + } + + // What a site takes in the frame, headers included. + uint64_t siteBytes(mlir::Operation *site) + { + if (auto arrayOp = dyn_cast(site)) + { + // the header block { data, length, capacity }, then the element block + return headerBytes() + 3 * headerBytes() + headerBytes() + arrayDataBytes(arrayOp); + } + + if (auto boxOp = dyn_cast(site)) + { + return blockBytes(boxOp.getType().getElementType()); + } + + return blockBytes(storageTypeOf(site->getResult(0).getType())); + } + + // Empty when the site may go on the stack; otherwise why not. + llvm::StringRef decide(mlir::Operation *site) + { + if (auto arrayOp = dyn_cast(site)) + { + return decideArray(arrayOp); + } + + if (auto boxOp = dyn_cast(site)) + { + return decideBox(boxOp); + } + + auto value = site->getResult(0); + auto storageType = storageTypeOf(value.getType()); + if (!storageType) + { + return "unclassified"; + } + + // A promoted object gives back what it owns when its function returns, later than the heap + // object it replaces would have been destroyed. Only a Shared handle's count can see + // that, so under the models that count, a module using Shared keeps every object that + // owns memory on the heap: a field of class type may hold a subclass with a handle in it. + if (compileOptions.tracksOwnership() && moduleUsesShared) + { + MLIRTypeHelper mth(&getContext(), compileOptions); + if (mth.ownsHeapMemory(site->getLoc(), storageType)) + { + return "shared-handle"; + } + } + + auto frame = frameReason(site->getParentOfType()); + if (!frame.empty()) + { + return frame; + } + + if (blockBytes(storageType) - headerBytes() > limits.maxObjectBytes) + { + return "too-big"; + } + + if (isa(site) && newMethodBody(newMethodOf(site)).empty()) + { + return "unclassified"; + } + + stack_escape::StackEscapeFacts::Walk walk; + auto reason = facts->escapes(value, walk); + if (!reason.empty()) + { + return reason; + } + + return loopCarried(site, walk) ? "loop-carried" : ""; + } + + // One stack slot per site, reused by every iteration of a loop around it: the object of one + // iteration must be gone by the next. It is when nothing that is the object is used outside the + // innermost loop holding the site, and every local slot it was stored into is declared in that + // loop with an initializer - so each iteration re-initialises it before anything reads it. + bool loopCarried(mlir::Operation *site, stack_escape::StackEscapeFacts::Walk &walk) + { + auto funcOp = site->getParentOfType(); + auto &body = funcOp.getBody(); + // one block has no loop, and has no dominator tree to ask + if (body.hasOneBlock()) + { + return false; + } + + auto *siteOp = body.findAncestorOpInRegion(*site); + auto *loop = loopInfoOf(funcOp).getLoopFor(siteOp->getBlock()); + if (!loop) + { + return false; + } + + auto inLoop = [&](mlir::Operation *op) { + auto *ancestor = body.findAncestorOpInRegion(*op); + return ancestor && loop->contains(ancestor->getBlock()); + }; + + for (auto alias : walk.aliases) + { + for (auto *user : alias.getUsers()) + { + if (!inLoop(user)) + { + return true; + } + } + } + + for (auto *slot : walk.slots) + { + if (!inLoop(slot) || slot->getNumOperands() == 0) + { + return true; + } + + for (auto *user : slot->getUsers()) + { + if (!inLoop(user)) + { + return true; + } + } + } + + return false; + } + + mlir::CFGLoopInfo &loopInfoOf(mlir_ts::FuncOp funcOp) + { + auto &entry = loopInfos[funcOp]; + if (!entry) + { + auto &domInfo = getAnalysis(); + entry = std::make_shared(domInfo.getDomTree(&funcOp.getBody())); + } + + return *entry; + } + + // The ops of `C..new` that make the object: its `ts.New` and what follows it up to the point + // the object is handed to the return slot - the vtable store. Empty when the body is not of + // that shape, which keeps the site on the heap. + llvm::SmallVector newMethodBody(mlir_ts::FuncOp newMethod) + { + llvm::SmallVector ops; + mlir_ts::NewOp newOp; + for (auto &op : newMethod.getBody().front()) + { + if (!newOp) + { + newOp = dyn_cast(&op); + if (newOp) + { + ops.push_back(&op); + } + + continue; + } + + if (isa(&op)) + { + return ops; + } + + if (auto storeOp = dyn_cast(&op); storeOp && storeOp.getValue() == newOp.getResult()) + { + return ops; + } + + if (!isa(&op)) + { + return {}; + } + + // everything it reads is made in this run of ops: nothing to map from elsewhere + for (auto operand : op.getOperands()) + { + if (!llvm::is_contained(ops, operand.getDefiningOp())) + { + return {}; + } + } + + ops.push_back(&op); + } + + return {}; + } + + unsigned headerBytes() + { + return compileOptions.sizeBits() / 8; + } + + // What a site's block takes in the frame: the header word and the payload. An estimate - every + // field rounded up to 8 bytes - because the exact layout is only known once lowered to LLVM; + // it bounds how much stack promotion takes, which needs no more precision than that. + uint64_t blockBytes(mlir::Type storageType) + { + llvm::SmallPtrSet visiting; + return headerBytes() + estimateBytes(storageType, visiting); + } + + uint64_t estimateBytes(mlir::Type type, llvm::SmallPtrSetImpl &visiting) + { + auto word = static_cast(headerBytes()); + auto roundUp = [](uint64_t bytes) { return (bytes + 7) / 8 * 8; }; + if (!visiting.insert(type).second) + { + return word; + } + + uint64_t bytes = word; + if (isa(type)) + { + MLIRTypeHelper mth(&getContext(), compileOptions); + bytes = 0; + for (auto fieldType : mth.getOwnershipFieldTypes(type)) + { + bytes += roundUp(estimateBytes(fieldType, visiting)); + } + } + else if (auto constTupleType = dyn_cast(type)) + { + bytes = 0; + for (auto &field : constTupleType.getFields()) + { + bytes += roundUp(estimateBytes(field.type, visiting)); + } + } + else if (auto unionType = dyn_cast(type)) + { + // the tag, then the largest member + uint64_t largest = 0; + for (auto memberType : unionType.getTypes()) + { + largest = std::max(largest, estimateBytes(memberType, visiting)); + } + + bytes = 8 + roundUp(largest); + } + else if (auto optionalType = dyn_cast(type)) + { + bytes = 8 + roundUp(estimateBytes(optionalType.getElementType(), visiting)); + } + else if (auto literalType = dyn_cast(type)) + { + bytes = estimateBytes(literalType.getElementType(), visiting); + } + else if (isa(type)) + { + bytes = 2 * word; + } + + visiting.erase(type); + return bytes; + } + + // The frame budget: per function, sites in loops first - where a heap allocation costs most - + // then in source order, until the next would not fit. + void applyFrameBudget(llvm::ArrayRef sites, llvm::MutableArrayRef reasons) + { + llvm::MapVector> byFunction; + for (auto [index, site] : llvm::enumerate(sites)) + { + if (reasons[index].empty()) + { + byFunction[site->getParentOfType()].push_back(index); + } + } + + for (auto &entry : byFunction) + { + auto &indexes = entry.second; + llvm::stable_sort(indexes, [&](unsigned left, unsigned right) { + return inLoop(sites[left]) && !inLoop(sites[right]); + }); + + uint64_t used = 0; + for (auto index : indexes) + { + auto bytes = siteBytes(sites[index]); + if (used + bytes > limits.maxFrameBytes) + { + reasons[index] = "frame-budget"; + continue; + } + + used += bytes; + } + } + } + + bool inLoop(mlir::Operation *site) + { + auto funcOp = site->getParentOfType(); + auto &body = funcOp.getBody(); + if (body.hasOneBlock()) + { + return false; + } + + return loopInfoOf(funcOp).getLoopFor(body.findAncestorOpInRegion(*site)->getBlock()) != nullptr; + } + + // A function whose frame is left and re-entered: an async one, which holds async-dialect ops + // once `await` is expanded, or a generator's step function, which dispatches on its state. + bool isCoroutine(mlir_ts::FuncOp funcOp) + { + auto it = coroutines.find(funcOp); + if (it != coroutines.end()) + { + return it->second; + } + + auto found = funcOp + .walk([](mlir::Operation *op) { + return isa(op) || + op->getDialect()->getNamespace() == "async" + ? mlir::WalkResult::interrupt() + : mlir::WalkResult::advance(); + }) + .wasInterrupted(); + coroutines[funcOp] = found; + return found; + } + + // A function with a try region: after the affine lowering, one with a landing pad. + bool hasTryRegion(mlir_ts::FuncOp funcOp) + { + auto it = tryRegions.find(funcOp); + if (it != tryRegions.end()) + { + return it->second; + } + + auto found = funcOp.walk([](mlir_ts::LandingPadOp) { return mlir::WalkResult::interrupt(); }).wasInterrupted(); + tryRegions[funcOp] = found; + return found; + } + + // The object goes to the function's entry block, where it is made once per call and dominates + // every use and every return; where the allocation was, ts.ReleaseContents `reset` gives back + // what the previous iteration's object owned and makes a fresh one. Under rc and own what the + // object owns is given back at every return too. + void promote(mlir::Operation *site) + { + // an array or a box gives nothing back at a return (decideArray, decideBox): its blocks + // only change where they are allocated + if (isa(site)) + { + site->setAttr(STACK_ALLOC_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + return; + } + + auto funcOp = site->getParentOfType(); + auto &entryBlock = funcOp.getBody().front(); + + mlir::Value stackObject; + mlir::OpBuilder builder(site); + if (auto newOp = dyn_cast(site)) + { + newOp.setStackAllocAttr(builder.getBoolAttr(true)); + stackObject = newOp.getResult(); + // where the allocation was - so before it moves, which the builder's position follows + builder.create(site->getLoc(), stackObject, builder.getUnitAttr()); + newOp->moveBefore(&entryBlock, entryBlock.begin()); + } + else + { + // `new C()`: the body of `C..new` in place of the call, its `ts.New` on the stack + auto body = newMethodBody(newMethodOf(site)); + mlir::IRMapping mapping; + for (auto *op : body) + { + if (auto newOp = dyn_cast(op)) + { + mlir::OpBuilder entryBuilder(&entryBlock, entryBlock.begin()); + auto stackNewOp = entryBuilder.create(site->getLoc(), newOp.getType(), + entryBuilder.getBoolAttr(true)); + // the call's ownership marks (`__owned_result`) describe the object it made + for (auto attr : site->getDiscardableAttrs()) + { + stackNewOp->setAttr(attr.getName(), attr.getValue()); + } + + stackObject = stackNewOp.getResult(); + mapping.map(newOp.getResult(), stackObject); + builder.create(site->getLoc(), stackObject, builder.getUnitAttr()); + continue; + } + + builder.clone(*op, mapping)->setLoc(site->getLoc()); + } + + site->getResult(0).replaceAllUsesWith(stackObject); + site->erase(); + } + + if (!compileOptions.tracksOwnership()) + { + return; + } + + // nothing counts or destroys an immortal block: the object's own retains and releases are + // no-ops, and they go + for (auto *user : llvm::make_early_inc_range(stackObject.getUsers())) + { + if (isa(user)) + { + user->erase(); + } + } + + MLIRTypeHelper mth(&getContext(), compileOptions); + if (!mth.ownsHeapMemory(stackObject.getLoc(), storageTypeOf(stackObject.getType()))) + { + return; + } + + // A returned value was retained where the return stored it, before the branch to the + // return block: giving back the object's fields here cannot take it away. + funcOp.walk([&](mlir_ts::ReturnInternalOp returnOp) { + mlir::OpBuilder returnBuilder(returnOp); + returnBuilder.create(returnOp.getLoc(), stackObject, mlir::UnitAttr()); + }); + } +}; + +} // namespace + +std::unique_ptr mlir_ts::createStackPromotionPass(CompileOptions &compileOptions, + mlir_ts::StackPromotionLimits limits) +{ + return std::make_unique(compileOptions, limits); +} diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index aff7af301..8d1909e90 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -182,6 +182,11 @@ endif() # Off by default: the default suite and its test names do not change. option(TSLANG_TEST_X86 "Also register the 32-bit (i686) twin of every non-JIT test" OFF) +# With TSLANG_TEST_NO_STACK_PROMOTION on, every `test-runner` registration builds with every +# allocation on the heap: run the suite both ways and the pass/fail lists must be the same +# (docs/superpowers/specs/2026-10-18-stack-promotion-design.md section 9). +option(TSLANG_TEST_NO_STACK_PROMOTION "Build every test-runner test with --no-stack-promotion" OFF) + if (TSLANG_TEST_X86) if (NOT WIN32) message(FATAL_ERROR "TSLANG_TEST_X86: the x86 twins build Windows i686 programs, so they need a Windows host") @@ -238,6 +243,10 @@ function(tslang_add_test) message(FATAL_ERROR "tslang_add_test(${arg_NAME}): the name must start with test-") endif() + if (TSLANG_TEST_NO_STACK_PROMOTION) + list(INSERT arg_COMMAND 1 -no-stack-promotion) + endif() + add_test(NAME ${arg_NAME} COMMAND ${arg_COMMAND}) set(twin "") @@ -4131,3 +4140,134 @@ if (WIN32) endforeach() endforeach() endif() + +# Stack promotion (docs/superpowers/specs/2026-10-18-stack-promotion-design.md). Run tests: every +# model, JIT and ahead of time; the output must not depend on where an object lives. +set(stack_promotion_run_tests class_call_same_as_new literal_boxed_local leaf_local leaf_returned + ctor_local borrow_uses pass_to_reader pass_to_keeper method_keeps_this virtual_one_keeps ctor_keeps_this + to_interface to_any pass_to_print loop_carried + fields_string fields_object field_overwrite return_field try_region conditional_alloc shared_handle + too_big frame_budget recursive async_local generator_local thrown + array_local_fixed array_local_push array_returned closure_local closure_escapes + virtual_exported_subclass recursive_closure) +# Programs -mm=own rejects on its own (a keeper it cannot move into, a move on some paths only): +# they say nothing about promotion under own. +set(stack_promotion_not_own method_keeps_this virtual_one_keeps ctor_keeps_this loop_carried) +foreach(stack_mm gc rc own none) + foreach(stack_test ${stack_promotion_run_tests}) + if (stack_mm STREQUAL "own" AND stack_test IN_LIST stack_promotion_not_own) + continue() + endif() + # async under rc and own fails to link in the JIT without promotion too + if ((stack_mm STREQUAL "rc" OR stack_mm STREQUAL "own") AND stack_test STREQUAL "async_local") + continue() + endif() + tslang_add_test(NAME test-jit-${stack_mm}-stack-${stack_test} COMMAND test-runner -jit -mm=${stack_mm} "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/${stack_test}.ts") + tslang_add_test(NAME test-compile-${stack_mm}-stack-${stack_test} COMMAND test-runner -mm=${stack_mm} "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/${stack_test}.ts") + endforeach() +endforeach() + +# The report reaches stderr, and the pass runs before --emit=mlir-affine stops the pipeline: every +# decision test below depends on both. +add_test(NAME test-stack-promotion-report-reaches-stderr + COMMAND $ --emit=mlir-affine --no-default-lib -mm=none --stack-promotion-report + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/class_call_same_as_new.ts") +set_tests_properties(test-stack-promotion-report-reaches-stderr + PROPERTIES PASS_REGULAR_EXPRESSION "remark: stack-promotion: " + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +add_test(NAME test-stack-promotion-off + COMMAND $ --emit=mlir-affine --no-default-lib -mm=none --stack-promotion-report --no-stack-promotion + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/class_call_same_as_new.ts") +set_tests_properties(test-stack-promotion-off + PROPERTIES PASS_REGULAR_EXPRESSION "ts\\.Func" + FAIL_REGULAR_EXPRESSION "stack-promotion:|Stack dump|Assertion failed") +tslang_add_test(NAME test-jit-gc-stack-off-class-call COMMAND test-runner -jit -mm=gc -no-stack-promotion "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/class_call_same_as_new.ts") + +# Decision tests: `|`; the remark at the allocation site says stack, or heap and why. +set(stack_promotion_decision_tests + "literal_boxed_local|literal_boxed_local\.ts:6:19: remark: stack-promotion: stack" + "leaf_local|leaf_local\.ts:11:19: remark: stack-promotion: stack" + "leaf_returned|leaf_returned\.ts:8:15: remark: stack-promotion: heap: returned" + "ctor_local|ctor_local\.ts:14:19: remark: stack-promotion: stack" + "borrow_uses|borrow_uses\.ts:17:15: remark: stack-promotion: stack.*borrow_uses\.ts:18:15: remark: stack-promotion: stack" + "pass_to_reader|pass_to_reader\.ts:18:19: remark: stack-promotion: stack" + "pass_to_keeper|pass_to_keeper\.ts:16:15: remark: stack-promotion: heap: argument" + "method_keeps_this|method_keeps_this\.ts:15:15: remark: stack-promotion: heap: argument" + "virtual_one_keeps|virtual_one_keeps\.ts:26:18: remark: stack-promotion: heap: argument" + "ctor_keeps_this|ctor_keeps_this\.ts:13:15: remark: stack-promotion: heap: argument" + "to_interface|to_interface\.ts:14:15: remark: stack-promotion: heap: converted" + "to_any|to_any\.ts:10:15: remark: stack-promotion: heap: converted" + "pass_to_print|pass_to_print\.ts:13:15: remark: stack-promotion: stack" + "loop_carried|loop_carried\.ts:13:19: remark: stack-promotion: heap: loop-carried" + "fields_string|fields_string\.ts:15:19: remark: stack-promotion: stack" + "fields_object|fields_object\.ts:17:15: remark: stack-promotion: stack.*fields_object\.ts:17:25: remark: stack-promotion: heap: argument" + "field_overwrite|field_overwrite\.ts:10:15: remark: stack-promotion: stack" + "return_field|return_field\.ts:11:15: remark: stack-promotion: stack" + "try_region|try_region\.ts:10:15: remark: stack-promotion: heap: try-region" + "conditional_alloc|conditional_alloc\.ts:12:19: remark: stack-promotion: stack" + "too_big|too_big\.ts:47:15: remark: stack-promotion: heap: too-big" + "frame_budget|frame_budget\.ts:67:17: remark: stack-promotion: stack.*frame_budget\.ts:69:17: remark: stack-promotion: heap: frame-budget" + "recursive|recursive\.ts:16:15: remark: stack-promotion: heap: recursive" + "async_local|async_local\.ts:10:15: remark: stack-promotion: heap: coroutine" + "generator_local|generator_local\.ts:11:19: remark: stack-promotion: heap: coroutine" + "thrown|thrown\.ts:10:15: remark: stack-promotion: heap: thrown" + "array_local_fixed|array_local_fixed\.ts:3:15: remark: stack-promotion: stack" + "array_local_push|array_local_push\.ts:3:25: remark: stack-promotion: heap: growable" + "array_returned|array_returned\.ts:3:15: remark: stack-promotion: heap: returned" + "virtual_exported_subclass|virtual_exported_subclass\.ts:23:18: remark: stack-promotion: heap: unknown-callee" + "recursive_closure|recursive_closure\.ts:17:15: remark: stack-promotion: heap: recursive") +foreach(stack_mm gc rc own none) + foreach(stack_case ${stack_promotion_decision_tests}) + string(FIND "${stack_case}" "|" stack_sep) + string(SUBSTRING "${stack_case}" 0 ${stack_sep} stack_name) + math(EXPR stack_sep "${stack_sep} + 1") + string(SUBSTRING "${stack_case}" ${stack_sep} -1 stack_regex) + if (stack_mm STREQUAL "own" AND stack_name IN_LIST stack_promotion_not_own) + continue() + endif() + add_test(NAME test-stack-decision-${stack_mm}-${stack_name} + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${stack_mm} --stack-promotion-report + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/${stack_name}.ts") + set_tests_properties(test-stack-decision-${stack_mm}-${stack_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${stack_regex}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") + endforeach() +endforeach() +# The same site is a stack site where nothing counts, and a heap site where a Shared count would see it. +foreach(stack_case "gc|stack" "none|stack" "rc|heap: shared-handle" "own|heap: shared-handle") + string(FIND "${stack_case}" "|" stack_sep) + string(SUBSTRING "${stack_case}" 0 ${stack_sep} stack_mm) + math(EXPR stack_sep "${stack_sep} + 1") + string(SUBSTRING "${stack_case}" ${stack_sep} -1 stack_expected) + add_test(NAME test-stack-decision-${stack_mm}-shared_handle + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${stack_mm} --stack-promotion-report + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/shared_handle.ts") + set_tests_properties(test-stack-decision-${stack_mm}-shared_handle + PROPERTIES PASS_REGULAR_EXPRESSION "shared_handle\.ts:14:14: remark: stack-promotion: ${stack_expected}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() +# The object limit is a flag. +add_test(NAME test-stack-decision-max-object + COMMAND $ --emit=mlir-affine --no-default-lib -mm=gc --stack-promotion-report --stack-promotion-max-object=512 + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/too_big.ts") +set_tests_properties(test-stack-decision-max-object + PROPERTIES PASS_REGULAR_EXPRESSION "too_big\.ts:47:15: remark: stack-promotion: stack" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +# A capture box: on the stack where nothing counts, and only while its closures stay in the function. +foreach(stack_case "gc|closure_local|10:11: remark: stack-promotion: stack" + "none|closure_local|10:11: remark: stack-promotion: stack" + "rc|closure_local|10:11: remark: stack-promotion: heap: counted-capture" + "own|closure_local|10:11: remark: stack-promotion: heap: counted-capture" + "gc|closure_escapes|4:12: remark: stack-promotion: heap: returned" + "none|closure_escapes|4:12: remark: stack-promotion: heap: returned") + string(REPLACE "|" ";" stack_parts "${stack_case}") + list(GET stack_parts 0 stack_mm) + list(GET stack_parts 1 stack_name) + list(GET stack_parts 2 stack_expected) + add_test(NAME test-stack-decision-${stack_mm}-${stack_name} + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${stack_mm} --stack-promotion-report + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/${stack_name}.ts") + set_tests_properties(test-stack-decision-${stack_mm}-${stack_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${stack_name}\.ts:${stack_expected}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() diff --git a/tslang/test/tester/stack-promotion/array_local_fixed.ts b/tslang/test/tester/stack-promotion/array_local_fixed.ts new file mode 100644 index 000000000..c8b15faac --- /dev/null +++ b/tslang/test/tester/stack-promotion/array_local_fixed.ts @@ -0,0 +1,18 @@ +// An array literal of the function's own values, only read: header and elements on the stack. +function sum3(x: number, y: number, z: number) { + const v = [x, y, z]; + let t: number = 0; + for (let i = 0; i < v.length; i++) { + t += v[i]; + } + return t; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 1000; i++) { + t += sum3(i, 1, 2); + } + assert(t == 502500); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/array_local_push.ts b/tslang/test/tester/stack-promotion/array_local_push.ts new file mode 100644 index 000000000..1ac4d9ad7 --- /dev/null +++ b/tslang/test/tester/stack-promotion/array_local_push.ts @@ -0,0 +1,17 @@ +// An array that grows: its header can stay on the stack, its elements cannot. +function count(n: number) { + const v: number[] = [n]; + for (let i = 0; i < n; i++) { + v.push(i); + } + return v.length; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100; i++) { + t += count(i); + } + assert(t == 5050); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/array_returned.ts b/tslang/test/tester/stack-promotion/array_returned.ts new file mode 100644 index 000000000..2106161f6 --- /dev/null +++ b/tslang/test/tester/stack-promotion/array_returned.ts @@ -0,0 +1,15 @@ +// A returned array outlives the function. +function pair(a: number, b: number) { + const v = [a, b]; + return v; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100; i++) { + const p = pair(i, 1); + t += p[0] + p[1]; + } + assert(t == 5050); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/async_local.ts b/tslang/test/tester/stack-promotion/async_local.ts new file mode 100644 index 000000000..e9a0458e3 --- /dev/null +++ b/tslang/test/tester/stack-promotion/async_local.ts @@ -0,0 +1,19 @@ +// An async function is a coroutine: no promotion in version 1. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +async function f(i: number) { + const p = new P(i); + await 1; + return p.x; +} + +async function main() { + const v = await f(5); + assert(v == 5); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/bench_small_class.ts b/tslang/test/tester/stack-promotion/bench_small_class.ts new file mode 100644 index 000000000..983fcdf30 --- /dev/null +++ b/tslang/test/tester/stack-promotion/bench_small_class.ts @@ -0,0 +1,24 @@ +// Benchmark (not registered with ctest): ten million short-lived two-field objects. +class Pair { + a: number; + b: number; + constructor(a: number, b: number) { + this.a = a; + this.b = b; + } +} + +function run(n: number) { + let t: number = 0; + for (let i = 0; i < n; i++) { + const p = new Pair(i, 1); + t += p.a + p.b; + } + return t; +} + +function main() { + const t = run(10000000); + assert(t == 50000005000000); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/bench_vector_math.ts b/tslang/test/tester/stack-promotion/bench_vector_math.ts new file mode 100644 index 000000000..87fd3b109 --- /dev/null +++ b/tslang/test/tester/stack-promotion/bench_vector_math.ts @@ -0,0 +1,32 @@ +// Benchmark (not registered with ctest): three-component vector temporaries in a tight loop. +class Vec3 { + x: number; + y: number; + z: number; + constructor(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + } +} + +function dot(a: Vec3, b: Vec3) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +function run(n: number) { + let t: number = 0; + for (let i = 0; i < n; i++) { + const a = new Vec3(i, i + 1, i + 2); + const b = new Vec3(1, 2, 3); + const d = new Vec3(a.x - b.x, a.y - b.y, a.z - b.z); + t += dot(d, d) - dot(a, b); + } + return t; +} + +function main() { + const t = run(5000000); + print(t); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/borrow_uses.ts b/tslang/test/tester/stack-promotion/borrow_uses.ts new file mode 100644 index 000000000..2c23218e6 --- /dev/null +++ b/tslang/test/tester/stack-promotion/borrow_uses.ts @@ -0,0 +1,30 @@ +// A method call, a comparison, instanceof and printing through its own toString: none of these +// let the object out. +class P { + x: number; + constructor(x: number) { + this.x = x; + } + twice() { + return this.x * 2; + } + toString() { + return "P"; + } +} + +function check(i: number) { + const p = new P(i); + const q = new P(i); + assert(p.twice() == i * 2); + assert(p !== q); + assert(p instanceof P); + print(p.toString()); +} + +function main() { + for (let i = 0; i < 3; i++) { + check(i); + } + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/class_call_same_as_new.ts b/tslang/test/tester/stack-promotion/class_call_same_as_new.ts new file mode 100644 index 000000000..272243c57 --- /dev/null +++ b/tslang/test/tester/stack-promotion/class_call_same_as_new.ts @@ -0,0 +1,26 @@ +// `P(...)` without `new` is the same as `new P(...)`: same constructor call, same object. +class P { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + toString() { + return "P"; + } +} + +function make() { + return P(5, 6); +} + +function main() { + const a = P(1, 2); + const b = new P(3, 4); + assert(a.x + a.y == 3); + assert(b.x + b.y == 7); + assert(make().y == 6); + assert(a instanceof P); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/closure_escapes.ts b/tslang/test/tester/stack-promotion/closure_escapes.ts new file mode 100644 index 000000000..e69829865 --- /dev/null +++ b/tslang/test/tester/stack-promotion/closure_escapes.ts @@ -0,0 +1,16 @@ +// A returned closure takes its capture box with it. +function counter() { + let c: number = 0; + return () => { + c++; + return c; + }; +} + +function main() { + const next = counter(); + next(); + next(); + assert(next() == 3); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/closure_local.ts b/tslang/test/tester/stack-promotion/closure_local.ts new file mode 100644 index 000000000..fa6544b90 --- /dev/null +++ b/tslang/test/tester/stack-promotion/closure_local.ts @@ -0,0 +1,19 @@ +// A closure that never leaves the function: its capture box goes on the stack. +function apply(f: (n: number) => void, n: number) { + for (let i = 0; i < n; i++) { + f(i); + } +} + +function total(n: number) { + let t: number = 0; + apply((i: number) => { + t += i; + }, n); + return t; +} + +function main() { + assert(total(10) == 45); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/conditional_alloc.ts b/tslang/test/tester/stack-promotion/conditional_alloc.ts new file mode 100644 index 000000000..9e59ace0f --- /dev/null +++ b/tslang/test/tester/stack-promotion/conditional_alloc.ts @@ -0,0 +1,26 @@ +// An allocation on a path that is not taken: the slot is still given back at the return, so it must +// hold nothing then. +class Holder { + s: string; + constructor(s: string) { + this.s = s; + } +} + +function f(c: boolean) { + if (c) { + const p = new Holder("s" + 1); + assert(p.s == "s1"); + } + return 1; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 1000; i++) { + t += f(false); + t += f(true); + } + assert(t == 2000); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/ctor_keeps_this.ts b/tslang/test/tester/stack-promotion/ctor_keeps_this.ts new file mode 100644 index 000000000..e3befbff6 --- /dev/null +++ b/tslang/test/tester/stack-promotion/ctor_keeps_this.ts @@ -0,0 +1,24 @@ +// The constructor stores `this` in a global: the object escapes while it is being built. +let all: P[] = []; + +class P { + x: number; + constructor(x: number) { + this.x = x; + all.push(this); + } +} + +function build(i: number) { + const p = new P(i); + return p.x; +} + +function main() { + for (let i = 0; i < 3; i++) { + build(i); + } + assert(all.length == 3); + assert(all[1].x == 1); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/ctor_local.ts b/tslang/test/tester/stack-promotion/ctor_local.ts new file mode 100644 index 000000000..bacad5182 --- /dev/null +++ b/tslang/test/tester/stack-promotion/ctor_local.ts @@ -0,0 +1,23 @@ +// The constructor only assigns fields: it borrows `this`, so the object stays on the stack. +class P { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +function sum(n: number) { + let t: number = 0; + for (let i = 0; i < n; i++) { + const p = new P(i, i + 1); + t += p.x + p.y; + } + return t; +} + +function main() { + assert(sum(1000) == 1000000); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/field_overwrite.ts b/tslang/test/tester/stack-promotion/field_overwrite.ts new file mode 100644 index 000000000..9639797fa --- /dev/null +++ b/tslang/test/tester/stack-promotion/field_overwrite.ts @@ -0,0 +1,28 @@ +// A field overwritten several times: every old value is given back once, the last one at exit. +class Holder { + s: string; + constructor(s: string) { + this.s = s; + } +} + +function f(i: number) { + const p = new Holder("a" + i); + print(p.s); + p.s = "b" + i; + print(p.s); + p.s = "c" + i; + print(p.s); + return p.s.length; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 3; i++) { + t += f(i); + const after = "x" + i + "y" + i; + print(after); + } + assert(t == 6); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/fields_object.ts b/tslang/test/tester/stack-promotion/fields_object.ts new file mode 100644 index 000000000..60c78350c --- /dev/null +++ b/tslang/test/tester/stack-promotion/fields_object.ts @@ -0,0 +1,28 @@ +// The outer object goes on the stack; the inner one is stored in a field, so it stays on the heap. +class Inner { + v: number; + constructor(v: number) { + this.v = v; + } +} + +class Outer { + inner: Inner; + constructor(inner: Inner) { + this.inner = inner; + } +} + +function f(i: number) { + const o = new Outer(new Inner(i)); + return o.inner.v; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 1000; i++) { + t += f(i); + } + assert(t == 499500); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/fields_string.ts b/tslang/test/tester/stack-promotion/fields_string.ts new file mode 100644 index 000000000..c6bd4c641 --- /dev/null +++ b/tslang/test/tester/stack-promotion/fields_string.ts @@ -0,0 +1,24 @@ +// A class with a string field built every iteration: on the stack, and the string is given back +// each time under rc and own. +class Named { + name: string; + n: number; + constructor(name: string, n: number) { + this.name = name; + this.n = n; + } +} + +function total(count: number) { + let t: number = 0; + for (let i = 0; i < count; i++) { + const p = new Named("item" + i, i); + t += p.name.length + p.n; + } + return t; +} + +function main() { + assert(total(10000) == 78890 + 49995000); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/frame_budget.ts b/tslang/test/tester/stack-promotion/frame_budget.ts new file mode 100644 index 000000000..8801a2471 --- /dev/null +++ b/tslang/test/tester/stack-promotion/frame_budget.ts @@ -0,0 +1,85 @@ +// 31 number fields and a vtable pointer: 256 bytes each, at the object limit. Each site takes 264 +// with its header, so 15 fit the 4096-byte frame budget and the 16th does not. +class Wide { + f0: number = 0; + f1: number = 1; + f2: number = 2; + f3: number = 3; + f4: number = 4; + f5: number = 5; + f6: number = 6; + f7: number = 7; + f8: number = 8; + f9: number = 9; + f10: number = 10; + f11: number = 11; + f12: number = 12; + f13: number = 13; + f14: number = 14; + f15: number = 15; + f16: number = 16; + f17: number = 17; + f18: number = 18; + f19: number = 19; + f20: number = 20; + f21: number = 21; + f22: number = 22; + f23: number = 23; + f24: number = 24; + f25: number = 25; + f26: number = 26; + f27: number = 27; + f28: number = 28; + f29: number = 29; + f30: number = 30; +} + +function sum() { + let t: number = 0; + const o0 = new Wide(); + t += o0.f0; + const o1 = new Wide(); + t += o1.f1; + const o2 = new Wide(); + t += o2.f2; + const o3 = new Wide(); + t += o3.f3; + const o4 = new Wide(); + t += o4.f4; + const o5 = new Wide(); + t += o5.f5; + const o6 = new Wide(); + t += o6.f6; + const o7 = new Wide(); + t += o7.f7; + const o8 = new Wide(); + t += o8.f8; + const o9 = new Wide(); + t += o9.f9; + const o10 = new Wide(); + t += o10.f10; + const o11 = new Wide(); + t += o11.f11; + const o12 = new Wide(); + t += o12.f12; + const o13 = new Wide(); + t += o13.f13; + const o14 = new Wide(); + t += o14.f14; + const o15 = new Wide(); + t += o15.f15; + const o16 = new Wide(); + t += o16.f16; + const o17 = new Wide(); + t += o17.f17; + const o18 = new Wide(); + t += o18.f18; + const o19 = new Wide(); + t += o19.f19; + return t; +} + +function main() { + assert(sum() == 190); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/generator_local.ts b/tslang/test/tester/stack-promotion/generator_local.ts new file mode 100644 index 000000000..b5b7ee77f --- /dev/null +++ b/tslang/test/tester/stack-promotion/generator_local.ts @@ -0,0 +1,23 @@ +// A generator's body is re-entered per next(): no promotion in version 1. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +function* gen(n: number) { + for (let i = 0; i < n; i++) { + const p = new P(i); + yield p.x; + } +} + +function main() { + let t: number = 0; + for (const v of gen(5)) { + t += v; + } + assert(t == 10); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/leaf_local.ts b/tslang/test/tester/stack-promotion/leaf_local.ts new file mode 100644 index 000000000..52d9efd8c --- /dev/null +++ b/tslang/test/tester/stack-promotion/leaf_local.ts @@ -0,0 +1,22 @@ +// A class with no constructor, built and dropped every iteration: its fields are only read and +// written, so it goes on the stack. +class P { + x: number; + y: number; +} + +function sum(n: number) { + let t: number = 0; + for (let i = 0; i < n; i++) { + const p = new P(); + p.x = i; + p.y = i + 1; + t += p.x + p.y; + } + return t; +} + +function main() { + assert(sum(1000) == 1000000); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/leaf_returned.ts b/tslang/test/tester/stack-promotion/leaf_returned.ts new file mode 100644 index 000000000..c316b41c4 --- /dev/null +++ b/tslang/test/tester/stack-promotion/leaf_returned.ts @@ -0,0 +1,20 @@ +// Returned, so it outlives the function: heap. +class P { + x: number; + y: number; +} + +function make(i: number) { + const p = new P(); + p.x = i; + return p; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 1000; i++) { + t += make(i).x; + } + assert(t == 499500); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/literal_boxed_local.ts b/tslang/test/tester/stack-promotion/literal_boxed_local.ts new file mode 100644 index 000000000..34dbf4a34 --- /dev/null +++ b/tslang/test/tester/stack-promotion/literal_boxed_local.ts @@ -0,0 +1,16 @@ +// An object literal with a method is boxed into a reference; used only through its fields, it +// goes on the stack. +function sum(n: number) { + let t: number = 0; + for (let i = 0; i < n; i++) { + const o = { x: i, y: i + 1, len() { return this.x + this.y; } }; + o.x = o.x * 2; + t += o.x + o.y; + } + return t; +} + +function main() { + assert(sum(100) == 14950); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/loop_carried.ts b/tslang/test/tester/stack-promotion/loop_carried.ts new file mode 100644 index 000000000..e5432b5d6 --- /dev/null +++ b/tslang/test/tester/stack-promotion/loop_carried.ts @@ -0,0 +1,24 @@ +// The object of iteration 3 is still used after the loop: the one stack slot would be reused, so +// it goes on the heap. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +function f() { + let keep: P = new P(-1); + for (let i = 0; i < 10; i++) { + const p = new P(i); + if (i == 3) { + keep = p; + } + } + return keep.x; +} + +function main() { + assert(f() == 3); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/method_keeps_this.ts b/tslang/test/tester/stack-promotion/method_keeps_this.ts new file mode 100644 index 000000000..c33dd1d6b --- /dev/null +++ b/tslang/test/tester/stack-promotion/method_keeps_this.ts @@ -0,0 +1,33 @@ +// A method that stores `this` somewhere: calling it lets the object out. +class P { + x: number; + constructor(x: number) { + this.x = x; + } + register() { + registry.push(this); + } +} + +let registry: P[] = []; + +function build(i: number) { + const p = new P(i); + p.register(); +} + +function overwrite(i: number) { + const a = new P(i * 100); + const b = new P(i * 1000); + return a.x + b.x; +} + +function main() { + for (let i = 0; i < 3; i++) { + build(i); + overwrite(i + 7); + } + assert(registry.length == 3); + assert(registry[2].x == 2); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/pass_to_keeper.ts b/tslang/test/tester/stack-promotion/pass_to_keeper.ts new file mode 100644 index 000000000..4f6963323 --- /dev/null +++ b/tslang/test/tester/stack-promotion/pass_to_keeper.ts @@ -0,0 +1,36 @@ +// A function that keeps its parameter: the object outlives the call, so it goes on the heap. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +let kept: P[] = []; + +function keep(p: P) { + kept.push(p); +} + +function build(i: number) { + const p = new P(i); + keep(p); +} + +function overwrite(i: number) { + const a = new P(i * 100); + const b = new P(i * 1000); + return a.x + b.x; +} + +function main() { + for (let i = 0; i < 3; i++) { + build(i); + overwrite(i + 7); + } + assert(kept.length == 3); + assert(kept[0].x == 0); + assert(kept[1].x == 1); + assert(kept[2].x == 2); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/pass_to_print.ts b/tslang/test/tester/stack-promotion/pass_to_print.ts new file mode 100644 index 000000000..763ea3b19 --- /dev/null +++ b/tslang/test/tester/stack-promotion/pass_to_print.ts @@ -0,0 +1,20 @@ +// print(p) converts p to a string through its own toString, which borrows `this`. +class P { + x: number; + constructor(x: number) { + this.x = x; + } + toString() { + return "P"; + } +} + +function show(i: number) { + const p = new P(i); + print(p); +} + +function main() { + show(1); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/pass_to_reader.ts b/tslang/test/tester/stack-promotion/pass_to_reader.ts new file mode 100644 index 000000000..9cff3a251 --- /dev/null +++ b/tslang/test/tester/stack-promotion/pass_to_reader.ts @@ -0,0 +1,27 @@ +// A function that only reads its parameter borrows it. +class P { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +function len2(p: P) { + return p.x * p.x + p.y * p.y; +} + +function total(n: number) { + let t: number = 0; + for (let i = 0; i < n; i++) { + const p = new P(i, 1); + t += len2(p); + } + return t; +} + +function main() { + assert(total(10) == 295); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/recursive.ts b/tslang/test/tester/stack-promotion/recursive.ts new file mode 100644 index 000000000..6226b89f1 --- /dev/null +++ b/tslang/test/tester/stack-promotion/recursive.ts @@ -0,0 +1,23 @@ +// A recursive function gets no promotion: every frame would carry the object. +class Pair { + a: number; + b: number; + constructor(a: number, b: number) { + this.a = a; + this.b = b; + } +} + +function depthSum(n: number): number { + if (n == 0) { + return 0; + } + + const p = new Pair(n, 1); + return p.a + p.b - 1 + depthSum(n - 1); +} + +function main() { + assert(depthSum(1000) == 500500); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/recursive_closure.ts b/tslang/test/tester/stack-promotion/recursive_closure.ts new file mode 100644 index 000000000..5ec663e57 --- /dev/null +++ b/tslang/test/tester/stack-promotion/recursive_closure.ts @@ -0,0 +1,29 @@ +// Recursion through a function value: f calls a closure that calls f through `ff`. A call +// through a value may reach any function whose address is taken, so f is recursive. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +let ff = (n: number): number => 0; + +function call(g: () => number) { + return g(); +} + +function f(n: number): number { + const p = new P(n); + if (n > 0) { + return p.x + call((): number => ff(n - 1)); + } + + return p.x; +} + +function main() { + ff = f; + assert(f(10) == 55); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/return_field.ts b/tslang/test/tester/stack-promotion/return_field.ts new file mode 100644 index 000000000..0e4dc9cca --- /dev/null +++ b/tslang/test/tester/stack-promotion/return_field.ts @@ -0,0 +1,24 @@ +// Returning a field of a stack object: the string must survive the object's contents being given +// back at the return. +class Holder { + s: string; + constructor(s: string) { + this.s = s; + } +} + +function f(i: number) { + const p = new Holder("v" + i); + return p.s; +} + +function main() { + let all = ""; + for (let i = 0; i < 1000; i++) { + const s = f(i); + all = all + s; + } + assert(all.length == 3890); + assert(f(42) == "v42"); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/shared_handle.ts b/tslang/test/tester/stack-promotion/shared_handle.ts new file mode 100644 index 000000000..4f7cbec34 --- /dev/null +++ b/tslang/test/tester/stack-promotion/shared_handle.ts @@ -0,0 +1,22 @@ +// A Shared handle's count sees when an object is destroyed: under rc and own, an object owning +// memory in a module using Shared stays on the heap, so the count drops when the heap version's +// would. +class Item { + name = ""; +} + +class Parent { + child: Shared | null = null; +} + +function main() { + const child = new Shared(new Item()); + let p1 = new Parent(); + const p2 = new Parent(); + p1.child = child; + p2.child = child; + p1 = new Parent(); + const n = Shared.count(child); + assert(n < 0 || n == 2, "p1's old Parent let go of the child"); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/thrown.ts b/tslang/test/tester/stack-promotion/thrown.ts new file mode 100644 index 000000000..6313c03a4 --- /dev/null +++ b/tslang/test/tester/stack-promotion/thrown.ts @@ -0,0 +1,21 @@ +// A thrown object outlives the function that threw it. +class Oops { + code: number; + constructor(code: number) { + this.code = code; + } +} + +function fail(code: number) { + const e = new Oops(code); + throw e; +} + +function main() { + try { + fail(7); + } catch (e) { + print("caught"); + } + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/to_any.ts b/tslang/test/tester/stack-promotion/to_any.ts new file mode 100644 index 000000000..0ff8d7b75 --- /dev/null +++ b/tslang/test/tester/stack-promotion/to_any.ts @@ -0,0 +1,18 @@ +// Converted to any: version 1 treats the conversion as an escape. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +function f(i: number) { + const p = new P(i); + const a: any = p; + return typeof a == "class" ? 1 : 0; +} + +function main() { + assert(f(4) == 1); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/to_interface.ts b/tslang/test/tester/stack-promotion/to_interface.ts new file mode 100644 index 000000000..cb872da58 --- /dev/null +++ b/tslang/test/tester/stack-promotion/to_interface.ts @@ -0,0 +1,22 @@ +// Seen through an interface: version 1 treats the conversion as an escape. +interface HasX { + x: number; +} + +class P implements HasX { + x: number; + constructor(x: number) { + this.x = x; + } +} + +function f(i: number) { + const p = new P(i); + const h: HasX = p; + return h.x; +} + +function main() { + assert(f(4) == 4); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/too_big.ts b/tslang/test/tester/stack-promotion/too_big.ts new file mode 100644 index 000000000..f78e9eaed --- /dev/null +++ b/tslang/test/tester/stack-promotion/too_big.ts @@ -0,0 +1,54 @@ +// 40 number fields and a vtable pointer: over the 256-byte default, so heap; under a 512 limit, +// stack. +class Big { + f0: number = 0; + f1: number = 1; + f2: number = 2; + f3: number = 3; + f4: number = 4; + f5: number = 5; + f6: number = 6; + f7: number = 7; + f8: number = 8; + f9: number = 9; + f10: number = 10; + f11: number = 11; + f12: number = 12; + f13: number = 13; + f14: number = 14; + f15: number = 15; + f16: number = 16; + f17: number = 17; + f18: number = 18; + f19: number = 19; + f20: number = 20; + f21: number = 21; + f22: number = 22; + f23: number = 23; + f24: number = 24; + f25: number = 25; + f26: number = 26; + f27: number = 27; + f28: number = 28; + f29: number = 29; + f30: number = 30; + f31: number = 31; + f32: number = 32; + f33: number = 33; + f34: number = 34; + f35: number = 35; + f36: number = 36; + f37: number = 37; + f38: number = 38; + f39: number = 39; +} + +function sum() { + const b = new Big(); + return b.f0 + b.f39; +} + +function main() { + assert(sum() == 39); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/try_region.ts b/tslang/test/tester/stack-promotion/try_region.ts new file mode 100644 index 000000000..916420b30 --- /dev/null +++ b/tslang/test/tester/stack-promotion/try_region.ts @@ -0,0 +1,25 @@ +// A function with a try region promotes nothing in version 1. +class Holder { + s: string; + constructor(s: string) { + this.s = s; + } +} + +function f(i: number) { + const p = new Holder("v" + i); + try { + if (i > 100) { + throw "big"; + } + } catch (e) { + return 0; + } + return p.s.length; +} + +function main() { + assert(f(1) == 2); + assert(f(1000) == 0); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/virtual_exported_subclass.ts b/tslang/test/tester/stack-promotion/virtual_exported_subclass.ts new file mode 100644 index 000000000..3722eb339 --- /dev/null +++ b/tslang/test/tester/stack-promotion/virtual_exported_subclass.ts @@ -0,0 +1,31 @@ +// A private base with an exported subclass: another module may extend the subclass and override +// visit to keep its argument, so a virtual call through the base cannot be trusted. +class Item { + v: number; + constructor(v: number) { + this.v = v; + } +} + +class Visitor { + visit(x: Item) { + return x.v; + } +} + +export class Base extends Visitor { + visit(x: Item) { + return x.v + 1; + } +} + +function inner(v: Visitor) { + const item = new Item(3); + return v.visit(item); +} + +function main() { + assert(inner(new Base()) == 4); + assert(inner(new Visitor()) == 3); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/virtual_one_keeps.ts b/tslang/test/tester/stack-promotion/virtual_one_keeps.ts new file mode 100644 index 000000000..16a0a26c4 --- /dev/null +++ b/tslang/test/tester/stack-promotion/virtual_one_keeps.ts @@ -0,0 +1,36 @@ +// The base method only reads its argument, the override keeps it: a virtual call may reach +// either, so the argument goes on the heap. +class Item { + v: number; + constructor(v: number) { + this.v = v; + } +} + +class Visitor { + visit(x: Item) { + return x.v; + } +} + +class Keeper extends Visitor { + visit(x: Item) { + store.push(x); + return 0; + } +} + +let store: Item[] = []; + +function run(v: Visitor, i: number) { + const item = new Item(i); + return v.visit(item); +} + +function main() { + assert(run(new Visitor(), 5) == 5); + run(new Keeper(), 6); + assert(store.length == 1); + assert(store[0].v == 6); + print("done."); +} diff --git a/tslang/test/tester/test-runner.cpp b/tslang/test/tester/test-runner.cpp index d99c257d6..83034b723 100644 --- a/tslang/test/tester/test-runner.cpp +++ b/tslang/test/tester/test-runner.cpp @@ -100,6 +100,8 @@ auto tslang_opt = std::string("--di --opt_level=0 --no-default-lib"); auto fastMath = false; auto memoryModel = std::string(""); +// `-no-stack-promotion`: build with every allocation on the heap, for comparing against the default +auto noStackPromotion = false; auto tslang_opt_ext = std::string(""); // `-x86`: build and run a 32-bit (i686) program. The target triple goes into tslang_opt rather @@ -155,7 +157,7 @@ void writeLibPathSettings(std::ostream &batFile) // would let whichever runner created it first decide the flags for everyone. std::string optVariantSuffix() { - return std::string(fastMath ? "fm" : "") + memoryModel + (x86 ? "x86" : ""); + return std::string(fastMath ? "fm" : "") + memoryModel + (x86 ? "x86" : "") + (noStackPromotion ? "nosp" : ""); } std::string jitBatName() @@ -783,6 +785,12 @@ void readParams(int argc, char **argv, std::vector &files) fastMath = true; tslang_opt_ext += " --fast-math"; } + else if (std::string(argv[index]) == "-no-stack-promotion") + { + noStackPromotion = true; + // into tslang_opt, like -mm=: every file of a multi-file test is built without it + tslang_opt += " --no-stack-promotion"; + } // `-mm=gc` is accepted as well as the three that change behaviour, and it is not a no-op: // it names the default explicitly, which is what anyone comparing the models types. // It gets its own cached script like the others - the suffix is what keeps two runners diff --git a/tslang/tslang/jitcache.cpp b/tslang/tslang/jitcache.cpp index 7672659f0..5ecc51644 100644 --- a/tslang/tslang/jitcache.cpp +++ b/tslang/tslang/jitcache.cpp @@ -59,6 +59,9 @@ extern cl::opt sizeLevel; extern cl::opt mainFuncName; extern cl::opt verifyOwnership; extern cl::opt ownSkipInference; +extern cl::opt noStackPromotion; +extern cl::opt stackPromotionMaxObject; +extern cl::opt stackPromotionMaxFrame; extern cl::opt jitCacheDir; std::unique_ptr createMLIRContext(); @@ -365,6 +368,9 @@ class JitUnitBuilder << " fast-math=" << compileOptions.enableFastMath << " gctors-as-method=" << compileOptions.appendGCtorsToMethod << " embed-declarations=" << compileOptions.embedExportDeclarations << " verify-ownership=" << verifyOwnership.getValue() << " own-skip-inference=" << ownSkipInference.getValue() + << " no-stack-promotion=" << noStackPromotion.getValue() + << " stack-promotion-max-object=" << stackPromotionMaxObject.getValue() + << " stack-promotion-max-frame=" << stackPromotionMaxFrame.getValue() << "\n"; os << "export=" << llvm::join(compileOptions.exportFilters, ",") << "\n"; os << "default-lib=" << compileOptions.defaultDeclarationTSFile << "\n"; diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index 3a8c1f8fb..fbf66ff65 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -79,6 +79,10 @@ extern cl::opt sizeLevel; extern cl::opt disableWarnings; extern cl::opt verifyOwnership; extern cl::opt ownSkipInference; +extern cl::opt noStackPromotion; +extern cl::opt stackPromotionMaxObject; +extern cl::opt stackPromotionMaxFrame; +extern cl::opt stackPromotionReport; int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir::OwningOpRef &module, CompileOptions &compileOptions) { @@ -150,6 +154,17 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); } + // Under every model, after own's inference: what it decides about a stack object's retains + // is already settled, and the optimiser has not yet inlined anything + if (!noStackPromotion) + { + mlir::typescript::StackPromotionLimits limits; + limits.maxObjectBytes = stackPromotionMaxObject; + limits.maxFrameBytes = stackPromotionMaxFrame; + limits.report = stackPromotionReport; + pm.addPass(mlir::typescript::createStackPromotionPass(compileOptions, limits)); + } + #ifdef ENABLE_OPT_PASSES if (enableOpt) { diff --git a/tslang/tslang/tslang.cpp b/tslang/tslang/tslang.cpp index 8cde6dc9f..257d9b49e 100644 --- a/tslang/tslang/tslang.cpp +++ b/tslang/tslang/tslang.cpp @@ -137,6 +137,10 @@ cl::opt memoryModelOpt("mm", cl::desc("Memory management of co cl::init(MemoryModelGC), cl::cat(TypeScriptCompilerCategory)); cl::opt disableWarnings("nowarn", cl::desc("Disable Warnings"), cl::cat(TypeScriptCompilerCategory)); cl::opt verifyOwnership("verify-ownership", cl::desc("Check that every slot taking a reference gives it back on every path out of the function, unwind paths included"), cl::cat(TypeScriptCompilerCategory)); +cl::opt noStackPromotion("no-stack-promotion", cl::desc("Keep every allocation on the heap; by default one that provably never outlives its function goes on the stack"), cl::cat(TypeScriptCompilerCategory)); +cl::opt stackPromotionMaxObject("stack-promotion-max-object", cl::desc("The most bytes one object may take on the stack"), cl::init(256), cl::cat(TypeScriptCompilerCategory)); +cl::opt stackPromotionMaxFrame("stack-promotion-max-frame", cl::desc("The most bytes one function may put on the stack through promotion"), cl::init(4096), cl::cat(TypeScriptCompilerCategory)); +cl::opt stackPromotionReport("stack-promotion-report", cl::desc("Report, for every allocation, whether it went on the stack and if not why"), cl::cat(TypeScriptCompilerCategory)); cl::opt ownSkipInference("own-skip-inference", cl::Hidden, cl::desc("Under -mm=own, skip ownership inference so the lowering backstop can be tested"), cl::cat(TypeScriptCompilerDebugCategory)); cl::opt generateDebugInfo("di", cl::desc("Generate Debug Infomation"), cl::cat(TypeScriptCompilerCategory)); cl::opt lldbDebugInfo("lldb", cl::desc("Debug Infomation for LLDB"), cl::cat(TypeScriptCompilerCategory));