diff --git a/include/networkit/coarsening/CoarsenedGraphView.hpp b/include/networkit/coarsening/CoarsenedGraphView.hpp index 684569496..316e7edc7 100644 --- a/include/networkit/coarsening/CoarsenedGraphView.hpp +++ b/include/networkit/coarsening/CoarsenedGraphView.hpp @@ -7,6 +7,7 @@ #ifndef NETWORKIT_COARSENING_COARSENED_GRAPH_VIEW_HPP_ #define NETWORKIT_COARSENING_COARSENED_GRAPH_VIEW_HPP_ +#include #include #include @@ -265,6 +266,29 @@ class CoarsenedGraphView { */ static void releaseThreadScratch(); + /** + * Adaptive eager neighbor cache (InducedSubgraphView-style eager-vs-lazy tradeoff). + * + * The view itself is immutable, so a cached aggregation never invalidates within the + * view's lifetime. Aggregation work for a supernode scales with the sum of its members' + * base degrees; recomputing that on every visit (lazy) is wasteful for hot supernodes + * but cheaper than storing it for cold ones. ensureEagerCache() materializes entries + * whose estimated work exceeds eagerNeighborThreshold (in staged edge-endpoints), + * while computeNeighbors() promotes any other supernode to the cache once its observed + * work crosses the same threshold. Supernodes below the threshold stay lazy. + * + * Set to 0 to cache everything (a full materialization, ~ParallelLeiden's memory + * profile) or to none to disable caching entirely (pure lazy, status quo ante). + * Tune via the NETWORKIT_LEIDEN_EAGER_THRESHOLD environment variable. + */ + void ensureEagerCache(); + + /// Aggregate work threshold for eager caching / lazy promotion (staged edge-endpoints). + size_t eagerNeighborThreshold = 2048; + + /// Number of cached supernode neighborhoods (for observability / tests). + count numberOfCachedNeighborhoods() const; + private: const Graph &originalGraph; std::vector nodeMapping; // original_node -> supernode @@ -272,9 +296,23 @@ class CoarsenedGraphView { count numSupernodes; /** - * Compute the aggregated neighbors of a supernode (on demand, no caching) + * Compute the aggregated neighbors of a supernode, consulting / populating the + * adaptive cache as described at ensureEagerCache(). Thread-safe. */ std::vector> computeNeighbors(node supernode) const; + + // Estimated aggregation work for @a supernode: sum of its members' base degrees. + size_t aggregationWork(node supernode) const; + + // Aggregate without consulting the cache; stores the staged pair count in @a workOut. + std::vector> aggregateNeighbors(node supernode, + size_t &workOut) const; + + mutable std::vector>> neighborCache_; + mutable std::vector neighborCached_; + mutable std::vector cacheMutexes_; + + void initCache(); }; static_assert(GraphLike, diff --git a/networkit/cpp/coarsening/CoarsenedGraphView.cpp b/networkit/cpp/coarsening/CoarsenedGraphView.cpp index b92f50063..323cf86d3 100644 --- a/networkit/cpp/coarsening/CoarsenedGraphView.cpp +++ b/networkit/cpp/coarsening/CoarsenedGraphView.cpp @@ -9,16 +9,32 @@ #include #include +#include +#include +#include #include #include +#include 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. +// Phase-scoped scratch for aggregation: 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; + +size_t eagerThresholdFromEnv() { + if (const char *env = std::getenv("NETWORKIT_LEIDEN_EAGER_THRESHOLD")) { + try { + const unsigned long long parsed = std::stoull(env); + return static_cast(parsed); + } catch (...) { + // Keep default if parsing fails. + } + } + return 2048; +} } // namespace void CoarsenedGraphView::releaseThreadScratch() { @@ -43,6 +59,7 @@ CoarsenedGraphView::CoarsenedGraphView(const Graph &originalGraph, const Partiti nodeMapping[u] = supernode; supernodeToOriginal[supernode].push_back(u); }); + initCache(); TRACE("Created CoarsenedGraphView with ", numSupernodes, " supernodes from ", originalGraph.numberOfNodes(), " original nodes"); @@ -65,11 +82,33 @@ CoarsenedGraphView::CoarsenedGraphView(const CoarsenedGraphView &baseView, nodeMapping[originalNode] = supernode; supernodeToOriginal[supernode].push_back(originalNode); }); + initCache(); TRACE("Created layered CoarsenedGraphView with ", numSupernodes, " supernodes from ", baseView.numberOfNodes(), " base supernodes"); } +void CoarsenedGraphView::initCache() { + eagerNeighborThreshold = eagerThresholdFromEnv(); + neighborCache_.resize(numSupernodes); + neighborCached_.assign(numSupernodes, 0); + cacheMutexes_ = std::vector(numSupernodes); +} + +count CoarsenedGraphView::numberOfCachedNeighborhoods() const { + count cached = 0; + for (char flag : neighborCached_) + cached += flag ? 1 : 0; + return cached; +} + +size_t CoarsenedGraphView::aggregationWork(node supernode) const { + size_t work = 0; + for (node originalNode : supernodeToOriginal[supernode]) + work += static_cast(originalGraph.degree(originalNode)); + return work; +} + count CoarsenedGraphView::numberOfEdges() const { count edges = 0; for (node u = 0; u < numberOfNodes(); ++u) { @@ -110,14 +149,15 @@ 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 the +std::vector> +CoarsenedGraphView::aggregateNeighbors(node supernode, size_t &workOut) 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. // Call releaseThreadScratch() at phase boundaries to shrink after every phase. t_staged.clear(); + workOut = 0; // No locks needed here - supernodeToOriginal and nodeMapping are read-only after // construction. Iterate through all original nodes in this supernode. @@ -159,7 +199,63 @@ std::vector> CoarsenedGraphView::computeNeighbors( if (acc > 0.0) // Only include edges with positive weight neighbors.emplace_back(cur, acc); + workOut = t_staged.size(); + return neighbors; +} + +std::vector> +CoarsenedGraphView::computeNeighbors(node supernode) const { + if (!hasNode(supernode)) + return {}; + { + std::lock_guard guard(cacheMutexes_[supernode]); + if (neighborCached_[supernode]) + return neighborCache_[supernode]; + } + // Uncached: aggregate without holding the lock, then publish under it. A loser of the + // race recomputes the identical vector and discards it. + size_t work = 0; + auto neighbors = aggregateNeighbors(supernode, work); + // Lazy promotion: hot supernodes pay aggregation on every visit, so keep the result. + if (work >= eagerNeighborThreshold) { + std::lock_guard guard(cacheMutexes_[supernode]); + if (!neighborCached_[supernode]) { + neighborCache_[supernode] = neighbors; + neighborCached_[supernode] = 1; + } + } return neighbors; } +void CoarsenedGraphView::ensureEagerCache() { + // Eager path: materialize hot supernodes up front, in parallel. The work estimate is + // O(members) per supernode from already-resident mapping storage. Each entry has its + // own mutex, so supernodes proceed independently. + if (eagerNeighborThreshold == none) { + return; // caching disabled + } +#pragma omp parallel for schedule(dynamic) + for (omp_index s = 0; s < static_cast(numSupernodes); ++s) { + const node supernode = static_cast(s); + // Eligibility check under the entry lock; aggregation itself runs unlocked + // (t_staged is thread-local) and publishes under the lock. + bool eligible = false; + { + std::lock_guard guard(cacheMutexes_[supernode]); + eligible = + !neighborCached_[supernode] && aggregationWork(supernode) >= eagerNeighborThreshold; + } + if (!eligible) + continue; + size_t work = 0; + auto neighbors = aggregateNeighbors(supernode, work); + tlx::unused(work); + std::lock_guard guard(cacheMutexes_[supernode]); + if (!neighborCached_[supernode]) { + neighborCache_[supernode] = std::move(neighbors); + neighborCached_[supernode] = 1; + } + } +} + } /* namespace NetworKit */ diff --git a/networkit/cpp/community/ParallelLeidenView.cpp b/networkit/cpp/community/ParallelLeidenView.cpp index 5eecbee45..90addc25a 100644 --- a/networkit/cpp/community/ParallelLeidenView.cpp +++ b/networkit/cpp/community/ParallelLeidenView.cpp @@ -276,8 +276,7 @@ void ParallelLeidenView::run() { result = std::move(p); currentCoarsenedView = newCoarsenedView; - } else { - // First coarsening from original graph + } else { // First coarsening from original graph ParallelPartitionCoarseningView ppcView(*currentGraph, refined); ppcView.run(); auto newCoarsenedView = ppcView.getCoarsenedGraphView(); @@ -294,6 +293,9 @@ void ParallelLeidenView::run() { currentGraph = nullptr; } + // Adaptive eager cache: materialize hot supernode neighborhoods once per level + // so inner move/refine iterations reuse them instead of re-aggregating. + currentCoarsenedView->ensureEagerCache(); calculateVolumes(*currentCoarsenedView); const count currentCommunities = result.numberOfSubsets();