Browse Source

Implement saving benchmark results to JSON for browser (#5327)

Fixes
[CMP-8282](https://youtrack.jetbrains.com/issue/CMP-8282/Benchmarks-support-saveStatsToJSON-for-web)

## Release Notes
N/A
release/1.9.0-alpha01 v1.9.0-alpha02+dev2542
Nikita Lipsky 7 months ago committed by GitHub
parent
commit
ab2e690bd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 54
      benchmarks/multiplatform/benchmarks/build.gradle.kts
  2. 6
      benchmarks/multiplatform/benchmarks/src/appleMain/kotlin/BenchmarksSave.apple.kt
  3. 5
      benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt
  4. 31
      benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/BenchmarksSave.kt
  5. 16
      benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Config.kt
  6. 6
      benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/BenchmarksSave.desktop.kt
  7. 92
      benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/BenchmarksSaveServer.kt
  8. 8
      benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/main.desktop.kt
  9. 114
      benchmarks/multiplatform/benchmarks/src/wasmJsMain/kotlin/BenchmarksSave.wasmJs.kt
  10. 9
      benchmarks/multiplatform/benchmarks/src/wasmJsMain/kotlin/main.wasmJs.kt
  11. 1
      benchmarks/multiplatform/build.gradle.kts
  12. 12
      benchmarks/multiplatform/gradle/libs.versions.toml

54
benchmarks/multiplatform/benchmarks/build.gradle.kts

@ -70,6 +70,9 @@ kotlin { @@ -70,6 +70,9 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.io)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
@ -77,6 +80,19 @@ kotlin { @@ -77,6 +80,19 @@ kotlin {
dependencies {
implementation(compose.desktop.currentOs)
runtimeOnly(libs.kotlinx.coroutines.swing)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.client.java)
implementation(libs.ktor.server.cors)
}
}
val wasmJsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
}
@ -142,6 +158,44 @@ tasks.register("buildD8Distribution", Zip::class.java) { @@ -142,6 +158,44 @@ tasks.register("buildD8Distribution", Zip::class.java) {
destinationDirectory.set(rootProject.layout.buildDirectory.dir("distributions"))
}
tasks.register("runBrowserAndSaveStats") {
fun printProcessOutput(inputStream: java.io.InputStream) {
Thread {
inputStream.bufferedReader().use { reader ->
reader.lines().forEach { line ->
println(line)
}
}
}.start()
}
fun runCommand(vararg command: String): Process {
return ProcessBuilder(*command).start().also {
printProcessOutput(it.inputStream)
printProcessOutput(it.errorStream)
}
}
doFirst {
var serverProcess: Process? = null
var clientProcess: Process? = null
try {
serverProcess = runCommand("./gradlew", "benchmarks:run",
"-PrunArguments=runServer=true saveStatsToJSON=true")
clientProcess = runCommand("./gradlew", "benchmarks:wasmJsBrowserProductionRun",
"-PrunArguments=$runArguments saveStatsToJSON=true")
serverProcess.waitFor()
} catch (e: Throwable) {
e.printStackTrace()
} finally {
serverProcess?.destroy()
clientProcess?.destroy()
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.targets.js.binaryen.BinaryenExec>().configureEach {
binaryenArgs.add("-g") // keep the readable names
}

6
benchmarks/multiplatform/benchmarks/src/appleMain/kotlin/BenchmarksSave.apple.kt

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
/*
* Copyright 2020-2025 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
actual fun saveBenchmarkStats(name: String, stats: BenchmarkStats) = saveBenchmarkStatsOnDisk(name, stats)

5
benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Benchmarks.kt

@ -5,7 +5,6 @@ import benchmarks.multipleComponents.MultipleComponentsExample @@ -5,7 +5,6 @@ import benchmarks.multipleComponents.MultipleComponentsExample
import benchmarks.lazygrid.LazyGrid
import benchmarks.visualeffects.NYContent
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
import kotlin.time.Duration
@ -247,7 +246,9 @@ suspend fun runBenchmark( @@ -247,7 +246,9 @@ suspend fun runBenchmark(
content = content
).generateStats()
stats.prettyPrint()
saveBenchmarkStatsOnDisk(name = name, stats = stats)
if (Config.saveStats()) {
saveBenchmarkStats(name = name, stats = stats)
}
}
}

31
benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/BenchmarksSave.kt

@ -17,10 +17,24 @@ import kotlinx.io.files.Path @@ -17,10 +17,24 @@ import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readByteArray
// port for the benchmarks save server
val BENCHMARK_SERVER_PORT = 8090
private val BENCHMARKS_SAVE_DIR = "build/benchmarks"
private fun pathToCsv(name: String) = Path("$BENCHMARKS_SAVE_DIR/$name.csv")
private fun pathToJson(name: String) = Path("$BENCHMARKS_SAVE_DIR/json-reports/$name.json")
internal fun saveJson(benchmarkName: String, jsonString: String) {
val jsonPath = pathToJson(benchmarkName)
SystemFileSystem.createDirectories(jsonPath.parent!!)
SystemFileSystem.sink(jsonPath).writeText(jsonString)
println("JSON results saved to ${SystemFileSystem.resolve(jsonPath)}")
}
fun saveBenchmarkStatsOnDisk(name: String, stats: BenchmarkStats) {
try {
if (Config.saveStatsToCSV) {
val path = Path("build/benchmarks/$name.csv")
val path = pathToCsv(name)
val keyToValue = mutableMapOf<String, String>()
keyToValue.put("Date", currentFormattedDate)
@ -40,12 +54,7 @@ fun saveBenchmarkStatsOnDisk(name: String, stats: BenchmarkStats) { @@ -40,12 +54,7 @@ fun saveBenchmarkStatsOnDisk(name: String, stats: BenchmarkStats) {
println("CSV results saved to ${SystemFileSystem.resolve(path)}")
println()
} else if (Config.saveStatsToJSON) {
val jsonString = stats.toJsonString()
val jsonPath = Path("build/benchmarks/json-reports/$name.json")
SystemFileSystem.createDirectories(jsonPath.parent!!)
SystemFileSystem.sink(jsonPath).writeText(jsonString)
println("JSON results saved to ${SystemFileSystem.resolve(jsonPath)}")
saveJson(name, stats.toJsonString())
println()
}
} catch (_: IOException) {
@ -55,11 +64,17 @@ fun saveBenchmarkStatsOnDisk(name: String, stats: BenchmarkStats) { @@ -55,11 +64,17 @@ fun saveBenchmarkStatsOnDisk(name: String, stats: BenchmarkStats) {
}
}
/**
* Saves benchmark statistics to disk or sends them to a server.
* This is an expect function with platform-specific implementations.
*/
expect fun saveBenchmarkStats(name: String, stats: BenchmarkStats)
private fun RawSource.readText() = use {
it.buffered().readByteArray().decodeToString()
}
private fun RawSink.writeText(text: String) = use {
internal fun RawSink.writeText(text: String) = use {
it.buffered().apply {
write(text.encodeToByteArray())
flush()

16
benchmarks/multiplatform/benchmarks/src/commonMain/kotlin/Config.kt

@ -37,6 +37,7 @@ object Args { @@ -37,6 +37,7 @@ object Args {
var versionInfo: String? = null
var saveStatsToCSV: Boolean = false
var saveStatsToJSON: Boolean = false
var runServer: Boolean = false
for (arg in args) {
if (arg.startsWith("modes=", ignoreCase = true)) {
@ -51,6 +52,8 @@ object Args { @@ -51,6 +52,8 @@ object Args {
saveStatsToJSON = arg.substringAfter("=").toBoolean()
} else if (arg.startsWith("disabledBenchmarks=", ignoreCase = true)) {
disabledBenchmarks += argToMap(arg.decodeArg()).keys
} else if (arg.startsWith("runServer=", ignoreCase = true)) {
runServer = arg.substringAfter("=").toBoolean()
}
}
@ -60,7 +63,8 @@ object Args { @@ -60,7 +63,8 @@ object Args {
disabledBenchmarks = disabledBenchmarks,
versionInfo = versionInfo,
saveStatsToCSV = saveStatsToCSV,
saveStatsToJSON = saveStatsToJSON
saveStatsToJSON = saveStatsToJSON,
runServer = runServer,
)
}
}
@ -83,7 +87,8 @@ data class Config( @@ -83,7 +87,8 @@ data class Config(
val disabledBenchmarks: Set<String> = emptySet(),
val versionInfo: String? = null,
val saveStatsToCSV: Boolean = false,
val saveStatsToJSON: Boolean = false
val saveStatsToJSON: Boolean = false,
val runServer: Boolean = false,
) {
/**
* Checks if a specific mode is enabled based on the configuration.
@ -127,6 +132,9 @@ data class Config( @@ -127,6 +132,9 @@ data class Config(
val saveStatsToJSON: Boolean
get() = global.saveStatsToJSON
val runServer: Boolean
get() = global.runServer
fun setGlobal(global: Config) {
this.global = global
}
@ -143,5 +151,7 @@ data class Config( @@ -143,5 +151,7 @@ data class Config(
fun getBenchmarkProblemSize(benchmark: String, default: Int): Int =
global.getBenchmarkProblemSize(benchmark, default)
}
fun saveStats() = saveStatsToCSV || saveStatsToJSON
}
}

6
benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/BenchmarksSave.desktop.kt

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
/*
* Copyright 2020-2025 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
actual fun saveBenchmarkStats(name: String, stats: BenchmarkStats) = saveBenchmarkStatsOnDisk(name, stats)

92
benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/BenchmarksSaveServer.kt

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
/*
* Copyright 2020-2025 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.serialization.Serializable
/**
* Data class for receiving benchmark results from client.
*/
@Serializable
data class BenchmarkResultFromClient(
val name: String,
val stats: String // JSON string of BenchmarkStats
)
/**
* Starts a Ktor server to receive benchmark results from browsers
* and save them to disk in the same format as the direct disk saving mechanism.
*/
object BenchmarksSaveServer {
private var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
fun start(port: Int = BENCHMARK_SERVER_PORT) {
if (server != null) {
println("Benchmark server is already running")
return
}
server = embeddedServer(Netty, port = port) {
install(ContentNegotiation) {
json()
}
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.ContentType)
anyHost()
}
routing {
post("/benchmark") {
val result = call.receive<BenchmarkResultFromClient>()
if (result.name.isEmpty()) {
println("Stopping server! Received empty name from client")
call.respond(HttpStatusCode.OK, "Server stopped.")
stop()
return@post
}
println("Received benchmark result for: ${result.name}")
withContext(Dispatchers.IO) {
if (Config.saveStatsToJSON) {
saveJson(result.name, result.stats)
}
if (Config.saveStatsToCSV) {
// TODO: for CSV, we would need to convert JSON to the values
println("CSV results are not yet supported for the browser.")
}
}
call.respond(HttpStatusCode.OK, "Benchmark result saved")
}
get("/") {
call.respondText("Benchmark server is running", ContentType.Text.Plain)
}
}
}.start(wait = true)
}
fun stop() {
server?.stop(1000, 2000)
server = null
println("Benchmark server stopped")
System.exit(0)
}
}

8
benchmarks/multiplatform/benchmarks/src/desktopMain/kotlin/main.desktop.kt

@ -8,5 +8,11 @@ import kotlinx.coroutines.runBlocking @@ -8,5 +8,11 @@ import kotlinx.coroutines.runBlocking
fun main(args: Array<String>) {
Config.setGlobalFromArgs(args)
runBlocking(Dispatchers.Main) { runBenchmarks() }
if (Config.runServer) {
// Start the benchmark server to receive results from browsers
BenchmarksSaveServer.start()
} else {
runBlocking(Dispatchers.Main) { runBenchmarks() }
}
}

114
benchmarks/multiplatform/benchmarks/src/wasmJsMain/kotlin/BenchmarksSave.wasmJs.kt

@ -0,0 +1,114 @@ @@ -0,0 +1,114 @@
/*
* Copyright 2020-2025 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.browser.window
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import kotlinx.serialization.Serializable
/**
* Browser implementation for saving benchmark results.
* Instead of trying to save directly to disk (which would fail with UnsupportedOperationException),
* this implementation sends the results to a server via HTTP.
*/
actual fun saveBenchmarkStats(name: String, stats: BenchmarkStats) {
GlobalScope.launch {
BenchmarksSaveServerClient.sendBenchmarkResult(name, stats)
}
}
/**
* Client for sending benchmark results to the server
*/
object BenchmarksSaveServerClient {
private val client = HttpClient {
install(ContentNegotiation) {
json()
}
}
private var resultsSendingInProgress = 0
private fun serverUrlRoot(): String {
val protocol = window.location.protocol
val hostname = window.location.hostname
return "$protocol//$hostname:$BENCHMARK_SERVER_PORT"
}
private fun serverUrl(): String {
return "${serverUrlRoot()}/benchmark"
}
/**
* Sends benchmark results to the server
*/
suspend fun sendBenchmarkResult(name: String, stats: BenchmarkStats) {
resultsSendingInProgress++
println("Sending results: $name")
sendBenchmarkResult(name, stats.toJsonString())
resultsSendingInProgress--
println("Benchmark result sent to server: ${serverUrl()}")
}
private suspend fun sendBenchmarkResult(name: String, stats: String) {
try {
val result = BenchmarkResultToServer(
name = name,
stats = stats
)
client.post(serverUrl()) {
contentType(ContentType.Application.Json)
setBody(result)
}
} catch (e: Throwable) {
println("Error sending benchmark result to server: ${e.message}")
}
}
suspend fun stopServer() {
while (resultsSendingInProgress > 0) {
yield()
}
sendBenchmarkResult("", "")
}
suspend fun isServerAlive(): Boolean {
// waiting for the server to start for 2 seconds
val TIMEOUT = 2000
val DELTA = 100L
var delayed = 0L
while (delayed < TIMEOUT) {
try {
return client.get(serverUrlRoot()).status == HttpStatusCode.OK
} catch (_: Throwable) {
delayed += DELTA
delay(DELTA)
}
}
return false
}
}
/**
* Data class for sending benchmark results to the server
*/
@Serializable
data class BenchmarkResultToServer(
val name: String,
val stats: String // JSON string of BenchmarkStats
)

9
benchmarks/multiplatform/benchmarks/src/wasmJsMain/kotlin/main.wasmJs.kt

@ -23,8 +23,17 @@ fun mainBrowser() { @@ -23,8 +23,17 @@ fun mainBrowser() {
Config.setGlobalFromArgs(args)
MainScope().launch {
if (Config.saveStats() && !BenchmarksSaveServerClient.isServerAlive()) {
println("No benchmark server found.")
return@launch
}
runBenchmarks()
println("Completed!")
if (Config.saveStats()) {
GlobalScope.launch {
BenchmarksSaveServerClient.stopServer()
}
}
}
}

1
benchmarks/multiplatform/build.gradle.kts

@ -12,6 +12,7 @@ allprojects { @@ -12,6 +12,7 @@ allprojects {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
maven("https://maven.pkg.jetbrains.space/kotlin/p/wasm/experimental")
mavenLocal()
}
}

12
benchmarks/multiplatform/gradle/libs.versions.toml

@ -1,16 +1,26 @@ @@ -1,16 +1,26 @@
[versions]
compose-multiplatform = "1.8.0"
compose-multiplatform = "1.8.1"
kotlin = "2.1.20"
kotlinx-coroutines = "1.8.0"
kotlinx-serialization = "1.8.0"
kotlinx-io = "0.7.0"
kotlinx-datetime = "0.6.2"
ktor = "3.0.0-wasm2"
[libraries]
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-server-cors = { module= "io.ktor:ktor-server-cors", version.ref = "ktor" }
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }

Loading…
Cancel
Save