mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-26 06:34:20 +08:00
Keep record types acyclic when only result-slot stores write their fields
The backend gives every function that returns a complete record literal a result-slot entry, `%into.<fn>(...params, dst)`, which stores the literal's fields into caller frame storage. The cycle analysis counted those stores as writes after construction, so a record type that refers to itself and is never written after construction carried the cycle collector's header, its live count and its candidate checks in release builds, even when nothing called the entry. Dev builds, which add no entries, left it acyclic. The destination is frame storage of a caller that only projects the value (stack-objects.ts): no object can point to it, so its fields cannot lie on a cycle. computeCycleMutability now skips those stores, under the same condition emit-statements.ts already uses to emit them as initializations (resultSlotStore in the new result-slots.ts). Every other store still makes its field mutable. The same facts mark such fields as never replaced, so reads of them borrow instead of retaining. A program that rebuilds a tree of 2^21 - 1 object literals 12 times runs in 1.19 s instead of 1.35 s and peaks at 68 MB instead of 84 MB: each node is 32 bytes instead of 40.
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
} from "../ir/ir.js";
|
||||
import { computeTraced, explainCycle } from "./cycle-analysis.js";
|
||||
import { computeTraced as llvmTraced } from "./llvm/shapes.js";
|
||||
import { addRecordResultEntries } from "./llvm/record-results.js";
|
||||
|
||||
const loc = { file: "cycles.ts", start: 0, end: 1 };
|
||||
function module(): IrModule {
|
||||
@@ -404,6 +405,61 @@ test("a later write through any alias makes the edge mutable", () => {
|
||||
checkExact(record, ["record:list"]);
|
||||
});
|
||||
|
||||
test("a result-slot entry's stores into its destination are not writes", () => {
|
||||
const next: IrType = { kind: "union", unionId: "next" };
|
||||
const listModule = (): IrModule => {
|
||||
const mod = module();
|
||||
mod.records = [shape("list", [next, F64])];
|
||||
mod.unions = [{ id: "next", arms: [ref("list"), { kind: "nullT" }] }];
|
||||
mod.functions.push({
|
||||
name: "cell",
|
||||
params: [{ localId: "rest", name: "rest", type: next }],
|
||||
locals: [],
|
||||
returnType: ref("list"),
|
||||
body: [
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "recordLit",
|
||||
fields: [
|
||||
{ name: "field0", value: { kind: "varRef", localId: "rest", type: next, loc } },
|
||||
{ name: "field1", value: { kind: "numLit", value: 1, type: F64, loc } },
|
||||
],
|
||||
type: ref("list"),
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
],
|
||||
loc,
|
||||
});
|
||||
return addRecordResultEntries(mod);
|
||||
};
|
||||
// The entry stores the literal's fields into caller frame storage that
|
||||
// nothing can point to: the list stays acyclic.
|
||||
const entries = listModule();
|
||||
expect(entries.functions.map((fn) => fn.name)).toContain("%into.cell");
|
||||
checkExact(entries, []);
|
||||
// The same store into the destination's local id outside an entry, and a
|
||||
// store into any other binding of the shape inside one, are writes.
|
||||
const store = (localId: string): IrStmt => ({
|
||||
kind: "recordSet",
|
||||
obj: { kind: "varRef", localId, type: ref("list"), loc },
|
||||
shapeId: "list",
|
||||
field: "field0",
|
||||
value: value(next),
|
||||
loc,
|
||||
});
|
||||
for (const [fn, localId] of [
|
||||
["main", "%into.dst"],
|
||||
["%into.cell", "v"],
|
||||
] as const) {
|
||||
const written = listModule();
|
||||
written.functions.find((f) => f.name === fn)!.body.unshift(store(localId));
|
||||
checkExact(written, ["record:list"], ["next"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("field increments count as writes", () => {
|
||||
const mod = tree();
|
||||
mod.functions[0]!.body.push({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
|
||||
import { RUNTIME_EMITTER_CLASS, RUNTIME_STREAM_CLASSES, typeKey } from "../ir/ir.js";
|
||||
import { dynamicFieldStores, type DynamicFieldStores } from "../ir/int32-slots.js";
|
||||
import { everyExprChild, everyStmtChild, everyTypeChild } from "../ir/traverse.js";
|
||||
import { resultSlotStore } from "./result-slots.js";
|
||||
|
||||
/** Which shape fields can be written after their object escapes.
|
||||
*
|
||||
@@ -17,6 +18,10 @@ import { everyExprChild, everyStmtChild, everyTypeChild } from "../ir/traverse.j
|
||||
* - every `fieldSet` outside a constructor's own `this`, `fieldIncDec`, and
|
||||
* `recordSet` makes its (unit, field) mutable; `recordKeySet` on declared
|
||||
* fields and `recordKeyDelete` make the whole record mutable;
|
||||
* - a result-slot entry's stores into its destination (result-slots.ts) are
|
||||
* not writes: the destination is frame storage of a caller that only
|
||||
* projects the value, so no object can point to it and its fields cannot
|
||||
* lie on a cycle;
|
||||
* - a constructor's stores into its own `this` are construction-only up to
|
||||
* the first top-level statement in which `this` escapes (any use other
|
||||
* than a field store target, a field read, or the receiver of the direct
|
||||
@@ -296,7 +301,8 @@ export function computeCycleMutability(
|
||||
result.fields.add(fieldKey(unitOfClass(s.className), s.field));
|
||||
break;
|
||||
case "recordSet":
|
||||
result.fields.add(fieldKey(`record:${s.shapeId}`, s.field));
|
||||
if (!resultSlotStore(fn.name, s))
|
||||
result.fields.add(fieldKey(`record:${s.shapeId}`, s.field));
|
||||
break;
|
||||
case "recordKeySet":
|
||||
if (s.overflowOnly !== true) result.units.add(`record:${s.shapeId}`);
|
||||
|
||||
@@ -37,7 +37,7 @@ import { InternalCompilerError } from "../../errors.js";
|
||||
import { emitDenseArrayGet, emitDenseArrayPop, emitDenseArraySet } from "./dense-array-access.js";
|
||||
import { emitBorrowedInput } from "./borrowed-inputs.js";
|
||||
import { f64Lit, llvmCommentText, loadArrCount, sizeToDouble } from "./common.js";
|
||||
import { RESULT_DST_ID, RESULT_ENTRY_PREFIX } from "./record-results.js";
|
||||
import { resultSlotStore } from "../result-slots.js";
|
||||
import {
|
||||
matchIntegerArrayForLoop,
|
||||
matchIntegerBytesForLoop,
|
||||
@@ -573,13 +573,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
|
||||
? emitter.classFieldPtr(obj.name, s.className, s.field, "compressed")
|
||||
: emitter.recordFieldPtr(obj.name, s.shapeId, s.field);
|
||||
// A result-slot entry fills zeroed caller storage, each field once.
|
||||
if (
|
||||
s.kind === "recordSet" &&
|
||||
s.obj.kind === "varRef" &&
|
||||
s.obj.localId === RESULT_DST_ID &&
|
||||
emitter.currentFunctionName.startsWith(RESULT_ENTRY_PREFIX)
|
||||
)
|
||||
emitter.initField(ptr, type, inline, v);
|
||||
if (resultSlotStore(emitter.currentFunctionName, s)) emitter.initField(ptr, type, inline, v);
|
||||
else emitter.replaceField(ptr, type, inline, v);
|
||||
if (
|
||||
s.kind === "fieldSet" &&
|
||||
|
||||
@@ -12,20 +12,9 @@ import {
|
||||
import { dynamicFieldStores } from "../../ir/int32-slots.js";
|
||||
import { everyExpr, everyStmtList, transformStmtList } from "../../ir/traverse.js";
|
||||
import { InlineUnionFields } from "./inline-unions.js";
|
||||
import { RESULT_DST_ID, resultEntryName } from "../result-slots.js";
|
||||
|
||||
/** Prefix of a function's result-slot entry: `%into.<name>(...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}`;
|
||||
}
|
||||
|
||||
/** The local id of a result-slot entry's destination parameter. The
|
||||
* caller passes zeroed frame storage, and on every path the entry stores
|
||||
* each of its fields at most once, so a store into it is an
|
||||
* initialization: there is no old value to release. */
|
||||
export const RESULT_DST_ID = "%into.dst";
|
||||
export { RESULT_DST_ID, RESULT_ENTRY_PREFIX, resultEntryName } from "../result-slots.js";
|
||||
|
||||
function distinctNames(names: readonly string[]): boolean {
|
||||
return new Set(names).size === names.length;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IrStmt } from "../ir/ir.js";
|
||||
|
||||
/** Prefix of a function's result-slot entry: `%into.<name>(...params, dst)`
|
||||
* writes the function's fresh result object into `dst` and returns nothing
|
||||
* (llvm/record-results.ts). */
|
||||
export const RESULT_ENTRY_PREFIX = "%into.";
|
||||
|
||||
export function resultEntryName(name: string): string {
|
||||
return `${RESULT_ENTRY_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
/** The local id of a result-slot entry's destination parameter. The
|
||||
* caller passes zeroed frame storage, and on every path the entry stores
|
||||
* each of its fields at most once, so a store into it is an
|
||||
* initialization: there is no old value to release. */
|
||||
export const RESULT_DST_ID = "%into.dst";
|
||||
|
||||
/** Whether `s`, a statement of the function `fnName`, is a result-slot
|
||||
* entry's store into its destination. The destination is frame storage of
|
||||
* a caller that only projects the value (llvm/stack-objects.ts), so the
|
||||
* store initializes a field of an object no other object can point to. */
|
||||
export function resultSlotStore(fnName: string, s: IrStmt): boolean {
|
||||
return (
|
||||
s.kind === "recordSet" &&
|
||||
s.obj.kind === "varRef" &&
|
||||
s.obj.localId === RESULT_DST_ID &&
|
||||
fnName.startsWith(RESULT_ENTRY_PREFIX)
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/7261-acyclic-result-builders.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Records built by functions that return object literals, as tree and list
|
||||
// builders are written. The backend gives such a function a result-slot
|
||||
// entry that writes the literal's fields into a caller's frame storage, and
|
||||
// those stores do not make the record's fields mutable: a type no store
|
||||
// changes after construction stays acyclic and needs no cycle-collector
|
||||
// header. This pins what such programs print: rebuilt trees, a shared
|
||||
// subtree (a DAG), a builder whose result the caller only projects (so the
|
||||
// entry runs), a list longer than the runtime's teardown depth budget, and
|
||||
// a record type the program writes after construction, whose rings only
|
||||
// the collector frees. The sanitized lane asserts every object is freed.
|
||||
|
||||
interface Node {
|
||||
value: number;
|
||||
left: Node | null;
|
||||
right: Node | null;
|
||||
}
|
||||
|
||||
function leaf(value: number): Node {
|
||||
return { value, left: null, right: null };
|
||||
}
|
||||
|
||||
function branch(value: number, left: Node | null, right: Node | null): Node {
|
||||
return { value, left, right };
|
||||
}
|
||||
|
||||
function tree(depth: number, value: number): Node {
|
||||
return depth === 0 ? leaf(value) : branch(value, tree(depth - 1, value * 2), tree(depth - 1, value * 2 + 1));
|
||||
}
|
||||
|
||||
function sum(node: Node | null): number {
|
||||
return node === null ? 0 : node.value + sum(node.left) + sum(node.right);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let generation = 0; generation < 8; generation++) total += sum(tree(8, generation + 1));
|
||||
console.log("trees", total);
|
||||
|
||||
// A subtree shared by both branches is released once.
|
||||
const shared = tree(3, 1);
|
||||
const dag = branch(0, shared, shared);
|
||||
console.log("dag", sum(dag), JSON.stringify(branch(1, leaf(2), null)));
|
||||
|
||||
// Only projected: the result lives in the caller's frame, which releases
|
||||
// the subtree it holds.
|
||||
const top = branch(5, tree(2, 1), null);
|
||||
console.log("top", top.value, sum(top.left), top.right === null);
|
||||
|
||||
interface Cell {
|
||||
n: number;
|
||||
next: Cell | null;
|
||||
}
|
||||
|
||||
function cons(n: number, next: Cell | null): Cell {
|
||||
return { n, next };
|
||||
}
|
||||
|
||||
let list: Cell | null = null;
|
||||
for (let i = 0; i < 5000; i++) list = cons(i, list);
|
||||
let length = 0;
|
||||
let last = -1;
|
||||
for (let c = list; c !== null; c = c.next) {
|
||||
length++;
|
||||
last = c.n;
|
||||
}
|
||||
console.log("list", length, last);
|
||||
list = null;
|
||||
|
||||
// A record type written after construction keeps its cycle-collector
|
||||
// header: these rings are garbage only the collector can free.
|
||||
interface Ring {
|
||||
id: number;
|
||||
next: Ring | null;
|
||||
}
|
||||
|
||||
function ring(id: number, next: Ring | null): Ring {
|
||||
return { id, next };
|
||||
}
|
||||
|
||||
let ids = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const a = ring(i, null);
|
||||
const b = ring(i + 1, a);
|
||||
a.next = b;
|
||||
ids += a.next.id + b.next!.id;
|
||||
}
|
||||
console.log("rings", ids);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3336, "san": 4712 }
|
||||
Reference in New Issue
Block a user