Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion include/networkit/coarsening/CoarsenedGraphView.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#ifndef NETWORKIT_COARSENING_COARSENED_GRAPH_VIEW_HPP_
#define NETWORKIT_COARSENING_COARSENED_GRAPH_VIEW_HPP_

#include <mutex>
#include <utility>
#include <vector>

Expand Down Expand Up @@ -265,16 +266,53 @@ 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<node> nodeMapping; // original_node -> supernode
std::vector<std::vector<node>> supernodeToOriginal; // supernode -> [original_nodes]
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<std::pair<node, edgeweight>> 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<std::pair<node, edgeweight>> aggregateNeighbors(node supernode,
size_t &workOut) const;

mutable std::vector<std::vector<std::pair<node, edgeweight>>> neighborCache_;
mutable std::vector<char> neighborCached_;
mutable std::vector<std::mutex> cacheMutexes_;

void initCache();
};

static_assert(GraphLike<CoarsenedGraphView>,
Expand Down
108 changes: 102 additions & 6 deletions networkit/cpp/coarsening/CoarsenedGraphView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,32 @@
#include <networkit/coarsening/CoarsenedGraphView.hpp>

#include <algorithm>
#include <cstdlib>
#include <omp.h>
#include <string>
#include <utility>
#include <vector>
#include <tlx/unused.hpp>

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<std::pair<node, edgeweight>> 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<size_t>(parsed);
} catch (...) {
// Keep default if parsing fails.
}
}
return 2048;
}
} // namespace

void CoarsenedGraphView::releaseThreadScratch() {
Expand All @@ -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");
Expand All @@ -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<std::mutex>(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<size_t>(originalGraph.degree(originalNode));
return work;
}

count CoarsenedGraphView::numberOfEdges() const {
count edges = 0;
for (node u = 0; u < numberOfNodes(); ++u) {
Expand Down Expand Up @@ -110,14 +149,15 @@ const std::vector<node> &CoarsenedGraphView::getOriginalNodes(node supernode) co
return supernodeToOriginal[supernode];
}

std::vector<std::pair<node, edgeweight>> CoarsenedGraphView::computeNeighbors(
node supernode) const { // Flat aggregation (InducedSubgraphView-style): stage (supernode,
// weight) pairs into the
std::vector<std::pair<node, edgeweight>>
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.
Expand Down Expand Up @@ -159,7 +199,63 @@ std::vector<std::pair<node, edgeweight>> 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<std::pair<node, edgeweight>>
CoarsenedGraphView::computeNeighbors(node supernode) const {
if (!hasNode(supernode))
return {};
{
std::lock_guard<std::mutex> 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<std::mutex> 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<omp_index>(numSupernodes); ++s) {
const node supernode = static_cast<node>(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<std::mutex> 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<std::mutex> guard(cacheMutexes_[supernode]);
if (!neighborCached_[supernode]) {
neighborCache_[supernode] = std::move(neighbors);
neighborCached_[supernode] = 1;
}
}
}

} /* namespace NetworKit */
6 changes: 4 additions & 2 deletions networkit/cpp/community/ParallelLeidenView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down