Skip to content

JvmProfileCapture.kt

src/jvmMain/kotlin/afield/app/diagnostics/JvmProfileCapture.kt · 234 lines · cited by Profiling

package afield.app.diagnostics

import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.Runloop
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem

import afield.app.protocol.CoroutineProfileSnapshot
import afield.app.protocol.ProfileGcPause
import afield.app.protocol.ProfileStackFrame
import afield.app.protocol.ProfileStackSample
import afield.app.protocol.ProfileStackSource
import afield.audio.synth.*
import afield.data.table.get
import afield.document.graph.box
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.kotlin.cache
import afield.runtime.kotlin.fieldSourcePropertyFromFileName
import afield.runtime.lifecycle.frames
import afield.ui.notation.with
import afield.ui.output.templates.filter
import afield.runtime.completion.KotlinSourceIndex
import jdk.jfr.Recording
import jdk.jfr.RecordingState
import jdk.jfr.consumer.RecordingFile
import org.jetbrains.org.objectweb.asm.ClassReader
import org.jetbrains.org.objectweb.asm.ClassVisitor
import org.jetbrains.org.objectweb.asm.Opcodes
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
import java.util.concurrent.Executors

The stack-sampling half of the profiling panel: a JFR recording that runs alongside a coroutine capture, read back as frames, samples and the source each frame came from.

CoroutineProfiles starts one only when a capture asks for stacks. It records execution samples every 10 ms, throttled allocation samples and GC pauses, under a size ceiling and a recording length the capture cannot outrun. Nothing is parsed while the capture is live: stopping the recording hands the dump to a single daemon thread, and the panel is told how far that has got.

Two things keep the result bounded and safe to hold. Registering a box's snippet copies its source text and a few identifiers, never the box, its classloader or its coroutines, and every list has a cap past which samples are counted as dropped rather than kept. A frame that is not box code is traced back to a file by reading the class's own SourceFile attribute and then requiring the declared package to agree, so two files of the same name in different packages are never guessed between.

internal class JvmProfileCapture

Optional, bounded JFR sampling. Parsing and source lookup never run on the game thread.

internal class JvmProfileCapture {
    private val recording = Recording().apply {
        name = "Field profiling timeline"
        maxSize = 64L * 1024 * 1024
        duration = Duration.ofSeconds(35) // safety ceiling even if the Runloop never returns
        enable("jdk.ExecutionSample").withPeriod(Duration.ofMillis(10)).withStackTrace()
        enable("jdk.ObjectAllocationSample").with("throttle", "150/s").withStackTrace()
        enable("jdk.GCPhasePause").withThreshold(Duration.ZERO)
        start()
    }
    private var start = Instant.now()
    private var startNs = 0L
    private var finishing = false
    private val scripts = linkedMapOf<String, Script>()
    @Volatile private var state = "recording"
    @Volatile private var error = ""
    @Volatile private var result: Result? = null

