Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,4 @@ gradle.properties_local
# Default ignored files
/shelf/
/workspace.xml
sample/android/.idea/
sample/android/.idea/
25 changes: 25 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
@@ -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
```
27 changes: 27 additions & 0 deletions benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<JavaExec>("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")
}
Binary file added benchmark/lets-plot-images/benchmark_results.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<Node> = mutableListOf())

private class NodeAdapter(private val root: Node) : TreeAdapter<Node> {
private val parentMap = HashMap<Node, Node?>()

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> = 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<Node>()
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<Pair<Int, Long>>()

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")
}
Original file line number Diff line number Diff line change
@@ -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<TestNode> = mutableListOf())

internal class TestNodeAdapter(private val root: TestNode) : io.github.linde9821.treelayout.TreeAdapter<TestNode> {
private val parentMap = HashMap<TestNode, TestNode?>()

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<TestNode> = 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<TestNode>()
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
}
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ dependencyResolutionManagement {
}

rootProject.name = "TreeLayoutKMP"
include(":library", ":sample")
include(":library", ":sample", ":benchmark")
Loading