/* * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * * Licensed 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. * */ import kotlinx.coroutines.runBlocking import net.sergeych.lyng.PerfFlags import java.io.File import java.lang.management.GarbageCollectorMXBean import java.lang.management.ManagementFactory import java.nio.file.Files import java.nio.file.Paths import kotlin.io.path.extension import kotlin.random.Random import kotlin.system.measureNanoTime import kotlin.test.Test import kotlin.test.Ignore @Ignore("TODO(compile-time-res): legacy tests disabled") class BookAllocationProfileTest { private fun outFile(): File = File("lynglib/build/book_alloc_profile.txt") private fun writeHeader(f: File) { if (!f.parentFile.exists()) f.parentFile.mkdirs() f.writeText("[DEBUG_LOG] Book allocation/time profiling (JVM)\n") f.appendText("[DEBUG_LOG] All sizes in bytes; time in ns (lower is better).\n") } private fun appendLine(f: File, s: String) { f.appendText(s + "\n") } // Optional STDERR filter to hide benign warnings during profiling runs private inline fun withFilteredStderr(vararg suppressContains: String, block: () -> T): T { val orig = System.err val filtering = java.io.PrintStream(object : java.io.OutputStream() { private val buf = StringBuilder() override fun write(b: Int) { if (b == '\n'.code) { val line = buf.toString() val suppress = suppressContains.any { line.contains(it) } if (!suppress) orig.println(line) buf.setLength(0) } else buf.append(b.toChar()) } }) return try { System.setErr(filtering) block() } finally { System.setErr(orig) } } private fun forceGc() { // Best-effort GC to stabilize measurements repeat(3) { System.gc() try { Thread.sleep(25) } catch (_: InterruptedException) {} } } private fun usedHeap(): Long { val mem = ManagementFactory.getMemoryMXBean().heapMemoryUsage return mem.used } private suspend fun runDocTestsNonFailing(file: String, bookMode: Boolean = true) { try { runDocTests(file, bookMode) } catch (t: Throwable) { // Profiling should not fail because of documentation snippet issues. println("[DEBUG_LOG] [PROFILE] Skipping failing doc: $file: ${t.message}") } } private suspend fun runBooksOnce(): Unit = runBlocking { // Mirror BookTest set, but run in bookMode to avoid strict assertions and allow shared context // Profiling should not fail on documentation snippet mismatches. runDocTestsNonFailing("../docs/tutorial.md", bookMode = true) runDocTestsNonFailing("../docs/math.md", bookMode = true) runDocTestsNonFailing("../docs/advanced_topics.md", bookMode = true) runDocTestsNonFailing("../docs/OOP.md", bookMode = true) runDocTestsNonFailing("../docs/Real.md", bookMode = true) runDocTestsNonFailing("../docs/List.md", bookMode = true) runDocTestsNonFailing("../docs/Range.md", bookMode = true) runDocTestsNonFailing("../docs/Set.md", bookMode = true) runDocTestsNonFailing("../docs/Map.md", bookMode = true) runDocTestsNonFailing("../docs/Buffer.md", bookMode = true) runDocTestsNonFailing("../docs/when.md", bookMode = true) // Samples folder, bookMode=true for (bt in Files.list(Paths.get("../docs/samples")).toList()) { if (bt.extension == "md") runDocTestsNonFailing(bt.toString(), bookMode = true) } runDocTestsNonFailing("../docs/declaring_arguments.md", bookMode = true) runDocTestsNonFailing("../docs/exceptions_handling.md", bookMode = true) runDocTestsNonFailing("../docs/time.md", bookMode = true) runDocTestsNonFailing("../docs/parallelism.md", bookMode = true) runDocTestsNonFailing("../docs/RingBuffer.md", bookMode = true) runDocTestsNonFailing("../docs/Iterable.md", bookMode = true) } private data class ProfileResult(val timeNs: Long, val allocBytes: Long) private suspend fun profileRun(): ProfileResult { forceGc() val before = usedHeap() val elapsed = measureNanoTime { withFilteredStderr("ScriptFlowIsNoMoreCollected") { runBooksOnce() } } forceGc() val after = usedHeap() val alloc = (after - before).coerceAtLeast(0) return ProfileResult(elapsed, alloc) } private data class GcSnapshot(val count: Long, val timeMs: Long) private fun gcSnapshot(): GcSnapshot { var c = 0L var t = 0L for (gc: GarbageCollectorMXBean in ManagementFactory.getGarbageCollectorMXBeans()) { c += (gc.collectionCount.takeIf { it >= 0 } ?: 0) t += (gc.collectionTime.takeIf { it >= 0 } ?: 0) } return GcSnapshot(c, t) } // --- Optional JFR support via reflection (works only on JDKs with Flight Recorder) --- @Ignore("TODO(compile-time-res): legacy tests disabled") private class JfrHandle(val rec: Any, val dump: (File) -> Unit, val stop: () -> Unit) private fun jfrStartIfRequested(name: String): JfrHandle? { val enabled = System.getProperty("lyng.jfr")?.toBoolean() == true if (!enabled) return null return try { val recCl = Class.forName("jdk.jfr.Recording") val ctor = recCl.getDeclaredConstructor() val rec = ctor.newInstance() val setName = recCl.methods.firstOrNull { it.name == "setName" && it.parameterTypes.size == 1 } setName?.invoke(rec, "Lyng-$name") val start = recCl.methods.first { it.name == "start" && it.parameterTypes.isEmpty() } start.invoke(rec) val stop = recCl.methods.first { it.name == "stop" && it.parameterTypes.isEmpty() } val dump = recCl.methods.firstOrNull { it.name == "dump" && it.parameterTypes.size == 1 } val dumper: (File) -> Unit = if (dump != null) { { f -> dump.invoke(rec, f.toPath()) } } else { { _ -> } } JfrHandle(rec, dumper) { stop.invoke(rec) } } catch (e: Throwable) { // JFR requested but not available; note once via stdout and proceed without it try { println("[DEBUG_LOG] JFR not available on this JVM; run with Oracle/OpenJDK 11+ to enable -Dlyng.jfr=true") } catch (_: Throwable) {} null } } private fun intProp(name: String, def: Int): Int = System.getProperty(name)?.toIntOrNull() ?: def private fun boolProp(name: String, def: Boolean): Boolean = System.getProperty(name)?.toBoolean() ?: def private data class FlagSnapshot( val RVAL_FASTPATH: Boolean, val PRIMITIVE_FASTOPS: Boolean, val ARG_BUILDER: Boolean, val ARG_SMALL_ARITY_12: Boolean, val FIELD_PIC: Boolean, val METHOD_PIC: Boolean, val FIELD_PIC_SIZE_4: Boolean, val METHOD_PIC_SIZE_4: Boolean, val INDEX_PIC: Boolean, val INDEX_PIC_SIZE_4: Boolean, val SCOPE_POOL: Boolean, val PIC_DEBUG_COUNTERS: Boolean, ) { fun restore() { PerfFlags.RVAL_FASTPATH = RVAL_FASTPATH PerfFlags.PRIMITIVE_FASTOPS = PRIMITIVE_FASTOPS PerfFlags.ARG_BUILDER = ARG_BUILDER PerfFlags.ARG_SMALL_ARITY_12 = ARG_SMALL_ARITY_12 PerfFlags.FIELD_PIC = FIELD_PIC PerfFlags.METHOD_PIC = METHOD_PIC PerfFlags.FIELD_PIC_SIZE_4 = FIELD_PIC_SIZE_4 PerfFlags.METHOD_PIC_SIZE_4 = METHOD_PIC_SIZE_4 PerfFlags.INDEX_PIC = INDEX_PIC PerfFlags.INDEX_PIC_SIZE_4 = INDEX_PIC_SIZE_4 PerfFlags.SCOPE_POOL = SCOPE_POOL PerfFlags.PIC_DEBUG_COUNTERS = PIC_DEBUG_COUNTERS } } private fun snapshotFlags() = FlagSnapshot( RVAL_FASTPATH = PerfFlags.RVAL_FASTPATH, PRIMITIVE_FASTOPS = PerfFlags.PRIMITIVE_FASTOPS, ARG_BUILDER = PerfFlags.ARG_BUILDER, ARG_SMALL_ARITY_12 = PerfFlags.ARG_SMALL_ARITY_12, FIELD_PIC = PerfFlags.FIELD_PIC, METHOD_PIC = PerfFlags.METHOD_PIC, FIELD_PIC_SIZE_4 = PerfFlags.FIELD_PIC_SIZE_4, METHOD_PIC_SIZE_4 = PerfFlags.METHOD_PIC_SIZE_4, INDEX_PIC = PerfFlags.INDEX_PIC, INDEX_PIC_SIZE_4 = PerfFlags.INDEX_PIC_SIZE_4, SCOPE_POOL = PerfFlags.SCOPE_POOL, PIC_DEBUG_COUNTERS = PerfFlags.PIC_DEBUG_COUNTERS, ) private fun median(values: List): Long { if (values.isEmpty()) return 0 val s = values.sorted() val mid = s.size / 2 return if (s.size % 2 == 1) s[mid] else ((s[mid - 1] + s[mid]) / 2) } private suspend fun runScenario( name: String, prepare: () -> Unit, repeats: Int = 3, out: (String) -> Unit ): ProfileResult { val warmup = intProp("lyng.profile.warmup", 1) val reps = intProp("lyng.profile.repeats", repeats) // JFR val jfr = jfrStartIfRequested(name) if (System.getProperty("lyng.jfr")?.toBoolean() == true && jfr == null) { out("[DEBUG_LOG] JFR: requested but not available on this JVM") } // Warm-up before GC snapshot (some profilers prefer this) prepare() repeat(warmup) { profileRun() } // GC baseline val gc0 = gcSnapshot() val times = ArrayList(repeats) val allocs = ArrayList(repeats) repeat(reps) { val r = profileRun() times += r.timeNs allocs += r.allocBytes } val pr = ProfileResult(median(times), median(allocs)) val gc1 = gcSnapshot() val gcCountDelta = (gc1.count - gc0.count).coerceAtLeast(0) val gcTimeDelta = (gc1.timeMs - gc0.timeMs).coerceAtLeast(0) out("[DEBUG_LOG] time=${pr.timeNs} ns, alloc=${pr.allocBytes} B (median of ${reps}), GC(count=${gcCountDelta}, timeMs=${gcTimeDelta})") // Stop and dump JFR if enabled if (jfr != null) { try { jfr.stop() val dumpFile = File("lynglib/build/jfr_${name}.jfr") jfr.dump(dumpFile) out("[DEBUG_LOG] JFR dumped: ${dumpFile.path}") } catch (_: Throwable) {} } return pr } @Test fun profile_books_allocations_and_time() = runTestBlocking { val f = outFile() writeHeader(f) fun log(s: String) = appendLine(f, s) val saved = snapshotFlags() try { data class Scenario(val label: String, val title: String, val prep: () -> Unit) val scenarios = mutableListOf() // Baseline A scenarios += Scenario("A", "JVM defaults") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false } // Most flags OFF B scenarios += Scenario("B", "most perf flags OFF") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false PerfFlags.RVAL_FASTPATH = false PerfFlags.PRIMITIVE_FASTOPS = false PerfFlags.ARG_BUILDER = false PerfFlags.ARG_SMALL_ARITY_12 = false PerfFlags.FIELD_PIC = false PerfFlags.METHOD_PIC = false PerfFlags.FIELD_PIC_SIZE_4 = false PerfFlags.METHOD_PIC_SIZE_4 = false PerfFlags.INDEX_PIC = false PerfFlags.INDEX_PIC_SIZE_4 = false PerfFlags.SCOPE_POOL = false } // Defaults with INDEX_PIC size 2 C scenarios += Scenario("C", "defaults except INDEX_PIC_SIZE_4=false") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false PerfFlags.INDEX_PIC = true; PerfFlags.INDEX_PIC_SIZE_4 = false } // One-flag toggles relative to A scenarios += Scenario("D", "A with RVAL_FASTPATH=false") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.RVAL_FASTPATH = false } scenarios += Scenario("E", "A with PRIMITIVE_FASTOPS=false") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.PRIMITIVE_FASTOPS = false } scenarios += Scenario("F", "A with INDEX_PIC=false") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.INDEX_PIC = false } scenarios += Scenario("G", "A with SCOPE_POOL=false") { saved.restore(); PerfFlags.PIC_DEBUG_COUNTERS = false; PerfFlags.SCOPE_POOL = false } val shuffle = boolProp("lyng.profile.shuffle", true) val order = if (shuffle) scenarios.shuffled(Random(System.nanoTime())) else scenarios val results = mutableMapOf() for (sc in order) { log("[DEBUG_LOG] Scenario ${sc.label}: ${sc.title}") results[sc.label] = runScenario(sc.label, prepare = sc.prep, out = ::log) } // Summary vs A if measured val a = results["A"] if (a != null) { log("[DEBUG_LOG] Summary deltas vs A (medians):") fun deltaLine(name: String, r: ProfileResult) = "[DEBUG_LOG] ${name} - A: time=${r.timeNs - a.timeNs} ns, alloc=${r.allocBytes - a.allocBytes} B" listOf("B","C","D","E","F","G").forEach { k -> results[k]?.let { r -> log(deltaLine(k, r)) } } } } finally { saved.restore() } } } // Minimal runBlocking bridge to avoid extra test deps here private fun runTestBlocking(block: suspend () -> Unit) { kotlinx.coroutines.runBlocking { block() } }