Skip to content

Commit 6ddaa2a

Browse files
committed
Drop guards on private constructor stores and borrow construction-only fields
Two uses of the construction-only facts outside the collector (backend/immutability.ts): - Publication guards: a store into the constructor's own `this` that lies before the constructor's escape point, in a constructor no ancestor of which lets `this` escape and that is never invoked directly on an existing object, cannot target a published object. It loses its rc == SIZE_MAX guard and no longer seeds may-throw, so constructors that could only throw through guards lose their pending checks at every `new`. Storing `this` into one of its own fields does not expose it. - Borrowed reads: `x.f` holding an object, record, array or string is borrowed as a call argument, receiver or stable alias when `f` is construction-only and `x` is itself borrowable (unchanged local, borrowed parameter, or such a projection): nothing can replace and release it while `x` lives. Not inside constructors, which may still write their own fields. Module constants (immutable, not TDZ-checked, assigned at most once) are borrowable too. No object is frozen or marked at run time; publication errors, store order and reflection are unchanged. tsc-ts (e469e33), static: published-field guards 6,528 -> 4,462, sc_release calls 146,056 -> 129,756, sc_retain calls 26,215 -> 22,123, scr_arr_release 29,865 -> 25,617, binary 23.68 -> 22.96 MB (both commits). Self-check single-threaded, executed: guards 96.1M -> 60.8M, retains 273.5M -> 247.2M, releases 340.3M -> 314.0M. Linux sandbox, interleaved medians, both commits against perf/alloc-hugepages: single-threaded (6 runs) base new change Excalidraw 8.62 s 8.34 s -3.2% Playwright 7.83 s 7.71 s -1.5% tsc-ts self-check 4.36 s 4.22 s -3.0% tRPC 1.63 s 1.58 s -3.1% TypeORM 6.68 s 6.50 s -2.5% --checkers 8 (10 runs): -2.3% .. +0.7% (neutral) Runtime suite (--layouts=4 --runs=16): geomean -1.9%; ast-interp -4.4%, validate-errors -3.8%, regex-logs -1.7% faster, template-render +1.4% slower, the rest neutral; sizes unchanged.
1 parent 44f4d0b commit 6ddaa2a

9 files changed

