Halo Exchange: Status and Roadmap¶
This document consolidates halo (ghost cell) exchange in OpenPFC: design goals (spectral + real-space), halo policies, implementation status, and evolution.
1. Purpose¶
Halo exchange enables real-space stencil operations (e.g. finite difference Laplacian) on distributed domains by synchronizing ghost cells between neighboring ranks. Without it, real-space operations that need neighbor data would have to go through global FFTs. With it we can support:
Finite difference derivatives and diffusion
Hybrid spectral + real-space methods
Future adaptive or local operations
2. Design goals: spectral + real-space¶
HeFFTe / FFT expects each rank to hold a contiguous block of physical samples for its subdomain (fft::get_inbox / decomposition::get_subworld), with no ghost layers in that layout.
In-place halos (traditional FD): ghost values are written into the boundary slabs of the same nx×ny×nz array used for the owned grid. After exchange, those boundary samples are not in general the same as the purely owned global-grid values at those indices on a multi-rank periodic domain. Therefore you must not use that same buffer for distributed FFT after a halo fill unless you know the physics allows it (e.g. single-rank).
Recommended for FFT + FD coexistence: keep a core buffer of size exactly the subdomain (nx×ny×nz) for spectral work, and store received ghost data in separate face buffers. Exchange is driven by pfc::SparseHaloExchanger<T> (include/openpfc/kernel/decomposition/sparse_halo_exchange.hpp) — fully sparse, grid-agnostic, accepts an arbitrary std::vector<halo::RemoteHalo<T>>. For the standard structured face exchange, pfc::halo::make_structured_halos<T>(decomp, rank, hw, dirs = Axes3D()) builds the RemoteHalo list from a HaloDirectionSet. After the exchange, pfc::halo::copy_to_face_layout(ex, face_halos) (see halo_face_layout.hpp) refills the std::array<std::vector<T>, 6> layout that field::fd::laplacian_periodic_separated<Order> (or the interior-only field::fd::laplacian_interior<Order> when the iteration is restricted to [hw, n-hw)) expects.
Minimal hybrid timestep (conceptual, periodic FD):
Spectral substep:
fft.forward/backwardon core only.Before FD:
ex.exchange_halos(core, core_size); halo::copy_to_face_layout(ex, face_halos);(or use the padded-brick path if FFT is not used on that field).FD:
laplacian_periodic_separated<Order>(core, face_halos, lap, …)(full owned domain), orlaplacian_interior<Order>(core, lap, …)(interior slab only).Update core (and/or
lap) as required by the scheme.
Which exchanger when¶
Use pfc::field::PaddedBrick<T> + pfc::communication::PaddedHaloExchanger<T> (or the CUDA pfc::cuda::PaddedDeviceHaloExchanger) for classical FD with a single contiguous (nx+2hw)*(ny+2hw)*(nz+2hw) array and negative halo indexing. This is the default for FD-only apps and is the lowest-overhead path: ghost width is baked into storage, and the inner stencil reads u(i±hw, j, k) directly with no face-buffer indirection.
Use pfc::field::LocalField<T> (unpadded nx*ny*nz) + pfc::SparseHaloExchanger<T> (typically built via pfc::halo::make_structured_halos) for:
Mixed FD + spectral — the FFT does not tolerate halo regions in the data block, so the core stays unpadded.
Non-axis halos (edges / corners) without baking a 3-pass widening into the FD path — the user supplies any
HaloDirectionSet(or anyRemoteHalolist at all).Arbitrary peer / index patterns that have no face geometry — multi-block grids, hopping over distance, mixed
(peer_rank, send_indices, recv_indices, send_tag, recv_tag)tuples.Future unstructured / FEM halo communication — the API already accepts arbitrary
RemoteHaloentries; only amake_fem_halos(...)builder needs to land on top.
Stage preparation protocol¶
PaddedHaloExchanger / HaloExchanger / SparseHaloExchanger are transport: they move ghost faces given buffers and a decomposition. They do not interpret integrator stage flags or boundary-condition timing.
pfc::communication::StagePreparationService (stage_preparation.hpp) is the protocol layered on that transport for CPU/MPI padded bricks:
Consumes
StagePreparationRequirements(needs_halo_exchange,needs_boundary_update,region_kind,BoundaryHaloOrder), typically viapfc::integrator::requirements_from(StageContext).When halo is required, calls existing
pfc::communication::exchangeon named, boundPaddedHaloExchangers.When boundary update is required, runs an injectable boundary hook. Default order is boundary then halo so updated owned faces are published to neighbors before evaluation.
prepareis pre-evaluation only. Post-evaluation BC enforcement after writing new owned values stays a separate driver responsibility outsideprepare.Rejection / retry does not roll back halo buffers: re-prepare from the accepted owned core.
Method and operator layers should request prepare rather than embedding ad-hoc MPI at each evaluation site. Raw exchanger calls remain valid for drivers that manage timing themselves.
3. Halo policies¶
Policies describe where ghost data lives and what is safe for FFT. They are documented here; see include/openpfc/kernel/decomposition/halo_policy.hpp for the enum class HaloPolicy used in API/docs cross-references.
Policy |
Storage |
FFT on same buffer |
FD / stencils |
|---|---|---|---|
None |
Core |
Yes |
N/A |
InPlace |
One array; ghosts in boundary slabs of that array |
No (multi-rank) after halo fill |
|
PaddedBrick |
Single contiguous |
No (FFT does not see padded layout) |
|
Separated |
Core + six face halo buffers |
Yes on core only |
|
Mixed / hybrid |
Core has no aliased ghosts; sidecar holds all ghost data |
Core only |
Same as Separated; extra sync/copy steps are explicit, slower path |
Sparse / arbitrary |
Any user-supplied |
Yes on core only |
|
Note: “No halos in the FFT block” means no ghost layers stored inside that array, not “no periodicity.” Periodicity still comes from Decomposition / Domain.
4. Out-of-band (“OOB”) ghost model¶
Ghosts are not resolved by per-point MPI or maps in hot loops. The pattern (which faces, which indices) is fixed at setup—same idea as halo_pattern.hpp. Each step runs batched communication, then stencils read pre-filled face buffers with closed-form indexing (see finite_difference.hpp for separated layout).
5. Current status¶
5.1 Components¶
Component |
Location |
Description |
|---|---|---|
Halo policy enum |
|
|
Face halo sizes |
|
Per-face element counts and |
Neighbor discovery |
|
Face / all neighbors; periodic only. |
Halo patterns |
|
|
SparseVector + gather/scatter |
|
Pack path for halos. Gather/scatter fail closed on out-of-range indices with |
MPI exchange |
|
|
Device SparseVector MPI |
|
|
Face MPI types |
|
|
Padded face MPI types |
|
|
In-place driver |
|
|
Padded brick driver |
|
|
Padded brick, host buffer (full 26-direction) |
|
|
Padded brick, device buffer (axis-aligned 6-face) |
|
|
Padded brick, device buffer (full 26-direction) |
|
|
Padded brick storage |
|
|
Brick iteration |
|
|
Sparse driver |
|
|
Persistent halos |
|
|
FD primitives |
|
|
Generic stencils (custom: Sobel, CNN, anisotropic) |
|
|
FD bricks |
|
|
FD point evaluator (CPU) |
|
|
FD point evaluator (GPU) |
|
|
GPU |
|
|
Examples |
|
Separated halos + heat equation; core is FFT-safe. |
Design choice: Indices for the pack path are exchanged once at setup; only values move each step.
Setup-phase size envelope: exchange::send on CPU (exchange.hpp), CUDA (exchange_cuda.hpp), and HIP (exchange_hip.hpp) always posts exactly one MPI_UNSIGNED_LONG_LONG size word before any index/data messages — including value 0 when the SparseVector is empty — matching shared exchange::receive, which always MPI_Recvs that single size word. A prior GPU empty path that used a zero-count MPI_Send(nullptr, 0, …) was incorrect and could hang or skew tags against CPU peers. Classic MPI message counts are int; SparseVector exchange and packed PaddedDeviceHaloExchanger face posts therefore call pfc::mpi::ensure_mpi_int_count and throw std::overflow_error when a count exceeds INT_MAX (before host/device staging allocations on the receive and CUDA/HIP send paths).
Index semantics (in-place):
Send: Local linear indices of the boundary layer to send.
Recv: Local linear indices where received data is written—inside the same
nx×ny×nzarray (boundary slabs).
Separated recv: No scatter into core; MPI receives into contiguous face buffers whose element order matches the same face traversal as create_recv_halo (and MPI subarray layout).
5.2 Tests¶
Unit:
tests/unit/kernel/decomposition/test_halo_pattern.cpp,test_halo_face_layout.cpp,test_halo_mpi_types.cpp(non-paddedcreate_face_types_6thin-domain throw, borderline owned==hw, flatnz==1),test_padded_halo_mpi_types.cpp(per-face subarray geometry, MPI_Sendrecv onMPI_COMM_SELF, thin owned throw + borderline owned==hw, overflow of padded extent).Unit:
tests/unit/kernel/field/test_padded_brick.cpp,test_brick_iteration.cpp— padded indexing, owned/inner/border iteration counts.Integration:
test_halo_patterns.cpp,test_halo_exchange_driver.cpp,test_padded_halo_exchange.cpp(1/2/4-rank periodic wrap, axis-aligned 6-face),test_full_padded_halo_exchange.cpp(host full 26-direction fill on 1/2/4 ranks, edges + corners),test_fd_heat_mpi.cpp— MPI parity (in-place vs separated where applicable).Integration (CUDA):
test_full_padded_device_halo.cpp— bit-identical full 26-direction fill on 1, 2 (2x1x1), and 4 (2x2x1) ranks; every padded cell, including all 12 edges and 8 corners, is checked againsthash(periodic_global_coord). Single-rankhw=2case also covered.Integration (CUDA):
test_fd_gradient_device.cu— end-to-endfor_each_interior_device(model, eval.pod(), du, t, ...)against an analytic polynomial RHS (u = a + b x + c x² + d y + e y² + f z + g z²,rhs = value + ∂_i u + ∂_i^2 u). Confirms every owned cell matches the closed form to within1e-9and that halo cells ofdustay untouched. Constructor diagnostic-throw test for unsupported D1 orders also covered.Integration (CUDA multi-field):
test_multi_field_device.cu/test_composite_gradient_pod_size.cu—DevicePtrPackN+CompositeGradientDevicepath for 2-field (wave2d-style catalogUGrads/VGrads, kobayashi-stylephi/tempr) and 3-field synthetic kernels; GPU owned-cell increments match CPUfor_each_interiorwithin1e-12;scatter_deviceandevaluate_fd_grad_compositecovered;sizeof(CompositeGradientDevicePOD) == 2088.
5.3 Documentation references¶
Architecture:
docs/architecture.mdDesign history:
llm/user-stories/0009-implement-halo-exchange-layer.md,llm/IMPLEMENTATION_HALO_PATTERN.md,llm/IMPLEMENTATION_SPARSE_VECTOR.md,llm/design/finite_difference_gradient_design.md
5.4 Direction sets and presets¶
Every face exchanger above accepts an explicit pfc::halo::HaloDirectionSet (in include/openpfc/kernel/decomposition/halo_directions.hpp) so callers can shrink the active direction list — most commonly to skip ±Z on a 2D slab problem. The set is a deduplicated, validated list of unit Int3 vectors (each component in {-1, 0, 1}, never {0,0,0}); presets cover the canonical cases.
Preset |
Size |
Members |
Use for |
|---|---|---|---|
|
4 |
|
2D slab problems ( |
|
8 |
axes + 4 XY corners |
2D problems with diagonal reads ( |
|
6 |
|
Default 3D — historical 6-face exchange (7-point Laplacian). |
|
26 |
axes + 12 edges + 8 corners |
3D mixed second derivatives; default for |
Public ctor pattern, applied uniformly to every face exchanger:
Exchanger(decomp, rank, hw, comm,
pfc::halo::HaloDirectionSet dirs = presets::Axes3D(),
int base_tag = 0,
pfc::halo::HaloDirectionSelector per_rank = {});
If per_rank is provided, the exchanger calls per_rank(rank) for its own rank and uses that result; otherwise it uses the uniform dirs. Exchangers that historically defaulted to a different connectivity (FullPaddedHaloExchanger / FullPaddedDeviceHalo ⇒ Full3D()) keep their old default after the change. Custom sets that mix faces with diagonals are tolerated by face-only exchangers (the diagonals are silently ignored — they cannot be expressed as one of the 6 canonical face slots); for full corner/edge fill use pfc::communication::FullPaddedHaloExchanger (host) or pfc::cuda::FullPaddedDeviceHalo (device) and feed it Full3D() (or a smaller preset to subset its widening passes).
HaloExchanger and PaddedHaloExchanger use the zero-copy MPI subarray fast path iff every face slot is in the active set; subsetting via direction set falls back to the gather/scatter pack path. PaddedDeviceHaloExchanger and BatchedPaddedDeviceHalo skip excluded slots in both their GPU-aware and packed-fallback branches; same-rank periodic faces inside the active set still use device pack/unpack (no MPI-to-self) — this is the lever that turns off the nx*ny*hw ±Z self transfers when local nz == 1.
FullPaddedDeviceHalo and FullPaddedHaloExchanger share the same axis_active / axis_widen interpretation of diagonal directions:
Pass
ais enabled iff at least one of±ais in the set.Pass
awidens the slab cross-section over previously-filled axes iff the set contains a direction withd[a] != 0andd[b] != 0for someb < a. WithFull3D()this is exactly the original 3-pass widening; withAxes3D()every pass uses narrow slabs (face-only); withAxes2D()the Z pass is skipped entirely.
For 2D slab apps (apps/kobayashi/src/cuda/kobayashi_fd_cuda.cpp is the canonical example), pass presets::Axes2D() to both PaddedDeviceHaloExchanger and BatchedPaddedDeviceHalo to remove all ±Z communication / self-pack work without changing the rest of the driver.
Inter-rank consistency: CPU exchangers that accept a
HaloDirectionSet/HaloDirectionSelector(HaloExchanger,PaddedHaloExchanger,PersistentHaloExchanger) callpfc::halo::validate_neighbour_direction_agreementimmediately afterresolve_direction_set. That helperMPI_Allgathers a canonical encoding of each rank’s resolved set and checks paired-boundary agreement: every active directiondtoward neighbournrequires-dinn’s set (global set identity is not required). A mismatch throwsstd::runtime_errorat construction — before any exchange posts — rather than hanging on an unmatched Waitall. Follow-up: CUDA/HIPPaddedDeviceHaloExchanger/FullPaddedDeviceHalo(and app-localBatchedPaddedDeviceHalo) dirs/selector constructors do not yet call the same helper.
6. Architecture (data flow)¶
Decomposition defines global domain and per-rank boxes.
Patterns map directions to send/recv index sets (in-place) or drive pack/unpack sizes (separated).
In-place flow:
gather→ MPI →scatterinto core, or zero-copyisend_face/irecv_faceon core.Separated flow:
isend_facefrom core +MPI_Irecvinto face buffer (or pack path: gather from core,irecv_datainto SparseVector, memcpy to face buffer).
7. State of the art and improvements¶
Aspect |
OpenPFC |
Notes |
|---|---|---|
Index vs data |
Setup once |
Same as common practice |
Six-face path |
Derived types on send from core |
Separated path: contiguous recv into slabs |
Fewer than six dirs |
Pack path |
Separated: gather/scatter to face buffers |
Overlap |
|
Same idea for separated (future split API) |
GPU / aware MPI |
See exchange docs |
Separated CPU path first |
8. Gaps and limitations¶
Persistent separated exchanger not implemented yet (mirror
PersistentHaloExchanger).Overlap API for separated exchanger: can add
start_/finish_mirroringHaloExchanger.Face-only vs full-26 on the CPU padded-brick path:
PaddedHaloExchangerremains the axis-aligned 6-face default. Full corner/edge fill is available viapfc::communication::FullPaddedHaloExchanger(3-pass widening, host twin ofFullPaddedDeviceHalo). Wiring mixed seconds intoFDGradient/apply_tensor_dis still a follow-up after corners are proven.Orchestration: Optional thin
exchange_if_needed(HaloPolicy, …)can be added when multiple call sites need it; policies are documented first.
9. Next steps¶
GPU / persistent variants for
pfc::SparseHaloExchanger.make_fem_halos(...)helper on top ofpfc::SparseHaloExchangerfor unstructured / FEM neighbour discovery.Optional
DataBlock/ gradient abstractions perllm/design/finite_difference_gradient_design.md.Derived types or tuning for pack-heavy decompositions.
10. Working examples¶
Separated layout (FFT-safe core + face buffers):
examples/15_finite_difference_heat.cpp—mpirun -np P ./15_finite_difference_heatruns the heat equation withpfc::SparseHaloExchanger<double>(configured bypfc::halo::make_structured_halos<double>(...), defaultAxes3D()) andlaplacian_periodic_separated<2>. After the exchange the example callspfc::halo::copy_to_face_layoutto refill the array-of-six face buffers the Laplacian consumes. The core field can be passed tofft.forward/backwardon the same decomposition (comment in source).Padded brick layout, version 0 (minimum-OpenPFC consumer of
PaddedHaloExchanger):apps/heat3d/src/cpu/heat3d_fd_scratch.cpp—mpirun -np P ./apps/heat3d/heat3d_fd_scratch N n_steps dtruns the same heat equation with the only OpenPFC piece in the hot loop beingpfc::PaddedHaloExchanger<double>::exchange_halos. Everything else is bare triple loops over[0, n), manual paddedlin = (i+hw)*sx + (j+hw)*sy + (k+hw)*sz, raw pointer arithmetic, and a plainstd::vector<double>for the per-step Laplacian (no halo). Read this driver to see what the higher-level layouts hide.Padded brick layout, laboratory style (in-place ghost ring + comm/compute overlap):
apps/heat3d/src/cpu/heat3d_fd_manual.cpp—mpirun -np P ./apps/heat3d/heat3d_fd_manual N n_steps dtruns the same heat equation againstpfc::field::PaddedBrick<double>+pfc::PaddedHaloExchanger<double>withHeatModel::rhsand thefor_each_inner / for_each_border / for_each_ownedlambda iterators. The driver shows the explicit non-blocking overlap (start_halo_exchange→for_each_inner_omp→finish_halo_exchange→for_each_border→ Euler) and per-sectionpfc::runtime::tic/toctimers; seeapps/heat3d/README.mdfor the side-by-side comparison withheat3d_fd_scratchand the compactheat3d_fd.
11. References¶
External
Topic |
Reference |
|---|---|
Non-blocking halo |
Irecv → Isend → Waitall |
GPU-aware MPI |
CUDA/HIP-aware MPI when available |
MPI derived types |
|
Single source of truth for halo exchange under docs/.