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.
This commit is contained in:
Chris Tate
2026-10-22 19:22:55 -05:00
parent 42d7d9a0c2
commit d4a7b82dd8
9 changed files with 258 additions and 12 deletions
@@ -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 = {
@@ -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);
+17 -2
View File
@@ -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 ────────────────────────────────────────────────────── */
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7401-program-lifetime-array-teardown.ts"
],
"diags": []
}
+32 -10
View File
@@ -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) {
+19
View File
@@ -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);
+39
View File
@@ -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 = { &nothing_retain, &nothing_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(&nothing_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(&nothing_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);
@@ -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<string, Node[]>();
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);
@@ -0,0 +1 @@
{ "plain": 3103, "san": 4147 }