Skip to content

Reduce PathConflictResolver memory and auto-select resolver - #1938

Merged
cstamas merged 4 commits into
masterfrom
gnodet/conflict-resolver-memory-improvements
Jun 29, 2026
Merged

Reduce PathConflictResolver memory and auto-select resolver#1938
cstamas merged 4 commits into
masterfrom
gnodet/conflict-resolver-memory-improvements

Conversation

@gnodet

@gnodet gnodet commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Improvements to PathConflictResolver to prevent OOM and StackOverflow on large dependency graphs, plus allocation and collection-sizing optimizations:

Memory & safety improvements

  1. Auto-selection heuristic (new default "auto" mode): walks the dependency tree counting total nodes (including diamond-expanded duplicates) with early-exit optimization. Falls back to ClassicConflictResolver when the estimated Path tree memory exceeds 25% of available heap. This correctly handles the pathological case where diamond dependencies cause the Path tree to be 100-150× larger than the unique node count.

  2. Lazy children lists: Path.children starts as null (leaf nodes never allocate a list). Initialized in addChildren() with exact capacity. Saves ~40 bytes per leaf node (typically 60-70% of all nodes).

  3. Out-of-scope flag: replaces removal from LinkedHashSet partitions with a boolean outOfScope flag. Partitions use ArrayList instead of LinkedHashSet, avoiding ~48 bytes/entry of HashMap.Node overhead.

  4. Iterative gatherCRNodes and moveOutOfScope: both replaced recursive DFS with explicit ArrayList stacks to avoid StackOverflowError on deep dependency chains.

  5. Partition compaction: after filtering active paths for each conflict group, the partition entry is replaced with the filtered list, releasing references to out-of-scope paths and their detached subtrees for GC during resolution instead of retaining them until the end.

  6. Children nulling on out-of-scope: moveOutOfScope() nulls children references on marked nodes, accelerating GC of detached subtrees.

Allocation & performance optimizations

  1. Allocation-free artifact comparisons: replaced ArtifactIdUtils.toId() / toVersionlessId() + String.equals() with equalsId() / equalsVersionlessId() in push() and isDirectDependencyOnPathToRoot().

  2. Eliminate string concatenation in relatedSiblingsCount(): compare groupId/artifactId fields directly.

  3. Pre-sized activePaths / items lists: initialized with allPaths.size() capacity.

  4. Right-sized partitions / resolvedIds HashMaps: initialized with exact capacity from sortedConflictIds.size().

  5. Pooled ScopeContext: single mutable instance on State, reset and reused.

  6. Optimized isDirectDependencyOnPathToRoot(): walks directly to the depth-1 ancestor.

  7. Removed dead recursive push() code: push() is always called with levels=0.

  8. Inline stats tracking: conflictItemCount tracked during the main loop.

Benchmark results

Tested with a real-world 814-module project with heavy inter-module dependencies:

Configuration Heap Result
Baseline (master, path default) 512m OOM (125s of GC thrashing)
Baseline (master, path default) 1g OOM (126s)
Baseline (master, path default) 2g OOM (267s)
Baseline (master, path default) 4g OOM (424s)
Patched (path forced) 4g ✅ OK (214s)
Patched (classic forced) 384m ✅ OK (183s)
Patched (classic forced) 512m ✅ OK (153-163s, 3 runs)
Patched (auto default) 384m ✅ OK (222s)
Patched (auto default) 512m ✅ OK (165-210s, 3 runs)

Key findings:

  • The baseline PathConflictResolver cannot resolve this project at any heap size (OOM even at 4GB)
  • The root cause: diamond dependencies cause the Path tree to explode. A single module with 7,522 unique nodes produces 1,190,821 tree nodes (158× expansion factor)
  • The old heuristic used conflictIds.size() (unique nodes) — estimated ~1MB when the actual Path tree was ~227MB
  • The new heuristic walks the tree counting actual nodes with early-exit, correctly falling back to ClassicConflictResolver for large graphs
  • With auto-selection, the patched resolver succeeds at 384MB heap where the baseline fails at 4GB

