From b28c0337fcb3f34533ffb61a8220e802eb6973e7 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:20:05 +0800 Subject: [PATCH] perf(javascript): reduce retained semantic analysis allocations (#1601) Reduce retained semantic allocations and memoize lexical scope ancestry while preserving semantic identities. Refs #1065 and #1563; the complete-directory memory failure remains open. --- ...scriptSemanticAnalysis.scope_depth.test.ts | 43 ++++++++++++ .../javascript/javascriptSemanticAnalysis.ts | 6 +- .../javascript/javascriptSemanticCalls.ts | 8 ++- .../javascriptSemanticMemberMutations.ts | 68 ++++++++----------- .../javascript/javascriptSemanticObjects.ts | 8 ++- .../javascript/javascriptSemanticReturns.ts | 9 ++- .../javascript/javascriptSemanticState.ts | 27 +++++++- 7 files changed, 125 insertions(+), 44 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts new file mode 100644 index 0000000000..175b22cc24 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.scope_depth.test.ts @@ -0,0 +1,43 @@ +import { expect, it, vi } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js"; + +it("bounds ancestry reads for repeated deep lexical references", () => { + const ancestryReads = (depth: number): number => { + const original = WeakMap.prototype.get; + let reads = 0; + const get = vi.spyOn(WeakMap.prototype, "get").mockImplementation(function ( + this: WeakMap, + key, + ) { + const value: unknown = original.call(this, key); + if ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ) + reads++; + return value; + }); + try { + const ir = analyzeJavaScriptSemantics( + `const a=1; module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`, + ); + expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 }); + expect(onlyBinding(ir, "a").value).toEqual({ + status: "literal", + value: 1, + }); + return reads; + } finally { + get.mockRestore(); + } + }; + + const smaller = ancestryReads(80); + const larger = ancestryReads(160); + expect(smaller).toBeGreaterThan(0); + expect(larger).toBeLessThan(3 * smaller); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 2719c5589e..f0b299b1cb 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -444,6 +444,7 @@ function* collectDefinitionsSteps( }[] = []; yield* traverseJavaScriptAstSteps(program, { enter: (node, parent, readAncestors) => { + if (parent !== null) state.parentsByNode.set(node, parent); let parentScope = currentSemanticScope(stack); if ( parent !== null && @@ -493,7 +494,10 @@ function* collectDefinitionsSteps( }); } const scope = currentSemanticScope(stack); - state.scopeByNode.set(node, scope); + // Other nodes inherit the nearest boundary through their AST parents. + // Avoid a second full-tree index for the same lexical relationship. + if (parent === null || openedScopes.has(node)) + state.scopeByNode.set(node, scope); collectSemanticCallable({ node, parent, diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 2d5f94abd7..53ada16541 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -428,4 +428,10 @@ const enclosingFunction = ( const semanticCallSiteId = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, ): string => - `call:${t.isNewExpression(node) ? "construct" : "call"}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`; + // Join once so retained IDs do not keep a chain of intermediate strings. + [ + "call", + t.isNewExpression(node) ? "construct" : "call", + String(node.start ?? -1), + String(node.end ?? -1), + ].join(":"); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 12ceec71d0..ed5d2fb090 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -40,11 +40,6 @@ export function* collectSemanticMemberMutationsSteps( state: JavaScriptSemanticAnalysisState, ): Generator { const parents = state.parentsByNode; - yield* traverseJavaScriptAstSteps(program, { - enter: (node, parent) => { - if (parent !== null) parents.set(node, parent); - }, - }); const normalizePath = observableMutationPaths(state); const returnedReferences = createSemanticReturnedReferences(state); const recordedEffects = new Set(); @@ -670,10 +665,10 @@ type MutationObservableOrigin = | "opaque"; interface MutationObserverNode { - readonly origins: Set; - readonly sources: Set; - readonly dependents: Set; - readonly projections: Map; + origins?: Set; + sources?: Set; + dependents?: Set; + projections?: Map; readonly read?: { readonly source: MutationObserverNode; readonly key: PropertyPath[number]; @@ -700,22 +695,17 @@ const observableMutationPaths = ( queued.add(node); pending.push(node); }; - const create = ( - read?: MutationObserverNode["read"], - ): MutationObserverNode => ({ - origins: new Set(), - sources: new Set(), - dependents: new Set(), - projections: new Map(), - ...(read === undefined ? {} : { read }), - }); + const create = (read?: MutationObserverNode["read"]): MutationObserverNode => + // Most bindings have no object origin or property projection. Materialize + // each collection only when it contains a fact or relationship. + read === undefined ? {} : { read }; const link = ( source: MutationObserverNode, target: MutationObserverNode, ): void => { - if (target.sources.has(source)) return; - target.sources.add(source); - source.dependents.add(target); + if (target.sources?.has(source) === true) return; + (target.sources ??= new Set()).add(source); + (source.dependents ??= new Set()).add(target); schedule(target); }; const expressionNode = (node: t.Node): MutationObserverNode => { @@ -736,11 +726,11 @@ const observableMutationPaths = ( const identity = JSON.stringify( key === null || typeof key === "object" ? key : String(key), ); - let selected = observer.projections.get(identity); + let selected = observer.projections?.get(identity); if (selected === undefined) { selected = create({ source: observer, key }); - observer.projections.set(identity, selected); - observer.dependents.add(selected); + (observer.projections ??= new Map()).set(identity, selected); + (observer.dependents ??= new Set()).add(selected); schedule(selected); } observer = selected; @@ -760,7 +750,7 @@ const observableMutationPaths = ( // Preserve exact paths where copies, defaults or iteration need their // reference-specific projections; an opaque origin prevents pruning. if (binding.referenceInitializers.length > 0) - observer.origins.add("opaque"); + (observer.origins ??= new Set()).add("opaque"); for (const initializer of binding.referenceInitializers) { for (const source of [ initializer, @@ -777,7 +767,7 @@ const observableMutationPaths = ( if (selected === undefined) break; const { expression, observer } = selected; if (t.isObjectExpression(expression) || t.isArrayExpression(expression)) - observer.origins.add(expression); + (observer.origins ??= new Set()).add(expression); else if (t.isIdentifier(expression)) { const binding = resolveSemanticBindingState( state, @@ -787,11 +777,12 @@ const observableMutationPaths = ( if (binding !== undefined) { const origin = bindingNodes.get(binding.bindingId); if (origin !== undefined) link(origin, observer); - } else if (expression.name === "Array") observer.origins.add("array"); + } else if (expression.name === "Array") + (observer.origins ??= new Set()).add("array"); else if ( ["globalThis", "global", "window", "self"].includes(expression.name) ) - observer.origins.add("ambient"); + (observer.origins ??= new Set()).add("ambient"); } else if ( t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression) @@ -818,21 +809,21 @@ const observableMutationPaths = ( queued.delete(observer); let changed = false; const add = (origin: MutationObservableOrigin): void => { - if (observer.origins.has(origin)) return; - observer.origins.add(origin); + if (observer.origins?.has(origin) === true) return; + (observer.origins ??= new Set()).add(origin); changed = true; }; - for (const source of observer.sources) - for (const origin of source.origins) add(origin); + for (const source of observer.sources ?? []) + for (const origin of source.origins ?? []) add(origin); if (observer.read !== undefined) { const { source, key } = 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) add("opaque"); + if ((source.origins?.size ?? 0) > 0) add("opaque"); } else - for (const origin of source.origins) { + for (const origin of source.origins ?? []) { if (typeof origin === "string") { if (origin === "array-prototype" || origin === "opaque") add(origin); @@ -869,7 +860,7 @@ const observableMutationPaths = ( } } if (changed) - for (const dependent of observer.dependents) schedule(dependent); + for (const dependent of observer.dependents ?? []) schedule(dependent); } }; settle(); @@ -881,15 +872,16 @@ const observableMutationPaths = ( if (effect === "escape") { observer = project(observer, path); settle(); - return observer.origins.size === 0 ? null : path; + return (observer.origins?.size ?? 0) === 0 ? null : path; } - if (observer.origins.size === 0) return path.length === 0 ? path : [null]; + if ((observer.origins?.size ?? 0) === 0) + return path.length === 0 ? path : [null]; for (let offset = 0; offset < path.length; offset++) { const key = path[offset]; if (key === undefined) break; observer = project(observer, [key]); settle(); - if (observer.origins.size === 0 && offset + 1 < path.length) + if ((observer.origins?.size ?? 0) === 0 && offset + 1 < path.length) return [...path.slice(0, offset + 1), null]; } return path; diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index 27f5d73b3c..08acf25f8a 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -179,7 +179,13 @@ const addObjectOperation = ( output: JavaScriptSemanticObjectOperation[], ): void => { output.push({ - objectOperationId: `object:${input.kind}:${String(input.node.start ?? -1)}:${String(input.node.end ?? -1)}`, + // These high-volume IDs must not retain intermediate concatenations. + objectOperationId: [ + "object", + input.kind, + String(input.node.start ?? -1), + String(input.node.end ?? -1), + ].join(":"), kind: input.kind, location: range(input.node), ownerCallableId: input.ownerCallableId, diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index fb51279bf0..80aeb1eb36 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -83,7 +83,14 @@ const semanticReturnSiteId = ( callableId: string, location: JavaScriptSemanticReturnSite["location"], ): string => - `${callableId}:return:${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`; + [ + callableId, + "return", + String(location.start.line), + String(location.start.column), + String(location.end.line), + String(location.end.column), + ].join(":"); /** Link exports to callables only when lexical resolution is unique and exact. */ export const resolveSemanticModuleCallables = ( diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 28ca97371e..f7ef253196 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -60,6 +60,7 @@ export interface JavaScriptSemanticAnalysisState { readonly parentsByNode: WeakMap; readonly scopes: JavaScriptSemanticScopeState[]; readonly scopesById: Map; + /** Scope boundaries and descendant scopes resolved on demand. */ readonly scopeByNode: WeakMap; readonly bindingsById: Map; readonly callables: JavaScriptSemanticCallable[]; @@ -94,7 +95,7 @@ export const resolveSemanticBindingState = ( node: t.Node, name: string, ): JavaScriptSemanticBindingState | undefined => { - let scope = state.scopeByNode.get(node); + let scope = semanticScopeForNode(state, node); while (scope !== undefined) { const binding = scope.bindings.get(name); if (binding !== undefined) return binding; @@ -113,7 +114,7 @@ export const semanticResolutionBlocked = ( node: t.Node, name: string, ): boolean => { - let scope = state.scopeByNode.get(node); + let scope = semanticScopeForNode(state, node); while (scope !== undefined) { if (scope.bindings.has(name)) return false; if (!scope.bindingsComplete) return true; @@ -125,6 +126,28 @@ export const semanticResolutionBlocked = ( return false; }; +const semanticScopeForNode = ( + state: JavaScriptSemanticAnalysisState, + node: t.Node, +): JavaScriptSemanticScopeState | undefined => { + let candidate: t.Node | undefined = node; + while (candidate !== undefined) { + const scope = state.scopeByNode.get(candidate); + if (scope !== undefined) { + // Repeated references in a deep expression share this ancestry. Cache + // the traversed path without eagerly indexing nodes never resolved. + let descendant: t.Node | undefined = node; + while (descendant !== undefined && descendant !== candidate) { + state.scopeByNode.set(descendant, scope); + descendant = state.parentsByNode.get(descendant); + } + return scope; + } + candidate = state.parentsByNode.get(candidate); + } + return undefined; +}; + /** Resolve one name starting from an explicitly selected lexical scope. */ export const resolveSemanticBindingFromScope = ( scope: JavaScriptSemanticScopeState,