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
514 lines
19 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|