diff --git a/.gitignore b/.gitignore index edb8a76..d8e563f 100644 --- a/.gitignore +++ b/.gitignore @@ -260,4 +260,4 @@ gradle.properties_local # Default ignored files /shelf/ /workspace.xml -sample/android/.idea/ +sample/android/.idea/ \ No newline at end of file diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..fd64397 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,25 @@ +# Benchmark + +Performance benchmark for the TreeLayoutKMP library. + +## What it does + +Generates balanced trees with increasing node counts (stepping by 15K up to ~1.1M) and measures the time to compute a full layout for each. Results are plotted as a line chart and exported to `benchmark_results.png` using Kotlin's lets-plot library. + +## Output + +A PNG chart (`benchmark_results.png`) with nodes on the x-axis and computation time (ms) on the y-axis. + +## Running + +From the project root: + +```bash +./gradlew :benchmark:jvmRun +``` + +## Running tests + +```bash +./gradlew :benchmark:jvmTest +``` diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..b42f431 --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) +} + +kotlin { + jvm() + + sourceSets { + jvmMain.dependencies { + implementation(project(":library")) + implementation(libs.lets.plot.kotlin.jvm) + implementation(libs.lets.plot.image.export) + implementation(libs.lets.plot.platf.awt) + } + jvmTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +tasks.register("runBenchmark") { + description = "Run the TreeLayoutKMP benchmark" + mainClass.set("io.github.linde9821.treelayout.benchmark.MainKt") + classpath = kotlin.jvm().compilations["main"].runtimeDependencyFiles + + kotlin.jvm().compilations["main"].output.allOutputs + jvmArgs = listOf("-Xmx8g", "-XX:+UseG1GC", "-XX:+AlwaysPreTouch") +} diff --git a/benchmark/lets-plot-images/benchmark_results.png b/benchmark/lets-plot-images/benchmark_results.png new file mode 100644 index 0000000..bb620f4 Binary files /dev/null and b/benchmark/lets-plot-images/benchmark_results.png differ diff --git a/benchmark/src/jvmMain/kotlin/io/github/linde9821/treelayout/benchmark/Main.kt b/benchmark/src/jvmMain/kotlin/io/github/linde9821/treelayout/benchmark/Main.kt new file mode 100644 index 0000000..c2e30b7 --- /dev/null +++ b/benchmark/src/jvmMain/kotlin/io/github/linde9821/treelayout/benchmark/Main.kt @@ -0,0 +1,139 @@ +package io.github.linde9821.treelayout.benchmark + +import io.github.linde9821.treelayout.TreeAdapter +import io.github.linde9821.treelayout.walker.WalkerTreeLayout +import org.jetbrains.letsPlot.export.ggsave +import org.jetbrains.letsPlot.geom.geomLine +import org.jetbrains.letsPlot.geom.geomPoint +import org.jetbrains.letsPlot.label.ggtitle +import org.jetbrains.letsPlot.label.labs +import org.jetbrains.letsPlot.label.xlab +import org.jetbrains.letsPlot.label.ylab +import org.jetbrains.letsPlot.letsPlot +import org.jetbrains.letsPlot.scale.scaleColorManual +import org.jetbrains.letsPlot.scale.scaleLinetypeManual +import org.jetbrains.letsPlot.scale.scaleSizeManual +import org.jetbrains.letsPlot.themes.themeMinimal +import kotlin.time.measureTime + +private class Node(val children: MutableList = mutableListOf()) + +private class NodeAdapter(private val root: Node) : TreeAdapter { + private val parentMap = HashMap() + + init { + parentMap[root] = null + buildParentMap(root) + } + + private fun buildParentMap(node: Node) { + for (child in node.children) { + parentMap[child] = node + buildParentMap(child) + } + } + + override fun root(): Node = root + override fun children(node: Node): List = node.children + override fun parent(node: Node): Node? = parentMap[node] +} + +/** + * Builds a balanced tree with approximately [targetSize] nodes. + * Uses a breadth-first approach with a fixed branching factor. + */ +private fun buildTree(targetSize: Int): Node { + val root = Node() + if (targetSize <= 1) return root + + val queue = ArrayDeque() + queue.add(root) + var count = 1 + val branchingFactor = 4 + + while (count < targetSize && queue.isNotEmpty()) { + val parent = queue.removeFirst() + val childrenToAdd = minOf(branchingFactor, targetSize - count) + repeat(childrenToAdd) { + val child = Node() + parent.children.add(child) + queue.add(child) + count++ + } + } + return root +} + +fun main() { + val sizes = generateSequence(1) { + it + 15_000 + } + val results = mutableListOf>() + + print("Heating up the jvm... ") + for (size in sizes.take(50)) { + val tree = buildTree(size) + val adapter = NodeAdapter(tree) + measureTime { + WalkerTreeLayout(adapter).layout() + } + } + println("done") + + for (size in sizes.takeWhile { it < 6_100_000 }) { + print("Laying out tree with $size nodes... ") + val tree = buildTree(size) + val adapter = NodeAdapter(tree) + + val duration = measureTime { + WalkerTreeLayout(adapter).layout() + } + val ms = duration.inWholeMilliseconds + println("${ms}ms") + results.add(size to ms) + } + + val nodes = results.map { it.first } + val times = results.map { it.second } + + // O(n) reference line scaled to match the last measured data point + val maxNodes = nodes.last().toDouble() + val maxTime = times.last().toDouble() + val onLine = nodes.map { (it / maxNodes * maxTime).toLong() } + + val measuredData = mapOf( + "nodes" to nodes, + "time_ms" to times, + "series" to List(nodes.size) { "Measured" } + ) + + val referenceData = mapOf( + "nodes" to nodes, + "time_ms" to onLine, + "series" to List(nodes.size) { "O(n) reference" } + ) + + val data = mapOf( + "nodes" to nodes + nodes, + "time_ms" to times + onLine, + "series" to List(nodes.size) { "Measured" } + List(nodes.size) { "O(n) reference" } + ) + + val jvmVersion = System.getProperty("java.version") + val os = System.getProperty("os.name") + + val plot = letsPlot(data) { x = "nodes"; y = "time_ms" } + + geomLine(data = referenceData) { color = "series"; linetype = "series" } + + geomLine(data = measuredData) { color = "series"; linetype = "series" } + + geomPoint(data = measuredData, size = 1.5) { color = "series" } + + scaleColorManual(values = listOf("#999999", "#2166AC")) + + scaleLinetypeManual(values = listOf("dashed", "solid")) + + xlab("Number of Nodes") + + ylab("Time (ms)") + + labs(subtitle = "JVM $jvmVersion ยท $os") + + ggtitle("TreeLayoutKMP Benchmark") + + themeMinimal() + + ggsave(plot, "benchmark_results.png", dpi = 150, w = 10.0, h = 6.0) + println("\nChart written to benchmark_results.png") +} diff --git a/benchmark/src/jvmTest/kotlin/io/github/linde9821/treelayout/benchmark/BenchmarkTest.kt b/benchmark/src/jvmTest/kotlin/io/github/linde9821/treelayout/benchmark/BenchmarkTest.kt new file mode 100644 index 0000000..76ff3ad --- /dev/null +++ b/benchmark/src/jvmTest/kotlin/io/github/linde9821/treelayout/benchmark/BenchmarkTest.kt @@ -0,0 +1,73 @@ +package io.github.linde9821.treelayout.benchmark + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BenchmarkTest { + + @Test + fun buildTreeProducesCorrectNodeCount() { + val sizes = listOf(1, 10, 100, 1_000) + for (size in sizes) { + val root = buildTreeForTest(size) + val count = countNodes(root) + assertEquals(count, size, "Expected $size nodes but got $count") + } + } + + @Test + fun benchmarkRunsWithoutError() { + // Smoke test: layout a small tree and verify it completes + val root = buildTreeForTest(100) + val adapter = TestNodeAdapter(root) + val layout = io.github.linde9821.treelayout.walker.WalkerTreeLayout(adapter).layout() + assertEquals(layout.getPositions().size, 100) + } + + private fun countNodes(node: TestNode): Int = + 1 + node.children.sumOf { countNodes(it) } +} + +internal class TestNode(val children: MutableList = mutableListOf()) + +internal class TestNodeAdapter(private val root: TestNode) : io.github.linde9821.treelayout.TreeAdapter { + private val parentMap = HashMap() + + init { + parentMap[root] = null + buildParentMap(root) + } + + private fun buildParentMap(node: TestNode) { + for (child in node.children) { + parentMap[child] = node + buildParentMap(child) + } + } + + override fun root(): TestNode = root + override fun children(node: TestNode): List = node.children + override fun parent(node: TestNode): TestNode? = parentMap[node] +} + +internal fun buildTreeForTest(targetSize: Int): TestNode { + val root = TestNode() + if (targetSize <= 1) return root + + val queue = ArrayDeque() + queue.add(root) + var count = 1 + val branchingFactor = 4 + + while (count < targetSize && queue.isNotEmpty()) { + val parent = queue.removeFirst() + val childrenToAdd = minOf(branchingFactor, targetSize - count) + repeat(childrenToAdd) { + val child = TestNode() + parent.children.add(child) + queue.add(child) + count++ + } + } + return root +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17d2c99..9070191 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,9 +5,15 @@ android-minSdk = "24" android-compileSdk = "36" vanniktechMavenPublish = "0.36.0" compose-version = "1.10.3" +lets-plot-kotlin = "4.13.0" +lets-plot = "4.8.2" +lets-plot-platf = "4.9.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +lets-plot-kotlin-jvm = { module = "org.jetbrains.lets-plot:lets-plot-kotlin-jvm", version.ref = "lets-plot-kotlin" } +lets-plot-image-export = { module = "org.jetbrains.lets-plot:lets-plot-image-export", version.ref = "lets-plot" } +lets-plot-platf-awt = { module = "org.jetbrains.lets-plot:platf-awt", version.ref = "lets-plot-platf" } [plugins] android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8eb5f6e..4eaed1d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,4 +14,4 @@ dependencyResolutionManagement { } rootProject.name = "TreeLayoutKMP" -include(":library", ":sample") +include(":library", ":sample", ":benchmark")