Skip to content

Commit bc33db7

Browse files
rc: a getter read is settled like a call (#408)
* rc: a getter read is settled like a call A getter retains what it returns, as every function does (§9.24), but OwnedReturnConsumptionPass only looked at ts.CallIndirect. A getter read is one of the accessor ops (ts.Accessor, ThisAccessor, ThisIndirect*, BoundIndirect*) until the affine lowering turns it into a call, so its +1 was never taken over or given back: `h.cc.x` leaked the C, and `let b = h.cc` retained a second time. own_getter_borrow read 12.6 MB under rc; it now reads 4.8 MB, as gc's 6.5 and own's 4.8 do. The accessor ops are classified as calls are - the getter named outright, every method of that member name for a virtual slot, the vtables for an interface slot, or the whole-module answer - and then marked, consumed by their receiver, or released at the end of the block. A getter read with no live use is left alone: an assignment through an accessor builds a read before rebuilding the op as a setter, and a release would keep that read, a call the program never made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Spec 15.3: rc's getter-temporary leak is fixed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Spec 15.7: the getter-temporary leak is no longer a known limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Spec 15.7: the <B>anyValue segfault was fixed by #407 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a563884 commit bc33db7

4 files changed

Lines changed: 237 additions & 9 deletions

File tree

‎tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -873,12 +873,15 @@ AOT, `measure.ps1`, at `-O3` and `-O1` (identical within 1%), in MB:
873873
| program | gc | rc | none | own |
874874
| --- | --- | --- | --- | --- |
875875
| `own_param_kept` | 6.5 | 4.8 | 1657.7 | 4.8 |
876-
| `own_getter_borrow` | 6.5 | 12.6 | 1653.3 | 4.8 |
876+
| `own_getter_borrow` | 6.5 | 4.8 (was 12.6) | 1653.3 | 4.8 |
877877
| `own_any_box` | 6.5 | 4.8 | 558.9 | 4.8 |
878878
| `own_call_no_drops` | 6.5 | 4.8 | 1100.7 | 4.8 |
879879

880-
rc climbs in `own_getter_borrow`: it never releases a getter's result used as a temporary
881-
(`h.cc.x`: the `ts.CallInternal` result has no `ts.Release`). Own has no reference to give back.
880+
rc climbed in `own_getter_borrow`: it never released a getter's result used as a temporary
881+
(`h.cc.x`). The getter retains its result like any function, but `OwnedReturnConsumptionPass`
882+
only settled `ts.CallIndirect`, and a getter read is an accessor op until the affine lowering.
883+
The pass now classifies accessor reads the way it classifies calls (`rc_getter_temporary.ts`).
884+
Own has no reference to give back.
882885
§14.5's `const a: any = new C(i)` loop reads 4.6 MB under all four models, at both levels. `none`
883886
does not grow, so LLVM removes the allocation and that program shows nothing about reclamation.
884887

@@ -939,11 +942,6 @@ of 3226.
939942
exported, so the virtual call to `area` has candidates this module cannot see, and it may drop
940943
(`own_err_exported_virtual_call` is the same shape). The error does not yet say why: the note
941944
on lost facts is attached to escapes and second references, not to drops.
942-
- rc: a getter's result used as a temporary is never released (§15.3).
943-
- Every model: `<B>anyValue` segfaults when `B` implements an interface.
944-
`mlirGenInstanceOfOpaque` calls vtable slot 0 as `..instanceOf`, but such a class keeps the
945-
interface's vtable there (`B..vtbl = {I, .instanceOf, ...}`). Fixing it changes the vtable
946-
layout, so it is left for its own PR.
947945
- From §14.6: a read reached through an interface, borrow chains through assigned borrowers,
948946
moves after the last use of every borrower, and a temporary taken by a `let` and consumed
949947
elsewhere.

‎tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "mlir/Pass/Pass.h"
2+
#include "mlir/Interfaces/SideEffectInterfaces.h"
23

34
#include "TypeScript/TypeScriptDialect.h"
45
#include "TypeScript/TypeScriptOps.h"
@@ -10,6 +11,7 @@
1011
#include "llvm/ADT/DenseMap.h"
1112
#include "llvm/ADT/DenseSet.h"
1213
#include "llvm/ADT/SmallVector.h"
14+
#include "llvm/ADT/TypeSwitch.h"
1315
#include "llvm/Support/Debug.h"
1416

1517
#define DEBUG_TYPE "pass"
@@ -183,6 +185,49 @@ class OwnedReturnConsumptionPass
183185
callOp->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext()));
184186
});
185187

