Skip to content

Commit 3ec87e4

Browse files
committed
Stageless: prettier cycle reporting (#7463)
Graph theory make head hurty. Closes #7367. Basically, when we topologically sort the dependency graph, we already find its strongly-connected components (a really [neat algorithm][1]). This PR adds an algorithm that can dissect those into simple cycles, giving us more useful error reports. test: `system_build_errors::dependency_cycle` ``` schedule has 1 before/after cycle(s): cycle 1: system set 'A' must run before itself system set 'A' ... which must run before system set 'B' ... which must run before system set 'A' ``` ``` schedule has 1 before/after cycle(s): cycle 1: system 'foo' must run before itself system 'foo' ... which must run before system 'bar' ... which must run before system 'foo' ``` test: `system_build_errors::hierarchy_cycle` ``` schedule has 1 in_set cycle(s): cycle 1: system set 'A' contains itself system set 'A' ... which contains system set 'B' ... which contains system set 'A' ``` [1]: https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
1 parent 5bd1907 commit 3ec87e4

File tree

2 files changed

+204
-35
lines changed

2 files changed

+204
-35
lines changed

crates/bevy_ecs/src/schedule/graph_utils.rs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::fmt::Debug;
22

33
use bevy_utils::{
4-
petgraph::{graphmap::NodeTrait, prelude::*},
4+
petgraph::{algo::TarjanScc, graphmap::NodeTrait, prelude::*},
55
HashMap, HashSet,
66
};
77
use fixedbitset::FixedBitSet;
@@ -274,3 +274,119 @@ where
274274
transitive_closure,
275275
}
276276
}
277+
278+
/// Returns the simple cycles in a strongly-connected component of a directed graph.
279+
///
280+
/// The algorithm implemented comes from
281+
/// ["Finding all the elementary circuits of a directed graph"][1] by D. B. Johnson.
282+
///
283+
/// [1]: https://doi.org/10.1137/0204007
284+
pub fn simple_cycles_in_component<N>(graph: &DiGraphMap<N, ()>, scc: &[N]) -> Vec<Vec<N>>
285+
where
286+
N: NodeTrait + Debug,
287+
{
288+
let mut cycles = vec![];
289+
let mut sccs = vec![scc.to_vec()];
290+
291+
while let Some(mut scc) = sccs.pop() {
292+
// only look at nodes and edges in this strongly-connected component
293+
let mut subgraph = DiGraphMap::new();
294+
for &node in &scc {
295+
subgraph.add_node(node);
296+
}
297+
298+
for &node in &scc {
299+
for successor in graph.neighbors(node) {
300+
if subgraph.contains_node(successor) {
301+
subgraph.add_edge(node, successor, ());
302+
}
303+
}
304+
}
305+
306+
// path of nodes that may form a cycle
307+
let mut path = Vec::with_capacity(subgraph.node_count());
308+
// we mark nodes as "blocked" to avoid finding permutations of the same cycles
309+
let mut blocked = HashSet::with_capacity(subgraph.node_count());
310+
// connects nodes along path segments that can't be part of a cycle (given current root)
311+
// those nodes can be unblocked at the same time
312+
let mut unblock_together: HashMap<N, HashSet<N>> =
313+
HashMap::with_capacity(subgraph.node_count());
314+
// stack for unblocking nodes
315+
let mut unblock_stack = Vec::with_capacity(subgraph.node_count());
316+
// nodes can be involved in multiple cycles
317+
let mut maybe_in_more_cycles: HashSet<N> = HashSet::with_capacity(subgraph.node_count());
318+
// stack for DFS
319+
let mut stack = Vec::with_capacity(subgraph.node_count());
320+
321+
// we're going to look for all cycles that begin and end at this node
322+
let root = scc.pop().unwrap();
323+
// start a path at the root
324+
path.clear();
325+
path.push(root);
326+
// mark this node as blocked
327+
blocked.insert(root);
328+
329+
// DFS
330+
stack.clear();
331+
stack.push((root, subgraph.neighbors(root)));
332+
while !stack.is_empty() {
333+
let (ref node, successors) = stack.last_mut().unwrap();
334+
if let Some(next) = successors.next() {
335+
if next == root {
336+
// found a cycle
337+
maybe_in_more_cycles.extend(path.iter());
338+
cycles.push(path.clone());
339+
} else if !blocked.contains(&next) {
340+
// first time seeing `next` on this path
341+
maybe_in_more_cycles.remove(&next);
342+
path.push(next);
343+
blocked.insert(next);
344+
stack.push((next, subgraph.neighbors(next)));
345+
continue;
346+
} else {
347+
// not first time seeing `next` on this path
348+
}
349+
}
350+
351+
if successors.peekable().peek().is_none() {
352+
if maybe_in_more_cycles.contains(node) {
353+
unblock_stack.push(*node);
354+
// unblock this node's ancestors
355+
while let Some(n) = unblock_stack.pop() {
356+
if blocked.remove(&n) {
357+
let unblock_predecessors =
358+
unblock_together.entry(n).or_insert_with(HashSet::new);
359+
unblock_stack.extend(unblock_predecessors.iter());
360+
unblock_predecessors.clear();
361+
}
362+
}
363+
} else {
364+
// if its descendants can be unblocked later, this node will be too
365+
for successor in subgraph.neighbors(*node) {
366+
unblock_together
367+
.entry(successor)
368+
.or_insert_with(HashSet::new)
369+
.insert(*node);
370+
}
371+
}
372+
373+
// remove node from path and DFS stack
374+
path.pop();
375+
stack.pop();
376+
}
377+
}
378+
379+
// remove node from subgraph
380+
subgraph.remove_node(root);
381+
382+
// divide remainder into smaller SCCs
383+
let mut tarjan_scc = TarjanScc::new();
384+
tarjan_scc.run(&subgraph, |scc| {
385+
if scc.len() > 1 {
386+
sccs.push(scc.to_vec());
387+
}
388+
});
389+
}
390+
391+
cycles
392+
}

crates/bevy_ecs/src/schedule/schedule.rs

Lines changed: 87 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use bevy_utils::default;
77
#[cfg(feature = "trace")]
88
use bevy_utils::tracing::info_span;
99
use bevy_utils::{
10-
petgraph::prelude::*,
10+
petgraph::{algo::TarjanScc, prelude::*},
1111
thiserror::Error,
1212
tracing::{error, warn},
1313
HashMap, HashSet,
@@ -942,7 +942,7 @@ impl ScheduleGraph {
942942

943943
// check hierarchy for cycles
944944
self.hierarchy.topsort = self
945-
.topsort_graph(&self.hierarchy.graph)
945+
.topsort_graph(&self.hierarchy.graph, ReportCycles::Hierarchy)
946946
.map_err(|_| ScheduleBuildError::HierarchyCycle)?;
947947

948948
let hier_results = check_graph(&self.hierarchy.graph, &self.hierarchy.topsort);
@@ -960,7 +960,7 @@ impl ScheduleGraph {
960960

961961
// check dependencies for cycles
962962
self.dependency.topsort = self
963-
.topsort_graph(&self.dependency.graph)
963+
.topsort_graph(&self.dependency.graph, ReportCycles::Dependency)
964964
.map_err(|_| ScheduleBuildError::DependencyCycle)?;
965965

966966
// check for systems or system sets depending on sets they belong to
@@ -1083,7 +1083,7 @@ impl ScheduleGraph {
10831083

10841084
// topsort
10851085
self.dependency_flattened.topsort = self
1086-
.topsort_graph(&dependency_flattened)
1086+
.topsort_graph(&dependency_flattened, ReportCycles::Dependency)
10871087
.map_err(|_| ScheduleBuildError::DependencyCycle)?;
10881088
self.dependency_flattened.graph = dependency_flattened;
10891089

@@ -1318,6 +1318,12 @@ impl ScheduleGraph {
13181318
}
13191319
}
13201320

1321+
/// Used to select the appropriate reporting function.
1322+
enum ReportCycles {
1323+
Hierarchy,
1324+
Dependency,
1325+
}
1326+
13211327
// methods for reporting errors
13221328
impl ScheduleGraph {
13231329
fn get_node_name(&self, id: &NodeId) -> String {
@@ -1345,7 +1351,7 @@ impl ScheduleGraph {
13451351
name
13461352
}
13471353

1348-
fn get_node_kind(id: &NodeId) -> &'static str {
1354+
fn get_node_kind(&self, id: &NodeId) -> &'static str {
13491355
match id {
13501356
NodeId::System(_) => "system",
13511357
NodeId::Set(_) => "system set",
@@ -1366,7 +1372,7 @@ impl ScheduleGraph {
13661372
writeln!(
13671373
message,
13681374
" -- {:?} '{:?}' cannot be child of set '{:?}', longer path exists",
1369-
Self::get_node_kind(child),
1375+
self.get_node_kind(child),
13701376
self.get_node_name(child),
13711377
self.get_node_name(parent),
13721378
)
@@ -1376,48 +1382,95 @@ impl ScheduleGraph {
13761382
error!("{}", message);
13771383
}
13781384

1379-
/// Get topology sorted [`NodeId`], also ensures the graph contains no cycle
1380-
/// returns Err(()) if there are cycles
1381-
fn topsort_graph(&self, graph: &DiGraphMap<NodeId, ()>) -> Result<Vec<NodeId>, ()> {
1382-
// tarjan_scc's run order is reverse topological order
1383-
let mut rev_top_sorted_nodes = Vec::<NodeId>::with_capacity(graph.node_count());
1384-
let mut tarjan_scc = bevy_utils::petgraph::algo::TarjanScc::new();
1385-
let mut sccs_with_cycle = Vec::<Vec<NodeId>>::new();
1385+
/// Tries to topologically sort `graph`.
1386+
///
1387+
/// If the graph is acyclic, returns [`Ok`] with the list of [`NodeId`] in a valid
1388+
/// topological order. If the graph contains cycles, returns [`Err`] with the list of
1389+
/// strongly-connected components that contain cycles (also in a valid topological order).
1390+
///
1391+
/// # Errors
1392+
///
1393+
/// If the graph contain cycles, then an error is returned.
1394+
fn topsort_graph(
1395+
&self,
1396+
graph: &DiGraphMap<NodeId, ()>,
1397+
report: ReportCycles,
1398+
) -> Result<Vec<NodeId>, Vec<Vec<NodeId>>> {
1399+
// Tarjan's SCC algorithm returns elements in *reverse* topological order.
1400+
let mut tarjan_scc = TarjanScc::new();
1401+
let mut top_sorted_nodes = Vec::with_capacity(graph.node_count());
1402+
let mut sccs_with_cycles = Vec::new();
13861403

13871404
tarjan_scc.run(graph, |scc| {
1388-
// by scc's definition, each scc is the cluster of nodes that they can reach each other
1389-
// so scc with size larger than 1, means there is/are cycle(s).
1405+
// A strongly-connected component is a group of nodes who can all reach each other
1406+
// through one or more paths. If an SCC contains more than one node, there must be
1407+
// at least one cycle within them.
13901408
if scc.len() > 1 {
1391-
sccs_with_cycle.push(scc.to_vec());
1409+
sccs_with_cycles.push(scc.to_vec());
13921410
}
1393-
rev_top_sorted_nodes.extend_from_slice(scc);
1411+
top_sorted_nodes.extend_from_slice(scc);
13941412
});
13951413

1396-
if sccs_with_cycle.is_empty() {
1397-
// reverse the reverted to get topological order
1398-
let mut top_sorted_nodes = rev_top_sorted_nodes;
1414+
if sccs_with_cycles.is_empty() {
1415+
// reverse to get topological order
13991416
top_sorted_nodes.reverse();
14001417
Ok(top_sorted_nodes)
14011418
} else {
1402-
self.report_cycles(&sccs_with_cycle);
1403-
Err(())
1419+
let mut cycles = Vec::new();
1420+
for scc in &sccs_with_cycles {
1421+
cycles.append(&mut simple_cycles_in_component(graph, scc));
1422+
}
1423+
1424+
match report {
1425+
ReportCycles::Hierarchy => self.report_hierarchy_cycles(&cycles),
1426+
ReportCycles::Dependency => self.report_dependency_cycles(&cycles),
1427+
}
1428+
1429+
Err(sccs_with_cycles)
14041430
}
14051431
}
14061432

1407-
/// Print detailed cycle messages
1408-
fn report_cycles(&self, sccs_with_cycles: &[Vec<NodeId>]) {
1409-
let mut message = format!(
1410-
"schedule contains at least {} cycle(s)",
1411-
sccs_with_cycles.len()
1412-
);
1433+
/// Logs details of cycles in the hierarchy graph.
1434+
fn report_hierarchy_cycles(&self, cycles: &[Vec<NodeId>]) {
1435+
let mut message = format!("schedule has {} in_set cycle(s):\n", cycles.len());
1436+
for (i, cycle) in cycles.iter().enumerate() {
1437+
let mut names = cycle.iter().map(|id| self.get_node_name(id));
1438+
let first_name = names.next().unwrap();
1439+
writeln!(
1440+
message,
1441+
"cycle {}: set '{first_name}' contains itself",
1442+
i + 1,
1443+
)
1444+
.unwrap();
1445+
writeln!(message, "set '{first_name}'").unwrap();
1446+
for name in names.chain(std::iter::once(first_name)) {
1447+
writeln!(message, " ... which contains set '{name}'").unwrap();
1448+
}
1449+
writeln!(message).unwrap();
1450+
}
1451+
1452+
error!("{}", message);
1453+
}
14131454

1414-
writeln!(message, " -- cycle(s) found within:").unwrap();
1415-
for (i, scc) in sccs_with_cycles.iter().enumerate() {
1416-
let names = scc
1455+
/// Logs details of cycles in the dependency graph.
1456+
fn report_dependency_cycles(&self, cycles: &[Vec<NodeId>]) {
1457+
let mut message = format!("schedule has {} before/after cycle(s):\n", cycles.len());
1458+
for (i, cycle) in cycles.iter().enumerate() {
1459+
let mut names = cycle
14171460
.iter()
1418-
.map(|id| self.get_node_name(id))
1419-
.collect::<Vec<_>>();
1420-
writeln!(message, " ---- {i}: {names:?}").unwrap();
1461+
.map(|id| (self.get_node_kind(id), self.get_node_name(id)));
1462+
let (first_kind, first_name) = names.next().unwrap();
1463+
writeln!(
1464+
message,
1465+
"cycle {}: {first_kind} '{first_name}' must run before itself",
1466+
i + 1,
1467+
)
1468+
.unwrap();
1469+
writeln!(message, "{first_kind} '{first_name}'").unwrap();
1470+
for (kind, name) in names.chain(std::iter::once((first_kind, first_name))) {
1471+
writeln!(message, " ... which must run before {kind} '{name}'").unwrap();
1472+
}
1473+
writeln!(message).unwrap();
14211474
}
14221475

14231476
error!("{}", message);

0 commit comments

Comments
 (0)