From 6d73515bf138293be49c0b9fc6fae116d0286ef4 Mon Sep 17 00:00:00 2001 From: Ge Li <118751170+galleonli@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:41:50 -0400 Subject: [PATCH] perf(javascript): prune typed-array element reference escapes (#5139) * perf(javascript): prune typed-array element reference escapes * fix(javascript): retain references from typed-array element properties --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ...ticAnalysis.typed_array_references.test.ts | 289 ++++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 58 +++- .../javascriptSemanticTypedArrays.ts | 73 +++++ 3 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.typed_array_references.test.ts create mode 100644 src/domain/javascript/javascriptSemanticTypedArrays.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.typed_array_references.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.typed_array_references.test.ts new file mode 100644 index 0000000000..c0e7849476 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.typed_array_references.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vitest"; + +import { + analyzeJavaScriptSemantics, + indexJavaScriptBindingsWithReceiversSteps, +} from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; + +const mutationBindings = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Failed to parse fixture"); + expect(parsed.errors).toEqual([]); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return [...index.bindings.bindingsById.values()]; +}; + +const escapes = (source: string, name = "value") => { + const matches = mutationBindings(source).filter( + (binding) => binding.name === name, + ); + expect(matches).toHaveLength(1); + return matches[0]!.escapedPaths; +}; + +describe("integer-indexed values in reference propagation", () => { + it.each([ + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float16Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "globalThis.Int32Array", + ])( + "does not retain reference effects for %s element values", + (constructor) => { + expect( + escapes(` + const heap = new ${constructor}(16); + const value = heap[index >> 2]; + consume(value); + `), + ).toEqual([]); + }, + ); + + it.each([ + "0", + "-0", + "-1", + "1.5", + "1e999", + '"NaN"', + '"Infinity"', + '"-0"', + '"1"', + "index >>> shift", + "index >> 2", + "index & 255", + "+index", + "index / 2", + ])("recognizes Number and canonical numeric keys: %s", (key) => { + expect( + escapes( + `const heap = new Int32Array(16); const value = heap[${key}]; consume(value);`, + ), + ).toEqual([]); + }); + + it.each([ + '"01"', + '"1.0"', + '" 1"', + '"+1"', + '"extra"', + '"buffer"', + '"__proto__"', + "key", + "index + 1", + "index >> shift", + "9007199254740993n", + ])( + "retains possible references for an ordinary or unknown property: %s", + (key) => { + expect( + escapes( + `const heap = new Int32Array(16); const value = heap[${key}]; consume(value);`, + ).length, + ).toBeGreaterThan(0); + }, + ); + + it.each([ + "const Int32Array = Factory;", + "globalThis.Int32Array = Factory;", + "replace(globalThis);", + "(0, eval)(source);", + "eval(source);", + ])("keeps uncertain constructor identities conservative: %s", (prefix) => { + expect( + escapes( + `${prefix} const heap = new Int32Array(16); const value = heap[index >> 2]; consume(value);`, + ).length, + ).toBeGreaterThan(0); + }); +}); + +describe("references reached through typed-array element properties", () => { + it.each([ + ["Int32Array", "0", "constructor"], + ["BigInt64Array", "0", "constructor"], + ["Int32Array", "index >> 2", "toString"], + ["BigInt64Array", "index >> 2", "toString"], + ["Int32Array", "0", "customReference"], + ["BigInt64Array", "0", "customReference"], + ])("retains %s[%s].%s references", (constructor, key, property) => { + expect( + escapes(` + const heap = new ${constructor}(16); + const value = heap[${key}]; + consume(value.${property}); + `).length, + ).toBeGreaterThan(0); + }); + + it.each(["Int32Array", "BigInt64Array"])( + "retains unknown property reads from %s elements", + (constructor) => { + expect( + escapes(` + const heap = new ${constructor}(16); + const value = heap[index >> 2]; + consume(value[property]); + `).length, + ).toBeGreaterThan(0); + }, + ); + + it.each(["Int32Array", "BigInt64Array"])( + "retains nested property reads through %s element aliases", + (constructor) => { + expect( + escapes(` + const heap = new ${constructor}(16); + const element = heap[index >> 2]; + const value = element; + consume(value.constructor.prototype); + `).length, + ).toBeGreaterThan(0); + }, + ); +}); + +describe("typed-array reference dependencies", () => { + it("propagates numeric-element facts through aliases and buffer replacement", () => { + expect( + escapes(` + let heap = new Int32Array(buffer); + function grow(next) { heap = new Int32Array(next); } + const alias = heap; + let value = alias[index >> 2]; + value = flag ? heap[4] : alias[index >>> 0]; + consume(value); + `), + ).toEqual([]); + }); + + it("does not lose a reference mixed with an integer-indexed value", () => { + const ir = analyzeJavaScriptSemantics(` + export function result() { + const child = { value: 1 }; + const heap = flag ? new Int32Array(16) : [child]; + const value = heap[0]; + consume(value); + return child.value; + } + `); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("keeps distinct numeric slots of ordinary arrays separate", () => { + const ir = analyzeJavaScriptSemantics(` + export function result() { + const child = { value: 1 }; + const array = [0, child]; + const zero = array[0]; + const value = array[1]; + consume(value); + return child.value; + } + `); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("keeps unknown and numeric projections distinct on the same view", () => { + expect( + escapes(` + const heap = new Int32Array(16); + const scalar = heap[index >> 2]; + const value = heap[key]; + consume(value); + `).length, + ).toBeGreaterThan(0); + }); + + it.each([ + "function result(Int32Array) {", + "function result(globalThis) { const Int32Array = globalThis.Int32Array;", + ])("does not assume a shadowed constructor is intrinsic: %s", (prefix) => { + expect( + escapes(`${prefix} + const heap = new Int32Array(16); + const value = heap[0]; + consume(value); + }`).length, + ).toBeGreaterThan(0); + }); + + it.each(["new Proxy(new Int32Array(16), handler)", "new CustomArray(16)"])( + "retains references from an uncertain receiver: %s", + (receiver) => { + expect( + escapes( + `const heap = ${receiver}; const value = heap[0]; consume(value);`, + ).length, + ).toBeGreaterThan(0); + }, + ); + + it("retains references when a view is reassigned an ordinary array", () => { + const ir = analyzeJavaScriptSemantics(` + export function result() { + const child = { value: 1 }; + let heap = new Int32Array(16); + heap = [child]; + const value = heap[0]; + consume(value); + return child.value; + } + `); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("retains escapes of the view itself and writes to its elements", () => { + const bindings = mutationBindings(` + const heap = new Int32Array(16); + heap[index >> 2] = 5; + consume(heap); + `); + const heap = bindings.find((binding) => binding.name === "heap")!; + expect(heap.memberWrites.length).toBeGreaterThan(0); + expect(heap.escapedPaths.length).toBeGreaterThan(0); + }); + + it("does not retain the cross-product of scalar assignments and store sites", () => { + const stores = Array.from( + { length: 48 }, + (_, i) => + `value = heap[index + ${i} >> 2]; heap[index + ${i + 1} >> 2] = value;`, + ).join("\n"); + const bindings = mutationBindings(` + const heap = new Int32Array(buffer); + function copy(index) { let value = 0; ${stores} } + `); + expect( + bindings.reduce((n, binding) => n + binding.escapedPaths.length, 0), + ).toBe(0); + expect( + bindings.find((binding) => binding.name === "heap")!.memberWrites.length, + ).toBeGreaterThan(0); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 7028d758bf..099d8865e9 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1,5 +1,9 @@ import * as t from "@babel/types"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; +import { + isIntrinsicTypedArrayConstruction, + isTypedArrayNumericIndex, +} from "./javascriptSemanticTypedArrays.js"; import { semanticGlobalProcessOrigin, @@ -1218,6 +1222,8 @@ type MutationObservableOrigin = | "ambient" | "array" | "array-prototype" + | "typed-array" + | "typed-array-element" | "function" | "opaque"; @@ -1229,6 +1235,7 @@ interface MutationObserverNode { readonly read?: { readonly source: MutationObserverNode; readonly key: PropertyPath[number]; + readonly numericIndex: boolean; }; } @@ -1283,15 +1290,16 @@ const observableMutationPaths = ( const project = ( source: MutationObserverNode, path: PropertyPath, + numericIndex = false, ): MutationObserverNode => { let observer = source; for (const key of path) { - const identity = JSON.stringify( + const identity = `${numericIndex ? "numeric:" : ""}${JSON.stringify( key === null || typeof key === "object" ? key : String(key), - ); + )}`; let selected = observer.projections?.get(identity); if (selected === undefined) { - selected = create({ source: observer, key }); + selected = create({ source: observer, key, numericIndex }); (observer.projections ??= new Map()).set(identity, selected); (observer.dependents ??= new Set()).add(selected); schedule(selected); @@ -1352,6 +1360,11 @@ const observableMutationPaths = ( // Keep class/getter projections distinct so cycle pruning does not // discard observable escapes through a shared getter result. if ( + t.isNewExpression(expression) && + isIntrinsicTypedArrayConstruction(expression, state) + ) + (observer.origins ??= new Set()).add("typed-array"); + else if ( t.isNewExpression(expression) && semanticClassCandidates(expression.callee, state).length === 0 ) @@ -1383,12 +1396,19 @@ const observableMutationPaths = ( t.isOptionalMemberExpression(expression) ) link( - project(expressionNode(expression.object), [ - semanticStaticPropertyKey( + project( + expressionNode(expression.object), + [ + semanticStaticPropertyKey( + expression.property, + expression.computed, + ), + ], + isTypedArrayNumericIndex( expression.property, expression.computed, ), - ]), + ), observer, ); else @@ -1416,16 +1436,29 @@ const observableMutationPaths = ( for (const source of observer.sources ?? []) for (const origin of source.origins ?? []) add(origin); if (observer.read !== undefined) { - const { source, key } = observer.read; + const { source, key, numericIndex } = observer.read; if (key === null || typeof key === "object") { // Dynamic keys and copied-slot masks retain their original paths. // Enumerating their exclusion combinations would recreate the growth // that the mutation collector's selection union already prevents. - if ((source.origins?.size ?? 0) > 0) add("opaque"); + for (const origin of source.origins ?? []) + add( + origin === "typed-array" && numericIndex + ? "typed-array-element" + : "opaque", + ); } else for (const origin of source.origins ?? []) { if (typeof origin === "string") { - if (origin === "array-prototype" || origin === "opaque") + if (origin === "typed-array") { + // Custom properties can hold references; only canonical numeric + // indices have the native Number/BigInt/undefined result contract. + add(numericIndex ? "typed-array-element" : "opaque"); + } else if (origin === "typed-array-element") { + // Number/BigInt property reads can expose constructors, methods + // or objects returned by prototype getters after boxing. + add("opaque"); + } else if (origin === "array-prototype" || origin === "opaque") add(origin); else if ( origin === "ambient" && @@ -1477,7 +1510,12 @@ const observableMutationPaths = ( if (effect === "escape") { observer = project(observer, path); settle(); - return (observer.origins?.size ?? 0) === 0 ? null : path; + const origins = observer.origins; + return origins === undefined || + origins.size === 0 || + (origins.size === 1 && origins.has("typed-array-element")) + ? null + : path; } if ((observer.origins?.size ?? 0) === 0) return path.length === 0 ? path : [null]; diff --git a/src/domain/javascript/javascriptSemanticTypedArrays.ts b/src/domain/javascript/javascriptSemanticTypedArrays.ts new file mode 100644 index 0000000000..5bda6960c1 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticTypedArrays.ts @@ -0,0 +1,73 @@ +import * as t from "@babel/types"; + +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; + +const typedArrayConstructors = [ + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float16Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", +]; + +/** Recognize native integer-indexed allocations, not constructor spelling alone. */ +export const isIntrinsicTypedArrayConstruction = ( + node: t.NewExpression, + state: JavaScriptSemanticAnalysisState, +): boolean => + !semanticOpaqueGlobalMayHaveChanged(node, state) && + typedArrayConstructors.some( + (name) => + isUnmodifiedGlobalReference(node.callee, state, name) && + !state.ambientEscapes.covers(["global", name]), + ); + +/** A successful evaluation of these expressions produces a Number, not a key object. */ +const hasNumberResult = (node: t.Node): boolean => { + const pending = [node]; + while (pending.length > 0) { + const expression = unwrapJavaScriptExpression(pending.pop()!).node; + if (t.isNumericLiteral(expression)) return true; + if (t.isUnaryExpression(expression)) { + if (expression.operator === "+") return true; + if (expression.operator === "-" || expression.operator === "~") + pending.push(expression.argument); + } else if (t.isBinaryExpression(expression)) { + if (expression.operator === ">>>") return true; + // These operators reject mixed Number/BigInt operands. Either Number + // operand proves a Number result on success; + can concatenate strings. + if ( + ["-", "*", "/", "%", "**", "<<", ">>", "&", "|", "^"].includes( + expression.operator, + ) + ) + pending.push(expression.left, expression.right); + } + } + return false; +}; + +/** Numeric indices never expose an object, even out of bounds or on a detached view. */ +export const isTypedArrayNumericIndex = ( + key: t.Node, + computed: boolean, +): boolean => { + if (!computed) return false; + const expression = unwrapJavaScriptExpression(key).node; + if (t.isStringLiteral(expression)) + return ( + expression.value === "-0" || + String(Number(expression.value)) === expression.value + ); + return hasNumberResult(expression); +};