188+
// A getter read is a call as well - `h.cc` becomes `H.get_cc(h)` in the affine lowering,
189+
// and the getter retains what it returns like any other function - so its result is
190+
// settled the same way. Left out, `h.cc.x` kept the getter's reference and nobody gave
191+
// it back: every temporary read through a getter leaked.
192+
module.walk([&](mlir::Operation *op) {
193+
if (!isGetterRead(op) || op->hasAttr(OWNED_RESULT_ATTR_NAME))
194+
{
195+
return;
196+
}
197+
198+
auto result = op->getResult(0);
199+
if (!mth.ownsHeapMemory(op->getLoc(), result.getType()))
200+
{
201+
return;
202+
}
203+
204+
// Assigning through an accessor builds a getter read first and a setter after it
205+
// (MLIRGen's assignment rebuilds the op with the value to set); the read is left with
206+
// no use, for the canonicalizer to delete. A release would be a use, and would keep a
207+
// call the program never made.
208+
if (!hasLiveUse(result))
209+
{
210+
return;
211+
}
212+
213+
if (anyUnclassifiedOwningReturn &&
214+
!getterReturnsOwned(op, returnsOwned, methodsByMemberName, vtableSlots))
215+
{
216+
return;
217+
}
218+
219+
auto retains = findReceiverRetains(result);
220+
if (!retains.empty())
221+
{
222+
op->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext()));
223+
op->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(&getContext()));
224+
toErase.append(retains.begin(), retains.end());
225+
return;
226+
}
227+
228+
op->setAttr(OWNED_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext()));
229+
});
230+
186231
for (auto *op : toErase)
187232
{
188233
op->erase();
@@ -698,7 +743,14 @@ class OwnedReturnConsumptionPass
698743
return false;
699744
}
700745

