Skip to content

Add true edge-granular redistribution (balance-edges-strict) - #99

Open
niklas-uhl wants to merge 26 commits into
mainfrom
feature/true-edge-balance-redistribution
Open

Add true edge-granular redistribution (balance-edges-strict)#99
niklas-uhl wants to merge 26 commits into
mainfrom
feature/true-edge-balance-redistribution

Conversation

@niklas-uhl

Copy link
Copy Markdown
Collaborator

Summary

  • Fixes a starvation bug in the existing vertex-atomic balance-edges breakpoint computation (PEs could end up with zero edges for oversized/hub vertices).
  • Adds a new redistribution mode, balance-edges-strict (BALANCE_EDGES_TRUE), which can split a single vertex's own edges across multiple PEs for exact edge-count balance, via a metadata-phase + single-shuffle primitive (RedistributeEdgesTrueBalance) with escalation-based dedup for split-vertex duplicate edges.
  • Wires both --redistribution/--distribution options into RMAT, Kronecker, GNM (directed/undirected), RGG2D/3D, and file-graph reading (METIS/ParHIP/plain-edgelist), without adding overhead to any generator's default (vertex-balanced) path.
  • Adds a collectively-consistent compatibility guard (CheckSplitVertexCompatibility) that throws a clear ConfigurationError instead of corrupting data or crashing when split vertices are combined with something that assumes single-PE vertex ownership: adjacency-grouped output formats (METIS/HMETIS/DOT/ParHIP/XtraPuLP/freight-netl/netd-are), CSR representation, --validate-simple-graph, statistics, and hashing/uniform-random edge weight generators. Also guards vertex/edge-weighted input against silent desync under any edge-balanced redistribution of file graphs.
  • Fixes a pre-existing bug mapping --edgeweights-generator=euclidean_distance to the wrong generator type.
  • Documents the new option and restrictions in the README.

Test plan

  • New/extended unit tests: redistribute_edges_test, rmat_distribution_test, gnm_distribution_test, rgg_distribution_test, generic_file_generator_test, kronecker_distribution_test, plain_edgelist_distribution_test, compatibility_guard_test.
  • Full ctest -j4 suite: 61/63 passing; only the pre-existing, known-flaky test_parhip_reader_writer fails intermittently under parallel load (passes in isolation), confirmed unrelated to this branch.
  • Manually verified a real segfault (CSR representation + split vertices) and confirmed the fix converts it into a clean ConfigurationError.

@DanielSeemaier

Copy link
Copy Markdown
Member

Apart from the failing unit test, Codex's review:

[P1] Strict balancing is calculated before global deduplication.
m and the edge buckets include cross-PE duplicates, but duplicates are removed afterward. This leaves holes in the computed buckets and breaks the promised ±1 balance. In the PR’s duplicate-hub scenario, four PEs differed by 1,000 edges. Compute global unique degrees first or rebalance after deduplication. postprocessor.cpp#L468-L487

This one might be worth investigating

[P1] fully_owned_vertex_range cannot be used as Graph::vertex_range.
The range deliberately omits isolated and split vertices, but every caller stores it as the graph’s complete vertex range. Consequently, vertex counts, headers, statistics, and generated vertex weights are wrong. A strict-balanced graph with 16 vertices and one edge was written as p 1 1; an RMAT graph with 128 vertices was written as p 122 .... The representation needs to preserve ownership of all n vertices separately from partial-edge metadata. postprocessor.cpp#L648-L673

Probably depends on how you plan to use fully_owned_vertex_range; since vertex_range is still maintained, it could be a non-issue?

[P1] --skip-postprocessing now empties GNM graphs.
Directed and undirected GNM generation now writes exclusively to local_edges_; those edges reach graph_.edges only from FinalizeEdgeList, which is skipped by this option. A 32-edge GNM run therefore produced p 16 0. Keep the normal vertex-balanced path in graph_.edges, or separate mandatory finalization from optional postprocessing. gnm_directed.cpp#L81-L100

Probably can ignore this one, since --skip-postprocessing is not really a feature but only for debugging purposes anyways.

@niklas-uhl

Copy link
Copy Markdown
Collaborator Author

