From e31e2ebe759a106d62f9cb735920d89d7ec1cd72 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 13:10:25 -0700 Subject: [PATCH] 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 --- .../shape-inference/static-objects.ts | 82 ++++++++++++++++++- .../npm/cases/static-stores/js-main.cjs | 2 + .../fixtures/npm/cases/static-stores/main.ts | 2 + .../node_modules/static-stores/index.d.ts | 2 + .../node_modules/static-stores/index.js | 17 ++++ tests/harness/static-stores.test.ts | 2 +- 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index a702e4f24..ab7c8e5fc 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -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): 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: { diff --git a/tests/fixtures/npm/cases/static-stores/js-main.cjs b/tests/fixtures/npm/cases/static-stores/js-main.cjs index c844f5b74..9925086f2 100644 --- a/tests/fixtures/npm/cases/static-stores/js-main.cjs +++ b/tests/fixtures/npm/cases/static-stores/js-main.cjs @@ -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()); diff --git a/tests/fixtures/npm/cases/static-stores/main.ts b/tests/fixtures/npm/cases/static-stores/main.ts index ad0e3eefc..ee05b1adf 100644 --- a/tests/fixtures/npm/cases/static-stores/main.ts +++ b/tests/fixtures/npm/cases/static-stores/main.ts @@ -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()); diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts index 4a98d27dd..88a4d6aa8 100644 --- a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts @@ -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 }; diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js index 429116787..8e65f2fc7 100644 --- a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js @@ -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 = {}; diff --git a/tests/harness/static-stores.test.ts b/tests/harness/static-stores.test.ts index 050905371..303237392 100644 --- a/tests/harness/static-stores.test.ts +++ b/tests/harness/static-stores.test.ts @@ -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;