701-
auto identifier = virtualRefOp.getIdentifier();
746+
return everyOverrideReturnsOwned(virtualRefOp.getIdentifier(), returnsOwned, methodsByMemberName);
747+
}
748+
749+
// Does every method that can be in the slot `identifier` was written against return owned?
750+
static bool everyOverrideReturnsOwned(mlir::StringRef identifier,
751+
const llvm::DenseSet<mlir::StringRef> &returnsOwned,
752+
const llvm::StringMap<llvm::SmallVector<mlir::StringRef>> &methodsByMemberName)
753+
{
702754
auto dot = identifier.rfind('.');
703755
if (dot == mlir::StringRef::npos || dot + 1 >= identifier.size())
704756
{
@@ -755,6 +807,15 @@ class OwnedReturnConsumptionPass
755807
return false;
756808
}
757809

810+
return interfaceMemberReturnsOwned(interfaceRefOp, returnsOwned, vtableSlots);
811+
}
812+
813+
// Does every implementation in the vtables of the interface `interfaceRefOp` reads return
814+
// owned?
815+
static bool interfaceMemberReturnsOwned(mlir_ts::InterfaceSymbolRefOp interfaceRefOp,
816+
const llvm::DenseSet<mlir::StringRef> &returnsOwned,
817+
const llvm::StringMap<llvm::DenseMap<int64_t, mlir::StringRef>> &vtableSlots)
818+
{
758819
auto interfaceType = dyn_cast<mlir_ts::InterfaceType>(interfaceRefOp.getInterfaceVal().getType());
759820
if (!interfaceType)
760821
{
@@ -786,6 +847,88 @@ class OwnedReturnConsumptionPass
786847
return sawCandidate;
787848
}
788849

850+
// `h.cc`, `h[i]` through a `get` accessor: the ops that call a getter and have its result.
851+
static bool isGetterRead(mlir::Operation *op)
852+
{
853+
return op->getNumResults() == 1 &&
854+
mlir::isa<mlir_ts::AccessorOp, mlir_ts::ThisAccessorOp, mlir_ts::ThisIndirectAccessorOp,
855+
mlir_ts::ThisIndexAccessorOp, mlir_ts::ThisIndirectIndexAccessorOp,
856+
mlir_ts::BoundIndirectAccessorOp, mlir_ts::BoundIndirectIndexAccessorOp>(op);
857+
}
858+
859+
// Is anything going to read this value? A use that is itself dead - a `ts.Cast` of it that
860+
// nothing reads - is not one: the canonicalizer deletes the pair.
861+
static bool hasLiveUse(mlir::Value value)
862+
{
863+
for (auto *user : value.getUsers())
864+
{
865+
if (!mlir::isOpTriviallyDead(user))
866+
{
867+
return true;
868+
}
869+
}
870+
871+
return false;
872+
}
873+
874+
// The getter a getter read calls, by the same rules as calleeNameOf and the two
875+
// candidate-set questions: named outright, a virtual slot (every method of that member
876+
// name), or an interface slot (what the vtables put there). A getter given as anything
877+
// else - `ts.Undef` when there is only a setter - is unclassified.
878+
static bool getterReturnsOwned(mlir::Operation *op, const llvm::DenseSet<mlir::StringRef> &returnsOwned,
879+
const llvm::StringMap<llvm::SmallVector<mlir::StringRef>> &methodsByMemberName,
880+
const llvm::StringMap<llvm::DenseMap<int64_t, mlir::StringRef>> &vtableSlots)
881+
{
882+
auto named = [&](mlir::FlatSymbolRefAttr getter) {
883+
return getter && returnsOwned.contains(getter.getValue());
884+
};
885+
886+
auto throughValue = [&](mlir::Value getter) {
887+
auto *definingOp = getter.getDefiningOp();
888+
if (!definingOp)
889+
{
890+
return false;
891+
}
892+
893+
if (auto symbolRefOp = mlir::dyn_cast<mlir_ts::SymbolRefOp>(definingOp))
894+
{
895+
return returnsOwned.contains(symbolRefOp.getIdentifier());
896+
}
897+
898+
if (auto thisSymbolRefOp = mlir::dyn_cast<mlir_ts::ThisSymbolRefOp>(definingOp))
899+
{
900+
return returnsOwned.contains(thisSymbolRefOp.getIdentifier());
901+
}
902+
903+
if (auto virtualSymbolRefOp = mlir::dyn_cast<mlir_ts::VirtualSymbolRefOp>(definingOp))
904+
{
905+
return everyOverrideReturnsOwned(virtualSymbolRefOp.getIdentifier(), returnsOwned,
906+
methodsByMemberName);
907+
}
908+
909+
if (auto thisVirtualSymbolRefOp = mlir::dyn_cast<mlir_ts::ThisVirtualSymbolRefOp>(definingOp))
910+
{
911+
return everyOverrideReturnsOwned(thisVirtualSymbolRefOp.getIdentifier(), returnsOwned,
912+
methodsByMemberName);
913+
}
914+
915+
if (auto interfaceRefOp = mlir::dyn_cast<mlir_ts::InterfaceSymbolRefOp>(definingOp))
916+
{
917+
return interfaceMemberReturnsOwned(interfaceRefOp, returnsOwned, vtableSlots);
918+
}
919+
920+
return false;
921+
};
922+
923+
return llvm::TypeSwitch<mlir::Operation *, bool>(op)
924+
.Case<mlir_ts::AccessorOp, mlir_ts::ThisAccessorOp, mlir_ts::ThisIndexAccessorOp>(
925+
[&](auto accessorOp) { return named(accessorOp.getGetAccessorAttr()); })
926+
.Case<mlir_ts::ThisIndirectAccessorOp, mlir_ts::ThisIndirectIndexAccessorOp,
927+
mlir_ts::BoundIndirectAccessorOp, mlir_ts::BoundIndirectIndexAccessorOp>(
928+
[&](auto accessorOp) { return throughValue(accessorOp.getGetAccessor()); })
929+
.Default([](mlir::Operation *) { return false; });
930+
}
931+
789932
// Is `vtableName` a vtable for `interfaceName`? Both shapes spell the interface as a whole
790933
// dot-separated component: `Sphere.Thing..vtbl` for a class that implements it,
791934
// `Thing.19585545..vtbl` for an object literal that satisfies it. A class's own vtable,

‎tslang/test/tester/CMakeLists.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,6 +2402,11 @@ set_tests_properties(test-own-inference-erases-birth-takes
24022402
PROPERTIES PASS_REGULAR_EXPRESSION "ts.Release"
24032403
FAIL_REGULAR_EXPRESSION "ts\\.Retain|error|Stack dump")
24042404

2405+
# A getter read is settled like a call: its temporary released, its receiver taking it over.
2406+
foreach(getter_mm rc gc none)
2407+
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")
2408+
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")
2409+
endforeach()
24052410
# Under rc, a call through an interface carries the result its implementation retained: no
24062411
# `ts.CallIndirect` returning a C through an interface slot is left without `__owned_result`
24072412
# (`__owned_result_named` alone only says it initialises a `const`).
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// -mm=rc: a getter hands back a reference like any other function, so a getter read is settled
2+
// like a call - a temporary is released at the end of its block, a receiver takes it over. Before,
3+
// `h.cc.x` kept the getter's reference and nothing gave it back. Every shape of getter read is
4+
// here, each read after a `churn()` that would reuse a block freed too early: a class getter
5+
// returning a field and a fresh object, an override, a static getter, `super`'s, an interface
6+
// property implemented by a getter, one taken by a `let`, passed to a call, and assigned through.
7+
class C {
8+
v: number[] = [];
9+
constructor(public x: number) {}
10+
}
11+
12+
function churn() {
13+
const junk: C[] = [];
14+
for (let i = 0; i < 64; i++) {
15+
const c = new C(999);
16+
c.v.push(99);
17+
junk.push(c);
18+
}
19+
}
20+
21+
interface HasC {
22+
get cc(): C;
23+
}
24+
25+
class H implements HasC {
26+
c: C = new C(1);
27+
get cc() {
28+
return this.c;
29+
}
30+
31+
set cc(v: C) {
32+
this.c = v;
33+
}
34+
35+
get fresh() {
36+
return new C(2);
37+
}
38+
39+
static s: C = new C(3);
40+
static get sc() {
41+
return H.s;
42+
}
43+
}
44+
45+
class K extends H {
46+
get cc() {
47+
return super.cc;
48+
}
49+
50+
get fresh() {
51+
return new C(4);
52+
}
53+
}
54+
55+
function xOf(c: C) {
56+
return c.x;
57+
}
58+
59+
function main() {
60+
let t: number = 0;
61+
for (let i = 0; i < 1000; i++) {
62+
const h = new H();
63+
const k: H = new K();
64+
const f: HasC = h;
65+
66+
t += h.cc.x + h.fresh.x + k.cc.x + k.fresh.x + H.sc.x + f.cc.x;
67+
churn();
68+
69+
let b = h.cc;
70+
const g = k.fresh;
71+
churn();
72+
t += b.x + g.x + xOf(h.cc) + xOf(k.fresh);
73+
74+
h.cc = new C(5);
75+
h.cc.x = h.cc.x + 1;
76+
churn();
77+
t += h.cc.x + h.cc.v.length + b.x;
78+
}
79+
80+
assert(t == 29000);
81+
print("done.");
82+
}

0 commit comments

Comments
 (0)