Skip to content

-mm=own, phase 0: single ownership inferred at compile time, no counting - #399

Merged
ASDAlexander77 merged 14 commits into
mainfrom
own-phase-0
Sep 29, 2026
Merged

ASDAlexander77 merged 14 commits into
mainfrom
own-phase-0

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

What this adds

-mm=own is a fourth memory model beside gc, rc and none. Each heap block has exactly one owner, inferred at compile time, and there is no reference counting at run time. If a program's ownership can't be proven, it is a compile error at the source location. The design is in docs/superpowers/specs/2026-09-24-own-memory-model-design.md, and phase-0 results are in §11 of that file. The plan is in docs/superpowers/plans/2026-09-28-own-phase-0.md.

Phase 0 (this PR):

  • Model and flag. MemoryModelOwn, the -mm=own flag, the test-runner variant, and tracksOwnership() next to isRefCounted(). All 21 isRefCounted() sites were audited; MLIRGen emits exactly what it emits under rc.
  • Lowering under own. A release loads the block header and checks for immortal, then destroys; nothing is counted. No __tslang_inc_ref or __tslang_dec_ref is ever referenced. Type descriptors and interface tags carry no retain routine. Any retain that reaches LLVM lowering is an error, never silently erased.
  • OwnershipInferencePass, at the affine level, runs under own only. It counts the uses that take a value: an owning declaration, a store, an array insertion, a return, or any use not on a short borrow list. It also counts the temporary when rc releases it.
    • A fresh value may have at most one owner.
    • That owner must be in the block that made the value, and nothing may use the value after it. This one check covers loops, branches and use after move.
    • When the rule holds, rc's retains for that value are erased. Anything else is an error: aliases, unknown producers, delete, closures, and interface or any boxing.
  • Verifier order. Under own, --verify-ownership runs before inference, so it checks rc's pairing on the IR as MLIRGen produced it.

Measured

test/tester/tools/measure.ps1 builds each program ahead of time at -O3 and reads its peak working set:

Program rc none own
1M fresh strings 4.2 MB 80.9 MB 4.2 MB
1M instances from a factory 4.2 MB 50.2 MB 4.2 MB

Tests

  • Programs that must compile and run: 6, each under JIT and ahead of time. Each is also checked for no counting routines in its LLVM output.
  • Programs that must be rejected: 13, each with its expected error text. They include the review's double-free reproductions, for example a push inside a loop, push-then-let, two lets of one new, and delete.
  • Teeth:
    • With every retain kept, all positive checks fail at the lowering backstop.
    • With the owner count loosened to two, a two-owner program compiles and segfaults under own, while rc runs it correctly. That program is now a negative test.
  • Suites:
    • Full Release suite on Windows: 3096/3096.
    • Debug: every own test passes. 47 Debug-only failures also fail on main: LLVM asserts in the throw-f32, cast-to-f32, throw-bool-bigint and linkname tests; hangs in two async JIT tests (00async-await, 00async-gc-threading); and output mismatches.
    • Linux was not run.

Also in this PR

Known limits (input to phase 1)

These are rejected in phase 0:

  • closures of any kind;
  • interface and any boxing;
  • delete;
  • owned locals declared inside a try body;
  • callees that keep a parameter;
  • a move out of the block that made the value.

The review also found latent over-releases in rc itself, reachable through consumption: push inside a loop, two lets of one new, and use of a folded const after an inner let. They are not fixed here.

🤖 Generated with Claude Code

ASDAlexander77 and others added 14 commits September 29, 2026 01:03
A fourth memory model beside gc, rc and none: ownership inferred at compile
time over the ownership ops MLIRGen already emits, moves and borrows instead
of counts, and a compile error where neither can be proven.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six amendments in a new section 10: a lowering error does not fail the
compile today (prerequisite for every model); the phase-0 gate is a pass,
not the lowering; the default library is refused under own; the header
word keeps its store on native targets; expected-error tests reuse the
call-arity ctest pattern; and almost every retain is a birth reference,
so a gate rejecting every retain compiles only heap-free programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 0 accepts a fresh value's single acquisition - rc's count provably
never above one - as a move, and reports every other retain. Seven tasks:
the lowering-error prerequisite (PR #398), the model and flag, the
isRefCounted() audit site by site, destroy-shaped lowering with a retain
backstop, the inference pass, positive/negative/no-counting tests, and a
rebuilt measuring script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every positive and negative program was dumped under -mm=rc on #397 and the
plan now states what each produces: make() returns +1 with no retain on
either side; a throw out of a try-less function leaks its local in rc too;
the alias test needs `let` (a folded const emits no retain); the unknown
producer is a declared function (.slice needs the default library). Also
the verified call-op names, the --di-only variable name, number-typed
accumulators, and the isHandOver move spelled out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sixteen of the 21 sites write something a release will read, or emit the ownership ops the inference pass judges, and become tracksOwnership(). Three retain lowerings and the captured-parameter retain are Task 4's; a cell's birth retain stays rc only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tain is an error

Under -mm=own emitIfLastReference tests the header against
HEAP_BLOCK_IMMORTAL instead of decrementing it, so a release is one load and
a compare. A retain that reaches LLVM lowering is an error, never erased; a
captured variable holding a heap value is an error (closures are phase 5);
and a type descriptor carries no retain routine, since retainViaDescriptor
is reached only from retain routines. emitIncRef's other callers
(emitRetainCell, buildCaptureBoxRoutine(retaining), buildRetainBody) are all
retain paths. --own-skip-inference (hidden) lets the backstop be tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s a move; every other retain is an error

Under -mm=own, at the affine level before the verifier: a heap value made by
an allocating op (or marked __owned_result) whose births plus retains total
one has exactly one owner, so its retains are erased and the release rc
already emits becomes the owner's destroy. Any other retain, a RetainCell,
a RetainSlot on a hoisted (initializer-less) variable, and a slot released
without ever being owned are reported. isHandOver moves to OwnershipOps.h,
shared with the ownership verifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six programs run under JIT and ahead of time with -mm=own, each checked to
emit neither __tslang_inc_ref nor __tslang_dec_ref; five are rejected with
the expected error. A literal acquired once is now a birth take too:
`let s = "abc"` and `v: number[] = []` were rejected, and a cast constant is
either the immortal global (its release skips it) or a copy one owner holds.

The "released but never owned" rule is removed: the ownership verifier's
stricter form of it is green over the corpus, so no program reaches it, and
isHandOver goes back into the verifier.

Teeth: with every retain kept, all 15 positive checks fail at the backstop.
With the count check loosened to two, own_err_two_owners.ts compiles and
segfaults under own (rc runs it: 500000, done.) - the count is what stands
between a second owner and a double free. Debug tree: every own test passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AOT measuring script, rebuilt: it links the way the test runner does
(read from the generated compile.bat) and reads the peak working set from
the process handle after exit - sampling while it runs read 0.0 MB for
programs that finish in milliseconds. Spec section 11 records the results:
own holds flat exactly where rc does (4.2 MB for 1M strings, none 80.9 MB),
the teeth, and phase 0's known limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ug build too

The array-to-string cast hits llvm_unreachable (CastLogicHelper.h:856) in a Debug build before it reports anything, so the message test failed there. An array literal lowering cannot build as number[] | null reports its error the same way in Release and Debug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit af4b45c into main Sep 29, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the own-phase-0 branch September 29, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant