feat(renderer): expose physical device information

This commit is contained in:
Serkyo 2026-07-31 00:38:02 +02:00
parent 89887c5b98
commit 0e8ba02038
5 changed files with 264 additions and 13 deletions

View file

@ -3,6 +3,7 @@
//! Logic for selecting physical devices and creating logical devices. //! Logic for selecting physical devices and creating logical devices.
use crate::error::RendererError; use crate::error::RendererError;
use crate::stats::{GpuInfo, decode_driver_version};
use ash::{Device, Instance, khr, vk}; use ash::{Device, Instance, khr, vk};
/// Picks a physical device (GPU) that supports the required features and extensions. /// Picks a physical device (GPU) that supports the required features and extensions.
@ -39,13 +40,18 @@ pub fn create_logical_device(
instance: &Instance, instance: &Instance,
physical_device: vk::PhysicalDevice, physical_device: vk::PhysicalDevice,
queue_family_index: u32, queue_family_index: u32,
memory_budget: bool,
) -> Result<(Device, vk::Queue), RendererError> { ) -> Result<(Device, vk::Queue), RendererError> {
let priorities = [1.0]; let priorities = [1.0];
let queue_info = vk::DeviceQueueCreateInfo::default() let queue_info = vk::DeviceQueueCreateInfo::default()
.queue_family_index(queue_family_index) .queue_family_index(queue_family_index)
.queue_priorities(&priorities); .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 // Enable Vulkan 1.3 features
let mut synchronization2_features = let mut synchronization2_features =
@ -103,20 +109,123 @@ pub fn find_graphics_queue_family(
Err(RendererError::NoSuitableGpu) 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<u64>, Option<u64>) {
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( fn is_device_suitable(
instance: &Instance, instance: &Instance,
device: vk::PhysicalDevice, device: vk::PhysicalDevice,
surface_loader: &khr::surface::Instance, surface_loader: &khr::surface::Instance,
surface: vk::SurfaceKHR, surface: vk::SurfaceKHR,
) -> bool { ) -> bool {
let extensions = unsafe { let has_swapchain = has_extension(instance, device, khr::swapchain::NAME);
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 formats = unsafe { let formats = unsafe {
surface_loader surface_loader

View file

@ -30,7 +30,7 @@ use std::ffi::c_char;
pub use error::RendererError; pub use error::RendererError;
pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; 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; use std::collections::HashMap;
@ -77,8 +77,15 @@ impl Renderer {
)?; )?;
// 5. Logical Device and Queue // 5. Logical Device and Queue
let (device, graphics_queue) = // 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.
device::create_logical_device(&instance, physical_device, graphics_queue_index)?; 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 // 6. Swapchain
let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) = let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) =
@ -169,6 +176,8 @@ impl Renderer {
pipeline_layout, pipeline_layout,
pipelines, pipelines,
render_mode: RenderMode::default(), render_mode: RenderMode::default(),
gpu_info,
memory_budget,
sync: Some(sync), sync: Some(sync),
current_frame: 0, current_frame: 0,
present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE), present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE),

View file

@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // 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::sync::SyncPrimitives;
use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{create_depth_resources, create_gpu_buffer, swapchain};
use crate::{error::RendererError, frustum::Frustum, vertex::Vertex}; use crate::{error::RendererError, frustum::Frustum, vertex::Vertex};
@ -225,6 +225,10 @@ pub struct Renderer {
pub(crate) frames_skipped: u64, pub(crate) frames_skipped: u64,
/// Submission statistics for the most recently completed frame, or [`None`] before the first frame completes. /// Submission statistics for the most recently completed frame, or [`None`] before the first frame completes.
pub(crate) last_frame_stats: Option<RenderStats>, pub(crate) last_frame_stats: Option<RenderStats>,
/// 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 { impl Renderer {
@ -465,6 +469,38 @@ impl Renderer {
self.last_frame_stats 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. /// Records the drawing commands into the given command buffer, returning what they submitted.
/// ///
/// # Errors /// # Errors

View file

@ -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<u64>,
/// 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<u64>,
/// 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)] #[cfg(test)]
#[path = "tests/stats.rs"] #[path = "tests/stats.rs"]
mod tests; mod tests;

View file

@ -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() { 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); 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);
}