Addressed the Codex findings:

Strict balancing computed before dedup — confirmed real. RedistributeEdgesTrueBalance computed its edge-rank bucket boundaries from pre-deduplication degree counts, so cross-PE duplicates left holes once removed, breaking the +/-1 guarantee. Fixed with a second, much cheaper rank-based rebalance pass that runs after dedup (only moves the actual drift, bounded by duplicate volume — never re-concentrates a hub's edges on one PE). Extended DedupsHubEdgesDuplicatedAcrossSourcePEs to also assert exact balance.

fully_owned_vertex_range misused as Graph::vertex_range — also confirmed, and broader than it looked: every caller does assign it directly to vertex_range (nothing "still maintains" the full range separately), and the range excluded not just split vertices but any isolated (degree-0) vertex, even with no split at all — that's the root cause of the "16 vertices, 1 edge → p 1 1" case. Fixed by replacing the pairwise Sendrecv boundary check with one Allgather and computing a complete, gap-free partition of [0, n) as vertex_range. Split this into two fields once it became clear the complete range doesn't imply full edge ownership at a shared boundary: vertex_range (complete, for counting/headers/weights) and a restored, stricter fully_owned_vertex_range (excludes shared boundary vertices from both sides, for anything assuming single-PE adjacency ownership). Also promoted SplitVertexInfo/partial_vertices onto Graph itself (previously computed internally and discarded at every call site) so a split-aware downstream consumer doesn't have to re-derive split detection via its own collective.

--skip-postprocessing empties GNM graphs — left as-is, agreed it's fine given it's a debugging-only flag.

New/extended regression tests cover exact balance under cross-PE duplicates, complete-partition coverage including isolated vertices, and the vertex_range vs fully_owned_vertex_range distinction at a real multi-way hub split. Full suite (63 tests) passes.

@niklas-uhl
niklas-uhl force-pushed the feature/true-edge-balance-redistribution branch from bbda169 to ef17ca6 Compare August 20, 2026 09:31
niklas-uhl and others added 25 commits August 20, 2026 18:08
ComputeBalancedEdgeDistribution's breakpoint loop previously emitted a
breakpoint for every bucket boundary a single vertex's degree crossed,
pushing the same vertex value multiple times. Distribution then handed
several consecutive PEs an empty (v, v) range, starving them entirely
even when other PEs' worth of edges/vertices were available.

Cap this at one breakpoint per vertex, attributing the vertex causing
the overflow to the *current* bucket (absorbing the overflow) and
starting the next bucket at the following vertex. Since the pushed
value is now always strictly greater than the previous one, it can
never collide with an already-used breakpoint -- which also fixes a
related, pre-existing collision when the overflowing vertex is the
very first vertex of a PE's range (its deferred breakpoint could
coincide with that PE's own start, e.g. rank 0's hardcoded initial 0).

Add a regression test with a dominant hub vertex plus a uniform
overlay of edges among the rest, asserting no PE is starved to an
empty range.
Adds the new GraphRedistribution/GraphDistribution enum value and its
"balance-edges-strict" CLI/option-string key, plumbed through the map
and operator<< helpers. Option-string parsing (context.cpp) already
applies "redistribution" generically to every generator, so this also
becomes reachable via GenerateFromOptionString without further changes.

No generator handles the new value yet -- the two switches over these
enums (graph500_generator.cpp, io.cpp) now warn via -Wswitch until
they're wired up in follow-up commits, which is the intended
transitional state (mirrors the plan's suggested build-first-verify
approach; querying it now would silently produce an empty graph).
Adds a new postprocessor primitive that splits a single vertex's own
edges across multiple PEs when necessary, achieving exact (+/-1)
edge-count balance even in the presence of a hub vertex whose degree
alone exceeds one PE's fair share -- something the existing
vertex-atomic RedistributeEdgesBalanced cannot do by design.