Lines changed: 827 additions & 3 deletions
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { expect, test } from "vitest";
2+
import {
3+
DYN,
4+
F64,
5+
STRING,
6+
VOID,
7+
type IrExpr,
8+
type IrModule,
9+
type IrStmt,
10+
type IrType,
11+
} from "../ir/ir.js";
12+
import { computeConstructionFacts, computeImmutableFields, stableGlobals } from "./immutability.js";
13+
14+
const loc = { file: "immutability.ts", start: 0, end: 1 };
15+
const obj = (className: string): IrType => ({ kind: "object", className });
16+
const value = (type: IrType): IrExpr => ({ kind: "varRef", localId: "v", type, loc });
17+
const thisRef = (className: string): IrExpr => ({
18+
kind: "varRef",
19+
localId: "this.0",
20+
type: obj(className),
21+
loc,
22+
});
23+
const setThis = (className: string, field: string): IrStmt => ({
24+
kind: "fieldSet",
25+
obj: thisRef(className),
26+
className,
27+
field,
28+
value: value(F64),
29+
loc,
30+
});
31+
const call = (callee: string, args: IrExpr[]): IrStmt => ({
32+
kind: "exprStmt",
33+
expr: { kind: "call", callee, args, type: VOID, loc },
34+
loc,
35+
});
36+
function ctor(className: string, body: IrStmt[]): IrModule["functions"][number] {
37+
return {
38+
name: `%${className}.constructor`,
39+
params: [{ localId: "this.0", name: "this", type: obj(className) }],
40+
locals: [],
41+
returnType: VOID,
42+
body,
43+
loc,
44+
};
45+
}
46+
function module(
47+
classes: NonNullable<IrModule["classes"]>,
48+
functions: IrModule["functions"],
49+
): IrModule {
50+
return {
51+
irVersion: 15,
52+
sourceFile: loc.file,
53+
entry: "main",
54+
classes,
55+
functions: [
56+
{ name: "main", locals: [], params: [], returnType: VOID, body: [], loc },
57+
...functions,
58+
],
59+
};
60+
}
61+
const fields = (...names: string[]) => names.map((name) => ({ name, type: F64 }));
62+
63+
/** The fields of `className` whose constructor stores are private. */
64+
function privateFields(mod: IrModule, className: string): string[] {
65+
const facts = computeConstructionFacts(mod);
66+
const fn = mod.functions.find((f) => f.name === `%${className}.constructor`)!;
67+
return fn.body
68+
.filter((s): s is Extract<IrStmt, { kind: "fieldSet" }> => s.kind === "fieldSet")
69+
.filter((s) => facts.privateStores.has(s))
70+
.map((s) => s.field);
71+
}
72+
73+
test("constructor stores before this escapes are private", () => {
74+
const mod = module(
75+
[{ name: "P", fields: fields("a", "b"), loc }],
76+
[ctor("P", [setThis("P", "a"), call("register", [thisRef("P")]), setThis("P", "b")])],
77+
);
78+
expect(privateFields(mod, "P")).toEqual(["a"]);
79+
});
80+
81+
test("storing this into its own field does not expose it", () => {
82+
const self: IrStmt = { ...setThis("P", "self"), value: thisRef("P") } as IrStmt;
83+
const mod = module(
84+
[{ name: "P", fields: [...fields("a"), { name: "self", type: obj("P") }], loc }],
85+
[ctor("P", [self, setThis("P", "a")])],
86+
);
87+
expect(privateFields(mod, "P")).toEqual(["self", "a"]);
88+
});
89+
90+
test("a base constructor's escape makes every subclass store public", () => {
91+
const superCall = call("%Base.constructor", [
92+
{ kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc },
93+
]);
94+
const mod = module(
95+
[
96+
{ name: "Base", fields: fields("a"), loc },
97+
{ name: "Sub", base: "Base", fields: fields("a", "b"), loc },
98+
],
99+
[
100+
ctor("Base", [setThis("Base", "a"), call("register", [thisRef("Base")])]),
101+
ctor("Sub", [superCall, setThis("Sub", "b")]),
102+
],
103+
);
104+
expect(privateFields(mod, "Base")).toEqual(["a"]);
105+
expect(privateFields(mod, "Sub")).toEqual([]);
106+
});
107+
108+
test("a constructor called on an existing object keeps every guard", () => {
109+
const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]);
110+
expect(privateFields(mod, "P")).toEqual(["a"]);
111+
mod.functions[0]!.body.push(call("%P.constructor", [value(obj("P"))]));
112+
expect(privateFields(mod, "P")).toEqual([]);
113+
});
114+
115+
test("library builds keep every guard", () => {
116+
const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]);
117+
mod.lib = {} as NonNullable<IrModule["lib"]>;
118+
expect(privateFields(mod, "P")).toEqual([]);
119+
});
120+
121+
test("construction-only fields exclude later writes and dynamic stores", () => {
122+
const mod = module(
123+
[{ name: "P", fields: fields("a", "b"), loc }],
124+
[ctor("P", [setThis("P", "a"), setThis("P", "b")])],
125+
);
126+
mod.functions[0]!.body.push(
127+
{ kind: "fieldSet", obj: value(obj("P")), className: "P", field: "b", value: value(F64), loc },
128+
{ kind: "exprStmt", expr: { kind: "dynFrom", value: value(obj("P")), type: DYN, loc }, loc },
129+
);
130+
const facts = computeImmutableFields(mod)!;
131+
expect(facts.classField("P", "a")).toBe(true);
132+
expect(facts.classField("P", "b")).toBe(false);
133+
// A runtime store into a dynamic value can reach P through its capsule.
134+
mod.functions[0]!.body.push({
135+
kind: "exprStmt",
136+
expr: {
137+
kind: "libCall",
138+
fn: "dyn.keySet",
139+
args: [value(DYN), value(STRING), value(DYN)],
140+
type: VOID,
141+
loc,
142+
},
143+
loc,
144+
});
145+
expect(computeImmutableFields(mod)!.classField("P", "a")).toBe(false);
146+
});
147+
148+
test("module constants are stable once assigned exactly once", () => {
149+
const mod = module([], []);
150+
mod.globals = [
151+
{ id: "%g.k", name: "k", type: STRING, mutable: false },
152+
{ id: "%g.m", name: "m", type: STRING, mutable: true },
153+
{ id: "%g.t", name: "t", type: STRING, mutable: false, tdz: true },
154+
{ id: "%g.twice", name: "twice", type: STRING, mutable: false },
155+
];
156+
const assign = (localId: string): IrStmt => ({
157+
kind: "assign",
158+
localId,
159+
value: value(STRING),
160+
loc,
161+
});
162+
mod.functions[0]!.body.push(
163+
assign("%g.k"),
164+
assign("%g.m"),
165+
assign("%g.t"),
166+
assign("%g.twice"),
167+
assign("%g.twice"),
168+
);
169+
expect([...stableGlobals(mod)]).toEqual(["%g.k"]);
170+
});
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/* Static immutability facts the backend uses to drop run-time immutability
2+
* checks (see computeConstructionFacts). Pure functions of the IR module. */
3+
import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js";
4+
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
5+
import {
6+
computeCycleMutability,
7+
constructorEscapePoint,
8+
constructorThisAliases,
9+
fieldKey,
10+
type CycleMutability,
11+
} from "./cycle-mutability.js";
12+
13+
/** Fields that are never written once their object can be observed outside
14+
* its own construction (cycle-mutability.ts derives the facts from every
15+
* store in the IR, including typed-ref commits from checked-dynamic code,
16+
* and disables them for library builds and dynamic islands).
17+
*
18+
* Outside constructors, a reference read from such a field stays valid for
19+
* as long as its owner object is alive: nothing can replace it and release
20+
* the old value. Constructors themselves may still write their own
21+
* `this`'s fields, so the facts do not apply while one runs. */
22+
export class ImmutableFields {
23+
constructor(
24+
private readonly mutability: CycleMutability,
25+
private readonly unitOfClass: (className: string) => string,
26+
) {}
27+
28+
/** `obj.field` on a class instance is construction-only. */
29+
classField(className: string, field: string): boolean {
30+
return this.unit(this.unitOfClass(className), field);
31+
}
32+
33+
/** `obj.field` on a record is never written after the literal. */
34+
recordField(shapeId: string, field: string): boolean {
35+
return this.unit(`record:${shapeId}`, field);
36+
}
37+
38+
private unit(unit: string, field: string): boolean {
39+
return (
40+
!this.mutability.all &&
41+
!this.mutability.units.has(unit) &&
42+
!this.mutability.fields.has(fieldKey(unit, field))
43+
);
44+
}
45+
}
46+
47+
export function computeImmutableFields(mod: IrModule): ImmutableFields | null {
48+
if (mod.lib !== undefined) return null;
49+
const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const));
50+
const roots = new Map<string, string>();
51+
const rootOf = (name: string): string => {
52+
const known = roots.get(name);
53+
if (known !== undefined) return known;
54+
let cur = name;
55+
for (let base = baseOf.get(cur); base !== null && base !== undefined; base = baseOf.get(cur))
56+
cur = base;
57+
roots.set(name, cur);
58+
return cur;
59+
};
60+
const unitOfClass = (name: string): string => `object:${rootOf(name)}`;
61+
const mutability = computeCycleMutability(mod, unitOfClass);
62+
return mutability.all ? null : new ImmutableFields(mutability, unitOfClass);
63+
}
64+
65+
/** Stores that provably target an object no other code can reach yet.
66+
*
67+
* A published object (@scriptc/threads `publish`) rejects every write, so
68+
* the emitter guards each store into a type the program may publish. A
69+
* store into the constructor's own `this` cannot target a published object
70+
* when nothing outside the construction can hold `this` at that point:
71+
*
72+
* - the constructor runs on a fresh object: it is only reached through
73+
* `new` (or a construct thunk) for its class or a subclass, never by a
74+
* direct call on an existing object (`%C.constructor` called anywhere but
75+
* as the direct super call of a subclass constructor disqualifies C and
76+
* every ancestor, which that call reaches with the same receiver);
77+
* - no ancestor constructor, which runs before this body continues, lets
78+
* `this` escape, and the store lies in a top-level statement before the
79+
* first one in which this constructor lets it escape
80+
* (cycle-mutability's constructorEscapePoint: `this`, or a constant alias
81+
* of it, may only be a field-store target, a field-read receiver, the
82+
* direct base constructor's receiver, or stored into one of its own
83+
* fields; closures and class references capturing it count as escapes).
84+
* A subclass constructor's code only runs after this body returns.
85+
*
86+
* Library builds keep every guard: their constructors can be invoked by an
87+
* embedder in ways the module does not show. */
88+
export interface ConstructionFacts {
89+
/** `fieldSet` statements whose target is a still-private `this`. */
90+
privateStores: Set<IrStmt>;
91+
}
92+
93+
export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
94+
const facts: ConstructionFacts = { privateStores: new Set() };
95+
if (mod.lib !== undefined) return facts;
96+
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const));
97+
const ctorClass = new Map([...classes.keys()].map((name) => [`%${name}.constructor`, name]));
98+
const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const));
99+
const thisOf = (name: string): string | null =>
100+
fnByName.get(`%${name}.constructor`)?.params[0]?.localId ?? null;
101+
const isThisRef = (e: IrExpr, thisId: string | null): boolean => {
102+
if (thisId === null) return false;
103+
if (e.kind === "varRef") return e.localId === thisId;
104+
if (e.kind === "upcast") return isThisRef(e.value, thisId);
105+
if (e.kind === "seqExpr") return isThisRef(e.result, thisId);
106+
return false;
107+
};
108+
109+
// Constructors reached with a receiver that may not be fresh.
110+
const reused = new Set<string>();
111+
const markReused = (name: string): void => {
112+
for (let c: string | undefined = name; c !== undefined && classes.has(c);) {
113+
if (reused.has(c)) break;
114+
reused.add(c);
115+
c = classes.get(c)!.base;
116+
}
117+
};
118+
for (const fn of mod.functions) {
119+
const own = ctorClass.get(fn.name);
120+
const thisId = own !== undefined ? thisOf(own) : null;
121+
const baseCtor =
122+
own !== undefined && classes.get(own)!.base !== undefined
123+
? `%${classes.get(own)!.base}.constructor`
124+
: null;
125+
const expr = (e: IrExpr): boolean => {
126+
if (e.kind === "call") {
127+
const target = ctorClass.get(e.callee);
128+
if (
129+
target !== undefined &&
130+
!(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId))
131+
)
132+
markReused(target);
133+
} else if (e.kind === "closure") {
134+
const target = ctorClass.get(e.fnName);
135+
if (target !== undefined) markReused(target);
136+
}
137+
return everyExprChild(e, expr, stmt);
138+
};
139+
const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt);
140+
fn.body.every(stmt);
141+
}
142+
143+
// The first escaping statement of each constructor (body length: none).
144+
// A class without a constructor function of its own (runtime roots) is
145+
// treated as escaping at once.
146+
const points = new Map<string, number>();
147+
const escapePoint = (name: string): number => {
148+
const known = points.get(name);
149+
if (known !== undefined) return known;
150+
const fn = fnByName.get(`%${name}.constructor`);
151+
const base = classes.get(name)?.base;
152+
const thisId = thisOf(name);
153+
const point =
154+
fn === undefined || thisId === null || classes.get(name)?.runtime === true
155+
? 0
156+
: constructorEscapePoint(fn, thisId, base !== undefined ? `%${base}.constructor` : null, {
157+
selfStores: true,
158+
});
159+
points.set(name, point);
160+
return point;
161+
};
162+
const ancestorEscapes = (name: string): boolean => {
163+
for (let c = classes.get(name)?.base; c !== undefined; c = classes.get(c)?.base) {
164+
const fn = fnByName.get(`%${c}.constructor`);
165+
if (!classes.has(c) || fn === undefined || escapePoint(c) < fn.body.length) return true;
166+
}
167+
return false;
168+
};
169+
170+
for (const [name] of classes) {
171+
const fn = fnByName.get(`%${name}.constructor`);
172+
if (fn === undefined || reused.has(name) || ancestorEscapes(name)) continue;
173+
const point = escapePoint(name);
174+
if (point === 0) continue;
175+
const aliases = constructorThisAliases(fn, thisOf(name)!);
176+
const isOwn = (e: IrExpr): boolean =>
177+
(e.kind === "varRef" && aliases.has(e.localId)) ||
178+
(e.kind === "upcast" && isOwn(e.value)) ||
179+
(e.kind === "seqExpr" && isOwn(e.result));
180+
const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt);
181+
const stmt = (s: IrStmt): boolean => {
182+
if (s.kind === "fieldSet" && isOwn(s.obj)) facts.privateStores.add(s);
183+
return everyStmtChild(s, expr, stmt);
184+
};
185+
for (let i = 0; i < point; i++) stmt(fn.body[i]!);
186+
}
187+
return facts;
188+
}
189+
190+
/** Module constants whose value never changes once initialized: immutable,
191+
* not TDZ-checked (every read follows the initialization), no import-cycle
192+
* init flag, and at most one assignment in the whole module. Such a global
193+
* owns its value for the rest of the program, so a read of it can be
194+
* borrowed across any later operand or call. */
195+
export function stableGlobals(mod: IrModule): Set<string> {
196+
const candidates = new Set(
197+
(mod.globals ?? [])
198+
.filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined)
199+
.map((g) => g.id),
200+
);
201+
const assigned = new Set<string>();
202+
const expr = (e: IrExpr): boolean => {
203+
if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId);
204+
return everyExprChild(e, expr, stmt);
205+
};
206+
const stmt = (s: IrStmt): boolean => {
207+
if (s.kind === "assign" && candidates.has(s.localId)) {
208+
if (assigned.has(s.localId)) candidates.delete(s.localId);
209+
assigned.add(s.localId);
210+
}
211+
return everyStmtChild(s, expr, stmt);
212+
};
213+
for (const fn of mod.functions) fn.body.every(stmt);
214+
return candidates;
215+
}

0 commit comments

Comments
 (0)