extract kahn algo and build interference graph

This commit is contained in:
janis 2026-10-25 00:42:27 +02:00
parent 195cf3e08a
commit 6a41cccf8b
Signed by: janis
SSH key fingerprint: SHA256:bB1qbbqmDXZNT0KKD5c2Dfjg53JGhj7B3CFcLIzSqq8
2 changed files with 250 additions and 30 deletions

View file

@ -53,6 +53,100 @@ pub struct ResourceLifetime {
pub memory_offset: u64, 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<ResourceId, BTreeSet<ResourceId>>,
}
impl ResourceInterference {
/// Build a symmetric interference graph from inclusive resource lifetimes.
pub fn from_lifetimes(lifetimes: &HashMap<ResourceId, ResourceLifetime>) -> 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<ResourceId>> {
self.adj.get(&id)
}
}
/// Deterministic Kahn topological-sort state for command dependencies.
struct Kahn {
edges: Vec<BTreeSet<usize>>,
indegree: Vec<usize>,
ready: BTreeSet<usize>,
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<usize> {
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 { pub struct RenderGraph {
next_id: u64, next_id: u64,
resources: BTreeMap<ResourceId, ResourceDesc>, resources: BTreeMap<ResourceId, ResourceDesc>,
@ -116,7 +210,7 @@ impl RenderGraph {
let order = compiler.topo_sort()?; let order = compiler.topo_sort()?;
compiler.validate_resources()?; compiler.validate_resources()?;
let barriers = compiler.build_barriers(&order); let barriers = compiler.build_barriers(&order);
let lifetimes = compiler.bind_lifetimes(); let (lifetimes, interference) = compiler.bind_lifetimes();
Ok(CompiledGraph { Ok(CompiledGraph {
resources: self.resources, resources: self.resources,
@ -125,6 +219,7 @@ impl RenderGraph {
order, order,
barriers, barriers,
lifetimes, lifetimes,
interference,
}) })
} }
} }
@ -148,9 +243,8 @@ impl<'a> GraphCompiler<'a> {
} }
} }
fn dependencies(&self) -> (Vec<BTreeSet<usize>>, Vec<usize>) { fn dependencies(&self) -> Kahn {
let mut edges = vec![BTreeSet::new(); self.command_count]; let mut kahn = Kahn::new(self.command_count);
let mut indegree = vec![0usize; self.command_count];
for current in 0..self.command_count { for current in 0..self.command_count {
for prior in 0..current { for prior in 0..current {
@ -159,32 +253,22 @@ impl<'a> GraphCompiler<'a> {
earlier.resource == later.resource && earlier.scope.conflicts(&later.scope) earlier.resource == later.resource && earlier.scope.conflicts(&later.scope)
}) })
}); });
if dependency && edges[prior].insert(current) { if dependency {
indegree[current] += 1; kahn.add_edge(prior, current);
} }
} }
} }
(edges, indegree) kahn
} }
fn topo_sort(&self) -> Result<Vec<CommandIndex>, GraphError> { fn topo_sort(&self) -> Result<Vec<CommandIndex>, GraphError> {
let (edges, mut indegree) = self.dependencies(); let mut kahn = self.dependencies();
let mut ready: BTreeSet<_> = indegree
.iter()
.enumerate()
.filter_map(|(index, &degree)| (degree == 0).then_some(index))
.collect();
let mut order = Vec::with_capacity(self.command_count); 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()); order.push(index.into());
for &dependent in &edges[index] { kahn.complete(index);
indegree[dependent] -= 1;
if indegree[dependent] == 0 {
ready.insert(dependent);
}
}
} }
if order.len() != self.command_count { if !kahn.is_complete() {
return Err(GraphError::DependencyCycle); return Err(GraphError::DependencyCycle);
} }
Ok(order) Ok(order)
@ -284,7 +368,7 @@ impl<'a> GraphCompiler<'a> {
(initial.layout, current_layout) (initial.layout, current_layout)
} }
fn bind_lifetimes(&self) -> HashMap<ResourceId, ResourceLifetime> { fn bind_lifetimes(&self) -> (HashMap<ResourceId, ResourceLifetime>, ResourceInterference) {
let mut lifetimes = HashMap::new(); let mut lifetimes = HashMap::new();
for (command, command_accesses) in self.accesses.iter().enumerate() { for (command, command_accesses) in self.accesses.iter().enumerate() {
for access in command_accesses { 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 // Skeleton aliasing policy: resources with non-overlapping lifetimes may
// share a heap offset. Actual size/alignment-aware Vulkan allocation is TODO. // share a heap offset. The graph test is equivalent to the previous
let mut slots: Vec<(u64, CommandIndex, u64)> = Vec::new(); // (offset, last use, capacity) // 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 next_offset = 0;
let mut ids: Vec<_> = lifetimes.keys().copied().collect(); let mut ids: Vec<_> = lifetimes.keys().copied().collect();
ids.sort_by_key(|id| lifetimes[id].first_command); ids.sort_by_key(|id| lifetimes[id].first_command);
@ -317,20 +405,20 @@ impl<'a> GraphCompiler<'a> {
if self.resources[&id].is_created() if self.resources[&id].is_created()
&& let Some((offset, last_use, capacity)) = slots && let Some((offset, last_use, capacity)) = slots
.iter_mut() .iter_mut()
.find(|(_, last, cap)| *last < lifetime.first_command && *cap >= size) .find(|(_, last, cap)| !interference.interferes(*last, id) && *cap >= size)
{ {
lifetime.memory_offset = *offset; lifetime.memory_offset = *offset;
*last_use = lifetime.last_command; *last_use = id;
let _ = capacity; let _ = capacity;
} else { } else {
lifetime.memory_offset = next_offset; lifetime.memory_offset = next_offset;
if self.resources[&id].is_created() { 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); next_offset = next_offset.saturating_add(size);
} }
} }
} }
lifetimes (lifetimes, interference)
} }
} }
@ -347,6 +435,7 @@ pub struct CompiledGraph {
order: Vec<CommandIndex>, order: Vec<CommandIndex>,
barriers: Vec<PipelineBarrier>, barriers: Vec<PipelineBarrier>,
lifetimes: HashMap<ResourceId, ResourceLifetime>, lifetimes: HashMap<ResourceId, ResourceLifetime>,
interference: ResourceInterference,
} }
enum ResolvedResource { enum ResolvedResource {
@ -367,6 +456,10 @@ impl CompiledGraph {
&self.lifetimes &self.lifetimes
} }
pub fn interference(&self) -> &ResourceInterference {
&self.interference
}
/// Create the graph's transient resources. Compilation remains independent /// Create the graph's transient resources. Compilation remains independent
/// of a Vulkan device, while command recording requires this resolution step /// of a Vulkan device, while command recording requires this resolution step
/// for any created resources used by commands. /// for any created resources used by commands.
@ -701,9 +794,124 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
commands::{ClearTexture, CopyBuffer}, 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] #[test]
fn copy_buffer_compiles_with_dependency_and_barrier() { fn copy_buffer_compiles_with_dependency_and_barrier() {
let mut graph = RenderGraph::new(); let mut graph = RenderGraph::new();
@ -732,6 +940,7 @@ mod tests {
assert_eq!(compiled.command_order(), &[CommandIndex(0)]); assert_eq!(compiled.command_order(), &[CommandIndex(0)]);
assert!(compiled.barriers().is_empty()); assert!(compiled.barriers().is_empty());
assert_eq!(compiled.resource_lifetimes().len(), 2); assert_eq!(compiled.resource_lifetimes().len(), 2);
assert!(compiled.interference().interferes(src.id(), dst.id()));
} }
#[test] #[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()),
},
}
}
} }

View file

@ -6,6 +6,6 @@ pub mod graph;
pub mod recorder; pub mod recorder;
pub mod resources; 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 recorder::{CommandIndex, CommandList, CommandRecorder, SideEffectMap};
pub use resources::{Buffer, GraphBufferDesc, GraphTextureDesc, ResourceId, Texture}; pub use resources::{Buffer, GraphBufferDesc, GraphTextureDesc, ResourceId, Texture};