vidya/crates/renderer_device/src/testing.rs
janis 5e4bc0ccf0
rendergraph: correct transient-resource aliasing with pooled placement
Lifetimes derive from the real dependency ordering (retained DAG +
compile-time happens-before closure), not command-index intervals.
Aliasing is restricted to the single-queue contract; alias reuse is
synchronized with memory/image barriers anchored at first-use commands.

- renderer_device: aliasable shared allocations (CAN_ALIAS), memory-
  requirements queries incl. dedicated hints, offset-aware
  bind_buffer/image_memory2, grouped bind under one allocator lock
- renderer_resources: unallocated resource creation, requirements
  cached in Allocation::Unmanaged (OnceLock), MemoryGroup pooling with
  intersecting memoryTypeBits, bind-time validation
- renderer_rendergraph: retained dependency DAG, happens_before
  closure, batch lifetimes, Placement type, plan_placements with
  pooled aliasing (first-fit over offset-sorted ranges, pool classes
  split by bufferImageGranularity), alias-barrier construction,
  placement/bind error variants
- perf: quadratic compile/resolve paths eliminated (per-resource
  indexing, frontier caching, backward-scan prune, cached recording
  index, recorder scratch reuse); single requirements query per
  resource through plan/validate/bind
- design plan + GPU-gated validation tests
2026-10-27 01:57:27 +01:00

514 lines
19 KiB
Rust

