Date: 2026-01-16 Module: comm Purpose: Comprehensive guide to BELFEM's MPI communication abstraction layer
Revision History:
Read this first: The BELFEM communication module has strict semantic contracts:
Rule: Calling any comm function before gComm.init() or after gComm.finalize() is undefined behavior.
The src/comm module provides a compile-time abstraction over MPI (Message Passing Interface) for parallel computing in BELFEM. It wraps MPI operations with type-safe C++ templates and supports both MPI builds and non-MPI builds (single-process fallback).
Important Limitations:
- Thread Safety: The comm module is not thread-safe. However, when compiled with BELFEM_STRUMPACK, MPI is initialized with MPI_THREAD_MULTIPLE. Concurrent calls from multiple threads still require external synchronization.
- Process Count: Maximum ~32,768 processes at a typical MPI_TAG_UB, set by the comm_tag() algorithm (see Process Count Limitation section).
- Message Size: Individual message chunks limited to int range (~2GB) even on 64-bit builds.
broadcast() resizes the receiving container itself, so this is not something you need to guard against:
The real hazard is the opposite one: reaching for broadcast on a payload large enough that its unchunked container transfer fails. For large or variable-size data use the share/receive pair documented below, which chunks.
File: Defined in cl_Communicator.hpp, declared extern
The framework provides a single global Communicator instance that manages all MPI state.
See: cl_Communicator.hpp:53
BELFEM's MPI initialization involves several automatic steps:
Pre-main Environment Setup: BELFEM automatically disables MPI process binding at load time (via GCC __attribute__((constructor))) to prevent conflicts with internal parallelism. This affects Open MPI, PRRTE, and MPICH Hydra. If you need specific binding, set these environment variables explicitly before launching mpirun.
Critical:
- gComm.init() must be called exactly once, before any comm function
- gComm.finalize() must be the last MPI-related call in main()
- Calling comm_rank() or any comm function before init() is undefined behavior
- Calling any comm function after finalize() is undefined behavior
Due to the comm_tag() algorithm, BELFEM has a maximum process limit based on your MPI implementation's MPI_TAG_UB value:
For a typical MPI_TAG_UB ≈ 2³¹ this gives approximately 32,768 processes. Exceeding it is a BELFEM_ERROR at initialization (cl_Communicator.cpp:219-224). (An earlier revision of this page said 46,340, which is √(2³¹) — the square root of the tag ceiling rather than the formula above applied to it.)
Tag Generation Formula: For source rank s and target rank t, the tag is computed as:
This ensures unique tags for each process pair but limits the maximum communicator size.
When BELFEM_PETSC is enabled:
For objects that manage MPI resources (e.g., persistent communicators, windows), inherit from CommunicationObject:
Objects are automatically registered with gComm.objects() upon construction and cleaned up when gComm.finalize() is called. This prevents MPI resource leaks in parallel environments.
File: commtypes.hpp
Template function that returns the MPI datatype for C++ type T.
| C++ Type | MPI Type |
|---|---|
| char | MPI_CHAR |
| signed char | MPI_SIGNED_CHAR |
| unsigned char | MPI_UNSIGNED_CHAR |
| short int | MPI_SHORT |
| unsigned short | MPI_UNSIGNED_SHORT |
| int | MPI_INT |
| unsigned int | MPI_UNSIGNED |
| long int | MPI_LONG |
| unsigned long | MPI_UNSIGNED_LONG |
| long long int | MPI_LONG_LONG |
| unsigned long long | MPI_UNSIGNED_LONG_LONG |
| float | MPI_FLOAT |
| double | MPI_DOUBLE |
| long double | MPI_LONG_DOUBLE |
| bool | MPI_CXX_BOOL |
| std::complex<float> | MPI_CXX_FLOAT_COMPLEX |
| std::complex<double> | MPI_CXX_DOUBLE_COMPLEX |
| std::complex<long double> | MPI_CXX_LONG_DOUBLE_COMPLEX |
Note: Non-MPI builds define comm_t as int and comm_type<T>() returns 0.
Limitation: comm_type<T>() supports only trivially copyable, contiguous types (primitives and std::complex). User-defined structs require manual MPI datatype definitions via MPI_Type_create_struct().
Returns: Total number of MPI processes (1 in non-MPI builds)
File: commtools.hpp:53
Returns: Rank of current process (0 to size-1), always 0 in non-MPI builds
File: commtools.hpp:62
Use case: Synchronize before timing, I/O, or phase transitions
File: commtools.hpp:80
Use case: Internal use by send/receive to avoid message confusion
File: commtools.hpp:124
Each rank pair shares exactly two tags (see the ordering contract on comm_tag() in commtools.cpp). If a send's matching receive is skipped, the failure does not surface where the send was issued; it silently poisons a later receive on the same tag. This check probes both fabric tags for every pair that includes the calling rank. If it finds a queued message, it raises BELFEM_ERROR on every rank, and the detecting ranks report the boundary label, source, tag, and byte count. An allreduce combines the local verdicts, so no rank is left waiting in a barrier and the debug throw policy stays consistent across ranks.
Use case: call this at exchange boundaries — points that every rank reaches with no exchange in flight. Current call sites: DofManager::solve, DofManager::solve_from_residual, DofManager::postprocess, SolverData::collect_matrices (entry), and Controller::finalize (exit).
Contract:
File: commtools.hpp:113
Returns: Cell containing chunk sizes for reliable large message transmission
Chunk size: Defined by gMaxCommChunkLength = 64*1024 elements of T (not bytes: 512 KiB for double)
Rationale: Chunking avoids implementation-dependent MPI limits on message size and improves robustness on older interconnects and debug builds. The 65 536-element limit balances message overhead with reliability.
File: commtools.hpp:134
Sends data from root process to all other processes. Non-root processes receive and resize automatically.
Broadcast Rule: Only the root rank's data is used. All other ranks' input values are ignored and overwritten. Non-root ranks do not need to pre-allocate containers — they are automatically resized.
Signature: void broadcast(T& aMessage, proc_t aRoot = 0)
Constraints: T must be arithmetic type
Signature: void broadcast(T* aMessage, proc_t aRoot, proc_t aLength)
Signature: void broadcast(Cell<T>& aData, proc_t aRoot = 0)
File: commtools.hpp:748
Signature: void broadcast(Vector<T>& aData, proc_t aRoot = 0)
File: commtools.hpp:1076
Signature: void broadcast(Matrix<T>& aData, proc_t aRoot = 0)
File: commtools.hpp:1879
Warning: Do not mix raw MPI_Send/MPI_Recv calls with BELFEM comm functions unless you manage tags explicitly. BELFEM assumes exclusive control of tag generation via comm_tag(), and mixing can cause extremely hard-to-debug message mismatches.
Signature: void send(const T aData, proc_t aTarget = 0)
File: commtools.hpp:314
Signature: void send(T* aData, index_t aLength, proc_t aTarget)
File: commtools.hpp:401
Signature: void send(Cell<T>& aData, proc_t aTarget = 0)
File: commtools.hpp:587
Signature: void send(Vector<T>& aData, proc_t aTarget = 0)
File: commtools.hpp:915
Signature: void send(Matrix<T>& aData, proc_t aTarget = 0)
File: commtools.hpp:1949
Signature: void receive(T& aData, proc_t aSource = 0)
File: commtools.hpp:351
Signature: void receive(T* aData, index_t& aLength, proc_t aSource)
Important: aLength is both input (allocated size) and output (received size)
File: commtools.hpp:491
Signature: void receive(Cell<T>& aData, proc_t aSource = 0)
File: commtools.hpp:672
Signature: void receive(Vector<T>& aData, proc_t aSource = 0)
File: commtools.hpp:1000
Signature: void receive(Matrix<T>& aData, proc_t aSource = 0)
File: commtools.hpp:2047
Symmetry Rule: distribute() is the inverse of collect() only if:
- Communicator size is unchanged between calls
- Container ordering is consistent across all ranks
- All ranks participate in both operations
distribute is a send-only operation. It sends data(p) to rank p for every p != comm_rank() and waits on its own sends; it posts no receives, so it never writes into the container it is given and delivers nothing unless someone is receiving.
It needs a matching receive somewhere. distribute and collect are not a fixed pair — they are a send-side and a receive-side helper over the same symmetric tag space (comm_tag(s,t) == comm_tag(t,s)), and either composes with the ordinary point-to-point calls:
- Scatter from one rank — the root calls distribute, every other rank calls the scalar receive(value, root).
- Gather to one rank — the workers call send(data, root) and the root calls collect.
Pattern 1 below uses both of these, one after the other.
- All-to-all — every rank calls distribute, then every rank calls collect. See the size caveat below before using this one.
An unmatched transfer is not harmless. The send may block, or it may sit queued and be picked up by a later exchange between the same rank pair, which share a tag. The distribute side must supply a container of size comm_size() (commtools.hpp:813); a receiver using the scalar receive supplies only its own variable.
Size caveat on the all-to-all form. distribute waits in MPI_Waitall before it returns, so every rank is still inside distribute when the matching receives ought to be posted. That only works while the sends complete into eager buffers. For distribute( Cell<T> ) — one element of T per peer — every MPI implementation in practice buffers that eagerly, though the standard guarantees nothing, which is why the shipped test (tests/comm/test_CommMPI.cpp, DistributeCollectScalar around line 581) passes. The Cell<Vector<T>>, Cell<Cell<T>> and Cell<Matrix<T>> overloads move arbitrary payloads and can exceed the rendezvous threshold, where distribute(); collect() on every rank deadlocks. Use a root-centred pairing for those, or post receives before sends with raw MPI.
Note that the received values land in the collect container, not back in the one passed to distribute — distribute never modifies its argument.
Signature: void distribute(Cell<T>& aData)
Requires: aData.size() == comm_size()
File: commtools.hpp:804
The overloads below take the same shape as the Cell<T> case above: each is send-only and needs a matching receiver — collect on every rank, or a per-rank receive of the corresponding type (distribute( Cell<Vector<T>> ) scatters against receive( Vector<T>, root ), not only the scalar form). None of them writes into the container it is given.
Signature: void distribute(Vector<T>& aData)
File: commtools.hpp:1131
Signature: void distribute(Cell<Vector<T>>& aData)
File: commtools.hpp:1239
Signature: void distribute(Cell<Cell<T>>& aData)
File: commtools.hpp:1325
Signature: void distribute(Cell<Matrix<T>>& aData)
File: commtools.hpp:2144
Signature: void distribute(const T* aData, const Vector<U>& aOffsets)
Requires: aOffsets.length() == comm_size() + 1
File: commtools.hpp:1414
collect is the receive-side helper. It resizes data to comm_size(), stores myValue in its own slot, and posts a receive against every other rank. It sends nothing, so it blocks until each of those ranks has sent — either from a matching distribute, or from an ordinary send(data, root), which is how Pattern 1 gathers worker results. collect on its own, with nobody sending, is a hang rather than a gather.
Signature: void collect(Cell<T>& aData, const T aMyValue = 0)
File: commtools.hpp:859
The overloads below share the semantics above: each is receive-only, and needs every other rank to be sending — through distribute, or through an ordinary send( data, root ) of the matching type. The one exception is collect( T*, offsets ), which is a narrower shape: it gathers sizes from everyone but receives payloads only from ranks 1 … N-1, taking its own offset from aOffsets(0) (commtools.hpp:1494-1565).
Signature: void collect(Vector<T>& aData, const T aMyValue = 0)
File: commtools.hpp:1186
Signature: void collect(Cell<Vector<T>>& aData, Vector<T> aMyData = {})
File: commtools.hpp:1577
Signature: void collect(Cell<Matrix<T>>& aData)
File: commtools.hpp:2287
Signature: void collect(T* aData, const Vector<U>& aOffsets)
File: commtools.hpp:1494
share sends the caller's vector to every other rank, in chunks. It is not collective: it posts sends only, so the ranks that are meant to get the data must call receive. Keep the if/else rank guard below — a rank that calls share when it should be receiving leaves every other rank waiting on data that never comes. (receive on the sender is harmless: it returns immediately when comm_rank() == aSource, commtools.hpp:1007-1008.)
Use this rather than broadcast for large or variable-size Vector<T> and Cell<T> payloads: container broadcast moves the whole payload in a single unchunked MPI_Ibcast after a second one carrying the size (commtools.hpp:1076-1120), which this project has observed to fail on large datasets (see doc/coding_philosophy.md). There is no share overload for scalars — use broadcast(T&) for those.
Signature: void share(Vector<T>& aData)
Signatures:
File: commtools.hpp:2443-2450 (implementation in commtools.cpp)
Caveat, unresolved. send( Vector<T> ) completes its own MPI_Waitall before returning (commtools.hpp:983). Two ranks that are each other's neighbors are therefore both inside the send loop before either reaches its receive loop. For payloads small enough to be sent eagerly this is fine, and it is what the code does today; for payloads over the MPI implementation's rendezvous threshold it can deadlock. Whether any BELFEM ghost exchange actually crosses that threshold has not been measured — treat the pattern as sound only for small ghost layers. For large symmetric exchanges, interleaving is not enough (both peers can still reach their send first): post the receives before the sends with non-blocking MPI calls, or use MPI_Sendrecv.
When compiled without BELFEM_MPI, all communication functions compile to no-ops:
Guarantee: Non-MPI builds compile to no-ops, not to serial equivalents — collect() leaves its container untouched. Use non-MPI builds for serial debugging, not for performance benchmarking.
This allows writing parallel code that degrades to serial execution, provided the caller sizes what collect() would have filled.
The comm module behavior is controlled by the USE_* CMake options (the BELFEM_* names are the compile definitions those options generate — they are not cache entries and setting them on the command line does nothing):
| CMake Option | Generated define | Effect | Default |
|---|---|---|---|
| USE_MPI | BELFEM_MPI | Master switch for MPI functionality | ON |
| USE_STRUMPACK | BELFEM_STRUMPACK | MPI_Init_thread() with MPI_THREAD_MULTIPLE (cl_Communicator.cpp, search MPI_Init_thread) | ON |
| USE_PETSC | BELFEM_PETSC | PETSc integration (auto-initialize) | ON |
Both are correct; the second overlaps its receives instead of serializing them. Note the rank guard is still needed — collect receives only, so calling it on every rank leaves nobody sending.
Note: BELFEM's comm layer uses non-blocking internally but waits immediately. For overlap, use raw MPI.
BELFEM's thread support depends on compile-time configuration:
| Build Configuration | MPI Thread Level | Behavior |
|---|---|---|
| Default (no threading libs) | MPI_Init() | Single-threaded MPI (MPI_THREAD_SINGLE) |
| With BELFEM_STRUMPACK | MPI_Init_thread(..., MPI_THREAD_MULTIPLE, ...) | Requests highest thread support |
| With BELFEM_PETSC but without BELFEM_STRUMPACK | MPI_Init() | Single-threaded; STRUMPACK, when present, decides the level regardless of PETSc |
Important: When BELFEM is compiled with STRUMPACK support, it initializes MPI with MPI_THREAD_MULTIPLE (the highest thread support level). If the MPI implementation cannot provide this level, a warning is printed to stderr, but initialization continues with the highest available level.
Critical: Do NOT call any BELFEM comm function from multiple threads concurrently, even when MPI_THREAD_MULTIPLE is initialized.
Why? Every rank pair shares exactly two tags on gComm.world() (comm_tag() is symmetric in source and target) and MPI matches per (source, tag) in FIFO order, so two threads issuing BELFEM comm calls to the same peer interleave their size/payload messages and each consumes the other's. The per-call request buffers themselves are malloced per call and private.
Safe threading approaches:
Warning: Even with MPI_THREAD_MULTIPLE, BELFEM's comm functions are not internally synchronized. External synchronization is always required for concurrent access.
All BELFEM comm functions route the MPI return code through comm_check(error_code), a BELFEM_ERROR that reports the implementation's error string — it throws in a debug build and aborts the job in release (see assert.hpp, throw_on_error()).
Use MPI_Wtime() to detect hangs:
Start debugging with 2 processes, then scale up:
Enable runtime diagnostics to catch tag mismatches and communication errors:
Tip: MPI verbose flags vary by implementation. Check your MPI documentation for specifics.