    private data class Script(val name: String, val source: ProfileStackSource)

internal data class Result(val frames: List<ProfileStackFrame>, val sources: List<ProfileStackSource>,

    internal data class Result(val frames: List<ProfileStackFrame>, val sources: List<ProfileStackSource>,
        val samples: List<ProfileStackSample>, val dropped: Int, val pauses: List<ProfileGcPause>)

fun align(nanos: Long)

    fun align(nanos: Long) {
        val clock = Instant.now()
        val mono = System.nanoTime()
        start = clock.minusNanos(mono - nanos)
        startNs = nanos
    }

@Synchronized fun register(origin: ProfileOrigin)

Copy strings/identifiers only: no live box, classloader or coroutine retained by the latch.

    @Synchronized fun register(origin: ProfileOrigin) {
        if (finishing || scripts.size >= 2048) return
        for (snapshot in origin.box.liveKi?.context?.debugScripts?.all().orEmpty()) {
            val name = snapshot.className ?: continue
            if (scripts.containsKey(name) || snapshot.sourceText.length > 262144) continue
            val original = origin.source.takeIf { it.length <= 262144 }.orEmpty()
            if (scripts.values.sumOf { it.source.code.length + it.source.boxCode.length } + snapshot.sourceText.length + original.length > 2_000_000) break
            scripts[name] = Script(name, ProfileStackSource(-1, snapshot.sourceName, snapshot.sourceText,
                origin.box.id, fieldSourcePropertyFromFileName(snapshot.sourceName) ?: "code", original))
        }
    }

@Synchronized fun finish(endNs: Long)

    @Synchronized fun finish(endNs: Long) {
        if (finishing) return
        finishing = true
        state = "processing"
        val frozenScripts = scripts.values.toList()
        scripts.clear()
        reader.execute {
            var file: Path? = null
            try {
                if (recording.state == RecordingState.RUNNING) recording.stop()
                file = Files.createTempFile("field-profile-", ".jfr")
                recording.dump(file)
                result = read(file, frozenScripts, (endNs-startNs)/1e6)
                state = "ready"
            } catch (e: Exception) {
                error = e.message ?: e.javaClass.simpleName
                state = "failed"
            } finally {
                recording.close()
                file?.let { Files.deleteIfExists(it) }
            }
        }
    }

fun decorate(snapshot: CoroutineProfileSnapshot, after: Int): CoroutineProfileSnapshot

body — 73 lines, from line 126
    fun decorate(snapshot: CoroutineProfileSnapshot, after: Int): CoroutineProfileSnapshot {
        val r = result
        val first = (after+1).coerceIn(0, r?.samples?.size ?: 0)
        return snapshot.copy(stackState = state, stackError = error,
            stackSampleCount = r?.samples?.size ?: 0, stackDropped = r?.dropped ?: 0,
            stackFrames = if (after < 0) r?.frames.orEmpty() else emptyList(),
            stackSources = if (after < 0) r?.sources.orEmpty() else emptyList(),
            stackSamples = r?.samples?.subList(first, minOf(first+1024, r.samples.size)).orEmpty(),
            gcPauses = r?.pauses.orEmpty())
    }

    private fun read(file: Path, scripts: List<Script>, endMs: Double): Result {
        val frames = arrayListOf<ProfileStackFrame>()
        val frameIds = hashMapOf<Triple<String,String,Int>, Int>()
        val sources = arrayListOf<ProfileStackSource>()
        val sourceIds = hashMapOf<String, Int>()
        val sourcePaths = hashMapOf<String, Int>()
        val samples = arrayListOf<ProfileStackSample>()
        val pauses = arrayListOf<ProfileGcPause>()
        val resolver = ProfileSourceResolver()
        var sourceBytes = 0
        var dropped = 0
        val allocationThreads = hashSetOf<Long>()
        fun sourceFor(name: String): Int {
            val script = scripts.filter { name == it.name || name.startsWith(it.name + "$") }.maxByOrNull { it.name.length }
            return sourceIds.getOrPut(script?.name ?: name.substringBefore('$')) {
            val source = script?.source ?: resolver.source(name) ?: return@getOrPut -1
            sourcePaths[source.path]?.let { return@getOrPut it }
            val size = source.code.length + source.boxCode.length
            if (sources.size >= 256 || sourceBytes + size > 4_000_000) return@getOrPut -1
            sourceBytes += size
            sources.size.also { sources += source.copy(id = it); sourcePaths[source.path] = it }
            }
        }
        RecordingFile(file).use { input ->
            while (input.hasMoreEvents()) {
                val event = input.readEvent()
                if (event.eventType.name == "jdk.GCPhasePause") {
                    val time = Duration.between(start, event.startTime).toNanos()/1e6
                    if (time in 0.0..endMs && pauses.size < 2048) pauses += ProfileGcPause(time, event.duration.toNanos()/1e6)
                    continue
                }
                val kind = when (event.eventType.name) {
                    "jdk.ExecutionSample" -> "cpu"
                    "jdk.ObjectAllocationSample" -> "allocation"
                    else -> continue
                }
                val time = Duration.between(start, event.startTime).toNanos()/1e6
                val thread = if (kind == "cpu") event.getThread("sampledThread") else event.thread
                // The first allocation weight on a thread can include bytes allocated before this
                // recording. Establish its baseline instead of charging those bytes to the brush.
                if (kind == "allocation" && allocationThreads.add(thread?.javaThreadId ?: -1)) {
                    if (time in 0.0..endMs) dropped++
                    continue
                }
                if (time < 0 || time > endMs) continue
                if (samples.size >= 12000) { dropped++; continue }
                val stack = event.stackTrace?.frames.orEmpty().take(64).mapNotNull { frame ->
                    val name = frame.method.type.name
                    val key = Triple(name, frame.method.name, frame.lineNumber)
                    frameIds[key] ?: if (frames.size >= 16000) null else frames.size.also { id ->
                        frameIds[key] = id
                        frames += ProfileStackFrame(id, name, frame.method.name, frame.lineNumber, sourceFor(name))
                    }
                }
                if (stack.isEmpty()) { dropped++; continue }
                samples += ProfileStackSample(time, thread?.javaThreadId ?: -1, thread?.javaName ?: "unknown",
                    kind, if (kind == "cpu") 1 else event.getLong("weight"), stack)
            }
        }
        samples.sortBy { it.timeMs }
        return Result(frames, sources, samples, dropped, pauses)
    }

companion object

    companion object {
        private val reader = Executors.newSingleThreadExecutor { Thread(it, "field-profile-reader").apply { isDaemon = true } }
    }
}

internal class ProfileSourceResolver

Reuse the source index; verify package and SourceFile rather than guessing by simple class name.

internal class ProfileSourceResolver {
    private val index by lazy { KotlinSourceIndex.latestIndex ?: KotlinSourceIndex.loadIndex() }
    private val root = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize()
    private val cache = hashMapOf<String, ProfileStackSource?>()

fun source(className: String): ProfileStackSource?

    fun source(className: String): ProfileStackSource? = cache.getOrPut(className.substringBefore('$')) {
        val top = className.substringBefore('$')
        val packageName = top.substringBeforeLast('.', "")
        val filename = runCatching {
            var name: String? = null
            javaClass.classLoader.getResourceAsStream(top.replace('.', '/') + ".class")?.use {
                ClassReader(it).accept(object : ClassVisitor(Opcodes.ASM9) {
                    override fun visitSource(source: String?, debug: String?) { name = source }
                }, ClassReader.SKIP_CODE or ClassReader.SKIP_FRAMES)
            }
            name
        }.getOrNull() ?: return@getOrPut null
        val candidates = index?.files.orEmpty().filter { it.path.substringAfterLast('/') == filename }
        val matches = candidates.mapNotNull { record ->
            val path = root.resolve(record.path).normalize()
            if (!path.startsWith(root.resolve("src")) || !Files.isRegularFile(path) || Files.size(path) > 262144) return@mapNotNull null
            val code = Files.readString(path)
            val declared = Regex("(?m)^\\s*package\\s+([\\w.]+)").find(code)?.groupValues?.get(1).orEmpty()
            if (declared != packageName) null else ProfileStackSource(-1, record.path, code)
        }
        matches.singleOrNull()
    }
}