Call methods of class instances inline at untyped call sites (#2605)

This commit is contained in:
Chris Tate
2026-10-30 02:32:48 -05:00
committed by GitHub
6 changed files with 527 additions and 1 deletions
@@ -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);
+318
View File
@@ -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<string, CapsuleCall | null>();
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<string> | null {
const value = callback.params[0]!.localId;
const keys = new Set<string>();
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,
@@ -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 {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/dynamic-capsule-method-calls.js"
],
"diags": []
}
@@ -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);
@@ -0,0 +1 @@
{ "plain": 3900, "san": 7200 }