Skip to content

timing.kt

src/jvmMain/kotlin/afield/graphics/vulkan/device/timing.kt · 494 lines

package afield.graphics.vulkan.device

GPU-side profiling for the Vulkan renderer: per-frame query pools for timestamps, pipeline statistics and, where the driver offers VK_KHR_performance_query, performance counters.

ProfilingOptions switches each kind on and sizes its pool; GpuProfiler owns one set of pools per frame in flight and hands out query slots as commands are recorded.

import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.*
import org.lwjgl.vulkan.KHRPerformanceQuery.*
import org.lwjgl.vulkan.VK10.*
import org.lwjgl.vulkan.VK11.vkGetPhysicalDeviceFeatures2
import org.lwjgl.vulkan.VK12.vkResetQueryPool
import org.lwjgl.vulkan.VK13.*
import org.lwjgl.vulkan.VK13.vkCmdWriteTimestamp2
import java.nio.ByteBuffer

data class ProfilingOptions

data class ProfilingOptions(
    val enabled: Boolean = false,
    val timestamps: Boolean = true,
    val pipelineStats: Boolean = true,
    val perfCounters: Boolean = true,
    val maxTimestampsPerFrame: Int = 2048,
    val maxPipeStatsPerFrame: Int = 512,
    val maxPerfQueriesPerFrame: Int = 256
)

object PipeStatIndex

const val IA_VERTICES = 0

const val IA_PRIMITIVES = 1

const val VS_INVOCATIONS = 2

const val GS_INVOCATIONS = 3

const val GS_PRIMITIVES = 4

const val CLIP_INVOCATIONS = 5

const val CLIP_PRIMITIVES = 6

const val FS_INVOCATIONS = 7

const val TCS_INVOCATIONS = 8

const val TES_INVOCATIONS = 9

const val CS_INVOCATIONS = 10

class FrameAllocator(private val max: Int)

class FrameAllocator(private val max: Int) {
    private var next = 0

fun alloc(count: Int = 1): Int

    fun alloc(count: Int = 1): Int {
        val n = next
        val nn = n + count
        check(nn <= max) { "Query pool overflow: need=$nn max=$max" }
        next = nn
        return n
    }

fun capacity(): Int = max

fun reset()

    fun reset() {
        next = 0
    }
}

class GpuProfiler

class GpuProfiler(
    private val device: VkDevice,
    private val physicalDevice: VkPhysicalDevice,
    private val queueFamilyIndex: Int,
    private val framesInFlight: Int,
    val opts: ProfilingOptions
) {

data class PerfCounterMeta(val name: String, val unit: Int, val scope: Int)

val perfMeta: List<PerfCounterMeta> get() = _perfMeta

    private var _perfMeta: List<PerfCounterMeta> = emptyList()

val enabled = opts.enabled

val timestampsEnabled: Boolean

val pipeStatsEnabled: Boolean

val perfQueryEnabled: Boolean

val timestampPeriodNs: Double

    private val timestampPools = LongArray(framesInFlight)
    private val pipeStatPools = LongArray(framesInFlight)
    private val perfPools = LongArray(framesInFlight)

    private val tsAlloc = arrayOfNulls<FrameAllocator>(framesInFlight)
    private val psAlloc = arrayOfNulls<FrameAllocator>(framesInFlight)
    private val pfAlloc = arrayOfNulls<FrameAllocator>(framesInFlight)

    private val hostQueryResetSupported: Boolean
    private val pipelineStatisticsQuerySupported: Boolean
    private val meshShaderStatBitsSupported: Boolean
    private val perfExtensionPresent: Boolean

    private val pipelineStatisticsMask: Int

init

body — 82 lines, from line 94
    init {
        if (!enabled) {
            timestampsEnabled = false
            pipeStatsEnabled = false
            perfQueryEnabled = false
            timestampPeriodNs = 0.0
            hostQueryResetSupported = false
            pipelineStatisticsQuerySupported = false
            meshShaderStatBitsSupported = false
            perfExtensionPresent = false
            pipelineStatisticsMask = 0

        } else {

            val props = VkPhysicalDeviceProperties.malloc()
            vkGetPhysicalDeviceProperties(physicalDevice, props)
            timestampPeriodNs = props.limits().timestampPeriod().toDouble()
            val apiVersion = props.apiVersion()
            props.free()

            val features2 = VkPhysicalDeviceFeatures2.calloc().`sType$Default`()
            val meshFeat = VkPhysicalDeviceMeshShaderFeaturesEXT.calloc().`sType$Default`()
            features2.pNext(meshFeat.address())
            vkGetPhysicalDeviceFeatures2(physicalDevice, features2)

            pipelineStatisticsQuerySupported = features2.features().pipelineStatisticsQuery()
            meshShaderStatBitsSupported = meshFeat.meshShader() || meshFeat.taskShader()
            features2.free()
            meshFeat.free()

            hostQueryResetSupported =
                (VK_API_VERSION_MAJOR(apiVersion) > 1) ||
                        (VK_API_VERSION_MAJOR(apiVersion) == 1 && VK_API_VERSION_MINOR(apiVersion) >= 2) ||
                        hasDeviceExtension(physicalDevice, "VK_EXT_host_query_reset")

            perfExtensionPresent = hasDeviceExtension(physicalDevice, "VK_KHR_performance_query")

            timestampsEnabled = opts.timestamps
            pipeStatsEnabled = opts.pipelineStats && pipelineStatisticsQuerySupported
            perfQueryEnabled = opts.perfCounters && perfExtensionPresent

            var mask =
                VK_QUERY_PIPELINE_STATISTIC_INPUT_ASSEMBLY_VERTICES_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_INPUT_ASSEMBLY_PRIMITIVES_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_VERTEX_SHADER_INVOCATIONS_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_CLIPPING_INVOCATIONS_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_CLIPPING_PRIMITIVES_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_FRAGMENT_SHADER_INVOCATIONS_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_COMPUTE_SHADER_INVOCATIONS_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_GEOMETRY_SHADER_INVOCATIONS_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_GEOMETRY_SHADER_PRIMITIVES_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_TESSELLATION_CONTROL_SHADER_PATCHES_BIT or
                        VK_QUERY_PIPELINE_STATISTIC_TESSELLATION_EVALUATION_SHADER_INVOCATIONS_BIT

            if (meshShaderStatBitsSupported) {
                // Optional: include TASK/MESH bits here if needed.
            }
            pipelineStatisticsMask = mask

            repeat(framesInFlight) { i ->
                if (timestampsEnabled) {
                    val a = FrameAllocator(opts.maxTimestampsPerFrame)
                    tsAlloc[i] = a
                    timestampPools[i] = createQueryPool(VK_QUERY_TYPE_TIMESTAMP, a.capacity(), 0)
                }
                if (pipeStatsEnabled) {
                    val a = FrameAllocator(opts.maxPipeStatsPerFrame)
                    psAlloc[i] = a
                    pipeStatPools[i] = createQueryPool(
                        VK_QUERY_TYPE_PIPELINE_STATISTICS,
                        a.capacity(),
                        pipelineStatisticsMask
                    )
                }
                if (perfQueryEnabled) {
                    val a = FrameAllocator(opts.maxPerfQueriesPerFrame)
                    pfAlloc[i] = a
                    perfPools[i] = createPerfQueryPoolAndChooseCounters(a.capacity())
                }
            }
        }
    }

fun beginFrame(frameIndex: Int)

    fun beginFrame(frameIndex: Int) {
        if (!enabled) return
        tsAlloc[frameIndex]?.reset()
        psAlloc[frameIndex]?.reset()
        pfAlloc[frameIndex]?.reset()

        if (!hostQueryResetSupported) return
        timestampPools[frameIndex].takeIf { it != 0L }?.let {
            vkResetQueryPool(device, it, 0, tsAlloc[frameIndex]!!.capacity())
        }
        pipeStatPools[frameIndex].takeIf { it != 0L }?.let {
            vkResetQueryPool(device, it, 0, psAlloc[frameIndex]!!.capacity())
        }
        perfPools[frameIndex].takeIf { it != 0L }?.let {
            vkResetQueryPool(device, it, 0, pfAlloc[frameIndex]!!.capacity())
        }
    }

fun endFrame(@Suppress("UNUSED_PARAMETER") frameIndex: Int) = Unit

    private fun legacyStageToStage2(stageLegacy: Int): Long = when (stageLegacy) {
        VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT -> VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT
        VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT -> VK_PIPELINE_STAGE_2_DRAW_INDIRECT_BIT
        VK_PIPELINE_STAGE_VERTEX_INPUT_BIT -> VK_PIPELINE_STAGE_2_VERTEX_INPUT_BIT
        VK_PIPELINE_STAGE_VERTEX_SHADER_BIT -> VK_PIPELINE_STAGE_2_VERTEX_SHADER_BIT
//        VK_PIPELINE_STAGE_TESSELLATION_CONTROL_BIT -> VK_PIPELINE_STAGE_2_TESSELLATION_CONTROL_SHADER_BIT
//        VK_PIPELINE_STAGE_TESSELLATION_EVALUATION_BIT -> VK_PIPELINE_STAGE_2_TESSELLATION_EVALUATION_SHADER_BIT
        VK_PIPELINE_STAGE_GEOMETRY_SHADER_BIT -> VK_PIPELINE_STAGE_2_GEOMETRY_SHADER_BIT
        VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT -> VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT
        VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT -> VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT
        VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT -> VK_PIPELINE_STAGE_2_LATE_FRAGMENT_TESTS_BIT
        VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT -> VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT
        VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT -> VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT
        VK_PIPELINE_STAGE_TRANSFER_BIT -> VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT
        VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT -> VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT
        VK_PIPELINE_STAGE_HOST_BIT -> VK_PIPELINE_STAGE_2_HOST_BIT
        // Safe default
        else -> VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT
    }

fun writeTimestamp(cmd: VkCommandBuffer, frameIndex: Int, stageLegacy: Int): Int

Backwards-compatible entry point: same signature, but uses sync2 when available.

    fun writeTimestamp(cmd: VkCommandBuffer, frameIndex: Int, stageLegacy: Int): Int {
        if (!timestampsEnabled) return -1
        val idx = tsAlloc[frameIndex]!!.alloc()

        val stage2: Long = legacyStageToStage2(stageLegacy)
        vkCmdWriteTimestamp2(cmd, stage2, timestampPools[frameIndex], idx)
        return idx
    }

fun writeTimestamp2(cmd: VkCommandBuffer, frameIndex: Int, stage2: Long): Int

Optional: a native sync2 overload if you want callers to pass stage2 directly.

    fun writeTimestamp2(cmd: VkCommandBuffer, frameIndex: Int, stage2: Long): Int {
        if (!timestampsEnabled) return -1
        val idx = tsAlloc[frameIndex]!!.alloc()
        vkCmdWriteTimestamp2(cmd, stage2, timestampPools[frameIndex], idx)
        return idx
    }

data class PipeStatScope internal constructor(val pool: Long, val index: Int)

Pipeline stats

    @ConsistentCopyVisibility
    data class PipeStatScope internal constructor(val pool: Long, val index: Int)

fun beginPipelineStats(cmd: VkCommandBuffer, frameIndex: Int): PipeStatScope?

    fun beginPipelineStats(cmd: VkCommandBuffer, frameIndex: Int): PipeStatScope? {
        if (!pipeStatsEnabled) return null
        val idx = psAlloc[frameIndex]!!.alloc()
        vkCmdBeginQuery(cmd, pipeStatPools[frameIndex], idx, 0)
        return PipeStatScope(pipeStatPools[frameIndex], idx)
    }

fun endPipelineStats(cmd: VkCommandBuffer, scope: PipeStatScope?)

    fun endPipelineStats(cmd: VkCommandBuffer, scope: PipeStatScope?) {
        if (scope == null) return
        vkCmdEndQuery(cmd, scope.pool, scope.index)
    }

data class PerfScope internal constructor(val index: Int)

Perf counters

    @ConsistentCopyVisibility
    data class PerfScope internal constructor(val index: Int)

fun beginPerfCounters(cmd: VkCommandBuffer, frameIndex: Int): PerfScope?

    fun beginPerfCounters(cmd: VkCommandBuffer, frameIndex: Int): PerfScope? {
        if (!perfQueryEnabled) return null
        val idx = pfAlloc[frameIndex]!!.alloc()
        vkCmdBeginQuery(cmd, perfPools[frameIndex], idx, 0)
        return PerfScope(idx)
    }

fun endPerfCounters(cmd: VkCommandBuffer, frameIndex: Int, scope: PerfScope?)

    fun endPerfCounters(cmd: VkCommandBuffer, frameIndex: Int, scope: PerfScope?) {
        if (scope == null) return
        vkCmdEndQuery(cmd, perfPools[frameIndex], scope.index)
    }

fun fetchTimestampNsRange(frameIndex: Int, first: Int, lastInclusive: Int): LongArray

    fun fetchTimestampNsRange(frameIndex: Int, first: Int, lastInclusive: Int): LongArray {
        if (!timestampsEnabled) return LongArray(0)
        val count = lastInclusive - first + 1
        if (count <= 0) return LongArray(0)

        val buf = MemoryUtil.memAllocLong(count)
        val res = vkGetQueryPoolResults(
            device, timestampPools[frameIndex], first, count,
            buf, java.lang.Long.BYTES.toLong(),
            VK_QUERY_RESULT_64_BIT or VK_QUERY_RESULT_WAIT_BIT
        )
        check(res == VK_SUCCESS) { "vkGetQueryPoolResults (timestamps) failed: $res" }

        val out = LongArray(count) { k -> (buf.get(k) * timestampPeriodNs).toLong() }
        MemoryUtil.memFree(buf)
        return out
    }

fun fetchPipelineStats(frameIndex: Int, index: Int, numStatWords: Int = 11): LongArray

    fun fetchPipelineStats(frameIndex: Int, index: Int, numStatWords: Int = 11): LongArray {
        if (!pipeStatsEnabled) return LongArray(0)

        val buf = MemoryUtil.memAllocLong(numStatWords)
        val res = vkGetQueryPoolResults(
            device, pipeStatPools[frameIndex], index, 1,
            buf, java.lang.Long.BYTES.toLong(),
            VK_QUERY_RESULT_64_BIT or VK_QUERY_RESULT_WAIT_BIT
        )
        check(res == VK_SUCCESS) { "vkGetQueryPoolResults (pipeline stats) failed: $res" }

        val out = LongArray(numStatWords) { k -> buf.get(k) }
        MemoryUtil.memFree(buf)
        return out
    }

fun fetchPerfCounters(frameIndex: Int, index: Int, expectedCount: Int): LongArray

body — 147 lines, from line 307
    fun fetchPerfCounters(frameIndex: Int, index: Int, expectedCount: Int): LongArray {
        if (!perfQueryEnabled) return LongArray(0)

        val buf = MemoryUtil.memAllocLong(expectedCount)
        val res = vkGetQueryPoolResults(
            device, perfPools[frameIndex], index, 1,
            buf, java.lang.Long.BYTES.toLong(),
            VK_QUERY_RESULT_64_BIT or VK_QUERY_RESULT_WAIT_BIT
        )
        check(res == VK_SUCCESS) { "vkGetQueryPoolResults (perf) failed: $res" }

        val out = LongArray(expectedCount) { k -> buf.get(k) }
        MemoryUtil.memFree(buf)
        return out
    }

    private fun createQueryPool(type: Int, count: Int, pipelineStatistics: Int): Long {
        val ci = VkQueryPoolCreateInfo.calloc()
            .sType(VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO)
            .queryType(type)
            .queryCount(count)
        if (type == VK_QUERY_TYPE_PIPELINE_STATISTICS) {
            ci.pipelineStatistics(pipelineStatistics)
        }
        val p = MemoryUtil.memAllocLong(1)
        val res = vkCreateQueryPool(device, ci, null, p)
        ci.free()
        check(res == VK_SUCCESS) { "vkCreateQueryPool failed: $res" }
        val handle = p[0]
        MemoryUtil.memFree(p)
        return handle
    }

    private fun createPerfQueryPoolAndChooseCounters(capacity: Int): Long {
        if (!perfQueryEnabled) return 0L

        val lockInfo = VkAcquireProfilingLockInfoKHR.calloc()
            .sType(VK_STRUCTURE_TYPE_ACQUIRE_PROFILING_LOCK_INFO_KHR)
            .timeout(1_000_000_000L)
        val lockRes = vkAcquireProfilingLockKHR(device, lockInfo)
        lockInfo.free()
        check(lockRes == VK_SUCCESS || lockRes == VK_TIMEOUT) { "vkAcquireProfilingLockKHR failed: $lockRes" }

        val pCount = MemoryUtil.memAllocInt(1)
        vkEnumeratePhysicalDeviceQueueFamilyPerformanceQueryCountersKHR(
            physicalDevice, queueFamilyIndex, pCount, null, null
        )
        val counterCount = pCount[0]
        if (counterCount <= 0) {
            MemoryUtil.memFree(pCount)
            vkReleaseProfilingLockKHR(device)
            return 0L
        }

        val counters = VkPerformanceCounterKHR.calloc(counterCount)
        val descriptions = VkPerformanceCounterDescriptionKHR.calloc(counterCount)
        val resEnum = vkEnumeratePhysicalDeviceQueueFamilyPerformanceQueryCountersKHR(
            physicalDevice, queueFamilyIndex, pCount, counters, descriptions
        )
        MemoryUtil.memFree(pCount)
        check(resEnum == VK_SUCCESS) { "Enumerate perf counters failed: $resEnum" }

        val selected = ArrayList<Int>(16)

        fun pickByUnit(unit: Int, max: Int = 4) {
            var taken = 0
            for (i in 0 until counterCount) {
                if (counters[i].unit() == unit) {
                    selected += i
                    taken++
                    if (taken >= max || selected.size >= 16) break
                }
            }
        }

        pickByUnit(VK_PERFORMANCE_COUNTER_UNIT_CYCLES_KHR)
        pickByUnit(VK_PERFORMANCE_COUNTER_UNIT_NANOSECONDS_KHR)
        pickByUnit(VK_PERFORMANCE_COUNTER_UNIT_GENERIC_KHR)

        var i = 0
        while (selected.size < 8 && i < counterCount) {
            if (!selected.contains(i)) selected += i
            i++
        }

        val selCount = selected.size
        val counterIndices = MemoryUtil.memAllocInt(selCount)
        selected.forEachIndexed { k, idx -> counterIndices.put(k, idx) }

        val metas = ArrayList<PerfCounterMeta>(selCount)
        for (idxSel in selected) {
            val c = counters[idxSel]
            val d = descriptions[idxSel]
            metas += PerfCounterMeta(
                name = d.nameString(),
                unit = c.unit(),
                scope = c.scope()
            )
        }
        _perfMeta = metas

        val perfPoolInfo = VkQueryPoolPerformanceCreateInfoKHR.calloc()
            .sType(VK_STRUCTURE_TYPE_QUERY_POOL_PERFORMANCE_CREATE_INFO_KHR)
            .queueFamilyIndex(queueFamilyIndex)
            .pCounterIndices(counterIndices)

        val qpInfo = VkQueryPoolCreateInfo.calloc()
            .sType(VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO)
            .pNext(perfPoolInfo.address())
            .queryType(VK_QUERY_TYPE_PERFORMANCE_QUERY_KHR)
            .queryCount(capacity)

        val pPool = MemoryUtil.memAllocLong(1)
        val resCreate = vkCreateQueryPool(device, qpInfo, null, pPool)

        qpInfo.free()
        perfPoolInfo.free()
        MemoryUtil.memFree(counterIndices)
        counters.free()
        descriptions.free()

        check(resCreate == VK_SUCCESS) { "vkCreateQueryPool (perf) failed: $resCreate" }

        val handle = pPool[0]
        MemoryUtil.memFree(pPool)

        vkReleaseProfilingLockKHR(device)
        return handle
    }

    private fun hasDeviceExtension(physicalDevice: VkPhysicalDevice, name: String): Boolean {
        val pCount = MemoryUtil.memAllocInt(1)
        vkEnumerateDeviceExtensionProperties(physicalDevice, null as? ByteBuffer?, pCount, null)
        val count = pCount[0]
        val props = VkExtensionProperties.calloc(count)
        vkEnumerateDeviceExtensionProperties(physicalDevice, null as? ByteBuffer?, pCount, props)
        MemoryUtil.memFree(pCount)

        var found = false
        for (i in 0 until count) {
            if (name == props[i].extensionNameString()) {
                found = true; break
            }
        }
        props.free()
        return found
    }

fun destroy()

    fun destroy() {
        if (!enabled) return
        for (i in 0 until framesInFlight) {
            if (timestampPools[i] != 0L) vkDestroyQueryPool(device, timestampPools[i], null)
            if (pipeStatPools[i] != 0L) vkDestroyQueryPool(device, pipeStatPools[i], null)
            if (perfPools[i] != 0L) vkDestroyQueryPool(device, perfPools[i], null)
        }
    }

fun formatPerfValue(v: Long, unit: Int): String

    fun formatPerfValue(v: Long, unit: Int): String = when (unit) {
        VK_PERFORMANCE_COUNTER_UNIT_GENERIC_KHR -> v.toString()
        VK_PERFORMANCE_COUNTER_UNIT_PERCENTAGE_KHR -> "%.2f %%".format(v.toDouble())
        VK_PERFORMANCE_COUNTER_UNIT_NANOSECONDS_KHR -> "%.3f ms".format(v / 1e6)
        VK_PERFORMANCE_COUNTER_UNIT_CYCLES_KHR -> "$v cycles"
        VK_PERFORMANCE_COUNTER_UNIT_BYTES_KHR -> humanBytes(v)
        VK_PERFORMANCE_COUNTER_UNIT_BYTES_PER_SECOND_KHR -> humanBytesPerSec(v)
        VK_PERFORMANCE_COUNTER_UNIT_KELVIN_KHR -> "%.2f K".format(v.toDouble())
        VK_PERFORMANCE_COUNTER_UNIT_WATTS_KHR -> "%.3f W".format(v / 1e3)
        VK_PERFORMANCE_COUNTER_UNIT_VOLTS_KHR -> "%.3f V".format(v / 1e3)
        VK_PERFORMANCE_COUNTER_UNIT_AMPS_KHR -> "%.3f A".format(v / 1e3)
        VK_PERFORMANCE_COUNTER_UNIT_HERTZ_KHR -> "%.3f MHz".format(v / 1e6)
        else -> v.toString()
    }

    private fun humanBytes(v: Long): String {
        val kb = 1024.0;
        val mb = kb * 1024;
        val gb = mb * 1024
        val dv = v.toDouble()
        return when {
            dv >= gb -> "%.2f GiB".format(dv / gb)
            dv >= mb -> "%.2f MiB".format(dv / mb)
            dv >= kb -> "%.2f KiB".format(dv / kb)
            else -> "$v B"
        }
    }

    private fun humanBytesPerSec(v: Long): String = humanBytes(v) + "/s"
}