Test plan

  • All 443 tests pass (438 original + 5 new auto-selection tests)
  • New tests cover: auto mode, explicit path/classic config dispatch, unknown config rejection, default config behavior
  • Benchmarked with 814-module real-world reproducer — confirms OOM fix and correct auto-selection

@gnodet
gnodet requested review from cstamas June 28, 2026 08:12
@seregamorph

Copy link
Copy Markdown

The build gets stuck. Some stack traces:

"mvn-turbo-builder-runner-board" #56 prio=5 os_prio=31 cpu=54709.59ms elapsed=618.98s tid=0x0000000853d73600 nid=0xd603 waiting on condition  [0x0000000178b41000]
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@17.0.19/Native Method)
	- parking to wait for  <0x0000000b4cfd4290> (a java.util.concurrent.CompletableFuture$Signaller)
	at java.util.concurrent.locks.LockSupport.park(java.base@17.0.19/LockSupport.java:211)
	at java.util.concurrent.CompletableFuture$Signaller.block(java.base@17.0.19/CompletableFuture.java:1864)
	at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@17.0.19/ForkJoinPool.java:3465)
	at java.util.concurrent.ForkJoinPool.managedBlock(java.base@17.0.19/ForkJoinPool.java:3436)
	at java.util.concurrent.CompletableFuture.waitingGet(java.base@17.0.19/CompletableFuture.java:1898)
	at java.util.concurrent.CompletableFuture.get(java.base@17.0.19/CompletableFuture.java:2072)
	at org.eclipse.aether.internal.impl.collect.bf.BfDependencyCollector.processDependency(BfDependencyCollector.java:257)
	at org.eclipse.aether.internal.impl.collect.bf.BfDependencyCollector.doCollectDependencies(BfDependencyCollector.java:222)
	at org.eclipse.aether.internal.impl.collect.DependencyCollectorDelegate.collectDependencies(DependencyCollectorDelegate.java:260)
	at org.eclipse.aether.internal.impl.collect.DefaultDependencyCollector.collectDependencies(DefaultDependencyCollector.java:79)
	at org.eclipse.aether.internal.impl.DefaultRepositorySystem.collectDependencies(DefaultRepositorySystem.java:247)
	at org.apache.maven.enforcer.rules.dependency.ResolverUtil.resolveTransitiveDependencies(ResolverUtil.java:149)
	at org.apache.maven.enforcer.rules.dependency.ResolverUtil.resolveTransitiveDependenciesVerbose(ResolverUtil.java:85)
	at org.apache.maven.enforcer.rules.dependency.DependencyConvergence.execute(DependencyConvergence.java:68)
	at org.apache.maven.plugins.enforcer.EnforceMojo.executeRuleNew(EnforceMojo.java:351)
	at org.apache.maven.plugins.enforcer.EnforceMojo.executeRule(EnforceMojo.java:325)
	at org.apache.maven.plugins.enforcer.EnforceMojo.execute(EnforceMojo.java:248)
	at org.apache.maven.plugin.DefaultBuildPluginManager.executeMojo(DefaultBuildPluginManager.java:127)
	at org.apache.maven.lifecycle.internal.MojoExecutor.doExecute2(MojoExecutor.java:336)
	at org.apache.maven.lifecycle.internal.MojoExecutor.doExecute(MojoExecutor.java:324)
	at org.apache.maven.lifecycle.internal.MojoExecutor.execute(MojoExecutor.java:220)
	at org.apache.maven.lifecycle.internal.MojoExecutor.execute(MojoExecutor.java:181)
	at org.apache.maven.lifecycle.internal.MojoExecutor.access$000(MojoExecutor.java:79)
	at org.apache.maven.lifecycle.internal.MojoExecutor$1.run(MojoExecutor.java:169)
	at org.apache.maven.plugin.DefaultMojosExecutionStrategy.execute(DefaultMojosExecutionStrategy.java:39)
	at org.apache.maven.lifecycle.internal.MojoExecutor.execute(MojoExecutor.java:166)
	at org.apache.maven.lifecycle.internal.LifecycleModuleBuilder.buildProject(LifecycleModuleBuilder.java:106)

