From 1689404912c37cefa2f5719c305cd97767624d2b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 12:07:26 -0700 Subject: [PATCH 1/2] Keep records, tuples and spread updates that own references in frame storage Frame storage for non-escaping records was limited to number, boolean and inline-union fields. Records with string, array, record or class fields, tuples, and same-shape spread updates (`{ ...state, count }`) were always heap-allocated, even when every use only projected them. Closed record and tuple shapes now qualify regardless of field types. The frame owns the reference fields: they start empty in entry-addressed slots and are registered before anything is stored, with the binding's lexical scope for locals and with the statement frame for call-argument temporaries, so an exception while the record is being built releases exactly what was stored. Interned string literal fields need no owner. A reassigned binding builds the new value apart, copies it over its slot and then releases the replaced fields. Result-slot entries cover producers returning such records, tuples and spreads of a binding, and store into the zeroed caller storage without releasing an old value. A spread of a binding is a projection for the lifetime analysis. Published shapes and switch-case bindings of reference-owning shapes keep heap storage. Co-Authored-By: Claude Opus 5.5 --- benchmarks/runtime/README.md | 1 + benchmarks/runtime/workloads.json | 7 + benchmarks/runtime/workloads/value-records.ts | 93 ++++++++++ .../src/backend/llvm/call-lifetimes.ts | 6 + packages/compiler/src/backend/llvm/emitter.ts | 74 +++++++- .../compiler/src/backend/llvm/expr-calls.ts | 5 +- .../src/backend/llvm/record-results.ts | 79 ++++++++- .../src/backend/llvm/stack-objects.test.ts | 61 +++++++ .../src/backend/llvm/stack-objects.ts | 158 +++++++++++++++-- .../5960-frame-records-own-references.ts.json | 6 + .../corpus/5961-frame-spread-updates.ts.json | 6 + .../tests/corpus/5962-frame-tuples.ts.json | 6 + .../5960-frame-records-own-references.ts | 164 ++++++++++++++++++ tests/corpus/5961-frame-spread-updates.ts | 96 ++++++++++ tests/corpus/5962-frame-tuples.ts | 57 ++++++ 15 files changed, 795 insertions(+), 24 deletions(-) create mode 100644 benchmarks/runtime/workloads/value-records.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-frame-records-own-references.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5961-frame-spread-updates.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5962-frame-tuples.ts.json create mode 100644 tests/corpus/5960-frame-records-own-references.ts create mode 100644 tests/corpus/5961-frame-spread-updates.ts create mode 100644 tests/corpus/5962-frame-tuples.ts diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index ed9ee0532b..9635ee1f7a 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -19,6 +19,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables | | `atomics-contended` | cpu | four worker threads updating `SharedArrayBuffer` counters with `Atomics`: one shared counter, one counter per worker, a `compareExchange` loop, and `Atomics.wait`/`notify` handoffs | | `value-objects` | cpu | small immutable records and class instances: vector helpers, method chains, particle updates | +| `value-records` | cpu | records that hold strings, spread state updates and destructured tuples: token helpers, an immutable reducer, tuple returns | | `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds | | `optional-fields` | cpu | records and class instances with `number \| undefined` and other scalar-union fields | | `record-boundaries` | cpu | records built from unchecked array reads passed to and returned from annotated functions | diff --git a/benchmarks/runtime/workloads.json b/benchmarks/runtime/workloads.json index c0d643a583..b95791a1d1 100644 --- a/benchmarks/runtime/workloads.json +++ b/benchmarks/runtime/workloads.json @@ -141,6 +141,13 @@ "2" ] }, + { + "name": "value-records", + "entry": "workloads/value-records.ts", + "args": [ + "1" + ] + }, { "name": "region-builds", "entry": "workloads/region-builds.ts", diff --git a/benchmarks/runtime/workloads/value-records.ts b/benchmarks/runtime/workloads/value-records.ts new file mode 100644 index 0000000000..922fb8e439 --- /dev/null +++ b/benchmarks/runtime/workloads/value-records.ts @@ -0,0 +1,93 @@ +// Records that hold references: a tokenizer that builds token records with +// string fields and passes them to helpers, an immutable reducer that +// updates its state with object spread, and tuple-returning helpers whose +// results are destructured at once. None of these temporaries outlives +// the statement or loop iteration that builds it. +interface Token { + kind: string; + text: string; + pos: number; +} +const KEYWORDS = ["let", "const", "if", "else", "for", "while", "return", "function"]; +function classify(text: string): string { + return KEYWORDS.includes(text) ? "keyword" : "ident"; +} +function token(kind: string, text: string, pos: number): Token { + return { kind, text, pos }; +} +function weight(t: Token): number { + return t.kind === "keyword" ? t.text.length * 3 : t.text.length + (t.pos & 7); +} +function tokenize(source: string): number { + let sum = 0; + let start = -1; + for (let i = 0; i <= source.length; i++) { + const c = i < source.length ? source.charCodeAt(i) : 32; + const word = (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c === 95; + if (word) { + if (start < 0) start = i; + continue; + } + if (start >= 0) { + const text = source.slice(start, i); + const t = token(classify(text), text, start); + sum += weight(t); + start = -1; + } + if (c !== 32 && c !== 10) { + const punct = { kind: "punct", text: source[i]!, pos: i }; + sum += weight(punct) + (punct.text === ";" ? 1 : 0); + } + } + return sum; +} + +interface State { + mode: string; + depth: number; + count: number; + last: string; +} +function step(s: State, text: string): State { + if (text === "{") return { ...s, depth: s.depth + 1, last: text }; + if (text === "}") return { ...s, depth: s.depth - 1, last: text }; + return s.mode === "code" ? { ...s, count: s.count + 1 } : { ...s, mode: "code", last: text }; +} +function reduce(texts: string[], rounds: number): number { + let total = 0; + for (let r = 0; r < rounds; r++) { + let state: State = { mode: "start", depth: 0, count: 0, last: "" }; + for (const text of texts) { + state = step(state, text); + if (state.depth > 3) state = { ...state, depth: 3 }; + } + total += state.count + state.depth + state.last.length + state.mode.length; + } + return total; +} + +function divmod(a: number, b: number): [number, number] { + return [Math.floor(a / b), a % b]; +} +function split(text: string, at: number): [string, string] { + return [text.slice(0, at), text.slice(at)]; +} +function tuples(n: number, texts: string[]): number { + let sum = 0; + for (let i = 0; i < n; i++) { + const [q, r] = divmod(i * 31, 17); + const [head, tail] = split(texts[i % texts.length]!, r % 3); + sum += q + r + head.length * 2 + tail.length; + } + return sum; +} + +const scaleArg = Number(process.argv[2] ?? "1"); +const line = "function step ( s , text ) { if ( text ) { return s ; } let x = s ; while ( x ) { x = x ; } }\n"; +const source = line.repeat(Math.floor(400 * scaleArg)); +const texts = source.split(" ").filter((t) => t.length > 0); +let lexed = 0; +for (let i = 0; i < 240; i++) lexed += tokenize(source); +const reduced = reduce(texts, Math.floor(600 * scaleArg)); +const pairs = tuples(Math.floor(2_000_000 * scaleArg), texts); +console.log("tokens", lexed, "reduce", reduced, "tuples", pairs); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 72acc625e6..5663047cdb 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -177,6 +177,12 @@ function collectUses( ) return true; break; + case "recordClone": + // A same-shape spread copies the source's fields (retaining the + // references) and keeps no reference to the source itself. + if (node.source.kind !== "varRef") break; + for (const field of node.overrides) expr(field.value); + return true; case "recordLit": if (node.type.kind !== "record") break; for (const field of node.fields) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 09bf4979c2..2acf4a35c9 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -88,7 +88,7 @@ import { type StringSplit, } from "./split-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; -import { RESULT_ENTRY_PREFIX, addRecordResultEntries } from "./record-results.js"; +import { RESULT_DST_ID, RESULT_ENTRY_PREFIX, addRecordResultEntries } from "./record-results.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { @@ -164,7 +164,9 @@ import { emitStackObject, findLocalStackObjects, findReassignedStackObjects, + stackObjectFields, stackObjectStruct, + type StackObjectField, type StackObjectSource, } from "./stack-objects.js"; import { @@ -574,6 +576,9 @@ export class LlEmitter { private reassignedStackObjects = new Set(); /** The binding slot of each reassigned stack-object local. */ private stackObjectSlots = new Map(); + /** Entry-block pointers to the reference fields of each frame object + * slot (stack-objects.ts stackObjectFields). */ + stackObjectFieldPtrs = new Map(); private localUnionStorageProofs = new Map(); private localUnionStorage = new Map(); integerArrayBindings = new Set(); @@ -4491,6 +4496,30 @@ export class LlEmitter { if (isRefCounted(type)) this.currentFrame().push({ name: slot, type, slot: true }); } + /** The innermost lexical scope releases a frame object's reference + * fields when it exits, as it releases its bindings. */ + ownScopeFields(fields: readonly StackObjectField[]): void { + const scope = this.scopes[this.scopes.length - 1]!; + for (const field of fields) scope.push({ slot: field.ptr, type: field.type }); + } + + /** Strike frame-owned slots (ownSlot) from their statement frames: their + * contents are moving to another owner. */ + disownSlots(slots: readonly { ptr: string }[]): void { + for (const { ptr } of slots) { + let found = false; + for (let i = this.frames.length - 1; i >= 0 && !found; i--) { + const idx = this.frames[i]!.findIndex((e) => e.slot === true && e.name === ptr); + if (idx >= 0) { + this.frames[i]!.splice(idx, 1); + found = true; + } + } + if (!found) + throw new InternalCompilerError(`llvm emitter bug: disowned slot ${ptr} not in a frame`); + } + } + /** Strike a refcounted temp from its frame: ownership is being moved. */ moveTemp(v: LlValue): void { if (!isRefCounted(v.type) || this.isImmortal(v.name)) return; @@ -6384,6 +6413,7 @@ export class LlEmitter { ? findReassignedStackObjects(this, fn, this.callLifetimes) : new Set(); this.stackObjectSlots = new Map(); + this.stackObjectFieldPtrs = new Map(); this.localUnionStorageProofs = findLocalUnionStorage(fn, this.callLifetimes, this.unionSources); this.localUnionStorage = new Map(); this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); @@ -7415,12 +7445,18 @@ export class LlEmitter { } const stackObject = this.localStackObjects.get(s.localId); if (stackObject) { - B.line(`store ptr ${emitStackObject(this, stackObject).name}, ptr ${b.slot}`); + const value = emitStackObject(this, stackObject, (fields) => this.ownScopeFields(fields)); + B.line(`store ptr ${value.name}, ptr ${b.slot}`); break; } if (s.init && this.reassignedStackObjects.has(s.localId)) { const slot = this.stackObjectSlot(s.localId, s.init.type); - emitStackObject(this, s.init as StackObjectSource, slot); + emitStackObject( + this, + s.init as StackObjectSource, + (fields) => this.ownScopeFields(fields), + slot, + ); B.line(`store ptr ${slot}, ptr ${b.slot}`); break; } @@ -7592,12 +7628,28 @@ export class LlEmitter { break; } if (this.reassignedStackObjects.has(s.localId)) { - // The new value may read the old one: build it apart, then copy. + // The new value may read the old one: build it apart (its + // reference fields owned by this statement until it completes), + // then copy it over the binding's slot. The binding takes over the + // new fields; the old ones are released after the slot no longer + // holds them. const struct = stackObjectStruct(s.value.type); - const value = emitStackObject(this, s.value as StackObjectSource); + let owned: readonly StackObjectField[] = []; + const value = emitStackObject(this, s.value as StackObjectSource, (fields) => { + owned = fields; + for (const field of fields) this.ownSlot(field.ptr, field.type); + }); + const target = this.stackObjectSlot(s.localId, s.value.type); + const old = stackObjectFields(this, s.value.type, target).map((field) => { + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${field.ptr}`); + return { name: t, type: field.type }; + }); const bits = B.tmp(); B.line(`${bits} = load ${struct}, ptr ${value.name}`); - B.line(`store ${struct} ${bits}, ptr ${this.stackObjectSlot(s.localId, s.value.type)}`); + B.line(`store ${struct} ${bits}, ptr ${target}`); + this.disownSlots(owned); + for (const field of old) this.releaseValue(field.name, field.type); break; } const localStorage = this.localUnionStorage.get(s.localId); @@ -7807,7 +7859,15 @@ export class LlEmitter { s.kind === "fieldSet" ? this.classFieldPtr(obj.name, s.className, s.field) : this.recordFieldPtr(obj.name, s.shapeId, s.field); - this.replaceField(ptr, type, inline, v); + // A result-slot entry fills zeroed caller storage, each field once. + if ( + s.kind === "recordSet" && + s.obj.kind === "varRef" && + s.obj.localId === RESULT_DST_ID && + this.currentFunctionName.startsWith(RESULT_ENTRY_PREFIX) + ) + this.initField(ptr, type, inline, v); + else this.replaceField(ptr, type, inline, v); if ( s.kind === "fieldSet" && s.field === "name" && diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 8c748cc931..d8f99140df 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -146,7 +146,10 @@ export function emitCallExpr( return emitScalarOptional(host, a); if (a.kind === "call" && host.scalarResultCall(a)) return emitCallExpr(host, a, false, true); - if (host.stackObjects && canStackObject(host, a)) return emitStackObject(host, a); + if (host.stackObjects && canStackObject(host, a)) + return emitStackObject(host, a, (fields) => { + for (const field of fields) host.ownSlot(field.ptr, field.type); + }); if (host.nullableFieldGet(a)) return { name: host.emitOwnedNullableStack(a).box, type: a.type }; // An inline union field read copies its tag and bits. diff --git a/packages/compiler/src/backend/llvm/record-results.ts b/packages/compiler/src/backend/llvm/record-results.ts index 41d76564e3..47a7983d2d 100644 --- a/packages/compiler/src/backend/llvm/record-results.ts +++ b/packages/compiler/src/backend/llvm/record-results.ts @@ -21,6 +21,16 @@ export function resultEntryName(name: string): string { return `${RESULT_ENTRY_PREFIX}${name}`; } +/** The local id of a result-slot entry's destination parameter. The + * caller passes zeroed frame storage, and on every path the entry stores + * each of its fields at most once, so a store into it is an + * initialization: there is no old value to release. */ +export const RESULT_DST_ID = "%into.dst"; + +function distinctNames(names: readonly string[]): boolean { + return new Set(names).size === names.length; +} + /** A plain record shape whose fields are numbers, booleans and inline * scalar unions (inline-unions.ts): its fields own nothing. */ export function scalarRecordShape( @@ -38,6 +48,15 @@ export function scalarRecordShape( ); } +/** A closed record or tuple shape: declared fields only. Its instance can + * live in caller storage; fields that own references are released by the + * frame that holds the storage (stack-objects.ts). Tuples are element + * records: destructuring and element reads project them, and every + * array-like use of one is a whole-value use that keeps it on the heap. */ +export function frameRecordShape(shape: IrRecordShape | undefined): boolean { + return shape !== undefined && !shape.indexValue; +} + /** A literal that stores every field of its shape, none of them through * the overflow map, and that sets no overflow-map flag. */ export function completeRecordLiteral( @@ -110,7 +129,7 @@ export function constructorInitializesFields(cls: IrClassDef, ctor: IrFunction): } /** Add a result-slot entry for every function whose returns all build a - * fresh scalar record or standalone scalar object, return another such + * fresh closed record or standalone scalar object, return another such * producer's result (forwarding the slot), or return a parameter of a * record shape no store can change (copied into the slot). The original * function stays as it is; the backend calls the entry only where the @@ -155,8 +174,12 @@ export function addRecordResultEntries(mod: IrModule): IrModule { }, }); const dynamicStores = dynamicFieldStores(mod); + // Copies are limited to scalar shapes: a copy of a reference-owning + // record would not carry the undeclared source keys a checked conversion + // may keep beside the original (record-sources.ts). const copyable = (type: IrType): boolean => type.kind === "record" && + scalarRecordShape(shapes.get(type.shapeId), inline) && mod.lib === undefined && !written.has(type.shapeId) && !dynamicStores.all && @@ -165,9 +188,11 @@ export function addRecordResultEntries(mod: IrModule): IrModule { const fillable = (value: IrExpr, params: ReadonlySet): boolean => { switch (value.kind) { case "recordLit": - return completeRecordLiteral( - value, - shapes.get((value.type as IrType & { kind: "record" }).shapeId), + return ( + completeRecordLiteral( + value, + shapes.get((value.type as IrType & { kind: "record" }).shapeId), + ) && distinctNames(value.fields.map((field) => field.name)) ); case "new": return stackClass(value.className); @@ -175,6 +200,15 @@ export function addRecordResultEntries(mod: IrModule): IrModule { return fillable(value.then, params) && fillable(value.else_, params); case "varRef": return params.has(value.localId) && copyable(value.type); + case "recordClone": + // A spread of a binding: the copies read it before any override + // evaluates, as the heap clone does. + return ( + value.source.kind === "varRef" && + value.type.kind === "record" && + frameRecordShape(shapes.get(value.type.shapeId)) && + distinctNames(value.overrides.map((override) => override.name)) + ); case "call": return producers.has(value.callee); default: @@ -210,6 +244,39 @@ export function addRecordResultEntries(mod: IrModule): IrModule { loc: value.loc, })); } + case "recordClone": { + const shapeId = (value.type as IrType & { kind: "record" }).shapeId; + const overridden = new Set(value.overrides.map((override) => override.name)); + const copies: IrStmt[] = shapes + .get(shapeId)! + .fields.filter((field) => !overridden.has(field.name)) + .map((field): IrStmt => ({ + kind: "recordSet", + obj: dst, + shapeId, + field: field.name, + value: { + kind: "recordGet", + obj: value.source, + shapeId, + field: field.name, + type: field.type, + loc: value.loc, + }, + loc: value.loc, + })); + return [ + ...copies, + ...value.overrides.map((override): IrStmt => ({ + kind: "recordSet", + obj: dst, + shapeId, + field: override.name, + value: override.value, + loc: override.value.loc, + })), + ]; + } case "call": return [ { @@ -327,7 +394,7 @@ export function addRecordResultEntries(mod: IrModule): IrModule { return false; const ret = fn.returnType; return ( - (ret.kind === "record" && scalarRecordShape(shapes.get(ret.shapeId), inline)) || + (ret.kind === "record" && frameRecordShape(shapes.get(ret.shapeId))) || (ret.kind === "object" && stackClass(ret.className)) ); }); @@ -360,7 +427,7 @@ export function addRecordResultEntries(mod: IrModule): IrModule { for (const fn of candidates) { if (!producers.has(fn.name)) continue; const ret = fn.returnType; - const dstId = "%into.dst"; + const dstId = RESULT_DST_ID; const dst: IrExpr = { kind: "varRef", localId: dstId, type: ret, loc: fn.loc }; const body = transformStmtList(fn.body, { expr: (e) => e, diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts index 845d6ae5ea..a413db333d 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.test.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -275,3 +275,64 @@ console.log(pick({ x: 1, y: 2 }, { x: 3, y: 4 }, true).y, forward({ x: 1, y: 1 } .sort(); expect(entries).toEqual([`${RESULT_ENTRY_PREFIX}forward`, `${RESULT_ENTRY_PREFIX}pick`]); }); + +const TOKENS = ` +interface Token { kind: string; text: string; pos: number } +function make(kind: string, text: string, pos: number): Token { return { kind, text, pos }; } +function width(t: Token): number { return t.text.length + t.kind.length; } +function divmod(a: number, b: number): [number, number] { return [Math.floor(a / b), a % b]; } +interface State { name: string; count: number } +function bump(s: State): State { return { ...s, count: s.count + 1 }; } +export function tokens(n: number, w: string): number { + const t = { kind: "id", text: w, pos: n }; + const u = make("num", w, n); + const [q, r] = divmod(n, 7); + return t.pos + width(u) + width({ kind: w, text: w, pos: 0 }) + q + r; +} +export function states(n: number, w: string): number { + let s: State = { name: w, count: 0 }; + for (let i = 0; i < n; i++) { + s = { ...s, count: s.count + 1 }; + s = bump(s); + } + return s.count + s.name.length; +} +console.log(tokens(3, "ab"), states(4, "cd")); +`; + +test("records and tuples that own references live in frame storage", async () => { + const ir = emitLlvmModule(await moduleFor(TOKENS)); + const tokens = body(ir, "tokens"); + expect(tokens).not.toMatch(/@sc_rnew_/); + expect(tokens).toMatch(/call void @sc_\w*into_make\(/); + expect(tokens).toMatch(/call void @sc_\w*into_divmod\(/); + // Reference fields start empty in entry-addressed slots, and the frame + // releases them; an interned literal field (`kind: "id"`) needs neither. + const fields = [...tokens.matchAll(/(%s\d+) = getelementptr inbounds %sc_rs_\w+, ptr %s\d+/g)]; + const owned = fields.filter(([, ptr]) => tokens.includes(`store ptr null, ptr ${ptr}\n`)); + expect(owned.length).toBe(5); + expect(fields.length).toBe(6); + for (const [, ptr] of owned) + expect(tokens).toMatch(new RegExp(`load ptr, ptr ${ptr}\\n\\s+call void @\\w+\\(`)); +}); + +test("spread updates copy into the binding's slot and release the replaced fields", async () => { + const mod = await moduleFor(TOKENS); + const entries = addRecordResultEntries(mod) + .functions.map((fn) => fn.name) + .filter((name) => name.startsWith(RESULT_ENTRY_PREFIX)); + expect(entries).toEqual( + expect.arrayContaining([ + `${RESULT_ENTRY_PREFIX}make`, + `${RESULT_ENTRY_PREFIX}divmod`, + `${RESULT_ENTRY_PREFIX}bump`, + ]), + ); + const states = body(emitLlvmModule(mod), "states"); + expect(states).not.toMatch(/@sc_rnew_|@sc_rclone_/); + expect(states).toMatch(/call void @sc_\w*into_bump\(/); + // The old name is loaded before the copy and released after it. + expect(states).toMatch( + /(%t\d+) = load ptr, ptr %s\d+\n\s+(%t\d+) = load %sc_rs_\w+, ptr %s\d+\n\s+store %sc_rs_\w+ \2, ptr %s\d+\n(?:.*\n)*?\s+call void @\w+\(ptr \1\)/, + ); +}); diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index c03c7fb442..e9f437f149 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -1,9 +1,16 @@ -import { VOID, typeEquals, type IrExpr, type IrFunction, type IrType } from "../../ir/ir.js"; +import { + VOID, + isRefCounted, + typeEquals, + type IrExpr, + type IrFunction, + type IrType, +} from "../../ir/ir.js"; import { completeRecordLiteral, constructorInitializesFields, + frameRecordShape, resultEntryName, - scalarRecordShape, scalarStandaloneClass, } from "./record-results.js"; import { everyStmtList } from "../../ir/traverse.js"; @@ -14,23 +21,45 @@ import type { CallLifetimes } from "./call-lifetimes.js"; import { emitCallExpr, emitConstruction } from "./expr-calls.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { emitRecordLitFields } from "./expr-primitives.js"; +import { llFieldType } from "./shapes.js"; + +export type StackObjectSource = IrExpr & { kind: "recordLit" | "recordClone" | "new" | "call" }; + +/** A field of a frame object that owns a reference: an entry-block pointer + * to its slot, and the field's type for the release. */ +export interface StackObjectField { + name: string; + ptr: string; + type: IrType; +} -export type StackObjectSource = IrExpr & { kind: "recordLit" | "new" | "call" }; +/** Records the owners of a frame object's reference fields before anything + * stores them: a lexical scope for bindings, a statement frame for + * temporaries. */ +export type StackObjectFieldOwner = (fields: readonly StackObjectField[]) => void; -/** A record literal, `new` or result-slot producer call whose object can - * live in the frame's own storage: a plain scalar record shape, or a +/** A record literal, spread copy, `new` or result-slot producer call whose + * object can live in the frame's own storage: a closed record shape, or a * standalone scalar class whose constructor never lets `this` escape * (record-results.ts). The caller proves that every use of the value only - * projects it. */ + * projects it. Record fields may own references; the frame releases them + * (stackObjectFields). Published shapes keep heap storage: their field + * guards treat the immortal header as a frozen object. */ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource { + if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true) + return false; // A producer with a result-slot entry builds its fresh result in the // caller's storage. if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee)); if (e.kind === "recordLit") { if (e.type.kind !== "record") return false; const shape = host.recordsById.get(e.type.shapeId); - return scalarRecordShape(shape, host.inlineUnions) && completeRecordLiteral(e, shape); + return frameRecordShape(shape) && completeRecordLiteral(e, shape); } + // A same-shape spread (`{ ...source, field: value }`) copies the source's + // fields; the frontend builds it only for plain declared-field records. + if (e.kind === "recordClone") + return e.type.kind === "record" && frameRecordShape(host.recordsById.get(e.type.shapeId)); if (e.kind !== "new") return false; const meta = host.classMeta.get(e.className); return ( @@ -45,6 +74,33 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO ); } +/** Whether a frame object of this type has fields that own references. */ +function ownsReferences(host: LlvmEmitterContext, type: IrType): boolean { + return ( + type.kind === "record" && + host + .recordShape(type.shapeId) + .fields.some((field) => isRefCounted(field.type) && !host.inlineUnions.record(field.type)) + ); +} + +/** Locals declared directly in a switch case. The cases share one scope + * whose exit runs whichever case was entered, so a scope-owned field would + * be released without its declaration having run. */ +function caseScopedLocals(fn: IrFunction): Set { + const ids = new Set(); + everyStmtList(fn.body, { + expr: () => true, + stmt: (stmt) => { + if (stmt.kind === "switch") + for (const c of stmt.cases) + for (const s of c.body) if (s.kind === "varDecl") ids.add(s.localId); + return true; + }, + }); + return ids; +} + /** Locals initialized once by a stack-eligible object and only projected * afterwards (CallLifetimes.locals): field reads, and arguments to * parameters that are themselves only projected. */ @@ -56,6 +112,7 @@ export function findLocalStackObjects( const result = new Map(); const safe = lifetimes.locals.get(fn.name); if (!safe?.size || fn.async || fn.generator) return result; + const caseScoped = caseScopedLocals(fn); everyStmtList(fn.body, { expr: () => true, stmt: (stmt) => { @@ -63,7 +120,8 @@ export function findLocalStackObjects( stmt.kind === "varDecl" && safe.has(stmt.localId) && stmt.init && - canStackObject(host, stmt.init) + canStackObject(host, stmt.init) && + !(caseScoped.has(stmt.localId) && ownsReferences(host, stmt.init.type)) ) result.set(stmt.localId, stmt.init); return true; @@ -112,7 +170,10 @@ export function findReassignedStackObjects( return true; }, }); - for (const id of result) if (!initialized.has(id)) result.delete(id); + const caseScoped = caseScopedLocals(fn); + for (const id of result) + if (!initialized.has(id) || (caseScoped.has(id) && ownsReferences(host, types.get(id)!))) + result.delete(id); return result; } @@ -122,13 +183,77 @@ export function stackObjectStruct(type: IrType): string { : `%${mangleClassStruct((type as IrType & { kind: "object" }).className)}`; } +/** The reference fields of the frame object in `slot`, addressed from the + * entry block so that every cleanup path can reach them. Inline union + * fields copy bits and own nothing; class objects in frame storage are + * scalar. */ +export function stackObjectFields( + host: LlvmEmitterContext, + type: IrType, + slot: string, +): readonly StackObjectField[] { + if (type.kind !== "record") return []; + const cached = host.stackObjectFieldPtrs.get(slot); + if (cached) return cached; + const struct = stackObjectStruct(type); + const fields: StackObjectField[] = []; + host.recordShape(type.shapeId).fields.forEach((field, index) => { + if (!isRefCounted(field.type) || host.inlineUnions.record(field.type)) return; + const ptr = host.B.slot(); + host.B.entryAllocas.push( + `${ptr} = getelementptr inbounds ${struct}, ptr ${slot}, i64 0, i32 ${index + 1}`, + ); + fields.push({ name: field.name, ptr, type: field.type }); + }); + host.stackObjectFieldPtrs.set(slot, fields); + return fields; +} + +/** Copy a same-shape spread's source fields into `slot`, retaining the + * references, then store the overrides in source order. The source is + * read once, before any override evaluates, as the heap clone helper + * reads it; an overridden field is never copied, so its slot stays empty + * until the override initializes it. */ +function emitRecordCloneFields( + host: LlvmEmitterContext, + e: IrExpr & { kind: "recordClone" }, + slot: string, +): void { + const B = host.B; + const shapeId = (e.type as IrType & { kind: "record" }).shapeId; + const overridden = new Set(e.overrides.map((override) => override.name)); + const source = host.emitReadReceiver(e.source); + for (const field of host.recordShape(shapeId).fields) { + if (overridden.has(field.name)) continue; + const from = host.recordFieldPtr(source.name, shapeId, field.name); + const to = host.recordFieldPtr(slot, shapeId, field.name); + const fieldTy = llFieldType(from.type, from.inline); + const v = B.tmp(); + B.line(`${v} = load ${fieldTy}, ptr ${from.ptr}`); + const stored = isRefCounted(from.type) && !from.inline ? host.retainValue(v, from.type) : v; + B.line(`store ${fieldTy} ${stored}, ptr ${to.ptr}`); + } + const initialized = new Set(); + for (const override of e.overrides) { + const v = host.emitFieldValue(override.value, host.recordFieldInline(shapeId, override.name)); + const { ptr, type, inline } = host.recordFieldPtr(slot, shapeId, override.name); + // The first store fills an empty slot; a repeated name replaces it. + if (initialized.has(override.name)) host.replaceField(ptr, type, inline, v); + else host.initField(ptr, type, inline, v); + initialized.add(override.name); + } +} + /** Build the object in an entry-block slot (or `into`, an existing one). * The immortal reference count makes every retain and release a no-op; * the proof that the value is only projected keeps the pointer from - * outliving the frame. */ + * outliving the frame. Reference fields start empty and are handed to + * `owner` before anything is stored, so an exception while the object is + * being built releases exactly the fields stored so far. */ export function emitStackObject( host: LlvmEmitterContext, e: StackObjectSource, + owner: StackObjectFieldOwner, into?: string, ): LlValue { const B = host.B; @@ -139,6 +264,18 @@ export function emitStackObject( slot = B.slot(); B.entryAllocas.push(`${slot} = alloca ${struct}, align 8`); } + // A literal's interned string fields are immortal and need no owner. A + // binding's own slot (`into`) keeps an owner for every field: later + // assignments copy arbitrary values into it. + const literals = + into === undefined && e.kind === "recordLit" + ? new Set(e.fields.filter((f) => f.value.kind === "strLit").map((f) => f.name)) + : new Set(); + const fields = stackObjectFields(host, type, slot).filter((f) => !literals.has(f.name)); + if (fields.length > 0) { + for (const field of fields) B.line(`store ptr null, ptr ${field.ptr}`); + owner(fields); + } // Literals store every field; constructors that store every field // before reading one need no zeroed storage either. if (type.kind === "object") { @@ -169,6 +306,7 @@ export function emitStackObject( host.markImmortal({ name: slot, type }), ); } else if (e.kind === "recordLit") emitRecordLitFields(host, e, slot); + else if (e.kind === "recordClone") emitRecordCloneFields(host, e, slot); else emitConstruction(host, e, slot, host.fnByName.get(`%${e.className}.constructor`)!); return { name: slot, type: e.type }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-frame-records-own-references.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-frame-records-own-references.ts.json new file mode 100644 index 0000000000..afe039025d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-frame-records-own-references.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5960-frame-records-own-references.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5961-frame-spread-updates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5961-frame-spread-updates.ts.json new file mode 100644 index 0000000000..255b1238e3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5961-frame-spread-updates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5961-frame-spread-updates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5962-frame-tuples.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5962-frame-tuples.ts.json new file mode 100644 index 0000000000..ab8d80fafb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5962-frame-tuples.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5962-frame-tuples.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5960-frame-records-own-references.ts b/tests/corpus/5960-frame-records-own-references.ts new file mode 100644 index 0000000000..894037ff70 --- /dev/null +++ b/tests/corpus/5960-frame-records-own-references.ts @@ -0,0 +1,164 @@ +// Records whose fields hold strings, arrays, other records and class +// instances, and that only flow into field reads, release exactly the +// references they hold: on normal scope exit, per loop iteration, on +// break/continue/return, and when a field expression or a producer +// throws while the record is half built. Records that are stored, +// returned, captured, compared by identity or printed stay observable +// exactly as in Node. +interface Token { + kind: string; + text: string; + pos: number; +} +interface Span { + label: string; + parts: string[]; + token: Token; +} +class Counter { + hits = 0; + name: string; + constructor(name: string) { + this.name = name; + } +} +interface Owned { + owner: Counter; + tag: string | undefined; + note?: string; +} + +const words = ["alpha", "beta", "gamma", "delta"]; +function word(i: number): string { + return words[i % words.length]! + i; +} +function makeToken(kind: string, text: string, pos: number): Token { + return { kind, text, pos }; +} +function pick(flag: boolean, a: string, b: string): Token { + return flag ? { kind: "a", text: a, pos: 1 } : makeToken("b", b, 2); +} +function width(t: Token): number { + return t.text.length + t.kind.length; +} +function spanWidth(s: Span): number { + return s.label.length + s.parts.length + width(s.token); +} +function failing(text: string, n: number): string { + if (n % 4 === 3) throw new Error(`bad ${text}`); + return text.toUpperCase(); +} +function strict(kind: string, n: number): Token { + return { kind, text: failing(kind + n, n), pos: n }; +} +const kept: Token[] = []; +function keep(t: Token): number { + kept.push(t); + return t.pos; +} + +let total = 0; +let log = ""; +for (let i = 0; i < 12; i++) { + const w = word(i); + const t = { kind: "id", text: w, pos: i }; + total += t.pos + t.text.length; + const u = makeToken("num", w + "!", i); + if (u.kind === "num") total += width(u); + total += width({ kind: w, text: w + w, pos: 0 }); + const s: Span = { label: "s" + i, parts: [w, t.text, u.text], token: makeToken("x", w, i) }; + total += spanWidth(s) + s.parts.join("").length; + const p = pick(i % 2 === 0, w, t.kind); + log += p.text[0]; + if (i % 5 === 4) total += keep(t); + try { + const bad = { kind: "k", text: failing(w, i), pos: i }; + log += bad.text.length; + } catch (e) { + log += "!"; + } + try { + const half = strict(w, i); + log += half.text.length; + } catch (e) { + log += "?"; + } + if (i === 6) continue; + const late = { kind: w, text: String(i), pos: -i }; + if (late.text === "9") break; + total += late.kind.length; +} +console.log(total, log, kept.map((t) => t.text).join(",")); + +function find(names: string[], target: string): number { + for (let i = 0; i < names.length; i++) { + const entry = { name: names[i]!, index: i, upper: names[i]!.toUpperCase() }; + if (entry.name === target) return entry.index * 10 + entry.upper.length; + } + return -1; +} +console.log(find(words, "gamma"), find(words, "omega")); + +function owners(n: number): string { + const counters = [new Counter("a"), new Counter("b")]; + let out = ""; + for (let i = 0; i < n; i++) { + const o: Owned = { owner: counters[i % 2]!, tag: i % 3 === 0 ? undefined : "t" + i }; + o.owner.hits++; + out += o.tag ?? "-"; + const optional: Owned = { owner: counters[0]!, tag: "x", note: i % 2 ? "odd" : undefined }; + out += optional.note === undefined ? "." : optional.note[0]; + } + return `${out} ${counters[0]!.hits} ${counters[1]!.hits}`; +} +console.log(owners(7)); + +function tally(lines: string[]): string { + let out = ""; + for (const line of lines) { + const parsed = { head: line.slice(0, 1), rest: line.slice(1), size: line.length }; + switch (parsed.head) { + case "a": { + const scoped = { head: parsed.rest, rest: parsed.head, size: 1 }; + out += scoped.head; + break; + } + case "b": + out += parsed.rest.toUpperCase(); + break; + default: + out += parsed.size; + } + } + return out; +} +console.log(tally(["abc", "bcd", "cde", "a"])); + +function guarded(n: number): string { + let out = ""; + for (let i = 0; i < n; i++) { + try { + const r = { name: word(i), list: [i, i + 1] }; + if (i % 2 === 0) throw new Error(r.name); + out += r.list.length; + } catch (e) { + out += (e as Error).message.length; + } finally { + const f = { name: "f" + i, list: [i] }; + out += f.name.length; + } + } + return out; +} +console.log(guarded(5)); + +// Identity, storage, capture and printing keep heap records. +const a = { kind: "same", text: "x", pos: 1 }; +const b = { kind: "same", text: "x", pos: 1 }; +const readers: (() => string)[] = []; +for (let i = 0; i < 3; i++) { + const r = { kind: "r", text: word(i), pos: i }; + readers.push(() => r.text); +} +console.log(a === b, readers.map((read) => read()).join(" ")); +console.log({ kind: "shown", text: word(2), pos: 3 }, JSON.stringify(makeToken("j", "s", 1))); diff --git a/tests/corpus/5961-frame-spread-updates.ts b/tests/corpus/5961-frame-spread-updates.ts new file mode 100644 index 0000000000..ef2f79cd9a --- /dev/null +++ b/tests/corpus/5961-frame-spread-updates.ts @@ -0,0 +1,96 @@ +// Same-shape spread updates (`{ ...state, field: value }`) of records that +// hold strings and arrays: each update reads the whole old value before +// any override evaluates, releases the replaced fields exactly once, and +// keeps the previous value when an override throws. Aliases, identity, +// storage and printing keep the heap object Node would have. +interface State { + name: string; + count: number; + items: string[]; + last: string | undefined; +} +interface Num { + count: number; + total: number; +} +function bump(s: State, item: string): State { + return { ...s, count: s.count + 1, last: item }; +} +function rename(s: State, name: string): State { + return name.length > 3 ? { ...s, name } : s.count > 2 ? { ...s, name: name + "!" } : bump(s, name); +} +function check(n: number): number { + if (n === 3) throw new Error("three"); + return n * 2; +} +function describe(s: State): string { + return `${s.name}:${s.count}:${s.items.length}:${s.last ?? "-"}`; +} + +function run(n: number): string { + let state: State = { name: "s", count: 0, items: ["a"], last: undefined }; + let out = ""; + for (let i = 0; i < n; i++) { + state = { ...state, count: state.count + 1 }; + if (i % 2 === 0) state = { ...state, last: "i" + i, name: state.name + i }; + state = bump(state, "b" + i); + state = rename(state, i % 3 === 0 ? "long" + i : "x"); + try { + state = { ...state, count: check(i) }; + } catch (e) { + out += "!"; + } + out += describe(state) + " "; + } + return out + state.items.join(""); +} +console.log(run(6)); + +function numeric(n: number): number { + let state: Num = { count: 0, total: 0 }; + for (let i = 0; i < n; i++) { + state = { ...state, count: state.count + 1 }; + if (i % 3 === 0) state = { ...state, total: state.total + i }; + } + return state.count * 1000 + state.total; +} +console.log(numeric(10)); + +// Overrides see the old value and run after the copy. +function order(): string { + let s: State = { name: "n", count: 1, items: [], last: "l" }; + let log = ""; + s = { ...s, name: (log += s.name + s.count, s.name + "2"), count: s.count + 10 }; + s = { ...s, last: s.name }; + return `${log} ${describe(s)}`; +} +console.log(order()); + +// Spread of a temporary and of a producer result. +function fresh(i: number): State { + return { name: "f" + i, count: i, items: [String(i)], last: undefined }; +} +function temporaries(n: number): string { + let out = ""; + for (let i = 0; i < n; i++) { + const s = { ...fresh(i), count: 100 + i }; + const t = { ...s, items: [s.name, s.name] }; + out += describe(s) + "/" + describe(t) + " "; + } + return out; +} +console.log(temporaries(3)); + +// Aliases and identity are observable: these stay heap objects. +let current: State = { name: "c", count: 0, items: [], last: undefined }; +const before = current; +current = { ...current, count: 5 }; +console.log(before === current, before.count, current.count); +const history: State[] = []; +let tracked: State = { name: "h", count: 0, items: [], last: undefined }; +for (let i = 0; i < 3; i++) { + tracked = { ...tracked, count: tracked.count + 1 }; + history.push(tracked); +} +console.log(history.map((s) => s.count).join(","), history[0] === history[1]); +console.log({ ...tracked, last: "shown" }); diff --git a/tests/corpus/5962-frame-tuples.ts b/tests/corpus/5962-frame-tuples.ts new file mode 100644 index 0000000000..b03cbc79d4 --- /dev/null +++ b/tests/corpus/5962-frame-tuples.ts @@ -0,0 +1,57 @@ +// Tuples that are only destructured or indexed: number and string +// elements, producer functions returning fresh tuples, literals passed to +// functions that only read their elements, and tuples that escape into +// arrays, closures, identity comparisons and printing. +function divmod(a: number, b: number): [number, number] { + return [Math.floor(a / b), a % b]; +} +const names = ["a", "bb", "ccc"]; +function entry(i: number): [string, number] { + return [names[i % 3]! + i, i]; +} +function flip(p: [string, number]): [number, string] { + return [p[1] * 2, p[0].toUpperCase()]; +} +function area(r: [number, number]): number { + return r[0] * r[1]; +} +function risky(i: number): [string, string] { + if (i % 4 === 2) throw new Error("risky " + i); + return ["r" + i, String(i * i)]; +} +const kept: [string, number][] = []; + +let acc = 0; +let log = ""; +for (let i = 0; i < 10; i++) { + const [q, r] = divmod(i * 13, 7); + acc += q + r; + const [k, v] = entry(i); + acc += k.length + v; + const pair: [number, number] = [i, i + 1]; + acc += pair[0] * pair[1] + area([i, 2]) + area(pair); + const [n, s] = flip(entry(i + 1)); + log += s + n; + try { + const [x, y] = risky(i); + log += x + y.length; + } catch (e) { + log += "!"; + } + if (i % 3 === 0) kept.push(entry(i)); +} +console.log(acc, log); +console.log(kept.map(([name, i]) => name + "=" + i).join(" ")); + +let [left, right] = ["l", "r"]; +for (let i = 0; i < 3; i++) [left, right] = [right, left + i]; +console.log(left, right); + +const t1: [number, string] = [1, "x"]; +const t2: [number, string] = [1, "x"]; +const getters: (() => string)[] = []; +for (let i = 0; i < 2; i++) { + const t: [string, number] = [names[i]!, i]; + getters.push(() => t[0] + t[1]); +} +console.log(t1 === t2, getters.map((get) => get()).join(","), entry(4), JSON.stringify(divmod(9, 4))); From 83517ecd8226a065aa20b8afc9c4fc5cc7bf9429 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 13:32:48 -0700 Subject: [PATCH 2/2] Accept a frame-stored tuple receiver in the tuple for-of test A for-of over a tuple producer's result only reads its elements, so the tuple now lives in frame storage and the producer runs through its result-slot entry. The test still checks that the receiver is computed once and that no array snapshot is allocated, whichever entry runs. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/test/tuple-forof.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/compiler/test/tuple-forof.test.ts b/packages/compiler/test/tuple-forof.test.ts index 3a66d9c099..78cc9376de 100644 --- a/packages/compiler/test/tuple-forof.test.ts +++ b/packages/compiler/test/tuple-forof.test.ts @@ -25,7 +25,12 @@ console.log(total, calls); const mod = deserializeModule(await readFile(outPath, "utf8")); expect(validateModule(mod)).toEqual([]); const llvm = emitLlvmModule(mod); - expect(llvm.match(/call ptr @sc_f_pair\(/g)).toHaveLength(1); + // The receiver is computed once: an ordinary call, or (when the loop + // only reads elements) the result-slot entry filling frame storage. + const calls = + (llvm.match(/call ptr @sc_f_pair\(/g) ?? []).length + + (llvm.match(/call void @sc_\w*into_pair\(ptr %s\d+\)/g) ?? []).length; + expect(calls).toBe(1); expect(llvm).not.toContain("@scr_arr_new"); } finally { await rm(dir, { recursive: true, force: true });