From b461a854e6cf2fd5c4d1221a7220f51ba0696ad2 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 14 Sep 2026 12:23:45 -0700 Subject: [PATCH 1/2] perf(leiden): flat sort-combine aggregation in CoarsenedGraphView + ParallelLeidenView Replace per-visit std::unordered_map hashing with InducedSubgraphView-style flat techniques: - CoarsenedGraphView::computeNeighbors stages (supernode, weight) pairs into a thread-local buffer, sorts, and combines runs. Removes hash-table ops and per-call bucket allocations; output is deterministic. - ParallelLeidenView::parallelMove stages (community, weight) pairs once per visit, sort-combines into parallel pointers/cutVals vectors, and uses binary search for the current-community cut. Reusable per-thread buffers cut malloc/free churn; single forNeighborsOf pass halves view re-aggregation in the move phase. - ParallelLeidenView::parallelRefine uses Aux::flat_map for cutWeights (small refinement neighborhoods favor binary search over hashing). --- .../cpp/coarsening/CoarsenedGraphView.cpp | 51 ++++++++----- .../cpp/community/ParallelLeidenView.cpp | 72 ++++++++++++------- 2 files changed, 80 insertions(+), 43 deletions(-) diff --git a/networkit/cpp/coarsening/CoarsenedGraphView.cpp b/networkit/cpp/coarsening/CoarsenedGraphView.cpp index 42f56a9b7..e116d11e8 100644 --- a/networkit/cpp/coarsening/CoarsenedGraphView.cpp +++ b/networkit/cpp/coarsening/CoarsenedGraphView.cpp @@ -8,7 +8,9 @@ #include #include -#include +#include +#include +#include namespace NetworKit { @@ -98,35 +100,52 @@ const std::vector &CoarsenedGraphView::getOriginalNodes(node supernode) co std::vector> CoarsenedGraphView::computeNeighbors(node supernode) const { - std::unordered_map aggregatedWeights; - - // No locks needed here - supernodeToOriginal and nodeMapping are read-only after construction - // Iterate through all original nodes in this supernode + // Flat aggregation (InducedSubgraphView-style): stage (supernode, weight) pairs into a + // reusable thread-local buffer, sort, then combine runs. This avoids per-call + // std::unordered_map hashing and its per-node bucket allocations; the only allocation + // left is the returned vector. Sort order also makes the output deterministic. + thread_local std::vector> staged; + staged.clear(); + + // No locks needed here - supernodeToOriginal and nodeMapping are read-only after + // construction. Iterate through all original nodes in this supernode. for (node originalNode : supernodeToOriginal[supernode]) { // Iterate through neighbors of each original node originalGraph.forNeighborsOf(originalNode, [&](node originalNeighbor, edgeweight weight) { node neighborSupernode = nodeMapping[originalNeighbor]; /* - * An undirected edge sits in the adjacency of both endpoints, so an edge inside this - * supernode would be aggregated twice. Count it once, from the higher endpoint, - * mirroring ParallelPartitionCoarsening's aggregation. + * An undirected edge sits in the adjacency of both endpoints, so an edge inside + * this supernode would be aggregated twice. Count it once, from the higher + * endpoint, mirroring ParallelPartitionCoarsening's aggregation. */ if (neighborSupernode == supernode && originalNode < originalNeighbor) return; - // Aggregate weights to the same supernode - aggregatedWeights[neighborSupernode] += weight; + staged.emplace_back(neighborSupernode, weight); }); } - // Convert to vector format - std::vector> neighbors; - neighbors.reserve(aggregatedWeights.size()); + if (staged.empty()) + return {}; - for (const auto &entry : aggregatedWeights) { - if (entry.second > 0.0) { // Only include edges with positive weight - neighbors.emplace_back(entry.first, entry.second); + std::sort(staged.begin(), staged.end(), + [](const auto &a, const auto &b) { return a.first < b.first; }); + + // Combine runs of equal supernodes into the output vector. + std::vector> neighbors; + neighbors.reserve(staged.size()); + node cur = staged[0].first; + edgeweight acc = 0.0; + for (const auto &entry : staged) { + if (entry.first != cur) { + if (acc > 0.0) + neighbors.emplace_back(cur, acc); + cur = entry.first; + acc = 0.0; } + acc += entry.second; } + if (acc > 0.0) // Only include edges with positive weight + neighbors.emplace_back(cur, acc); return neighbors; } diff --git a/networkit/cpp/community/ParallelLeidenView.cpp b/networkit/cpp/community/ParallelLeidenView.cpp index 1c557398f..67d4e0808 100644 --- a/networkit/cpp/community/ParallelLeidenView.cpp +++ b/networkit/cpp/community/ParallelLeidenView.cpp @@ -6,12 +6,15 @@ #include #include +#include #include #ifndef _WIN32 #include #endif +#include #include -#include +#include +#include namespace NetworKit { @@ -433,9 +436,13 @@ ParallelLeidenView::MoveStats ParallelLeidenView::parallelMove(const GraphType & currentNodes.reserve(tshare); std::vector newNodes; newNodes.reserve(WORKING_SIZE); - // Sparse cutWeight[Community] storage avoids one dense community vector per thread. - std::unordered_map cutWeights; + // Flat cut-weight storage (InducedSubgraphView-style): stage (community, weight) + // pairs once per visit, sort, and combine runs into parallel pointers/cutVals + // vectors. Binary search replaces hash lookups; per-thread staging vectors are + // reused across visits to avoid malloc/free churn. + std::vector> staged; std::vector pointers; + std::vector cutVals; auto moveLimitReached = [&](node candidate) { return movesPerNode[candidate].load() >= static_cast(maxMovesPerNode); @@ -538,51 +545,61 @@ ParallelLeidenView::MoveStats ParallelLeidenView::parallelMove(const GraphType & index bestCommunity = none; double degree = 0; count nodeMass = nodeSize(graph, u); - for (auto z : pointers) { - // Reset the clearlist : Set all cutweights to 0 and clear the pointer vector - cutWeights.erase(z); - } + staged.clear(); pointers.clear(); + cutVals.clear(); graph.forNeighborsOf(u, [&](node neighbor, edgeweight ew) { - index neighborCommunity = result[neighbor]; - if (cutWeights.find(neighborCommunity) == cutWeights.end()) { - pointers.push_back(neighborCommunity); - } if (u == neighbor) { - degree += ew; + degree += 2 * ew; } else { - cutWeights[neighborCommunity] += ew; + staged.emplace_back(result[neighbor], ew); + degree += ew; // keep track of the nodes degree. Loops count twice } - degree += ew; // keep track of the nodes degree. Loops count twice }); - if (pointers.empty()) { + if (staged.empty()) { finishProcessingNode(u); continue; } + // Sort + combine runs: one entry per adjacent community. + std::sort(staged.begin(), staged.end(), + [](const auto &a, const auto &b) { return a.first < b.first; }); + for (const auto &entry : staged) { + if (!pointers.empty() && pointers.back() == entry.first) { + cutVals.back() += entry.second; + } else { + pointers.push_back(entry.first); + cutVals.push_back(entry.second); + } + } + staged.clear(); + + auto cutOf = [&](index community) { + auto it = std::lower_bound(pointers.begin(), pointers.end(), community); + if (it == pointers.end() || *it != community) + return 0.0; + return cutVals[static_cast(it - pointers.begin())]; + }; + double singletonScore = scoreCommunity(0.0, degree, 0.0, nodeMass, 0); // Determine move score for all neighbor communities - for (auto community : pointers) { + for (size_t k = 0; k < pointers.size(); ++k) { + index community = pointers[k]; // "Moving" a node to its current community is pointless if (community != currentCommunity) { - double delta; - const auto cutWeightIt = cutWeights.find(community); - const double cutWeight = - cutWeightIt == cutWeights.end() ? 0.0 : cutWeightIt->second; - delta = scoreCommunity(cutWeight, degree, communityVolumes[community], - nodeMass, communitySizes[community]); + double delta = + scoreCommunity(cutVals[k], degree, communityVolumes[community], + nodeMass, communitySizes[community]); if (delta > maxDelta) { maxDelta = delta; bestCommunity = community; } } } - const auto currentCutWeightIt = cutWeights.find(currentCommunity); - const double currentCutWeight = - currentCutWeightIt == cutWeights.end() ? 0.0 : currentCutWeightIt->second; + const double currentCutWeight = cutOf(currentCommunity); double modThreshold = scoreCurrentCommunityThreshold( currentCutWeight, degree, communityVolumes[currentCommunity], nodeMass, communitySizes[currentCommunity]); @@ -760,8 +777,9 @@ Partition ParallelLeidenView::parallelRefine(const GraphType &graph, bool &refin #pragma omp parallel { std::vector neighComms; - // Keeps track of relevant Neighbor communities. Needed to reset the clearlist fast - std::unordered_map cutWeights; // cut from Node to Communities + // Flat cut map (InducedSubgraphView-style): sorted-vector storage instead of a hash + // table; refinement neighborhoods are small so binary search beats hashing. + Aux::flat_map cutWeights; // cut from Node to Communities auto &mt = Aux::Random::getURNG(); #pragma omp for for (omp_index u = 0; u < static_cast(graph.upperNodeIdBound()); u++) { From bc70269d0a71ae1cd43990277b672e935a65843d Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 14 Sep 2026 12:50:08 -0700 Subject: [PATCH 2/2] perf(leiden): shrink view scratch after every phase Scope the CoarsenedGraphView aggregation buffer to the phase: reusable thread-local scratch within a move/refine phase, released (shrink_to_fit) via CoarsenedGraphView::releaseThreadScratch() at each phase boundary in ParallelLeidenView::parallelMove/parallelRefine. A giant-supernode high-water mark from one level can no longer leak into the next. --- .../coarsening/CoarsenedGraphView.hpp | 7 ++++ .../cpp/coarsening/CoarsenedGraphView.cpp | 36 ++++++++++++------- .../cpp/community/ParallelLeidenView.cpp | 5 +++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/include/networkit/coarsening/CoarsenedGraphView.hpp b/include/networkit/coarsening/CoarsenedGraphView.hpp index a14c9ea95..684569496 100644 --- a/include/networkit/coarsening/CoarsenedGraphView.hpp +++ b/include/networkit/coarsening/CoarsenedGraphView.hpp @@ -258,6 +258,13 @@ class CoarsenedGraphView { */ const std::vector &getOriginalNodes(node supernode) const; + /** + * Release the calling thread's computeNeighbors() scratch buffer (capacity included). + * Call at the end of a move/refine phase so a giant-supernode high-water mark from one + * level cannot leak into the next. + */ + static void releaseThreadScratch(); + private: const Graph &originalGraph; std::vector nodeMapping; // original_node -> supernode diff --git a/networkit/cpp/coarsening/CoarsenedGraphView.cpp b/networkit/cpp/coarsening/CoarsenedGraphView.cpp index e116d11e8..b92f50063 100644 --- a/networkit/cpp/coarsening/CoarsenedGraphView.cpp +++ b/networkit/cpp/coarsening/CoarsenedGraphView.cpp @@ -14,6 +14,18 @@ namespace NetworKit { +namespace { +// Phase-scoped scratch for computeNeighbors(): reused across calls within a move/refine +// phase, released via releaseThreadScratch() at each phase boundary so a high-water mark +// from one coarsening level cannot leak into the next. +thread_local std::vector> t_staged; +} // namespace + +void CoarsenedGraphView::releaseThreadScratch() { + t_staged.clear(); + t_staged.shrink_to_fit(); +} + CoarsenedGraphView::CoarsenedGraphView(const Graph &originalGraph, const Partition &partition) : originalGraph(originalGraph) { @@ -98,14 +110,14 @@ const std::vector &CoarsenedGraphView::getOriginalNodes(node supernode) co return supernodeToOriginal[supernode]; } -std::vector> -CoarsenedGraphView::computeNeighbors(node supernode) const { - // Flat aggregation (InducedSubgraphView-style): stage (supernode, weight) pairs into a - // reusable thread-local buffer, sort, then combine runs. This avoids per-call +std::vector> CoarsenedGraphView::computeNeighbors( + node supernode) const { // Flat aggregation (InducedSubgraphView-style): stage (supernode, + // weight) pairs into the + // phase-scoped thread-local buffer, sort, then combine runs. This avoids per-call // std::unordered_map hashing and its per-node bucket allocations; the only allocation // left is the returned vector. Sort order also makes the output deterministic. - thread_local std::vector> staged; - staged.clear(); + // Call releaseThreadScratch() at phase boundaries to shrink after every phase. + t_staged.clear(); // No locks needed here - supernodeToOriginal and nodeMapping are read-only after // construction. Iterate through all original nodes in this supernode. @@ -120,22 +132,22 @@ CoarsenedGraphView::computeNeighbors(node supernode) const { */ if (neighborSupernode == supernode && originalNode < originalNeighbor) return; - staged.emplace_back(neighborSupernode, weight); + t_staged.emplace_back(neighborSupernode, weight); }); } - if (staged.empty()) + if (t_staged.empty()) return {}; - std::sort(staged.begin(), staged.end(), + std::sort(t_staged.begin(), t_staged.end(), [](const auto &a, const auto &b) { return a.first < b.first; }); // Combine runs of equal supernodes into the output vector. std::vector> neighbors; - neighbors.reserve(staged.size()); - node cur = staged[0].first; + neighbors.reserve(t_staged.size()); + node cur = t_staged[0].first; edgeweight acc = 0.0; - for (const auto &entry : staged) { + for (const auto &entry : t_staged) { if (entry.first != cur) { if (acc > 0.0) neighbors.emplace_back(cur, acc); diff --git a/networkit/cpp/community/ParallelLeidenView.cpp b/networkit/cpp/community/ParallelLeidenView.cpp index 67d4e0808..5eecbee45 100644 --- a/networkit/cpp/community/ParallelLeidenView.cpp +++ b/networkit/cpp/community/ParallelLeidenView.cpp @@ -710,6 +710,9 @@ ParallelLeidenView::MoveStats ParallelLeidenView::parallelMove(const GraphType & TRACE("Thread ", omp_get_thread_num(), " worked ", totalNodesPerThread[omp_get_thread_num()], "Nodes and moved ", moved[omp_get_thread_num()]); + // Shrink after every phase: drop this thread's view-aggregation scratch so a + // high-water mark from this level cannot leak into the next. + CoarsenedGraphView::releaseThreadScratch(); } result.setUpperBound(upperBound); assert(queue.empty()); @@ -976,6 +979,8 @@ Partition ParallelLeidenView::parallelRefine(const GraphType &graph, bool &refin locks[bestC].unlock(); locks[u].unlock(); } + // Shrink after every phase: drop this thread's view-aggregation scratch. + CoarsenedGraphView::releaseThreadScratch(); } refineMadeChanges = anyRefinementChanges.load();