diff --git a/packages/compiler/src/backend/llvm/js-ic.ts b/packages/compiler/src/backend/llvm/js-ic.ts index dbd11c155..c9786c9f2 100644 --- a/packages/compiler/src/backend/llvm/js-ic.ts +++ b/packages/compiler/src/backend/llvm/js-ic.ts @@ -26,12 +26,14 @@ * v.num +16. Every property of an object with a layout is * a writable data property (scr_dyn.h's layout mode); * dictionary-mode objects have a null layout. + * v.typed_ref {ptr +16, type_key +40}. * ScrJsIc entries of 40 bytes {layout, target, holder, * holder_layout, slot}; 168 bytes in all */ -import { BOOL, DYN, F64, STRING, type IrExpr, type IrFunction } from "../../ir/ir.js"; +import { BOOL, DYN, F64, STRING, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; import { jsIcMethodSites, type JsIcSite } from "../../ir/js-ic.js"; import { mangleBorrowedFunction } from "../mangle.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; +import { classFieldBase, classFieldIndex } from "./classes.js"; import { DYN_KIND } from "./dyn.js"; import type { ExprOf, LlValue, LlvmEmitterContext } from "./expr-context.js"; import { JS_BITS } from "./jsval-encoding.js"; @@ -317,6 +319,222 @@ function keyString(host: Host, key: string, e: IrExpr): string { return host.emitExpr({ kind: "strLit", value: key, type: STRING, loc: e.loc }).name; } +/** A class instance's answer that a read helper gives by loading one + * declared field: the branch `if (dyn.typedRefIs(p, KEY)) { r = + * dynCheck(p); return [js.fromDyn](fieldGet C.f(r)) }`. */ +interface CapsuleRead { + key: string; + className: string; + field: string; +} + +/** The class branches of a read helper that a site can answer inline for + * capsules (class instances boxed as dyn values), or none. + * + * The helper first normalizes its receiver (`%dyn.class.normalize` + * reboxes a capsule keyed with a base class whose instance belongs to a + * subclass), then tests each class branch in turn. The site loads the + * field of a capsule whose key is a branch's key without normalizing, so + * a branch qualifies only when every subclass the normalization can + * rebox its key to has a branch that loads the same field from the same + * slot of a layout that extends the class's. Every other capsule, and + * every helper or normalization of another form, keeps the helper. */ +function capsuleReads(host: Host, helper: IrFunction): CapsuleRead[] { + const receiver = helper.params[0]?.localId; + if (receiver === undefined || helper.params.length !== 1) return []; + const reads = new Map(); + for (const stmt of helper.body) { + if (normalizes(stmt, receiver)) continue; + if (stmt.kind !== "if" || stmt.else_ !== null) break; + if (notCapsule(stmt.cond, receiver)) continue; + const key = brandKey(stmt.cond, receiver); + if (key === null) return []; + if (!reads.has(key)) reads.set(key, capsuleRead(stmt, receiver, key)); + } + const reboxes = normalizationTargets(host); + if (reboxes === null) return []; + const out: CapsuleRead[] = []; + for (const read of reads.values()) { + if (read === null || !plainField(host, read.className, read.field)) continue; + const targets = reboxes.get(read.key) ?? []; + const same = targets.every((target) => { + const other = reads.get(`object:${target}`); + return ( + other != null && + other.field === read.field && + plainField(host, target, read.field) && + extendsLayout(host, target, read.className, read.field) + ); + }); + if (same) out.push(read); + } + return out; +} + +/** `p = %dyn.class.normalize(p)`. */ +function normalizes(stmt: IrStmt, receiver: string): boolean { + return ( + stmt.kind === "assign" && + stmt.localId === receiver && + stmt.value.kind === "call" && + stmt.value.callee === "%dyn.class.normalize" && + stmt.value.args.length === 1 && + isLocal(stmt.value.args[0]!, receiver) + ); +} + +/** `!classCapsule(p)`: false for every capsule of a compiled class, so a + * capsule never enters the branch it guards. */ +function notCapsule(cond: IrExpr, receiver: string): boolean { + return ( + cond.kind === "dynTest" && + cond.test === "classCapsule" && + cond.negated === true && + isLocal(cond.value, receiver) + ); +} + +function isLocal(e: IrExpr, id: string): boolean { + return e.kind === "varRef" && e.localId === id; +} + +/** The capsule key `dyn.typedRefIs(p, "object:C")` tests, or null. */ +function brandKey(cond: IrExpr, receiver: string): string | null { + if (cond.kind !== "libCall" || cond.fn !== "dyn.typedRefIs" || cond.args.length !== 2) + return null; + const [value, key] = cond.args; + if (!isLocal(value!, receiver) || key?.kind !== "strLit" || !key.value.startsWith("object:")) + return null; + return key.value; +} + +/** The field a class branch loads, or null for a branch of another form. */ +function capsuleRead( + branch: IrStmt & { kind: "if" }, + receiver: string, + key: string, +): CapsuleRead | null { + const [decl, ret] = branch.then; + if (branch.then.length !== 2 || decl?.kind !== "varDecl" || ret?.kind !== "return") return null; + if (decl.init?.kind !== "dynCheck" || !isLocal(decl.init.value, receiver)) return null; + let value = ret.value; + if (value?.kind === "libCall" && value.fn === "js.fromDyn" && value.args.length === 1) + value = value.args[0]!; + if (value?.kind !== "fieldGet" || !isLocal(value.obj, decl.localId)) return null; + if (key !== `object:${value.className}`) return null; + return { key, className: value.className, field: value.field }; +} + +/** For each capsule key `%dyn.class.normalize` reboxes, the classes it + * may rebox to; null when the normalization has another form. */ +function normalizationTargets(host: Host): Map | null { + const targets = new Map(); + const normalize = host.fnByName.get("%dyn.class.normalize"); + if (normalize === undefined) return targets; + const value = normalize.params[0]?.localId; + if (value === undefined) return null; + for (const stmt of normalize.body) { + if (stmt.kind === "return") { + if (stmt.value !== null && isLocal(stmt.value, value)) continue; + return null; + } + if (stmt.kind !== "if" || stmt.else_ !== null) return null; + if (notCapsule(stmt.cond, value)) continue; + const key = brandKey(stmt.cond, value); + if (key === null) return null; + const [decl, ...checks] = stmt.then; + if (decl?.kind !== "varDecl" || decl.init?.kind !== "dynCheck") return null; + const into = targets.get(key) ?? []; + for (const check of checks) { + if ( + check.kind !== "if" || + check.else_ !== null || + check.cond.kind !== "instanceOf" || + !isLocal(check.cond.value, decl.localId) + ) + return null; + into.push(check.cond.className); + } + targets.set(key, into); + } + return targets; +} + +/** A declared field of a compiled class that holds a plain dyn cell + * pointer: not compact, compressed, int32 or an inline union. */ +function plainField(host: Host, className: string, field: string): boolean { + const meta = host.classMeta.get(className); + if (meta === undefined || meta.compact) return false; + const decl = meta.def.fields.find((f) => f.name === field); + if (decl === undefined || decl.type.kind !== "dyn") return false; + if (classFieldIndex(meta, field, "compressed").compressed) return false; + return ( + !host.int32Slots.isField(className, field) && !host.inlineUnions.classField(className, field) + ); +} + +/** Whether `sub`'s layout starts with `base`'s through `field`: the same + * header and the same fields in the same slots, so a load through + * `base`'s struct reads `sub`'s field. */ +function extendsLayout(host: Host, sub: string, base: string, field: string): boolean { + const a = host.classMeta.get(base); + const b = host.classMeta.get(sub); + if (a === undefined || b === undefined || b.compact) return false; + if (classFieldBase(a) !== classFieldBase(b)) return false; + const end = a.def.fields.findIndex((f) => f.name === field); + if (end < 0 || end >= b.def.fields.length) return false; + for (let i = 0; i <= end; i++) { + const x = a.def.fields[i]!; + const y = b.def.fields[i]!; + if (x.name !== y.name || x.type.kind !== y.type.kind) return false; + } + return ( + classFieldIndex(a, field, "compressed").index === classFieldIndex(b, field, "compressed").index + ); +} + +/** The inline part of a read site for a capsule `d`: a capsule whose key + * is one of `reads`' keys answers with the (borrowed) field cell, branching + * to `join` and recording its phi input; anything else, including a field + * not yet set, branches to `miss`. */ +function inlineCapsuleLoad( + host: Host, + d: string, + reads: readonly CapsuleRead[], + incoming: string[], + join: string, + miss: string, +): void { + const B = host.B; + const capsule = B.newLabel("icc.cap"); + kindIs(host, d, DYN_KIND.TYPED_REF, capsule, miss); + B.startBlock(capsule); + // Capsules boxed by this program share its key constants. + const key = load(host, "ptr", d, 40); + let next = B.newLabel("icc.k"); + B.br(next); + reads.forEach((read, i) => { + B.startBlock(next); + next = i + 1 < reads.length ? B.newLabel("icc.k") : miss; + const same = B.tmp(); + B.line(`${same} = icmp eq ptr ${key}, ${host.cstr(read.key)}`); + const matched = B.newLabel("icc.m"); + B.condBr(same, matched, next); + B.startBlock(matched); + const instance = load(host, "ptr", d, 16); + const { ptr } = host.classFieldPtr(instance, read.className, read.field); + const cell = B.tmp(); + B.line(`${cell} = load ptr, ptr ${ptr}`); + const set = B.tmp(); + B.line(`${set} = icmp ne ptr ${cell}, null`); + const hit = B.newLabel("icc.hit"); + B.condBr(set, hit, miss); + B.startBlock(hit); + incoming.push(`[ ${cell}, %${B.currentLabel()} ]`); + B.br(join); + }); +} + /** Emits a call to a classified helper as a cache site. */ export function emitJsIcCall( host: Host, @@ -371,9 +589,15 @@ function emitGet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string) // conversion to the result type. const hit = B.newLabel("icg.hit"); const hits: string[] = []; - kindIs(host, receiver.name, DYN_KIND.OBJ, object, probe); + const reads = capsuleReads(host, callee); + const other = reads.length > 0 ? B.newLabel("icg.other") : probe; + kindIs(host, receiver.name, DYN_KIND.OBJ, object, other); B.startBlock(object); inlineLoad(host, receiver.name, ic, INLINE_WAYS, INLINE_PROTO, (cell) => cell, hits, hit, probe); + if (reads.length > 0) { + B.startBlock(other); + inlineCapsuleLoad(host, receiver.name, reads, hits, hit, probe); + } B.startBlock(probe); // The read's probe also answers keys the receiver's plain chain lacks. // Only `length` and `next` reads can be answered for a native array or diff --git a/packages/compiler/test/js-ic-emission.test.ts b/packages/compiler/test/js-ic-emission.test.ts index e0ad8e329..aa4d83959 100644 --- a/packages/compiler/test/js-ic-emission.test.ts +++ b/packages/compiler/test/js-ic-emission.test.ts @@ -143,6 +143,50 @@ console.log(read(o), read(p), read(t), at(dyn([5, 6]), 1), at(p, "x"), at(t, "ta } }); +test("property reads answer class instances' declared fields inline", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-capsule-")); + try { + const entry = join(dir, "main.js"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +class Point { + constructor(x) { this.x = x; } +} +class Tagged extends Point { + constructor(x, tag) { super(x); this.tag = tag; } +} +class Gauge { + get x() { return 9; } +} +function dyn(v) { return v; } +function read(o) { return o.x; } +function tag(o) { return o.tag; } +var p = dyn(new Point(4)); +var t = dyn(new Tagged(6, "t")); +console.log(read(p), read(t), read(dyn(new Gauge())), read({ x: 1 }), tag(t)); +`, + ); + 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); + const read = body(llvm, "read"); + // Point and Tagged instances load the field after one key compare + // each; Gauge's getter, and every other receiver, keeps the probe and + // the helper. + expect(read.match(/^icc\.m\d+:/gm)).toHaveLength(2); + expect(read).toContain("@scr_js_get_probe("); + expect(read).toMatch(/@sc_b?f__x25_dyn_class_property_\d+\(ptr %t\d+\) noinline cold/); + expect(body(llvm, "tag").match(/^icc\.m\d+:/gm)).toHaveLength(1); + } 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 { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8410-dynamic-class-instance-reads.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8410-dynamic-class-instance-reads.js.json new file mode 100644 index 000000000..cec5ee64b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8410-dynamic-class-instance-reads.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8410-dynamic-class-instance-reads.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8411-dynamic-base-keyed-instances/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8411-dynamic-base-keyed-instances/main.ts.json new file mode 100644 index 000000000..5d15c0479 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8411-dynamic-base-keyed-instances/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8411-dynamic-base-keyed-instances/reader.cjs", + "/tests/corpus/8411-dynamic-base-keyed-instances/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8410-dynamic-class-instance-reads.js b/tests/corpus/8410-dynamic-class-instance-reads.js new file mode 100644 index 000000000..83b9cad9b --- /dev/null +++ b/tests/corpus/8410-dynamic-class-instance-reads.js @@ -0,0 +1,62 @@ +// Untyped reads and writes of class instances at property sites: base and +// subclass instances, subclass instances returned where the base class is +// the inferred type, a subclass that redeclares an inherited field, a +// getter, fields set after construction and fields never set. +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } +} +class Tagged extends Point { + constructor(x, y, tag) { + super(x, y); + this.tag = tag; + } +} +class Shifted extends Point { + label = "s"; + x = 100; +} +class Gauge { + get x() { + return 9; + } +} +class Late { + constructor(set) { + if (set) this.x = "late"; + } +} +function make(i) { + if (i % 3 === 0) return new Point(i, -i); + if (i % 3 === 1) return new Tagged(i, i * 2, "t" + i); + return new Shifted(i, 0); +} +function readX(o) { + return o.x; +} +function readY(o) { + return o.y; +} +function writeX(o, v) { + o.x = v; +} +const values = []; +for (let i = 0; i < 9; i++) values.push(make(i)); +values.push(new Gauge(), new Late(true), new Late(false), { x: "plain", y: 1 }, { y: 2, x: "swapped" }); +let sum = 0; +for (let round = 0; round < 3; round++) { + for (const v of values) { + const x = readX(v); + sum += typeof x === "number" ? x : x === undefined ? 1000 : x.length; + const y = readY(v); + if (typeof y === "number") sum += y; + } + for (let i = 0; i < 9; i++) writeX(values[i], round * 10 + i); +} +console.log(sum); +console.log(values.map((v) => String(readX(v))).join(" ")); +console.log(values.map((v) => String(readY(v))).join(" ")); +writeX(values[1], "changed"); +console.log(readX(values[1]), readX(values[2]), values[2].label, values[1].tag); diff --git a/tests/corpus/8411-dynamic-base-keyed-instances/main.ts b/tests/corpus/8411-dynamic-base-keyed-instances/main.ts new file mode 100644 index 000000000..5c0caa936 --- /dev/null +++ b/tests/corpus/8411-dynamic-base-keyed-instances/main.ts @@ -0,0 +1,32 @@ +// Untyped property reads of class instances that typed code passes as their +// base class: subclass instances in a Point[] read their inherited field and +// their own field through an untyped module. +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const reader: { + readX(o: unknown): unknown; + readTag(o: unknown): unknown; + sum(list: unknown[]): number; +} = require("./reader.cjs"); + +class Point { + x: any; + constructor(x: any) { + this.x = x; + } +} +class Tagged extends Point { + tag: any; + constructor(x: any, tag: any) { + super(x); + this.tag = tag; + } +} +function make(i: number): Point { + return i % 2 === 0 ? new Point(i) : new Tagged(i * 10, "t" + i); +} +const list: Point[] = []; +for (let i = 0; i < 6; i++) list.push(make(i)); +console.log(list.map((p) => String(reader.readX(p))).join(" ")); +console.log(list.map((p) => String(reader.readTag(p))).join(" ")); +console.log(reader.sum(list)); diff --git a/tests/corpus/8411-dynamic-base-keyed-instances/package.json b/tests/corpus/8411-dynamic-base-keyed-instances/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/tests/corpus/8411-dynamic-base-keyed-instances/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/8411-dynamic-base-keyed-instances/reader.cjs b/tests/corpus/8411-dynamic-base-keyed-instances/reader.cjs new file mode 100644 index 000000000..941299ed5 --- /dev/null +++ b/tests/corpus/8411-dynamic-base-keyed-instances/reader.cjs @@ -0,0 +1,11 @@ +exports.readX = function (o) { + return o.x; +}; +exports.readTag = function (o) { + return o.tag; +}; +exports.sum = function (list) { + let s = 0; + for (let i = 0; i < list.length; i++) s += list[i].x; + return s; +};