diff --git a/docs/memory-models.md b/docs/memory-models.md index f3ef84846..acfe7bbed 100644 --- a/docs/memory-models.md +++ b/docs/memory-models.md @@ -129,8 +129,10 @@ 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. +- made in a function that is `async`, a generator, or recursive, or - under `-mm=rc` and + `-mm=own` - one that has a `try`; +- still in use when the next iteration of its loop makes a new one. A use on the way out of the + loop - a `return` or a `break` out of a search - is not. 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. 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 index bd1e778db..468b7bfa3 100644 --- a/tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md +++ b/tslang/docs/superpowers/specs/2026-10-18-stack-promotion-design.md @@ -144,7 +144,9 @@ Strings are not in the first version (they are produced by runtime/DefaultLib ca 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. +declared outside the loop, for instance) is an escape for this purpose. A use outside the loop +from which the site cannot run again - a `return` or a `break` out of a search - sees the last +iteration's object and is not. ## 6. Per memory model diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index 8ec8ea599..4e18be368 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -232,10 +232,13 @@ class OwnershipRoutineLogic } // 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). + // a promoted object's fields when its frame is done with it (ts.ReleaseContents). Each field + // given back is nulled, so that the block never holds a reference it no longer owns: a second + // ts.ReleaseContents, or a release of a field, finds nothing to give back. At a return the + // stores are dead, and LLVM drops them. void emitReleaseContents(mlir::Type storageType, mlir::Value payloadPtr) { - releaseFields(storageType, payloadPtr); + releaseFields(storageType, payloadPtr, /*nullReleased=*/true); } // Drops one reference held by the value in `slotPtr`, whose TypeScript type is `type`. @@ -730,8 +733,9 @@ class OwnershipRoutineLogic FlatSymbolRefAttr::get(rewriter.getContext(), routineName), ValueRange{slotPtr}); } - // Releases each field a record-shaped value owns. `basePtr` addresses the record itself. - void releaseFields(mlir::Type recordType, mlir::Value basePtr) + // Releases each field a record-shaped value owns. `basePtr` addresses the record itself. With + // `nullReleased`, each field released is then zeroed. + void releaseFields(mlir::Type recordType, mlir::Value basePtr, bool nullReleased = false) { TypeHelper th(rewriter); TypeConverterHelper tch(typeConverter); @@ -784,6 +788,11 @@ class OwnershipRoutineLogic rewriter.create(loc, TypeRange{}, FlatSymbolRefAttr::get(rewriter.getContext(), routineName), ValueRange{fieldPtr}); + if (nullReleased) + { + auto llvmFieldType = tch.convertType(fieldType); + rewriter.create(loc, rewriter.create(loc, llvmFieldType), fieldPtr); + } } } diff --git a/tslang/lib/TypeScript/StackEscapeFacts.h b/tslang/lib/TypeScript/StackEscapeFacts.h index a0f22f467..5be3b92cd 100644 --- a/tslang/lib/TypeScript/StackEscapeFacts.h +++ b/tslang/lib/TypeScript/StackEscapeFacts.h @@ -104,47 +104,149 @@ class StackEscapeFacts // method they may reach. bool isRecursive(mlir_ts::FuncOp funcOp) { - auto it = recursive.find(funcOp); - if (it != recursive.end()) + if (!recursive) { - return it->second; + collectRecursive(); } - llvm::SmallPtrSet seen; - llvm::SmallVector work{funcOp}; - auto found = false; - while (!work.empty() && !found) + return recursive->contains(funcOp); + } + + // Whether argument `argIndex` of a call through `candidates` may outlive the call: empty when + // every candidate's parameter stays inside it. + llvm::StringRef argumentEscapes(llvm::ArrayRef candidates, unsigned argIndex) + { + for (auto candidate : candidates) { - auto current = work.pop_back_val(); - for (auto callee : calleesOfFunction(current)) + 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 ""; + } + + private: + mlir::ModuleOp module; + std::optional> recursive; + + // Every function on a cycle of the call graph, in one pass (Tarjan's strongly connected + // components, iterative: a module's call chains may be deeper than the native stack): a + // function is recursive when its component holds another function, or it calls itself. + void collectRecursive() + { + recursive.emplace(); + + llvm::SmallVector funcs; + llvm::DenseMap numberOf; + module.walk([&](mlir_ts::FuncOp funcOp) { + numberOf[funcOp] = funcs.size(); + funcs.push_back(funcOp); + }); + + // the edges, each callee once; a callee with no definition here is on no cycle of ours + llvm::SmallVector> edges(funcs.size()); + llvm::SmallVector callsItself(funcs.size(), false); + for (auto [index, funcOp] : llvm::enumerate(funcs)) + { + llvm::SmallPtrSet seen; + for (auto callee : calleesOfFunction(funcOp)) { auto calleeOp = module.lookupSymbol(callee); - if (!calleeOp) + if (!calleeOp || !seen.insert(calleeOp).second) { continue; } - if (calleeOp == funcOp) + auto calleeIndex = numberOf.lookup(calleeOp); + callsItself[index] = callsItself[index] || calleeIndex == index; + edges[index].push_back(calleeIndex); + } + } + + constexpr unsigned unvisited = ~0u; + llvm::SmallVector order(funcs.size(), unvisited); + llvm::SmallVector lowLink(funcs.size(), 0); + llvm::SmallVector onStack(funcs.size(), false); + llvm::SmallVector stack; + // the depth-first path: a function and the index of the next of its edges to follow + llvm::SmallVector> path; + unsigned nextOrder = 0; + + for (auto root : llvm::seq(0, funcs.size())) + { + if (order[root] != unvisited) + { + continue; + } + + auto visit = [&](unsigned node) { + order[node] = lowLink[node] = nextOrder++; + stack.push_back(node); + onStack[node] = true; + path.push_back({node, 0}); + }; + + visit(root); + while (!path.empty()) + { + auto &[node, edgeIndex] = path.back(); + if (edgeIndex < edges[node].size()) { - found = true; - break; + auto callee = edges[node][edgeIndex++]; + if (order[callee] == unvisited) + { + visit(callee); + } + else if (onStack[callee]) + { + lowLink[node] = std::min(lowLink[node], order[callee]); + } + + continue; + } + + // every edge followed: `node` closes a component when nothing below reached higher + auto done = node; + path.pop_back(); + if (!path.empty()) + { + auto parent = path.back().first; + lowLink[parent] = std::min(lowLink[parent], lowLink[done]); + } + + if (lowLink[done] != order[done]) + { + continue; } - if (seen.insert(calleeOp).second) + llvm::SmallVector component; + unsigned member; + do + { + member = stack.pop_back_val(); + onStack[member] = false; + component.push_back(member); + } while (member != done); + + if (component.size() > 1 || callsItself[done]) { - work.push_back(calleeOp); + for (auto index : component) + { + recursive->insert(funcs[index]); + } } } } - - 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. @@ -268,26 +370,6 @@ class StackEscapeFacts 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) { diff --git a/tslang/lib/TypeScript/StackPromotionPass.cpp b/tslang/lib/TypeScript/StackPromotionPass.cpp index b0b9a79c6..bacca5d45 100644 --- a/tslang/lib/TypeScript/StackPromotionPass.cpp +++ b/tslang/lib/TypeScript/StackPromotionPass.cpp @@ -56,6 +56,10 @@ class StackPromotionPass : public mlir::PassWrapperhasAttr(CAPTURE_BOX_ATTR_NAME)) + { + return "captured-box"; + } } continue; @@ -274,7 +293,30 @@ class StackPromotionPass : public mlir::PassWrapper(user); boundOp && use.getOperandNumber() == 0) { - reason = facts->escapes(boundOp.getResult(), walk); + auto symbolRefOp = boundOp->getOperand(1).getDefiningOp(); + if (!symbolRefOp) + { + return "unclassified"; + } + + reason = facts->argumentEscapes({symbolRefOp.getIdentifierAttr()}, 0); + if (reason.empty()) + { + reason = facts->escapes(boundOp.getResult(), walk); + } + + if (!reason.empty()) + { + return reason; + } + + continue; + } + + // the closure called by name (`const g = () => ...; g()`): the box is its `this` + if (auto callOp = dyn_cast(user)) + { + reason = facts->argumentEscapes({callOp.getCalleeAttr()}, use.getOperandNumber()); if (!reason.empty()) { return reason; @@ -286,6 +328,8 @@ class StackPromotionPass : public mlir::PassWrappergetParentOfType(); @@ -398,14 +444,44 @@ class StackPromotionPass : public mlir::PassWrappercontains(ancestor->getBlock()); }; - for (auto alias : walk.aliases) - { - for (auto *user : alias.getUsers()) + // whether the site may run again after `op`, along every edge of the CFG - unwind edges too + auto *siteBlock = siteOp->getBlock(); + auto siteReachableFrom = [&](mlir::Operation *op) { + auto *ancestor = body.findAncestorOpInRegion(*op); + if (!ancestor) { - if (!inLoop(user)) + return true; + } + + llvm::SmallPtrSet seen; + llvm::SmallVector work{ancestor->getBlock()}; + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (block == siteBlock) { return true; } + + for (auto *successor : block->getSuccessors()) + { + if (seen.insert(successor).second) + { + work.push_back(successor); + } + } + } + + return false; + }; + + auto carriedUse = [&](mlir::Operation *user) { return !inLoop(user) && siteReachableFrom(user); }; + + for (auto alias : walk.aliases) + { + if (llvm::any_of(alias.getUsers(), carriedUse)) + { + return true; } } @@ -416,12 +492,9 @@ class StackPromotionPass : public mlir::PassWrappergetUsers()) + if (llvm::any_of(slot->getUsers(), carriedUse)) { - if (!inLoop(user)) - { - return true; - } + return true; } } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 8d1909e90..5acd826e3 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -4149,7 +4149,8 @@ set(stack_promotion_run_tests class_call_same_as_new literal_boxed_local leaf_lo 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) + virtual_exported_subclass recursive_closure loop_exit_use closure_direct_call closure_nested recursive_mutual + try_catch_reads) # 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) @@ -4203,7 +4204,6 @@ set(stack_promotion_decision_tests "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" @@ -4215,7 +4215,9 @@ set(stack_promotion_decision_tests "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") + "recursive_closure|recursive_closure\.ts:17:15: remark: stack-promotion: heap: recursive" + "loop_exit_use|loop_exit_use\.ts:12:19: remark: stack-promotion: stack.*loop_exit_use\.ts:23:19: remark: stack-promotion: stack" + "recursive_mutual|recursive_mutual\.ts:11:15: remark: stack-promotion: heap: recursive.*recursive_mutual\.ts:16:15: remark: stack-promotion: heap: recursive.*recursive_mutual\.ts:21:15: remark: stack-promotion: stack") foreach(stack_mm gc rc own none) foreach(stack_case ${stack_promotion_decision_tests}) string(FIND "${stack_case}" "|" stack_sep) @@ -4253,13 +4255,26 @@ add_test(NAME test-stack-decision-max-object 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. +# Decisions that depend on the model, `||`. A capture box: on the +# stack where nothing counts, and only while its closures stay in the function - called by name or +# passed to a reader, nested in another closure. A function with a try region: refused only where +# an unwind path would have to give back what the object owns. 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") + "none|closure_escapes|4:12: remark: stack-promotion: heap: returned" + "gc|closure_direct_call|4:15: remark: stack-promotion: stack" + "none|closure_direct_call|4:15: remark: stack-promotion: stack" + "gc|closure_nested|11:11: remark: stack-promotion: stack" + "none|closure_nested|11:11: remark: stack-promotion: stack" + "gc|try_region|11:15: remark: stack-promotion: stack" + "none|try_region|11:15: remark: stack-promotion: stack" + "rc|try_region|11:15: remark: stack-promotion: heap: try-region" + "own|try_region|11:15: remark: stack-promotion: heap: try-region" + "gc|try_catch_reads|19:19: remark: stack-promotion: stack" + "none|try_catch_reads|19:19: remark: stack-promotion: stack") string(REPLACE "|" ";" stack_parts "${stack_case}") list(GET stack_parts 0 stack_mm) list(GET stack_parts 1 stack_name) @@ -4271,3 +4286,12 @@ foreach(stack_case "gc|closure_local|10:11: remark: stack-promotion: stack" PROPERTIES PASS_REGULAR_EXPRESSION "${stack_name}\.ts:${stack_expected}" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") endforeach() +# ts.ReleaseContents nulls each field it gives back: the release call is followed by a null store. +foreach(stack_mm rc own) + add_test(NAME test-stack-release-contents-nulls-${stack_mm} + COMMAND $ --emit=mlir-llvm --no-default-lib -mm=${stack_mm} + "${PROJECT_SOURCE_DIR}/test/tester/stack-promotion/fields_string.ts") + set_tests_properties(test-stack-release-contents-nulls-${stack_mm} + PROPERTIES PASS_REGULAR_EXPRESSION "llvm\\.call @tsrel_[0-9]+\\(%[0-9]+\\) : \\(!llvm\\.ptr\\) -> \\(\\)[\r\n]+ +%[0-9]+ = llvm\\.mlir\\.zero : !llvm\\.ptr[\r\n]+ +llvm\\.store" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() diff --git a/tslang/test/tester/stack-promotion/closure_direct_call.ts b/tslang/test/tester/stack-promotion/closure_direct_call.ts new file mode 100644 index 000000000..5630b2a5d --- /dev/null +++ b/tslang/test/tester/stack-promotion/closure_direct_call.ts @@ -0,0 +1,16 @@ +// A closure called by name, never passed anywhere: its capture box goes on the stack. +function total(n: number) { + let t: number = 0; + const g = (i: number) => { + t += i; + }; + for (let i = 0; i < n; i++) { + g(i); + } + return t; +} + +function main() { + assert(total(10) == 45); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/closure_nested.ts b/tslang/test/tester/stack-promotion/closure_nested.ts new file mode 100644 index 000000000..dac1173ee --- /dev/null +++ b/tslang/test/tester/stack-promotion/closure_nested.ts @@ -0,0 +1,22 @@ +// A closure nested in a closure captures the cells, not the outer box: the outer box stays on +// the stack while both closures stay in the function. +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) => { + apply((j: number) => { + t += j; + }, i); + }, n); + return t; +} + +function main() { + assert(total(5) == 10); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/loop_exit_use.ts b/tslang/test/tester/stack-promotion/loop_exit_use.ts new file mode 100644 index 000000000..b720ff5a8 --- /dev/null +++ b/tslang/test/tester/stack-promotion/loop_exit_use.ts @@ -0,0 +1,38 @@ +// A use on the way out of a loop - a return, a break out of a search - comes after the last +// iteration: the one stack slot is not reused after it, so the object goes on the stack. +class Holder { + s: string; + constructor(s: string) { + this.s = s; + } +} + +function findReturn(n: number) { + for (let i = 0; i < n; i++) { + const p = new Holder("v" + i); + if (i == 2) { + return p.s; + } + } + return ""; +} + +function findBreak(n: number) { + let found = ""; + for (let i = 0; i < n; i++) { + const p = new Holder("w" + i); + if (i == 3) { + found = p.s; + break; + } + } + return found; +} + +function main() { + assert(findReturn(10) == "v2"); + assert(findReturn(1) == ""); + assert(findBreak(10) == "w3"); + assert(findBreak(2) == ""); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/recursive_mutual.ts b/tslang/test/tester/stack-promotion/recursive_mutual.ts new file mode 100644 index 000000000..61ae39d2f --- /dev/null +++ b/tslang/test/tester/stack-promotion/recursive_mutual.ts @@ -0,0 +1,29 @@ +// Functions on a cycle of calls keep their objects on the heap; a function that only calls into +// the cycle is not on it. +class P { + x: number; + constructor(x: number) { + this.x = x; + } +} + +function isEven(n: number): boolean { + const p = new P(n); + return p.x == 0 ? true : isOdd(p.x - 1); +} + +function isOdd(n: number): boolean { + const q = new P(n); + return q.x == 0 ? false : isEven(q.x - 1); +} + +function check(n: number) { + const r = new P(n); + return isEven(r.x); +} + +function main() { + assert(check(10)); + assert(!check(7)); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/try_catch_reads.ts b/tslang/test/tester/stack-promotion/try_catch_reads.ts new file mode 100644 index 000000000..adce32d23 --- /dev/null +++ b/tslang/test/tester/stack-promotion/try_catch_reads.ts @@ -0,0 +1,34 @@ +// Under gc and none a stack object is read in a catch - a funclet of its own on Windows - and +// survives a throw out of a loop iteration. +class Holder { + s: string; + constructor(s: string) { + this.s = s; + } +} + +function check(i: number) { + if (i % 3 == 0) { + throw "bad"; + } +} + +function f(n: number) { + let total = 0; + for (let i = 0; i < n; i++) { + const p = new Holder("v" + i); + try { + check(i); + total += p.s.length; + } catch (e) { + total += p.s.length * 100; + continue; + } + } + return total; +} + +function main() { + assert(f(7) == 3 * 200 + 4 * 2); + print("done."); +} diff --git a/tslang/test/tester/stack-promotion/try_region.ts b/tslang/test/tester/stack-promotion/try_region.ts index 916420b30..5236d8294 100644 --- a/tslang/test/tester/stack-promotion/try_region.ts +++ b/tslang/test/tester/stack-promotion/try_region.ts @@ -1,4 +1,5 @@ -// A function with a try region promotes nothing in version 1. +// A function with a try region promotes nothing under rc and own in version 1, where the unwind +// path would have to give back what the object owns; under gc and none it promotes. class Holder { s: string; constructor(s: string) {