mm is a build orchestration framework for projects that mix C, C++, Fortran, CUDA, and Python. You declare what you are building in a small configuration file; mm discovers your sources, resolves external dependencies, and drives GNU make to compile, link, and install everything in parallel.
mm has no generated makefiles. Its build logic lives in a library of make
fragments that GNU make loads at run time. There is nothing to regenerate when
you add a source file or change a dependency — running mm is always
sufficient.
Each project has a .mm/ directory at its root. The top-level file is named
after the project:
# .mm/myproject.mm
myproject.libraries := mylib.lib
myproject.packages := mylib.pkg
myproject.extensions := mylib.ext
myproject.tests := mylib.lib.tests mylib.pkg.testsThis is the complete project manifest. Each name in those lists is an asset
with its own configuration file in .mm/. The project file delegates all
detail to those files and stays small regardless of how large the project grows.
Asset configurations use the same flat namespace of dotted names:
# .mm/mylib.lib
mylib.lib.stem := mylib
mylib.lib.root := lib/mylib/
mylib.lib.extern := mpi hdf5
mylib.lib.c++.flags += $($(compiler.c++).std.c++20)stem controls the output name. root is the directory mm scans for sources.
extern is the list of external packages the asset needs — mm translates each
name to the correct include paths, compile definitions, and link flags.
mm reads .mm/mm.yaml at the start of every build. This file configures mm
itself — where products land, which compilers to use, how many cores to
occupy — as opposed to the .mm/{project}.mm files, which configure what is
being built.
mm:
mode: dev
bldroot: ~/tmp/builds/myproject
prefix: ~/.local
target: opt, shared
compilers: clang, python/python3
slots: 8The mode option is the most important setting to get right. mm has four
modes:
| Mode | Intermediate files | Installed products |
|---|---|---|
dev (default) |
builds/{target}/ inside source tree |
products/ inside source tree |
conda |
builds/{env}/{target}/ |
conda environment root (auto-detected) |
macports |
builds/macports/{target}/ |
MacPorts root (auto-detected) |
ubuntu |
builds/ubuntu/{target}/ |
/usr |
The dev default exists to get you started without any configuration. It is
not suitable for CI, shared machines, or any workflow where you want a clean
separation between source and build products. For dev builds beyond
experiments, always set bldroot and prefix in .mm/mm.yaml.
pyre environment variable interpolation works inside string values:
mm:
mode: conda
prefix: "{pyre.environ.CONDA_PREFIX}"
bldroot: "{pyre.environ.HOME}/tmp/builds/{pyre.environ.CONDA_DEFAULT_ENV}"See docs/command-line-reference.md for the full option list, grouped by
how often you are likely to need each one.
mm understands six asset types.
Libraries compile and link a set of C, C++, Fortran, or CUDA source files
into a static or shared library. mm discovers all sources under {asset}.root
automatically.
Packages install a Python package tree. mm copies the source to the installation prefix and generates any templated files (version metadata, etc.) from substitution templates at build time.
Extensions build pybind11 modules that expose C++ libraries to Python.
The wraps variable names the library being bound, giving mm enough context
to set up include paths and link flags without repetition. A capsule variable
controls whether the extension participates in PyCapsule sharing with other
extensions in the same process.
Tests compile and run a test suite. Each source file under {asset}.root
becomes an independent test binary. mm links each binary against the assets
listed in extern, runs all of them, and reports any that produce output.
Silence is pass.
Drivers are executable scripts installed to bin/. mm installs every
file in the project's bin/ directory without requiring a configuration entry.
Verbatim assets copy files to the installation prefix without processing — useful for configuration files, schemas, and other static content.
All asset types appear in the project manifest under the appropriate plural key
(libraries, packages, extensions, tests). mm handles the dependency
ordering between them.
mm scans {asset}.root and picks up every source file whose extension matches
a supported language. For projects that span multiple subdirectories:
mylib.lib.directories := ./ geometry/ physics/ io/Source files can be excluded individually:
mylib.lib.sources.exclude := src/scratch.ccThe expected directory layout mirrors the namespace hierarchy. A library for
the mylib:: C++ namespace lives under lib/mylib/, with one file trio per
class (Foo.h, Foo.icc, Foo.cc), a forward.h for forward declarations,
and a public.h that exposes the full API of that directory level. A sibling
lib/mylib.h includes mylib/public.h so clients need only #include <mylib.h>.
Python packages follow the same logic: pkg/mylib/ maps to the installed
mylib package. The tests/ directory mirrors the source tree:
tests/mylib.lib/, tests/mylib.ext/, and tests/mylib.pkg/ test each
asset independently.
Version information and other build-time metadata can be injected into source
files through a template substitution mechanism. A template named
version.h.in in a library's source directory is processed at build time:
mylib.lib.headers.autogen := version.h.in
mylib.lib.autogen = \
@MAJOR@|$(mylib.major) \
@MINOR@|$(mylib.minor) \
@MICRO@|$(mylib.micro) \
@REVISION@|$(mylib.revision)mm extracts version numbers from the most recent git tag that matches the
project's version tag pattern and fills in the @TOKEN@ placeholders. The
same mechanism works for Python packages via meta.py.in. The generated file
is always a build product; you edit the template, not the output.
mm ships pre-configured support for a broad set of scientific and systems packages:
cantera, CGAL, CSPICE, CUDA, Eigen, FFTW, fmt, GDAL, GeoTIFF, Gmsh, GSL, GTest, HDF5, Kokkos, libpq, METIS, MKL, MPI, NumPy, OpenBLAS, ParMETIS, PETSc, PROJ, pybind11, pyre, and others.
Pointing mm to a package is a one-line addition to any asset's configuration:
mylib.lib.extern := mpi hdf5 pybind11Machine-specific paths are declared in ~/.mm/config.mm, which is never
committed to the project repository:
# ~/.mm/config.mm
cuda.dir := /usr/local/cuda-12
hdf5.dir := /opt/hdf5For packages without pre-configured support, the same variables work without any mm internals:
mypackage.dir := /opt/mypackage
mypackage.incpath := $(mypackage.dir)/include
mypackage.libpath := $(mypackage.dir)/lib
mypackage.libraries := foo barmm also integrates with conda. Running mm --pkgdb=conda builds a database
of installed packages and uses it to resolve external dependencies
automatically.
Some projects depend on external developer tools — a browser-automation runner,
a bundler, a linter — that are heavy, version-sensitive, and naturally shared
across every build of an environment rather than owned by any one project. mm
installs these as toolchains: once per environment, at a shared location
keyed by the active conda environment (~/tools/mm/$CONDA_DEFAULT_ENV/toolchains
by default, overridable with --toolchains). Because the location tracks the
environment and not the build variant, a toolchain is reused across every build
context and is never disturbed by mm clean.
A project declares the tools it uses; it never installs or reinstalls them. The lifecycle is driven from the command line:
mm toolchains # list the available toolchains and the shared root
mm playwright.install # fetch and install one (a deliberate, online action)
mm playwright.verify # check it is present; fails a build if it is missing
mm playwright.info # show its pinned version and location
mm playwright.clean # remove the installation
Builds and tests only verify that a toolchain is present — they never reach the network. If a required tool is missing, the build stops with a one-line instruction to install it. Installation is always an explicit, deliberate step, so an offline build never surprises you by trying to download anything.
Every C++ source file in an mm project begins with:
#include <portinfo>portinfo is generated at build time from the detected toolchain. It sets the
platform and compiler macros your code needs to handle differences across
operating systems and compilers without scattering #ifdef throughout the
source. The header is installed by mm and is always available to any asset that
depends on a library compiled with mm.
mm is the build system for pyre, a Python component framework. Projects that use pyre gain access to several layers of integration that go beyond compilation.
pyre ships journal, a logging framework that works identically in C++ and
Python. The same named channel can be used from both sides of the language
boundary; channels are identified by dotted name and their state is global.
In C++:
auto channel = pyre::journal::debug_t("mylib.solver");
channel << "step " << n << pyre::journal::endl;In Python:
import journal
channel = journal.info("mylib.driver")
channel.log(f"loaded {n} records")Debug channels compile to no-ops unless JOURNAL_DEBUG is defined, making
them free in production builds:
mylib.lib.c++.defines += JOURNAL_DEBUGThis define is not inherited by any other asset, including extensions that wrap the library. Extension binding code typically does not use debug channels, and pybind11 headers can generate spurious warnings under aggressive warning flags. Each asset's defines are explicit.
Applications that inherit from pyre.application replace argparse with a
declarative trait system:
class App(pyre.application, family="myapp.app"):
"""Run a simulation and report results."""
count = pyre.properties.int(default=1)
count.doc = "number of iterations"
@pyre.export
def main(self, *args, **kwds): ...Traits declared at the class level are automatically wired to command-line
arguments, configuration files (.pfg, .yaml, .ini), and the pyre
configuration store. The family string is the key used in configuration
files to address this component. A user can override any trait for a specific
run without touching the source:
myapp --count=100 -- some-commandor persistently in ~/.pyre/myapp.pfg:
[myapp.app]
count = 100pyre's component model separates the declaration of an interface from its implementation and defers the binding between them to configuration time. A protocol declares obligations:
class Solver(mylib.protocol, family="mylib.protocols.solver"):
@mylib.provides
def solve(self, problem): ...
@classmethod
def pyre_default(cls, **kwds):
return mylib.components.DirectSolverA component implements the protocol:
class DirectSolver(
mylib.component,
family="mylib.solvers.direct",
implements=mylib.protocols.solver,
):
@mylib.export
def solve(self, problem): ...An application trait that holds a solver:
solver = mylib.protocols.solver()
solver.doc = "the solver implementation to use"With this in place, the solver implementation is a configuration choice. The
default comes from pyre_default — typically the highest-performance
implementation available. A user switches implementations at the command line
without touching any code:
myapp --myapp.solver=mylib.solvers.iterative -- ...This is the component system's core value: the application's structure is defined once in code, and the wiring of its parts is deferred to the caller.
mm manages the shell environment alongside the build.
mm --activate emits shell export statements that add the build's bin/ and
Python package directory to the current session's PATH and PYTHONPATH. The
conventional wrapper is a shell function (not an alias — eval must execute in
the calling shell's scope):
mm.activate() {
eval "$(mm --quiet --activate)"
}--quiet suppresses the mm banner so only the export lines reach eval.
mm.activate is idempotent: it ejects the previous activation before injecting
the new one, so calling it when a build is already active is always safe.
mm --branch=on derives a build tag from the current git state:
{project}/{branch}
and activates the installation tree that corresponds to that tag. With a tag, build products land in:
builds/{suite}/{project}/{branch}/{target}/
products/{suite}/{project}/{branch}/{target}/
Without a tag they land in:
builds/{target}/
products/{target}/
Branch-tagged builds are completely isolated. You can maintain separate compiled products for every active branch and switch between them in one command, without rebuilding anything:
mm.branch() {
eval "$(mm --quiet --branch=on)"
}
mm.clear() {
eval "$(mm --quiet --branch=off)"
}mm.clear removes the tag and returns to the unscoped build.
Source these functions from ~/.bashrc or ~/.bash_profile. A ready-made
version lives in examples/step08/etc/bash/activate.bash.
mm supports named build variants — combinations of optimisation level, debug
information, shared/static linking, and code coverage instrumentation. The
default build is unoptimised with debug information and shared libraries.
Override with --target:
mm # debug + shared (default)
mm --target=opt # optimised
mm --target=opt,shared # optimised + sharedEach target lands in its own subdirectory of builds/, so all variants
co-exist without interfering.
mm uses all available cores by default. Limit parallelism with --slots:
mm --slots=4- Python 3.10 or later
- pyre framework
- GNU make 4.2.1 or later (on macOS: install
gmakevia Homebrew or MacPorts)
./install.sh ~/.local
export PATH="$HOME/.local/bin:$PATH"Pass --bash-completion to also install tab-completion support for bash.
docs/tutorial.md— a step-by-step walkthrough that builds a C++ timer library with Python bindings, a pyre application driver, and full shell integration; eight progressive steps, each introducing one new conceptdocs/testing.md— the two testing models (per-file drivers and runners) and how to wire up a test suitedocs/externals.md— how to add support for an external package, including dependencies, verification markers, and flavor-driven librariesdocs/FAQ.md— answers to common configuration and troubleshooting questionsdocs/command-line-reference.md— all command-line options, classified as common, advanced, and esotericexamples/— the complete source for each tutorial step