From 0e8ba02038f75f01931de2a6239c2b2ae1e7fbb4 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Fri, 31 Jul 2026 00:38:02 +0200 Subject: [PATCH] feat(renderer): expose physical device information --- crates/renderer/src/device.rs | 127 +++++++++++++++++++++++++++-- crates/renderer/src/lib.rs | 15 +++- crates/renderer/src/renderer.rs | 38 ++++++++- crates/renderer/src/stats.rs | 71 ++++++++++++++++ crates/renderer/src/tests/stats.rs | 26 ++++++ 5 files changed, 264 insertions(+), 13 deletions(-) diff --git a/crates/renderer/src/device.rs b/crates/renderer/src/device.rs index 34b0390..833a87d 100644 --- a/crates/renderer/src/device.rs +++ b/crates/renderer/src/device.rs @@ -3,6 +3,7 @@ //! Logic for selecting physical devices and creating logical devices. use crate::error::RendererError; +use crate::stats::{GpuInfo, decode_driver_version}; use ash::{Device, Instance, khr, vk}; /// Picks a physical device (GPU) that supports the required features and extensions. @@ -39,13 +40,18 @@ pub fn create_logical_device( instance: &Instance, physical_device: vk::PhysicalDevice, queue_family_index: u32, + memory_budget: bool, ) -> Result<(Device, vk::Queue), RendererError> { let priorities = [1.0]; let queue_info = vk::DeviceQueueCreateInfo::default() .queue_family_index(queue_family_index) .queue_priorities(&priorities); - let device_extensions = [khr::swapchain::NAME.as_ptr()]; + // `VK_EXT_memory_budget` is optional and is requested only where the device advertises it; naming an unsupported extension fails device creation outright. + let mut device_extensions = vec![khr::swapchain::NAME.as_ptr()]; + if memory_budget { + device_extensions.push(ash::ext::memory_budget::NAME.as_ptr()); + } // Enable Vulkan 1.3 features let mut synchronization2_features = @@ -103,20 +109,123 @@ pub fn find_graphics_queue_family( Err(RendererError::NoSuitableGpu) } +/// Reports whether `physical_device` advertises the optional `VK_EXT_memory_budget` extension. +/// +/// The extension is what makes driver-side VRAM usage and budget readable; without it those figures are simply unavailable, which is a reportable state rather than an error. +pub fn supports_memory_budget(instance: &Instance, physical_device: vk::PhysicalDevice) -> bool { + has_extension(instance, physical_device, ash::ext::memory_budget::NAME) +} + +/// Queries the immutable properties of `physical_device` into a reportable snapshot. +/// +/// `memory_budget` records whether the optional budget extension was enabled on the logical device, since the caller owns that decision and this query cannot observe it. +pub fn query_gpu_info( + instance: &Instance, + physical_device: vk::PhysicalDevice, + memory_budget: bool, +) -> GpuInfo { + let props = unsafe { instance.get_physical_device_properties(physical_device) }; + let memory_props = unsafe { instance.get_physical_device_memory_properties(physical_device) }; + + // The name is a fixed-size, NUL-terminated array of `c_char`; `to_string_lossy` substitutes replacement characters rather than failing on a malformed driver string. + let device_name = unsafe { std::ffi::CStr::from_ptr(props.device_name.as_ptr()) } + .to_string_lossy() + .into_owned(); + + GpuInfo { + device_name, + device_type: device_type_name(props.device_type), + vendor_id: props.vendor_id, + device_id: props.device_id, + driver_version: decode_driver_version(props.vendor_id, props.driver_version), + api_version: format!( + "{}.{}.{}", + vk::api_version_major(props.api_version), + vk::api_version_minor(props.api_version), + vk::api_version_patch(props.api_version) + ), + vram_total_bytes: device_local_heap_bytes(&memory_props), + memory_budget_supported: memory_budget, + } +} + +/// Reads the driver's current usage and budget across the device-local heaps. +/// +/// Returns `(usage, budget)` in bytes. Both are [`None`] unless `VK_EXT_memory_budget` is enabled, since the values are carried in a structure the extension defines. The figures cover every process on the device, not only this one. +pub fn query_memory_budget( + instance: &Instance, + physical_device: vk::PhysicalDevice, + memory_budget: bool, +) -> (Option, Option) { + if !memory_budget { + return (None, None); + } + + let mut budget_props = vk::PhysicalDeviceMemoryBudgetPropertiesEXT::default(); + let mut props = vk::PhysicalDeviceMemoryProperties2::default().push_next(&mut budget_props); + unsafe { + instance.get_physical_device_memory_properties2(physical_device, &mut props); + } + + // Only the device-local heaps are of interest; host-visible system-memory heaps are not the resource under pressure. The three arrays are parallel and all sized `VK_MAX_MEMORY_HEAPS`, so zipping them cannot desynchronise. + let heaps = &props.memory_properties; + let count = heaps.memory_heap_count as usize; + let (usage, budget) = heaps.memory_heaps[..count] + .iter() + .zip(&budget_props.heap_usage[..count]) + .zip(&budget_props.heap_budget[..count]) + .filter(|((heap, _), _)| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL)) + .fold((0u64, 0u64), |(usage, budget), ((_, used), allowed)| { + (usage.saturating_add(*used), budget.saturating_add(*allowed)) + }); + + (Some(usage), Some(budget)) +} + +/// Sums the capacity of every heap flagged `DEVICE_LOCAL`, in bytes. +fn device_local_heap_bytes(props: &vk::PhysicalDeviceMemoryProperties) -> u64 { + props.memory_heaps[..props.memory_heap_count as usize] + .iter() + .filter(|heap| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL)) + .map(|heap| heap.size) + .sum() +} + +/// Returns a human-readable name for a physical-device class. +const fn device_type_name(device_type: vk::PhysicalDeviceType) -> &'static str { + match device_type { + vk::PhysicalDeviceType::DISCRETE_GPU => "discrete", + vk::PhysicalDeviceType::INTEGRATED_GPU => "integrated", + vk::PhysicalDeviceType::VIRTUAL_GPU => "virtual", + vk::PhysicalDeviceType::CPU => "cpu", + _ => "other", + } +} + +/// Reports whether `physical_device` advertises the named device extension. +fn has_extension( + instance: &Instance, + physical_device: vk::PhysicalDevice, + name: &std::ffi::CStr, +) -> bool { + let extensions = unsafe { + instance + .enumerate_device_extension_properties(physical_device) + .unwrap_or_default() + }; + extensions + .iter() + .any(|ext| unsafe { std::ffi::CStr::from_ptr(ext.extension_name.as_ptr()) } == name) +} + +/// Reports whether a physical device can present to `surface` and supports the extensions the renderer requires. fn is_device_suitable( instance: &Instance, device: vk::PhysicalDevice, surface_loader: &khr::surface::Instance, surface: vk::SurfaceKHR, ) -> bool { - let extensions = unsafe { - instance - .enumerate_device_extension_properties(device) - .unwrap_or_default() - }; - let has_swapchain = extensions.iter().any(|ext| unsafe { - std::ffi::CStr::from_ptr(ext.extension_name.as_ptr()) == khr::swapchain::NAME - }); + let has_swapchain = has_extension(instance, device, khr::swapchain::NAME); let formats = unsafe { surface_loader diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 6fef170..7c85054 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -30,7 +30,7 @@ use std::ffi::c_char; pub use error::RendererError; pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; -pub use stats::{ProjectionInfo, RenderStats, SwapchainInfo}; +pub use stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo}; use std::collections::HashMap; @@ -77,8 +77,15 @@ impl Renderer { )?; // 5. Logical Device and Queue - let (device, graphics_queue) = - device::create_logical_device(&instance, physical_device, graphics_queue_index)?; + // Driver-side memory reporting is optional; the extension is detected here so it can be both enabled on the device and recorded in the reported device information. + let memory_budget = device::supports_memory_budget(&instance, physical_device); + let (device, graphics_queue) = device::create_logical_device( + &instance, + physical_device, + graphics_queue_index, + memory_budget, + )?; + let gpu_info = device::query_gpu_info(&instance, physical_device, memory_budget); // 6. Swapchain let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) = @@ -169,6 +176,8 @@ impl Renderer { pipeline_layout, pipelines, render_mode: RenderMode::default(), + gpu_info, + memory_budget, sync: Some(sync), current_frame: 0, present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE), diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index 4a8f2e6..f344e11 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only -use crate::stats::{ProjectionInfo, RenderStats, SwapchainInfo}; +use crate::stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo}; use crate::sync::SyncPrimitives; use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{error::RendererError, frustum::Frustum, vertex::Vertex}; @@ -225,6 +225,10 @@ pub struct Renderer { pub(crate) frames_skipped: u64, /// Submission statistics for the most recently completed frame, or [`None`] before the first frame completes. pub(crate) last_frame_stats: Option, + /// Immutable properties of the selected physical device, queried once at initialisation. + pub(crate) gpu_info: GpuInfo, + /// Whether `VK_EXT_memory_budget` was enabled on the logical device, gating the driver-side figures in [`MemoryUsage`]. + pub(crate) memory_budget: bool, } impl Renderer { @@ -465,6 +469,38 @@ impl Renderer { self.last_frame_stats } + /// Returns the immutable properties of the physical device the renderer selected. + #[must_use] + pub const fn gpu_info(&self) -> &GpuInfo { + &self.gpu_info + } + + /// Reads live memory figures from the driver and the renderer's allocator. + /// + /// The driver-side figures require `VK_EXT_memory_budget` and are [`None`] where it is unsupported. The allocator figures are always available but describe only this process's suballocations. + #[must_use] + pub fn memory_usage(&self) -> MemoryUsage { + let (heap_usage_bytes, heap_budget_bytes) = crate::device::query_memory_budget( + &self.instance, + self.physical_device, + self.memory_budget, + ); + + // The allocator is taken only during teardown, so a live renderer always observes it; absent it, the process-side figures are simply reported as zero rather than failing the whole snapshot. + let (allocator_allocated_bytes, allocator_capacity_bytes) = + self.allocator.as_ref().map_or((0, 0), |allocator| { + let report = allocator.generate_report(); + (report.total_allocated_bytes, report.total_capacity_bytes) + }); + + MemoryUsage { + heap_usage_bytes, + heap_budget_bytes, + allocator_allocated_bytes, + allocator_capacity_bytes, + } + } + /// Records the drawing commands into the given command buffer, returning what they submitted. /// /// # Errors diff --git a/crates/renderer/src/stats.rs b/crates/renderer/src/stats.rs index ae3e847..7f0c25f 100644 --- a/crates/renderer/src/stats.rs +++ b/crates/renderer/src/stats.rs @@ -84,6 +84,77 @@ impl RenderStats { } } +/// Immutable description of the physical device the renderer selected. +/// +/// Queried once at initialisation: every field is a property of the device or driver and cannot change for the lifetime of the renderer. Live memory figures are not part of this and are read separately through [`MemoryUsage`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GpuInfo { + /// Marketing name the driver reports for the device. + pub device_name: String, + /// Device class: discrete, integrated, virtual, CPU, or other. + pub device_type: &'static str, + /// PCI vendor identifier, as reported by the driver. + pub vendor_id: u32, + /// Vendor-assigned device identifier. + pub device_id: u32, + /// Driver version, decoded with the vendor's own packing scheme where it differs from the Vulkan convention. + pub driver_version: String, + /// Vulkan API version the device supports, as `major.minor.patch`. + pub api_version: String, + /// Total capacity of every heap flagged `DEVICE_LOCAL`, in bytes. This is dedicated video memory on a discrete device and a share of system memory on an integrated one. + pub vram_total_bytes: u64, + /// Whether `VK_EXT_memory_budget` was available and enabled, and therefore whether [`MemoryUsage`] can report driver-side figures. + pub memory_budget_supported: bool, +} + +/// Live memory figures, read on demand rather than cached. +/// +/// Two independent views: the driver's own accounting of the device-local heaps (available only where `VK_EXT_memory_budget` is supported) and the renderer's allocator, which sees only what this process suballocates. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct MemoryUsage { + /// Bytes the driver reports as currently in use across the device-local heaps, by every process. [`None`] where `VK_EXT_memory_budget` is unsupported. + pub heap_usage_bytes: Option, + /// Bytes the driver reports this process may use across the device-local heaps before it risks eviction. [`None`] where `VK_EXT_memory_budget` is unsupported. + pub heap_budget_bytes: Option, + /// Bytes currently handed out by the renderer's allocator as live suballocations. + pub allocator_allocated_bytes: u64, + /// Bytes the renderer's allocator holds in device memory blocks, including regions not yet suballocated. Always at least `allocator_allocated_bytes`; the difference is allocator slack. + pub allocator_capacity_bytes: u64, +} + +/// PCI vendor identifier for NVIDIA, whose driver packs `driver_version` differently from the Vulkan convention. +const VENDOR_NVIDIA: u32 = 0x10DE; + +/// PCI vendor identifier for Intel, whose Windows driver packs `driver_version` differently from the Vulkan convention. +const VENDOR_INTEL: u32 = 0x8086; + +/// Decodes a `VkPhysicalDeviceProperties::driverVersion` into a human-readable string. +/// +/// The field is documented as vendor-specific, and two vendors deviate from the `VK_MAKE_VERSION` packing the rest follow. NVIDIA uses a four-component 10/8/8/6-bit layout. Intel's Windows driver uses a 14/18-bit split; its Linux (Mesa) driver follows the Vulkan convention, so the deviation is applied only on Windows. Every other vendor is decoded as major/minor/patch. +#[must_use] +pub fn decode_driver_version(vendor_id: u32, version: u32) -> String { + if vendor_id == VENDOR_NVIDIA { + return format!( + "{}.{}.{}.{}", + (version >> 22) & 0x3ff, + (version >> 14) & 0x0ff, + (version >> 6) & 0x0ff, + version & 0x3f + ); + } + + if vendor_id == VENDOR_INTEL && cfg!(windows) { + return format!("{}.{}", version >> 14, version & 0x3fff); + } + + format!( + "{}.{}.{}", + version >> 22, + (version >> 12) & 0x3ff, + version & 0xfff + ) +} + #[cfg(test)] #[path = "tests/stats.rs"] mod tests; diff --git a/crates/renderer/src/tests/stats.rs b/crates/renderer/src/tests/stats.rs index 11436a9..31c7f98 100644 --- a/crates/renderer/src/tests/stats.rs +++ b/crates/renderer/src/tests/stats.rs @@ -52,3 +52,29 @@ fn cull_ratio_is_full_when_every_mesh_is_culled() { fn cull_ratio_is_the_culled_share_of_the_considered_set() { assert!((stats_with_counts(3, 1).cull_ratio_percent() - 25.0).abs() < f32::EPSILON); } + +#[test] +fn driver_version_uses_the_vulkan_convention_for_unknown_vendors() { + // 1.2.131 packed as 22/12/0-bit major/minor/patch. + let packed = (1 << 22) | (2 << 12) | 0x83; + assert_eq!(decode_driver_version(0x1002, packed), "1.2.131"); +} + +#[test] +fn driver_version_uses_the_four_component_layout_for_nvidia() { + // 535.104.5.0 packed as 10/8/8/6-bit components. + let packed = (535 << 22) | (104 << 14) | (5 << 6); + assert_eq!(decode_driver_version(VENDOR_NVIDIA, packed), "535.104.5.0"); +} + +#[test] +fn driver_version_for_intel_follows_the_host_platform_convention() { + // 101.4502 packed as a 14/18-bit split, which is the Windows layout; the same word decodes differently under the Vulkan convention Mesa follows on Linux. + let packed = (101 << 14) | 0x1196; + let expected = if cfg!(windows) { + "101.4502" + } else { + "0.405.406" + }; + assert_eq!(decode_driver_version(VENDOR_INTEL, packed), expected); +}