From e452c352dfa61408df52f0291d05b5bcf84fb1fd Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 8 Jun 2026 13:18:46 +0200 Subject: [PATCH 1/4] Create simple JMH benchmark to compare CCR and PCR A simple JMH benchmark that compares classic and path conflict resolvers. --- maven-resolver-util/pom.xml | 25 +++ .../ConflictResolverJMHBenchmark.java | 198 ++++++++++++++++++ pom.xml | 12 ++ 3 files changed, 235 insertions(+) create mode 100644 maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java diff --git a/maven-resolver-util/pom.xml b/maven-resolver-util/pom.xml index 6234749749..8b5756a589 100644 --- a/maven-resolver-util/pom.xml +++ b/maven-resolver-util/pom.xml @@ -58,6 +58,16 @@ junit-jupiter-params test + + org.openjdk.jmh + jmh-core + test + + + org.openjdk.jmh + jmh-generator-annprocess + test + @@ -66,6 +76,21 @@ com.github.siom79.japicmp japicmp-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + + + default-testCompile + + full + + org.openjdk.jmh.generators.BenchmarkProcessor + + + + + diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java new file mode 100644 index 0000000000..04a7e3cf93 --- /dev/null +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.util.graph.transformer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.eclipse.aether.RepositoryException; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.collection.DependencyGraphTransformationContext; +import org.eclipse.aether.graph.DefaultDependencyNode; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.internal.test.util.TestUtils; +import org.eclipse.aether.internal.test.util.TestVersion; +import org.eclipse.aether.internal.test.util.TestVersionConstraint; +import org.eclipse.aether.util.graph.visitor.DependencyGraphDumper; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@State(Scope.Benchmark) +@Warmup(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +public class ConflictResolverJMHBenchmark { + private static final RepositorySystemSession session = TestUtils.newSession(); + + private ConflictResolver classic; + private ConflictResolver path; + + @Setup + public void setup() { + classic = new ClassicConflictResolver( + new ConfigurableVersionSelector(), + new JavaScopeSelector(), + new SimpleOptionalitySelector(), + new JavaScopeDeriver()); + path = new PathConflictResolver( + new ConfigurableVersionSelector(), + new JavaScopeSelector(), + new SimpleOptionalitySelector(), + new JavaScopeDeriver()); + } + + public static void main(String... args) throws RunnerException { + new Runner(new OptionsBuilder() + .include(ConflictResolverJMHBenchmark.class.getSimpleName()) + .build()) + .run(); + } + + @Benchmark + public void uniqueSnake_20_path() throws RepositoryException { + uniqueSnake(path, 20); + } + + @Benchmark + public void uniqueSnake_20_classic() throws RepositoryException { + uniqueSnake(classic, 20); + } + + @Benchmark + public void uniqueSnake_40_path() throws RepositoryException { + uniqueSnake(path, 40); + } + + @Benchmark + public void uniqueSnake_40_classic() throws RepositoryException { + uniqueSnake(classic, 40); + } + + @Benchmark + public void uniqueSnakeWithRootCycle_20_path() throws RepositoryException { + uniqueSnakeWithRootCycle(path, 20); + } + + @Benchmark + public void uniqueSnakeWithRootCycle_20_classic() throws RepositoryException { + uniqueSnakeWithRootCycle(classic, 20); + } + + @Benchmark + public void uniqueSnakeWithRootCycle_40_path() throws RepositoryException { + uniqueSnakeWithRootCycle(path, 40); + } + + @Benchmark + public void uniqueSnakeWithRootCycle_40_classic() throws RepositoryException { + uniqueSnakeWithRootCycle(classic, 40); + } + + /** + * A "snake", plain chain of unique dependencies of given length. + */ + private static void uniqueSnake(ConflictResolver conflictResolver, int length) throws RepositoryException { + DependencyNode root = makeDependencyNode("group-id", "root", "1.0"); + DependencyNode last = root; + for (int i = 0; i < length; i++) { + DependencyNode dep = makeDependencyNode("group-id", "dep-" + i, "1.0"); + last.setChildren(mutableList(dep)); + last = dep; + } + + DependencyNode transformedNode = transform(conflictResolver, root); + + assertSame(root, transformedNode); + assertEquals(1, transformedNode.getChildren().size()); + } + + /** + * A "snake", plain chain of unique dependencies of given length, where last dep points back to root forming a + * cycle. + */ + private static void uniqueSnakeWithRootCycle(ConflictResolver conflictResolver, int length) + throws RepositoryException { + DependencyNode root = makeDependencyNode("group-id", "root", "1.0"); + DependencyNode last = root; + for (int i = 0; i < length; i++) { + DependencyNode dep = makeDependencyNode("group-id", "dep-" + i, "1.0"); + last.setChildren(mutableList(dep)); + last = dep; + } + last.setChildren(mutableList(root)); + + DependencyNode transformedNode = transform(conflictResolver, root); + + assertSame(root, transformedNode); + assertEquals(1, transformedNode.getChildren().size()); + } + + private static final DependencyGraphDumper DUMPER_SOUT = new DependencyGraphDumper(System.out::println); + + private static DependencyNode transform(ConflictResolver conflictResolver, DependencyNode root) + throws RepositoryException { + DependencyGraphTransformationContext context = TestUtils.newTransformationContext(session); + root = conflictResolver.transformGraph(root, context); + assertNotNull(root); + return root; + } + + private static DependencyNode makeDependencyNode(String groupId, String artifactId, String version) { + return makeDependencyNode(groupId, artifactId, version, "compile"); + } + + private static DependencyNode makeDependencyNode(String groupId, String artifactId, String version, String scope) { + return makeDependencyNode(groupId, artifactId, version, null, "compile"); + } + + private static DependencyNode makeDependencyNode( + String groupId, String artifactId, String version, String classifier, String scope) { + DefaultDependencyNode node = (classifier != null && !classifier.isEmpty()) + ? new DefaultDependencyNode(new Dependency( + new DefaultArtifact(groupId + ':' + artifactId + ":jar:" + classifier + ":" + version), scope)) + : new DefaultDependencyNode( + new Dependency(new DefaultArtifact(groupId + ':' + artifactId + ':' + version), scope)); + node.setVersion(new TestVersion(version)); + node.setVersionConstraint(new TestVersionConstraint(node.getVersion())); + return node; + } + + private static List mutableList(DependencyNode... nodes) { + return new ArrayList<>(Arrays.asList(nodes)); + } +} diff --git a/pom.xml b/pom.xml index 19a01bbb1e..e8a81b52e4 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,7 @@ 3.6.1 4.0.3 4.1.1 + 1.37 [3.8.8,) [21,) @@ -325,6 +326,17 @@ commons-compress 1.28.0 + + + org.openjdk.jmh + jmh-core + ${jmhVersion} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmhVersion} + From 2ce6264de5fc5f01c205e9951e0ab34c570ded86 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 8 Jun 2026 20:22:57 +0200 Subject: [PATCH 2/4] Add symmetricTree --- .../ConflictResolverJMHBenchmark.java | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java index 04a7e3cf93..294b318e40 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java @@ -18,6 +18,7 @@ */ package org.eclipse.aether.util.graph.transformer; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -54,8 +55,8 @@ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) @State(Scope.Benchmark) -@Warmup(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS) -@Measurement(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Warmup(iterations = 1, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS) public class ConflictResolverJMHBenchmark { private static final RepositorySystemSession session = TestUtils.newSession(); @@ -123,6 +124,26 @@ public void uniqueSnakeWithRootCycle_40_classic() throws RepositoryException { uniqueSnakeWithRootCycle(classic, 40); } + @Benchmark + public void symmetricBinaryTreeUnique_5_path() throws RepositoryException { + symmetricBinaryTree(path, 5, Integer.MAX_VALUE); + } + + @Benchmark + public void symmetricBinaryTreeUnique_5_classic() throws RepositoryException { + symmetricBinaryTree(classic, 5, Integer.MAX_VALUE); + } + + @Benchmark + public void symmetricBinaryTreeMod50_5_path() throws RepositoryException { + symmetricBinaryTree(path, 5, 50); + } + + @Benchmark + public void symmetricBinaryTreeMod50_5_classic() throws RepositoryException { + symmetricBinaryTree(classic, 5, 50); + } + /** * A "snake", plain chain of unique dependencies of given length. */ @@ -162,6 +183,35 @@ private static void uniqueSnakeWithRootCycle(ConflictResolver conflictResolver, assertEquals(1, transformedNode.getChildren().size()); } + /** + * A symmetric binary tree with given depth. Provided modulo is to create conflicts, if larger that total tree nodes, + * tree will be "unique" (no conflicts). + */ + private static void symmetricBinaryTree(ConflictResolver conflictResolver, int depth, int modulo) + throws RepositoryException { + DependencyNode root = makeDependencyNode("group-id", "root", "1.0"); + int level = 2; + int idCounter = 1; + ArrayDeque stack = new ArrayDeque<>(); + stack.push(root); + for (int i = 0; i < depth; i++) { + ArrayList children = new ArrayList<>(); + while (!stack.isEmpty()) { + DependencyNode node = stack.pop(); + DependencyNode left = makeDependencyNode("group-id", "d" + idCounter++ % modulo, "1.0"); + DependencyNode right = makeDependencyNode("group-id", "d" + idCounter++ % modulo, "1.0"); + node.setChildren(mutableList(left, right)); + children.add(left); + children.add(right); + } + stack.addAll(children); + } + + DependencyNode transformedNode = transform(conflictResolver, root); + + assertSame(root, transformedNode); + } + private static final DependencyGraphDumper DUMPER_SOUT = new DependencyGraphDumper(System.out::println); private static DependencyNode transform(ConflictResolver conflictResolver, DependencyNode root) From 02d714768f21aa1fa28084f66c5dc06f6b127a85 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 8 Jun 2026 20:28:40 +0200 Subject: [PATCH 3/4] Add more --- .../ConflictResolverJMHBenchmark.java | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java index 294b318e40..56c0800599 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java @@ -34,7 +34,6 @@ import org.eclipse.aether.internal.test.util.TestUtils; import org.eclipse.aether.internal.test.util.TestVersion; import org.eclipse.aether.internal.test.util.TestVersionConstraint; -import org.eclipse.aether.util.graph.visitor.DependencyGraphDumper; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Measurement; @@ -144,6 +143,26 @@ public void symmetricBinaryTreeMod50_5_classic() throws RepositoryException { symmetricBinaryTree(classic, 5, 50); } + @Benchmark + public void diamondFan_10x5_path() throws RepositoryException { + diamondFan(path, 5, 10); + } + + @Benchmark + public void diamondFan_10x5_classic() throws RepositoryException { + diamondFan(classic, 5, 10); + } + + @Benchmark + public void diamondFan_20x10_path() throws RepositoryException { + diamondFan(path, 10, 20); + } + + @Benchmark + public void diamondFan_20x10_classic() throws RepositoryException { + diamondFan(classic, 10, 20); + } + /** * A "snake", plain chain of unique dependencies of given length. */ @@ -159,7 +178,6 @@ private static void uniqueSnake(ConflictResolver conflictResolver, int length) t DependencyNode transformedNode = transform(conflictResolver, root); assertSame(root, transformedNode); - assertEquals(1, transformedNode.getChildren().size()); } /** @@ -180,7 +198,6 @@ private static void uniqueSnakeWithRootCycle(ConflictResolver conflictResolver, DependencyNode transformedNode = transform(conflictResolver, root); assertSame(root, transformedNode); - assertEquals(1, transformedNode.getChildren().size()); } /** @@ -212,7 +229,61 @@ private static void symmetricBinaryTree(ConflictResolver conflictResolver, int d assertSame(root, transformedNode); } - private static final DependencyGraphDumper DUMPER_SOUT = new DependencyGraphDumper(System.out::println); + /** + * A "diamond fan": {@code width} independent chains of length {@code depth}, all converging + * on the same shared artifact at two different versions (simulating a real version conflict). + * This is the topology where CCR's O(N²) re-walk per conflict group shows up most clearly. + * + * Graph shape (width=3, depth=2): + * root + * ├── chain-0-0 → chain-0-1 → shared:1.0 + * ├── chain-1-0 → chain-1-1 → shared:2.0 (conflict: 1.0 vs 2.0) + * └── chain-2-0 → chain-2-1 → shared:1.0 + */ + private static void diamondFan(ConflictResolver conflictResolver, int depth, int width) throws RepositoryException { + DependencyNode root = makeDependencyNode("group-id", "root", "1.0"); + List chains = new ArrayList<>(width); + + for (int w = 0; w < width; w++) { + DependencyNode chainHead = makeDependencyNode("group-id", "chain-" + w + "-0", "1.0"); + DependencyNode last = chainHead; + for (int d = 1; d < depth; d++) { + DependencyNode dep = makeDependencyNode("group-id", "chain-" + w + "-" + d, "1.0"); + last.setChildren(mutableList(dep)); + last = dep; + } + // All chains converge on the same artifact at alternating versions — real conflict + String version = (w % 2 == 0) ? "1.0" : "2.0"; + DependencyNode shared = makeDependencyNode("group-id", "shared", version); + last.setChildren(mutableList(shared)); + chains.add(chainHead); + } + + root.setChildren(chains); + + DependencyNode result = transform(conflictResolver, root); + assertNotNull(result); + // After conflict resolution, all paths to "shared" must agree on one version + assertEquals(1, countDistinctVersions(result, "shared")); + } + + private static int countDistinctVersions(DependencyNode node, String artifactId) { + // BFS/DFS to count distinct versions of the given artifactId in the resolved graph + java.util.Set versions = new java.util.HashSet<>(); + collectVersions(node, artifactId, versions); + return versions.size(); + } + + private static void collectVersions(DependencyNode node, String artifactId, java.util.Set versions) { + if (node.getArtifact() != null + && artifactId.equals(node.getArtifact().getArtifactId()) + && node.getData().get(ConflictResolver.NODE_DATA_WINNER) == null) { + versions.add(node.getArtifact().getVersion()); + } + for (DependencyNode child : node.getChildren()) { + collectVersions(child, artifactId, versions); + } + } private static DependencyNode transform(ConflictResolver conflictResolver, DependencyNode root) throws RepositoryException { @@ -227,7 +298,7 @@ private static DependencyNode makeDependencyNode(String groupId, String artifact } private static DependencyNode makeDependencyNode(String groupId, String artifactId, String version, String scope) { - return makeDependencyNode(groupId, artifactId, version, null, "compile"); + return makeDependencyNode(groupId, artifactId, version, null, scope); } private static DependencyNode makeDependencyNode( From 10b7d4895928686e5b2b328cda8a4311bebf6247 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 8 Jun 2026 20:47:02 +0200 Subject: [PATCH 4/4] Git add all tests --- .../ConflictResolverJMHBenchmark.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java index 56c0800599..4a8dd31e35 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverJMHBenchmark.java @@ -143,6 +143,26 @@ public void symmetricBinaryTreeMod50_5_classic() throws RepositoryException { symmetricBinaryTree(classic, 5, 50); } + @Benchmark + public void symmetricBinaryTreeUnique_10_path() throws RepositoryException { + symmetricBinaryTree(path, 10, Integer.MAX_VALUE); + } + + @Benchmark + public void symmetricBinaryTreeUnique_10_classic() throws RepositoryException { + symmetricBinaryTree(classic, 10, Integer.MAX_VALUE); + } + + @Benchmark + public void symmetricBinaryTreeMod50_10_path() throws RepositoryException { + symmetricBinaryTree(path, 10, 50); + } + + @Benchmark + public void symmetricBinaryTreeMod50_10_classic() throws RepositoryException { + symmetricBinaryTree(classic, 10, 50); + } + @Benchmark public void diamondFan_10x5_path() throws RepositoryException { diamondFan(path, 5, 10);