Send every dyn kind that can be a cycle candidate through one release path

The inline dyn release sorted a surviving value into three groups by
kind: arrays and objects tested the candidate-buffer word and then called
scr_dyn_release_survivor, capsules and handles called it directly, and
functions and proxies appended to the candidate buffer inline. All six
kinds now take one path: a value already in a candidate buffer, or never
one, returns inline, and scr_dyn_release_survivor, which now also covers
functions and proxies, decides the rest. Behavior is unchanged, and each
inlined release site keeps one kind test and one call.

Effect's executable text is 10% smaller and it runs 5.8% faster; zod
runs 3.8% faster, and bench:runtime's dynamic-properties 14% faster.
This commit is contained in:
Chris Tate
2026-10-25 13:11:47 -05:00
parent 8a22e0f515
commit ffbbf1590f
4 changed files with 67 additions and 41 deletions
@@ -146,11 +146,19 @@ describe("LLVM runtime RC symbols", () => {
// The dyn fast path buffers surviving values by kind, as scr_dyn_release does.
const ir = emitInlineRcHelpers(host).join("\n");
expect(ir).toContain("call void @scr_dyn_release_survivor(ptr %o)");
expect(ir).toMatch(
/switch i32 %kind, label %done \[ i32 4, label %container i32 5, label %container i32 8, label %root/,
// Every kind that can be a candidate takes one path; scalars stay inline.
expect(ir).toContain(
"switch i32 %kind, label %done [ i32 4, label %candidate i32 5, label %candidate i32 8, label %candidate i32 9, label %candidate i32 12, label %candidate i32 13, label %candidate ]",
);
// Arrays and objects not yet buffered ask the runtime, which skips intact documents.
// Values not yet buffered ask the runtime, which decides per value.
expect(ir).toMatch(/br i1 %cqueued, label %done, label %survivor/);
// The runtime buffers dyn candidates, so the dyn helper has no inline append.
const dynHelper = ir.slice(
ir.indexOf("@sc_rc_release_dyn("),
ir.indexOf("\n}", ir.indexOf("@sc_rc_release_dyn(")),
);
expect(dynHelper).not.toContain("sc_cyc_append");
expect(dynHelper).not.toContain("scr_cyc_on_release");
});
test("sanitized builds call the fast-path helpers out of line", () => {
@@ -208,36 +216,42 @@ describe("LLVM runtime RC symbols", () => {
});
test("optimized builds buffer candidates through the module's append helper", () => {
const emit = (inlineRc: true | "release", extra: Partial<ShapeHost>) => {
const emit = (inlineRc: true | "release", extra: Partial<ShapeHost>, kind: "union" | "dyn") => {
const { host } = declarationHost(64, inlineRc);
const configured: ShapeHost = { ...host, inlineAlloc: true, ...extra };
releaseSym(configured, { kind: "dyn" });
releaseSym(configured, kind === "union" ? ({ kind, unionId: "u0" } as never) : { kind });
return {
ir: emitInlineRcHelpers(configured).join("\n"),
decls: inlineRcDecls(configured),
};
};
const { ir, decls } = emit(true, {}, "union");
expect(ir).toContain("call preserve_mostcc void @sc_cyc_append(ptr %o)");
expect(ir).toContain("define internal preserve_mostcc void @sc_cyc_append(ptr %o) noinline");
// The young test reads the generation byte at obj-5; the append fills
// scr_cyc_buffers' nursery buffer and the header's position at obj-4.
expect(ir).toContain("getelementptr i8, ptr %o, i64 -5");
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 96");
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 104");
expect(ir).toContain("load i64, ptr @scr_cyc_live");
expect(ir).toContain("or i32 %i, -2147483648");
expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)");
expect(decls).toContain(
"@scr_cyc_buffers = external global { [3 x { ptr, i64, i64, i64 }], i64, i64 }",
);
expect(decls).toContain("@scr_cyc_live = external global i64");
// The dyn release buffers through scr_dyn_release_survivor, so a module
// whose only headered release is dyn's has no append helper.
for (const mode of [true, "release"] as const) {
const { ir, decls } = emit(mode, {});
expect(ir).toContain("call preserve_mostcc void @sc_cyc_append(ptr %o)");
expect(ir).toContain("define internal preserve_mostcc void @sc_cyc_append(ptr %o) noinline");
// The young test reads the generation byte at obj-5; the append fills
// scr_cyc_buffers' nursery buffer and the header's position at obj-4.
expect(ir).toContain("getelementptr i8, ptr %o, i64 -5");
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 96");
expect(ir).toContain("getelementptr inbounds i8, ptr @scr_cyc_buffers, i64 104");
expect(ir).toContain("load i64, ptr @scr_cyc_live");
expect(ir).toContain("or i32 %i, -2147483648");
expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)");
expect(decls).toContain(
"@scr_cyc_buffers = external global { [3 x { ptr, i64, i64, i64 }], i64, i64 }",
);
expect(decls).toContain("@scr_cyc_live = external global i64");
const dyn = emit(mode, {}, "dyn");
expect(dyn.ir).not.toContain("sc_cyc_append");
expect(dyn.ir).not.toContain("scr_cyc_on_release");
expect(dyn.decls.join("\n")).not.toContain("scr_cyc_buffers");
}
// Worker executables and targets without inline allocation call
// scr_cyc_on_release, which tries the same append first.
for (const extra of [{ threadLocalAlloc: true }, { inlineAlloc: false }]) {
const { ir, decls } = emit(true, extra);
const { ir, decls } = emit(true, extra, "union");
expect(ir).not.toContain("sc_cyc_append");
expect(ir).toContain("call void @scr_cyc_on_release(ptr %o)");
expect(decls.join("\n")).not.toContain("scr_cyc_buffers");
+23 -16
View File
@@ -589,9 +589,10 @@ interface InlineRcFamily {
* repeats its own checks, so the fast path never duplicates it.
*
* The dyn release buffers a surviving value by kind (scr_dyn_release):
* the fast path decides the always and never cases and calls
* scr_dyn_release_survivor for kinds whose candidacy is per value,
* including arrays and objects of intact documents (ScrDynRegion).
* the fast path skips scalars and values already in a candidate buffer or
* never candidates, and calls scr_dyn_release_survivor for the rest, which
* decides per value (intact documents, ScrDynRegion; capsules and handles
* without edges).
*
* The array release inlines the decrement of an array that stays alive
* and is not headered (`a->cyc`): those are most array releases in
@@ -698,9 +699,10 @@ function requestRcHelper(host: ShapeHost, family: string, op: RcOp): string {
for (const decl of rcHelperDecls(family, op)) host.declare(decl);
// Its body is written last (emitInlineRcHelpers), after the requests are
// collected: a release that can buffer a candidate requests the append
// helper now. The jsval row (JS_RC_ROW) has no collector header; its
// cells release through dyn's helper, which makes its own request.
if (op === "release" && row.cycle === "always") requestCycleAppend(host);
// helper now. The dyn release buffers through scr_dyn_release_survivor
// instead. The jsval row (JS_RC_ROW) has no collector header; its cells
// release through dyn's helper.
if (op === "release" && row.cycle === "always" && family !== "dyn") requestCycleAppend(host);
return `@${inlineRcName(family, op)}`;
}
@@ -820,8 +822,11 @@ function rcHelperDecls(family: string, op: RcOp): string[] {
if (op === "retain" || row.release === null) return [];
return [
`declare void @${row.release}(ptr)`,
...(row.cycle === "always" ? [`declare void @scr_cyc_on_release(ptr)`] : []),
...(family === "dyn" ? [`declare void @scr_dyn_release_survivor(ptr)`] : []),
...(family === "dyn"
? [`declare void @scr_dyn_release_survivor(ptr)`]
: row.cycle === "always"
? [`declare void @scr_cyc_on_release(ptr)`]
: []),
];
}
@@ -931,21 +936,21 @@ function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): st
// scr_dyn_release buffers a surviving value by kind: always for
// functions and proxies, per value for arrays and objects (not
// while their document is intact, ScrDynRegion), capsules and
// handles (scr_dyn_release_survivor), never for scalars. An
// array or object already in a candidate buffer stays inline.
// handles, never for scalars. Every kind that can be buffered
// takes one path, which keeps each inlined site small: a value
// already in a candidate buffer (or never one) stays inline, and
// scr_dyn_release_survivor decides the rest.
` %kp = getelementptr i8, ptr %o, ${S} ${S === "i64" ? 8 : 4} ; ScrDyn.kind`,
` %kind = load i32, ptr %kp`,
// SCR_DYN_ARR 4, OBJ 5, FUNC 8, PROXY 13; HANDLE 9, TYPED_REF 12.
` switch i32 %kind, label %done [ i32 4, label %container i32 5, label %container i32 8, label %root i32 13, label %root i32 9, label %survivor i32 12, label %survivor ]`,
`container:`,
// SCR_DYN_ARR 4, OBJ 5, FUNC 8, HANDLE 9, TYPED_REF 12, PROXY 13.
` switch i32 %kind, label %done [ i32 4, label %candidate i32 5, label %candidate i32 8, label %candidate i32 9, label %candidate i32 12, label %candidate i32 13, label %candidate ]`,
`candidate:`,
` %cbufp = getelementptr i8, ptr %o, ${S} -${host.cycleBufOffset}`,
` %cbuf = load i32, ptr %cbufp`,
` %cqueued = icmp slt i32 %cbuf, 0`,
` br i1 %cqueued, label %done, label %survivor`,
`survivor:`,
` call void @scr_dyn_release_survivor(ptr %o)`,
` br label %done`,
`root:`,
]
: family === "box"
? [
@@ -964,7 +969,9 @@ function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): st
`root:`,
]
: []),
...(cycle === "always" ? cycleRootLines(host, "done") : [` br label %done`]),
...(cycle === "always" && family !== "dyn"
? cycleRootLines(host, "done")
: [` br label %done`]),
`done:`,
` ret void`,
`}`,
+2 -1
View File
@@ -1941,7 +1941,8 @@ extern void scr_dyn_listener_fire_data(ScrClosure *cb, ScrBytes *chunk);
extern void scr_dyn_listener_fire_err(ScrClosure *cb, ScrStr *msg);
void *scr_dyn_retain_v(void *d);
/* After a compiled fast path decremented a surviving capsule or handle. */
/* After a compiled fast path decremented a surviving value of a kind that
* can be a cycle candidate and is not one yet (scr_json.c). */
void scr_dyn_release_survivor(ScrDyn *d);
void scr_dyn_release_v(void *d);
void scr_dyn_trace_v(void *d, ScrTraceVisit visit, void *ctx);
+7 -3
View File
@@ -1317,9 +1317,9 @@ static void scr_dyn_gcfree(void *ptr) { scr_dyn_dispose(ptr, true); }
static void scr_dyn_destroy(void *ptr) { scr_dyn_dispose(ptr, false); }
/* The cycle bookkeeping of a value that survives a release. Compiled
* release fast paths decrement first and call this for the kinds whose
* candidacy depends on per-value state, matching scr_dyn_release: arrays
* and objects not yet buffered (skipped while their document is intact,
* release fast paths decrement first and call this for every kind that can
* be a candidate and is not one yet, matching scr_dyn_release: functions
* and proxies, arrays and objects (skipped while their document is intact,
* ScrDynRegion), capsules and handles. */
void scr_dyn_release_survivor(ScrDyn *d) {
if (d->kind == SCR_DYN_ARR || d->kind == SCR_DYN_OBJ) {
@@ -1327,6 +1327,10 @@ void scr_dyn_release_survivor(ScrDyn *d) {
if (!region || !region->intact) scr_cyc_released(d);
return;
}
if (d->kind == SCR_DYN_FUNC || d->kind == SCR_DYN_PROXY) {
scr_cyc_released(d);
return;
}
if ((d->kind == SCR_DYN_TYPED_REF &&
(d->v.typed_ref.materialized || d->v.typed_ref.casts || d->v.typed_ref.traced ||
d->prototype || d->symbol_properties || d->symbol_keys)) ||