From 6a41cccf8bf69eede96f0203eb6746df3645b8fe Mon Sep 17 00:00:00 2001 From: janis Date: Sun, 25 Oct 2026 00:42:27 +0200 Subject: [PATCH] extract kahn algo and build interference graph --- crates/renderer_rendergraph/src/graph.rs | 278 ++++++++++++++++++++--- crates/renderer_rendergraph/src/lib.rs | 2 +- 2 files changed, 250 insertions(+), 30 deletions(-) diff --git a/crates/renderer_rendergraph/src/graph.rs b/crates/renderer_rendergraph/src/graph.rs index 6017e0a..afaa967 100644 --- a/crates/renderer_rendergraph/src/graph.rs +++ b/crates/renderer_rendergraph/src/graph.rs @@ -53,6 +53,100 @@ pub struct ResourceLifetime { pub memory_offset: u64, } +impl ResourceLifetime { + fn overlaps(&self, other: &Self) -> bool { + self.first_command <= other.last_command && other.first_command <= self.last_command + } +} + +/// The resource-level interference graph used by transient-resource aliasing. +/// An edge means the two resources are live at the same command index. +#[derive(Debug, Clone, Default)] +pub struct ResourceInterference { + adj: BTreeMap>, +} + +impl ResourceInterference { + /// Build a symmetric interference graph from inclusive resource lifetimes. + pub fn from_lifetimes(lifetimes: &HashMap) -> Self { + let mut graph = Self::default(); + + let resources: Vec<_> = lifetimes + .iter() + .map(|(&id, lifetime)| (id, *lifetime)) + .collect(); + + for (index, &(id, lifetime)) in resources.iter().enumerate() { + graph.adj.entry(id).or_default(); + for &(other_id, other) in &resources[index + 1..] { + if lifetime.overlaps(&other) { + graph.adj.entry(id).or_default().insert(other_id); + graph.adj.entry(other_id).or_default().insert(id); + } + } + } + graph + } + + /// Returns whether both resources are live at any common command index. + pub fn interferes(&self, a: ResourceId, b: ResourceId) -> bool { + self.adj + .get(&a) + .is_some_and(|neighbors| neighbors.contains(&b)) + } + + /// Returns the resources that overlap `id`'s inclusive lifetime interval. + pub fn neighbors(&self, id: ResourceId) -> Option<&BTreeSet> { + self.adj.get(&id) + } +} + +/// Deterministic Kahn topological-sort state for command dependencies. +struct Kahn { + edges: Vec>, + indegree: Vec, + ready: BTreeSet, + emitted: usize, + total: usize, +} + +impl Kahn { + fn new(node_count: usize) -> Self { + Self { + edges: vec![BTreeSet::new(); node_count], + indegree: vec![0; node_count], + ready: (0..node_count).collect(), + emitted: 0, + total: node_count, + } + } + + fn add_edge(&mut self, from: usize, to: usize) { + if self.edges[from].insert(to) { + self.indegree[to] += 1; + self.ready.remove(&to); + } + } + + fn pop_ready(&mut self) -> Option { + self.ready.pop_first() + } + + fn complete(&mut self, node: usize) { + self.emitted += 1; + for &dependent in &self.edges[node] { + self.indegree[dependent] -= 1; + if self.indegree[dependent] == 0 { + self.ready.insert(dependent); + } + } + } + + fn is_complete(&self) -> bool { + self.emitted == self.total + } +} + pub struct RenderGraph { next_id: u64, resources: BTreeMap, @@ -116,7 +210,7 @@ impl RenderGraph { let order = compiler.topo_sort()?; compiler.validate_resources()?; let barriers = compiler.build_barriers(&order); - let lifetimes = compiler.bind_lifetimes(); + let (lifetimes, interference) = compiler.bind_lifetimes(); Ok(CompiledGraph { resources: self.resources, @@ -125,6 +219,7 @@ impl RenderGraph { order, barriers, lifetimes, + interference, }) } } @@ -148,9 +243,8 @@ impl<'a> GraphCompiler<'a> { } } - fn dependencies(&self) -> (Vec>, Vec) { - let mut edges = vec![BTreeSet::new(); self.command_count]; - let mut indegree = vec![0usize; self.command_count]; + fn dependencies(&self) -> Kahn { + let mut kahn = Kahn::new(self.command_count); for current in 0..self.command_count { for prior in 0..current { @@ -159,32 +253,22 @@ impl<'a> GraphCompiler<'a> { earlier.resource == later.resource && earlier.scope.conflicts(&later.scope) }) }); - if dependency && edges[prior].insert(current) { - indegree[current] += 1; + if dependency { + kahn.add_edge(prior, current); } } } - (edges, indegree) + kahn } fn topo_sort(&self) -> Result, GraphError> { - let (edges, mut indegree) = self.dependencies(); - let mut ready: BTreeSet<_> = indegree - .iter() - .enumerate() - .filter_map(|(index, °ree)| (degree == 0).then_some(index)) - .collect(); + let mut kahn = self.dependencies(); let mut order = Vec::with_capacity(self.command_count); - while let Some(index) = ready.pop_first() { + while let Some(index) = kahn.pop_ready() { order.push(index.into()); - for &dependent in &edges[index] { - indegree[dependent] -= 1; - if indegree[dependent] == 0 { - ready.insert(dependent); - } - } + kahn.complete(index); } - if order.len() != self.command_count { + if !kahn.is_complete() { return Err(GraphError::DependencyCycle); } Ok(order) @@ -284,7 +368,7 @@ impl<'a> GraphCompiler<'a> { (initial.layout, current_layout) } - fn bind_lifetimes(&self) -> HashMap { + fn bind_lifetimes(&self) -> (HashMap, ResourceInterference) { let mut lifetimes = HashMap::new(); for (command, command_accesses) in self.accesses.iter().enumerate() { for access in command_accesses { @@ -301,9 +385,13 @@ impl<'a> GraphCompiler<'a> { } } + let interference = ResourceInterference::from_lifetimes(&lifetimes); + // Skeleton aliasing policy: resources with non-overlapping lifetimes may - // share a heap offset. Actual size/alignment-aware Vulkan allocation is TODO. - let mut slots: Vec<(u64, CommandIndex, u64)> = Vec::new(); // (offset, last use, capacity) + // share a heap offset. The graph test is equivalent to the previous + // strict `last_use < first_command` check (intervals are inclusive). + // Actual size/alignment-aware Vulkan allocation is TODO. + let mut slots: Vec<(u64, ResourceId, u64)> = Vec::new(); // (offset, last occupant, capacity) let mut next_offset = 0; let mut ids: Vec<_> = lifetimes.keys().copied().collect(); ids.sort_by_key(|id| lifetimes[id].first_command); @@ -317,20 +405,20 @@ impl<'a> GraphCompiler<'a> { if self.resources[&id].is_created() && let Some((offset, last_use, capacity)) = slots .iter_mut() - .find(|(_, last, cap)| *last < lifetime.first_command && *cap >= size) + .find(|(_, last, cap)| !interference.interferes(*last, id) && *cap >= size) { lifetime.memory_offset = *offset; - *last_use = lifetime.last_command; + *last_use = id; let _ = capacity; } else { lifetime.memory_offset = next_offset; if self.resources[&id].is_created() { - slots.push((next_offset, lifetime.last_command, size)); + slots.push((next_offset, id, size)); next_offset = next_offset.saturating_add(size); } } } - lifetimes + (lifetimes, interference) } } @@ -347,6 +435,7 @@ pub struct CompiledGraph { order: Vec, barriers: Vec, lifetimes: HashMap, + interference: ResourceInterference, } enum ResolvedResource { @@ -367,6 +456,10 @@ impl CompiledGraph { &self.lifetimes } + pub fn interference(&self) -> &ResourceInterference { + &self.interference + } + /// Create the graph's transient resources. Compilation remains independent /// of a Vulkan device, while command recording requires this resolution step /// for any created resources used by commands. @@ -701,9 +794,124 @@ mod tests { use super::*; use crate::{ commands::{ClearTexture, CopyBuffer}, - resources::{BufferRange, GraphBufferDesc, GraphTextureDesc}, + resources::{BufferRange, GraphBufferDesc, GraphTextureDesc, ResourceDesc}, }; + #[test] + fn kahn_emits_dag_in_deterministic_order() { + let mut kahn = Kahn::new(4); + kahn.add_edge(0, 2); + kahn.add_edge(1, 2); + kahn.add_edge(2, 3); + + let mut order = Vec::new(); + while let Some(node) = kahn.pop_ready() { + order.push(node); + kahn.complete(node); + } + + assert_eq!(order, [0, 1, 2, 3]); + assert!(kahn.is_complete()); + } + + #[test] + fn kahn_detects_cycles() { + let mut kahn = Kahn::new(3); + kahn.add_edge(0, 1); + kahn.add_edge(1, 2); + kahn.add_edge(2, 1); + + while let Some(node) = kahn.pop_ready() { + kahn.complete(node); + } + + assert_eq!(kahn.emitted, 1); + assert!(!kahn.is_complete()); + } + + #[test] + fn interference_tracks_overlapping_inclusive_lifetimes() { + let lifetimes = HashMap::from([ + ( + ResourceId(0), + ResourceLifetime { + first_command: CommandIndex(0), + last_command: CommandIndex(2), + memory_offset: 0, + }, + ), + ( + ResourceId(1), + ResourceLifetime { + first_command: CommandIndex(2), + last_command: CommandIndex(3), + memory_offset: 0, + }, + ), + ( + ResourceId(2), + ResourceLifetime { + first_command: CommandIndex(4), + last_command: CommandIndex(5), + memory_offset: 0, + }, + ), + ]); + let interference = ResourceInterference::from_lifetimes(&lifetimes); + + assert!(interference.interferes(ResourceId(0), ResourceId(1))); + assert!(interference.interferes(ResourceId(1), ResourceId(0))); + assert!(!interference.interferes(ResourceId(0), ResourceId(2))); + assert!(!interference.interferes(ResourceId(1), ResourceId(2))); + } + + #[test] + fn aliasing_reuses_non_interfering_slot_and_separates_interfering_resources() { + let resources = BTreeMap::from([ + ( + ResourceId(0), + ResourceDesc::CreatedBuffer(GraphBufferDesc { + size: 8, + ..Default::default() + }), + ), + ( + ResourceId(1), + ResourceDesc::CreatedBuffer(GraphBufferDesc { + size: 8, + ..Default::default() + }), + ), + ( + ResourceId(2), + ResourceDesc::CreatedBuffer(GraphBufferDesc { + size: 8, + ..Default::default() + }), + ), + ]); + let accesses = vec![ + vec![resource_access_for(ResourceId(0))], + vec![ + resource_access_for(ResourceId(0)), + resource_access_for(ResourceId(1)), + ], + vec![resource_access_for(ResourceId(0))], + vec![resource_access_for(ResourceId(2))], + ]; + let compiler = GraphCompiler::new(&resources, &accesses, accesses.len()); + let (lifetimes, interference) = compiler.bind_lifetimes(); + + let long_lived_offset = lifetimes[&ResourceId(0)].memory_offset; + let overlapping_offset = lifetimes[&ResourceId(1)].memory_offset; + let later_offset = lifetimes[&ResourceId(2)].memory_offset; + assert!(interference.interferes(ResourceId(0), ResourceId(1))); + assert_ne!(long_lived_offset, overlapping_offset); + assert!(!interference.interferes(ResourceId(0), ResourceId(2))); + assert!(!interference.interferes(ResourceId(1), ResourceId(2))); + assert!(later_offset == long_lived_offset || later_offset == overlapping_offset); + } + #[test] fn copy_buffer_compiles_with_dependency_and_barrier() { let mut graph = RenderGraph::new(); @@ -732,6 +940,7 @@ mod tests { assert_eq!(compiled.command_order(), &[CommandIndex(0)]); assert!(compiled.barriers().is_empty()); assert_eq!(compiled.resource_lifetimes().len(), 2); + assert!(compiled.interference().interferes(src.id(), dst.id())); } #[test] @@ -977,4 +1186,15 @@ mod tests { }, } } + + fn resource_access_for(resource: ResourceId) -> crate::recorder::ResourceAccess { + crate::recorder::ResourceAccess { + resource, + scope: SubresourceScope { + stage: vk::PipelineStageFlags2::COPY, + access: vk::AccessFlags2::TRANSFER_WRITE, + subresource: SubresourceScopeInner::Buffer(BufferRange::full()), + }, + } + } } diff --git a/crates/renderer_rendergraph/src/lib.rs b/crates/renderer_rendergraph/src/lib.rs index f7e4d46..b51deb6 100644 --- a/crates/renderer_rendergraph/src/lib.rs +++ b/crates/renderer_rendergraph/src/lib.rs @@ -6,6 +6,6 @@ pub mod graph; pub mod recorder; pub mod resources; -pub use graph::{CompiledGraph, GraphError, RenderGraph}; +pub use graph::{CompiledGraph, GraphError, RenderGraph, ResourceInterference}; pub use recorder::{CommandIndex, CommandList, CommandRecorder, SideEffectMap}; pub use resources::{Buffer, GraphBufferDesc, GraphTextureDesc, ResourceId, Texture};