diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index e62fae52a..a33213a9c 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -68,7 +68,7 @@ import { inputSuffixWrites, } from "./borrowed-inputs.js"; import type { Writes } from "./reference-writes.js"; -import { emitJsIcCall, jsIcSiteOf } from "./js-ic.js"; +import { capsuleCallSite, emitCapsuleCall, emitJsIcCall, jsIcSiteOf } from "./js-ic.js"; import { emitClassMembership, emitDowncastGuard } from "./narrow-guards.js"; import { registerReplay } from "./emit-journal.js"; @@ -110,6 +110,10 @@ export function emitCallExpr( // cache site (two-tier design S4). const site = jsIcSiteOf(host, e); if (site) return emitJsIcCall(host, e, callee, site); + // So is a method dispatch helper whose class branches a capsule + // receiver can call directly. + const capsuleCalls = capsuleCallSite(host, e, callee); + if (capsuleCalls) return emitCapsuleCall(host, e, callee, capsuleCalls); } if (callee.fastRead === "index" && !plainCall && !awaitEntry && !resultSlot) { const indexed = emitNumericIndexRead(host, e, callee); diff --git a/packages/compiler/src/backend/llvm/js-ic.ts b/packages/compiler/src/backend/llvm/js-ic.ts index 8b7ed10f9..7712cb774 100644 --- a/packages/compiler/src/backend/llvm/js-ic.ts +++ b/packages/compiler/src/backend/llvm/js-ic.ts @@ -13,6 +13,9 @@ * keyed dense array element at an integral number key: inline; * otherwise scr_js_keyed_probe (string keys of ordinary objects) * method dynInvoke's method read: scr_js_load_probe + * call a method dispatch helper's call: a capsule whose key is one of + * the helper's plain class branches calls that branch's method + * directly (capsuleCallSite); otherwise the helper * literal an object literal's constant-key member: scr_js_store_probe, * else scr_dyn_key_set * @@ -35,6 +38,7 @@ import { mangleBorrowedFunction } from "../mangle.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; import { classFieldBase, classFieldIndex } from "./classes.js"; import { DYN_KIND } from "./dyn.js"; +import { dispatchesCall } from "./integer-clones.js"; import type { ExprOf, LlValue, LlvmEmitterContext } from "./expr-context.js"; import { JS_BITS } from "./jsval-encoding.js"; import { jsImmediates } from "./jsval.js"; @@ -535,6 +539,320 @@ function inlineCapsuleLoad( }); } +/** A class instance's answer that a method dispatch helper gives by + * calling one compiled method: the branch `if (dyn.typedRefIs(p, KEY)) { + * M(R, p1, ..., pn); return [js.fromDyn](p) }` of a method that returns + * its receiver, or `{ return [js.fromDyn](M(R, p1, ..., pn)) }`, with R + * `dynCheck(p)` or an upcast of it. */ +interface CapsuleCall { + key: string; + className: string; + method: string; + /** The branch returns the receiver after the call. */ + returnsReceiver: boolean; +} + +/** A call of a method dispatch helper `%dyn.class.call.N(receiver, + * %dyn.class.callback.N(receiver), args..., calleeName)` whose class + * branches a site can call inline. */ +export interface CapsuleCallSite { + callback: IrFunction; + calls: CapsuleCall[]; +} + +/** The capsule call site a call from dynamic-tier code is, or null. + * + * The helper normalizes its receiver like a read helper, then tests each + * class branch; the callback the site calls before evaluating the + * arguments resolves own function-valued properties of bag classes. A + * capsule whose key is a branch's key calls the branch's method directly + * when the branch qualifies under capsuleReads' rule (every subclass the + * normalization can rebox the key to has a branch calling the same + * method) and the callback has no branch for the key or any of those + * subclasses, so it answers undefined for the capsule without effects. + * Module initializers call this way only inside loops (jsIcMethodSites). */ +export function capsuleCallSite( + host: Host, + e: ExprOf<"call">, + helper: IrFunction, +): CapsuleCallSite | null { + if (!jsIcMethods(host) || !helper.name.startsWith("%dyn.class.call.")) return null; + if (helper.async !== undefined || helper.generator !== undefined) return null; + if (e.type.kind !== "jsval" && e.type.kind !== "dyn") return null; + const { params } = helper; + const arity = params.length - 3; + if ( + arity < 0 || + e.args.length !== params.length || + params[1]!.localId !== "p.callback" || + params[params.length - 1]!.localId !== "p.calleeName" || + params.slice(2, -1).some((p, i) => p.localId !== `p.${i + 1}`) + ) + return null; + const [receiver, resolve] = e.args; + if ( + receiver?.kind !== "varRef" || + resolve?.kind !== "call" || + resolve.args.length !== 1 || + !isLocal(resolve.args[0]!, receiver.localId) + ) + return null; + const callback = host.fnByName.get(resolve.callee); + if (callback === undefined || callback.params.length !== 1) return null; + const resolved = callbackKeys(callback); + if (resolved === null) return null; + const branches = new Map(); + const self = params[0]!.localId; + for (const stmt of helper.body) { + if (normalizes(stmt, self)) continue; + if (stmt.kind !== "if" || stmt.else_ !== null) break; + if (notCapsule(stmt.cond, self) || classObjectTest(stmt.cond, self)) continue; + const key = brandKey(stmt.cond, self); + if (key === null) return null; + if (!branches.has(key)) branches.set(key, capsuleCall(host, stmt, self, key, arity)); + } + const reboxes = normalizationTargets(host); + if (reboxes === null) return null; + const calls: CapsuleCall[] = []; + for (const call of branches.values()) { + if (call === null) continue; + const targets = reboxes.get(call.key) ?? []; + if (resolved.has(call.key) || targets.some((t) => resolved.has(`object:${t}`))) continue; + const same = targets.every((target) => { + const other = branches.get(`object:${target}`); + return ( + other != null && + other.method === call.method && + other.returnsReceiver === call.returnsReceiver + ); + }); + if (same) calls.push(call); + } + return calls.length > 0 ? { callback, calls } : null; +} + +/** The capsule keys a dispatch callback has branches for, or null when + * its body has another form: the normalization, branches on capsule or + * class-object tests, and a final return of undefined. */ +function callbackKeys(callback: IrFunction): Set | null { + const value = callback.params[0]!.localId; + const keys = new Set(); + const body = callback.body; + for (let i = 0; i < body.length; i++) { + const stmt = body[i]!; + if (normalizes(stmt, value)) continue; + if (stmt.kind === "if" && stmt.else_ === null) { + if (notCapsule(stmt.cond, value) || classObjectTest(stmt.cond, value)) continue; + const key = brandKey(stmt.cond, value); + if (key === null) return null; + keys.add(key); + continue; + } + if (i === body.length - 1 && stmt.kind === "return" && returnsUndefined(stmt.value)) + return keys; + return null; + } + return null; +} + +/** `dyn.classIs(p, "classval:C")`: true only for a class object, never + * for a capsule. */ +function classObjectTest(cond: IrExpr, receiver: string): boolean { + return ( + cond.kind === "libCall" && + cond.fn === "dyn.classIs" && + cond.args.length === 2 && + isLocal(cond.args[0]!, receiver) + ); +} + +function returnsUndefined(value: IrExpr | null): boolean { + if (value?.kind === "libCall" && value.fn === "js.fromDyn" && value.args.length === 1) + value = value.args[0]!; + return ( + value?.kind === "dynFrom" && value.value.kind === "unitLit" && value.value.unit === "undefined" + ); +} + +/** The method a class branch calls, or null for a branch of another + * form. */ +function capsuleCall( + host: Host, + branch: IrStmt & { kind: "if" }, + receiver: string, + key: string, + arity: number, +): CapsuleCall | null { + const [first, second] = branch.then; + let call: IrExpr | null | undefined; + let returnsReceiver: boolean; + if (branch.then.length === 2 && first?.kind === "exprStmt" && second?.kind === "return") { + call = first.expr; + let value = second.value; + if (value?.kind === "libCall" && value.fn === "js.fromDyn" && value.args.length === 1) + value = value.args[0]!; + if (value === null || !isLocal(value, receiver) || call.type.kind !== "void") return null; + returnsReceiver = true; + } else if (branch.then.length === 1 && first?.kind === "return") { + call = first.value; + if (call?.kind === "libCall" && call.fn === "js.fromDyn" && call.args.length === 1) + call = call.args[0]!; + returnsReceiver = false; + } else return null; + if (call?.kind !== "call" || call.args.length !== arity + 1) return null; + let self = call.args[0]!; + if (self.kind === "upcast") self = self.value; + if (self.kind !== "dynCheck" || !isLocal(self.value, receiver) || self.type.kind !== "object") + return null; + if (key !== `object:${self.type.className}`) return null; + if (!call.args.slice(1).every((a, i) => isLocal(a, `p.${i + 1}`))) return null; + if (!plainMethod(host, call.callee, returnsReceiver)) return null; + return { key, className: self.type.className, method: call.callee, returnsReceiver }; +} + +/** A method the site can call with the ordinary or borrowed ABI and a + * dyn, jsval or void result. */ +function plainMethod(host: Host, name: string, returnsReceiver: boolean): boolean { + const fn = host.fnByName.get(name); + if (fn === undefined || fn.async !== undefined || fn.generator !== undefined) return false; + if (fn.captures !== undefined) return false; + if (host.int32Abis.has(name)) return false; + if (dispatchesCall(host.integerClones, host.currentFunctionName, name)) return false; + const result = fn.returnType.kind; + return returnsReceiver ? result === "void" : result === "dyn" || result === "jsval"; +} + +/** A call of a method dispatch helper from dynamic-tier code whose class + * branches qualify (capsuleCallSite). A capsule with one of their keys + * skips the callback (it answers undefined for it) and calls the + * branch's method with the capsule's instance; anything else calls the + * callback before the arguments and then the helper, as the plain call + * does. The arguments are evaluated once, after the callback. */ +export function emitCapsuleCall( + host: Host, + e: ExprOf<"call">, + helper: IrFunction, + site: CapsuleCallSite, +): LlValue { + const B = host.B; + const receiver = emitBorrowedInput(host, e.args[0]!); + const capsule = B.newLabel("iccall.cap"); + const resolve = B.newLabel("iccall.cb"); + const evaluate = B.newLabel("iccall.args"); + const matched = site.calls.map(() => B.newLabel("iccall.k")); + kindIs(host, receiver.name, DYN_KIND.TYPED_REF, capsule, resolve); + B.startBlock(capsule); + // Capsules boxed by this program share its key constants. + const key = load(host, "ptr", receiver.name, 40); + site.calls.forEach((call, i) => { + const same = B.tmp(); + B.line(`${same} = icmp eq ptr ${key}, ${host.cstr(call.key)}`); + const next = i + 1 < site.calls.length ? B.newLabel("iccall.t") : resolve; + B.condBr(same, matched[i]!, next); + if (next !== resolve) B.startBlock(next); + }); + for (const label of matched) { + B.startBlock(label); + B.br(evaluate); + } + B.startBlock(resolve); + const resolved = helperCall(host, e.args[1] as ExprOf<"call">, site.callback, [receiver]); + const resolveEnd = B.currentLabel(); + B.br(evaluate); + B.startBlock(evaluate); + // The branch a capsule takes, or -1 for the helper. + const branch = B.tmp(); + const branches = matched.map((label, i) => `[ ${i}, %${label} ]`); + B.line(`${branch} = phi i32 ${[...branches, `[ -1, %${resolveEnd} ]`].join(", ")}`); + // The callback's result for an inline capsule is never read: undefined + // for a jsval result, null for a dyn one. + const unread = + site.callback.returnType.kind === "jsval" + ? `inttoptr (i64 ${JS_BITS.undefined} to ptr)` + : "null"; + const cb = B.tmp(); + const cbIn = matched.map((label) => `[ ${unread}, %${label} ]`); + B.line(`${cb} = phi ptr ${[...cbIn, `[ ${resolved}, %${resolveEnd} ]`].join(", ")}`); + host.own({ name: cb, type: site.callback.returnType }); + const rest = e.args.slice(2).map((a) => emitBorrowedInput(host, a)); + const slow = B.newLabel("iccall.slow"); + const join = B.newLabel("iccall.j"); + const calls = site.calls.map(() => B.newLabel("iccall.m")); + const cases = calls.map((label, i) => `i32 ${i}, label %${label}`).join(" "); + B.terminate(`switch i32 ${branch}, label %${slow} [ ${cases} ]`); + const incoming: string[] = []; + site.calls.forEach((call, i) => { + B.startBlock(calls[i]!); + const instance = load(host, "ptr", receiver.name, 16); + const self: LlValue = { name: instance, type: { kind: "object", className: call.className } }; + const result = methodCall(host, call.method, [self, ...rest.slice(0, -1)]); + let out: string; + if (call.returnsReceiver) { + const held = host.retainFrame(receiver.name, DYN); + out = e.type.kind === "jsval" ? cellWord(host, held) : held; + } else { + const returned = host.fnByName.get(call.method)!.returnType; + out = returned.kind === e.type.kind ? result : asResult(host, result, e, true); + } + incoming.push(`[ ${out}, %${B.currentLabel()} ]`); + B.br(join); + }); + B.startBlock(slow); + const result = helperCall(host, e, helper, [ + receiver, + { name: cb, type: site.callback.returnType }, + ...rest, + ]); + incoming.push(`[ ${result}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(join); + const out = B.tmp(); + B.line(`${out} = phi ptr ${incoming.join(", ")}`); + return host.own({ name: out, type: e.type }); +} + +/** A held cell as a jsval word. */ +function cellWord(host: Host, cell: string): string { + const B = host.B; + const bits = B.tmp(); + const word = B.tmp(); + const tagged = B.tmp(); + B.line(`${bits} = ptrtoint ptr ${cell} to i64`); + B.line(`${word} = or i64 ${bits}, ${JS_BITS.cellTag}`); + B.line(`${tagged} = inttoptr i64 ${word} to ptr`); + return tagged; +} + +/** A direct call of a compiled method with the site's evaluated + * (borrowed) arguments, as helperCall passes them. Returns the owned + * result, or "" for a void method. */ +function methodCall(host: Host, name: string, args: LlValue[]): string { + const B = host.B; + const method = host.fnByName.get(name)!; + const borrowed = host.callLifetimes.borrowed.get(name); + const argList = args + .map((a, i) => { + const type = method.params[i]!.type; + const value = borrowed?.has(i) ? a.name : host.retainFrame(a.name, type); + return `${host.llType(type)} ${value}`; + }) + .join(", "); + const target = `@${borrowed ? mangleBorrowedFunction(name) : host.callTarget(name)}`; + if (method.returnType.kind === "void") { + const invoked = host.emitDirectCall(`call void ${target}(${argList})`, name); + if (!invoked && host.mayThrow.has(name)) host.emitPendingCheck(); + return ""; + } + let t = B.tmp(); + const invoked = host.emitDirectCall( + `${t} = call ${host.llType(method.returnType)} ${target}(${argList})`, + name, + ); + if (!invoked && host.mayThrow.has(name)) host.emitPendingCheck(); + if (borrowed && host.borrowedReturns.has(name)) t = host.retainFrame(t, method.returnType); + return t; +} + /** Emits a call to a classified helper as a cache site. */ export function emitJsIcCall( host: Host, diff --git a/packages/compiler/test/js-ic-emission.test.ts b/packages/compiler/test/js-ic-emission.test.ts index aa4d83959..3d1f98f53 100644 --- a/packages/compiler/test/js-ic-emission.test.ts +++ b/packages/compiler/test/js-ic-emission.test.ts @@ -187,6 +187,70 @@ console.log(read(p), read(t), read(dyn(new Gauge())), read({ x: 1 }), tag(t)); } }); +test("method calls on class instances call the method inline", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-js-ic-capsule-call-")); + try { + const entry = join(dir, "main.js"); + const outPath = join(dir, "main.ir.json"); + await writeFile( + entry, + ` +class Cursor { + constructor(x) { this.x = x; } + step(d) { this.x += d; return this; } + sum(k) { return this.x * k; } +} +class Marker extends Cursor { + constructor(x, name) { super(x); this.name = name; } +} +class Fast extends Cursor { + step(d) { this.x += d * 2; return this; } +} +class Shadowed { + scale(k) { return k; } +} +function move(c) { return c.step(1); } +function total(c) { return c.sum(3); } +function scaled(c) { return c.scale(2); } +var list = [0, 1, 2].map((i) => (i === 0 ? new Cursor(1) : i === 1 ? new Marker(2, "m") : new Fast(3))); +var own = new Shadowed(); +own.scale = function (k) { return -k; }; +for (const c of list) console.log(move(c).x, total(c)); +console.log(total({ sum: (k) => k + 1 }), scaled(own), scaled(new Shadowed())); +top: for (let i = 0; i < 2; i++) console.log(list[i].sum(i)); +console.log(list[2].sum(5)); +`, + ); + 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 inline = (fn: string): number => fn.match(/^iccall\.m\d+:/gm)?.length ?? 0; + // Fast overrides step, so the helper calls Cursor's step on Cursor and + // Marker capsules through a virtual call, and only Fast capsules call + // step inline. Every class answers sum with Cursor's, so all three + // call it inline; every other receiver keeps the callback and the + // helper. + const move = body(llvm, "move"); + expect(inline(move)).toBe(1); + expect(move).toContain("@sc_bf__x25_dyn_class_receiver_x3a_Fast_step("); + expect(move).toMatch(/@sc_b?f__x25_dyn_class_callback_\d+\([^)]*\) noinline cold/); + expect(move).toMatch(/@sc_b?f__x25_dyn_class_call_\d+\([^)]*\) noinline cold/); + const total = body(llvm, "total"); + expect(inline(total)).toBe(3); + expect(total).toMatch(/switch i32 %t\d+, label %iccall\.slow\d+ \[/); + // Shadowed's instances may have an own scale, so its capsules keep the + // callback, which reads it. + expect(inline(body(llvm, "scaled"))).toBe(0); + // A module initializer calls inline only inside its loops. + expect(inline(body(llvm, "_x25_init_0"))).toBe(3); + } 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/dynamic-capsule-method-calls.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-capsule-method-calls.js.json new file mode 100644 index 000000000..e35868827 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-capsule-method-calls.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-capsule-method-calls.js" + ], + "diags": [] +} diff --git a/tests/corpus/dynamic-capsule-method-calls.js b/tests/corpus/dynamic-capsule-method-calls.js new file mode 100644 index 000000000..f85126fba --- /dev/null +++ b/tests/corpus/dynamic-capsule-method-calls.js @@ -0,0 +1,133 @@ +// Untyped code calls methods of compiled class instances: methods that +// return their receiver, numbers, strings or nothing, inherited and +// overriding methods, an instance whose own property shadows a method, +// receivers of mixed kinds at one site, argument evaluation order, missing +// and extra arguments, and throws from the called method. +class Cursor { + constructor(x, y) { + this.x = x; + this.y = y; + } + step(dx, dy) { + this.x += dx; + this.y += dy; + return this; + } + sum(k) { + return (this.x + this.y) * k; + } + label(prefix) { + return prefix + "(" + this.x + "," + this.y + ")"; + } + reset() { + this.x = 0; + this.y = 0; + } + checked(v) { + if (v > 40) throw new RangeError("too large: " + v); + return v * 2; + } +} +class Marker extends Cursor { + constructor(x, y, name) { + super(x, y); + this.name = name; + } + label(prefix) { + return "[" + this.name + "] " + super.label(prefix); + } +} +class Fast extends Cursor { + step(dx, dy) { + this.x += dx * 2; + this.y += dy * 2; + return this; + } +} + +const order = []; +function note(tag, value) { + order.push(tag); + return value; +} + +function drive(list, rounds) { + let total = 0; + let text = ""; + for (let r = 0; r < rounds; r++) { + const c = list[r % list.length]; + const moved = c.step((r % 3) - 1, (r % 5) - 2); + total += moved === c ? 1 : 1000; + total += c.sum(r % 4); + if (r % 50 === 0) text += c.label("r" + r) + ";"; + if (Math.abs(c.x) > 60) c.reset(); + } + return total + " " + text; +} + +const cursors = []; +for (let i = 0; i < 6; i++) { + if (i % 3 === 0) cursors.push(new Cursor(i, -i)); + else if (i % 3 === 1) cursors.push(new Marker(i, i * 2, "m" + i)); + else cursors.push(new Fast(-i, i)); +} +console.log(drive(cursors, 400)); + +// Instances built by one expression for both classes. +const mixed = []; +for (let i = 0; i < 4; i++) mixed.push(i % 2 === 0 ? new Cursor(i, i) : new Marker(i, -i, "t" + i)); +console.log(drive(mixed, 200)); +console.log(mixed.map((c) => c.label("m")).join(" ")); + +// The same site sees object literals and a shadowing own property. +const literal = { + x: 1, + y: 2, + step(dx, dy) { + this.x -= dx; + this.y -= dy; + return this; + }, + sum(k) { + return k; + }, + label(prefix) { + return prefix + "!"; + }, + reset() {}, +}; +const shadowed = new Cursor(5, 5); +shadowed.sum = function (k) { + return -k; +}; +console.log(drive([cursors[0], literal, shadowed, cursors[1]], 120)); + +// Chained calls and results used as values. +let chain = new Marker(1, 1, "chain"); +for (let i = 0; i < 20; i++) chain = chain.step(1, 2).step(-0.5, 0.25); +console.log(chain.x, chain.y, chain.name, chain.label("end")); + +// Arguments are evaluated after the receiver, left to right. +const target = new Cursor(0, 0); +for (let i = 0; i < 3; i++) note("recv" + i, target).step(note("a" + i, 1), note("b" + i, 2)); +console.log(order.join(","), target.x, target.y); + +// Missing and extra arguments. +const loose = new Fast(1, 1); +for (let i = 0; i < 3; i++) { + loose.step(1); + loose.step(1, 2, 3); +} +console.log(loose.x, Number.isNaN(loose.y), cursors[2].sum(), cursors[3].reset()); + +// A throw from the called method unwinds to the caller's handler. +let caught = 0; +let doubled = 0; +for (let i = 35; i < 45; i++) { + try { + doubled += cursors[i % 2].checked(i); + } catch (e) { + if (caught++ === 0) console.log(e instanceof RangeError, e.message); + } +} +console.log(caught, doubled); diff --git a/tests/harness/corpus-costs/dynamic-capsule-method-calls.js.json b/tests/harness/corpus-costs/dynamic-capsule-method-calls.js.json new file mode 100644 index 000000000..04718fd49 --- /dev/null +++ b/tests/harness/corpus-costs/dynamic-capsule-method-calls.js.json @@ -0,0 +1 @@ +{ "plain": 3900, "san": 7200 }