//! Real Vulkan test devices with Khronos validation output captured for assertions.
//!
//! Run the device smoke tests with `cargo test -p renderer_device --features testing`.
use std::{
ffi::{CStr, c_void},
fmt,
sync::{Arc, Mutex},
};
use ash::{Entry, ext, khr, vk};
use crate::{
Device, Instance, PhysicalDeviceFeatures,
instance::{DebugUtils, InstanceDesc, VALIDATION_LAYER},
};
const TEST_LAYER_SETTINGS: &[vk::LayerSettingEXT<'static>] = &[
vk::LayerSettingEXT {
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_sync".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
vk::LayerSettingEXT {
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_best_practices".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
];
/// One validation callback message captured from a real Vulkan instance.
#[derive(Debug, Clone)]
pub struct CapturedMessage {
pub severity: vk::DebugUtilsMessageSeverityFlagsEXT,
pub types: vk::DebugUtilsMessageTypeFlagsEXT,
pub message: String,
}
impl fmt::Display for CapturedMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?} {:?}: {}", self.severity, self.types, self.message)
}
}
/// A Vulkan instance and logical device configured for validation-backed tests.
///
/// Instances are independent; tests may acquire their own device without sharing Vulkan state.
pub struct TestVulkan {
device: Device,
// Drop the callback messenger while its instance and user data are still alive.
_messenger: DebugUtils,
messages: Arc<Mutex<Vec<CapturedMessage>>>,
instance: Instance,
}
impl TestVulkan {
/// Attempt to create a Vulkan instance/device, printing a reason and returning `None` when
/// Vulkan, a physical device, or the Khronos validation layer is unavailable.
pub fn acquire() -> Option<Self> {
match Self::create() {
Ok(test) => {
eprintln!(
"renderer_device Vulkan test adapter: {}",
test.device
.properties()
.core
.device_name_as_c_str()
.map(CStr::to_string_lossy)
.unwrap_or_else(|_| "<invalid device name>".into())
);
Some(test)
}
Err(reason) => {
eprintln!("Skipping Vulkan test: {reason}");
None
}
}
}
/// Create a validation-backed Vulkan test device or panic. Useful for CI jobs where Vulkan is
/// a required test dependency.
pub fn expect() -> Self {
Self::acquire().expect("Vulkan test device is required")
}
fn create() -> Result<Self, String> {
// Do not rewrite loader environment variables here. The loader's default search paths and
// environment provided by the development shell are both supported.
let entry = unsafe { Entry::load() }.map_err(|error| format!("loading Vulkan: {error}"))?;
let layers = unsafe { entry.enumerate_instance_layer_properties() }
.map_err(|error| format!("enumerating instance layers: {error}"))?;
if !layers
.iter()
.any(|layer| layer.layer_name_as_c_str().ok() == Some(VALIDATION_LAYER))
{
return Err("VK_LAYER_KHRONOS_validation is not available".into());
}
let instance = Instance::new(&InstanceDesc {
app_name: Some("renderer_device validation tests"),
instance_extensions: &[crate::make_extension!(khr::surface)],
layer_settings: TEST_LAYER_SETTINGS,
layers: &[VALIDATION_LAYER],
display_handle: None,
..Default::default()
})
.map_err(|error| format!("creating Vulkan instance: {error}"))?;
let messages = Arc::new(Mutex::new(Vec::new()));
let debug_loader =
ext::debug_utils::Instance::new(&instance.inner.entry, &instance.inner.raw);
let create_info = vk::DebugUtilsMessengerCreateInfoEXT::default()
.message_severity(
vk::DebugUtilsMessageSeverityFlagsEXT::ERROR
| vk::DebugUtilsMessageSeverityFlagsEXT::WARNING,
)
.message_type(
vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
| vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
| vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
)
.user_data(Arc::as_ptr(&messages) as *mut c_void)
.pfn_user_callback(Some(capture_callback));
let raw_messenger =
unsafe { debug_loader.create_debug_utils_messenger(&create_info, None) }
.map_err(|error| format!("creating validation message messenger: {error}"))?;
let messenger = DebugUtils {
instance: debug_loader,
messenger: raw_messenger,
};
let adapter = instance
.choose_adapter_default(None, &[], None)
.map_err(|error| format!("selecting physical device: {error}"))?;
let supported = &adapter.features;
let requested = PhysicalDeviceFeatures {
core: vk::PhysicalDeviceFeatures {
shader_sampled_image_array_dynamic_indexing: supported
.core
.shader_sampled_image_array_dynamic_indexing,
..Default::default()
},
core12: vk::PhysicalDeviceVulkan12Features {
descriptor_indexing: supported.core12.descriptor_indexing,
descriptor_binding_partially_bound: supported
.core12
.descriptor_binding_partially_bound,
descriptor_binding_update_unused_while_pending: supported
.core12
.descriptor_binding_update_unused_while_pending,
descriptor_binding_sampled_image_update_after_bind: supported
.core12
.descriptor_binding_sampled_image_update_after_bind,
descriptor_binding_storage_buffer_update_after_bind: supported
.core12
.descriptor_binding_storage_buffer_update_after_bind,
runtime_descriptor_array: supported.core12.runtime_descriptor_array,
timeline_semaphore: supported.core12.timeline_semaphore,
..Default::default()
},
core13: vk::PhysicalDeviceVulkan13Features {
synchronization2: supported.core13.synchronization2,
dynamic_rendering: supported.core13.dynamic_rendering,
maintenance4: supported.core13.maintenance4,
..Default::default()
},
..Default::default()
};
let device = adapter
.create_logical_device(&instance, &[], requested, None)
.map_err(|error| format!("creating logical device: {error}"))?;
Ok(Self {
device,
_messenger: messenger,
messages,
instance,
})
}
pub fn instance(&self) -> &Instance {
&self.instance
}
pub fn device(&self) -> &Device {
&self.device
}
pub fn physical_device(&self) -> &crate::PhysicalDeviceInfo {
self.device.physical_device()
}
/// Prints all warnings and fails only for validation errors.
pub fn assert_no_validation_errors(&self) {
let messages = self.take_messages();
for message in &messages {
if message
.severity
.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING)
{
eprintln!("Vulkan validation warning: {message}");
}
}
let errors = messages
.into_iter()
.filter(|message| {
message
.severity
.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR)
})
.collect::<Vec<_>>();
assert!(
errors.is_empty(),
"Vulkan validation errors:\n{}",
format_messages(&errors)
);
}
/// Fail for warnings as well as errors, for tests that explicitly require a clean log.
pub fn assert_no_warnings(&self) {
let messages = self.take_messages();
assert!(
messages.is_empty(),
"Vulkan validation messages:\n{}",
format_messages(&messages)
);
}
/// Drain captured messages; useful for tests that intentionally exercise a validation error.
pub fn take_messages(&self) -> Vec<CapturedMessage> {
std::mem::take(
&mut *self
.messages
.lock()
.expect("validation message lock poisoned"),
)
}
}
fn format_messages(messages: &[CapturedMessage]) -> String {
messages
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
}
unsafe extern "system" fn capture_callback(
severity: vk::DebugUtilsMessageSeverityFlagsEXT,
types: vk::DebugUtilsMessageTypeFlagsEXT,
callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
user_data: *mut c_void,
) -> vk::Bool32 {
if callback_data.is_null() || user_data.is_null() {
return vk::FALSE;
}
let data = unsafe { &*callback_data };
let message = if data.p_message.is_null() {
"<empty validation message>".into()
} else {
unsafe { CStr::from_ptr(data.p_message) }
.to_string_lossy()
.into_owned()
};
let messages = unsafe { &*(user_data as *const Mutex<Vec<CapturedMessage>>) };
if let Ok(mut messages) = messages.lock() {
messages.push(CapturedMessage {
severity,
types,
message,
});
}
vk::FALSE
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::{
Allocation, AllocationDesc, AllocationStrategy, DeviceObject, MemoryLocation,
};
#[test]
fn instance_and_device_creation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
assert_ne!(test.instance().inner.raw.handle(), vk::Instance::null());
assert_ne!(test.device().dev().handle(), vk::Device::null());
test.device().wait_idle().expect("device wait idle");
test.assert_no_validation_errors();
}
#[test]
fn buffer_lifecycle_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let info = vk::BufferCreateInfo::default()
.size(256)
.usage(vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let buffer = unsafe { device.dev().create_buffer(&info, None) }.expect("create buffer");
let buffer = DeviceObject::new(device.clone(), buffer);
let allocation = device
.allocate_for_buffer(
*buffer,
AllocationDesc {
name: "renderer_device validation smoke buffer",
location: MemoryLocation::CpuToGpu,
strategy: AllocationStrategy::AllocatorManaged,
},
)
.expect("allocate buffer memory");
let allocation = DeviceObject::new(device.clone(), allocation);
device
.bind_buffer_memory(&allocation, *buffer)
.expect("bind buffer memory");
drop(buffer);
drop(allocation);
test.assert_no_validation_errors();
}
#[test]
fn aliasable_buffer_allocation_binds_at_zero_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let create_info = vk::BufferCreateInfo::default()
.size(256)
.usage(vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let buffer = unsafe { device.dev().create_buffer(&create_info, None) }
.expect("create aliasable buffer");
let buffer = DeviceObject::new(device.clone(), buffer);
let requirements = device
.buffer_memory_requirements(*buffer)
.expect("query buffer requirements");
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate aliasable buffer memory");
let binding = device
.bind_buffer_at_offset(*buffer, &requirements, memory, 0)
.expect("bind aliasable buffer memory");
assert!(matches!(binding, Allocation::Shared(_)));
drop(buffer);
drop(binding);
test.assert_no_validation_errors();
}
#[test]
fn aliasable_image_allocation_binds_at_zero_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let create_info = vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.extent(vk::Extent3D {
width: 16,
height: 16,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(vk::ImageUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED);
let image = unsafe { device.dev().create_image(&create_info, None) }
.expect("create aliasable image");
let image = DeviceObject::new(device.clone(), image);
let requirements = device
.image_memory_requirements(*image)
.expect("query image requirements");
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate aliasable image memory");
let binding = device
.bind_image_at_offset(*image, &requirements, memory, 0)
.expect("bind aliasable image memory");
assert!(matches!(binding, Allocation::Shared(_)));
drop(image);
drop(binding);
test.assert_no_validation_errors();
}
#[test]
fn aliasable_binding_rejects_out_of_bounds_and_unaligned_offsets() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let create_info = vk::BufferCreateInfo::default()
.size(256)
.usage(vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let make_buffer = || {
let raw = unsafe { device.dev().create_buffer(&create_info, None) }
.expect("create offset validation buffer");
DeviceObject::new(device.clone(), raw)
};
let buffer = make_buffer();
let requirements = device
.buffer_memory_requirements(*buffer)
.expect("query offset validation requirements");
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate offset validation memory");
assert!(matches!(
device.bind_buffer_at_offset(*buffer, &requirements, memory, requirements.size),
Err(crate::Error::InvalidAllocationOffset { .. })
));
drop(buffer);
let buffer = make_buffer();
let requirements = device
.buffer_memory_requirements(*buffer)
.expect("query unaligned validation requirements");
if requirements.alignment > 1 {
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate unaligned validation memory");
assert!(matches!(
device.bind_buffer_at_offset(*buffer, &requirements, memory, 1),
Err(crate::Error::InvalidAllocationOffset { .. })
));
}
drop(buffer);
test.assert_no_validation_errors();
}
#[test]
fn validation_capture_works() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let buffer_info = vk::BufferCreateInfo::default()
.size(16)
.usage(vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let buffer = unsafe { device.dev().create_buffer(&buffer_info, None) }
.expect("create validation probe buffer");
let buffer = DeviceObject::new(device.clone(), buffer);
let allocation = device
.allocate_for_buffer(
*buffer,
AllocationDesc {
name: "renderer_device validation probe buffer",
location: MemoryLocation::CpuToGpu,
strategy: AllocationStrategy::AllocatorManaged,
},
)
.expect("allocate validation probe buffer");
let allocation = DeviceObject::new(device.clone(), allocation);
device
.bind_buffer_memory(&allocation, *buffer)
.expect("bind validation probe buffer");
let pool_info = vk::CommandPoolCreateInfo::default()
.queue_family_index(device.queues.graphics().family.index.index());
let pool = unsafe { device.dev().create_command_pool(&pool_info, None) }
.expect("create command pool");
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let command_buffer = unsafe { device.dev().allocate_command_buffers(&allocate_info) }
.expect("allocate command buffer")[0];
unsafe {
device
.dev()
.begin_command_buffer(command_buffer, &vk::CommandBufferBeginInfo::default())
.expect("begin command buffer");
// The size must be a multiple of four. The invalid command is never submitted.
device
.dev()
.cmd_fill_buffer(command_buffer, *buffer, 0, 3, 0);
device
.dev()
.end_command_buffer(command_buffer)
.expect("end command buffer");
device.dev().destroy_command_pool(pool, None);
}
drop(buffer);
drop(allocation);
let messages = test.take_messages();
assert!(
messages.iter().any(|message| {
message
.severity
.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR)
&& message.message.contains("VUID-vkCmdFillBuffer-size")
}),
"expected a vkCmdFillBuffer size validation error, got:\n{}",
format_messages(&messages)
);
}
}