From 7bc8a191f029bb247b91b97faacf96ae3398741a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 14 Oct 2026 02:44:11 -0500 Subject: [PATCH] Keep non-escaping number records and objects in frame storage --- benchmarks/runtime/README.md | 1 + benchmarks/runtime/workloads.json | 7 + benchmarks/runtime/workloads/value-objects.ts | 132 +++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 61 ++++- .../compiler/src/backend/llvm/expr-calls.ts | 127 +++++----- .../src/backend/llvm/expr-primitives.ts | 52 ++-- .../src/backend/llvm/record-results.ts | 206 ++++++++++++++++ .../src/backend/llvm/stack-objects.test.ts | 223 ++++++++++++++++++ .../src/backend/llvm/stack-objects.ts | 160 +++++++++++++ .../tests/corpus/5120-frame-records.ts.json | 6 + .../tests/corpus/5121-frame-objects.ts.json | 6 + .../corpus/5122-frame-result-slots.ts.json | 6 + .../corpus/5123-frame-reassigned.ts.json | 6 + .../corpus/5124-frame-objects-control.ts.json | 6 + .../corpus/5127-frame-loop-bindings.ts.json | 6 + tests/corpus/5120-frame-records.ts | 54 +++++ tests/corpus/5121-frame-objects.ts | 86 +++++++ tests/corpus/5122-frame-result-slots.ts | 57 +++++ tests/corpus/5123-frame-reassigned.ts | 72 ++++++ tests/corpus/5124-frame-objects-control.ts | 46 ++++ tests/corpus/5127-frame-loop-bindings.ts | 61 +++++ 21 files changed, 1306 insertions(+), 75 deletions(-) create mode 100644 benchmarks/runtime/workloads/value-objects.ts create mode 100644 packages/compiler/src/backend/llvm/record-results.ts create mode 100644 packages/compiler/src/backend/llvm/stack-objects.test.ts create mode 100644 packages/compiler/src/backend/llvm/stack-objects.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5120-frame-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5121-frame-objects.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5122-frame-result-slots.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5123-frame-reassigned.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5124-frame-objects-control.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5127-frame-loop-bindings.ts.json create mode 100644 tests/corpus/5120-frame-records.ts create mode 100644 tests/corpus/5121-frame-objects.ts create mode 100644 tests/corpus/5122-frame-result-slots.ts create mode 100644 tests/corpus/5123-frame-reassigned.ts create mode 100644 tests/corpus/5124-frame-objects-control.ts create mode 100644 tests/corpus/5127-frame-loop-bindings.ts diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index 9dea0a3ea..44f67b28c 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -14,6 +14,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `word-graph` | cpu | string-keyed Map/Set, BFS | | `alloc-trees` | cpu | allocation-heavy trees, immutable object-spread updates | | `numeric-kernels` | cpu | sieve, hashing, `number[][]` matmul, `Float64Array` | +| `value-objects` | cpu | small immutable records and class instances: vector helpers, method chains, particle updates | | `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains | | `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | diff --git a/benchmarks/runtime/workloads.json b/benchmarks/runtime/workloads.json index 5e7ebfce9..8d8c183f0 100644 --- a/benchmarks/runtime/workloads.json +++ b/benchmarks/runtime/workloads.json @@ -70,6 +70,13 @@ "0.5" ] }, + { + "name": "value-objects", + "entry": "workloads/value-objects.ts", + "args": [ + "2" + ] + }, { "name": "validate-errors", "entry": "workloads/validate-errors.ts", diff --git a/benchmarks/runtime/workloads/value-objects.ts b/benchmarks/runtime/workloads/value-objects.ts new file mode 100644 index 000000000..f917e5eb1 --- /dev/null +++ b/benchmarks/runtime/workloads/value-objects.ts @@ -0,0 +1,132 @@ +// Small immutable value objects: Vec3 records built by helper functions, +// a Complex class whose methods return new instances, and an immutable +// particle update. Most temporaries never leave the expression that +// creates them. +interface Vec3 { + x: number; + y: number; + z: number; +} +function vec(x: number, y: number, z: number): Vec3 { + return { x, y, z }; +} +function add(a: Vec3, b: Vec3): Vec3 { + return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }; +} +function sub(a: Vec3, b: Vec3): Vec3 { + return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }; +} +function scale(a: Vec3, k: number): Vec3 { + return { x: a.x * k, y: a.y * k, z: a.z * k }; +} +function dot(a: Vec3, b: Vec3): number { + return a.x * b.x + a.y * b.y + a.z * b.z; +} +function cross(a: Vec3, b: Vec3): Vec3 { + return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x }; +} +function normalize(a: Vec3): Vec3 { + const len = Math.sqrt(dot(a, a)); + return len === 0 ? a : scale(a, 1 / len); +} + +interface Sphere { + center: Vec3; + radius: number; + shade: number; +} +function hit(origin: Vec3, dir: Vec3, sphere: Sphere): number { + const oc = sub(origin, sphere.center); + const b = dot(oc, dir); + const c = dot(oc, oc) - sphere.radius * sphere.radius; + const disc = b * b - c; + if (disc < 0) return -1; + const t = -b - Math.sqrt(disc); + return t > 0 ? t : -1; +} +function trace(width: number, height: number, spheres: Sphere[]): number { + const eye = vec(0, 0, -4); + const light = normalize(vec(-1, 1, -1)); + let total = 0; + for (let py = 0; py < height; py++) { + for (let px = 0; px < width; px++) { + const dir = normalize(vec((px - width / 2) / width, (py - height / 2) / height, 1)); + let nearest = Infinity; + let shade = 0; + for (const sphere of spheres) { + const t = hit(eye, dir, sphere); + if (t > 0 && t < nearest) { + nearest = t; + const point = add(eye, scale(dir, t)); + const normal = normalize(sub(point, sphere.center)); + shade = sphere.shade * Math.max(0, dot(normal, light)); + } + } + total += shade; + } + } + return total; +} + +class Complex { + re: number; + im: number; + constructor(re: number, im: number) { + this.re = re; + this.im = im; + } + plus(o: Complex): Complex { + return new Complex(this.re + o.re, this.im + o.im); + } + times(o: Complex): Complex { + return new Complex(this.re * o.re - this.im * o.im, this.re * o.im + this.im * o.re); + } + norm(): number { + return this.re * this.re + this.im * this.im; + } +} +function mandelbrot(size: number, limit: number): number { + let inside = 0; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const c = new Complex((x / size) * 3 - 2, (y / size) * 3 - 1.5); + let z = new Complex(0, 0); + let n = 0; + while (n < limit && z.norm() <= 4) { + z = z.times(z).plus(c); + n++; + } + if (n === limit) inside++; + } + } + return inside; +} + +function particles(count: number, steps: number): number { + const positions: Vec3[] = []; + const velocities: Vec3[] = []; + for (let i = 0; i < count; i++) { + positions.push(vec(i % 7, (i * 3) % 11, (i * 5) % 13)); + velocities.push(vec(((i % 5) - 2) / 10, ((i % 3) - 1) / 10, 0.05)); + } + const gravity = vec(0, -0.01, 0); + for (let s = 0; s < steps; s++) { + for (let i = 0; i < count; i++) { + const v = add(velocities[i]!, gravity); + velocities[i] = v; + positions[i] = add(positions[i]!, scale(v, 0.5)); + } + } + let sum = 0; + for (const p of positions) sum += dot(p, cross(p, vec(1, 2, 3))) + p.y; + return sum; +} + +const scaleArg = Number(process.argv[2] ?? "1"); +const spheres: Sphere[] = []; +for (let i = 0; i < 8; i++) + spheres.push({ center: vec((i % 4) - 1.5, Math.floor(i / 4) - 0.5, 2 + (i % 3)), radius: 0.45, shade: 1 + i / 8 }); +const image = trace(Math.floor(320 * scaleArg), Math.floor(240 * scaleArg), spheres); +const fractal = mandelbrot(Math.floor(220 * scaleArg), 200); +const motion = particles(Math.floor(2000 * scaleArg), 200); +console.log("trace", image.toFixed(6), "mandelbrot", fractal, "particles", motion.toFixed(6)); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 0e948a2d7..4c2be9440 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -74,6 +74,7 @@ import { type StringSplit, } from "./split-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; +import { addRecordResultEntries } from "./record-results.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js"; @@ -123,6 +124,13 @@ import { } from "../mangle.js"; import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js"; import { canStackUnion, emitStackUnion, findLocalStackUnions } from "./stack-unions.js"; +import { + emitStackObject, + findLocalStackObjects, + findReassignedStackObjects, + stackObjectStruct, + type StackObjectSource, +} from "./stack-objects.js"; import { allocateLocalUnion, emitScalarOptional, @@ -408,7 +416,9 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) // Keep source storage intact for debugger inspection in dev builds. return new LlEmitter( options.debugSources === undefined - ? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction)) + ? addRecordResultEntries( + scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction)), + ) : mod, options, ).emit(); @@ -422,7 +432,9 @@ export function emitLlvmModuleSource( const mod = unshareNodes(input); const parts = new LlEmitter( options.debugSources === undefined - ? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction)) + ? addRecordResultEntries( + scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction)), + ) : mod, options, ).emitParts(); @@ -476,6 +488,10 @@ export class LlEmitter { private scalarStringSlices = new Map(); readonly stringSlices = new Map(); private localStackUnions = new Map(); + private localStackObjects = new Map(); + private reassignedStackObjects = new Set(); + /** The binding slot of each reassigned stack-object local. */ + private stackObjectSlots = new Map(); private localUnionStorageProofs = new Map(); private localUnionStorage = new Map(); integerArrayBindings = new Set(); @@ -529,6 +545,8 @@ export class LlEmitter { return lines; } private readonly debug: LlvmDebugInfo | null; + /** Projection-only scalar objects may live in frame storage (stack-objects.ts). */ + readonly stackObjects: boolean; private debugScope: string | null = null; readonly sizeType: "i32" | "i64"; readonly ffiExtendNarrowIntegers: boolean; @@ -931,6 +949,7 @@ export class LlEmitter { options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); this.wasi = options.wasi === true; this.stacklessGenerators = !this.wasi && mod.workers !== true && this.debug === null; + this.stackObjects = this.debug === null; // An empty triple targets the host, as for executableTls below: the // Node-hosted CLI passes none, the native CLI passes its toolchain's, and // both must emit the same module for the same target. @@ -5509,6 +5528,13 @@ export class LlEmitter { this.debug === null ? findScalarStringSlices(fn, this.callLifetimes) : new Map(); this.mapReadLifetimes = findMapReadLifetimes(fn, this.boxedUnionsById, this.callLifetimes); this.localStackUnions = findLocalStackUnions(fn, this.callLifetimes, this.boxedUnionsById); + this.localStackObjects = this.stackObjects + ? findLocalStackObjects(this, fn, this.callLifetimes) + : new Map(); + this.reassignedStackObjects = this.stackObjects + ? findReassignedStackObjects(this, fn, this.callLifetimes) + : new Set(); + this.stackObjectSlots = new Map(); this.localUnionStorageProofs = findLocalUnionStorage(fn, this.callLifetimes, this.unionSources); this.localUnionStorage = new Map(); this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); @@ -6430,6 +6456,17 @@ export class LlEmitter { ); break; } + const stackObject = this.localStackObjects.get(s.localId); + if (stackObject) { + B.line(`store ptr ${emitStackObject(this, stackObject).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); + B.line(`store ptr ${slot}, ptr ${b.slot}`); + break; + } const localUnion = this.localStackUnions.get(s.localId); if (localUnion) { const union = emitStackUnion(this, localUnion); @@ -6577,6 +6614,15 @@ export class LlEmitter { B.line(`store ptr ${v.name}, ptr ${owner}`); break; } + if (this.reassignedStackObjects.has(s.localId)) { + // The new value may read the old one: build it apart, then copy. + const struct = stackObjectStruct(s.value.type); + const value = emitStackObject(this, s.value as StackObjectSource); + 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)}`); + break; + } const localStorage = this.localUnionStorage.get(s.localId); if (localStorage) { storeLocalUnion(this, localStorage, s.value); @@ -8558,6 +8604,17 @@ export class LlEmitter { return this.emitExpr(e); } + /** The frame slot a reassigned stack-object local keeps for its value. */ + private stackObjectSlot(localId: string, type: IrType): string { + let slot = this.stackObjectSlots.get(localId); + if (slot === undefined) { + slot = this.B.slot(); + this.B.entryAllocas.push(`${slot} = alloca ${stackObjectStruct(type)}, align 8`); + this.stackObjectSlots.set(localId, slot); + } + return slot; + } + /** A private stack union source with an independently owned payload. */ canStackReceiver(e: IrExpr): boolean { return this.isStackUnionSource(e); diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index b38ae0bb1..13ff63ad2 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -10,6 +10,7 @@ import { type IrFunction, } from "../../ir/ir.js"; import { INT32_ABI_SUFFIX, SCALAR_UNION_RESULT } from "./int32-abi.js"; +import { canStackObject, emitStackObject } from "./stack-objects.js"; import { collectFfiRetainedOps, parseFfiCallbackKey } from "../ffi-callbacks.js"; import { mangleAsyncAdoptCall, @@ -62,6 +63,9 @@ export function emitCallExpr( /** A projection consumes a scalar union result (Int32Abi.union): answer * a private stack box instead of a heap box. */ stackResult = false, + /** The frame storage a result-slot entry (record-results.ts) writes: + * passed after the call's own arguments. */ + resultSlot?: LlValue, ): LlValue { const B = host.B; switch (e.kind) { @@ -125,6 +129,7 @@ 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.nullableFieldGet(a)) return { name: host.emitOwnedNullableStack(a).box, type: a.type }; } @@ -132,6 +137,7 @@ export function emitCallExpr( } return host.emitExpr(a); }); + if (resultSlot) args.push(resultSlot); // A borrowed parameter's owned argument stays in the caller frame. // This also preserves snapshots when a later argument mutates its // source, and releases earlier arguments if evaluation throws. @@ -913,60 +919,7 @@ export function emitCallExpr( const o = B.tmp(); B.line(`${o} = call ptr @${mangleClassNew(e.className)}()`); const out = host.own({ name: o, type: e.type }); - const ctorName = `%${e.className}.constructor`; - const borrowed = host.callLifetimes.borrowed.get(ctorName); - if (borrowed) { - // The borrowed constructor body, like a direct call: this frame - // owns the new object across the call, borrowed arguments stay - // owned by a call frame, and projection-only parameters (stores - // into nullable-pointer fields, tests) receive private stack boxes. - const projected = host.callLifetimes.parameters.get(ctorName); - const inputs = borrowableInputs( - host, - e.args, - host.referenceEffects.functions.has(ctorName), - host.referenceWrites?.constructWrites(e.className), - ); - host.frames.push([]); - const args = e.args.map((a, i) => { - if (borrowed.has(i + 1)) { - if (projected?.has(i + 1) && canStackUnion(a, host.boxedUnionsById)) - return emitStackUnion(host, a).value; - if (projected?.has(i + 1) && host.nullableFieldGet(a)) - return { name: host.emitOwnedNullableStack(a).box, type: a.type }; - if (inputs[i]) return host.emitReadReceiver(a); - } - return host.emitExpr(a); - }); - args.forEach((a, i) => { - if (!borrowed.has(i + 1)) host.moveTemp(a); - }); - const self = borrowed.has(0) - ? o - : (() => { - const r = B.tmp(); - B.line(`${r} = call ptr @${mangleClassRetain(e.className)}(ptr ${o})`); - return r; - })(); - const argList = [`ptr ${self}`, ...ctorArguments(host, ctorName, ctor, args, e.args)].join( - ", ", - ); - B.line( - `call void @${mangleBorrowedFunction(ctorName)}${host.int32Abis.has(ctorName) ? INT32_ABI_SUFFIX : ""}(${argList})`, - ); - if (host.mayThrow.has(ctorName)) host.emitPendingCheck(); - host.releaseFrame(host.frames.pop()!); - return out; - } - const args = e.args.map((a) => host.emitExpr(a)); - for (const a of args) host.moveTemp(a); - const r = B.tmp(); - B.line(`${r} = call ptr @${mangleClassRetain(e.className)}(ptr ${o})`); - const argList = [`ptr ${r}`, ...ctorArguments(host, ctorName, ctor, args, e.args)].join(", "); - B.line( - `call void @${mangleFunction(ctorName)}${host.int32Abis.has(ctorName) ? INT32_ABI_SUFFIX : ""}(${argList})`, - ); - if (host.mayThrow.has(`%${e.className}.constructor`)) host.emitPendingCheck(); + emitConstruction(host, e, o, ctor); return out; } case "classRef": { @@ -1239,3 +1192,69 @@ export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); return { name: t, type: e.type }; } + +/** Run the constructor of `new` over the allocated object `o` (fields + * zeroed, header stamped). The ctor owns and releases its `this` param like + * any callee, so it receives a +1 distinct from the caller's reference. */ +export function emitConstruction( + host: LlvmEmitterContext, + e: ExprOf<"new">, + o: string, + ctor: IrFunction, +): void { + const B = host.B; + const ctorName = `%${e.className}.constructor`; + const borrowed = host.callLifetimes.borrowed.get(ctorName); + if (borrowed) { + // The borrowed constructor body, like a direct call: this frame + // owns the new object across the call, borrowed arguments stay + // owned by a call frame, and projection-only parameters (stores + // into nullable-pointer fields, tests) receive private stack boxes. + const projected = host.callLifetimes.parameters.get(ctorName); + const inputs = borrowableInputs( + host, + e.args, + host.referenceEffects.functions.has(ctorName), + host.referenceWrites?.constructWrites(e.className), + ); + host.frames.push([]); + const args = e.args.map((a, i) => { + if (borrowed.has(i + 1)) { + if (projected?.has(i + 1) && canStackUnion(a, host.boxedUnionsById)) + return emitStackUnion(host, a).value; + if (projected?.has(i + 1) && host.nullableFieldGet(a)) + return { name: host.emitOwnedNullableStack(a).box, type: a.type }; + if (inputs[i]) return host.emitReadReceiver(a); + } + return host.emitExpr(a); + }); + args.forEach((a, i) => { + if (!borrowed.has(i + 1)) host.moveTemp(a); + }); + const self = borrowed.has(0) + ? o + : (() => { + const r = B.tmp(); + B.line(`${r} = call ptr @${mangleClassRetain(e.className)}(ptr ${o})`); + return r; + })(); + const argList = [`ptr ${self}`, ...ctorArguments(host, ctorName, ctor, args, e.args)].join( + ", ", + ); + B.line( + `call void @${mangleBorrowedFunction(ctorName)}${host.int32Abis.has(ctorName) ? INT32_ABI_SUFFIX : ""}(${argList})`, + ); + if (host.mayThrow.has(ctorName)) host.emitPendingCheck(); + host.releaseFrame(host.frames.pop()!); + return; + } + const args = e.args.map((a) => host.emitExpr(a)); + for (const a of args) host.moveTemp(a); + const r = B.tmp(); + B.line(`${r} = call ptr @${mangleClassRetain(e.className)}(ptr ${o})`); + const argList = [`ptr ${r}`, ...ctorArguments(host, ctorName, ctor, args, e.args)].join(", "); + B.line( + `call void @${mangleFunction(ctorName)}${host.int32Abis.has(ctorName) ? INT32_ABI_SUFFIX : ""}(${argList})`, + ); + if (host.mayThrow.has(`%${e.className}.constructor`)) host.emitPendingCheck(); +} diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 180e8b2f0..00b22200d 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -1,7 +1,7 @@ /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js"; -import { isRefCounted, type IrExpr } from "../../ir/ir.js"; +import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js"; import { mangleRecordClone, mangleRecordNew } from "../mangle.js"; import { arrNewCall, elemAccess } from "./shapes.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -968,24 +968,7 @@ export function emitRecordExpr( const rec = B.tmp(); B.line(`${rec} = call ptr @${mangleRecordNew(shapeId)}()`); const out = host.own({ name: rec, type: e.type }); - for (const f of e.fields) { - const v = host.emitExpr(f.value); - if (f.overflow) { - const lit = host.internLiteral(f.name); - const acc = v.type.kind === "f64" ? "f64" : v.type.kind === "bool" ? "bool" : "ref"; - if (acc === "ref") host.moveTemp(v); - const ovf = host.recordOvfPtr(rec, shapeId); - const argTy = acc === "f64" ? "double" : acc === "bool" ? "i1 zeroext" : "ptr"; - host.declare(`declare void @scr_map_set_str_${acc}(ptr, ptr, ${argTy})`); - B.line( - `call void @scr_map_set_str_${acc}(ptr ${ovf}, ptr ${lit}, ${argTy === "i1 zeroext" ? "i1" : argTy} ${v.name})`, - ); - continue; - } - if (isRefCounted(v.type)) host.moveTemp(v); - const { ptr, type } = host.recordFieldPtr(rec, shapeId, f.name); - host.storeField(ptr, type, v.name); - } + emitRecordLitFields(host, e, rec); return out; } case "recordClone": { @@ -1054,3 +1037,34 @@ export function emitRecordExpr( } } } + +/** Write a record literal's fields into the fresh record `rec`, IN SOURCE + * ORDER (JS evaluates property values in source order). Ownership of + * refcounted values moves in; the struct is fresh, so there is never an + * old value to release. */ +export function emitRecordLitFields( + host: LlvmEmitterContext, + e: IrExpr & { kind: "recordLit" }, + rec: string, +): void { + const B = host.B; + const shapeId = (e.type as IrType & { kind: "record" }).shapeId; + for (const f of e.fields) { + const v = host.emitExpr(f.value); + if (f.overflow) { + const lit = host.internLiteral(f.name); + const acc = v.type.kind === "f64" ? "f64" : v.type.kind === "bool" ? "bool" : "ref"; + if (acc === "ref") host.moveTemp(v); + const ovf = host.recordOvfPtr(rec, shapeId); + const argTy = acc === "f64" ? "double" : acc === "bool" ? "i1 zeroext" : "ptr"; + host.declare(`declare void @scr_map_set_str_${acc}(ptr, ptr, ${argTy})`); + B.line( + `call void @scr_map_set_str_${acc}(ptr ${ovf}, ptr ${lit}, ${argTy === "i1 zeroext" ? "i1" : argTy} ${v.name})`, + ); + continue; + } + if (isRefCounted(v.type)) host.moveTemp(v); + const { ptr, type } = host.recordFieldPtr(rec, shapeId, f.name); + host.storeField(ptr, type, v.name); + } +} diff --git a/packages/compiler/src/backend/llvm/record-results.ts b/packages/compiler/src/backend/llvm/record-results.ts new file mode 100644 index 000000000..d5de03920 --- /dev/null +++ b/packages/compiler/src/backend/llvm/record-results.ts @@ -0,0 +1,206 @@ +import { constructorEscapePoint } from "../cycle-mutability.js"; +import { + VOID, + type IrClassDef, + type IrExpr, + type IrFunction, + type IrModule, + type IrRecordShape, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; +import { everyExpr, everyStmtList, transformStmtList } from "../../ir/traverse.js"; + +/** Prefix of a function's result-slot entry: `%into.(...params, dst)` + * writes the function's fresh result object into `dst` and returns nothing. */ +export const RESULT_ENTRY_PREFIX = "%into."; + +export function resultEntryName(name: string): string { + return `${RESULT_ENTRY_PREFIX}${name}`; +} + +function scalarFields(fields: readonly { type: IrType }[]): boolean { + return fields.every((field) => field.type.kind === "f64" || field.type.kind === "bool"); +} + +/** A plain record shape whose fields are numbers and booleans. */ +export function scalarRecordShape(shape: IrRecordShape | undefined): boolean { + return shape !== undefined && !shape.indexValue && !shape.tuple && scalarFields(shape.fields); +} + +/** A literal that stores every field of its shape, none of them through + * the overflow map. */ +export function completeRecordLiteral( + e: IrExpr & { kind: "recordLit" }, + shape: IrRecordShape | undefined, +): boolean { + if (!shape || e.fields.some((field) => field.overflow)) return false; + const names = new Set(e.fields.map((field) => field.name)); + return shape.fields.every((field) => names.has(field.name)); +} + +/** A standalone class with scalar fields whose constructor never lets + * `this` escape: its instance can be built in caller-provided storage. */ +export function scalarStandaloneClass( + cls: IrClassDef | undefined, + ctor: IrFunction | undefined, + isBase: boolean, + programLifetime: boolean, +): boolean { + if ( + !cls || + !ctor || + isBase || + programLifetime || + cls.runtime || + cls.base !== undefined || + cls.localCaptures !== undefined || + cls.tracksOwnFields || + (cls.symbolFields?.length ?? 0) > 0 || + ctor.async || + ctor.generator || + !scalarFields(cls.fields) + ) + return false; + const self = ctor.params[0]?.localId; + return self !== undefined && constructorEscapePoint(ctor, self, null) === ctor.body.length; +} + +/** Whether the constructor's top-level statements store every field of + * `this` before anything reads a field it has not stored yet. Storage for + * such an instance needs no zeroing first. */ +export function constructorInitializesFields(cls: IrClassDef, ctor: IrFunction): boolean { + const self = ctor.params[0]?.localId; + if (self === undefined) return false; + const stored = new Set(); + const unstoredRead = { + expr: (node: IrExpr): boolean => + !( + node.kind === "fieldGet" && + node.obj.kind === "varRef" && + node.obj.localId === self && + !stored.has(node.field) + ), + stmt: () => true, + }; + for (const stmt of ctor.body) { + if (stmt.kind === "fieldSet" && stmt.obj.kind === "varRef" && stmt.obj.localId === self) { + if (!everyExpr(stmt.value, unstoredRead)) return false; + stored.add(stmt.field); + } else if (!everyStmtList([stmt], unstoredRead)) return false; + } + return cls.fields.every((field) => stored.has(field.name)); +} + +/** Add a result-slot entry for every function whose returns all build a + * fresh scalar record or standalone scalar object. The original function + * stays as it is; the backend calls the entry only where the result is + * proven to be consumed by projections, passing frame storage. */ +export function addRecordResultEntries(mod: IrModule): IrModule { + const shapes = new Map((mod.records ?? []).map((shape) => [shape.id, shape])); + const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls])); + const bases = new Set((mod.classes ?? []).flatMap((cls) => (cls.base ? [cls.base] : []))); + const lifetime = new Set(mod.contracts?.programLifetime ?? []); + const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); + const stackClass = (name: string): boolean => + scalarStandaloneClass( + classes.get(name), + functions.get(`%${name}.constructor`), + bases.has(name), + lifetime.has(name), + ); + const fillable = (value: IrExpr): boolean => { + switch (value.kind) { + case "recordLit": + return completeRecordLiteral( + value, + shapes.get((value.type as IrType & { kind: "record" }).shapeId), + ); + case "new": + return stackClass(value.className); + case "ternary": + return fillable(value.then) && fillable(value.else_); + default: + return false; + } + }; + const fill = (value: IrExpr, dst: IrExpr): IrStmt[] => { + switch (value.kind) { + case "recordLit": + return value.fields.map((field) => ({ + kind: "recordSet", + obj: dst, + shapeId: (value.type as IrType & { kind: "record" }).shapeId, + field: field.name, + value: field.value, + loc: field.value.loc, + })); + case "new": + return [ + { + kind: "exprStmt", + expr: { + kind: "call", + callee: `%${value.className}.constructor`, + args: [dst, ...value.args], + type: VOID, + loc: value.loc, + }, + loc: value.loc, + }, + ]; + case "ternary": + return [ + { + kind: "if", + cond: value.cond, + then: fill(value.then, dst), + else_: fill(value.else_, dst), + loc: value.loc, + }, + ]; + default: + throw new Error("unreachable"); + } + }; + const added: IrFunction[] = []; + for (const fn of mod.functions) { + if (fn.async || fn.generator || fn.captures !== undefined || fn.classCaptures !== undefined) + continue; + const ret = fn.returnType; + if ( + !(ret.kind === "record" && scalarRecordShape(shapes.get(ret.shapeId))) && + !(ret.kind === "object" && stackClass(ret.className)) + ) + continue; + let returns = 0; + const eligible = everyStmtList(fn.body, { + expr: (e) => e.kind !== "seqExpr", + stmt: (s) => { + if (s.kind !== "return") return s.kind !== "tryCatch"; + returns++; + return s.value !== null && fillable(s.value); + }, + }); + if (!eligible || returns === 0) continue; + const dstId = "%into.dst"; + const dst: IrExpr = { kind: "varRef", localId: dstId, type: ret, loc: fn.loc }; + const body = transformStmtList(fn.body, { + expr: (e) => e, + stmt: (s) => + s.kind === "return" && s.value !== null + ? { kind: "block", body: [...fill(s.value, dst), { ...s, value: null }], loc: s.loc } + : s, + }); + const { sourceName: _sourceName, ownsPrototype: _ownsPrototype, ...rest } = fn; + added.push({ + ...rest, + name: resultEntryName(fn.name), + params: [...fn.params, { localId: dstId, name: "dst", type: ret }], + locals: [...fn.locals, { id: dstId, name: "dst", type: ret, mutable: false }], + returnType: VOID, + body, + }); + } + return added.length === 0 ? mod : { ...mod, functions: [...mod.functions, ...added] }; +} diff --git a/packages/compiler/src/backend/llvm/stack-objects.test.ts b/packages/compiler/src/backend/llvm/stack-objects.test.ts new file mode 100644 index 000000000..9dfb90a84 --- /dev/null +++ b/packages/compiler/src/backend/llvm/stack-objects.test.ts @@ -0,0 +1,223 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import { + F64, + VOID, + type IrExpr, + type IrFunction, + type IrModule, + type IrType, +} from "../../ir/ir.js"; +import { validateModule } from "../../ir/validate.js"; +import { emitLlvmModule } from "./emitter.js"; +import { addRecordResultEntries, RESULT_ENTRY_PREFIX } from "./record-results.js"; + +async function moduleFor(source: string) { + const dir = await mkdtemp(join(tmpdir(), "scriptc-stack-objects-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The body of a source function: its borrowing or i32 entry when it has one. */ +function body(ir: string, name: string): string { + return new RegExp(`^define internal [^\\n]*@sc_b?f_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec( + ir, + )![0]; +} + +const VECTORS = ` +interface Vec { x: number; y: number } +function add(a: Vec, b: Vec): Vec { return { x: a.x + b.x, y: a.y + b.y }; } +function dot(a: Vec, b: Vec): number { return a.x * b.x + a.y * b.y; } +function shortest(a: Vec): Vec { return a.x < a.y ? { x: a.x, y: 0 } : { x: 0, y: a.y }; } +const kept: Vec[] = []; +export function projected(n: number): number { + const p = { x: n, y: 1 }; + const q = add(p, { x: 2, y: n }); + return dot(q, p) + dot(shortest(q), q); +} +export function escaping(n: number): number { + const p = { x: n, y: 1 }; + kept.push(p); + const q = add(p, p); + kept.push(q); + return q.x; +} +export function reassigned(n: number): number { + let p = { x: n, y: 0 }; + for (let i = 0; i < n; i++) p = add(p, { x: 1, y: i }); + return p.x + p.y; +} +console.log(projected(2), escaping(3), reassigned(4), kept.length); +`; + +test("projection-only records live in frame storage and producers fill it", async () => { + const ir = emitLlvmModule(await moduleFor(VECTORS)); + const projected = body(ir, "projected"); + expect(projected).toMatch(/alloca %sc_rs_\w+, align 8/); + expect(projected).toContain("store i64 -1, ptr"); + expect(projected).not.toMatch(/@sc_rnew_/); + expect(projected).toMatch(/call void @sc_\w*into_add\(/); + expect(projected).toMatch(/call void @sc_\w*into_shortest\(/); + // A literal stores every field: its storage is not zeroed first. + expect(projected).not.toContain("zeroinitializer"); + // Stored values keep the heap path and the ordinary producer. + const escaping = body(ir, "escaping"); + expect(escaping).toMatch(/call ptr @sc_rnew_/); + expect(escaping).toMatch(/call ptr @sc_\w*f_add\(/); + expect(escaping).not.toMatch(/alloca %sc_rs_/); +}); + +test("reassigned bindings build each value apart and copy it into their slot", async () => { + const ir = emitLlvmModule(await moduleFor(VECTORS)); + const reassigned = body(ir, "reassigned"); + expect(reassigned).not.toMatch(/@sc_rnew_/); + expect(reassigned).toMatch(/load %sc_rs_\w+, ptr %s\d+\n\s+store %sc_rs_\w+ %t\d+, ptr %s\d+/); +}); + +test("result-slot entries cover producers whose every return is fresh", async () => { + const mod = await moduleFor(` +interface Vec { x: number; y: number } +function fresh(n: number): Vec { if (n > 1) return { x: n, y: n }; return { x: 0, y: 1 }; } +function passthrough(v: Vec): Vec { return v; } +function guarded(n: number): Vec { try { return { x: n, y: 0 }; } finally { console.log(n); } } +console.log(fresh(1).x, passthrough({ x: 1, y: 2 }).y, guarded(3).x); +`); + const entries = addRecordResultEntries(mod) + .functions.map((fn) => fn.name) + .filter((name) => name.startsWith(RESULT_ENTRY_PREFIX)); + expect(entries).toEqual([`${RESULT_ENTRY_PREFIX}fresh`]); +}); + +test("objects stay on the heap when their constructor or a method lets them escape", async () => { + const ir = emitLlvmModule( + await moduleFor(` +const seen: Tracked[] = []; +class Tracked { id: number; constructor(id: number) { this.id = id; seen.push(this); } } +class Plain { + a: number; b: number; + constructor(a: number, b: number) { this.a = a; this.b = b; } + sum(): number { return this.a + this.b; } + self(): Plain { return this; } +} +const selves: Plain[] = []; +export function run(n: number): number { + const t = new Tracked(n); + const p = new Plain(n, 1); + const s = new Plain(n, 2); + selves.push(s.self()); + return t.id + p.sum() + s.sum(); +} +console.log(run(1), seen.length, selves.length); +`), + ); + const run = body(ir, "run"); + expect(run.match(/alloca %sc_o_\w+, align 8/g)).toHaveLength(1); + expect(run).toContain("call ptr @sc_new_Tracked()"); + expect(run).toContain("call ptr @sc_new_Plain()"); +}); + +test("loop bindings are never reassigned into a fixed slot", () => { + const loc = { file: "loop.ts", start: 0, end: 0 }; + const point: IrType = { kind: "record", shapeId: "point" }; + const points: IrType = { kind: "array", elem: point }; + const ref = (localId: string, type: IrType): IrExpr => ({ kind: "varRef", localId, type, loc }); + const x = (obj: IrExpr): IrExpr => ({ + kind: "recordGet", + obj, + shapeId: "point", + field: "x", + type: F64, + loc, + }); + const work: IrFunction = { + name: "work", + loc, + returnType: F64, + params: [{ localId: "items", name: "items", type: points }], + locals: [ + { id: "items", name: "items", type: points, mutable: true }, + { id: "total", name: "total", type: F64, mutable: true }, + { id: "p", name: "p", type: point, mutable: true }, + ], + body: [ + { + kind: "varDecl", + localId: "total", + init: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, + { + kind: "forOf", + localId: "p", + iterable: ref("items", points), + body: [ + { + kind: "assign", + localId: "p", + value: { + kind: "recordLit", + fields: [ + { + name: "x", + value: { + kind: "bin", + op: "+", + left: x(ref("p", point)), + right: { kind: "numLit", value: 10, type: F64, loc }, + type: F64, + loc, + }, + }, + ], + type: point, + loc, + }, + loc, + }, + { + kind: "assign", + localId: "total", + value: { + kind: "bin", + op: "+", + left: ref("total", F64), + right: x(ref("p", point)), + type: F64, + loc, + }, + loc, + }, + ], + loc, + }, + { kind: "return", value: ref("total", F64), loc }, + ], + }; + const mod: IrModule = { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + records: [{ id: "point", fields: [{ name: "x", type: F64 }] }], + functions: [{ name: "main", params: [], locals: [], body: [], returnType: VOID, loc }, work], + }; + expect(validateModule(mod)).toEqual([]); + const ir = body(emitLlvmModule(mod), "work"); + // The literal keeps its ordinary record; the binding is the element. + expect(ir).toContain("call ptr @sc_rnew_point()"); + expect(ir).not.toMatch(/alloca %sc_rs_point/); +}); diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts new file mode 100644 index 000000000..f2e570470 --- /dev/null +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -0,0 +1,160 @@ +import { VOID, typeEquals, type IrExpr, type IrFunction, type IrType } from "../../ir/ir.js"; +import { + completeRecordLiteral, + constructorInitializesFields, + resultEntryName, + scalarRecordShape, + scalarStandaloneClass, +} from "./record-results.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import { mangleClassStruct, mangleRecordStruct } from "../mangle.js"; +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"; + +export type StackObjectSource = IrExpr & { kind: "recordLit" | "new" | "call" }; + +/** 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 + * standalone scalar class whose constructor never lets `this` escape + * (record-results.ts). The caller proves that every use of the value only + * projects it. */ +export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource { + // 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) && completeRecordLiteral(e, shape); + } + if (e.kind !== "new") return false; + const meta = host.classMeta.get(e.className); + return ( + meta !== undefined && + scalarStandaloneClass( + meta.def, + host.fnByName.get(`%${e.className}.constructor`), + meta.hierarchy, + host.programLifetime.has(e.className), + ) + ); +} + +/** 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. */ +export function findLocalStackObjects( + host: LlvmEmitterContext, + fn: IrFunction, + lifetimes: CallLifetimes, +): Map { + const result = new Map(); + const safe = lifetimes.locals.get(fn.name); + if (!safe?.size || fn.async || fn.generator) return result; + everyStmtList(fn.body, { + expr: () => true, + stmt: (stmt) => { + if ( + stmt.kind === "varDecl" && + safe.has(stmt.localId) && + stmt.init && + canStackObject(host, stmt.init) + ) + result.set(stmt.localId, stmt.init); + return true; + }, + }); + return result; +} + +/** Reassigned locals declared with an initializer, whose every write + * builds a stack-eligible object of the binding's own type, and whose uses + * only project the value + * (CallLifetimes.projectedLocals). The binding keeps one slot; each + * assignment builds its value in fresh storage, which may read the old + * value, and then copies it into the binding's slot. */ +export function findReassignedStackObjects( + host: LlvmEmitterContext, + fn: IrFunction, + lifetimes: CallLifetimes, +): Set { + const result = new Set(); + const projected = lifetimes.projectedLocals.get(fn.name); + const stable = lifetimes.locals.get(fn.name); + if (!projected?.size || fn.async || fn.generator) return result; + const types = new Map(fn.locals.map((local) => [local.id, local.type])); + for (const id of projected) { + const type = types.get(id); + if (!stable?.has(id) && (type?.kind === "record" || type?.kind === "object")) result.add(id); + } + // The binding must be declared by its own initializer: a loop that binds + // it (`for...of`) would point it at each element, not at the slot. + const initialized = new Set(); + everyStmtList(fn.body, { + expr: () => true, + stmt: (stmt) => { + if (stmt.kind === "forOf") result.delete(stmt.localId); + if (stmt.kind === "varDecl" && stmt.init) initialized.add(stmt.localId); + if ((stmt.kind === "varDecl" || stmt.kind === "assign") && result.has(stmt.localId)) { + const value = stmt.kind === "varDecl" ? stmt.init : stmt.value; + if ( + !value || + !canStackObject(host, value) || + !typeEquals(value.type, types.get(stmt.localId)!) + ) + result.delete(stmt.localId); + } + return true; + }, + }); + for (const id of result) if (!initialized.has(id)) result.delete(id); + return result; +} + +export function stackObjectStruct(type: IrType): string { + return type.kind === "record" + ? `%${mangleRecordStruct(type.shapeId)}` + : `%${mangleClassStruct((type as IrType & { kind: "object" }).className)}`; +} + +/** 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. */ +export function emitStackObject( + host: LlvmEmitterContext, + e: StackObjectSource, + into?: string, +): LlValue { + const B = host.B; + const type = e.type; + const struct = stackObjectStruct(type); + let slot = into; + if (slot === undefined) { + slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${struct}, align 8`); + } + // Literals store every field; constructors that store every field + // before reading one need no zeroed storage either. + if (type.kind === "object") { + const cls = host.classMeta.get(type.className)!.def; + const ctor = host.fnByName.get(`%${type.className}.constructor`)!; + if (!constructorInitializesFields(cls, ctor)) + B.line(`store ${struct} zeroinitializer, ptr ${slot}`); + } + B.line(`store ${host.sizeType} -1, ptr ${slot}`); + if (e.kind === "call") { + // The entry writes the result into the slot it receives last. + emitCallExpr( + host, + { ...e, callee: resultEntryName(e.callee), type: VOID }, + false, + false, + host.markImmortal({ name: slot, type }), + ); + } else if (e.kind === "recordLit") emitRecordLitFields(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/5120-frame-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5120-frame-records.ts.json new file mode 100644 index 000000000..e549e0083 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5120-frame-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5120-frame-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5121-frame-objects.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5121-frame-objects.ts.json new file mode 100644 index 000000000..9966443f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5121-frame-objects.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5121-frame-objects.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5122-frame-result-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5122-frame-result-slots.ts.json new file mode 100644 index 000000000..16835f9ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5122-frame-result-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5122-frame-result-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5123-frame-reassigned.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5123-frame-reassigned.ts.json new file mode 100644 index 000000000..9bbcbc2b9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5123-frame-reassigned.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5123-frame-reassigned.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5124-frame-objects-control.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5124-frame-objects-control.ts.json new file mode 100644 index 000000000..6328b38d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5124-frame-objects-control.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5124-frame-objects-control.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5127-frame-loop-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5127-frame-loop-bindings.ts.json new file mode 100644 index 000000000..de8dd0338 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5127-frame-loop-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5127-frame-loop-bindings.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5120-frame-records.ts b/tests/corpus/5120-frame-records.ts new file mode 100644 index 000000000..c8d3277be --- /dev/null +++ b/tests/corpus/5120-frame-records.ts @@ -0,0 +1,54 @@ +// Number and boolean records that only flow into field reads keep their +// values; records that are stored, returned, captured, compared by +// identity or printed stay observable exactly like Node. +interface Point { + x: number; + y: number; +} +interface Flagged { + on: boolean; + weight: number; +} +function length2(p: Point): number { + return p.x * p.x + p.y * p.y; +} +function dot(a: Point, b: Point): number { + return a.x * b.x + a.y * b.y; +} +function weigh(f: Flagged): number { + return f.on ? f.weight : -f.weight; +} +const stored: Point[] = []; +function keep(p: Point): number { + stored.push(p); + return p.x; +} +function same(p: Point): Point { + return p; +} +function depth(p: Point, n: number): number { + return n === 0 ? length2(p) : depth({ x: p.x + 1, y: p.y - 1 }, n - 1) + p.x; +} + +let total = 0; +const readers: (() => number)[] = []; +for (let i = 0; i < 6; i++) { + const p = { x: i, y: i * 2 }; + const q: Point = { x: 1, y: -1 }; + total += length2(p) + dot(p, q) + dot({ x: 3, y: 4 }, { x: i, y: 1 }); + total += weigh({ on: i % 2 === 0, weight: i }); + if (i % 3 === 0) total += keep(p); + const r = { x: i, y: 0 }; + readers.push(() => r.x * 10); + const s = same({ x: i, y: i }); + stored.push(s); + const a = { x: 1, y: 2 }; + const b = { x: 1, y: 2 }; + if (a === b) total += 1000; + total += depth({ x: i, y: 5 }, 4); +} +console.log(total); +console.log(stored.map((p) => `${p.x}:${p.y}`).join(" ")); +console.log(readers.map((read) => read()).join(" ")); +const shown = { x: 7, y: 8 }; +console.log(shown, JSON.stringify({ x: 1, y: 2 })); diff --git a/tests/corpus/5121-frame-objects.ts b/tests/corpus/5121-frame-objects.ts new file mode 100644 index 000000000..283ee6747 --- /dev/null +++ b/tests/corpus/5121-frame-objects.ts @@ -0,0 +1,86 @@ +// Instances of small classes with number fields that are only read keep +// their values; constructors that register `this`, methods returning +// `this`, subclasses and throwing constructors behave as in Node. +class Vec { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + dot(o: Vec): number { + return this.x * o.x + this.y * o.y; + } + self(): Vec { + return this; + } +} +const registry: Tracked[] = []; +class Tracked { + id: number; + constructor(id: number) { + this.id = id; + registry.push(this); + } + twice(): number { + return this.id * 2; + } +} +class Base { + v: number; + constructor(v: number) { + this.v = v; + } + get(): number { + return this.v; + } +} +class Derived extends Base { + get(): number { + return this.v * 100; + } +} +class Checked { + n: number; + half: number; + constructor(n: number) { + this.n = n; + if (n < 0) throw new RangeError(`negative ${n}`); + this.half = n / 2; + } + sum(): number { + return this.n + this.half; + } +} +class Defaults { + a = 1; + b = 2; + c: number; + constructor(c: number) { + this.c = c + this.a + this.b; + } + total(): number { + return this.a + this.b + this.c; + } +} + +let total = 0; +const selves: Vec[] = []; +for (let i = 0; i < 5; i++) { + const v = new Vec(i, i + 1); + total += v.dot(new Vec(2, 3)) + new Vec(i, i).dot(v); + const t = new Tracked(i); + total += t.twice(); + selves.push(new Vec(i, -i).self()); + const b: Base = i % 2 === 0 ? new Base(i) : new Derived(i); + total += b.get(); + try { + const c = new Checked(i - 2); + total += c.sum(); + } catch (error) { + console.log("caught", (error as Error).message); + } + total += new Defaults(i).total(); +} +console.log(total); +console.log(registry.map((t) => t.id).join(","), selves.map((v) => `${v.x}/${v.y}`).join(",")); diff --git a/tests/corpus/5122-frame-result-slots.ts b/tests/corpus/5122-frame-result-slots.ts new file mode 100644 index 000000000..e9db7636b --- /dev/null +++ b/tests/corpus/5122-frame-result-slots.ts @@ -0,0 +1,57 @@ +// Functions that return fresh number records or objects on every path, +// including early returns in loops and conditional results, give the same +// values whether their result is only read or kept. +interface Pair { + a: number; + b: number; +} +class Money { + cents: number; + constructor(cents: number) { + this.cents = cents; + } + plus(o: Money): Money { + return new Money(this.cents + o.cents); + } + scaled(k: number): Money { + return new Money(Math.round(this.cents * k)); + } +} +function pair(a: number, b: number): Pair { + return { a, b }; +} +function swap(p: Pair): Pair { + return { a: p.b, b: p.a }; +} +function pick(n: number): Pair { + if (n > 4) return { a: n, b: -n }; + for (let i = 0; i < 3; i++) if (i === n) return { a: i, b: i * i }; + return n < 0 ? { a: 0, b: 0 } : { a: 1, b: 1 }; +} +function risky(n: number): Pair { + if (n === 2) throw new Error(`risky ${n}`); + return { a: n, b: n + 0.5 }; +} +function sum(p: Pair): number { + return p.a + p.b; +} +function countdown(n: number): Pair { + return n === 0 ? { a: 0, b: 0 } : { a: sum(countdown(n - 1)) + n, b: n }; +} +const kept: Pair[] = []; +let total = 0; +for (let i = -1; i < 7; i++) { + total += sum(swap(pair(i, 2 * i))) + sum(pick(i)); + const p = pick(i); + total += p.a * 3 + p.b; + kept.push(pick(i)); + try { + total += sum(risky(i)); + } catch (error) { + console.log((error as Error).message); + } + const m = new Money(100 + i).plus(new Money(5)).scaled(1.5); + total += m.cents; + total += sum(countdown(i < 0 ? 0 : i)); +} +console.log(total, kept.map((p) => `${p.a},${p.b}`).join(" ")); diff --git a/tests/corpus/5123-frame-reassigned.ts b/tests/corpus/5123-frame-reassigned.ts new file mode 100644 index 000000000..afc229b79 --- /dev/null +++ b/tests/corpus/5123-frame-reassigned.ts @@ -0,0 +1,72 @@ +// Reassigned bindings of number records and objects: each new value may +// be computed from the old one, aliases keep the object they were given, +// and an assignment that throws leaves the previous value in place. +class Complex { + re: number; + im: number; + constructor(re: number, im: number) { + this.re = re; + this.im = im; + } + times(o: Complex): Complex { + return new Complex(this.re * o.re - this.im * o.im, this.re * o.im + this.im * o.re); + } + plus(o: Complex): Complex { + return new Complex(this.re + o.re, this.im + o.im); + } + swapped(): Complex { + return new Complex(this.im, this.re); + } +} +interface P { + x: number; + y: number; +} +function rotate(p: P): P { + return { x: -p.y, y: p.x }; +} +function fail(p: P, n: number): P { + if (n === 2) throw new Error("fail"); + return { x: p.x + n, y: p.y }; +} + +let inside = 0; +for (let y = -4; y <= 4; y++) + for (let x = -8; x <= 4; x++) { + const c = new Complex(x / 4, y / 4); + let z = new Complex(0, 0); + let n = 0; + while (n < 50 && z.re * z.re + z.im * z.im <= 4) { + z = z.times(z).plus(c); + n++; + } + if (n === 50) inside++; + } +console.log("inside", inside); + +let out = ""; +for (let k = 0; k < 3; k++) { + let z = new Complex(k, 1); + let p: P = { x: 1, y: 2 }; + for (let i = 0; i < 4; i++) { + z = z.times(z).plus(new Complex(0.5, -0.25)); + if (i % 2 === 0) z = z.swapped(); + else p = rotate(p); + p = { x: p.y, y: p.x + i }; + try { + p = fail(p, i); + } catch { + out += "!"; + } + } + let q: P = { x: 0, y: 0 }; + if (k === 1) q = rotate(p); + else if (k === 2) q = { x: 9, y: 9 }; + out += `${z.re.toFixed(3)},${z.im.toFixed(3)};${p.x},${p.y};${q.x},${q.y}|`; +} +console.log(out); + +let w = new Complex(1, 2); +const alias = w; +w = new Complex(3, 4); +console.log(alias.re, w.re); diff --git a/tests/corpus/5124-frame-objects-control.ts b/tests/corpus/5124-frame-objects-control.ts new file mode 100644 index 000000000..d92d90f73 --- /dev/null +++ b/tests/corpus/5124-frame-objects-control.ts @@ -0,0 +1,46 @@ +// Number records and objects across try/catch/finally, labeled loops, +// generators and async functions keep the same values and order of +// effects as in Node. +interface Box { + w: number; + h: number; +} +function area(b: Box): number { + return b.w * b.h; +} +function make(w: number, h: number): Box { + return { w, h }; +} +function guarded(n: number): number { + const b = make(n, n + 1); + try { + if (n === 3) throw new Error("three"); + return area(b); + } catch (error) { + return -area({ w: (error as Error).message.length, h: 1 }); + } finally { + console.log("finally", area(b)); + } +} +function* boxes(n: number): Generator { + for (let i = 0; i < n; i++) { + const b = make(i, 2); + yield area(b); + } +} +async function later(n: number): Promise { + const b = make(n, 3); + await null; + return area(b) + area(make(1, 1)); +} + +let total = 0; +outer: for (let i = 0; i < 5; i++) { + for (let j = 0; j < 5; j++) { + const b = { w: i, h: j }; + if (area(b) > 6) continue outer; + total += area(b) + guarded(j); + } +} +console.log(total, [...boxes(4)].join(",")); +later(4).then((value) => console.log("later", value)); diff --git a/tests/corpus/5127-frame-loop-bindings.ts b/tests/corpus/5127-frame-loop-bindings.ts new file mode 100644 index 000000000..dc7180d10 --- /dev/null +++ b/tests/corpus/5127-frame-loop-bindings.ts @@ -0,0 +1,61 @@ +// Loop bindings of number records and objects that the body reassigns: +// for...of over arrays, holey arrays, Sets, Map values and generators, +// destructured bindings, for-loop headers and for...in bodies all read +// the value most recently assigned, and the iterated values are unchanged. +interface P { x: number; y: number } +class C { v: number; constructor(v: number) { this.v = v; } } +function shift(p: P): P { return { x: p.x + 100, y: p.y }; } +function read(p: P): number { return p.x * 10 + p.y; } +const points: P[] = [{ x: 1, y: 2 }, { x: 3, y: 4 }]; +const objects = [new C(1), new C(2)]; +let out = ""; +for (let p of points) { + out += read(p) + " "; + p = { x: p.x + 10, y: 0 }; + out += read(p) + " "; + p = shift(p); + out += read(p) + ";"; +} +for (let c of objects) { + c = new C(c.v * 7); + out += c.v + ";"; +} +for (let { x, y } of points) { + let q: P = { x, y }; + q = shift(q); + out += read(q) + ";"; +} +for (let i = 0, p: P = { x: 0, y: 0 }; i < 3; i++, p = { x: p.x + i, y: i }) out += read(p) + ","; +for (const key in { a: 1, b: 2 }) { + let r: P = { x: key.length, y: 0 }; + r = shift(r); + out += read(r) + ";"; +} +console.log(out, points.map(read).join(" "), objects.map((c) => c.v).join(" ")); +const set = new Set

([{ x: 1, y: 2 }, { x: 3, y: 4 }]); +const map = new Map([["a", new C(1)], ["b", new C(2)]]); +function* gen(): Generator

{ yield { x: 5, y: 6 }; yield { x: 7, y: 8 }; } +let out2 = ""; +for (let p of set) { + out2 += read(p) + " "; + p = { x: p.x + 10, y: 0 }; + out2 += read(p) + " "; + p = shift(p); + out2 += read(p) + ";"; +} +for (let c of map.values()) { + c = new C(c.v * 7); + out2 += c.v + ";"; +} +for (let p of gen()) { + p = shift(p); + out2 += read(p) + ";"; +} +const holey: P[] = [{ x: 1, y: 1 }]; +holey[3] = { x: 2, y: 2 }; +for (let p of holey) { + if (p === undefined) continue; + p = shift(p); + out2 += read(p) + ";"; +} +console.log(out2, [...set].map(read).join(" "), [...map.values()].map((c) => c.v).join(" "));