Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ allocation when many paths are requested from one compilation unit; on a 1500-me
it roughly halves total allocation again on top of the previous change, with no effect on
normal code. No user-visible behavior change.

Performance: `AnnotatedTypeFactory.getPath` and dataflow now search for tree paths from the
tightest known starting point instead of rescanning the whole compilation unit. Previously a
path lookup during checking or flow analysis could rescan an entire class, making allocation
quadratic in the number of members; it is now linear. On a 6000-method class this roughly
halves total allocation (~32 GB to ~15 GB); on normal code there is no change. No user-visible
behavior change.

Fixed a bug that caused an IndexOutOfBoundsException for lambdas in varargs,
for type systems that had the Aliasing Checker as a subchecker, like the
Optional Checker.
Expand Down
37 changes: 31 additions & 6 deletions docs/developer/performance-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,31 @@ so small per-call wins paid back substantially.
case fails on the subtree-only variant). The original outward-expanding scan is correct
and is now documented in the code.

- **PR #1789 — linear (instead of quadratic) `getPath` searches (June 2026).** Even after #1786 +
#1788, a single class with very many methods allocated *super-linearly* (6000-method class: 32 GB,
~2.5–2.7×/doubling). A nearest-CF-frame allocation capture on a `gen-sized-program.py` size sweep
traced **57% of allocation at 6000 methods** to `com.sun.tools.javac.util.List$2` iterators that
`TreeScanner` allocates while `TreePathCacher.scan` *traverses* the tree — i.e. `getPath` searches
rescanning the whole class (**268M node visits** at 1500 methods). Instrumenting `getPath` showed
the targets were almost always local; they were just searched from too broad a start. Three causes,
all fixed by starting each search from the tightest known path:
- `AnnotatedTypeFactory.getPath`'s final fallback searched from `visitorTreePath` climbed up a
*fixed two levels*; for a method-body path that overshoots to the **class**, forcing a whole-class
rescan. It now searches from the original (tightest) `visitorTreePath` (the second overload still
expands outward for non-local targets). Alone this cut traversal 268M → 38.5M (−86%).
- `GenericAnnotatedTypeFactory.performFlowAnalysis` pinned `visitorTreePath` to the enclosing
*class*; flow-analysis-time inference lookups now run against the **body** being analyzed.
(A no-op by itself — the climb above negated it — but needed together with the first fix.)
- `CFCFGBuilder`'s per-body `getPath(root, code)` scanned from the compilation-unit root (O(members)
per body). `analyze` now primes that body's path in O(1) from the class path (`class → method →
body`, an unambiguous extension; methods only — lambdas/initializers fall through), so the lookup
is a cache hit.
Result: per-method allocation went from *rising* (3.3 → 5.4 MB/method, quadratic) to *flat*
(2.6 → 2.5 MB/method, linear); 6000-method class **32.1 GB → 14.8 GB (−54%)**, growing with size.
**No effect on normal code** (all-systems unchanged) and **no correctness risk**: all three are
search *hints* — `getPath` always returns the correct path (guarded by
`framework/util/TreePathCacherTest`'s JDK-equivalence check). Validated with `alltests`.

### Value Checker

- **PR #1647** — *Cache a frequent conversion in the Value Checker.*
Expand Down Expand Up @@ -1353,12 +1378,12 @@ CF-controllable clusters and their state, highest-leverage first:
`TreePath.<init>` was under `AnnotatedTypeFactory.getPath`'s slow path (uncached
`TreePath.getPath(root, tree)` scans on cache miss + heuristic failure). PR #1786 caches the
per-body lookup; PR #1788 makes `TreePathCacher` lazy and routes `getPath` through it, removing
most of that allocation. **Residual (new, open):** a single class with very many methods still
allocates *super-linearly* even after both (deterministic harness, lazy cacher: 1500 methods 4.9
GB → 3000 11.8 GB → 6000 32.1 GB, ~2.5–2.7× per doubling), so an O(members²) cost remains
*somewhere off* the now-cached `getPath` path. Type-argument inference re-walking outer paths is a
suspect. Next step: a size sweep (`gen-sized-program.py`) plus an `alloc`-by-nearest-CF-frame
capture to locate the surviving quadratic before proposing anything.
most of that allocation. **Residual — RESOLVED by PR #1789.** A single class with very many
methods still allocated *super-linearly* after #1786/#1788 (1500 methods 4.9 GB → 3000 11.8 GB →
6000 32.1 GB, ~2.5–2.7× per doubling). An `alloc`-by-nearest-CF-frame capture (via a
`gen-sized-program.py` size sweep) traced it to `getPath` searches that rescanned the whole class
per lookup; PR #1789 starts those searches from the tightest known path, making it linear (6000
methods 32.1 GB → 14.8 GB). See "Linear `getPath` searches" in Applied optimizations.
4. **`declarationFromElement` residual (~5–7%).** Still the largest single javac-interaction cost after
the smaller-scope scan; residual is method-subtree scanning. The cheap levers are exhausted (scoping
tighter than a method has no element; `trees.getTree` and the single-pass map were rejected).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4461,7 +4461,12 @@ public void setVisitorTreePath(@Nullable TreePath visitorTreePath) {
current = current.getParentPath();
}

return treePathCache.getPath(currentPath, tree);
// The target is not on the visitor path. Search from the visitor path's leaf outward
// (getPath(TreePath, Tree) expands leaf-first, then to ancestors, then the whole unit), so
// a target nested under the visited tree -- the common case -- is found locally instead of
// rescanning the whole compilation unit. Using the original visitorTreePath (not a
// climbed-up ancestor) keeps that start point as tight as possible.
return treePathCache.getPath(visitorTreePath, tree);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1584,6 +1584,18 @@ protected void performFlowAnalysis(ClassTree classTree) {
}
};

/**
* Returns true if {@code path}'s leaf is (by reference) the given {@code tree}.
*
* @param path a tree path
* @param tree a tree
* @return true if {@code path}'s leaf is {@code tree}
*/
@SuppressWarnings("interning:not.interned") // reference equality on AST nodes is intended
private static boolean treePathLeafIs(TreePath path, Tree tree) {
return path.getLeaf() == tree;
}

/**
* Analyze the AST {@code ast} and store the result. Additional operations that should be
* performed after analysis should be implemented in {@link #postAnalyze(ControlFlowGraph)}.
Expand All @@ -1610,6 +1622,26 @@ protected void analyze(
boolean updateInitializationStore,
boolean isStatic,
@Nullable Store capturedStore) {
// Prime the cache with this method body's path, built in O(1) from the enclosing class
// path, so the getPath(root, code) lookups below (in CFCFGBuilder.build, and when the
// visitor path is set before performAnalysis) are cache hits rather than an O(members) scan
// from the compilation-unit root -- which, once per body, is quadratic over a class with
// many methods. Only done for methods, whose body path is an unambiguous two-step extension
// of the class path (class -> method -> body); lambdas and initializers (rarer / nested)
// fall through to the normal lookup.
if (ast.getKind() == UnderlyingAST.Kind.METHOD) {
UnderlyingAST.CFGMethod cfgMethod = (UnderlyingAST.CFGMethod) ast;
TreePath classPath = getVisitorTreePath();
Tree body = cfgMethod.getCode();
if (classPath != null
&& body != null
&& treePathLeafIs(classPath, cfgMethod.getClassTree())) {
checker.getTreePathCacher()
.addPath(
body,
new TreePath(new TreePath(classPath, cfgMethod.getMethod()), body));
}
}
ControlFlowGraph cfg =
CFCFGBuilder.build(this.getRoot(), ast, checker, this, processingEnv);
/*
Expand All @@ -1634,7 +1666,21 @@ protected void analyze(
} else {
transfer.setFixedInitialStore(capturedStore);
}
analysis.performAnalysis(cfg, fieldValues);
// Point the visitor path at the body being analyzed. getPath() -- used by, e.g.,
// type-argument inference triggered during the analysis below -- uses visitorTreePath as a
// search-start hint. performFlowAnalysis sets it to the enclosing *class*, so a lookup for
// a tree inside this body rescans the whole class subtree, which is quadratic in the number
// of members. The body's path was just cached by CFCFGBuilder.build above, so this is a
// cache hit; query the cacher directly (not getPath) so this does not itself depend on
// visitorTreePath. Restored in the finally so the class path is back in place for the rest
// of performFlowAnalysis.
TreePath prevVisitorTreePath = getVisitorTreePath();
setVisitorTreePath(checker.getTreePathCacher().getPath(this.getRoot(), ast.getCode()));
try {
analysis.performAnalysis(cfg, fieldValues);
} finally {
setVisitorTreePath(prevVisitorTreePath);
}
AnalysisResult<Value, Store> result = analysis.getResult();

// store result
Expand Down
Loading