mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-26 06:34:20 +08:00
Cache untyped property access in programs that declare classes
A program that declares a class reads and writes untyped properties through helpers that first select the receiver's property bag. The bag helpers return jsvals, so the helpers read the bag through js.toDyn; keyed helpers test each class's key table between the key's coercion and the read; and a program with subclasses normalizes the receiver (value = %dyn.class.normalize(value)) at the start of each helper. The inline-cache recognizer accepted none of these forms, so every property site in such a program called its helper. It now looks through dyn/jsval conversions of the receiver, accepts capsule branches before a keyed helper's final read, and accepts a rebinding of the receiver to a bag of itself. Plain objects and arrays then read and write through the site's cache, as they do in programs without classes. bench/dynamic-properties (new) exercises these sites.
This commit is contained in:
@@ -13,6 +13,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
|
||||
| `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner |
|
||||
| `dictionary-walks` | cpu | `Record<string, number>` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted |
|
||||
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
|
||||
| `dynamic-properties` | cpu | untyped JavaScript reading and writing properties of values it does not type: records built by object literals in two key orders, nested records, and instances of a class and its subclass |
|
||||
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
|
||||
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
|
||||
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"entry": "workloads/dynamic-properties.mjs",
|
||||
"args": [
|
||||
"1000000"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Untyped JavaScript reading and writing properties of values it never
|
||||
// declares types for: records built by object literals in two key orders,
|
||||
// nested records, property updates, and instances of a class and its
|
||||
// subclass read and written the same way. Argument: the number of
|
||||
// iterations.
|
||||
const rounds = Number(process.argv[2] ?? "1000000");
|
||||
class Cursor {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
step(dx, dy) {
|
||||
this.x += dx;
|
||||
this.y += dy;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class Marker extends Cursor {
|
||||
constructor(x, y, label) {
|
||||
super(x, y);
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
function makeItem(i) {
|
||||
if (i % 3 === 0)
|
||||
return { id: i, price: i % 17, qty: i % 5, meta: { tag: i % 7, weight: i % 11 } };
|
||||
return { id: i, qty: i % 5, price: i % 13, meta: { weight: i % 9, tag: i % 5 } };
|
||||
}
|
||||
function cost(item) {
|
||||
return item.price * item.qty + item.meta.weight;
|
||||
}
|
||||
function restock(item, round) {
|
||||
item.qty = (item.qty + round) % 7;
|
||||
item.meta.tag = (item.meta.tag + 1) % 7;
|
||||
}
|
||||
function distance(a, b) {
|
||||
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
||||
}
|
||||
const items = [];
|
||||
for (let i = 0; i < 1024; i++) items.push(makeItem(i));
|
||||
const cursors = [];
|
||||
for (let i = 0; i < 64; i++)
|
||||
cursors.push(i % 2 === 0 ? new Cursor(i - 32, 7 - i) : new Marker(i - 32, 7 - i, "m" + i));
|
||||
const origin = { x: 0, y: 0 };
|
||||
let checksum = 0;
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
const item = items[r & 1023];
|
||||
checksum += cost(item);
|
||||
restock(item, r & 3);
|
||||
checksum += item.meta.tag;
|
||||
const cursor = cursors[r & 63];
|
||||
checksum += distance(cursor.step((r % 3) - 1, (r % 5) - 2), origin);
|
||||
if (cursor.x > 100 || cursor.x < -100) cursor.x = 0;
|
||||
if (cursor.y > 100 || cursor.y < -100) cursor.y = 0;
|
||||
}
|
||||
console.log(checksum, items[5].qty, items[6].meta.tag, cursors[3].x, cursors[3].y, cursors[3].label);
|
||||
@@ -126,6 +126,140 @@ test("property helpers whose class branches only test capsules are cache sites",
|
||||
expect(sites.site("%dyn.class.property.1")).toEqual({ kind: "set", key: "y" });
|
||||
});
|
||||
|
||||
test("bags converted between dyn and jsval are still the receiver", () => {
|
||||
// A program that declares a class returns its bags as jsvals, and the
|
||||
// helpers read them through js.toDyn.
|
||||
const toDyn = (value: IrExpr): IrExpr => ({
|
||||
kind: "libCall",
|
||||
fn: "js.toDyn",
|
||||
args: [value],
|
||||
type: DYN,
|
||||
loc,
|
||||
});
|
||||
const fromDyn = (value: IrExpr): IrExpr => ({
|
||||
kind: "libCall",
|
||||
fn: "js.fromDyn",
|
||||
args: [value],
|
||||
type: { kind: "jsval" },
|
||||
loc,
|
||||
});
|
||||
const jsvalBag = helper(
|
||||
"%dyn.class.readPropertiesOrEmpty.js",
|
||||
["v"],
|
||||
[capsuleBranch("v"), ret(fromDyn(ref("v")))],
|
||||
);
|
||||
const staticBag = helper("%dyn.class.staticProperties", ["v"], [ret(ref("v"))]);
|
||||
const getter = getHelper(
|
||||
"%dyn.class.property.0",
|
||||
"x",
|
||||
[capsuleBranch("p.0")],
|
||||
call(staticBag.name, toDyn(call(jsvalBag.name, ref("p.0")))),
|
||||
);
|
||||
const setter = setHelper("%dyn.class.property.1", "y");
|
||||
const write = setter.body[0] as Extract<IrStmt, { kind: "exprStmt" }>;
|
||||
const keySet = write.expr as Extract<IrExpr, { kind: "libCall" }>;
|
||||
keySet.args[0] = toDyn(call(jsvalBag.name, ref("p.0")));
|
||||
// A converted value that is not the receiver stays a helper call.
|
||||
const foreign = getHelper(
|
||||
"%dyn.class.property.2",
|
||||
"x",
|
||||
[],
|
||||
toDyn(fromDyn({ kind: "dynObjLit", type: DYN, loc } as IrExpr)),
|
||||
);
|
||||
const sites = sitesOf(jsvalBag, staticBag, getter, setter, foreign);
|
||||
expect(sites.site("%dyn.class.property.0")).toEqual({ kind: "get", key: "x" });
|
||||
expect(sites.site("%dyn.class.property.1")).toEqual({ kind: "set", key: "y" });
|
||||
expect(sites.site("%dyn.class.property.2")).toBeNull();
|
||||
});
|
||||
|
||||
test("a bag helper may first rebind its parameter to a bag of it", () => {
|
||||
// Programs with subclasses normalize the value before the class branches.
|
||||
const normalize = helper("%dyn.class.normalize", ["v"], [capsuleBranch("v"), ret(ref("v"))]);
|
||||
const rebind = (value: IrExpr): IrStmt => ({ kind: "assign", localId: "v", value, loc });
|
||||
const normalized = helper(
|
||||
"%dyn.class.readPropertiesOrEmpty.normalized",
|
||||
["v"],
|
||||
[rebind(call(normalize.name, ref("v"))), capsuleBranch("v"), ret(ref("v"))],
|
||||
);
|
||||
const replaced = helper(
|
||||
"%dyn.class.readPropertiesOrEmpty.replaced",
|
||||
["v"],
|
||||
[rebind({ kind: "dynObjLit", type: DYN, loc } as IrExpr), ret(ref("v"))],
|
||||
);
|
||||
const sites = sitesOf(
|
||||
normalize,
|
||||
normalized,
|
||||
replaced,
|
||||
getHelper("%dyn.class.property.0", "x", [], call(normalized.name, ref("p.0"))),
|
||||
getHelper("%dyn.class.property.1", "x", [], call(replaced.name, ref("p.0"))),
|
||||
// The property helper itself normalizes its receiver first, too.
|
||||
getHelper("%dyn.class.property.2", "x", [
|
||||
{ kind: "assign", localId: "p.0", value: call(normalize.name, ref("p.0")), loc },
|
||||
capsuleBranch("p.0"),
|
||||
]),
|
||||
);
|
||||
expect(sites.site("%dyn.class.property.0")).toEqual({ kind: "get", key: "x" });
|
||||
expect(sites.site("%dyn.class.property.1")).toBeNull();
|
||||
expect(sites.site("%dyn.class.property.2")).toEqual({ kind: "get", key: "x" });
|
||||
});
|
||||
|
||||
test("keyed helpers may test capsules between the key's coercion and the read", () => {
|
||||
const read = (object: IrExpr, key: string): IrExpr => ({
|
||||
kind: "libCall",
|
||||
fn: "js.fromDyn",
|
||||
args: [{ kind: "dynKeyGet", value: object, key: ref(key), type: DYN, loc }],
|
||||
type: { kind: "jsval" },
|
||||
loc,
|
||||
});
|
||||
const guard = (test: "symbol" | "nullish", tested: string, object: IrExpr): IrStmt => ({
|
||||
kind: "if",
|
||||
cond: { kind: "dynTest", test, value: ref(tested), type: BOOL, loc },
|
||||
then: [ret(read(object, "p.key"))],
|
||||
else_: null,
|
||||
loc,
|
||||
});
|
||||
const keyed = (name: string, between: IrStmt[]): IrFunction =>
|
||||
helper(
|
||||
name,
|
||||
["p.0", "p.key"],
|
||||
[
|
||||
capsuleBranch("p.0"),
|
||||
guard("symbol", "p.key", call(identityBag.name, ref("p.0"))),
|
||||
guard("nullish", "p.0", ref("p.0")),
|
||||
{
|
||||
kind: "varDecl",
|
||||
localId: "key.string",
|
||||
init: {
|
||||
kind: "libCall",
|
||||
fn: "dyn.toStringCoerce",
|
||||
args: [ref("p.key")],
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
},
|
||||
...between,
|
||||
ret(read(call(identityBag.name, ref("p.0")), "key.string")),
|
||||
],
|
||||
["key.string"],
|
||||
);
|
||||
const errorBranch: IrStmt = {
|
||||
kind: "if",
|
||||
cond: { kind: "dynTest", test: "error", value: ref("p.0"), type: BOOL, loc },
|
||||
then: [ret(ref("p.0"))],
|
||||
else_: null,
|
||||
loc,
|
||||
};
|
||||
const sites = sitesOf(
|
||||
keyed("%dyn.class.computed.0", []),
|
||||
keyed("%dyn.class.computed.1", [capsuleBranch("p.0"), capsuleBranch("p.0", "dyn.classIs")]),
|
||||
keyed("%dyn.class.computed.2", [errorBranch]),
|
||||
);
|
||||
expect(sites.site("%dyn.class.computed.0")).toEqual({ kind: "keyed" });
|
||||
expect(sites.site("%dyn.class.computed.1")).toEqual({ kind: "keyed" });
|
||||
expect(sites.site("%dyn.class.computed.2")).toBeNull();
|
||||
});
|
||||
|
||||
test("any other prefix or tail keeps the helper call", () => {
|
||||
const errorBranch: IrStmt = {
|
||||
kind: "if",
|
||||
|
||||
@@ -62,7 +62,11 @@ export class JsIcSites {
|
||||
const receiver = fn.params[0]?.localId;
|
||||
if (receiver === undefined) return null;
|
||||
let i = 0;
|
||||
while (i < fn.body.length && this.capsuleBranch(fn.body[i]!, receiver)) i++;
|
||||
while (
|
||||
i < fn.body.length &&
|
||||
(this.capsuleBranch(fn.body[i]!, receiver) || this.rebinding(fn.body[i]!, receiver))
|
||||
)
|
||||
i++;
|
||||
const tail = fn.body.slice(i);
|
||||
if (fn.params.length === 1) return this.getTail(tail, receiver);
|
||||
if (fn.params.length !== 2) return null;
|
||||
@@ -109,10 +113,14 @@ export class JsIcSites {
|
||||
/** `if (typeof key === "symbol") return dynKeyGet(BAG(receiver), key);
|
||||
* if (receiver == null) return dynKeyGet(receiver, key);
|
||||
* const s = String(key); return dynKeyGet(BAG(receiver), s)`, each read
|
||||
* possibly under js.fromDyn. */
|
||||
* possibly under js.fromDyn. Capsule branches may precede the final read:
|
||||
* a program that declares classes looks the string key up in each class's
|
||||
* key table there. */
|
||||
private keyedTail(tail: IrStmt[], receiver: string, key: string): JsIcSite | null {
|
||||
const [symbol, nullish, decl, ret] = tail;
|
||||
if (tail.length !== 4) return null;
|
||||
const [symbol, nullish, decl] = tail;
|
||||
const ret = tail[tail.length - 1];
|
||||
if (tail.length < 4) return null;
|
||||
if (!tail.slice(3, -1).every((s) => this.capsuleBranch(s, receiver))) return null;
|
||||
if (!this.guard(symbol, "symbol", key, (read) => this.bagOf(read, receiver), key)) return null;
|
||||
if (!this.guard(nullish, "nullish", receiver, (read) => isVar(read, receiver), key))
|
||||
return null;
|
||||
@@ -159,9 +167,12 @@ export class JsIcSites {
|
||||
}
|
||||
|
||||
/** Whether `e` is the receiver itself for every ordinary object or array:
|
||||
* the receiver, or a chain of property-bag helpers applied to it. */
|
||||
* the receiver, or a chain of property-bag helpers applied to it. A
|
||||
* conversion between the dyn and jsval forms keeps the object it holds. */
|
||||
private bagOf(e: IrExpr, receiver: string): boolean {
|
||||
if (isVar(e, receiver)) return true;
|
||||
if (e.kind === "libCall" && (e.fn === "js.toDyn" || e.fn === "js.fromDyn"))
|
||||
return e.args.length === 1 && this.bagOf(e.args[0]!, receiver);
|
||||
return (
|
||||
e.kind === "call" &&
|
||||
e.args.length === 1 &&
|
||||
@@ -182,6 +193,13 @@ export class JsIcSites {
|
||||
return ret.kind === "return" && ret.value !== null && this.bagOf(ret.value, param);
|
||||
}
|
||||
|
||||
/** `param = <bag of param>`, such as the subclass normalization
|
||||
* `value = %dyn.class.normalize(value)`, which leaves an ordinary object
|
||||
* or array in `param`. */
|
||||
private rebinding(s: IrStmt, param: string): boolean {
|
||||
return s.kind === "assign" && s.localId === param && this.bagOf(s.value, param);
|
||||
}
|
||||
|
||||
/** A property-bag helper that returns its argument unless a capsule
|
||||
* branch is taken. */
|
||||
private identityHelper(name: string): boolean {
|
||||
@@ -204,7 +222,10 @@ export class JsIcSites {
|
||||
identity =
|
||||
fn.body
|
||||
.slice(0, -1)
|
||||
.every((s) => this.capsuleBranch(s, param) || this.kindExit(s, param)) &&
|
||||
.every(
|
||||
(s) =>
|
||||
this.capsuleBranch(s, param) || this.kindExit(s, param) || this.rebinding(s, param),
|
||||
) &&
|
||||
last?.kind === "return" &&
|
||||
last.value !== null &&
|
||||
this.bagOf(last.value, param);
|
||||
|
||||
@@ -64,6 +64,50 @@ console.log(read(o), at(dyn([5, 6]), 1), call(o), o.n);
|
||||
}
|
||||
});
|
||||
|
||||
test("property sites keep their caches in programs that declare classes and subclasses", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-class-"));
|
||||
try {
|
||||
const entry = join(dir, "main.js");
|
||||
const outPath = join(dir, "main.ir.json");
|
||||
// Declared classes give every property helper class branches and
|
||||
// property-bag lookups, and a subclass adds a normalization step; plain
|
||||
// objects still read through the caches.
|
||||
await writeFile(
|
||||
entry,
|
||||
`
|
||||
class Point {
|
||||
constructor(x) { this.x = x; }
|
||||
}
|
||||
class Tagged extends Point {
|
||||
constructor(x, tag) { super(x); this.tag = tag; }
|
||||
}
|
||||
function dyn(v) { return v; }
|
||||
function read(o) { return o.x; }
|
||||
function write(o, v) { o.x = v; }
|
||||
function at(a, i) { return a[i]; }
|
||||
var o = dyn({ x: 1 });
|
||||
var p = dyn(new Point(4));
|
||||
var t = dyn(new Tagged(6, "t"));
|
||||
write(o, 3);
|
||||
write(p, 5);
|
||||
write(t, 7);
|
||||
console.log(read(o), read(p), read(t), at(dyn([5, 6]), 1), at(p, "x"), at(t, "tag"));
|
||||
`,
|
||||
);
|
||||
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const mod = deserializeModule(await readFile(outPath, "utf8"));
|
||||
expect(validateModule(mod)).toEqual([]);
|
||||
const llvm = emitLlvmModule(mod);
|
||||
expect(body(llvm, "read")).toContain("@scr_js_load_probe(");
|
||||
expect(body(llvm, "write")).toContain("@scr_js_store_probe(");
|
||||
expect(body(llvm, "at")).toContain("@scr_js_keyed_probe(");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("typed programs have no cache sites and keep the ordinary runtime", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-typed-"));
|
||||
try {
|
||||
|
||||
@@ -394,8 +394,8 @@ export function dynamicTierSymbols(names) {
|
||||
* not yet traced to its typed root. bench/thread-slots,
|
||||
* bench/channel-messages and bench/worker-messages, added later, link them
|
||||
* through worker messages:
|
||||
* node:worker_threads values are dyn objects. bench/dynamic-methods is
|
||||
* untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and
|
||||
* node:worker_threads values are dyn objects. bench/dynamic-methods and
|
||||
* bench/dynamic-properties are untyped JavaScript. Every other bench workload is fully typed and must link none. The gate and
|
||||
* tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also
|
||||
* fails when a listed workload stops linking them, so remove a workload here
|
||||
* when a leak fix makes its binary clean. */
|
||||
@@ -410,6 +410,7 @@ export const DYNAMIC_WORKLOADS = new Set([
|
||||
"bench/thread-slots",
|
||||
"bench/channel-messages",
|
||||
"bench/dynamic-methods",
|
||||
"bench/dynamic-properties",
|
||||
"bench/worker-messages",
|
||||
"bench/typed-array-scan-workers",
|
||||
"bench/log-summary",
|
||||
|
||||
Reference in New Issue
Block a user