Speed up connect_the_chunks for large numbers of chunks - #3266
Open
jin-castle wants to merge 1 commit into
Open
Conversation
jin-castle
force-pushed
the
pr/connect-chunks-speedup
branch
from
August 21, 2026 08:30
68e68e9 to
26b174d
Compare
connect_the_chunks tested every (boundary point of every chunk, every chunk) combination on every process -- an O(num_chunks^2 * boundary points) scan that dominates (re)initialization time at large process counts and/or with many PML-split chunks. Measured on a 2.2M-voxel 3D cell on a 128-core EPYC node: 12.3 s at 64 processes and 40.9 s at 128 processes per fields (re)initialization, growing superlinearly. Three changes, none of which alter the resulting connection tables: - Chunk pairs are pruned with conservative padded bounding-box tests that account for periodic wrapping (+-1 lattice vector per periodic direction, matching locate_point_in_user_volume) and symmetry transforms. The per-point owns() checks are unchanged, so a false candidate costs a few wasted comparisons but can never change the result, and a true pair can never be missed. - Chunks none of whose candidate pairs involve a process-local chunk are skipped entirely (their contributions were filtered out point-by-point before). - Communication buffers are allocated only for pairs that exchange data instead of num_chunks^2 * num_field_types allocations. Benchmarked with forced chunk counts (single process, so only the pruning applies): 33.2 s -> 4.9 s at 192 chunks, with bit-identical fields after stepping. Under MPI the process-local skip reduces the scan further. Regression-tested with the symmetry, periodic-boundary, and mode-decomposition Python tests serially and under mpirun -np 4.
jin-castle
force-pushed
the
pr/connect-chunks-speedup
branch
from
August 21, 2026 10:12
26b174d to
a9223d9
Compare
Collaborator
|
This is a reasonable sort of optimization at first glance, but I'm a little worried about anything that increases the complexity of the code that computes the boundary connections, which is already extremely complicated. How much speedup do real applications get from optimizing this code, which usually only executes once at the beginning of the simulation? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fields::connect_the_chunks()tests every not-owned boundary point of everychunk against every other chunk, on every process. The point scan is the
expensive half and it runs
num_chunkstimes per chunk, so the cost grows asO(num_chunks² × points-per-boundary). PML splitting pushes the chunk count well
past the process count, and any workflow that rebuilds
fieldsrepeatedly —reset_meep()in an optimization loop — pays it every iteration.On a 2.2M-voxel 3D cell on a dual-EPYC-9554 node (128 physical cores), the
first
step()afterinit_sim(), which is where the connection tables getbuilt, costs 12.3 s at 64 processes and 40.9 s at 128, against roughly 1 ms per
timestep of actual stepping. It grows superlinearly, so it gets worse exactly
where you were adding nodes to make things faster. (Those two figures are from
an earlier run on that machine; everything below is measured on this tree.)
This prunes the candidate chunk pairs before the point scan. The connection
tables come out identical.
What changed
Chunk-pair pruning. A
chunk_candidates[i]list is precomputed once percall from a bounding-box test, and both point-scan loops iterate that instead
of
for (int j = 0; j < num_chunks; j++).Skipping chunks with no local pair.
chunk_needed[i]is false when nocandidate pair
(i, j)involves a process-local chunk.j == iis always acandidate, since a box intersects itself, so this is true whenever chunk
iismine; it only drops chunks whose every candidate pair is remote-to-remote,
which the loop body's
(chunks[i]->is_mine() || chunks[j]->is_mine())guardrejects anyway. Those chunks previously had their boundary points scanned and
filtered one at a time.
Sparse comm-buffer allocation. Buffers were allocated for all
num_chunks² × num_field_typespairs, most of them zero-length. They are nowallocated only for pairs present in
comm_sizes.That third one is the only change that alters state rather than the order
things are computed in, so to be explicit about why leaving the rest NULL is
safe: NULL is already the value the constructor writes (
src/fields.cpp:66-68,:120-122), the destructor handles it (:136), and every read ofcomm_blocks[ft][pair_idx]goes throughcomms_sequence_for_field[ft], whoseoperations are only created for pairs with
comm_size_tot(f, pair) != 0(
src/boundaries.cpp:688-689). A pair without a buffer is never reached.Why the box test cannot drop a real pair
The pruning is only sound if it over-admits. The inner loop's sole acceptance
test is
chunks[j]->gv.owns(here), so it is enough to bound whatherecanbe:
pfromLOOP_OVER_VOL_NOTOWNED(vi, ...), sop ∈ [little_corner(vᵢ), big_corner(vᵢ)]— call that boxBᵢ.locate_component_point()→locate_point_in_user_volume()mapsp ↦ S.transform(p + s, sn), withsover 0 and ±1 lattice vector perperiodic direction and
sn ∈ [0, S.multiplicity()).owns(here)implieshere ∈ [little_corner(v_j), big_corner(v_j)] = B_j.So a nonzero contribution from
(i, j)requiresS.transform(Bᵢ + s, sn) ∩ B_j ≠ ∅for some(s, sn), which is what theprecomputation evaluates.
Two details keep that from being merely plausible.
S.transformis a signedpermutation of the coordinate axes about a fixed center, so it carries a box to
a box — transforming the two corners and taking a per-direction min/max
recovers the image exactly, with no inflation or clipping. And both boxes are
dilated outward by 2 ivec units (one pixel) before intersecting, so the test
admits pairs the point scan then rejects; the per-point
owns()checks areuntouched, so a false candidate costs a few wasted comparisons while a true
pair cannot be lost.
The complexity claim is narrow, though. What goes away is the
O(num_chunks² × points-per-boundary) term, replaced by
O(num_chunks² × 3^(#periodic dirs) × multiplicity) box tests plus
O(candidates × points). The pair enumeration is still quadratic — this is a
much smaller constant on the term that was dominating, not a change of order.
Measurements
Single process with forced
num_chunks, so only the pruning is exercised andthe
chunk_neededskip contributes nothing here. 8×5×4 µm cell, resolution 16,timed as the first
step()afterinit_sim():Baseline is master at e7d46f2, patched is that same tree with only
src/boundaries.cppchanged, so nothing else moves between the columns.Equivalence
Since the claim is that the tables are unchanged, the check is whether any
field anywhere differs. Whole-grid checksums — sum and max of |Ez|, |Ey|, |Hx|
over the entire cell after 700 steps — are bit-identical between the baseline
and patched builds across all 16 combinations of
num_chunks= 3, 7, 16, 48The periodic and mirror cases are the point of that list: the benchmark above
is all-PML, which reaches neither the lattice-shift enumeration nor the
S.transform()branch, and those are the two places the argument is doing realwork. The run length matters for the same reason — a short run leaves most
chunk interfaces exchanging zeros, where a wrong connection cannot show up at
all, so the comparison aborts if the field is still trivial when it is read.
Regression suite on the patched build:
test_mode_coeffs,test_special_kz,test_n2f_periodic,test_bend_fluxandtest_dispersive_eigenmodepassserially;
test_mode_coeffsandtest_bend_fluxpass undermpirun -np 4.All of that is downstream evidence — identical fields imply identical tables
but do not check the tables directly. Building
comm_sizeswith the pruningdisabled and diffing it against the pruned build at each chunk division would,
and it is cheap; I am glad to add it as a regression test if you would like it
in here.
Limitations
The pad of 2 is a hardcoded constant. It is one pixel, sized for the not-owned
halo that
LOOP_OVER_VOL_NOTOWNEDwalks, and it would be better derived fromthat halo than written down, so it follows if the halo ever changes.
There is no spatial index, so the pair enumeration stays O(num_chunks²) box
tests. That is cheap at the chunk counts I measured, but it is the term that
survives, and at some larger count it would want a grid or tree over the chunk
boxes.
The 16-combination field comparison is single-process. Under MPI only the
regression suite ran, at
-np 4— andchunk_neededis the MPI-specific part,so it has the least direct evidence behind it.
The cases cover one mirror plane and one periodic axis. Several simultaneous
mirrors raise
S.multiplicity()and several periodic directions make the shiftenumeration combinatorial; both follow from the same argument, but neither was
run.
Reopened and rebased onto current master; the diff is unchanged from the
original.
🤖 Generated with Claude Code