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.
This commit is contained in:
Ysopwnable
2026-10-11 03:20:05 +08:00
committed by GitHub
parent f4e437e544
commit b28c0337fc
7 changed files with 125 additions and 44 deletions
@@ -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<object, unknown>,
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);
});
@@ -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,
@@ -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(":");
@@ -40,11 +40,6 @@ export function* collectSemanticMemberMutationsSteps(
state: JavaScriptSemanticAnalysisState,
): Generator<void, void> {
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<string>();
@@ -670,10 +665,10 @@ type MutationObservableOrigin =
| "opaque";
interface MutationObserverNode {
readonly origins: Set<MutationObservableOrigin>;
readonly sources: Set<MutationObserverNode>;
readonly dependents: Set<MutationObserverNode>;
readonly projections: Map<string, MutationObserverNode>;
origins?: Set<MutationObservableOrigin>;
sources?: Set<MutationObserverNode>;
dependents?: Set<MutationObserverNode>;
projections?: Map<string, MutationObserverNode>;
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;
@@ -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,
@@ -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 = (
@@ -60,6 +60,7 @@ export interface JavaScriptSemanticAnalysisState {
readonly parentsByNode: WeakMap<t.Node, t.Node>;
readonly scopes: JavaScriptSemanticScopeState[];
readonly scopesById: Map<string, JavaScriptSemanticScopeState>;
/** Scope boundaries and descendant scopes resolved on demand. */
readonly scopeByNode: WeakMap<t.Node, JavaScriptSemanticScopeState>;
readonly bindingsById: Map<string, JavaScriptSemanticBindingState>;
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,