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 }