mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-02 07:43:42 +08:00
Dispatch a constant function table without a fallback when every caller passes one of its keys
When the key is a parameter of a function that never escapes, every call passes a string literal key of the table, and the body neither assigns the parameter nor uses arguments, the dispatch needs no arm for other keys. In nsbench the fallback boxed the Vec probe into a dynamic call, so every Vec carried the property bag and lived on the heap; without it the workload inlines into one loop. SCRIPTC_SHAPE_EXHAUSTIVE_TABLES=0 opts out. nsbench (n=20000, overlay on): 36 -> 8.2 ms (Node about 21 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
484fc2cb13
commit
e31e2ebe75
@@ -913,7 +913,63 @@ export function planStaticObjectRewrites(
|
||||
// key reads the table as written. The members do not use `this`, the
|
||||
// table has no accessors, and comparing a string runs no code, so only
|
||||
// the representation of the call changes.
|
||||
const tableCalls: { sf: ts.SourceFile; call: ts.CallExpression; arms: [string, string][] }[] = [];
|
||||
const tableCalls: {
|
||||
sf: ts.SourceFile;
|
||||
call: ts.CallExpression;
|
||||
arms: [string, string][];
|
||||
exhaustive: boolean;
|
||||
}[] = [];
|
||||
// Whether a key parameter only ever holds one of the table's keys: its
|
||||
// function never escapes, every call of it passes a string literal key
|
||||
// of the table, and its body neither assigns the parameter nor uses
|
||||
// `arguments` (which aliases parameters in sloppy code). The dispatch
|
||||
// then needs no arm for other keys.
|
||||
const literalKeysOnly = (keyDecl: Node, keys: ReadonlySet<string>): boolean => {
|
||||
if (process.env["SCRIPTC_SHAPE_EXHAUSTIVE_TABLES"] === "0") return false;
|
||||
if (!ts.isParameter(keyDecl) || !ts.isIdentifier(keyDecl.name)) return false;
|
||||
const fn = keyDecl.parent;
|
||||
if (fn === undefined || !ts.isFunctionLike(fn) || escapedFns.has(fn)) return false;
|
||||
for (const a of analysis.atoms.atoms)
|
||||
if (a.kind === "function" && a.node === fn && analysis.escaped.has(a)) return false;
|
||||
const index = (fn as ts.FunctionLikeDeclaration).parameters.nodes.indexOf(keyDecl);
|
||||
const name = keyDecl.name.text;
|
||||
let touched = false;
|
||||
const visit = (n: Node): void => {
|
||||
if (touched) return;
|
||||
if (ts.isIdentifier(n) && n.text === "arguments") touched = true;
|
||||
else if (
|
||||
ts.isBinaryExpression(n) &&
|
||||
n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
||||
n.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
|
||||
ts.isIdentifier(n.left) &&
|
||||
n.left.text === name
|
||||
)
|
||||
touched = true;
|
||||
else if (
|
||||
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
|
||||
ts.isIdentifier(n.operand) &&
|
||||
n.operand.text === name
|
||||
)
|
||||
touched = true;
|
||||
ts.forEachChild(n, (c) => {
|
||||
visit(c);
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
const body = (fn as { body?: Node }).body;
|
||||
if (body === undefined) return false;
|
||||
visit(body);
|
||||
if (touched) return false;
|
||||
const edges = analysis.calls.filter((c) => c.callee.node === fn);
|
||||
if (edges.length === 0) return false;
|
||||
return edges.every((e) => {
|
||||
if (!ts.isCallExpression(e.site)) return false;
|
||||
const args = e.site.arguments.nodes;
|
||||
if (args.slice(0, index + 1).some((a) => ts.isSpreadElement(a))) return false;
|
||||
const arg = args[index];
|
||||
return arg !== undefined && ts.isStringLiteral(arg) && keys.has(arg.text);
|
||||
});
|
||||
};
|
||||
for (const { op, atoms } of mergedOps) {
|
||||
if (op.kind !== "computedRead") continue;
|
||||
const node = op.node;
|
||||
@@ -954,16 +1010,34 @@ export function planStaticObjectRewrites(
|
||||
kept.push(`${where}: a member is not a hoisted function of this file`);
|
||||
continue;
|
||||
}
|
||||
tableCalls.push({ sf: call.getSourceFile(), call, arms });
|
||||
tableCalls.push({
|
||||
sf: call.getSourceFile(),
|
||||
call,
|
||||
arms,
|
||||
exhaustive: literalKeysOnly(keyDecl, new Set(arms.map(([k]) => k))),
|
||||
});
|
||||
}
|
||||
for (const { sf, call, arms } of tableCalls) {
|
||||
for (const { sf, call, arms, exhaustive } of tableCalls) {
|
||||
const node = call.expression as ts.ElementAccessExpression;
|
||||
const key = node.argumentExpression.getText();
|
||||
const args = call.arguments.nodes.map((a) => sliceWithCalls(sf, a)).join(", ");
|
||||
const table = sliceWithCalls(sf, node.expression);
|
||||
const text = arms
|
||||
const tested = exhaustive ? arms.slice(0, -1) : arms;
|
||||
const text = tested
|
||||
.map(([k, name]) => `${key} === ${JSON.stringify(k)} ? ${name}(${args}) : `)
|
||||
.join("");
|
||||
if (exhaustive) {
|
||||
callEdits.push({
|
||||
sf,
|
||||
edit: {
|
||||
start: ts.tokenStartOfNode(call),
|
||||
end: call.end,
|
||||
text: `(${text}${arms[arms.length - 1]![1]}(${args}))`,
|
||||
},
|
||||
});
|
||||
tableDispatches++;
|
||||
continue;
|
||||
}
|
||||
callEdits.push({
|
||||
sf,
|
||||
edit: {
|
||||
|
||||
@@ -5,4 +5,6 @@ let near = 0;
|
||||
for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++;
|
||||
console.log(near, lib.name(), lib.early, lib.after);
|
||||
console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" }));
|
||||
const key = process.argv.length > 99 ? "dbl" : "inc";
|
||||
console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3));
|
||||
console.log(other.seen, other.check());
|
||||
|
||||
@@ -6,4 +6,6 @@ let near = 0;
|
||||
for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++;
|
||||
console.log(near, lib.name(), lib.early, lib.after);
|
||||
console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" }));
|
||||
const key = process.argv.length > 99 ? "dbl" : "inc";
|
||||
console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3));
|
||||
console.log(other.seen, other.check());
|
||||
|
||||
+2
@@ -6,5 +6,7 @@ export declare const lib: {
|
||||
counter: { n: number; inc(): number };
|
||||
twice(a: number): number;
|
||||
tagOf(this: { tag: string }): string;
|
||||
apply(op: "inc" | "dbl", v: number): number;
|
||||
applyAny(op: string, v: number): number | string;
|
||||
};
|
||||
export declare const other: { seen: number; check(): boolean };
|
||||
|
||||
+17
@@ -30,6 +30,23 @@ lib.tagOf = function () {
|
||||
const read = () => this.tag;
|
||||
return read();
|
||||
};
|
||||
// A constant function table: `apply` is only ever called with literal keys
|
||||
// of the table, so its dispatch needs no arm for other keys; `applyAny` is
|
||||
// called with a computed key and keeps reading the table for other keys.
|
||||
lib.ops = {
|
||||
inc: function (v) {
|
||||
return v + 1;
|
||||
},
|
||||
dbl: function (v) {
|
||||
return v * 2;
|
||||
},
|
||||
};
|
||||
lib.apply = function (op, v) {
|
||||
return lib.ops[op](v);
|
||||
};
|
||||
lib.applyAny = function (op, v) {
|
||||
return lib.ops[op](v);
|
||||
};
|
||||
|
||||
// A store after a call: code ran before it, so no read folds.
|
||||
var other = {};
|
||||
|
||||
@@ -33,7 +33,7 @@ test(
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const report = (result.warnings ?? []).find((w) => w.code === "SC6103");
|
||||
expect(report?.message).toMatch(/, 5 constant reads\)/);
|
||||
expect(report?.message).toMatch(/2 function table calls, 5 constant reads\)/);
|
||||
const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" });
|
||||
pending.child.stdin?.end();
|
||||
const { stdout } = await pending;
|
||||
|
||||
Reference in New Issue
Block a user