While all bfcollector threads are sleeping:

"BfDependencyCollector-1-1" #46 daemon prio=5 os_prio=31 cpu=630.15ms elapsed=619.24s tid=0x0000000852760600 nid=0xc903 waiting on condition  [0x00000001776ca000]
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@17.0.19/Native Method)
	- parking to wait for  <0x0000000b5dacfec8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.park(java.base@17.0.19/LockSupport.java:341)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@17.0.19/AbstractQueuedSynchronizer.java:506)
	at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@17.0.19/ForkJoinPool.java:3465)
	at java.util.concurrent.ForkJoinPool.managedBlock(java.base@17.0.19/ForkJoinPool.java:3436)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@17.0.19/AbstractQueuedSynchronizer.java:1630)
	at java.util.concurrent.LinkedBlockingQueue.take(java.base@17.0.19/LinkedBlockingQueue.java:435)
	at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@17.0.19/ThreadPoolExecutor.java:1062)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@17.0.19/ThreadPoolExecutor.java:1122)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@17.0.19/ThreadPoolExecutor.java:635)
	at java.lang.Thread.run(java.base@17.0.19/Thread.java:840)

Worth to mention:

  • there is no OOM
  • the options are
# AFAIK default now
-Daether.dependencyCollector.impl=bf
-Daether.dependencyCollector.bf.threads=10

@seregamorph

Copy link
Copy Markdown

After rerunning the build I have OOM problems. The implementation consumes memory quite actively (for 3.9.16 it's enough to have only 9g):

Screenshot 2026-06-28 at 12 20 26

At some points the process gets stuck, the stack trace is:

"mvn-turbo-builder-data-migration-between-regions-temporal-organizations-integration-test" #45 prio=5 os_prio=31 cpu=66339.06ms elapsed=229.64s tid=0x000000099b6cd800 nid=0xda03 runnable  [0x00000001734f5000]
   java.lang.Thread.State: RUNNABLE
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$Path.addChildren(PathConflictResolver.java:680)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$Path.access$2200(PathConflictResolver.java:386)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:350)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.gatherCRNodes(PathConflictResolver.java:352)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.build(PathConflictResolver.java:339)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.<init>(PathConflictResolver.java:328)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver$State.<init>(PathConflictResolver.java:253)
	at org.eclipse.aether.util.graph.transformer.PathConflictResolver.transformGraph(PathConflictResolver.java:168)
	at org.eclipse.aether.util.graph.transformer.ConflictResolver.transformGraph(ConflictResolver.java:290)
	at org.eclipse.aether.util.graph.transformer.ChainedDependencyGraphTransformer.transformGraph(ChainedDependencyGraphTransformer.java:72)
	at org.eclipse.aether.internal.impl.collect.DependencyCollectorDelegate.collectDependencies(DependencyCollectorDelegate.java:284)
	at org.eclipse.aether.internal.impl.collect.DefaultDependencyCollector.collectDependencies(DefaultDependencyCollector.java:79)
	at org.eclipse.aether.internal.impl.DefaultRepositorySystem.collectDependencies(DefaultRepositorySystem.java:247)
	at org.apache.maven.project.DefaultProjectDependenciesResolver.resolve(DefaultProjectDependenciesResolver.java:154)
	at org.apache.maven.lifecycle.internal.LifecycleDependencyResolver.getDependencies(LifecycleDependencyResolver.java:231)
	at org.apache.maven.lifecycle.internal.LifecycleDependencyResolver.resolveProjectDependencies(LifecycleDependencyResolver.java:140)

