From d3a9121641bf04147262081b4de799379c6dc1d5 Mon Sep 17 00:00:00 2001 From: Romain BOULLARD Date: Sun, 30 Aug 2026 14:28:00 +0200 Subject: [PATCH] Topological sorting (will be useful for load/save) --- Bigfoot/Benchmarks/Utils/Algorithm/Graph.cpp | 92 +++++++++++ .../Utils/Include/Utils/Algorithm/Graph.hpp | 154 ++++++++++++++++++ Bigfoot/Tests/Utils/Algorithm/Graph.cpp | 79 +++++++++ 3 files changed, 325 insertions(+) create mode 100644 Bigfoot/Benchmarks/Utils/Algorithm/Graph.cpp create mode 100644 Bigfoot/Sources/Utils/Include/Utils/Algorithm/Graph.hpp create mode 100644 Bigfoot/Tests/Utils/Algorithm/Graph.cpp diff --git a/Bigfoot/Benchmarks/Utils/Algorithm/Graph.cpp b/Bigfoot/Benchmarks/Utils/Algorithm/Graph.cpp new file mode 100644 index 0000000..bcc4492 --- /dev/null +++ b/Bigfoot/Benchmarks/Utils/Algorithm/Graph.cpp @@ -0,0 +1,92 @@ +/********************************************************************* + * \file Graph.cpp + * + * \author Romain BOULLARD + * \date August 2026 + *********************************************************************/ +#include + +#include +#include +#include + +#include + +namespace Bigfoot +{ +using Random = effolkronium::random_static; + +class LayeredTopologicalSortFixture: public benchmark::Fixture +{ + protected: + /* + * Nodes are grouped into layers, and every node in a layer depends on up to DEPENDENCY_MAX_COUNT + * distinct nodes randomly picked from the previous layer. + */ + void SetUp(benchmark::State& state) override + { + Random::seed(0x5EEDU); + + const auto nodeCount = static_cast(state.range(0)); + + m_graph.clear(); + m_nodes.clear(); + m_graph.reserve(nodeCount); + m_nodes.reserve(nodeCount); + + for (std::uint32_t node = 0; node < nodeCount; ++node) + { + m_nodes.insert(node); + eastl::vector& edges = m_graph[node]; + + const std::uint32_t layerStart = (node / NODE_COUNT_PER_LAYER) * NODE_COUNT_PER_LAYER; + if (layerStart == 0) + { + continue; + } + + const std::uint32_t previousLayerStart = layerStart - NODE_COUNT_PER_LAYER; + ankerl::unordered_dense::set picked; + for (std::uint32_t i = 0; i < DEPENDENCY_MAX_COUNT; ++i) + { + const std::uint32_t dependency = Random::get(previousLayerStart, layerStart - 1); + if (picked.insert(dependency).second) + { + edges.push_back(dependency); + } + } + } + } + + static constexpr std::uint32_t NODE_COUNT_PER_LAYER = 64; + static constexpr std::uint32_t DEPENDENCY_MAX_COUNT = 4; + + ankerl::unordered_dense::map> m_graph; + ankerl::unordered_dense::set m_nodes; +}; + +/****************************************************************************************/ + +BENCHMARK_DEFINE_F(LayeredTopologicalSortFixture, LayeredTopologicalSortBench)(benchmark::State& state) +{ + const auto nodeCount = static_cast(state.range(0)); + + for (auto _: state) + { + eastl::vector> waves; + eastl::vector cyclicNodes; + benchmark::DoNotOptimize(LayeredTopologicalSort(m_graph, m_nodes, waves, cyclicNodes)); + benchmark::DoNotOptimize(waves); + benchmark::ClobberMemory(); + } + + state.SetItemsProcessed(state.iterations() * nodeCount); +} + +BENCHMARK_REGISTER_F(LayeredTopologicalSortFixture, LayeredTopologicalSortBench) + ->Name("Bigfoot::LayeredTopologicalSort") + ->Arg(512) + ->Arg(8192) + ->Arg(262'144) + ->Arg(4'194'304); +} // namespace Bigfoot diff --git a/Bigfoot/Sources/Utils/Include/Utils/Algorithm/Graph.hpp b/Bigfoot/Sources/Utils/Include/Utils/Algorithm/Graph.hpp new file mode 100644 index 0000000..40a2947 --- /dev/null +++ b/Bigfoot/Sources/Utils/Include/Utils/Algorithm/Graph.hpp @@ -0,0 +1,154 @@ +/********************************************************************* + * \file Graph.hpp + * + * \author Romain BOULLARD + * \date August 2026 + *********************************************************************/ +#ifndef BIGFOOT_UTILS_ALGORITHM_GRAPH_HPP +#define BIGFOOT_UTILS_ALGORITHM_GRAPH_HPP +#include + +#include +#include + +#include +#include + +namespace Bigfoot +{ +/* + * Kahn's algorithm + * + * \param p_graph Every discovered node's outgoing edges (eg: its dependencies) + * \param p_nodes The set of nodes to sort + * \param p_waves The sorted nodes + * \param p_cyclicNodes Nodes that could not be sorted (ie: part of a cycle) + * \return True if a cycle was found + */ +template +[[nodiscard]] +bool LayeredTopologicalSort(const ankerl::unordered_dense::map>& p_graph, + const ankerl::unordered_dense::set& p_nodes, + eastl::vector>& p_waves, + eastl::vector& p_cyclicNodes) +{ + ASSERT(UtilsAssertHandler, p_waves.empty(), "p_waves must be empty"); + ASSERT(UtilsAssertHandler, p_cyclicNodes.empty(), "p_cyclicNodes must be empty"); + ASSERT(UtilsAssertHandler, + p_nodes.size() <= std::numeric_limits::max(), + "Can't sort more than {} nodes", + std::numeric_limits::max()); + + const auto nodeCount = static_cast(p_nodes.size()); + + ankerl::unordered_dense::map nodeToIndex; + nodeToIndex.reserve(nodeCount); + eastl::vector indexToNode; + indexToNode.reserve(nodeCount); + for (const NODE_TYPE& node: p_nodes) + { + nodeToIndex.emplace(node, static_cast(indexToNode.size())); + indexToNode.push_back(node); + } + + // Step 1: Compute in-degree for each node + eastl::vector inDegree(nodeCount, 0); + + // Reverse adjacency (which nodes depend on each node) stored as CSR: adjacencyOffsets[i]..adjacencyOffsets[i + 1] + // is node i's slice of adjacency. Built in two passes (count listeners per node, then fill) so the whole + // structure is a couple of flat allocations instead of one per node. + eastl::vector adjacencyOffsets(nodeCount + 1, 0); + + // Initialize queue with nodes having in-degree 0 + eastl::vector queue; + queue.reserve(nodeCount); + for (std::uint32_t index = 0; index < nodeCount; ++index) + { + std::uint32_t degree = 0; + for (const NODE_TYPE& edge: p_graph.at(indexToNode[index])) + { + const auto it = nodeToIndex.find(edge); + if (it != nodeToIndex.end()) + { + ++degree; + ++adjacencyOffsets[it->second + 1]; + } + } + + inDegree[index] = degree; + if (degree == 0) + { + queue.push_back(index); + } + } + + for (std::uint32_t index = 0; index < nodeCount; ++index) + { + adjacencyOffsets[index + 1] += adjacencyOffsets[index]; + } + + eastl::vector adjacency(adjacencyOffsets.back()); + eastl::vector cursor(adjacencyOffsets.begin(), adjacencyOffsets.end() - 1); + for (std::uint32_t index = 0; index < nodeCount; ++index) + { + for (const NODE_TYPE& edge: p_graph.at(indexToNode[index])) + { + const auto it = nodeToIndex.find(edge); + if (it != nodeToIndex.end()) + { + adjacency[cursor[it->second]++] = index; + } + } + } + + // Step 2: Process nodes + std::size_t resolvedCount = 0; + eastl::vector nextQueue; + nextQueue.reserve(nodeCount); + while (!queue.empty()) + { + resolvedCount += queue.size(); + + // Reduce in-degree for neighbors + nextQueue.clear(); + for (const std::uint32_t index: queue) + { + for (std::uint32_t i = adjacencyOffsets[index]; i < adjacencyOffsets[index + 1]; ++i) + { + const std::uint32_t next = adjacency[i]; + if (--inDegree[next] == 0) + { + nextQueue.push_back(next); + } + } + } + + eastl::vector waveNodes; + waveNodes.reserve(queue.size()); + for (const std::uint32_t index: queue) + { + waveNodes.push_back(indexToNode[index]); + } + p_waves.push_back(std::move(waveNodes)); + + queue.swap(nextQueue); + } + + // Step 3 & 4: Check for valid topological order + if (resolvedCount != nodeCount) + { + p_cyclicNodes.reserve(nodeCount - resolvedCount); + for (std::uint32_t index = 0; index < nodeCount; ++index) + { + if (inDegree[index] != 0) + { + p_cyclicNodes.push_back(indexToNode[index]); + } + } + } + + return resolvedCount != nodeCount; +} +} // namespace Bigfoot + +#endif diff --git a/Bigfoot/Tests/Utils/Algorithm/Graph.cpp b/Bigfoot/Tests/Utils/Algorithm/Graph.cpp new file mode 100644 index 0000000..c20f248 --- /dev/null +++ b/Bigfoot/Tests/Utils/Algorithm/Graph.cpp @@ -0,0 +1,79 @@ +/********************************************************************* + * \file Graph.cpp + * + * \author Romain BOULLARD + * \date August 2026 + *********************************************************************/ +#include + +#include +#include + +namespace Bigfoot +{ +class LayeredTopologicalSortFixture: public ::testing::Test +{ + protected: + ankerl::unordered_dense::map> m_graph; + ankerl::unordered_dense::set m_nodes; + eastl::vector> m_waves; + eastl::vector m_cyclicNodes; +}; + +/****************************************************************************************/ + +TEST_F(LayeredTopologicalSortFixture, ShouldOrderEachNodeInAnEarlierWaveThanTheNodesThatDependOnIt) +{ + m_graph = {{0, {1, 2}}, {1, {3}}, {2, {3}}, {3, {}}}; + m_nodes = {0, 1, 2, 3}; + + EXPECT_FALSE(LayeredTopologicalSort(m_graph, m_nodes, m_waves, m_cyclicNodes)); + + ASSERT_EQ(m_waves.size(), 3); + EXPECT_THAT(m_waves[0], ::testing::UnorderedElementsAre(3)); + EXPECT_THAT(m_waves[1], ::testing::UnorderedElementsAre(1, 2)); + EXPECT_THAT(m_waves[2], ::testing::UnorderedElementsAre(0)); + EXPECT_TRUE(m_cyclicNodes.empty()); +} + +/****************************************************************************************/ + +TEST_F(LayeredTopologicalSortFixture, ShouldOmitNodesThatAreCaughtInACycleWithoutHanging) +{ + m_graph = {{0, {1}}, {1, {0}}}; + m_nodes = {0, 1}; + + EXPECT_TRUE(LayeredTopologicalSort(m_graph, m_nodes, m_waves, m_cyclicNodes)); + + EXPECT_TRUE(m_waves.empty()); + EXPECT_THAT(m_cyclicNodes, ::testing::UnorderedElementsAre(0, 1)); +} + +/****************************************************************************************/ + +TEST_F(LayeredTopologicalSortFixture, ShouldIgnoreEdgesThatPointOutsideOfTheNodesBeingSorted) +{ + m_graph = {{0, {1}}}; + m_nodes = {0}; + + EXPECT_FALSE(LayeredTopologicalSort(m_graph, m_nodes, m_waves, m_cyclicNodes)); + + ASSERT_EQ(m_waves.size(), 1); + EXPECT_THAT(m_waves[0], ::testing::ElementsAre(0)); + EXPECT_TRUE(m_cyclicNodes.empty()); +} + +/****************************************************************************************/ + +TEST_F(LayeredTopologicalSortFixture, ShouldReportNodesThatOnlyDependOnACycleAsCyclicToo) +{ + // 0 <-> 1 is the cycle; 2 depends on 0 so it can never resolve either, even though it isn't part of the cycle. + m_graph = {{0, {1}}, {1, {0}}, {2, {0}}}; + m_nodes = {0, 1, 2}; + + EXPECT_TRUE(LayeredTopologicalSort(m_graph, m_nodes, m_waves, m_cyclicNodes)); + + EXPECT_TRUE(m_waves.empty()); + EXPECT_THAT(m_cyclicNodes, ::testing::UnorderedElementsAre(0, 1, 2)); +} +} // namespace Bigfoot