Skip to content

FramePresentationLog.kt

src/jvmMain/kotlin/afield/graphics/vulkan/util/FramePresentationLog.kt · 314 lines

package afield.graphics.vulkan.util

A diagnostic log of swapchain presentation timing, written to a file while enabled.

FramePresentationLog records when each present call was made and returned, and when the presentation or scanout of that image was observed; FramePresentationLog.mark adds a labelled line to the same log.

import afield.app.diagnostics.logging.lprintln
import org.lwjgl.vulkan.KHRSwapchain
import org.lwjgl.vulkan.VK10
import java.io.BufferedWriter
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale

object FramePresentationLog

object FramePresentationLog {
    private data class PresentKey(val swapchain: Long, val presentId: Long)

    private data class PresentRecord(
        val frameId: Long,
        val windowIndex: Int,
        val imageIndex: Int,
        val callNs: Long,
        val returnNs: Long
    )

var enabled: Boolean

    @Volatile
    var enabled: Boolean = Options.framePresentationLog

var waitAfterPresent: Boolean

    @Volatile
    var waitAfterPresent: Boolean = Options.framePresentationLogWaitAfterPresent

    private val lock = Any()
    private var writer: BufferedWriter? = null
    private var path: Path? = Options.framePresentationLogPath?.takeIf { it.isNotBlank() }?.let(Path::of)
    private var originNs: Long = System.nanoTime()
    private var linesSinceFlush = 0
    private val pendingPresents = mutableMapOf<PresentKey, PresentRecord>()
    private val lastPresentReturnNsByWindow = mutableMapOf<Int, Long>()
    private val lastObservedPresentNsByWindow = mutableMapOf<Int, Long>()
    private val lastScanoutNsByWindow = mutableMapOf<Int, Long>()

fun start

    fun start(
        outputPath: String? = null,
        waitAfterPresent: Boolean = Options.framePresentationLogWaitAfterPresent,
        append: Boolean = false
    ): String {
        synchronized(lock) {
            closeLocked()
            this.waitAfterPresent = waitAfterPresent
            path = outputPath?.takeIf { it.isNotBlank() }?.let(Path::of) ?: defaultPath()
            originNs = System.nanoTime()
            linesSinceFlush = 0
            pendingPresents.clear()
            lastPresentReturnNsByWindow.clear()
            lastObservedPresentNsByWindow.clear()
            lastScanoutNsByWindow.clear()
            writer = openWriter(path!!, append)
            writeHeaderLocked()
            enabled = true
            writeLineLocked(originNs, "log_start", -1L, -1, -1, null, null, null, "wait_after_present=$waitAfterPresent")
            flushLocked()
            return path!!.toAbsolutePath().normalize().toString()
        }
    }

fun stop()

    fun stop() {
        synchronized(lock) {
            if (writer != null) {
                writeLineLocked(System.nanoTime(), "log_stop", -1L, -1, -1, null, null, null, "")
            }
            enabled = false
            closeLocked()
        }
    }

fun mark(message: String)

    fun mark(message: String) {
        synchronized(lock) {
            if (!enabled) return
            ensureWriterLocked()
            writeLineLocked(System.nanoTime(), "mark", -1L, -1, -1, null, null, null, message)
            flushLocked()
        }
    }

fun addLine(message: String) = mark(message)

internal fun presentReturned

    internal fun presentReturned(
        swapchain: Long,
        frameId: Long,
        windowIndex: Int,
        imageIndex: Int,
        presentId: Long?,
        callNs: Long,
        returnNs: Long,
        result: Int
    ) {
        synchronized(lock) {
            if (!enabled) return
            ensureWriterLocked()
            val intervalMs = lastPresentReturnNsByWindow.put(windowIndex, returnNs)?.let { nsToMs(returnNs - it) }
            val queueReturnMs = nsToMs(returnNs - callNs)
            writeLineLocked(
                returnNs,
                "present_return",
                frameId,
                windowIndex,
                imageIndex,
                presentId,
                queueReturnMs,
                intervalMs,
                "result=${vkResultName(result)}"
            )
            if (presentId != null) {
                pendingPresents[PresentKey(swapchain, presentId)] =
                    PresentRecord(frameId, windowIndex, imageIndex, callNs, returnNs)
            }
            flushIfNeededLocked()
        }
    }

internal fun presentObserved

    internal fun presentObserved(
        swapchain: Long,
        presentId: Long,
        reason: String,
        result: Int,
        waitBeginNs: Long,
        waitEndNs: Long
    ) {
        synchronized(lock) {
            if (!enabled) return
            ensureWriterLocked()
            val record = pendingPresents[PresentKey(swapchain, presentId)]
            val windowIndex = record?.windowIndex ?: -1
            val intervalMs = lastObservedPresentNsByWindow.put(windowIndex, waitEndNs)?.let { nsToMs(waitEndNs - it) }
            val sincePresentCallMs = record?.let { nsToMs(waitEndNs - it.callNs) }
            val waitMs = nsToMs(waitEndNs - waitBeginNs)
            writeLineLocked(
                waitEndNs,
                "present_observed",
                record?.frameId ?: -1L,
                windowIndex,
                record?.imageIndex ?: -1,
                presentId,
                sincePresentCallMs,
                intervalMs,
                "reason=$reason result=${vkResultName(result)} wait_ms=${formatMs(waitMs)}"
            )
            flushIfNeededLocked()
        }
    }

internal fun scanoutObserved

Called by SwapChain.drainPastTimings for each VK_EXT_present_timing stage timestamp returned by vkGetPastPresentationTimingEXT.

Writes a scanout_timing row to the CSV. The dt_ms column holds the time from the original vkQueuePresentKHR call to this scanout stage. The interval_ms column holds the scanout-to-scanout interval (the key jitter signal). The detail column carries the stage flag, nominal refresh duration, actual interval deviation from the refresh period, and whether the timing report was complete.

body — 147 lines, from line 167
    internal fun scanoutObserved(
        swapchain: Long,
        presentId: Long,
        stageFlag: Int,
        stageNs: Long,
        refreshDurationNs: Long,
        reportComplete: Boolean
    ) {
        synchronized(lock) {
            if (!enabled) return
            ensureWriterLocked()
            val record = pendingPresents[PresentKey(swapchain, presentId)]
            val windowIndex = record?.windowIndex ?: -1

            val intervalNs = lastScanoutNsByWindow.put(windowIndex, stageNs)?.let { stageNs - it }
            val intervalMs = intervalNs?.let { nsToMs(it) }
            val sincePresentCallMs = record?.let { nsToMs(stageNs - it.callNs) }

            val stageLabel = stageLabel(stageFlag)
            val jitterDetail = if (intervalNs != null && refreshDurationNs > 0L) {
                val jitterNs = intervalNs - refreshDurationNs
                " jitter_ns=$jitterNs"
            } else ""
            val refreshDetail = if (refreshDurationNs > 0L) " refresh_ns=$refreshDurationNs" else ""

            writeLineLocked(
                stageNs,
                "scanout_timing",
                record?.frameId ?: -1L,
                windowIndex,
                record?.imageIndex ?: -1,
                presentId,
                sincePresentCallMs,
                intervalMs,
                "stage=$stageLabel$refreshDetail$jitterDetail report_complete=$reportComplete"
            )
            flushIfNeededLocked()
        }
    }

    private fun stageLabel(stageFlag: Int): String = when (stageFlag) {
        0x1 -> "QUEUE_OPERATIONS_END"
        0x2 -> "REQUEST_DEQUEUED"
        0x4 -> "IMAGE_FIRST_PIXEL_OUT"
        0x8 -> "IMAGE_FIRST_PIXEL_VISIBLE"
        else -> "stage_0x${stageFlag.toString(16)}"
    }

    private fun ensureWriterLocked() {
        if (writer != null) return
        if (path == null) path = defaultPath()
        writer = openWriter(path!!, append = true)
        if (runCatching { Files.size(path!!) }.getOrDefault(0L) == 0L) {
            writeHeaderLocked()
        }
    }

    private fun openWriter(path: Path, append: Boolean): BufferedWriter {
        path.parent?.let { Files.createDirectories(it) }
        val options = if (append) {
            arrayOf(StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)
        } else {
            arrayOf(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)
        }
        lprintln("info,perf", "frame presentation log: ${path.toAbsolutePath().normalize()}")
        return Files.newBufferedWriter(path, *options)
    }

    private fun writeHeaderLocked() {
        writer?.write("mono_ns,relative_ns,event,frame,window,image,present_id,dt_ms,interval_ms,detail\n")
    }

    private fun writeLineLocked(
        monoNs: Long,
        event: String,
        frameId: Long,
        windowIndex: Int,
        imageIndex: Int,
        presentId: Long?,
        dtMs: Double?,
        intervalMs: Double?,
        detail: String
    ) {
        writer?.apply {
            write(monoNs.toString())
            write(",")
            write((monoNs - originNs).toString())
            write(",")
            write(csv(event))
            write(",")
            write(if (frameId >= 0L) frameId.toString() else "")
            write(",")
            write(if (windowIndex >= 0) windowIndex.toString() else "")
            write(",")
            write(if (imageIndex >= 0) imageIndex.toString() else "")
            write(",")
            write(presentId?.toString() ?: "")
            write(",")
            write(dtMs?.let(::formatMs) ?: "")
            write(",")
            write(intervalMs?.let(::formatMs) ?: "")
            write(",")
            write(csv(detail))
            write('\n'.code)
        }
        linesSinceFlush++
    }

    private fun flushIfNeededLocked() {
        if (linesSinceFlush >= Options.framePresentationLogFlushEvery) flushLocked()
    }

    private fun flushLocked() {
        writer?.flush()
        linesSinceFlush = 0
    }

    private fun closeLocked() {
        runCatching {
            writer?.flush()
            writer?.close()
        }
        writer = null
    }

    private fun defaultPath(): Path {
        val stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
        return Path.of(".field-frame-logs", "frame-presents-$stamp.csv")
    }

    private fun csv(value: String): String {
        if (value.none { it == ',' || it == '"' || it == '\n' || it == '\r' }) return value
        return "\"" + value.replace("\"", "\"\"") + "\""
    }

    private fun nsToMs(ns: Long): Double = ns.toDouble() / 1_000_000.0

    private fun formatMs(ms: Double): String = String.format(Locale.US, "%.6f", ms)

    private fun vkResultName(result: Int): String = when (result) {
        VK10.VK_SUCCESS -> "VK_SUCCESS"
        KHRSwapchain.VK_SUBOPTIMAL_KHR -> "VK_SUBOPTIMAL_KHR"
        KHRSwapchain.VK_ERROR_OUT_OF_DATE_KHR -> "VK_ERROR_OUT_OF_DATE_KHR"
        VK10.VK_TIMEOUT -> "VK_TIMEOUT"
        else -> result.toString()
    }
}