monoprop

Parallelism and distribution

Scaling by partitioning the operator across cores and across MPI ranks.

monoprop scales the same way on one node and on many: it partitions the operator into disjoint pieces and applies each gate to every partition in lock-step, exchanging only the terms that cross a partition boundary. Two axes compose:

  • Across cores (default): one single-threaded partition per physical core, within a single process. This is on automatically, and can be configured with environment variables (see below).
  • Across nodes (opt-in): MPI ranks, each of which partitions further across its own cores. Distributing the operator and its graph across ranks lets you simulate and variationally optimise systems larger than one node's memory.

Operator partitioning (single node)

By default monoprop splits the operator into one partition per physical core and gives each partition a pinned worker thread that runs it serially. Each partition keeps its own small, cache-resident term index; a gate is applied to all partitions at once, synchronised by a lightweight barrier, and anticommuting terms whose partner lives in another partition are resolved through a per-gate exchange.

A partition addresses its own terms with a 32-bit index, so one partition holds at most 2^32 (about 4.3 billion) terms. That is a few hundred gigabytes of resident operator in a single partition, so the ceiling is reached by running too few partitions rather than by problem size, and it is not a build option. An append that would cross it is refused before it happens — a RuntimeError naming the ceiling and the term counts, with the operator left as it was, rather than a silent wrap. The fix is more partitions or more MPI ranks — both divide the terms per partition — or a larger lower_atol (see Truncation and cutoffs).

Runtime environment variables

VariableDefaultMeaning
monoprop_NUM_THREADSone partition per physical coreCaps the number of partitions. Set it to run fewer partitions than cores.
monoprop_PARTITIONSautoauto = one partition per core (capped by monoprop_NUM_THREADS); an integer N = exactly N partitions; off = one partition holding the whole operator.

Which MPI rank owns a term is set separately; see Rank routing.

# Run 8 partitions instead of one-per-core:
export monoprop_NUM_THREADS=8

Placement report

Building a propagator writes one COMMPLACE line per rank to stderr, naming the CPUs the launcher gave that rank and whether co-located ranks got disjoint masks. It is report-only — no placement decision reads it — and there is no knob: redirect stderr to drop it.

COMMPLACE rank=0 node_rank=0 node_size=2 masks=private cpus=64 node_cpus=128 cpu_list=0-63

masks is private when the co-located ranks' affinity masks are pairwise disjoint, shared when two ranks can land on the same CPU, alone when this rank is the only one on its host (which is not evidence a multi-rank launcher bound correctly), and unknown when a mask did not fit the exchanged window.

Pinning each partition to a core is not configurable: leaving placement to the launcher measured propagate[hubbard] 2.90x slower, so the disabled arm is gone.

MPI distribution (multi-node)

MPI partitions the operator and graph across ranks, composing with per-rank partitioning into one flat world of R × S partitions (R ranks, S partitions each). MPI communication is serialised through each rank's first partition, bracketed by the intra-rank barriers.

Rank routing

A gate with generator GG maps M↦M⊕GM \mapsto M \oplus G. Ranks are assigned by a GF(2)-linear hash h(M)=⨁i∈supp(M)vih(M) = \bigoplus_{i \in \mathrm{supp}(M)} v_i, keeping its low d=log⁡2Rd = \log_2 R bits hdh_d, so

h(M⊕G)=h(M)⊕h(G).h(M \oplus G) = h(M) \oplus h(G).

Every query a rank sends for GG therefore goes to the single peer r⊕hd(G)r \oplus h_d(G), whatever RR is. XOR is an involution, so both ends derive the pairing without communicating, and the exchange runs point-to-point. Partitions within a rank keep the splitmix hash qq, because shared-memory fanout is free:

flat(M)=hd(M) S+(q mod S).\mathrm{flat}(M) = h_d(M)\,S + (q \bmod S).
  • RR must be a power of two with log⁡2R≤min⁡(2N,64)\log_2 R \le \min(2N, 64). Otherwise MajoranaPropagator and PauliPropagator raise at construction; use monoprop_ROUTING=splitmix for other rank counts. R=1R = 1 is the dense case.
  • The basis is drawn full rank, so ranks are balanced whenever the circuit's shifts hd(G)h_d(G) span all dd bits. If they span fewer, some ranks stay empty, and a COMMROUTE line on stderr says so.

A related scheme sums kk-bit blocks modulo the rank count (Broers et al., 2026). That sum is not linear over XOR, so a gate's queries still reach several ranks rather than one.

VariableDefaultMeaning
monoprop_ROUTINGlinearlinear routes as above; splitmix keeps the dense all-to-all. Any other value raises.
monoprop_ROUTE_SEED6768574230969066775Decimal uint64 seed for the basis {vi}\{v_i\}.

Both variables, and the partition count, must match on every rank. A mismatch would deadlock the exchange, so it is checked collectively at startup and raised.

Serial or MPI backend

monoprop picks the backend per propagator. comm=None runs serially, whatever the installation; an mpi4py communicator runs on the MPI backend, which monoprop loads the first time it is needed. Importing monoprop never imports mpi4py or initializes MPI. Under mpiexec, a propagator built without comm therefore runs serially on every rank: pass comm=MPI.COMM_WORLD to distribute it.

MPI_ABIS lists the MPI ABI families the installation can drive; it is empty without MPI support. The MPI backend matches the family of the MPI library mpi4py uses. Passing a communicator to an installation without MPI support raises MPIUnavailableError; a family the installation was not built for raises MPIAbiMismatchError, and an MPI library older than the supported versions (see Enabling MPI) raises MPIVersionError.

Single-node (MPI.COMM_SELF)

from mpi4py import MPI
sim = MajoranaPropagator(..., comm=MPI.COMM_SELF)

Multi-node (MPI.COMM_WORLD)

Replace the communicator and launch with mpiexec:

from mpi4py import MPI
sim = MajoranaPropagator(..., comm=MPI.COMM_WORLD)
mpiexec -n 8 uv run python your_script.py

A pure-MPI rank keeps its whole share in one partition unless monoprop_NUM_THREADS is set, so an MPI user who has not asked for threads gets one partition per rank.

Enabling MPI

The PyPI wheels ship an MPI backend for the MPICH family (MPICH, Intel MPI, MVAPICH, Cray MPICH) and for Open MPI, and use the MPI library mpi4py finds on the machine. Install mpi4py with the mpi extra; on a machine without MPI, add an implementation from PyPI as well:

pip install "monoprop[mpi]" mpich     # or openmpi

Supported MPI versions, from the version below up to the latest release:

ImplementationABI familyMinimum
Open MPIopenmpi4.1
MPICHmpich4.0
Intel MPImpich2021.0
MVAPICHmpich3.0
Cray MPICHmpich8.1

An older version is rejected when the MPI backend loads (MPIVersionError) and when CMake configures a from-source build or a C++ consumer; an implementation not in the table runs with a warning. Open MPI 4.1, MPICH 4.0 and Intel MPI 2021.1 are tested in CI at those versions; MVAPICH and Cray MPICH are not.

On Cray systems, load cray-mpich-abi so that libmpi.so.12 is available, as mpi4py requires. From-source builds enable MPI with the monoprop.mpi option; see Building from source.

On this page