Regarding the stuck state - I've already seen this, it happens once in a while. But this actually means there is a wrong multi-thread implementation somewhere and at scale these problems will happen quite often.

Four improvements to prevent OOM on large dependency graphs:

1. Auto-selection heuristic (new default): estimates Path tree memory
   cost (~200 bytes × nodeCount) from the conflict-ID map and falls
   back to ClassicConflictResolver if the parallel tree would exceed
   25% of available heap.

2. Lazy right-sized children lists: Path.children starts as null (not
   an empty ArrayList) and is initialized to exactly the right capacity
   in addChildren(). Leaves (~60-70% of nodes) never allocate a list,
   saving ~40 bytes per leaf.

3. outOfScope flag replaces LinkedHashSet partition entries: moveOutOfScope()
   sets a boolean instead of removing from a LinkedHashSet, eliminating
   the HashMap.Node overhead (~48 bytes per Path). Active paths are
   filtered at query time with a simple boolean check.

4. Iterative gatherCRNodes: converts the recursive tree-building method
   to use an explicit stack, avoiding StackOverflowError on very deep
   dependency graphs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the gnodet/conflict-resolver-memory-improvements branch from 7b3ae16 to 32c90a7 Compare June 28, 2026 10:50
@seregamorph

Copy link
Copy Markdown

Few comments:

  • indeed, the number of nested recursive calls inside of gatherCRNodes is high, but there is no StackOverflowError (as it happens with way deeper stack), it just means that if the complexity is high in each nested iteration, this can lead to OOM/high CPU while traversing too many tree elements
  • The build hang - almost agree there is not caused by this particular PR, but still deserves attention 🙏

…opeContext, right-sized collections

- Replace ArtifactIdUtils.toId()/toVersionlessId() + String.equals() with
  allocation-free equalsId()/equalsVersionlessId() in push() and
  isDirectDependencyOnPathToRoot()
- Eliminate string concatenation in relatedSiblingsCount() by comparing
  groupId/artifactId fields directly
- Pre-size activePaths and items lists with allPaths.size()
- Right-size partitions and resolvedIds HashMaps using conflict ID count
- Pool ScopeContext on State with reset() method to avoid per-node allocation
- Remove dead recursive push() code (always called with levels=0)
- Optimize isDirectDependencyOnPathToRoot to walk directly to depth-1
  ancestor instead of recursing through every level

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This pull request improves dependency conflict resolution scalability by reducing PathConflictResolver’s memory footprint and recursion risks, and introduces a new default "auto" mode in ConflictResolver that selects between path-based and classic resolution based on estimated heap usage.

Changes:

  • Add "auto" (now default) conflict resolver selection that chooses PathConflictResolver only when the estimated Path tree fits in available heap; otherwise falls back to ClassicConflictResolver.
  • Reduce PathConflictResolver allocations (lazy children lists, allocation-free artifact comparisons, right-sized collections, pooled scope context) and replace recursive graph build with an explicit stack.
  • Replace partition removal with an outOfScope flag and filter paths at query time.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java Memory/allocation optimizations, iterative graph walk, and new out-of-scope handling for conflict partitions.
maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java Introduces default "auto" resolver selection heuristic and wiring for the new resolver mode.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +175 to +185
// paths in given conflict group to consider; filter out those moved out of scope
List<Path> allPaths = state.partitions.get(conflictId);
List<Path> activePaths = new ArrayList<>(allPaths.size());
List<ConflictItem> items = new ArrayList<>(allPaths.size());
for (Path p : allPaths) {
if (!p.outOfScope) {
activePaths.add(p);
items.add(new ConflictItem(p));
}
}
if (activePaths.isEmpty()) {

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.

Fixed in 0b4ca58. Partition entries are now replaced with the filtered activePaths list after each conflict group, releasing references to out-of-scope paths and their detached subtrees for GC during resolution. Additionally, moveOutOfScope() now nulls children references on marked nodes to break reference chains, and conflictItemCount is tracked inline during the main loop instead of re-scanning partitions post-resolution.

Comment on lines +667 to +671
private void moveOutOfScope() {
this.state.partitions.get(this.conflictId).remove(this);
for (Path child : this.children) {
child.moveOutOfScope();
this.outOfScope = true;
if (this.children != null) {
for (Path child : this.children) {
child.moveOutOfScope();

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.

Fixed in 0b4ca58. Converted moveOutOfScope() to use an explicit ArrayList stack instead of recursion, consistent with the iterative gatherCRNodes() approach. The iterative version also nulls children references on out-of-scope nodes to accelerate GC of detached subtrees.

Comment on lines +286 to +290
/**
* Selects the most appropriate conflict resolver based on graph size and available memory.
* <p>
* PathConflictResolver builds a parallel tree of Path objects that costs ~200 bytes per node.
* For very large dependency graphs (millions of nodes), this can exhaust the heap.

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.

Fixed in 0b4ca58. Added 5 unit tests covering the delegating ConflictResolver dispatch: explicit "auto" config, explicit "path" config, explicit "classic" config, unknown config rejection (IllegalArgumentException), and default config (no property set, exercises the auto-selection code path). All 443 tests pass.

… auto-selection tests

- Compact partition entries after filtering to release references to
  out-of-scope paths and their detached subtrees, allowing GC during
  resolution instead of retaining losers until the end
- Make moveOutOfScope() iterative (explicit stack) to avoid
  StackOverflowError on deep dependency chains, consistent with
  gatherCRNodes(); also null children references on out-of-scope nodes
  to accelerate GC of detached subtrees
- Track conflictItemCount inline during the main loop instead of
  re-scanning partitions in the stats block
- Add 5 unit tests covering the auto-selection heuristic and delegating
  ConflictResolver: auto mode, explicit path/classic config, unknown
  config rejection, and default config behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java:236

  • Losers are marked outOfScope during push(), but the partition list for the current conflictId was populated before that and is never compacted again (the conflictId is processed only once). This keeps strong references to newly-losing Path instances until the end of the transform, delaying GC of pruned losers. Consider compacting the partition after the group is resolved (e.g., keep only winnerPath) to drop loser references promptly.
                // derive with new values from this to children only; observe winner flag
                path.derive(1, path == winnerPath);
                // push this node full level changes to DN graph
                path.push(0);
            }

Comment on lines +302 to +307
Map<DependencyNode, String> conflictIds =
(Map<DependencyNode, String>) context.get(TransformationContextKeys.CONFLICT_IDS);
int nodeCount = conflictIds != null ? conflictIds.size() : 0;

// Estimate Path tree memory: ~200 bytes per Path (object + right-sized children list + partition entry)
long pathTreeEstimate = (long) nodeCount * 200;
@cstamas

cstamas commented Jun 28, 2026

Copy link
Copy Markdown
Member

@seregamorph have you tried latest changes?

@seregamorph

Copy link
Copy Markdown

Checked 0b4ca58
No, the problem is not solved. Please try the reproducing project - it also still shows the performance issue (compare the build speed with 3.9)

@gnodet
gnodet force-pushed the gnodet/conflict-resolver-memory-improvements branch from 798a43d to 663438b Compare June 28, 2026 20:18
…ue nodes

The previous auto-selection heuristic used conflictIds.size() (unique
DependencyNode count) to estimate Path tree memory. For large projects
with diamond dependencies, this vastly underestimates the actual tree
size because each shared subtree is duplicated in the Path tree.

For example, a single module in an 814-module project showed:
- Unique nodes (conflictIds): 7,522
- Actual tree nodes (diamond-expanded): 1,190,821 (158x larger)

The old heuristic estimated ~1MB for this module's Path tree; the
actual size was ~227MB. This caused the auto-selector to always choose
PathConflictResolver, leading to OOM even at 4GB heap.

The fix walks the dependency tree counting total nodes (including
diamond-expanded duplicates) with an early-exit optimization: counting
stops as soon as the threshold is exceeded, avoiding the cost of
measuring multi-million-node trees that clearly won't fit.

With this fix, auto-selection correctly falls back to
ClassicConflictResolver for modules with large diamond-expanded trees,
allowing 814-module projects to resolve at 512MB heap (vs OOM at 4GB
before).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the gnodet/conflict-resolver-memory-improvements branch from 663438b to 6072fc5 Compare June 28, 2026 20:20
@gnodet
gnodet marked this pull request as ready for review June 28, 2026 21:35
@gnodet

gnodet commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Claude Code on behalf of Guillaume Nodet

Ran an independent benchmark with the reproducer project (814-module server_subset) to validate the changes.

Edit: The assertions in this comment were incorrect. The benchmark script had a bug (missing tar extraction of the Maven distribution), which caused the 3.10.0-SNAPSHOT runs to use a stale binary. Additionally, a debugging patch (removing early-exit from the tree walk heuristic) caused spurious OOMs that were mistakenly attributed to the threshold being too conservative.

See the corrected benchmark results for accurate numbers. The auto heuristic correctly selects path for all modules at every heap size tested.

@cstamas

cstamas commented Jun 29, 2026

Copy link
Copy Markdown
Member

So, then basically gigantic projects are doomed to use classic (if they insist on same memory amount; no speedup)?

@gnodet

gnodet commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Claude Code on behalf of Guillaume Nodet

Ran an independent benchmark with the reproducer project (814-module server_subset) to validate the changes. Each configuration was run 3 times at 4g/8g/16g to confirm consistency; smaller heaps were single runs.

Results

Maven 3.9.16 (baseline, classic resolver) — stable across all heap sizes (~96–99s):

Heap Run 1 Run 2 Run 3 Avg
512m 98.1s 98.1s
768m 99.2s 99.2s
1g 95.7s 95.7s
2g 98.2s 98.2s
4g 95.6s 97.3s 96.0s 96.3s
8g 94.2s 97.3s 98.1s 96.5s
16g 95.4s 96.2s 98.2s 96.6s

3.10.0-SNAPSHOT (patched, auto mode selects path resolver):

Heap Run 1 Run 2 Run 3 Avg Speedup
512m 69.8s 69.8s 29%
768m 69.8s 69.8s 30%
1g 69.4s 69.4s 28%
2g 72.6s 72.6s 26%
4g 74.9s 73.0s 73.2s 73.7s 23%
8g 80.1s 78.7s 79.2s 79.3s 18%
16g 83.7s 83.4s 87.8s 85.0s 12%

Observations

  • The patched path resolver is 12–30% faster than the 3.9.16 classic resolver across all tested heap sizes (512m to 16g).
  • The auto-selection heuristic correctly selects path for all 333 modules at every heap size tested.
  • All configurations succeed, including at 512m — no OOM.
  • The path resolver performs best at smaller heaps (~70s at 512m–1g), with a gradual slowdown at larger heaps (~85s at 16g), likely due to GC behavior with the parallel tree structure. The classic resolver is unaffected by heap size.

@seregamorph

Copy link
Copy Markdown

LGTM

@cstamas

cstamas commented Jun 29, 2026

Copy link
Copy Markdown
Member

@cstamas
cstamas merged commit d331083 into master Jun 29, 2026
17 checks passed
@cstamas
cstamas deleted the gnodet/conflict-resolver-memory-improvements branch June 29, 2026 19:45
@github-actions github-actions Bot added this to the 2.0.20 milestone Jun 29, 2026
@github-actions

Copy link
Copy Markdown

@cstamas Please assign appropriate label to PR according to the type of change.

@cstamas cstamas added the enhancement New feature or request label Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants