From d4a7b82dd80cff07a88eb3b2cf7cba38264eeb18 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 19:22:55 -0500 Subject: [PATCH] Free arrays whose elements need no release without a teardown When the last reference to an array dropped, the runtime ran the generic teardown: scr_rc_destroy's depth bookkeeping, an indirect call to the array destroy, and a loop over the element slots. Arrays of numbers, booleans and compact ProgramLifetime references release nothing, and neither do arrays of other ProgramLifetime instances, whose release does nothing. - scr_arr_release frees an acyclic array without an ext directly when its elements are numbers, booleans or compact references, or its descriptor has SCR_ARR_OPS_RELEASE_NOTHING. Every other array keeps the full teardown. - The backend sets SCR_ARR_OPS_RELEASE_NOTHING in the descriptor's flags word on 64-bit targets for ProgramLifetime element types that are not compact, plain or as the pointer arm of a nullable union, in programs without lifetime regions. Registration copies it into a table indexed by the descriptor's registry id before the id is published. Descriptors without the flag read 0 and keep the full teardown. Corpus 7401 drops arrays of ProgramLifetime instances, numbers and booleans through every array operation, with holes and far indices, next to arrays of ordinary objects, and matches Node. --- .../compiler/src/backend/llvm/classes.test.ts | 37 +++++++ packages/compiler/src/backend/llvm/emitter.ts | 5 + packages/compiler/src/backend/llvm/shapes.ts | 19 +++- ...01-program-lifetime-array-teardown.ts.json | 6 ++ packages/runtime/src/scr_array.c | 42 ++++++-- packages/runtime/src/scr_array.h | 19 ++++ packages/runtime/test/test_array.c | 39 +++++++ .../7401-program-lifetime-array-teardown.ts | 102 ++++++++++++++++++ ...01-program-lifetime-array-teardown.ts.json | 1 + 9 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7401-program-lifetime-array-teardown.ts.json create mode 100644 tests/corpus/7401-program-lifetime-array-teardown.ts create mode 100644 tests/harness/corpus-costs/7401-program-lifetime-array-teardown.ts.json diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts index f921900a3..bee954afb 100644 --- a/packages/compiler/src/backend/llvm/classes.test.ts +++ b/packages/compiler/src/backend/llvm/classes.test.ts @@ -168,6 +168,43 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts", expect(body(mangleClassRelease("Owner"))).toContain("load i32, ptr %bufp"); }); +test("arrays of ProgramLifetime instances tell the runtime their teardown releases nothing", () => { + const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } }; + const owners = { kind: "array" as const, elem: { kind: "object" as const, className: "Owner" } }; + const mod: IrModule = { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [ + { + name: "main", + params: [], + returnType: VOID, + locals: [], + body: [ + { kind: "exprStmt", expr: { kind: "arrayLit", elems: [], type: kids, loc }, loc }, + { kind: "exprStmt", expr: { kind: "arrayLit", elems: [], type: owners, loc }, loc }, + ], + loc, + }, + ], + classes: [ + { name: "Node", fields: [], methods: [], loc }, + { name: "Owner", fields: [{ name: "nodes", type: kids }], methods: [], loc }, + ], + contracts: { programLifetime: ["Node"] }, + }; + const llvm = emitLlvmModule(mod, { pointerBits: 64 }); + const ops = (cls: string): string => + new RegExp(`^@sc_arrops\\.[^\\n]*${mangleClassRelease(cls)}[^\\n]*$`, "m").exec(llvm)?.[0] ?? + ""; + // SCR_ARR_OPS_RELEASE_NOTHING for Node; Owner's elements are released. + expect(ops("Node")).toMatch( + /= internal global \{ ptr, ptr, ptr, i32, i32 \} .*, i32 0, i32 2 \}$/, + ); + expect(ops("Owner")).toMatch(/= internal global %ScrArrOps .*, i32 0 \}$/); +}); + test("classes whose trace visits nothing start acyclic in a traced hierarchy", () => { const mapper = { kind: "object" as const, className: "Mapper" }; const mod: IrModule = { diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ef512dddd..8542963e0 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1591,6 +1591,11 @@ export class LlEmitter { dynPromiseAdapter: (type) => this.dynPromiseAdapter(type), isErrorClass: (name) => this.classMeta.get(name)?.root.def.name === "%Error", classRootOf: (name) => this.classMeta.get(name)?.root.def.name, + releasesNothing: (t) => { + if (t.kind !== "object" || (this.shapeHost.regions ?? null) !== null) return false; + const meta = this.classMeta.get(t.className); + return meta !== undefined && programLifetimeClass(this.shapeHost, meta); + }, classSubtypes: (name) => { const target = this.classMetaOf(name); const intervals = classMembershipIntervals(this.classMeta, target.def.name); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 8ae24375e..d13f80a78 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -78,6 +78,10 @@ export interface ShapeHost { /** The hierarchy root of a class (a program class or a runtime base), * or undefined for classes outside the class graph. */ classRootOf?(className: string): string | undefined; + /** Whether releasing a value of this type does nothing: instances of a + * program-lifetime class without lifetime regions (classes.ts + * programLifetimeClass). */ + releasesNothing?(t: IrType): boolean; /** Whether arrays of `elem` hold 32-bit references to compact class * instances (SCR_ELEM_CREF; LlEmitter.compactArrayElem). */ compactArrayElem?(elem: IrType): boolean; @@ -1117,7 +1121,9 @@ export function arrNewCall( * writable: the runtime stores its registry id on the first array. Its tail * padding is the runtime's flags word (0); arrays of compact class * references (ShapeHost.compactArrayElem) spell the word out as - * SCR_ARR_OPS_COMPACT. */ + * SCR_ARR_OPS_COMPACT, and on 64-bit targets arrays whose element release + * does nothing (ShapeHost.releasesNothing) as SCR_ARR_OPS_RELEASE_NOTHING, + * so that destroying them only frees their storage. */ export function arrOps(host: ShapeHost, elem: IrType): string { const v = vAdapters(host, elem); const trace = traceArg(host, elem); @@ -1129,14 +1135,23 @@ export function arrOps(host: ShapeHost, elem: IrType): string { ); return `${name}.c`; } + const nullable = host.nullableUnions.of(elem); + const payload = nullable?.repr === "single" ? nullable.arm : elem; + if (host.sizeType === "i64" && host.releasesNothing?.(payload) === true) { + host.declare( + `${name}.n = internal global { ptr, ptr, ptr, i32, i32 } { ptr ${v.retain}, ptr ${v.release}, ptr ${trace}, i32 0, i32 ${SCR_ARR_OPS_RELEASE_NOTHING} }`, + ); + return `${name}.n`; + } host.declare( `${name} = internal global %ScrArrOps { ptr ${v.retain}, ptr ${v.release}, ptr ${trace}, i32 0 }`, ); return name; } -/** scr_array.h SCR_ARR_OPS_COMPACT. */ +/** scr_array.h SCR_ARR_OPS_COMPACT and SCR_ARR_OPS_RELEASE_NOTHING. */ const SCR_ARR_OPS_COMPACT = 1; +const SCR_ARR_OPS_RELEASE_NOTHING = 2; /* ── capture boxes ────────────────────────────────────────────────────── */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7401-program-lifetime-array-teardown.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7401-program-lifetime-array-teardown.ts.json new file mode 100644 index 000000000..4341cdbdc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7401-program-lifetime-array-teardown.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7401-program-lifetime-array-teardown.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 3d6b918e9..f3e216a36 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -108,6 +108,7 @@ ScrArrExt *scr_arr_ext(ScrArr *a) { /* Registry of element ops by id (scr_arr_ops); slot 0 is no ops. */ const ScrArrOps *scr_arr_ops_table[SCR_ARR_OPS_OVERFLOW] = {0}; +uint8_t scr_arr_ops_release_nothing[SCR_ARR_OPS_OVERFLOW + 1] = {0}; static uint32_t scr_arr_ops_count = 1; /* Assign `ops` its id on its first array; any thread may get there first, @@ -119,6 +120,9 @@ uint32_t scr_arr_ops_register(ScrArrOps *ops) { if (id) return id; uint32_t fresh = __atomic_fetch_add(&scr_arr_ops_count, 1, __ATOMIC_RELAXED); if (fresh < SCR_ARR_OPS_OVERFLOW) { + /* Before the id is published: an array that carries the id can be + * released on any thread. */ + scr_arr_ops_release_nothing[fresh] = scr_arr_ops_release_nothing_flag(ops); __atomic_store_n(&scr_arr_ops_table[fresh], ops, __ATOMIC_RELEASE); } else { fresh = SCR_ARR_OPS_OVERFLOW; @@ -1530,6 +1534,16 @@ ScrArr *scr_arr_new_ops(const ScrArrOps *ops, size_t initial_cap) { ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops) { return scr_arr_make_ops(ops, 0, true); } +/* Frees an acyclic array whose elements are already released. */ +static inline __attribute__((always_inline)) void scr_arr_free(ScrArr *a) { + scr_arr_free_parts(a); +#ifdef SCR_RC_AUDIT + scr_live_arrays--; +#endif + scr_weak_dispose_inline(a); + scr_mem_free(a); +} + static void scr_arr_destroy(void *object) { ScrArr *a = object; if (scr_arr_ext_of(a)) { @@ -1597,21 +1611,29 @@ static void scr_arr_destroy(void *object) { } } } - if (a->cyc) { - scr_arr_gc_free(a); - } else { - scr_arr_free_parts(a); -#ifdef SCR_RC_AUDIT - scr_live_arrays--; -#endif - scr_weak_dispose_inline(a); - scr_mem_free(a); - } + if (a->cyc) scr_arr_gc_free(a); + else scr_arr_free(a); +} + +/* Whether destroying `a`, an acyclic array, releases nothing: it has no ext + * and its elements are numbers, booleans or compact references, or + * references whose release does nothing (SCR_ARR_OPS_RELEASE_NOTHING). */ +static inline bool scr_arr_releases_nothing(const ScrArr *a) { + if (a->spare & SCR_ARR_HAS_EXT) return false; + if (!scr_elem_is_ref(a->elem)) return true; + return a->elem == SCR_ELEM_REF && + scr_arr_ops_release_nothing[(uint32_t)a->spare >> SCR_ARR_OPS_SHIFT]; } void scr_arr_release(ScrArr *a) { if (!a || a->rc == SIZE_MAX) return; /* NULL: an uninitialized `let` local */ if (--a->rc == 0) { + /* Nothing to tear down: free the storage without scr_rc_destroy's + * queue (no release can recurse) or the element loop. */ + if (!a->cyc && scr_arr_releases_nothing(a)) { + scr_arr_free(a); + return; + } if (a->cyc) scr_cyc_dead(a); scr_rc_destroy(a, scr_arr_destroy); } else if (a->cyc) { diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 689eb63ee..e13acb233 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -89,6 +89,22 @@ static inline bool scr_arr_ops_compact(const ScrArrOps *ops) { #endif } +/* `release` does nothing: the element type's instances live until the + * process exits (llvm/shapes.ts arrOps). Destroying such an array frees only + * its storage. */ +#define SCR_ARR_OPS_RELEASE_NOTHING 2u + +/* Whether `ops` has SCR_ARR_OPS_RELEASE_NOTHING. 32-bit descriptors end + * before the flags word, so they never have it. */ +static inline bool scr_arr_ops_release_nothing_flag(const ScrArrOps *ops) { +#if UINTPTR_MAX > 0xffffffffu + return (ops->flags & SCR_ARR_OPS_RELEASE_NOTHING) != 0; +#else + (void)ops; + return false; +#endif +} + typedef struct ScrArr { size_t rc; /* SIZE_MAX = immortal (unused for arrays; kept per convention) */ /* Length and dense capacity are at most SCR_ARR_MAX_LENGTH (2^32 - 1); @@ -216,6 +232,9 @@ static inline struct ScrArrExt *scr_arr_ext_of(const ScrArr *a) { #define SCR_ARR_OPS_SHIFT 5 #define SCR_ARR_OPS_OVERFLOW (UINT16_MAX >> SCR_ARR_OPS_SHIFT) extern const ScrArrOps *scr_arr_ops_table[SCR_ARR_OPS_OVERFLOW]; +/* By registry id: 1 when the descriptor has SCR_ARR_OPS_RELEASE_NOTHING. + * Written before the id is published; the overflow id stays 0. */ +extern uint8_t scr_arr_ops_release_nothing[SCR_ARR_OPS_OVERFLOW + 1]; /* Assigns the id on a descriptor's first array; out of line, so the id * load in scr_arr_ops_id stays the only cost on later arrays. */ __attribute__((cold, noinline)) uint32_t scr_arr_ops_register(ScrArrOps *ops); diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 6e4ead89a..769b8ef3a 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -446,6 +446,44 @@ static void test_ref_elements(void) { #endif } +/* Elements whose release does nothing (SCR_ARR_OPS_RELEASE_NOTHING): the + * last release of an array frees its storage without calling the element + * release. An array with out-of-line storage keeps the full teardown. */ +static int nothing_releases = 0; +static void *nothing_retain(void *p) { return p; } +static void nothing_release(void *p) { + (void)p; + nothing_releases++; +} +static ScrArrOps nothing_ops = { ¬hing_retain, ¬hing_release, NULL, 0, + SCR_ARR_OPS_RELEASE_NOTHING }; + +static void test_release_nothing_ops(void) { +#ifdef SCR_RC_AUDIT + long arrays0 = scr_arr_live_count(); +#endif + static MockRec kept[3]; + ScrArr *a = scr_arr_new_ops(¬hing_ops, 0); + for (int i = 0; i < 3; i++) scr_arr_push_ref(a, &kept[i]); + ScrArr *shared = scr_arr_retain(a); + scr_arr_release(shared); + check_f64(scr_arr_len(a), 3, "release-nothing: a shared release keeps the array"); + scr_arr_release(a); + check(nothing_releases == 0, "release-nothing: teardown calls no element release"); + ScrArr *sparse = scr_arr_new_ops(¬hing_ops, 0); + scr_arr_push_ref(sparse, &kept[0]); + scr_arr_set_ref(sparse, 4000000000.0, &kept[1]); /* out-of-line sparse slot */ + check(scr_arr_ext_of(sparse) != NULL, "release-nothing: a far index makes an ext"); + scr_arr_release(sparse); + check(nothing_releases == 2, "release-nothing: an array with an ext keeps the full teardown"); + ScrArr *nums = scr_arr_new(SCR_ELEM_F64, 0); + scr_arr_push_f64(nums, 1); + scr_arr_release(nums); +#ifdef SCR_RC_AUDIT + check(scr_arr_live_count() == arrays0, "release-nothing: no arrays leaked"); +#endif +} + /* Element ops past the registry's capacity: every descriptor still finds its * own retain and release, and the first ones keep their registry ids. */ static void test_ops_overflow(void) { @@ -2419,6 +2457,7 @@ int main(int argc, char **argv) { test_nested_rc(); test_index_of_includes(); test_ref_elements(); + test_release_nothing_ops(); test_ref_known_count_cycle(&mock_cyc_ops); test_ops_overflow(); test_ref_known_count_cycle(&mock_cyc_ops_late); diff --git a/tests/corpus/7401-program-lifetime-array-teardown.ts b/tests/corpus/7401-program-lifetime-array-teardown.ts new file mode 100644 index 000000000..64e23118a --- /dev/null +++ b/tests/corpus/7401-program-lifetime-array-teardown.ts @@ -0,0 +1,102 @@ +import type { ProgramLifetime } from "@scriptc/types"; + +// Arrays whose elements need no release when the array is destroyed: +// ProgramLifetime instances (plain and `| undefined` elements), numbers and +// booleans. They are created and dropped by literals, push, slice, filter, +// map, concat, splice, sort and spread, kept in fields, closures and maps, +// nested in other arrays, given holes and far indices, and +// mixed with arrays of ordinary objects, whose elements are released. + +class Node implements ProgramLifetime { + readonly id: number; + next: Node | undefined = undefined; + readonly kids: Node[] = []; + constructor(id: number) { + this.id = id; + } +} + +class Leaf extends Node { + weight = 2; +} + +class Temp { + readonly label: string; + constructor(label: string) { + this.label = label; + } +} + +const nodes: Node[] = []; +for (let i = 0; i < 40; i++) nodes.push(i % 3 === 0 ? new Leaf(i) : new Node(i)); +for (let i = 1; i < nodes.length; i++) nodes[i - 1]!.next = nodes[i]; +for (const n of nodes) for (let k = 1; k <= n.id % 4; k++) n.kids.push(nodes[(n.id + k) % 40]!); + +function ids(list: readonly (Node | undefined)[]): string { + return list.map((n) => (n === undefined ? "-" : String(n.id))).join(","); +} + +// Temporary arrays from every array operation, dropped at once. +let total = 0; +for (let round = 0; round < 200; round++) { + const picked = nodes.filter((n) => n.id % 7 === round % 7); + const mapped = picked.map((n) => n.next); + const joined = picked.concat(nodes.slice(round % 10, (round % 10) + 3)); + const spliced = joined.slice(); + spliced.splice(1, 2, nodes[round % 40]!); + const sorted = [...spliced].sort((a, b) => b.id - a.id); + const optional: (Node | undefined)[] = [nodes[round % 40], undefined, mapped[0]]; + total += picked.length + mapped.length + joined.length + sorted.length + optional.length; + if (round % 50 === 0) console.log(round, ids(sorted), ids(optional), ids(mapped)); +} +console.log("temporaries", total); + +// Numbers and booleans. +let sum = 0; +for (let round = 0; round < 100; round++) { + const values = [round, round * 2, round * 3]; + const flags = values.map((v) => v % 2 === 0); + sum += values.reduce((a, b) => a + b, 0) + flags.filter((f) => f).length; +} +console.log("scalars", sum); + +// Holes and far indices keep the full teardown. +const holey: Node[] = []; +holey[3] = nodes[3]!; +holey[0] = nodes[0]!; +const far: Node[] = [nodes[1]!]; +far[4_000_000_000] = nodes[2]!; +console.log("holey", holey.length, 1 in holey, ids([holey[0], holey[3]])); +console.log("far", far.length, far[4_000_000_000]?.id, far[0]?.id); + +// Kept arrays: a field, a closure, a map, an array of arrays. +class Holder { + items: Node[] = []; +} +const holder = new Holder(); +for (let i = 0; i < 5; i++) holder.items = nodes.slice(i, i + 4); +const later = ((kept: Node[]) => () => ids(kept))(nodes.filter((n) => n instanceof Leaf)); +const byKey = new Map(); +for (let i = 0; i < 10; i++) byKey.set(`k${i % 3}`, nodes.slice(i, i + 2)); +const nested: Node[][] = []; +for (let i = 0; i < 6; i++) nested.push(nodes.slice(i, i + i)); +nested.splice(2, 3); +console.log("field", ids(holder.items)); +console.log("closure", later()); +console.log("map", [...byKey].map(([k, v]) => `${k}:${ids(v)}`).join(" ")); +console.log("nested", nested.map((list) => ids(list)).join(" | ")); + +// Arrays of ordinary objects still release their elements. +let labels = ""; +for (let round = 0; round < 50; round++) { + const temps = [new Temp(`t${round}`), new Temp(`u${round}`)]; + const kept = temps.filter((t) => t.label.endsWith("0")); + labels += kept.map((t) => t.label).join(""); +} +console.log("objects", labels); + +// Kids lists reached through the graph. +let walked = 0; +for (let at: Node | undefined = nodes[0]; at !== undefined; at = at.next) + walked += at.kids.filter((k) => k.id > at!.id).length; +console.log("walked", walked, nodes[39]!.kids.length, (nodes[3] as Leaf).weight); diff --git a/tests/harness/corpus-costs/7401-program-lifetime-array-teardown.ts.json b/tests/harness/corpus-costs/7401-program-lifetime-array-teardown.ts.json new file mode 100644 index 000000000..2741c4d83 --- /dev/null +++ b/tests/harness/corpus-costs/7401-program-lifetime-array-teardown.ts.json @@ -0,0 +1 @@ +{ "plain": 3103, "san": 4147 }