mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user