Skip to content

Eliminate per-node HashSet allocation in PathConflictResolver - #2075

Open
gnodet wants to merge 1 commit into
masterfrom
perf/path-conflict-resolver-hashset-elimination
Open

Eliminate per-node HashSet allocation in PathConflictResolver#2075
gnodet wants to merge 1 commit into
masterfrom
perf/path-conflict-resolver-hashset-elimination

Conversation

@gnodet

@gnodet gnodet commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace per-node HashSet<String> copy with parent-chain walk for cycle detection in PathConflictResolver.Path
  • Each Path previously copied its parent's entire conflictIdsOnPath set (new HashSet<>(parent.conflictIdsOnPath) + .add()), which JFR profiling showed consumed ~45% CPU on a 4,383-module reactor build (HashSet.<init> 16.4%, AbstractCollection.addAll 16.9%, HashMap.put 13.7%)
  • Since dependency tree depth is bounded in practice (< 30), the O(depth) walk per hasConflictIdOnPathToRoot() call is trivially fast while eliminating all HashSet allocation overhead

Benchmark Results

Tested on the 4,383-module generated reactor project, clean install -DskipTests -q -B, Apple M4 Pro, JDK 21:

Configuration Wall time vs RC6
Maven 4.0.0-rc-6 (unpatched) 2:12 baseline
Patched maven-4.0.x (all #12667 PRs, without this fix) 2:25
+ this HashSet elimination 1:21 -39%
+ install/deploy plugin fixes 1:14 -44%
Maven 3.9.16 1:20

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

  • Existing PathConflictResolver unit tests pass
  • Full maven-resolver-util test suite passes
  • Benchmarked on 4,383-module reactor — correct build output, significant performance improvement

🤖 Generated with Claude Code

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 gnodet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (&lt; 30), each check is fast while avoiding per-node HashSet allocation
* that was a major JFR hotspot (~45% CPU) in large multi-module builds.
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
*/
for (Path current = this; current != null; current = current.parent) {
if (Objects.equals(targetConflictId, current.conflictId)) {
return true;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants