Skip to content

VulkanProbe.kt

src/jvmMain/kotlin/afield/graphics/vulkan/util/VulkanProbe.kt · 260 lines

package afield.graphics.vulkan.util

Reports what Vulkan this machine offers: loader, instance, devices, validation layers, the SDK and, on macOS, the KosmicKrisp driver.

VulkanProbe runs the check in a child JVM and returns a VulkanStatus, which the Vulkan panel on /welcome shows along with install steps for the current OS. VulkanDeviceInfo describes one physical device.

import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.*
import java.io.File
import java.util.concurrent.TimeUnit

data class VulkanDeviceInfo

One physical device as the loader reports it.

@Serializable
data class VulkanDeviceInfo(
    /** The device name the driver reports, which is what the Vulkan panel lists. */
    val name: String,
    /** `discrete`, `integrated`, `virtual`, `cpu` or `other`. A machine whose only device is `cpu` has a software rasteriser and no GPU driver. */
    val type: String,
    /** The highest Vulkan version this device supports, as `major.minor.patch`. */
    val apiVersion: String,
    /** The driver's own name, from `VkPhysicalDeviceDriverProperties`. Empty on a driver that does not fill it in. */
    val driverName: String = "",
    /** The driver's version string, from the same properties and equally optional. */
    val driverInfo: String = "",
    /**
     * The `VK_DRIVER_ID_*` the driver reports, which is what tells a MoltenVK device from a
     * KosmicKrisp one on macOS. 0 when the driver reports none. `PhysicalDevice` matches this number
     * against [Options.MacVulkanDriver] when it picks a device.
     */
    val driverId: Int = 0,
)

data class VulkanStatus

What a graphics box on this machine would get, and what is missing. Shown by the Vulkan panel on /welcome, where a fresh clone first lands, so the user finds out before opening a window.

ok means a loader, an instance, and at least one device with a platform surface. Everything else is optional: validation layers (Field asks for them by default and carries on without), the SDK, and on macOS the KosmicKrisp driver.

@Serializable
data class VulkanStatus(
    /** `macos`, `windows` or `linux`. */
    val os: String,
    /** The JVM's `os.arch`. On macOS it is one of the conditions on KosmicKrisp, which runs on Apple silicon only. */
    val arch: String,
    /** A loader, an instance, and at least one device with a platform surface. False leaves [error] saying which of those was missing. */
    val ok: Boolean = false,
    /** `sdk` (a LunarG SDK's loader), `system` (whatever the OS resolves), `lwjgl-moltenvk` (the MoltenVK
     *  inside LWJGL's jar, no loader), or `none`. */
    val loaderSource: String = "none",
    /** The loader `Options` pinned, on macOS. Null everywhere else, where the loader is whatever the OS resolves and has no path worth printing. */
    val loaderPath: String? = null,
    /** The SDK root the probe found: `Options.macVulkanSdk` on macOS, `VULKAN_SDK` elsewhere. Null when there is none, which is not by itself a failure. */
    val sdkPath: String? = null,
    /** The version read out of [sdkPath]'s own directory name or its parent's, whichever looks like a version. Null for an SDK installed under a path that names none. */
    val sdkVersion: String? = null,
    /** The highest instance API version the loader supports, as `major.minor.patch`. Null when the loader did not load. */
    val instanceVersion: String? = null,
    /** The platform surface extension the instance offers beside `VK_KHR_surface`, such as `VK_EXT_metal_surface`. Null means no window can be opened, and is one of the two things that clears [ok]. */
    val surfaceExtension: String? = null,
    /** Whether `VK_LAYER_KHRONOS_validation` is installed. Field asks for it by default and runs without it, so false is a weaker diagnostic story rather than a broken one. */
    val validationLayer: Boolean = false,
    /** Every physical device the instance enumerated. Empty is the other thing that clears [ok]: a loader loaded, and no GPU driver offered it an ICD. */
    val devices: List<VulkanDeviceInfo> = emptyList(),
    /** macOS: the driver `Options` asked the loader for, and whether that SDK has a KosmicKrisp to ask for. */
    val requestedDriver: String? = null,
    /** Whether the SDK at [sdkPath] ships a KosmicKrisp manifest to ask for. That it is there does not mean it is in use — [requestedDriver] says which one was asked for. */
    val kosmicKrispAvailable: Boolean = false,
    /**
     * What went wrong, phrased for the Vulkan panel to show, or null when nothing did. A probe that
     * crashed loading a driver, or ran past its timeout, reports it here rather than throwing, so a
     * failed probe still returns a status.
     */
    val error: String? = null,
    /** How long the probe took, in milliseconds. A cached status carries the duration of the probe that produced it, not of the lookup. */
    val probeMs: Long = 0,
)

