diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index e9ba5c466..134ed3f27 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -873,12 +873,15 @@ AOT, `measure.ps1`, at `-O3` and `-O1` (identical within 1%), in MB: | program | gc | rc | none | own | | --- | --- | --- | --- | --- | | `own_param_kept` | 6.5 | 4.8 | 1657.7 | 4.8 | -| `own_getter_borrow` | 6.5 | 12.6 | 1653.3 | 4.8 | +| `own_getter_borrow` | 6.5 | 4.8 (was 12.6) | 1653.3 | 4.8 | | `own_any_box` | 6.5 | 4.8 | 558.9 | 4.8 | | `own_call_no_drops` | 6.5 | 4.8 | 1100.7 | 4.8 | -rc climbs in `own_getter_borrow`: it never releases a getter's result used as a temporary -(`h.cc.x`: the `ts.CallInternal` result has no `ts.Release`). Own has no reference to give back. +rc climbed in `own_getter_borrow`: it never released a getter's result used as a temporary +(`h.cc.x`). The getter retains its result like any function, but `OwnedReturnConsumptionPass` +only settled `ts.CallIndirect`, and a getter read is an accessor op until the affine lowering. +The pass now classifies accessor reads the way it classifies calls (`rc_getter_temporary.ts`). +Own has no reference to give back. §14.5's `const a: any = new C(i)` loop reads 4.6 MB under all four models, at both levels. `none` does not grow, so LLVM removes the allocation and that program shows nothing about reclamation. @@ -939,11 +942,6 @@ of 3226. exported, so the virtual call to `area` has candidates this module cannot see, and it may drop (`own_err_exported_virtual_call` is the same shape). The error does not yet say why: the note on lost facts is attached to escapes and second references, not to drops. -- rc: a getter's result used as a temporary is never released (§15.3). -- Every model: `anyValue` segfaults when `B` implements an interface. - `mlirGenInstanceOfOpaque` calls vtable slot 0 as `..instanceOf`, but such a class keeps the - interface's vtable there (`B..vtbl = {I, .instanceOf, ...}`). Fixing it changes the vtable - layout, so it is left for its own PR. - From §14.6: a read reached through an interface, borrow chains through assigned borrowers, moves after the last use of every borrower, and a temporary taken by a `let` and consumed elsewhere. diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index f408a39f3..aebd26023 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -1,4 +1,5 @@ #include "mlir/Pass/Pass.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" #include "TypeScript/TypeScriptDialect.h" #include "TypeScript/TypeScriptOps.h" @@ -10,6 +11,7 @@ #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/DenseSet.h" #include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/TypeSwitch.h" #include "llvm/Support/Debug.h" #define DEBUG_TYPE "pass" @@ -183,6 +185,49 @@ class OwnedReturnConsumptionPass callOp->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); }); + // A getter read is a call as well - `h.cc` becomes `H.get_cc(h)` in the affine lowering, + // and the getter retains what it returns like any other function - so its result is + // settled the same way. Left out, `h.cc.x` kept the getter's reference and nobody gave + // it back: every temporary read through a getter leaked. + module.walk([&](mlir::Operation *op) { + if (!isGetterRead(op) || op->hasAttr(OWNED_RESULT_ATTR_NAME)) + { + return; + } + + auto result = op->getResult(0); + if (!mth.ownsHeapMemory(op->getLoc(), result.getType())) + { + return; + } + + // Assigning through an accessor builds a getter read first and a setter after it + // (MLIRGen's assignment rebuilds the op with the value to set); the read is left with + // no use, for the canonicalizer to delete. A release would be a use, and would keep a + // call the program never made. + if (!hasLiveUse(result)) + { + return; + } + + if (anyUnclassifiedOwningReturn && + !getterReturnsOwned(op, returnsOwned, methodsByMemberName, vtableSlots)) + { + return; + } + + auto retains = findReceiverRetains(result); + if (!retains.empty()) + { + op->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + op->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + toErase.append(retains.begin(), retains.end()); + return; + } + + op->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + }); + for (auto *op : toErase) { op->erase(); @@ -698,7 +743,14 @@ class OwnedReturnConsumptionPass return false; } - auto identifier = virtualRefOp.getIdentifier(); + return everyOverrideReturnsOwned(virtualRefOp.getIdentifier(), returnsOwned, methodsByMemberName); + } + + // Does every method that can be in the slot `identifier` was written against return owned? + static bool everyOverrideReturnsOwned(mlir::StringRef identifier, + const llvm::DenseSet &returnsOwned, + const llvm::StringMap> &methodsByMemberName) + { auto dot = identifier.rfind('.'); if (dot == mlir::StringRef::npos || dot + 1 >= identifier.size()) { @@ -755,6 +807,15 @@ class OwnedReturnConsumptionPass return false; } + return interfaceMemberReturnsOwned(interfaceRefOp, returnsOwned, vtableSlots); + } + + // Does every implementation in the vtables of the interface `interfaceRefOp` reads return + // owned? + static bool interfaceMemberReturnsOwned(mlir_ts::InterfaceSymbolRefOp interfaceRefOp, + const llvm::DenseSet &returnsOwned, + const llvm::StringMap> &vtableSlots) + { auto interfaceType = dyn_cast(interfaceRefOp.getInterfaceVal().getType()); if (!interfaceType) { @@ -786,6 +847,88 @@ class OwnedReturnConsumptionPass return sawCandidate; } + // `h.cc`, `h[i]` through a `get` accessor: the ops that call a getter and have its result. + static bool isGetterRead(mlir::Operation *op) + { + return op->getNumResults() == 1 && + mlir::isa(op); + } + + // Is anything going to read this value? A use that is itself dead - a `ts.Cast` of it that + // nothing reads - is not one: the canonicalizer deletes the pair. + static bool hasLiveUse(mlir::Value value) + { + for (auto *user : value.getUsers()) + { + if (!mlir::isOpTriviallyDead(user)) + { + return true; + } + } + + return false; + } + + // The getter a getter read calls, by the same rules as calleeNameOf and the two + // candidate-set questions: named outright, a virtual slot (every method of that member + // name), or an interface slot (what the vtables put there). A getter given as anything + // else - `ts.Undef` when there is only a setter - is unclassified. + static bool getterReturnsOwned(mlir::Operation *op, const llvm::DenseSet &returnsOwned, + const llvm::StringMap> &methodsByMemberName, + const llvm::StringMap> &vtableSlots) + { + auto named = [&](mlir::FlatSymbolRefAttr getter) { + return getter && returnsOwned.contains(getter.getValue()); + }; + + auto throughValue = [&](mlir::Value getter) { + auto *definingOp = getter.getDefiningOp(); + if (!definingOp) + { + return false; + } + + if (auto symbolRefOp = mlir::dyn_cast(definingOp)) + { + return returnsOwned.contains(symbolRefOp.getIdentifier()); + } + + if (auto thisSymbolRefOp = mlir::dyn_cast(definingOp)) + { + return returnsOwned.contains(thisSymbolRefOp.getIdentifier()); + } + + if (auto virtualSymbolRefOp = mlir::dyn_cast(definingOp)) + { + return everyOverrideReturnsOwned(virtualSymbolRefOp.getIdentifier(), returnsOwned, + methodsByMemberName); + } + + if (auto thisVirtualSymbolRefOp = mlir::dyn_cast(definingOp)) + { + return everyOverrideReturnsOwned(thisVirtualSymbolRefOp.getIdentifier(), returnsOwned, + methodsByMemberName); + } + + if (auto interfaceRefOp = mlir::dyn_cast(definingOp)) + { + return interfaceMemberReturnsOwned(interfaceRefOp, returnsOwned, vtableSlots); + } + + return false; + }; + + return llvm::TypeSwitch(op) + .Case( + [&](auto accessorOp) { return named(accessorOp.getGetAccessorAttr()); }) + .Case( + [&](auto accessorOp) { return throughValue(accessorOp.getGetAccessor()); }) + .Default([](mlir::Operation *) { return false; }); + } + // Is `vtableName` a vtable for `interfaceName`? Both shapes spell the interface as a whole // dot-separated component: `Sphere.Thing..vtbl` for a class that implements it, // `Thing.19585545..vtbl` for an object literal that satisfies it. A class's own vtable, diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index cfd7974a5..116c8c87e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2402,6 +2402,11 @@ set_tests_properties(test-own-inference-erases-birth-takes PROPERTIES PASS_REGULAR_EXPRESSION "ts.Release" FAIL_REGULAR_EXPRESSION "ts\\.Retain|error|Stack dump") +# A getter read is settled like a call: its temporary released, its receiver taking it over. +foreach(getter_mm rc gc none) + tslang_add_test(NAME test-jit-${getter_mm}-getter-temporary COMMAND test-runner -jit -mm=${getter_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/rc_getter_temporary.ts") + tslang_add_test(NAME test-compile-${getter_mm}-getter-temporary COMMAND test-runner -mm=${getter_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/rc_getter_temporary.ts") +endforeach() # Under rc, a call through an interface carries the result its implementation retained: no # `ts.CallIndirect` returning a C through an interface slot is left without `__owned_result` # (`__owned_result_named` alone only says it initialises a `const`). diff --git a/tslang/test/tester/own/rc_getter_temporary.ts b/tslang/test/tester/own/rc_getter_temporary.ts new file mode 100644 index 000000000..ae658bff6 --- /dev/null +++ b/tslang/test/tester/own/rc_getter_temporary.ts @@ -0,0 +1,82 @@ +// -mm=rc: a getter hands back a reference like any other function, so a getter read is settled +// like a call - a temporary is released at the end of its block, a receiver takes it over. Before, +// `h.cc.x` kept the getter's reference and nothing gave it back. Every shape of getter read is +// here, each read after a `churn()` that would reuse a block freed too early: a class getter +// returning a field and a fresh object, an override, a static getter, `super`'s, an interface +// property implemented by a getter, one taken by a `let`, passed to a call, and assigned through. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + junk.push(c); + } +} + +interface HasC { + get cc(): C; +} + +class H implements HasC { + c: C = new C(1); + get cc() { + return this.c; + } + + set cc(v: C) { + this.c = v; + } + + get fresh() { + return new C(2); + } + + static s: C = new C(3); + static get sc() { + return H.s; + } +} + +class K extends H { + get cc() { + return super.cc; + } + + get fresh() { + return new C(4); + } +} + +function xOf(c: C) { + return c.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 1000; i++) { + const h = new H(); + const k: H = new K(); + const f: HasC = h; + + t += h.cc.x + h.fresh.x + k.cc.x + k.fresh.x + H.sc.x + f.cc.x; + churn(); + + let b = h.cc; + const g = k.fresh; + churn(); + t += b.x + g.x + xOf(h.cc) + xOf(k.fresh); + + h.cc = new C(5); + h.cc.x = h.cc.x + 1; + churn(); + t += h.cc.x + h.cc.v.length + b.x; + } + + assert(t == 29000); + print("done."); +}