Development¶
CPU development venv¶
CPU development and the install check use ~/environments/fastmace_cpu in
WSL2 Ubuntu on the PC. It is FastMACE's own environment and may be rebuilt at
any time; the CPU CI follows the same recipe. It installs the main checkout
in editable mode, so it outlives any worktree and always sees the code that
is checked out there.
Keep pip caches in ~/pip-cache and temporary files in ~/tmp:
export PIP_CACHE_DIR=~/pip-cache TMPDIR=~/tmp
python3.10 -m venv ~/environments/fastmace_cpu
~/environments/fastmace_cpu/bin/python -m pip install --upgrade pip
~/environments/fastmace_cpu/bin/python -m pip install torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu
~/environments/fastmace_cpu/bin/python -m pip install -e "/mnt/d/Margraf/FastMACE[dev]"
~/environments/fastmace_cpu/bin/python -m pip check
~/environments/fastmace_cpu/bin/python -m pip freeze > ~/environments/fastmace_cpu.requirements.txt
The D3 cross-check tests (tests/cpu/test_d3_references.py) need the
d3-reference extra and are skipped without it; fastmace_cpu has it since
22 October 2026 (3b-T01):
~/environments/fastmace_cpu/bin/python -m pip install -e "/mnt/d/Margraf/FastMACE[dev,d3-reference]"
Torch comes from the PyTorch CPU index first, so the editable install keeps
it and does not pull a CUDA build from PyPI. The installed versions are
listed in ~/environments/fastmace_cpu.requirements.txt.
Check that it works:
~/environments/fastmace_cpu/bin/python -c "import fastmace, torch; print(fastmace.__version__, torch.__version__)"
Run the CPU suite from the repository root (at most 8 workers locally, at most 2 while other sessions run tests):
CUDA_VISIBLE_DEVICES='' OMP_NUM_THREADS=2 PYTHONDONTWRITEBYTECODE=1 ~/environments/fastmace_cpu/bin/python -m pytest -n 8 --dist=load --maxschedchunk=1 tests/cpu
Never install FastMACE into the GPU environments; GPU runs use
PYTHONPATH=<checkout> with the environment's Python (AGENTS.md).
Test tiers¶
Plan tasks 1b-T24 and 1b-T27 (19 October 2026), and 4-T13. The GPU suite has three tiers and a
compatibility check (tests/_support/tiers.py):
| Tier | Command | Who runs it, when | Local RTX 5060 Ti, warm caches |
|---|---|---|---|
| fast (worker) | pytest tests/gpu -m "not slow and not acceptance" |
every worker, for every change, as often as needed | 7.9 min (190 tests, 1b-T27) |
| full (merge) | pytest tests/gpu -m "not acceptance" |
the conductor, at merges that touch model/, rewrites/, kernels/ or engine/ |
38.7 min (244 tests; 142 min before 1b-T27) |
| acceptance | pytest tests/gpu -m acceptance |
phase acceptance, and merges that touch model/, rewrites/ or kernels/ |
murphy: 103 min (10 tests) |
| compat | python tools/acceptance/compat.py --provider P --output DIR (pytest tests/gpu -m compat) |
every production GPU type each phase, and once when a type is added (SPEC decision 39, amended 23 October 2026) | about 30 min (estimate: the fast tier and about 20 min of listed slow tests) |
The compatibility tier (plan task 4-T13) checks what differs between GPU architectures: the fast
tier, plus the slow tests listed in tiers.COMPAT_TESTS (kernels compile and attach, the accuracy gate
on MH-1, MP-0 and POLAR-1-S, replay against eager, reproducible mode, a restart round trip), plus
tests/gpu/test_compat_gpu.py (the captured graphs' kernel counts, recorded, and a short NVE, NVT and
NPT smoke). The root conftest marks them compat; it needs FASTMACE_MODELS but no research engine.
The driver records the provider, the GPU, its compute capability and driver, and the package versions in
compat.json. To add a test to the tier, list it in COMPAT_TESTS; tests/cpu/test_tiers.py checks
that every entry is still collected.
Merge rule: the full tier on merges that touch model/, rewrites/, kernels/ or engine/,
and in addition the acceptance tier on merges that touch model/, rewrites/ or kernels/
(they change numerics).
The first run after a change of environment, or with emptied caches, is slower, because it compiles everything and builds the OEq extension once. Measurements: validation/1b-T24-test-suite-speed.md, validation/1b-T27-gpu-tiers.md.
The CPU suite (tests/cpu) has no tiers: everyone runs all of it.
A test is marked @pytest.mark.slow when it needs one of these:
- a real checkpoint (MH-1, MP-0, OFF, POLAR-1-S), whose load and compile take tens of seconds;
- a long trajectory (hundreds of steps);
- a cold compiler cache (the edge-bucket regression tests).
A test is also marked @pytest.mark.acceptance (and keeps slow, so that the older
-m "not slow" leaves it out too) when it is:
- a comparison with the research engine (
FASTMACE_BASELINE) in its own process; - a statistical check over tens of thousands of steps (ASE equivalence, the hybrid restart);
- a timing record (the gate's time on NbCl5).
Fast- and full-tier tests make no step-time assertions: trials and workers share the GPU, so their step times are not comparable. A step-time comparison is an acceptance test of its own, which reuses the numerical test's measurement when both run in one process (2-T14d).
tests/cpu/test_tiers.py fails for an acceptance mark without slow. When a -m expression
selects acceptance tests (-m acceptance, or -m slow), tests/conftest.py stops the run with a
usage error if FASTMACE_BASELINE, FASTMACE_HYBRID_PYTHON or FASTMACE_MODELS is unset or names
nothing, so the acceptance tier never skips silently. The check runs in the controller before
collection, so xdist workers never see it. A run without -m keeps the tests' own skips.
Run CPU suites under xdist (-n 2 or more) whenever you change conftest.py or a pytest plugin.
Every behaviour tested on the GPU keeps at least one test in the fast tier, usually on a
tiny model or the Lennard-Jones force field in the pytest process, except those listed
below. When you add a GPU test that is slow for one of these reasons, mark it slow and
check that a fast test still covers the behaviour. If none can, add the behaviour to that list.
Budgets. tools/test_budget.py reads junit files (pytest --junitxml) and the tiers (by
collecting tests/gpu). It prints each tier's total against its budget first (fast 10 min, full
40 min, local RTX 5060 Ti with warm caches; over budget is a warning). It exits 1 if a fast-tier
test takes over 60 s, or a full-tier test over 120 s. The exception is a test that
FULL_TIER_ALLOWED in the tool lists with its measured time and a reason; such a test fails only
when it takes more than 1.5 times its recorded time. Run the tool on the junit of a tier run before
you merge new GPU tests:
python tools/test_budget.py results/<run>/junit.xml
GPU command for workers on the local PC (one process; FASTMACE_HYBRID_PYTHON runs the
hybrid tests in the OEq environment). TORCHINDUCTOR_COMPILE_THREADS=4 keeps Inductor from
starting 24 compile workers per process. Without it, WSL's 15 GB ran out on
19 October 2026 while other sessions ran CPU suites, and the WSL VM restarted:
TORCHINDUCTOR_COMPILE_THREADS=4 FASTMACE_HYBRID_PYTHON=~/environments/mace_cueq_oeq/bin/python FASTMACE_MODELS=~/models FASTMACE_BASELINE=~/fastmace-baseline/1a-T18 PYTHONPATH=$PWD PYTHONDONTWRITEBYTECODE=1 ~/environments/mace_cueq_latest/bin/python -m pytest -m "not slow and not acceptance" tests/gpu
Two MH-1 behaviours have tiny-model versions in the fast tier
(tests/gpu/test_tiny_engine_gpu.py, plan task 1b-T25): no compile at a later
edge bucket, from a fresh Inductor cache (test_no_compile_at_a_later_edge_bucket_tiny_plain_cueq),
and a planned engine equal to a hand-built one
(test_tiny_plain_cueq_float64_planned_equals_hand_built). The MH-1 versions stay
in the full tier, and the hybrid edge-bucket case is tested only there.
Plan task 1b-T26 marked slow the GPU tests merged after 1b-T24 that run MH-1 or
POLAR-1-S (30 s to 5 min each). Two of them have tiny-model versions in the fast tier
(tests/gpu/test_tiny_released_gpu.py): the runtime-state registry in plain cuEq float64
(capture warm-ups leave no trace, the registry covers what the stages mutate), and the gate
failing a broken captured graph while the eager stages pass. Tiny versions of the others were
tried. They cost almost as much as the MH-1 tests, because fresh processes, compiles and the
tests' own child processes dominate, so they were left out
(validation/1b-T26-gpu-retiering.md). These behaviours
are tested only in the full tier:
- an engine resumed from canonical restart points follows the uninterrupted run within 1e-12 Å
(
test_canonical_restart_gpu.py; the CPU suite has tiny-model restart tests); - the gate passes in graph mode, NVT with fresh references and full-cell NPT
(
test_selfcheck_gpu.py::test_gate_passes_in_graph_mode); fm.SimulationreachesFixedPlanner's trajectory, plain cuEq and hybrid (test_simulation_gpu.py);- LBFGS with
fm.calcreaches MACE's minimum (test_ase_calculator_gpu.py; a tiny random model does not converge); - the registry checks in hybrid pipelined NVT, hybrid full-cell NPT and POLAR-1-S
(
test_registry_gpu.py).
Behaviours tested only in the acceptance tier (1b-T27):
- Langevin and BAOAB reproduce ASE's integrators statistically (
test_ase_equivalence_gpu.py, 88 min; the CPU suite checks both integrators step by step against ASE); - a hybrid engine resumed from a restart file is statistically the same as the uninterrupted run
(
test_canonical_restart_gpu.py::test_mh1_hybrid_resumes_statistically; plain cuEq within 1e-12 Å stays in the full tier); - the gate's time on NbCl5 (
test_selfcheck_gpu.py::test_gate_time_on_nbcl5); - the planned MH-1 engine less than 25 % slower than the hand-built one, plain cuEq and hybrid
(
test_planner_gpu.py::*planned_is_as_fast_as_hand_built, 2-T14d; their numerical comparison stays in the full tier); - FastMACE against the research engine: the MH-1 engine in plain cuEq NVT and NPT and the hybrid
(
test_engine_gpu.py::*against_the_research_engine), and the compiled production force fields (test_forces_gpu.py::test_mh1_agrees_with_maceforces[*compiled-production]; the eager case stays in the full tier).
Caches the suite shares¶
- Compiler caches.
tests/conftest.pygives the whole run one Inductor/Triton cache, inherited by every child process: the FastMACE cache root's (FASTMACE_CACHE, else~/.cache/fastmace, as production runs), unlessTORCHINDUCTOR_CACHE_DIRis already set.pytest --fresh-compile-cachecompiles everything from scratch in a new directory under<cache root>/test-runs/. The pytest header names the caches. Tests that need a cold cache set their own, explicitly. - OEq host extension.
fastmace.kernels._oeq_runtimebuilds OpenEquivariance's host extension once per build identity (Python and environment, torch, OpenEquivariance, the CUDA runtime wheel, the compiler) in<cache root>/oeq/extensions/<key>/and copies it into each process's own extension directory. Only the first process after a change of environment builds it. Delete that directory to force a rebuild.
Golden texts¶
tests/cpu/test_plan_text.py compares str(plan) for seven plans with the files in
tests/cpu/golden/plan_text/ (plan task 5-T05). A change that alters the text on purpose
rewrites them:
FASTMACE_UPDATE_GOLDEN=1 CUDA_VISIBLE_DEVICES='' ~/environments/fastmace_cpu/bin/python -m pytest tests/cpu/test_plan_text.py
Review the diff of the golden files and commit it with the change. Tasks that fill a section of the plan text (5-T03's memory line, 5-T07's tier switches, 5-T09's calibration line) update the files this way.
CPU test scheduling¶
Use --dist=load --maxschedchunk=1 with xdist, keeping the local limit of eight workers and
OMP_NUM_THREADS=2. The suite mixes thousands of short tests with long MC statistical cases. The default
scheduler queues large consecutive batches (162 cases per worker in a 5,199-case, eight-worker run), so
the MC cases can occupy one worker while the others finish. Small scheduling chunks spread that work
without dropping cases, shortening trajectories or changing tolerances. They do not increase the worker
limit. Timing evidence and the coverage comparison are recorded in docs/validation/6-T16.md.
Since suite-speed (27 October 2026, docs/validation/suite-speed.md) tests/conftest.py applies these
defaults itself whenever -n is given, so a plain pytest -n 8 -m "not acceptance" tests/cpu gets them
too. It sets OMP_NUM_THREADS=2, MKL_NUM_THREADS=2 and TORCHINDUCTOR_COMPILE_THREADS=1 for the
workers and their child processes, unless the caller already set them, and --maxschedchunk=1 with
--dist=load. Without the thread limits, every worker's OpenMP pool used every core: eight local workers
ran at a load of about 130 and took about 100 minutes. The workers also start the test functions listed in
tests/_support/data/cpu_durations.json first, longest first, so that the 40–75 s tests do not start at
the end of the run. Refresh the file from a full run's junit file after large test changes:
python tools/test_order.py <run>/junit.xml. A stale file changes only the order, never which tests run.
Bitwise equality in CPU float64 tests¶
Found in task 0-T04 (17 October 2026, fastmace_cpu, torch 2.14.1+cpu,
MACE 0.3.16). On CPU with more than one OpenMP thread, the first model
evaluated in a process gives results that differ from every later model by
about 2e-17 relative (1 force component in 192 on a tiny NaCl model). It does
so even when the weights are bitwise equal. Repeated evaluations of one
model are bitwise equal, and all later models agree bitwise with each other.
The difference disappears with OMP_NUM_THREADS=1, with TorchScript's
profiling executor disabled (torch._C._jit_set_profiling_executor(False)),
or after one throwaway evaluation in the process.
For equivalence tests (FastMACE against the research engine, phase 1a), use either of these:
- compare at
rtol=1e-12(forces alsoatol=1e-14) in float64; or - run one throwaway evaluation in the process before comparing bitwise.
Do not compare the first evaluation of a process bitwise against anything.
CI¶
The CPU CI is .github/workflows/cpu.yml (SPEC §12.4). It runs on every
branch push and by hand (workflow_dispatch); there is no separate
pull_request trigger, because pull requests come from branches of this
repository and would run the same commit twice. A newer push to the same
branch cancels the running workflow (concurrency), and pip downloads are
cached, since Actions minutes count on a private repository.
Matrix, all on ubuntu-latest with CPU torch from the PyTorch CPU index:
| Cell | Python | torch | Other packages |
|---|---|---|---|
| latest | 3.10, 3.11, 3.12 | 2.14.1 | newest the pyproject.toml floors allow |
| previous minor | 3.10, 3.11, 3.12 | 2.13.0 (newest 2.13.x) | newest the floors allow |
| qualified | 3.10 | 2.14.1 | -c constraints/qualified.txt (SPEC §4.1 pins) |
pyproject.toml keeps the §4.1 floors, so the six plain cells test what a
user gets from a fresh install today. constraints/qualified.txt pins the
qualified stack (torch 2.14.1, mace-torch 0.3.16, cuequivariance and
cuequivariance-torch 0.12.0, e3nn 0.4.4, ASE 3.27.0, NumPy 2.2.6); update it
together with SPEC §4.1.
Each cell installs torch first, then pip install -e ".[dev]", runs
ruff check fastmace tests and then
CUDA_VISIBLE_DEVICES='' pytest -n auto --dist=load --maxschedchunk=1 tests/cpu tests/gpu; the GPU tests
skip. The same checks locally, from the repository root:
~/environments/fastmace_cpu/bin/ruff check fastmace tests
CUDA_VISIBLE_DEVICES='' OMP_NUM_THREADS=2 PYTHONDONTWRITEBYTECODE=1 ~/environments/fastmace_cpu/bin/python -m pytest -n 8 --dist=load --maxschedchunk=1 tests/cpu tests/gpu
Ruff's rules are in pyproject.toml (E, W, F, I, line length 120).
fastmace and _support are first-party for isort; a module that must
import fastmace before torch marks that with # isort: split
(tests/conftest.py).
Install check, 17 October 2026 (pip install --dry-run in throwaway venvs):
MACE 0.3.16, e3nn 0.4.4 and cuEquivariance 0.12.0 (pinned) or 0.13.0
(newest) resolve on Python 3.10, 3.11 and 3.12 with torch 2.14.1 and
2.13.0, so no cell is dropped. The newest resolution then was ASE 3.29.0,
NumPy 2.5.4 and filelock 4.2.0.
GPU CI¶
Plan task 2-T18, owner decision D2 (SPEC §18 decision 5). .github/workflows/gpu.yml runs the GPU
tiers (see "Test tiers") on two self-hosted GitHub Actions runners. They cost no Actions minutes:
| Runner | Machine | GPU | Labels | Directory |
|---|---|---|---|---|
murphy-sm89 |
murphy (shared workstation) | RTX 4000 Ada, sm_89 | self-hosted, linux, x64, fastmace-gpu, sm_89, murphy |
/mnt/homes/zwingel/actions-runner/murphy |
local-5060ti-sm120 |
the PC, WSL Ubuntu | RTX 5060 Ti, sm_120 | self-hosted, linux, x64, fastmace-gpu, sm_120, local-5060ti |
~/actions-runner/local |
What runs when:
| Event | murphy | local |
|---|---|---|
push to main |
fast tier | fast tier |
| nightly, 00:30 UTC | full tier | — (the PC is not always on or idle) |
by hand (gh workflow run gpu.yml -f tier=full -f runner=local) |
any tier | any tier |
A push that changes only plans/, docs/, results/ or Markdown files starts no GPU run. Each runner
has at most one run in progress and one waiting per ref (concurrency, never cancelling a running one).
A burst of merges therefore runs once more, not once per merge.
gpu.yml calls gpu-tier.yml once per runner. It has two jobs:
- idle waits until the GPU is free (
tools/ci/gpu_idle.py check), for at most 30 minutes, or 150 minutes at night. The GPU is busy while any compute process is on it: another user's, one of our own from another task, or one whose ownerpscannot name. It is also busy ifnvidia-smifails. WSL'snvidia-smilists no processes, so the local runner counts the processes that hold/dev/dxg. It also needs three consecutive samples under its memory, utilization and RAM limits. The desktop uses about 4.3 GB, and WSL needs 10 GB available. Someone else's live local GPU reservation (see the next section) also counts as busy. A GPU that stays busy is not a failure: the tests job shows as skipped, and the idle job's summary names the processes. - tests checks once more, then runs
pytest tests/gpu -m <tier>with the runner'sFASTMACE_CI_PYTHONandPYTHONPATHset to the checkout. Nothing is installed. On the PC the run holds the local GPU reservation, and skips if it does not get it within 30 minutes. Then it runstools/test_budget.pyon the junit file. Over budget fails the job on the local runner, where the budgets were measured. On murphy it is informational. While the tests run, a watcher records any other user's process that appears on the GPU. It does not stop the run, and the job summary lists what it saw. The artefactgpu-<tier>-<runner>-<attempt>holdsjunit.xml,pytest.log,budget.txtand the watcher's record for 30 days.
Each runner's .env file holds its paths and limits (tools/ci/runner/env.murphy,
tools/ci/runner/env.local), so the workflow names no machine path. On murphy everything stays under
/mnt/homes/zwingel: the runner, its _work checkout, HOME, TMPDIR and a cache of its own
(fastmace-cache/ci-murphy).
The local GPU reservation¶
A GPU suite started while sibling CPU suites ran crashed WSL on 19 October 2026, when WSL had 15 GB of
RAM (40 GB since 21 October). WSL's nvidia-smi does not show who is on the GPU. So every local GPU
run holds one reservation, tools/ci/local_gpu.py: the GPU CI runner, a worker's GPU suite and a
timing run alike. Every local CPU suite checks it first.
- Reserve creates the marker
~/.fastmace-local-gpuatomically (mkdir), withholder.json: owner, purpose, pid, the pid's start time and the time of the reservation. While another live holder has it, reserve waits. - First come, first served: every waiting
runorreservefirst puts a ticket into the queue next to the marker,~/.fastmace-local-gpu-queue/(one JSON file per waiter: owner, purpose, pid, the waiting process and its start time, and the arrival time; written to a temp file and renamed). It takes the marker only when the marker is free and its ticket is the oldest live one. Before the queue, waiters raced for the marker at each release, and on 22 October 2026 one worker's back-to-back jobs kept four short test runs waiting 37 minutes. A ticket whose waiting process is gone (or whose pid now belongs to another process) is stale: the next waiter reports and drops it. A waiter that gives up after--wait-minutesremoves its own ticket. - Then wait: holding the marker, it waits until no other
pytestprocess that can use the GPU runs and at least 10 GiB of RAM are available. Then the run starts. A pytest process whose environment setsCUDA_VISIBLE_DEVICESempty (or-1) cannot use the GPU, so it does not count (22 October 2026: with five phase 3 workers, CPU suites kept GPU reservations waiting). If either wait takes longer than--wait-minutes(default 60), it releases the marker and exits with status 75. - Timing runs pass
--quiet. They wait until no other pytest runs at all, and their reservation blocks CPU-only suites too. - Release removes the marker, only for the pid that holds it.
- A stale marker is reported and removed by the next reserve or check. It is stale when its holder process is gone, or when its pid now belongs to another process.
- Check exits 1 while a live marker exists.
check --cpu-only, for a CPU suite run withCUDA_VISIBLE_DEVICES=, exits 1 only while a quiet reservation is live.
Worker commands on the PC (python3 is WSL's system Python). The conductor's main checkout,
/mnt/d/Margraf/FastMACE, always has the current tool:
# A local GPU suite: reserve, run, release (on any exit; the command's status is returned)
python3 tools/ci/local_gpu.py run --purpose "3-Tnn fast tier" -- <the GPU command>
# A timing run: the machine quiet, and CPU-only suites wait meanwhile
python3 tools/ci/local_gpu.py run --quiet --purpose "3-Tnn timing" -- <the timing command>
# The same in a shell script that runs several commands
python3 tools/ci/local_gpu.py reserve --quiet --purpose "3-Tnn timing" --pid $$ && trap 'python3 tools/ci/local_gpu.py release --pid $$' EXIT
# Before every local CPU suite: CPU only, no quiet reservation, and 6 GB available
python3 tools/ci/local_gpu.py check --cpu-only --min-available-gib 6 && CUDA_VISIBLE_DEVICES= <the CPU suite command>
# Who holds it, and who waits in which order (with purposes and waiting times)
python3 tools/ci/local_gpu.py status
The local runner sets FASTMACE_LOCAL_GPU_MARKER to the same marker by its absolute path, because
keepalive.sh moves its HOME. The queue follows the marker: it is the marker's path with -queue
appended.
Keeping the runners up without root¶
GitHub's svc.sh install needs sudo, and murphy has no linger for systemd user services. So
tools/ci/runner/keepalive.sh keeps each runner up. It starts run.sh once per runner directory
(flock), in a process group of its own, with HOME and TMPDIR inside the runner directory.
- murphy: cron, open to every user there, runs it at
@rebootand every 10 minutes (tools/ci/runner/crontab.murphy). A run while the runner is up exits at once. - The PC: a shortcut in the Windows Startup folder runs it at log-on with
--loop, throughwsl.exe, minimized. The openwsl.exekeeps the WSL VM running. Closing that window stops the runner.
The runner updates itself (conductor answer to 2-T18, Q8, 20 October 2026). It was installed at v2.338.0.
Registering the runners (the owner)¶
The owner runs these from Git Bash in D:\Margraf\FastMACE, on main after the merge. Both runners can
be registered at once. The local runner starts a job only when the reservation and its idle rule allow
it, so it never runs beside a worker's suite.
gh passes the registration token, valid for one hour, straight into a pipe, so it is never shown or
written down. Registering needs admin rights on the repository, which the FZWINGEL-UBT account has
(GH_TOKEN=$(gh auth token --user FZWINGEL-UBT), for that one command). The git show copies keep LF
line endings.
murphy:
R=/mnt/homes/zwingel/actions-runner/murphy
ssh murphy "mkdir -p $R"
for f in keepalive.sh env.murphy crontab.murphy; do git -c core.autocrlf=false show main:tools/ci/runner/$f | ssh murphy "cat > $R/$f"; done
ssh murphy "chmod +x $R/keepalive.sh && cd $R && curl -fsSLo runner.tar.gz https://github.com/actions/runner/releases/download/v2.338.0/actions-runner-linux-x64-2.338.0.tar.gz && echo 'af4b794c1bc41d73d40535e3fe092a39f9679cd8d965954c2aca25a05ca41d32 runner.tar.gz' | sha256sum -c - && tar xzf runner.tar.gz && rm runner.tar.gz"
GH_TOKEN=$(gh auth token --user FZWINGEL-UBT) gh api -X POST repos/FZWINGEL-UBT/FastMACE/actions/runners/registration-token --jq .token | ssh murphy "read -r T && cd $R && ./config.sh --unattended --url https://github.com/FZWINGEL-UBT/FastMACE --token \"\$T\" --name murphy-sm89 --labels fastmace-gpu,sm_89,murphy --work _work --replace"
ssh murphy "cd $R && cat env.murphy >> .env && (crontab -l 2>/dev/null; cat crontab.murphy) | crontab - && setsid -f $R/keepalive.sh $R < /dev/null > /dev/null 2>&1"
The local PC (WSL). MSYS_NO_PATHCONV=1 stops Git Bash from rewriting /home/... arguments for
wsl.exe:
export MSYS_NO_PATHCONV=1
R=/home/fzwin/actions-runner/local
wsl -e mkdir -p $R
for f in keepalive.sh env.local; do git -c core.autocrlf=false show main:tools/ci/runner/$f | wsl -e sh -c "cat > $R/$f"; done
wsl -e bash -c "chmod +x $R/keepalive.sh && cd $R && curl -fsSLo runner.tar.gz https://github.com/actions/runner/releases/download/v2.338.0/actions-runner-linux-x64-2.338.0.tar.gz && echo 'af4b794c1bc41d73d40535e3fe092a39f9679cd8d965954c2aca25a05ca41d32 runner.tar.gz' | sha256sum -c - && tar xzf runner.tar.gz && rm runner.tar.gz"
GH_TOKEN=$(gh auth token --user FZWINGEL-UBT) gh api -X POST repos/FZWINGEL-UBT/FastMACE/actions/runners/registration-token --jq .token | wsl -e bash -c "read -r T && cd $R && ./config.sh --unattended --url https://github.com/FZWINGEL-UBT/FastMACE --token \"\$T\" --name local-5060ti-sm120 --labels fastmace-gpu,sm_120,local-5060ti --work _work --replace && cat env.local >> .env"
Then, in PowerShell, the Startup shortcut. The second line starts the runner now:
$l = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\FastMACE GPU runner.lnk"; $s = (New-Object -ComObject WScript.Shell).CreateShortcut($l); $s.TargetPath = "$env:WINDIR\System32\wsl.exe"; $s.Arguments = "-d Ubuntu --exec /home/fzwin/actions-runner/local/keepalive.sh --loop /home/fzwin/actions-runner/local"; $s.WindowStyle = 7; $s.Save()
Start-Process $l
Check that both are online and idle:
gh api repos/FZWINGEL-UBT/FastMACE/actions/runners --jq '.runners[] | [.name, .status, .busy, ([.labels[].name] | join(","))] | @tsv'
Pausing, removing, cleaning up¶
- Pause all GPU CI:
gh workflow disable gpu.yml(andenableto resume). New jobs are not queued. - Pause one runner: create
PAUSEDin its directory, then stop its process group, for examplessh murphy 'R=/mnt/homes/zwingel/actions-runner/murphy; touch $R/PAUSED; kill -- -$(cat $R/.keepalive.pid)'. Never use a broadpkill. WhilePAUSEDexists, cron and the shortcut start nothing. Remove it to resume. The runner shows as offline. Jobs for it wait in the queue, and fail after 24 hours. - Remove a runner: pause it, then
GH_TOKEN=$(gh auth token --user FZWINGEL-UBT) gh api -X POST repos/FZWINGEL-UBT/FastMACE/actions/runners/remove-token --jq .token | ssh murphy 'read -r T && cd /mnt/homes/zwingel/actions-runner/murphy && ./config.sh remove --token "$T"'(throughwsl -e bash -cfor the local one). Then delete the cron lines (crontab -e) or the Startup shortcut, and the runner directory. GitHub also drops a runner that has been offline for 14 days. - Clean up: each job deletes its own pytest temporary directory.
_workholds one checkout, and the CI cache (fastmace-cache/ci-murphy,~/fastmace-cache/ci-local) holds compiled kernels. Delete either between runs if they grow, and_diag/*.logtoo.
Security¶
Self-hosted runners run the workflow's code as the runner's user: on murphy as zwingel, with
access to the NAS home. So:
- the repository stays private, with no collaborators beyond the owner;
gpu.ymlhas nopull_requesttrigger, and onlycontents: read;- the workflows use no secrets;
- jobs reach these runners only through the
fastmace-gpulabel.
Anyone who can push a branch with a changed workflow can run code on murphy, so push access must stay with the owner. (GitHub's notes: "Secure use reference", self-hosted runner hardening.)
Releasing¶
Plan task 6-T12; D7 keeps hosted CI off and requires the local suite before each tag. The planned 0.1.0 name reservation was not published; the owner authorized tagging and publishing 1.0.0 after acceptance.
Version. fastmace/__init__.py's __version__ is the only place the version is written;
pyproject.toml reads it ([tool.setuptools.dynamic] version = { attr = "fastmace.__version__" }), and
fastmace --version, fastmace doctor and the provenance records report it. Semantic versioning, 0.x while the
API may change. A release commit sets the version, moves CHANGELOG.md's "Unreleased" entries under the new
version and date, and is tagged v<version> (annotated). Development after it may set <next>.dev0. The phase
tags (fastmace-5, ...) stay as they are. The 1.0.0 tag includes the phase-6 packaging, licence metadata
and Python 3.10–3.13 prebuilt OEq extension support.
Steps (in WSL, from the repository root, on the commit to tag):
- CPU suite and lint, locally (D7):
~/environments/fastmace_cpu/bin/ruff check fastmace tests
CUDA_VISIBLE_DEVICES='' OMP_NUM_THREADS=2 PYTHONDONTWRITEBYTECODE=1 \
~/environments/fastmace_cpu/bin/python -m pytest -n 8 --dist=load --maxschedchunk=1 tests/cpu tests/gpu
- Tag:
git tag -a v1.0.0 -m "FastMACE 1.0.0"andgit push origin v1.0.0. - Build and check:
tools/release/make_release.sh v1.0.0. It extracts the tag withgit archiveinto a new directory under~/tmp, runspython -m build(sdist and wheel) andtwine check --strict, checks the file names carry the version, prints the SHA-256 sums and the upload command. It never uploads. Optionally,.github/workflows/release.yml(manualworkflow_dispatchonly, no secrets) builds and checks the same artifacts on GitHub and keeps them for 14 days; it has no publish step either. - Upload with an authorized maintainer's PyPI token: publish the four
fastmace-oeq-extwheels first, then FastMACE's wheel and sdist. Twine prompts for the token without putting it in shell history:
TWINE_USERNAME=__token__ \
~/environments/fastmace_cpu/bin/python -m twine upload ~/tmp/fastmace-release-v1.0.0-*/dist/*
Never commit or log the token. Try TestPyPI first if in doubt (--repository testpypi, a TestPyPI token).
Later option: trusted publishing. PyPI's trusted publishing (OIDC from a GitHub Actions workflow, no stored
token) can replace step 4: register the repository and a workflow in the project's PyPI settings and add a
publish job with id-token: write and pypa/gh-action-pypi-publish, gated on a protected environment. Not set
up: it needs hosted Actions for releases (D7) and an owner decision.
NAS checkout¶
The workstations (murphy, leto, coraline, ...) see FastMACE at
/mnt/homes/zwingel/FastMACE. It is a plain directory, not a git
repository, like ResearchModule's NAS copy. .source_commit holds the
commit it was copied from. Caches and temporary files of runs there go to
/mnt/homes/zwingel/fastmace-cache/. Results and other NAS-only files
must never be deleted, so every sync only adds and overwrites tracked
files. Nobody edits tracked files on the NAS; check first that the NAS
copy matches its .source_commit, so an edit made there is not lost:
C=$(ssh murphy cat /mnt/homes/zwingel/FastMACE/.source_commit)
git -c core.quotepath=false ls-tree -r $C | awk -F'\t' '{split($1,a," "); print a[3]" "$2}' > local.txt
cut -c43- local.txt | ssh murphy 'cd /mnt/homes/zwingel/FastMACE && git hash-object --stdin-paths' | paste -d' ' - <(cut -c43- local.txt) | diff <(sed 's/ / /' local.txt) -
rm local.txt
No output means no NAS-side edits. Then copy main with LF line endings
(a plain git archive on Windows writes CRLF) and record the commit:
git -c core.autocrlf=false archive --format=tar main | ssh murphy 'tar -x -C /mnt/homes/zwingel/FastMACE'
ssh murphy "echo $(git rev-parse main) > /mnt/homes/zwingel/FastMACE/.source_commit"
tar -x never deletes, so a file removed from git stays on the NAS; remove
it by hand after checking that it is not a result. Run tests there with the
NAS environment's Python and PYTHONPATH=/mnt/homes/zwingel/FastMACE; the
full command is in docs/validation/phase-0.md. pytest needs
--basetemp=/mnt/homes/zwingel/fastmace-cache/<name>: under a TMPDIR on
the NAS it refuses its pytest-of-zwingel directory, because NFS reports
another owner uid (1024) than the workstation login (1012).
Modal¶
Plan task MODAL-T01 (owner, 21 October 2026): GPU jobs on Modal while the workstations are unreachable. - L4 (sm_89): a stand-in for murphy's RTX 4000 Ada. - A100 (sm_80): a third architecture. - H100: also available.
The code is in tools/modal/.
Client:
- Venv: ~/environments/modal_client in WSL, Modal 1.6.2; the package list is
~/environments/modal_client.requirements.txt.
- Authentication: the owner authenticates once with modal token new, and the token lives in
~/.modal.toml. Never print, copy or commit it.
- Profile: fzwingel.
Image (tools/modal/fastmace_modal.py):
- Base: debian_slim, Python 3.10, with g++, git and procps.
- Packages: tools/modal/requirements-hybrid.txt, which pins every package of
~/environments/mace_cueq_oeq.requirements.txt (recipe C/hybrid: torch 2.14.1+cu130 from the PyTorch
cu130 index, cuEquivariance 0.12.0, OpenEquivariance 0.7.0, MACE 0.3.16, e3nn 0.4.4, ASE 3.27.0,
NumPy 2.2.6). It drops the local nfs-soft-locks hook and adds pytest 9.1.1 and pytest-xdist 3.8.0.
- OpenEquivariance: FastMACE builds its host extension in-process (fastmace/kernels/_oeq_runtime.py),
with g++; no nvcc and no oeq_no_nvcc.sh launcher.
- Records: every job records the image's pip freeze (pip-freeze.txt and its sha256 in modal.json).
Volume fastmace-data, mounted at /data:
- models/: checkpoints from ~/models;
- baseline/1a-T18/: the research-engine export with the benchmark structures (FASTMACE_STRUCTURES);
- cache/<GPU>/: FastMACE, Inductor, Triton and OEq caches, one set per GPU type. These are committed after
every job, so later runs skip compilation.
Entry points, run from WSL in the worktree with the client's modal:
modal run tools/modal/fastmace_modal.py::upload --models mace-mh-1.model
modal run tools/modal/fastmace_modal.py::smoke --commit <sha> --gpu L4
modal run tools/modal/fastmace_modal.py::job --gpu L4 --timeout-min 80 --commit <sha> --command "bash tools/modal/opt_t08.sh" --out ~/fastmace-acceptance/modal-<run>
- What a job runs:
--commitisgit archived from--repo(default: the main checkout, which sees every pushed branch) with.source_commit, like the pinned copies.--tarballtakes a ready archive instead. - Where the command runs: from the source root, with
$OUTas its results directory. The results come back as<out>/out/, withmodal.jsonholding: - the GPU, driver and CUDA;
- the source commit and the command;
- the exit code and the wall time;
- an estimated GPU cost at the list prices in
PRICE_PER_HOUR. CPU, memory and container start are billed on top; the dashboard has the exact figure. - Cost control: every function has a timeout, and the container stops when the call returns. Smoke on the L4 first.
Monthly cap (MODAL-T02; owner: "on modal make sure to in total not use more than the 30$ credit i get for
free per month"). The guard is in tools/modal/budget.py, called by every entry point before it launches
anything:
- Cap: $30 per calendar month (Modal's free credit), less a $3 reserve. A launch is refused when
this month's metered cost + the worst case of jobs still running from this checkout + this job's worst case
exceeds $27. The refusal names the numbers.
- Metered cost: read from modal.Workspace.from_context().billing.summary().metered_cost (as
modal billing summary). It counts GPU, CPU, memory and container start, so it runs above the GPU-list
estimates in modal.json: $3.65 metered on 22 October 2026 against about $2.15 estimated.
- Worst case: (function timeout + 300 s start allowance) × (GPU rate + CPU limit × CPU rate + memory
limit × memory rate). The rates come from billing.rates() (as modal billing rates).
- The limits are hard: run_job has cpu=(4, 8) cores and memory=(16, 32) GiB, so the bound holds.
- Example: a 60-minute L4 job has a function timeout of 70 min, and its worst case is about $1.79.
- Unreadable billing refuses: this covers a network or permission error, a missing rate and an
unparsable value.
- Lower limits: --limit-usd 10 or FASTMACE_MODAL_LIMIT_USD=10.
- Higher limits: need --owner-override "<who>: <why>", and the ledger records it. Use it only on the
owner's word.
- Jobs still running: ~/.fastmace-modal/modal-in-flight.json holds the worst case of each job until it
returns, or until its worst case has expired. Parallel launches therefore cannot each fit alone and overrun
together. The directory (FASTMACE_MODAL_STATE) is shared by every checkout and worktree on the machine;
until 22 October 2026 each checkout had its own results/ files, so jobs from different worktrees did not
see each other.
- Ledger: after every launch (also a failed one), ~/.fastmace-modal/modal-ledger.csv gets a row (the
conductor commits copies to results/modal-ledger.csv):
- the date, commit and GPU;
- the timeout and wall time;
- the estimated cost at the container limits, and the worst case;
- the metered cost before and after, and the delta;
- the limit, the override and the exit code.
Modal's metering lags by minutes, so the "after" value may not yet contain the job. The next launch reads
it fresh.
- No cost multipliers: retries=0 on every function and every with_options, and scaledown_window=2
(the container stops 2 s after the call). tests/cpu/test_modal_budget.py checks both, along with the
timeouts and that every .remote call is behind the guard.
- Not covered: image builds (one per change of requirements-hybrid.txt, a few minutes of CPU) and jobs
started outside fastmace_modal.py.
Measured so far:
| Run | GPU, driver | Result | Wall time, GPU cost (est.) |
|---|---|---|---|
Smoke, 724c118 |
NVIDIA L4 (sm_89, 23 GB), driver 610.57.04, CUDA UMD 13.3; torch 2.14.1+cu130 sees capability (8, 9) | tests/gpu/test_smoke.py: 2 passed |
31 s, ≈ $0.007 |
Smoke, 340b23c |
NVIDIA A100-SXM4-40GB (sm_80), driver 610.57.04, CUDA UMD 13.3 | tests/gpu/test_smoke.py: 2 passed |
30 s, ≈ $0.017 |
OPT-T08 (tools/modal/opt_t08.sh), 3e5decd, "Modal L4, not murphy" |
L4 as above | force gate passed (NaCl ratios: energy 0.33, forces 1.15, stress 0.39; NbCl5: 0.61, 1.00, 0.85). Same-process A/B, all three records on/off, 12 blocks: NaCl 0.918 (off first) / 0.920 (on first), NbCl5 0.922 / 0.920; medians NaCl 4.69 → 4.31 ms, NbCl5 37.29 → 34.37 ms. Results: results/modal-opt-T08-L4-3e5decd/ |
38 min, ≈ $0.51 |
Noise floor, 1176db8, "Modal L4, not murphy" (round2.sh noise-ab/ba: a = gated_rows=auto, b = gated_rows=on, the same plan) |
L4 | b/a NaCl 1.0008 / 1.0019, NbCl5 1.0056 / 0.9979: floor ≈ 0.2 % NaCl, 0.6 % NbCl5. NbCl5's block-to-block spread is 8–13 % within a run, and absolute times differ by 4 % between containers (36.0 against 37.4 ms) | 21 min, ≈ $0.28 |
Round 2 on L4, 1176db8 (main 32a0dd9 + this branch), "Modal L4, not murphy", OPT-T08's records on both sides |
L4 | overlap_rebuild (OPT2-T04): NaCl 300 K 0.996 / 0.990, NbCl5 0.996 / 1.003, NaCl 1000 K 0.988 / 0.987. folded_backward_tiles (OPT2-T03): NaCl 1.005 / 1.000, NbCl5 1.000 / 1.001 |
54 min, ≈ $0.72 |
OPT-T08 on A100, 1176db8, "Modal A100, not murphy" (records off against on) |
A100-SXM4-40GB | NaCl 0.907 / 0.896 (3.11 → 2.82 ms), NbCl5 0.907 / 0.906 (15.07 → 13.66 ms); runs valid with the PID-1 fix | 18 min, ≈ $0.62 |
Timing on Modal:
- Labelling: label every result "Modal modal-noise-L4-1176db8).
- The validity flag: inside Modal's sandbox nvidia-smi --query-compute-apps lists every GPU process as PID 1, the
container's dumb-init (results/modal-opt-T08-L4-3e5decd/pid-check.txt). tools/ab_step_time.gpu_processes()
therefore drops PID 1 inside a container (MODAL_IS_REMOTE, MODAL_TASK_ID or /.dockerenv). Runs before that
fix, such as modal-opt-T08-L4-3e5decd, show INVALID for this reason only.
Google Colab¶
Plan task ops-colab (23 October 2026): FastMACE jobs on Colab GPUs through the colab CLI (0.7.5, in WSL,
signed in as the owner). The GPUs are G4 (RTX PRO 6000 Blackwell, sm_120, 96 GB), A100 and L4. The owner
does not cap Colab compute units ("Dont cap anything for colab"), but every VM is stopped when its job ends,
and every job records the units it used. Timed A/B comparisons stay on the local GPU (SPEC decision 39,
amended). Label every Colab result "Colab
The code is in tools/colab/:
- fastmace_colab.py: the driver, run from WSL with the system python3 (standard library only);
- remote.sh: the VM side, in stages (probe, setup, restore, run, pack);
- remote_step.py: runs one stage detached on the VM, so that colab exec can reattach to a long command.
One job:
python3 tools/colab/fastmace_colab.py --gpu G4 --commit <sha> --out ~/fastmace-acceptance/colab-<run> --command 'python tools/acceptance/compat.py --provider colab --label "Colab G4" --output $OUT'
- Commands:
--commandis repeatable, and the commands run one after another on the same VM. Each runs from the source root, with$OUTas its own results directory (out/cmd<n>/). A failing command does not stop the next one; the job's exit code is the first non-zero one. - Quoting from Windows:
wsl.exe -e bash -lc '...'expands$OUTand other variables on the WSL side before the driver sees them, so the VM gets an empty path and the results are lost (OPT4-T07, 24 October 2026). Launch from a script file in WSL instead of an inlinebash -lcstring. - The VM:
--session NAMEnames it. The default name is unique per call: GPU, UTC time and a random suffix. Two 4-T13 jobs started in the same second once got the same name, andcolab stopthen stopped only one of their VMs. The driver refuses a name that is already live (exit 2) and leaves that VM alone;--reuseruns on it instead.--keepleaves the VM running for such a call, and its venv is kept too. Without--keepthe driver always runscolab stopon a VM it allocated or reused, also after an error, Ctrl-C, SIGTERM or SIGHUP. If all three stop attempts fail, it prints the command to run. - Orphaned VMs: a VM without a local name shows as
[?] <endpoint>incolab sessions, andcolab stopcannot reach it. Release it withpython3 tools/colab/fastmace_colab.py stop-endpoint <endpoint>, which runs whatcolab stopruns after its name lookup (state.client.unassign(endpoint)in the CLI's own interpreter). The command is refused for an endpoint that a named session owns (it prints thecolab stop -sto use instead) and for one that is not live. - Source: a
git -c core.autocrlf=false archiveof--commitfrom--repo(default: the main checkout, which sees every pushed branch) with.source_commit. The repository is private, so the VM does not clone it. - Environment: a uv-managed Python 3.10 venv with
tools/modal/requirements-hybrid.txt(themace_cueq_oeqpins). certifi is raised to 2026.10.19 as in the local environment (OVERRIDES). OEq builds its host extension withg++, withoutnvcc, andOEQ_CUDA_DRIVER_LIBRARYpoints at Colab's/usr/lib64-nvidia/libcuda.so.1. The commands run in a clean environment, without the Colab kernel's variables (MPLBACKEND,TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD). - Models:
--modelstakes checkpoint names fromfastmace.model.labels(defaultmh-1,medium,polar-1-s, the compatibility tier's). The VM downloads them from their official URLs and accepts each one only if its SHA-256 matches the label table. Nothing large is uploaded from the PC. - Structures:
--baseline(default~/fastmace-baseline/1a-T18, under 1 MB) goes up with the source asFASTMACE_STRUCTURESandFASTMACE_BASELINE. - Results:
<out>/out/holdsprobe.json(GPU, capability, driver, CPUs, RAM),setup.json(Python, package versions, thepip freezesha256, setup times),pip-freeze.txtandcmd<n>/.<out>/colab.jsonrecords the provider, GPU, capability, driver, versions, commit, commands and exit codes, the setup and wall times, the cache key, and the compute units before and after (colab usage). - Ledger: every call, a failed one too, appends a row to
~/.fastmace-colab/colab-ledger.csv(--ledgerto change it). Like the Modal ledger, the conductor commits copies toresults/colab-ledger.csv.
No Google Drive. colab drivemount needs the owner's browser consent on every new VM: on 23 October
2026 a second VM got HTTP 400 without a fresh consent. A Drive-backed environment therefore cannot run
unattended. The driver keeps everything else in a local store, ~/.fastmace-colab
(FASTMACE_COLAB_STATE):
- Environment: installed on every new VM. It takes about a minute, because uv fetches the wheels at
datacenter speed. --build-env sets up the environment without a command and writes
manifests/<stack>.json, the pip freeze and its sha256. Every later job compares its own freeze against
it (env_matches_manifest in colab.json).
- Compile caches: Triton, Inductor, FastMACE's cache root with the OEq extension builds, MACE's compile
cache and the CUDA cache, under one root on the VM. The store keeps them per key
sm<capability>-py310-<stack>, where <stack> is the sha256 of the requirements and the capability comes
from the VM's nvidia-smi, not from a table. Each key is a directory of parts, 000.tar.gz (the first
run's caches) and then one part per later run with only the files that run added or changed. The driver
uploads every part before the commands. Most of the volume is Inductor's FX graph cache: about 1.5 GB, or
513 MB gzipped, after the compatibility tier on a G4.
- Large uploads: colab upload sends a file as one base64 JSON request, which fails above the Jupyter
server's request limit (a 513 MB file did). The driver uploads files over 48 MiB in chunks (64 MiB went up, 80 MiB failed with HTTP 400), and
remote_step.py joins them on the VM.
- Colab-only packages: EXTRAS adds dftd3==1.6.0 (simple-dftd3, the [d3-reference] extra), so that the
16 simple-dftd3 cross-check tests run instead of skipping.
- Driver library: Colab keeps libcuda.so.1 in /usr/lib64-nvidia. It is among OEq's driver candidates
(fastmace/kernels/_oeq_runtime.py), and remote.sh also sets OEQ_CUDA_DRIVER_LIBRARY. Without it, OEq
failed on the A100 pilot and the planner fell back to plain cuEq.
- Batch the tiers: a cold tier is several times slower than a warm local one (A100 pilot: 43.5 min for
the fast tier, against 7.9 min locally warm). Give one call several --commands rather than one VM per
tier.
Measured on a G4 (23 October 2026, docs/validation/ops-colab-g4-20261023.md):
- Setup: a VM is ready to run commands about a minute after the call starts (56 s cold, 81 s for the
environment build). With the compile caches it takes 204 s, because uploading the 513 MB cache takes 154 s.
- The compatibility tier: 40.8 min of tests cold and 36.6 min warm. The caches save about 10 %, about
100 s net of the upload: most of the tier is runtime, not compilation.
- Compute units: 9.4 CU for the cold job (about 13 CU/h). cu_used is the change in the account's
balance, so it includes every other VM that was active (active_before, active_after in colab.json).