Eliminate per-node HashSet allocation in PathConflictResolver - #2075
Eliminate per-node HashSet allocation in PathConflictResolver#2075gnodet wants to merge 1 commit into
Conversation
Replace the per-node HashSet<String> copy in Path constructor with a parent-chain walk for cycle detection. Each Path previously copied its parent's entire conflictIdsOnPath set (O(depth) per node), which JFR profiling showed consumed ~45% CPU on a 4,383-module reactor build. Since dependency tree depth is bounded in practice (< 30), the O(depth) walk per hasConflictIdOnPathToRoot() call is trivially fast while eliminating all HashSet allocation, HashMap.put, and AbstractCollection.addAll overhead that dominated the profile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gnodet
left a comment
There was a problem hiding this comment.
LGTM — Well-justified performance optimization. JFR profiling showed HashSet ops consuming ~45% CPU on a 4,383-module reactor build; replacing per-node HashSet allocation with a parent-chain walk yields a 39% wall-time improvement, bringing Maven 4 below Maven 3.9.16 on this benchmark.
One minor observation below (non-blocking).
📋 PR Metadata
| Aspect | Current | Suggested |
|---|---|---|
| Category | (unlabeled) | performance |
| Labels | (none) | + enhancement |
| Milestone | (none) | 3.0.0 |
🔀 Backport Status: Not needed — PathConflictResolver.java does not exist on maintenance branches (maven-resolver-1.9.x, maven-resolver-1.6.x).
This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of Guillaume Nodet
| * Walks the parent chain comparing conflict IDs. Since dependency tree depth is bounded | ||
| * in practice (< 30), each check is fast while avoiding per-node HashSet allocation | ||
| * that was a major JFR hotspot (~45% CPU) in large multi-module builds. | ||
| */ |
There was a problem hiding this comment.
Minor (non-blocking): targetConflictId.equals(current.conflictId) would NPE if targetConflictId is null. The pre-HashSet version (before commit 51f1af6) used Objects.equals(current.conflictId, targetConflictId) which was null-safe.
In practice, null conflict IDs would indicate a separate upstream bug (ConflictMarker assigns IDs to all nodes with dependencies), and the codebase already uses the same non-null-safe pattern elsewhere (line 967), so this is consistent. Just noting it for completeness.
| */ | |
| for (Path current = this; current != null; current = current.parent) { | |
| if (Objects.equals(targetConflictId, current.conflictId)) { | |
| return true; | |
| } |
Summary
HashSet<String>copy with parent-chain walk for cycle detection inPathConflictResolver.PathPathpreviously copied its parent's entireconflictIdsOnPathset (new HashSet<>(parent.conflictIdsOnPath)+.add()), which JFR profiling showed consumed ~45% CPU on a 4,383-module reactor build (HashSet.<init>16.4%,AbstractCollection.addAll16.9%,HashMap.put13.7%)hasConflictIdOnPathToRoot()call is trivially fast while eliminating all HashSet allocation overheadBenchmark Results
Tested on the 4,383-module generated reactor project,
clean install -DskipTests -q -B, Apple M4 Pro, JDK 21:With this fix applied alongside the other optimizations from #12667, Maven 4 is now faster than Maven 3.9.16 on this benchmark.
See: apache/maven#12667
Test plan
PathConflictResolverunit tests passmaven-resolver-utiltest suite passes🤖 Generated with Claude Code