object VulkanProbe

object VulkanProbe {

    private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }

    private val osName: String = when {
        Options.isMac -> "macos"
        Options.isWindows -> "windows"
        else -> "linux"
    }

    @Volatile
    private var cached: VulkanStatus? = null

fun status(refresh: Boolean = false): VulkanStatus

The last probe, or a new one when there is none or refresh asks. One probe at a time.

    @Synchronized
    fun status(refresh: Boolean = false): VulkanStatus {
        if (!refresh) cached?.let { return it }
        return probeInChildJvm().also { cached = it }
    }

fun probeInChildJvm(timeoutSec: Long = 30): VulkanStatus

Run probeInProcess in a child JVM, because it loads every ICD the loader finds. A broken driver can crash the process that does that — Nsight already crashed it once (see Options.dumpToolingInfo) — and the process asking here is the Field server.

    fun probeInChildJvm(timeoutSec: Long = 30): VulkanStatus {
        val started = System.nanoTime()
        fun failed(message: String) = VulkanStatus(os = osName, arch = System.getProperty("os.arch"), error = message,
            probeMs = (System.nanoTime() - started) / 1_000_000)
        val javaHome = System.getProperty("java.home") ?: return failed("java.home is not set")
        val java = File(File(javaHome, "bin"), if (Options.isWindows) "java.exe" else "java").path
        val classpath = System.getProperty("java.class.path")?.takeIf { it.isNotBlank() } ?: return failed("java.class.path is blank")
        val process = runCatching {
            ProcessBuilder(
                java, "--enable-native-access=ALL-UNNAMED", "-Djava.awt.headless=true",
                "-Dorg.lwjgl.system.stackSize=256", "-cp", classpath, "afield.graphics.vulkan.util.VulkanProbeKt"
            ).redirectErrorStream(false).start()
        }.getOrElse { return failed("could not start the probe JVM: ${it.message}") }
        process.errorStream.bufferedReader().let { err -> Thread { err.readText() }.apply { isDaemon = true }.start() }
        val out = process.inputStream.bufferedReader().readText()
        if (!process.waitFor(timeoutSec, TimeUnit.SECONDS)) {
            process.destroyForcibly()
            return failed("the probe did not finish in ${timeoutSec}s")
        }
        val line = out.lineSequence().lastOrNull { it.startsWith(PREFIX) }
            ?: return failed("the probe exited with code ${process.exitValue()} without a result — " +
                "a crash while loading a Vulkan driver looks like this")
        return runCatching { json.decodeFromString<VulkanStatus>(line.removePrefix(PREFIX)) }
            .getOrElse { failed("unreadable probe result: ${it.message}") }
    }

fun probeInProcess(): VulkanStatus

Load the loader the way Field does, create a bare instance, list devices, tear it all down.

body — 111 lines, from line 137
    fun probeInProcess(): VulkanStatus {
        val started = System.nanoTime()
        Options.doVulkanSDK()
        val sdk = if (Options.isMac) Options.macVulkanSdk else System.getenv("VULKAN_SDK")?.let(::File)
        val macLoader = Options.vulkan_library_name?.takeIf { Options.isMac && File(it).exists() }
        var status = VulkanStatus(
            os = osName,
            arch = System.getProperty("os.arch"),
            loaderSource = when {
                macLoader != null -> "sdk"
                Options.isMac -> "lwjgl-moltenvk"
                else -> "system"
            },
            loaderPath = macLoader,
            sdkPath = sdk?.path,
            // …/VulkanSDK/<v>/macOS, C:\VulkanSDK\<v>, ~/vulkan/<v>/x86_64: the version is the first
            // of the directory or its parent that looks like one.
            sdkVersion = sdk?.let { dir -> listOfNotNull(dir.name, dir.parentFile?.name).firstOrNull { it.matches(Regex("""\d+(\.\d+)+""")) } },
            requestedDriver = if (Options.isMac) Options.resolvedMacVulkanDriver.name.lowercase() else null,
            kosmicKrispAvailable = Options.isMac &&
                File(sdk, "share/vulkan/icd.d/${Options.MacVulkanDriver.KOSMICKRISP.icdManifest}").exists(),
        )
        try {
            // With VULKAN_EXPLICIT_INIT off, LWJGL loads the loader when VK is first touched and keeps
            // any failure until a function is asked for — which is this.
            VK.getFunctionProvider()
        } catch (t: Throwable) {
            return status.copy(loaderSource = "none", error = "no Vulkan loader: ${t.message ?: t.javaClass.simpleName}",
                probeMs = (System.nanoTime() - started) / 1_000_000)
        }
        try {
            MemoryStack.stackPush().use { stack ->
                val instanceVersion = VK.getInstanceVersionSupported()
                val count = stack.mallocInt(1)
                VK10.vkEnumerateInstanceExtensionProperties(null as String?, count, null)
                val extProps = VkExtensionProperties.calloc(count[0], stack)
                VK10.vkEnumerateInstanceExtensionProperties(null as String?, count, extProps)
                val exts = (0 until extProps.capacity()).map { extProps[it].extensionNameString() }.toSet()
                VK10.vkEnumerateInstanceLayerProperties(count, null)
                val layerProps = VkLayerProperties.calloc(count[0], stack)
                VK10.vkEnumerateInstanceLayerProperties(count, layerProps)
                val layers = (0 until layerProps.capacity()).map { layerProps[it].layerNameString() }.toSet()
                val surface = listOf(
                    "VK_EXT_metal_surface", "VK_KHR_win32_surface", "VK_KHR_wayland_surface",
                    "VK_KHR_xcb_surface", "VK_KHR_xlib_surface"
                ).firstOrNull { it in exts && "VK_KHR_surface" in exts }
                val portability = "VK_KHR_portability_enumeration" in exts
                status = status.copy(
                    instanceVersion = versionString(instanceVersion),
                    surfaceExtension = surface,
                    validationLayer = "VK_LAYER_KHRONOS_validation" in layers,
                )

                val names = stack.mallocPointer(if (portability) 1 else 0)
                if (portability) names.put(0, stack.ASCII("VK_KHR_portability_enumeration"))
                val ci = VkInstanceCreateInfo.calloc(stack).`sType$Default`()
                    .pApplicationInfo(VkApplicationInfo.calloc(stack).`sType$Default`().apiVersion(VK13.VK_API_VERSION_1_3))
                    .ppEnabledExtensionNames(names)
                    .flags(if (portability) KHRPortabilityEnumeration.VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR else 0)
                val pInstance = stack.mallocPointer(1)
                val rc = VK10.vkCreateInstance(ci, null, pInstance)
                if (rc != VK10.VK_SUCCESS) {
                    return status.copy(error = "vkCreateInstance returned $rc", probeMs = (System.nanoTime() - started) / 1_000_000)
                }
                val instance = VkInstance(pInstance[0], ci)
                try {
                    VK10.vkEnumeratePhysicalDevices(instance, count, null)
                    val handles = stack.mallocPointer(count[0])
                    VK10.vkEnumeratePhysicalDevices(instance, count, handles)
                    val devices = (0 until handles.capacity()).map { i ->
                        val pd = VkPhysicalDevice(handles[i], instance)
                        val driver = VkPhysicalDeviceDriverProperties.calloc(stack).`sType$Default`()
                        val props2 = VkPhysicalDeviceProperties2.calloc(stack).`sType$Default`().pNext(driver.address())
                        VK11.vkGetPhysicalDeviceProperties2(pd, props2)
                        val p = props2.properties()
                        VulkanDeviceInfo(
                            name = p.deviceNameString(),
                            type = when (p.deviceType()) {
                                VK10.VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU -> "discrete"
                                VK10.VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU -> "integrated"
                                VK10.VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU -> "virtual"
                                VK10.VK_PHYSICAL_DEVICE_TYPE_CPU -> "cpu"
                                else -> "other"
                            },
                            apiVersion = versionString(p.apiVersion()),
                            driverName = driver.driverNameString(),
                            driverInfo = driver.driverInfoString(),
                            driverId = driver.driverID(),
                        )
                    }
                    status = status.copy(
                        devices = devices,
                        ok = devices.isNotEmpty() && surface != null,
                        error = when {
                            devices.isEmpty() -> "the loader found no Vulkan device — no GPU driver provides a Vulkan ICD"
                            surface == null -> "no platform surface extension, so Field cannot open a window"
                            else -> null
                        },
                    )
                } finally {
                    VK10.vkDestroyInstance(instance, null)
                }
            }
        } catch (t: Throwable) {
            status = status.copy(error = "${t.javaClass.simpleName}: ${t.message}")
        }
        return status.copy(probeMs = (System.nanoTime() - started) / 1_000_000)
    }

    private fun versionString(v: Int) =
        "${VK10.VK_API_VERSION_MAJOR(v)}.${VK10.VK_API_VERSION_MINOR(v)}.${VK10.VK_API_VERSION_PATCH(v)}"

internal const val PREFIX = "VULKAN_STATUS "

internal fun encode(status: VulkanStatus) = json.encodeToString(status)

fun main()

Entry point for VulkanProbe.probeInChildJvm; prints one prefixed JSON line.

fun main() {
    println(VulkanProbe.PREFIX + VulkanProbe.encode(VulkanProbe.probeInProcess()))
    System.out.flush()
    Runtime.getRuntime().halt(0)
}