vidya/crates/renderer_rendergraph/tests/graph_validation.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

331 lines
11 KiB
Rust

use std::sync::Arc;
use ash::vk;
use renderer_device::{
device::{MemoryLocation, QueueFlags},
testing::TestVulkan,
};
use renderer_rendergraph::{
RenderGraph,
commands::Command,
commands::CopyBuffer,
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
resources::{
BufferRange, GraphBufferDesc, GraphTextureDesc, ResourceId, ResourceState,
SubresourceScope, SubresourceScopeInner,
},
};
use renderer_resources::buffers::{Buffer, BufferDesc};
struct ScopedNoop(Vec<(ResourceId, SubresourceScope)>);
impl Command for ScopedNoop {
fn side_effects(&self, command_index: CommandIndex, map: &mut SideEffectMap) {
for &(resource, scope) in &self.0 {
map.insert(command_index, resource, scope);
}
}
}
impl RecordedCommand for ScopedNoop {
fn apply(&mut self, _recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
Ok(())
}
}
fn buffer_scope() -> SubresourceScope {
SubresourceScope {
stage: vk::PipelineStageFlags2::COPY,
access: vk::AccessFlags2::TRANSFER_WRITE,
subresource: SubresourceScopeInner::Buffer(BufferRange {
offset: 0,
size: 256,
}),
}
}
fn image_scope() -> SubresourceScope {
SubresourceScope {
stage: vk::PipelineStageFlags2::COMPUTE_SHADER,
access: vk::AccessFlags2::SHADER_STORAGE_WRITE,
subresource: SubresourceScopeInner::Texture {
range: vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
},
layout: vk::ImageLayout::GENERAL,
},
}
}
fn submit_graph(test: &TestVulkan, graph: &mut renderer_rendergraph::CompiledGraph) {
let device = test.device();
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 alias test command pool");
let allocation_info = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let command_buffer = unsafe { device.dev().allocate_command_buffers(&allocation_info) }
.expect("allocate alias test command buffer")[0];
unsafe {
device
.dev()
.begin_command_buffer(command_buffer, &vk::CommandBufferBeginInfo::default())
.expect("begin alias test command buffer");
}
let mut recorder = CommandRecorder::new(device.dev(), command_buffer);
graph
.record_commands(&mut recorder)
.expect("record alias graph");
unsafe {
device
.dev()
.end_command_buffer(command_buffer)
.expect("end alias test command buffer");
let command_buffers = [command_buffer];
let submit = vk::SubmitInfo::default().command_buffers(&command_buffers);
device
.dev()
.queue_submit(device.queues.graphics().raw, &[submit], vk::Fence::null())
.expect("submit alias graph");
}
device.wait_idle().expect("wait for alias graph idle");
unsafe { device.dev().destroy_command_pool(pool, None) };
}
#[test]
fn imported_buffer_copy_records_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
if device.enabled_features().core13.synchronization2 != vk::TRUE {
eprintln!("Skipping graph recording: synchronization2 is unsupported");
return;
}
let make_buffer = || {
Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST,
queue_families: QueueFlags::empty(),
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create graph test buffer"),
)
};
let source = make_buffer();
let destination = make_buffer();
let mut graph = RenderGraph::new();
let source_id = graph
.import_buffer(source, ResourceState::default())
.expect("import source buffer");
let destination_id = graph
.import_buffer(destination, ResourceState::default())
.expect("import destination buffer");
graph.push(CopyBuffer {
src: source_id,
src_range: BufferRange {
offset: 0,
size: 256,
},
dst: destination_id,
dst_range: BufferRange {
offset: 0,
size: 256,
},
});
let mut graph = graph.compile().expect("compile imported buffer graph");
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 graph command pool");
let allocation_info = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let command_buffer = unsafe { device.dev().allocate_command_buffers(&allocation_info) }
.expect("allocate graph command buffer")[0];
unsafe {
device
.dev()
.begin_command_buffer(command_buffer, &vk::CommandBufferBeginInfo::default())
.expect("begin graph command buffer");
}
let mut recorder = CommandRecorder::new(device.dev(), command_buffer);
graph
.record_commands(&mut recorder)
.expect("record imported buffer graph");
unsafe {
device
.dev()
.end_command_buffer(command_buffer)
.expect("end graph command buffer");
device.dev().destroy_command_pool(pool, None);
}
device.wait_idle().expect("wait for graph device idle");
test.assert_no_validation_errors();
}
#[test]
fn resolved_buffers_alias_and_submit_with_memory_barrier() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
if device.enabled_features().core13.synchronization2 != vk::TRUE {
eprintln!("Skipping graph aliasing: synchronization2 is unsupported");
return;
}
let bridge = Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_DST,
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create dependency bridge buffer"),
);
let mut graph = RenderGraph::new();
let first = graph.create_buffer(GraphBufferDesc {
size: 256,
..Default::default()
});
let second = graph.create_buffer(GraphBufferDesc {
size: 256,
..Default::default()
});
let bridge = graph
.import_buffer(bridge, ResourceState::default())
.expect("import dependency bridge");
graph.push(ScopedNoop(vec![
(first.id(), buffer_scope()),
(bridge.id(), buffer_scope()),
]));
graph.push(ScopedNoop(vec![
(bridge.id(), buffer_scope()),
(second.id(), buffer_scope()),
]));
let mut compiled = graph.compile().expect("compile buffer alias graph");
compiled
.resolve(device)
.expect("resolve buffer alias graph");
compiled
.resolve(device)
.expect("resolving an already-resolved graph is idempotent");
let first_placement = compiled.resource_lifetimes()[&first.id()].placement;
let second_placement = compiled.resource_lifetimes()[&second.id()].placement;
assert_eq!(first_placement.pool, second_placement.pool);
assert_eq!(first_placement.offset, second_placement.offset);
assert!(first_placement.aliased && second_placement.aliased);
assert!(compiled.barriers().iter().any(|barrier| {
barrier.before_command == CommandIndex(1) && !barrier.memory_barriers.is_empty()
}));
submit_graph(&test, &mut compiled);
test.assert_no_validation_errors();
drop(compiled);
device
.wait_idle()
.expect("wait before shared allocation teardown");
test.assert_no_validation_errors();
}
#[test]
fn resolved_images_alias_and_submit_with_undefined_transition() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
if device.enabled_features().core13.synchronization2 != vk::TRUE {
eprintln!("Skipping graph aliasing: synchronization2 is unsupported");
return;
}
let bridge = Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_DST,
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create dependency bridge buffer"),
);
let mut graph = RenderGraph::new();
let first = graph.create_texture(GraphTextureDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
extra_usage: vk::ImageUsageFlags::STORAGE,
..Default::default()
});
let second = graph.create_texture(GraphTextureDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
extra_usage: vk::ImageUsageFlags::STORAGE,
..Default::default()
});
let bridge = graph
.import_buffer(bridge, ResourceState::default())
.expect("import dependency bridge");
graph.push(ScopedNoop(vec![
(first.id(), image_scope()),
(bridge.id(), buffer_scope()),
]));
graph.push(ScopedNoop(vec![
(bridge.id(), buffer_scope()),
(second.id(), image_scope()),
]));
let mut compiled = graph.compile().expect("compile image alias graph");
compiled.resolve(device).expect("resolve image alias graph");
compiled
.resolve(device)
.expect("resolving an already-resolved graph is idempotent");
let first_placement = compiled.resource_lifetimes()[&first.id()].placement;
let second_placement = compiled.resource_lifetimes()[&second.id()].placement;
assert_eq!(first_placement.pool, second_placement.pool);
assert_eq!(first_placement.offset, second_placement.offset);
assert!(first_placement.aliased && second_placement.aliased);
assert!(compiled.barriers().iter().any(|barrier| {
barrier.before_command == CommandIndex(1)
&& barrier.resources.iter().any(|resource| {
resource.id == second.id()
&& resource.old_layout == Some(vk::ImageLayout::UNDEFINED)
&& resource.src_access == vk::AccessFlags2::SHADER_STORAGE_WRITE
})
}));
submit_graph(&test, &mut compiled);
test.assert_no_validation_errors();
drop(compiled);
device
.wait_idle()
.expect("wait before shared allocation teardown");
test.assert_no_validation_errors();
}