Algorithm: reuses the existing "lightweight metadata exchange, then a
single edge-data shuffle" shape of ComputeBalancedEdgeDistribution,
extended so the metadata phase (routing (vertex, degree) pairs to
each vertex's nominal owner by vertex ID, not by where its edges
currently happen to live) also resolves, for every vertex, the exact
global edge rank of each contributing source PE's first local edge of
that vertex. Each source PE then locally derives every one of its
edges' final target PE from that one base rank plus its own offset,
against a distribution array every PE can compute independently --
no second edge-data shuffle. A vertex is correctly split across
however many adjacent PEs its degree demands (not capped at 2), since
the split point is resolved analytically rather than assumed to fall
within a locally-sorted concatenation of edges.

(An earlier, simpler design -- local sort per PE plus MPI_Exscan of
raw local edge counts -- was considered, but does not actually
guarantee correct splitting when a vertex's edges are scattered
across many non-adjacent PEs before redistribution, which is the
normal case for a hub vertex in RMAT before this call.)

Adds Graph::has_split_vertices and Generator::{Set,}HasSplitVertices
to carry the "did any vertex actually get split" signal through
Finalize()/Take() for the compatibility guard added in a later commit.

Extends the existing parameterized redistribute_edges_test.cpp fixture
with RedistributeEdgesTrueBalance as a third case (wrapped down to a
VertexRange via .fully_owned_vertex_range) for the edge-multiset
tests; the ownership-invariant tests are skipped for it since true
splitting intentionally breaks single-PE ownership for boundary
vertices. Adds a dedicated adversarial test with a dominant hub vertex
asserting exact (+/-1) balance and that the hub's edges land on more
than one PE once there is more than one PE to split it across.
Graph500Generator::FinalizeEdgeList (shared by RMAT and Kronecker) now
handles GraphRedistribution::BALANCE_EDGES_TRUE via
RedistributeEdgesTrueBalance, propagating has_split_vertices. Adds a
--redistribution CLI option (factored into a shared add_option_redistribution
helper) to gnm-directed, gnm-undirected, rgg2d, rgg3d, and kronecker as
well, ahead of wiring their generators in follow-up commits.

