perf(javascript): reuse semantic member-mutation lookups (#2941)

The semantic member-mutation collector revisits the same nodes while it closes
alias chains, and each visit re-derived results that cannot change within the
phase:

- resolveSemanticBindingState re-walked the scope chain for the same identifier
  node. Definition collection completes before this phase, and addBinding is
  reachable only from it, so a scope's bindings no longer change.
- directInitializerPosition, directMutationPosition and
  referenceInitializerPosition are pure functions of a node and the analysis
  parent map, but were recomputed for every mutation and initializer.
- markValue re-encoded the same capture tuple with JSON.stringify and built a
  fresh long key for every Set/Map lookup, so each lookup re-hashed the whole
  key. The capture key is now cached per binding, origin and owner, and the
  lifetime key is derived from it.

On pdfjs-dist 5.3.93 wasm/openjpeg_nowasm_fallback.js (462316 bytes, sha256
53c228428a8861035a34738966af263fc9d4cee6df25a0ad942af947469a0bd9) the semantic
phase takes 177.8 s instead of 272.9 s in-process, with a byte-identical
semantic IR. With --max-heap-mb 3072 and the default heap the base revision
exhausts the worker; with the default 300 s per-source deadline raised, the
completed CLI run takes 240.6 s instead of 396.7 s and its output is identical
apart from volatile identifiers.

Refs #2498

Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com>
This commit is contained in:
gongfppandgongfpp authored and GitHub committed 2026-10-14 03:09:34 +08:00
1 parent a9195c3a98
commit 0c6c571a01
2 files changed
+130 -20

No files matched your search

@@ -54,6 +54,57 @@ export function* collectSemanticMemberMutationsSteps(
const selections = new Map<string, readonly string[]>();
const coveringPaths = new Map<string, SemanticPropertyPathCoverage>();
let arrayIterationUnknown = false;
// Definition collection finishes before this phase, so a scope's bindings no
// longer change and one node resolves to one binding. Repeated visits of the
// same node otherwise repeat the same scope walk.
const resolvedBindings = new WeakMap<
t.Node,
JavaScriptSemanticBindingState | null
>();
const resolveBinding = (
node: t.Node,
name: string,
): JavaScriptSemanticBindingState | undefined => {
const cached = resolvedBindings.get(node);
if (cached !== undefined) return cached ?? undefined;
const resolved = resolveSemanticBindingState(state, node, name);
resolvedBindings.set(node, resolved ?? null);
return resolved;
};
// One capture tuple recurs across every alias it reaches. Re-encoding it
// would rebuild the same long key and force a fresh string hash per lookup.
const captureKeys = new WeakMap<
JavaScriptSemanticBindingState,
Map<string, string>
>();
const captureKeyOf = (
binding: JavaScriptSemanticBindingState,
originAt: t.Node | undefined,
ownerInitializer: t.Node | undefined,
): string => {
let byPosition = captureKeys.get(binding);
if (byPosition === undefined) {
byPosition = new Map();
captureKeys.set(binding, byPosition);
}
const positionKey = `${originAt?.start ?? "u"}:${originAt?.end ?? "u"}:${
ownerInitializer?.start ?? "u"
}:${ownerInitializer?.end ?? "u"}`;
const cached = byPosition.get(positionKey);
if (cached !== undefined) return cached;
const created = JSON.stringify([
binding.bindingId,
originAt?.start,
originAt?.end,
ownerInitializer?.start,
ownerInitializer?.end,
]);
byPosition.set(positionKey, created);
return created;
};
// A capture key already ends the array; a path stays one nested element.
const lifetimeKey = (captureKey: string, path: PropertyPath): string =>
`${captureKey.slice(0, -1)},${JSON.stringify(path)}]`;
const pendingReferences: {
readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number];
readonly path: PropertyPath;
@@ -121,11 +172,7 @@ export function* collectSemanticMemberMutationsSteps(
if (current === undefined) break;
const expression = unwrapJavaScriptExpression(current.node).node;
if (t.isIdentifier(expression)) {
const binding = resolveSemanticBindingState(
state,
expression,
expression.name,
);
const binding = resolveBinding(expression, expression.name);
if (binding === undefined) {
if (
(expression.name === "Array" &&
@@ -142,21 +189,18 @@ export function* collectSemanticMemberMutationsSteps(
// An alias restored from a saved reference can revisit the same binding
// at an earlier capture or a shorter getter-receiver path. Only a
// repeated capture and path closes a cycle.
const capture = [
binding.bindingId,
current.originAt?.start,
current.originAt?.end,
current.ownerInitializer?.start,
current.ownerInitializer?.end,
];
const lifetime = JSON.stringify([...capture, current.path]);
const captureKey = captureKeyOf(
binding,
current.originAt,
current.ownerInitializer,
);
const lifetime = lifetimeKey(captureKey, current.path);
if (current.bindings.has(lifetime)) continue;
// A self alias such as `node = node[key]` revisits its capture with the
// same prefix added on every cycle, so exact paths never repeat. When
// that prefix can never be pruned, escaping it once covers every longer
// effect, including writes below shared children.
// Only a capture already on this alias chain can repeat a prefix.
const captureKey = JSON.stringify(capture);
const revisited = current.bindings.has(captureKey);
let repeatedPrefix: PropertyPath | undefined;
for (
@@ -165,8 +209,7 @@ export function* collectSemanticMemberMutationsSteps(
start++
) {
const suffix = current.path.slice(start);
if (!current.bindings.has(JSON.stringify([...capture, suffix])))
continue;
if (!current.bindings.has(lifetimeKey(captureKey, suffix))) continue;
repeatedPrefix = current.path.slice(0, start);
break;
}
@@ -408,7 +451,7 @@ export function* collectSemanticMemberMutationsSteps(
if (!t.isNode(node)) continue;
const pathKey = JSON.stringify(value.path);
if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) {
const binding = resolveSemanticBindingState(state, node, node.name);
const binding = resolveBinding(node, node.name);
const identity = JSON.stringify([
binding === undefined ? null : binding.bindingId,
binding === undefined ? node.name : null,
@@ -12,6 +12,37 @@ interface StatementPosition {
readonly declaratorIndex: number;
}
/**
* Initializer positions depend only on a node and its parent map, which one
* analysis builds once and never changes. Different mutations and initializers
* re-derive the same positions along shared ancestry, so retain the first
* result for as long as its parent map is reachable.
*/
interface InitializerPositionCache {
readonly direct: WeakMap<t.Node, StatementPosition | null>;
readonly mutation: WeakMap<t.Node, StatementPosition | null>;
readonly reference: WeakMap<t.Node, StatementPosition | null>;
}
const initializerPositionCaches = new WeakMap<
WeakMap<t.Node, t.Node>,
InitializerPositionCache
>();
const initializerPositionCache = (
parents: WeakMap<t.Node, t.Node>,
): InitializerPositionCache => {
const cached = initializerPositionCaches.get(parents);
if (cached !== undefined) return cached;
const created: InitializerPositionCache = {
direct: new WeakMap(),
mutation: new WeakMap(),
reference: new WeakMap(),
};
initializerPositionCaches.set(parents, created);
return created;
};
const statementPosition = (
statement: t.Statement,
parents: WeakMap<t.Node, t.Node>,
@@ -42,7 +73,7 @@ const positionPrecedes = (
left.declaratorIndex >= 0 &&
left.declaratorIndex < right.declaratorIndex));
const directInitializerPosition = (
const computeDirectInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
@@ -67,7 +98,19 @@ const directInitializerPosition = (
return null;
};
const directMutationPosition = (
const directInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
const cache = initializerPositionCache(parents);
const cached = cache.direct.get(node);
if (cached !== undefined) return cached;
const computed = computeDirectInitializerPosition(node, parents);
cache.direct.set(node, computed);
return computed;
};
const computeDirectMutationPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
@@ -115,7 +158,19 @@ const directMutationPosition = (
}
};
const referenceInitializerPosition = (
const directMutationPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
const cache = initializerPositionCache(parents);
const cached = cache.mutation.get(node);
if (cached !== undefined) return cached;
const computed = computeDirectMutationPosition(node, parents);
cache.mutation.set(node, computed);
return computed;
};
const computeReferenceInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
@@ -137,6 +192,18 @@ const referenceInitializerPosition = (
}
};
const referenceInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
): StatementPosition | null => {
const cache = initializerPositionCache(parents);
const cached = cache.reference.get(node);
if (cached !== undefined) return cached;
const computed = computeReferenceInitializerPosition(node, parents);
cache.reference.set(node, computed);
return computed;
};
/** Resolve origins at their use or capture, never at a later transitive effect. */
export const semanticMutationInitializers = (
binding: JavaScriptSemanticBindingState,