MPI & interconnects#
Threads scale you within one machine. To use many machines — a cluster — you need MPI, the distributed-memory model from the primer. This chapter introduces MPI from zero, shows the two ways qc-rs uses it, and covers the part that trips everyone up on a real cluster: the interconnect (the network between machines) and how to select it.
What MPI is#
MPI (Message Passing Interface) is the standard for distributed-memory parallelism. An MPI job launches
several processes — called ranks, numbered 0, 1, 2, … — possibly on different machines. Each rank
has its own private memory and runs the same program; to share data, ranks send messages to one
another over the network. This is the primer’s distributed-memory model made
concrete:
Ranks are the unit of work (one rank ≈ one CPU core group, often one per NUMA domain).
Each rank holds only part of the big data (e.g. its slab of the integrals), so the whole problem can be far larger than one machine’s memory.
Ranks coordinate with collectives (all ranks combine a value — a sum, a broadcast) and point-to-point messages.
qc-rs uses MPI to distribute the memory-hungry integral/Fock work across ranks so a calculation too big for one node fits across several.
Two ways to run MPI in qc-rs#
1. run(nmpi=..., hosts=...) — qc-rs relaunches for you#
The easy path: ask .run() for ranks, and qc-rs relaunches itself under mpirun in a subprocess:
import qc
m = qc.chk.new(atom="...", ao="cc-pvtz")
m = m.scf(ref="r").run(nthread=8, nmpi=4, hosts="nodeA:2,nodeB:2")
nmpi= number of ranks (nmpi=1, the default, runs in-process — no MPI).nthread= cores per rank (required whennmpi>1).hosts= where the ranks go:None(all local),"nodeA:2,nodeB:2"(explicit slots), or"nodeA,nodeB"(even split).
nmpi>1 prints a parallel-plan banner — total ranks, threads per rank, nodes, and a per-node table
showing the budget ranks × nthread against the node’s core count, flagging oversubscription (⚠).
Oversubscription is an error by default (the parallel.strict IOP; set False to only warn). Keep
ranks_per_node × nthread ≤ cores_per_node, exactly as the threads chapter warned.
2. External mpirun + comm= — you launch, qc-rs joins#
The HPC path: you launch the job with mpirun/mpiexec yourself and hand qc-rs the communicator:
import mpi4py
mpi4py.rc.thread_level = "serialized" # REQUIRED for the UCX PML — see caveats
from mpi4py import MPI
import qc
m = qc.chk.new(atom="...", ao="cc-pvtz")
m = m.scf(ref="r").run(nthread=8, comm=MPI.COMM_WORLD, log_rank="root")
Run it with mpirun -np 4 python script.py. With a comm, nmpi is ignored (the launcher already set the
ranks). log_rank controls which ranks print: "root" (default, rank 0 only), "all", "gather", or a
list — this keeps a 128-rank job from flooding the output.
Tip
Which to use
nmpi=/hosts= is convenient for a laptop or an interactive node. The comm= path is standard on a
cluster with a scheduler (PBS/Slurm), where the job script already runs under mpirun with the right host
list. Cross-host nmpi= runs additionally need a shared filesystem for the relaunch temp files.
The interconnect: why the network matters enormously#
On a cluster the ranks talk over a network, and its speed can dominate a communication-heavy step. Two kinds matter:
Ethernet / TCP — ordinary networking. Ubiquitous but slow (~0.1 GB/s on 1 GbE), and MPI’s auto-selection sometimes silently falls back to it.
InfiniBand (IB) — a high-performance interconnect with RDMA (remote direct memory access). Roughly 35× the bandwidth of 1 GbE (measured ~3.9 GB/s here), and the right choice whenever ranks exchange large arrays.
Pick the transport explicitly — leaving it to auto-selection can hang or quietly use slow Ethernet. The three OpenMPI recipes below are the ones that work in practice:
# (1) InfiniBand (UCX) — the fast cross-node path (~35x TCP)
mpirun --mca pml ucx \
-x UCX_TLS=rc_verbs,ud_verbs,sm,self -x UCX_NET_DEVICES=mlx4_0:1 \
-x PATH -x LD_LIBRARY_PATH -x MKL_THREADING_LAYER \
--host nodeA:128,nodeB:128 -np <N> python script.py
# (2) TCP fallback (no IB / IB down)
mpirun --mca pml ob1 --mca btl tcp,sm,self --mca btl_tcp_if_include eno1 \
-x PATH -x LD_LIBRARY_PATH -x MKL_THREADING_LAYER \
--host nodeA:128,nodeB:128 -np <N> python script.py
# (3) Shared-memory only (single node; also the safe path when the network is broken)
mpirun --mca pml ob1 --mca btl self,sm -np <N> python script.py
Check the link first: cat /sys/class/infiniband/*/ports/*/state should read ACTIVE.
The caveats that will otherwise hang you#
These are the non-obvious failures a real cluster throws — each documented from hard experience:
mpi4py must init at
MPI_THREAD_SERIALIZEDfor the UCX PML. mpi4py defaults toTHREAD_MULTIPLE, which this UCX cannot select (“PML UCX could not be selected”). Putimport mpi4py; mpi4py.rc.thread_level = "serialized"beforefrom mpi4py import MPI. qc-rs only calls MPI from the rank’s main thread, so serialized is enough.UCX RC needs the
udauxiliary transport.UCX_TLS=rc_verbs,sm,self(noud_verbs) fails with “no auxiliary transport … Destination is unreachable”. Always includeud_verbs.Forward the environment with
-x. Remote ranks do not inherit your login shell, so pass-x PATH -x LD_LIBRARY_PATH -x MKL_THREADING_LAYER(keepMKL_THREADING_LAYER=GNU)./tmpis node-local. Cross-node scripts and scratch must live on a shared filesystem ($HOME//home1), not/tmp.Bind ranks to NUMA nodes when packing several ranks per host:
--map-by numa:PE=<threads> --bind-to core. The default--bind-to nonethrashes and looks like a hang.InfiniBand needs a high
memlockulimit (RDMA pins memory). Batch jobs often inherit a low cap (ibv_reg_mr ... Cannot allocate memory); the admin fix raisesLimitMEMLOCKon the compute nodes.
qc-rs supports OpenMPI, MPICH, and MVAPICH (all can drive InfiniBand via UCX); rebuild mpi4py
against whichever you use. The exhaustive per-cluster recipes live in the README’s HPC appendix.
Under the hood: distributed memory done right#
qc-rs does not naively broadcast whole matrices. Its distributed backends build on a one-sided RMA (remote
direct memory access) layer: each rank fills its own slab in place and serves it to others with no copy,
streams one remote block at a time instead of reconstructing a whole factor, and reduces the small aux
intermediate rather than the big tensor. The payoff is that per-rank memory scales as total / nranks — which
is how RI-JK and RI-MP2 break the “one node’s RAM” wall. You do not manage any of this; it is why the same
eri="ri-ram" calculation that needs one big node can instead spread across several small ones.
Exercise 18
Your 2-node MPI job runs, but no faster than a single node, and
topshows the network barely used. Name two likely causes from this chapter.Why can an MPI run tackle a molecule whose integrals do not fit in one machine’s RAM, while adding threads cannot?
A cluster job hangs at start with no error. You suspect the interconnect. What is the first single-line check, and what is the safest transport to fall back to?
Solution to Exercise 18
(a) MPI silently fell back to TCP/Ethernet instead of InfiniBand (transport not selected explicitly), so cross-node messages are slow; and/or (b) the job is communication-bound / too small so Amdahl caps the gain. Also check ranks aren’t oversubscribed or unbound (
--bind-to none).MPI is distributed memory — each rank holds only part of the integrals, so the whole (larger than one node) is spread across nodes. Threads are shared memory, bounded by a single machine’s RAM, so they cannot exceed it.
cat /sys/class/infiniband/*/ports/*/state(should beACTIVE). The safest fallback is shared-memory-only--mca pml ob1 --mca btl self,sm(single node), or TCP with an explicit interface for cross-node.
CPUs — threaded and distributed — are one road to speed. The other is the GPU.