While smoke-testing RMAT with balance-edges-strict, found that
duplicate edges could survive redistribution: a generator like RMAT
symmetrizes every directed sample unconditionally and samples
independently per chunk, so it can legitimately draw the same
undirected edge twice on two different source PEs (particularly
likely for hub-adjacent pairs, i.e. exactly the vertices this feature
splits). The vertex-atomic modes always catch such duplicates because
routing is a pure function of vertex ID, so both copies are always
co-located and caught by a final per-PE dedup. True-split routing is
per-edge (based on position within a vertex's aggregated edge list),
so two duplicate copies of a split vertex's edge could legitimately
be routed to different target PEs and never meet.

Fix: after determining a vertex is split (its degree spans more than
one PE's fair edge share), escalate its actual edges -- not just a
count -- to the vertex's owner instead of routing them directly. The
owner gathers every contributing sender's data (bounded by the split
vertices' own total degree, not the whole graph, since the vast
majority of vertices aren't split and keep using the cheap direct
routing), deduplicates by head value, and forwards each surviving
edge straight to its true target. This also means any duplicate that
does survive dedup at this stage is guaranteed to straddle at most
one boundary between two adjacent PEs (equal values are adjacent once
sorted), matching the invariant the vertex-atomic modes get for free.

Adds a regression test modeling the extreme case (every PE
contributes an identical copy of the same hub) confirming the
duplicates collapse to exactly the expected unique edge count with no
duplicates surviving in the gathered output.
Adds the new mode to the existing parameterized distribution/density
matrix (skipping OwnershipInvariant, which assumes single-PE vertex
ownership) and to the cross-distribution edge-set-identity check
against balance-vertices. The latter specifically exercises real RMAT
generation through the public GenerateFromOptionString API rather
than a synthetic edge list, which would have caught the split-vertex
dedup gap fixed in the previous commit.
Adds a local_edges_ staging buffer to both GNMDirected and
GNMUndirected<BigInt>, redirecting their PushEdge call sites into it,
plus a FinalizeEdgeList override handling all three
GraphRedistribution modes.

balance-vertices is a true no-op: GNM's chunked generation already
confines every edge's tail to its own PE's [start_node_, end_node_)
range by construction (see the local_row / to >= start_node_ guards
in GNMUndirected, and the recursive chunk-confined sampling in
GNMDirected), so redistributing would just reproduce the same
placement at the cost of an unnecessary full shuffle -- this must not
regress the default setting's performance. balance-edges and
balance-edges-strict route through RedistributeEdgesBalanced /
RedistributeEdgesTrueBalance as usual, with remap_round_robin=false
(GNM has no ID-correlated skew the way RMAT does, so there's nothing
to gain from scrambling vertex IDs).

Adds --redistribution to the gnm-directed/gnm-undirected CLI
subcommands (via the add_option_redistribution helper introduced for
RMAT/Kronecker) and a new gnm_distribution_test.cpp mirroring the
RMAT distribution test structure across both directed/undirected
variants and all three redistribution modes.
Adds FinalizeEdgeList overrides to the concrete RGG2D/RGG3D classes
(the shared Geometric2D/Geometric3D::GenerateEdgeList() is final and
also shared with Delaunay, out of scope, so it can't live there).

balance-vertices is a no-op, matching the same reasoning applied to
GNM: Geometric{2,3}D::GenerateEdgeList() already confines every
edge's tail to this PE's own chunk-assigned vertex_range, which also
encodes spatial locality worth preserving -- redistributing would
just reproduce the same placement at the cost of an unnecessary full
shuffle, regressing the default setting's performance.

Adds two guards, both throwing ConfigurationError for edge-balanced
redistribution (balance-edges/-strict) combined with:
- --coordinates: splitting/reshuffling vertices would require a
  second coordinate-shuffling exchange primitive not built here.
- --edgeweights-generator=euclidean-distance: RGG pushes these
  weights directly into graph_.edge_weights during generation, in
  lockstep with graph_.edges; the redistribution primitives only
  shuffle Edgelist, so redistributing afterwards would desync
  weights from edges.

Widens the try/catch in GenerateInMemory (in_memory_facade.cpp) to
cover the whole generate/finalize/weight pipeline, not just
NormalizeParameters -- these new guards fire from inside Finalize(),
and previously that would have produced an uncaught abort instead of
the intended clear error message.

Fixes an unrelated pre-existing bug found while testing the
euclidean-distance guard: GetEdgeWeightGeneratorTypeMap() mapped the
string "euclidean_distance" to HASHING_BASED instead of
EUCLIDEAN_DISTANCE, making that generator unreachable via the CLI/
option-string API.

Adds --redistribution to rgg2d/rgg3d (already done for the CLI in an
earlier commit) and a new rgg_distribution_test.cpp: the usual
parameterized ownership/duplicate/cross-distribution matrix, plus a
dedicated negative-test suite driving the low-level Factory/Generator
API directly (GenerateFromOptionString funnels through
GenerateInMemory, which now catches and aborts on ConfigurationError,
so it can't be used to observe the exception in gtest).
Removes the "not implemented" stub for REQUIRES_REDISTRIBUTION
readers (plain edgelist etc.) combined with BALANCE_EDGES: they now
read an arbitrary partition and defer to FinalizeGraphFragment's
postprocessing pass, the same way BALANCE_VERTICES already does for
them.

For BALANCE_EDGES_TRUE, every reader -- including METIS/ParHIP, which
are FindNodeByEdge-capable and normally bypass postprocessing entirely
via an efficient offset-based range read -- now reads an ordinary
vertex-balanced partition (a plain vertex-range read, which every
reader supports) and lets FinalizeGraphFragment's postprocessing pass
do the real redistribution via RedistributeEdgesTrueBalance. This
intentionally does NOT throw for METIS/ParHIP: FindNodeByEdge only
supports the vertex-atomic approximation BALANCE_EDGES needs, not the
exact positions true balancing requires, but a plain vertex-range read
works for any reader.

FinalizeGraphFragment now takes InputGraphConfig so it can dispatch on
config.distribution instead of hardcoding vertex balance for every
REQUIRES_REDISTRIBUTION reader regardless of what was actually
requested (ROOT/EXPLICIT/BALANCE_VERTICES keep that pre-existing
behavior, matching the plan's explicit scope; BALANCE_EDGES and
BALANCE_EDGES_TRUE get their own real dispatch).

Found and fixed two additional bugs while getting this to actually
work end-to-end:

- None of the redistribution primitives carry vertex_weights/
  edge_weights alongside the Edgelist they redistribute, so
  redistributing a weighted graph would silently desync weights from
  their edges/vertices -- a pre-existing gap for BALANCE_EDGES, not
  just the new BALANCE_EDGES_TRUE path. Added a guard in
  FinalizeGraphFragment that throws ConfigurationError instead
  (collectively -- a PE with an empty local partition would otherwise
  see empty weight arrays locally even though the graph as a whole is
  weighted, causing some PEs to throw while others proceed into
  collective redistribution code, deadlocking).
- generic_file_generator_test.cpp's ReadStaticGraphOnRoot helper
  called Finalize(MPI_COMM_WORLD) from inside an `if (rank == 0)`
  block, on a generator constructed as if it were the only PE
  (rank=0, size=1). This was harmless while Finalize() was a no-op
  for METIS/ParHIP, but deadlocks now that BALANCE_EDGES_TRUE performs
  real collective communication there; fixed to use MPI_COMM_SELF,
  matching the single-PE semantics the generator was already
  constructed with.

Relocates ConfigurationError from kagen/generators/generator.h to
kagen/definitions.h (generator.h now includes it, so existing
consumers are unaffected) so kagen/io.cpp can throw it without a
layering inversion.

Extends generic_file_generator_test.cpp: adds BALANCE_EDGES_TRUE to
the existing (format, distribution, representation) parameterization
for METIS/ParHIP (EDGE_LIST only for BALANCE_EDGES_TRUE -- CSR
requires single vertex ownership, which a split-vertex graph violates,
the same restriction adjacency-grouped output formats have; this test
fixture also bypasses the compatibility guard that will reject that
combination at the CLI/library entry points, added in a follow-up
commit), and updates the weighted-graph tests to expect
ConfigurationError for BALANCE_EDGES_TRUE.
Adds a centralized check in GenerateInMemory, right after Finalize()
and before GenerateEdgeWeights/GenerateVertexWeights/statistics run:
collectively reduces (MPI_LOR) each PE's Generator::HasSplitVertices()
into a single any_split flag, then rejects combining an actual split
with anything that assumes single-PE vertex ownership:
- adjacency-grouped output formats (METIS, HMETIS variants, DOT
  variants, ParHIP, XtraPuLP, freight-netl variants, netd-are),
- --validate-simple-graph,
- a cross-PE-lookup edge-weight generator (hashing-based/
  uniform-random; both do AllgatherVertexRange+FindPEInRange and would
  silently misbehave on split vertices),
- statistics (guarded by the same !config.quiet condition the actual
  statistics computation uses further down, since --quiet skips it
  regardless of statistics_level).

Only fires when a split *actually happened*, not merely when
BALANCE_EDGES_TRUE was requested -- a graph with no real hub under
that mode is fully compatible with everything.

The decision logic lives in its own function,
CheckSplitVertexCompatibility(any_split, config), rather than inlined
in GenerateInMemory, specifically so it can be unit-tested directly:
GenerateInMemory funnels through MPI_Abort on ConfigurationError (by
design, so the CLI fails cleanly), which makes EXPECT_THROW-style
testing impossible against the full pipeline. The new
compatibility_guard_test.cpp exercises the decision matrix (each
adjacency-grouped format, validate-simple-graph, both cross-PE-lookup
edge-weight generators, statistics with/without --quiet, multiple
output formats) without driving any MPI collectives.

Adds a doc comment to ValidateVertexRanges (kagen/tools/validator.cpp)
noting it hard-requires single-PE vertex ownership and is not adapted
to tolerate split vertices, per the plan's explicit scope (no code
change there -- the guard above already prevents reaching it).
Adds tests/kronecker/kronecker_distribution_test.cpp, mirroring the
RMAT distribution test pattern (ownership/duplicate/cross-distribution
checks across all three redistribution modes) -- Kronecker
mechanically shares Graph500Generator::FinalizeEdgeList with RMAT, but
wasn't covered by a dedicated test yet.

Adds tests/file/plain_edgelist_distribution_test.cpp covering the one
reader category generic_file_generator_test.cpp cannot: a
REQUIRES_REDISTRIBUTION reader (plain edgelist), where balance-edges
used to throw "not implemented" and balance-edges-strict needs the
same "read an arbitrary partition, then postprocess" treatment as
balance-edges now does. Uses a hub-and-ring graph so there's a real
hub to exercise splitting under balance-edges-strict.

Found and fixed a real deadlock while building this test: computing a
unique-per-process temp filename via getpid() doesn't work when called
from every rank, since each MPI rank is a separate OS process with its
own PID -- only rank 0 ends up writing to the name it computed, while
every other rank looks for a *different*, nonexistent file of its own
and hangs trying to read it. Fixed by having only rank 0 compute the
name (and write the file), then broadcasting it to the other ranks.
Adds a new "Distributing the Generated Graph Across PEs" section
explaining the three modes (balance-vertices, balance-edges,
balance-edges-strict), their tradeoffs, and balance-edges-strict's
compatibility restrictions (edge-list-shaped consumption only; the
additional --coordinates / euclidean-distance-edge-weights
restriction for the geometric generators). Previously this was only
documented via inline CLI --help text.

Adds the --redistribution flag to the CLI usage blocks for
gnm-directed/gnm-undirected, rgg2d/rgg3d, rmat, and kronecker, and
updates the File Graph Generator's --distribution line to include
balance-edges-strict.
Found via manual testing after the fact: KaGen::UseCSRRepresentation()
combined with a graph that ends up with split vertices (from
--redistribution=balance-edges-strict) segfaulted instead of hitting
the compatibility guard.

Root cause: the guard in GenerateInMemory runs *after*
generator->Finalize() returns, but for the affected generators, the
actual CSR-building step (BuildCSRFromEdgeList, which assumes every
edge's tail lies within vertex_range -- untrue for a split vertex's
edges) happens *inside* Finalize(): EdgeListOnlyGenerator::FinalizeCSR
and FileGraphGenerator::FinalizeCSR both call FinalizeEdgeList() (which
performs the redistribution and determines has_split_vertices) and
then immediately build CSR from the result, all within the same call.
By the time Finalize() returns (or, as it turned out, crashes), the
out-of-bounds write already happened. The facade-level guard can only
meaningfully react to CSR representation once has_split_vertices is
already known, which is too late for this specific path.

Fixed by checking graph_.has_split_vertices immediately after
FinalizeEdgeList() in both FinalizeCSR implementations, before
BuildCSRFromEdgeList() runs, throwing ConfigurationError instead. This
is safe to do independently per generator (no additional Allreduce
needed) since has_split_vertices is already collectively consistent
by this point -- RedistributeEdgesTrueBalance Allreduces it before
returning.

Also extends CheckSplitVertexCompatibility (the facade-level guard)
with a GraphRepresentation parameter and its own CSR check, as
defense in depth for the general case (representation is known before
generation starts, unlike has_split_vertices) and to document the
restriction in one place; updates its call site and unit tests
accordingly.
The GNU/Release matrix leg hung twice in a row in ctest (around
test_plain_edgelist_distribution.4cores), stalling the job for the
full 60-minute job timeout instead of failing fast. No test in the
suite normally takes anywhere near 120s, so this only fires on a
genuine hang and turns a 60-minute stall into a ~2-minute failure
that names the specific hung test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RedistributeEdgesTrueBalance computed its edge-rank bucket boundaries
from pre-deduplication degree counts, so cross-PE duplicate edges left
holes once they were removed, breaking the promised +/-1 balance. A
cheap rank-based rebalance pass now runs after dedup to close that
drift without ever concentrating a hub's full edge set on one PE.

Separately, fully_owned_vertex_range (assigned directly to
Graph::vertex_range) silently excluded isolated (degree-0) vertices
and one side of every split boundary vertex, corrupting header/global
vertex counts and per-vertex-weight generation even when no split
actually occurred. Boundary detection is now a single Allgather
instead of a pairwise Sendrecv, producing a complete, gap-free
partition of [0, n) as vertex_range, plus a restored, stricter
fully_owned_vertex_range for consumers that need single-PE adjacency
ownership. SplitVertexInfo/partial_vertices, previously computed
internally and discarded at every call site, is now exposed on Graph
so a split-aware downstream consumer can use it directly instead of
re-deriving split detection itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Read each PE's strict edge slice straight from disk for
--distribution=balance-edges-strict on eligible formats, avoiding the
vertex-balanced-read + all-to-all RedistributeEdgesTrueBalance pass (2x
memory + communication). Adds a GraphReader::ReadStrictEdgeRange
capability:

- ParHIP: native CSR (split-aware, partial boundary rows) or edge list,
  from a single contiguous adjncy slice via the offset header.
- METIS: sequential-scan edge slice; native split CSR built locally via
  BuildCSRFromEdgeList over the row-space vertex_range.
- weighted-binary-edgelist: edge-indexed slice, gated on a cheap global
  tail-sortedness check; unsorted input falls back to redistribution.

Boundary/split metadata is computed from one MPI_Allgather (no edge
movement); the tail-based computation is factored out of
RedistributeEdgesTrueBalance into shared ComputeBoundaryOwnership /
ComputeEdgeBalancedBoundaries helpers.

Also extend --distribution=balance-edges to the direct path for sorted
edge-list readers: read the strict slice, then heal partial boundary
vertices to whole-vertex (vertex-atomic) ownership via a single
weight-carrying neighbor exchange (HealToVertexAtomic), falling back to
RedistributeEdgesBalanced when a single exchange cannot resolve ownership
(empty PE or a vertex spanning >2 PEs) or the input is unsorted.

Edge-weighted input now succeeds on the direct path (weights stay
attached); vertex-weighted input is rejected (a split vertex's whole
weight is ambiguous). The CSR + split-vertex guard is relaxed for the
directly-read case; the corrupting build-from-edgelist CSR paths still
throw earlier. CLI help text corrected.

Tests: parameterized strict_edge_balance_test over ParHIP+METIS (edge
list + split CSR); new weighted_edgelist_distribution_test (direct
balance-edges/-strict with weight preservation + unsorted fallback);
updated compatibility_guard and generic_file_generator expectations. Also
disambiguate parhip_reader_writer temp filenames by comm size to fix a
pre-existing concurrent-run flake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously a split graph (--redistribution=balance-edges-strict) could
only be produced as CSR by the direct strict ParHIP file read; converting
a split edge list to CSR threw ("CSR representation requires single-PE
vertex ownership"). That excluded all in-memory generators (rmat, gnm) and
edge-list file inputs from producing a truly edge-balanced CSR graph.

EdgeListOnlyGenerator::FinalizeCSR and FileGraphGenerator::FinalizeCSR now
build a split CSR by extending the row space down by one vertex on a
left-partial (replica) PE, whose first vertex is credited to the lower-rank
canonical PE and would otherwise underflow BuildCSRFromEdgeList's
`from - vertex_range.first`. The result is the same physically-present
row-space layout the ParHIP reader produces: adjacent PEs' ranges overlap
by one vertex at each split, described by partial_vertices. Trailing
isolated vertices are still absorbed as empty rows.

Also relax the statistics guard: basic statistics (vertex/edge counts and
imbalance) need only per-PE counts and stay valid for a split graph -- edge
imbalance in particular is exactly what balance-edges-strict targets -- so
only advanced statistics (per-vertex degree distribution, density,
locality, ghost nodes) are rejected now.

Document the CSR-vs-edge-list vertex_range distinction in kagen.h (the
gap-free contract holds for edge lists; CSR overlaps by one at splits).

Tests: cover strict-edge-balanced CSR generation for rmat and update the
compatibility guard for the basic-statistics carve-out. Fixes a latent
test bug where the rmat distribution tests passed `distribution=` in the
option string, which generators ignore (the key is `redistribution=`), so
they never actually exercised balance-edges/-strict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous note claimed the CSR overlap only arises from a strict file
read because edge-list-to-CSR conversion of a split graph throws -- but the
prior commit removed that throw. Reframe around the actual invariant: the
range is gap-free in every case except a split graph in CSR representation
(both the file read and the edge-list-to-CSR conversion produce the
overlapping row space), and note the summed-size consequence of the overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…R graphs

vertex_range deliberately overlaps by one vertex at each split boundary in
the CSR representation; these give the gap-free, single-owner range/count
instead, matching what edge-list representation already provides.

Replaces the ambiguous partial_vertices vector with explicit
left_partial_vertex/right_partial_vertex fields on Graph and
EdgeBalancedDistribution, since a PE whose entire local CSR is one shared
row could not otherwise tell locally whether that vertex was shared with
the lower- or higher-rank neighbor -- which silently dropped a vertex from
the true count in that case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
vertex_range previously meant different things per representation: for
EDGE_LIST it was the gap-free ownership range, but for a split CSR graph it
was the overlapping physically-present row space instead. Any generic
consumer of a Graph (e.g. EdgeRange::FromGraph, a library caller not aware
of the CSR special case) that read vertex_range directly -- as its name
suggests it should be safe to do -- would silently miscount or misindex a
split graph.

vertex_range is now always the gap-free, single-owner partition of [0, n),
identically for both representations; Graph::PhysicalVertexRange() gives
the (possibly-overlapping) set of vertices a PE holds any neighborhood
information for, which CSR's xadj/adjncy happen to be indexed by. This
replaces the TrueVertexRange()/TrueNumberOfLocalVertices() added earlier
this session, which are now redundant since vertex_range already has that
meaning.

Fixes two latent bugs this uncovered: EdgeRange::FromGraph mapped CSR rows
to the wrong global vertex ids on a split graph, and PrintBasicStatistics
double-counted a split boundary vertex for CSR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-balance boundary

ReadStrictEdgeRange locates a PE's physical row window via FindNodeByEdge,
which finds vertices by the edges they own. A run of degree-0 vertices
sitting exactly at a from_edge/to_edge boundary owns no edges, so it's
invisible to that lookup and gets skipped from the physically read xadj
window -- even though ComputeBoundaryOwnership's gap-free partition still
credits those vertices to whichever PE precedes them. This left xadj one
row short of PhysicalVertexRange() on the affected PE, misaligning every
row from the boundary onward for CSR consumers indexing by global vertex
id via PhysicalVertexRange().

Backfill degenerate (zero-length) rows for any such absorbed run so xadj
stays aligned with PhysicalVertexRange(); a genuine split vertex already
has its row physically present, so this never fires for those.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…alone repro script

KaCCv2 hangs during kagen generation with redistribution=balance-edges-strict at
large scale (768 ranks, n=201326592, m=1610612736). The breadcrumbs bracket every
collective in the true-balance redistribution path so the last one printed without
a matching "after ..." pinpoints where a run gets stuck. The script isolates the
KaGen call (via the CLI options subcommand) from KaCCv2/MultiStep entirely, using
the same SLURM/env setup as the job that hung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The first repro run hung right at the stage-2 residual-rebalance MPI_Exscan/
MPI_Allreduce, with rank 0 having finished everything before it -- meaning some
other rank never reached that collective, but root-only breadcrumbs can't say
which one. Add KAGEN_TRUE_BALANCE_DEBUG_ALL_RANKS=1 to print from every rank
(tagged with its own rank number) so the straggler can be found; default stays
root-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… fixed

The exclusive_scan int32-overflow fix (77572ee) was confirmed to resolve the
hang at full scale, so the instrumentation added to find it is no longer
needed. Keeps the fix itself, drops the breadcrumb prints and the
KAGEN_TRUE_BALANCE_DEBUG_ALL_RANKS env toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same class of bug as 77572ee: exclusive_scan's accumulator type is deduced
from the init value, not the output iterator, so a plain 0 (int) silently
overflows once the running total exceeds INT32_MAX.

- tests/gather.h: GatherCSR's xadj prefix sum (SInt) -- affects gathering a
  graph with >~2^31 edges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@niklas-uhl
niklas-uhl force-pushed the feature/true-edge-balance-redistribution branch from 2ac6109 to d6b9728 Compare August 20, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants