Building from Source
Build the Python bindings and the C++ unit-test tree, with or without MPI.
monoprop has one supported from-source build workflow:
- the Python bindings — the nanobind extension behind
import monoprop, built with scikit-build-core and driven byuv(orpip); - the C++ unit tests — built within the same
uv(orpip) invocation.
MPI is off by default in every build path; you enable it explicitly. The mechanism differs by build:
| Build | Enable MPI with |
|---|---|
Python bindings and C++ tests (scikit-build / uv / pip) | monoprop_ENABLE_MPI=ON in the environment |
The prebuilt wheels published to PyPI (pip install monoprop) are also built
without MPI, so a from-source build is required for multi-rank runs.
Prerequisites
-
a C++23-compliant compiler; on Linux the minimum supported versions are GCC 14 and Clang 18. GCC builds pass
-ffp-contract=on, so GCC, like Clang by default, fuses a multiply and an add only within one expression; the test suite's bitwise comparisons between code paths rely on it. A project that compiles the installed headers itself uses its own contraction setting. -
CMake and Ninja
-
Python 3.11 or newer and the
uvpackage manager (for the bindings) -
an MPI implementation such as Open MPI (only for MPI builds)
-
OpenMP for C++, in every build — with or without MPI, and even when you only run one thread; there is no OpenMP-disabled build. Use the runtime that matches your compiler:
- GCC ships its own
libgomp; nothing extra is needed; - Clang on Linux needs the
libompof the same LLVM version, e.g.libomp-18-devforclang++-18on Debian/Ubuntu; - Apple Clang needs Homebrew's
libomp. It is keg-only, so point CMake at it withexport OpenMP_ROOT="$(brew --prefix libomp)"before building, and before configuring a project that consumes the installed package.
The library calls the OpenMP runtime itself, so both
libmonopropand the Python extension link it. The runtime therefore starts when monoprop loads. If you setOMP_PLACESand leave binding enabled, it binds the importing thread to the first place at that point. That is OpenMP applying your placement request, not monoprop: monoprop never changes affinity. SetOMP_PROC_BIND=falseif you want no binding. - GCC ships its own
The repository ships a DevContainer with all of the above pre-configured; opening the folder in VS Code and rebuilding the container is the quickest route to a working environment.
Building with Nix
The repository is also a Nix flake:
Flake outputs support x86_64 Linux, aarch64 Linux, and aarch64 macOS. The
nixos-unstable input no longer supports x86_64 macOS.
| Command | What it gives you |
|---|---|
nix develop | a shell with every prerequisite above, plus uv, just and Node.js |
nix build .#monoprop | the Python package, built without MPI |
nix build .#monoprop-mpi | the same package with monoprop_ENABLE_MPI=ON |
nix build .#monoprop-py313 | the package for a specific interpreter: py312, py313 or py314, with a -mpi suffix for the MPI build |
nix run | a Python interpreter with monoprop importable |
The packages and the shell take OpenMP from the compiler: GCC's libgomp on Linux,
and llvmPackages.openmp wherever the standard environment's compiler is Clang
(macOS).
The unsuffixed packages, nix run and nix develop use Python 3.14, the
interpreter nixpkgs builds and caches; older interpreters are compiled locally.
Inside nix develop the uv sync and just workflows below apply unchanged. The
shell sets two variables that only matter on NixOS: UV_PYTHON_PREFERENCE=only-system,
because uv's managed interpreters expect a loader NixOS does not provide, and
LD_LIBRARY_PATH, so that manylinux wheels can resolve libstdc++.
The packaged build deviates from the uv build in three places, all in
nix/monoprop.nix:
- the C++ unit tests are disabled, because they resolve msgpack-cxx through a git fetch that the build sandbox denies — build them from the dev shell instead;
monoprop_ENABLE_ARCH_FLAGSis off, since a store path may be substituted onto a machine other than the one that built it; pass.override { enableArchFlags = true; }for a native build;- the version comes from the tracked root
VERSIONfile, because setuptools-scm cannot read git metadata inside the sandbox; update it to the stable version when preparing a release. The release workflow rejects tags that do not match it.
Using monoprop downstream with Nix
Use the exported overlay when monoprop must share a downstream project's Python
and native dependency package set. Following the downstream nixpkgs input avoids
introducing a second Python, Boost, OpenMP, or MPI closure:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
monoprop = {
url = "github:Algorithmiq/monoprop";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, monoprop, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ monoprop.overlays.default ];
};
python = pkgs.python313.withPackages (ps: [
ps.monoprop
ps.numpy
]);
in {
devShells.${system}.default = pkgs.mkShell {
packages = [ python ];
};
};
}The overlay adds monoprop, monoprop-mpi and nanobind-backend to every Python
package set through pythonPackagesExtensions, so ps.monoprop works for any
interpreter in withPackages. On Python 3.12 it also skips mpi4py's test suite,
because the binary cache has no mpi4py for that interpreter and its tests add
minutes to every build. CI evaluates the packages for Python 3.12, 3.13 and 3.14
but builds only Python 3.12, the interpreter the binary cache covers least. The
top-level pkgs.monoprop and pkgs.monoprop-mpi are the Python 3.14 builds.
For consumers that follow their own nixpkgs input, its nanobind_3 version must
match the nanobind pin in pyproject.toml (currently 3.1.0). For consumers that
do not need to compose package sets, the pre-built flake outputs remain available
as monoprop.packages.${system}.monoprop, monoprop.packages.${system}.monoprop-mpi
and the per-interpreter monoprop-py3XX[-mpi] variants. Consume these entrypoints from the Git
repository flake; flake.nix, flake.lock, and nix/ are deliberately excluded
from the Python source distribution.
Building the Python bindings
uv creates a virtual environment, installs the Python dependencies, and
compiles the nanobind extension in editable mode. Re-run the sync command whenever
the dependency graph or the C++ sources change.
Without MPI (default)
uv sync --all-extras -vThis produces a single-process build with no MPI dependency.
With MPI
Set monoprop_ENABLE_MPI=ON in the environment:
monoprop_ENABLE_MPI=ON uv sync --all-extras -vIt works the same with pip when installing from a checkout:
monoprop_ENABLE_MPI=ON pip install .That one variable does two things, matched by the
[[tool.scikit-build.overrides]] block in pyproject.toml: it sets the
monoprop_ENABLE_MPI CMake option, and it adds mpi4py to the build
requirements. mpi4py is a build-time input — the bindings need its headers to
hand an MPI communicator across the nanobind boundary — but only for MPI
builds, so it is injected per-build through PEP 517's
get_requires_for_build_wheel rather than sitting in build-system.requires
where every build would pay for it.
Because it is an environment variable, it does not participate in uv's build
cache key. Add --reinstall-package monoprop --no-cache when flipping MPI on
or off in an existing environment, or uv may reuse the previous build.
Driving it with config-settings instead
A scikit-build-core override can only match environment variables, never
config-settings, so cmake.define alone will not pull in mpi4py — the build
would fail at configure time with a message telling you as much. Pass both
settings to use this route:
uv sync --all-extras -v \
--config-settings=cmake.define.monoprop_ENABLE_MPI=ON \
--config-settings=build.requires=mpi4py>=4.1.0In a workspace, prefer the per-package form so the settings do not reach the
sibling distributions in packages/:
uv sync --all-extras -v \
--config-settings-package="monoprop:cmake.define.monoprop_ENABLE_MPI=ON" \
--config-settings-package="monoprop:build.requires=mpi4py>=4.1.0"The cost of this route is that the mpi4py pin is repeated on the command line
instead of living once in pyproject.toml; the environment switch is preferred
for that reason.
Verify the install
uv run python -c "import monoprop as mp; print(mp.__version__)"Running the bindings
A serial run is just a normal Python invocation:
uv run python your_script.pyFor a multi-rank run, launch the same script under mpiexec (requires an MPI
build) and pass comm=MPI.COMM_WORLD to the simulator:
mpiexec -n 8 uv run python your_script.pySee Parallelism and distribution for the communicator options, the thread budget and the launch configuration.
Building the C++ unit tests
The supported C++ workflow reuses the build tree produced by uv sync. Do not
run cmake --preset ... to configure this project directly: the top-level CMake
configuration expects scikit-build-core to provide Python, nanobind, and related
cache variables. Instead, first create the tree with uv sync, then invoke ctest
directly to run the C++ unit tests.
Release tree
uv sync --all-extras -v
ctest --test-dir build/editable/ReleaseThis uses the scikit-build-core Release tree at build/editable/Release and
runs bin/monoprop_unit_tests.x there, along with bin/monoprop_link_export_probe.x — a
link-time check that the installed shared monoprop library exports every detail/** free
function reachable from the public template chain. The installed CMake target records whether
monoprop itself was built with MPI; a serial package does not acquire MPI compile definitions or
link dependencies merely because the consuming project already has an MPI::MPI_CXX target.
just test-find-package goes one step further out: it builds
cpp/tests/find_package_smoke — a standalone project that consumes the installed
package through find_package(monoprop CONFIG) — and runs it. That is the only leg that
exercises monopropConfig.cmake, so it is what catches a missing find_dependency or a
usage requirement the package fails to export. It compiles the same source as the in-tree
probe, so the two cannot drift. The probe drives both the Majorana and the Pauli basis and
instantiates the internal OpenMP worksharing template in its own translation unit, so the
consumer only builds if the imported monoprop::monoprop target carries OpenMP's compile
and link flags — the consumer project adds none of its own. Its constructor calls reach the library's out-of-line
thread-budget and MPI thread checks, so an unexported helper fails the consumer's link. The consumer also checks
that it inherited the build's MPI setting and no runtime selector: pass -DSMOKE_EXPECT_MPI=ON|OFF to make a
mismatched package fail to compile. It checks the constructor's shape at compile time, runs every public operation
family, an ordinary derived class through the base interface and the raw-access rule (at one and at two threads),
and, when launched on several ranks with monoprop_TEST_EXPECT_RANKS set, compares the root on MPI_COMM_WORLD
with one process's. It also runs the
header-only fixed-team phase primitive (detail/sharded/Team.h), whose barriers must compile through
the same imported OpenMP flags, and the owner-initialized shard state (detail/sharded/State.h) and the
direct-buffer construction engine (detail/sharded/Construction.h), each from a separate translation unit that
includes only that header, so the installed headers must be self-contained. A further translation unit starts with
the sharded evaluation header (detail/sharded/Evaluation.h) and calls the exported ev_sharded and
ev_and_grad_sharded at run time, after a coefficient-informed build whose seed replay crosses the shared-library
boundary.
The exported low-level entry points (evolve_step, evolve_operator and
state_operator_derivative_local) take a trailing thread budget with a serial default. Removing the
partition runtime changed the MonomialPropagator constructor and layout, mpi::Comm, and the installed
headers (the partition, topology and in-process communicator headers are gone; see
Parallelism and distribution). Rebuild C++ consumers, libmonoprop and the Python
bindings together, from the same monoprop build, and install into a fresh prefix: an old prefix keeps the
deleted headers.
monoprop links no topology library: the package config's dependencies are Boost, Threads, OpenMP and, in an MPI build, MPI. An MPI or OpenMP implementation may itself use hwloc; that is its own dependency.
Debug tree
uv sync --all-extras -v --config-settings=cmake.build-type=Debug
ctest --test-dir build/editable/DebugSanitizer trees
The QA workflow builds two sanitizer profiles, selected with monoprop_SANITIZER
(none, asan-ubsan, or tsan). Both require Linux and either GCC or Clang;
CI uses the default system GCC, which is what builds the released wheels.
Sanitizer trees intentionally skip the _core.pyi typing stubs — generating them
means importing the instrumented extension, which is not worth arranging for a
tree that is never packaged.
ASan + LSan + UBSan
SKBUILD_CMAKE_BUILD_TYPE=AsanUbsan \
SKBUILD_CMAKE_DEFINE="monoprop_SANITIZER=asan-ubsan" \
uv sync --group workspace-test --all-extras --reinstall-package monoprop --no-cache -v--reinstall-package monoprop --no-cache matters locally. uv decides whether to
rebuild from the cache-keys in pyproject.toml, which hash source files — not
environment variables. So if you already have an editable install, switching only
the build type leaves the sources unchanged, uv sync does nothing, and
build/editable/AsanUbsan is never created (the ctest below then fails with
"No such file or directory"). CI omits these flags because each job starts on a
clean runner with nothing installed.
The C++ test binary is fully instrumented, so every check applies to it, including leak detection:
just test-cpp-asanThe Python tests are a different situation: an instrumented _core is loaded
into an ordinary CPython, so the ASan runtime must be preloaded, and the checks
that assume the whole process is instrumented have to be switched off.
just test-py-asanBoth preloads are required. Without libasan.so, the first import monoprop
aborts with ASan runtime does not come first in initial library list. Without
libstdc++.so.6, ASan initialises before libstdc++ is loaded — python is not
linked against it — so ASan's __cxa_throw interceptor never resolves the real
symbol, and the first C++ exception thrown out of the engine dies with
CHECK failed: real___cxa_throw != 0. monoprop reports validation errors as C++
exceptions, so that affects a large part of the suite.
Leak detection is off here because LSan cannot get a clean baseline from an uninstrumented interpreter — the C++ leg above is what covers leaks.
Sanitizer reports are hidden by pytest
pytest replaces the stderr file descriptor, so a sanitizer report printed from
native code is swallowed and the run looks like a bare exit code 1 with no
diagnostic. just test-py-asan therefore sets log_path, routing the reports to
sanitizer-log.*; read them with:
just sanitizer-reportsThe QA workflow runs that recipe on failure.
ThreadSanitizer
SKBUILD_CMAKE_BUILD_TYPE=Tsan \
SKBUILD_CMAKE_DEFINE="monoprop_SANITIZER=tsan" \
uv sync --group workspace-test --all-extras --reinstall-package monoprop --no-cache -v
just test-cpp-tsanjust test-cpp-tsan is scoped to the concurrency surface, which is where its reports are worth the runtime cost:
the sharded runtime's team, state, construction, evaluation, exchange and root tests (sharded_), the fused-record
launches (fused_env_t*), every openmp_* test (the workshare primitive, the threaded kernels, scan and resolve
phases, and the budget launches), the multi-shard coefficient launches (evolved_coefficients_env_t*) and the threaded
lazy-fold checks (combined_threaded_*). The OpenMP caveat below applies to all of them,
and CI runs this leg with Clang, libomp and Archer for that reason.
The OpenMP kernels (openmp_* tests) need an OpenMP runtime that TSan understands. GCC's libgomp is
not instrumented, so TSan reports false races even on correct parallel loops; do not read its
output as evidence either way. Clang with LLVM's libomp and its Archer tool works, with the runtime's
own internal locking suppressed:
CXX=clang++ CC=clang SKBUILD_CMAKE_BUILD_TYPE=Tsan \
SKBUILD_CMAKE_DEFINE="monoprop_SANITIZER=tsan;monoprop_ENABLE_MPI=OFF" \
uv sync --group workspace-test --all-extras --reinstall-package monoprop --no-cache -v
OMP_TOOL_LIBRARIES="$(clang++ -print-resource-dir)/../../libarcher.so" \
TSAN_OPTIONS="suppressions=$PWD/.github/tsan-libomp.supp" \
just test-cpp-tsanCheck a new toolchain first with a small known-racy and known-safe OpenMP program: the safe one must
stay silent and the racy one must be reported in your own frames. The Archer path varies by
distribution (on Debian and Ubuntu, /usr/lib/llvm-<version>/lib/libarcher.so).
There is no ThreadSanitizer equivalent of the ASan Python leg. TSan does not
support dlopen of instrumented libraries, so loading an instrumented _core
into a stock interpreter crashes during TSan initialisation; exercising the
Python API under TSan would require a TSan-instrumented CPython.
Related workflows
- Use
just code-coveragefor the coverage build. - Use
just test-cppandjust test-cpp-mpifor the two C++ legs, orctest --test-dir build/editable/Release -L mpi-2(or-L mpi-4) to filter further. - Use
just build [uv sync args…]when you want the build CI performs. It adds--all-extrasand, for a serial build, drops thempiextra — importingmpi4py.MPIwithout an MPI runtime raises.monoprop_ENABLE_MPIdrives all of it, somonoprop_ENABLE_MPI=ON just build --group workspace-testis the MPI build and a barejust buildis the serial one. - The platform packages the build needs are listed in
tools/packages/, one per line:apt.txt/brew.txt, plusapt-mpi.txt/brew-mpi.txtfor an MPI build..github/actions/setupand.devcontainer/Dockerfileboth install from those files, so there is one list per platform to keep current.
The runtime a build contains
There is one runtime and no build option that selects another: a default build is the sharded OpenMP runtime
(Parallelism and distribution). Its extension carries a read-only identity, the attribute
_core.__runtime_identity__ and the same NUL-terminated literal in the extension file:
uv run --no-sync python -c 'from monoprop import _core; print(_core.__runtime_identity__)'
# monoprop-runtime=sharded-openmp
just check-runtime # attribute, literal, MPI setting, and no stale selector in the C++ treeThe parity driver and the benchmark preflight read both, so evidence names the binary it came from. Builds from
before the partition runtime was removed carried monoprop-runtime=sharded-openmp-prototype (the development
selector monoprop_SHARDED_OPENMP_PROTOTYPE=ON) or monoprop-runtime=legacy-partitions, and the preserved
partition baseline carries none; the tools still read those for archived evidence. A build tree configured before
the removal keeps a monoprop_SHARDED_OPENMP_PROTOTYPE cache entry that no longer does anything: delete the tree.
Linux x86_64 with GCC/libgomp, Clang/libomp, ASan/UBSan and Clang TSan with Archer, with and without MPI, has been run. macOS, Linux aarch64, wheels from CI, Nix and the minimum compiler versions are pending their own runs.
See also
- Getting Started — installing a prebuilt release from PyPI.
- Parallelism and distribution — running across MPI ranks and shared-memory threads.
- Testing — the full Python and C++ test workflow.
- How to Contribute — contributor workflow and documentation checks.