Running peclet on Snellius (SURF)¶
Consolidated runbook: the toolchain, how to build, how to queue, and the traps that have actually
cost time. The authoritative scripts are
peclet-examples/examples/wall-bounded-turbulence/{snellius_env.sh,install_snellius.sh} — this
document explains them and the conventions the benchmark job scripts share.
Coordinates¶
| host | snellius (SSH config alias; int4.local.snellius.surf.nl etc.) |
| account | tes24005 (every #SBATCH --account) |
| project space | /projects/0/prjs1022/peclet — the suite lives at .../peclet/suite |
| the venv | $SUITE/.venv — one venv for everything, as on the workstation |
| CPU partition | genoa — 192 cores/node, 336 GiB; --exclusive |
| GPU partition | gpu_h100 — 4× H100 94 GB/node, 16 cores/GPU (also gpu_a100) |
Toolchain — the 2024a stack¶
snellius_env.sh is sourced by build and run scripts so they cannot disagree:
module load 2024
module load gompi/2024a # GCC 13.3 + OpenMPI + UCX 1.16
module load CUDA/12.6.0
module load UCX-CUDA/1.16.0-GCCcore-13.3.0-CUDA-12.6.0
module load Python/3.12.3-GCCcore-13.3.0
export OMPI_MCA_pml=ucx UCX_MEMTYPE_CACHE=n # GPU-aware MPI
The 2024a stack is the one that satisfies both Kokkos 5.1.1 (CUDA ≥ 12.2) and GPU-aware MPI
(UCX-CUDA exists for this CUDA). On the older 2023 stack those two were mutually exclusive. The
script fails fast with a module avail dump if a name has drifted — fix names there, in one place.
Module versions on Snellius do drift. The one hard requirement is that the OpenMPI loaded here
is the same one mpi4py was pip-built against, since pip install mpi4py compiles against
whatever mpicc is on PATH. Never mix module stacks between build and run.
Installing a release (tools/hpc/)¶
For a released family, use the site-install script instead of the campaign tree: it clones the
umbrella at the tag into its own tree + venv, bootstraps Kokkos for the backend and builds every
package with PECLET_*_MPI=ON — peclet-morton, peclet-geom, peclet-halo (built by core/),
the pure-Python peclet-core compatibility shell, peclet-flow, -pnm, -dem, -voro, peclet-amr
and peclet-coupling — then import-checks the canonical modules under
-W error::DeprecationWarning. It leaves a site-specific wheelhouse other project members can
pip install --no-index --find-links from (never upload those wheels to PyPI — they link the
module OpenMPI and CUDA):
cd $PROJ/suite-v<family> # SUBMIT FROM INSIDE THE TREE (see the traps below)
sbatch --nodes=1 --gpus-per-node=1 --ntasks-per-node=1 tools/hpc/install_snellius.sh v<family> h100
sbatch -p gpu_a100 --nodes=1 --gpus-per-node=1 --ntasks-per-node=1 tools/hpc/install_snellius.sh v<family> a100
sbatch -p genoa --gpus-per-node=0 --ntasks=1 --cpus-per-task=32 tools/hpc/install_snellius.sh v<family> cpu
sbatch tools/hpc/smoke_snellius.slurm # certifies it: 1- vs 8-rank permeability, dem step_mpi
Arguments are positional (SURF's sbatch drops leading VAR=x); snellius_env.sh next to it is the
shared module recipe. The pre-built containers are the other route (containers).
Three traps, all hit on this script's first real run (2026-09-16, peclet 1.1.0) and all now fixed in the script — the recipe above is the corrected one; see RELEASE §7.
- Submit from inside the tree. sbatch copies the script to
/var/spool/slurm/..., so the$SLURM_SUBMIT_DIRfallback is what findssnellius_env.sh; submitting from the parent resolves to a nonexistent$PROJ/tools/hpcand the job dies on a bareNo such file or directory. - Each backend gets its own tree —
$PROJ/suite-<tag>-<backend>.h100anda100both build thenvidia-cudaprefix at different arch, and the script doesvenv --clearon$SUITE/.venv, so two backends in one tree destroy each other whether run together or in sequence. --gpus=0does not override#SBATCH --gpus-per-node=1on a GPU-less partition; use--gpus-per-node=0or sbatch rejects the job.
Validated releases¶
| family | backend | tree | job | outcome |
|---|---|---|---|---|
| v1.1.0 | cpu (genoa) | suite-v1.1.0-cpu |
26813727 | OK — flow OpenMP has_mpi True; smoke 26814834 CERTIFIED (see below) |
| v1.1.0 | h100 | suite-v1.1.0-h100 |
26813725 | OK — flow Cuda has_mpi True; smoke 26813984 queued (gpu_h100 was at 56 alloc / 12 resv; est. start 2026-09-17T12:30) — result will be suite-v1.1.0-h100/peclet-smoke-26813984.out, compare its two k lines |
| v1.1.0 | a100 | suite-v1.1.0-a100 |
26813726 | OK — flow Cuda has_mpi True; smoke 26815101 CERTIFIED (see below) |
Each wheelhouse ($PROJ/wheelhouse/v1.1.0-<backend>/) carries all seven packages at the released
versions: flow 1.1.0, core/dem/pnm/voro 1.0.2, morton/coupling 1.0.1, cp312, linked against the
module OpenMPI + CUDA 12.6 — site-specific, never upload these to PyPI. Project members install
with pip install --no-index --find-links $PROJ/wheelhouse/v1.1.0-<backend> peclet-flow.
$PROJ/suite-v1.1.0 (no backend suffix) is the tree the release was submitted from; it carries
tools/ only and has no venv.
Smoke certification, v1.1.0 cpu/genoa (job 26814834, 4 ranks). The first fully green run of
smoke_snellius.slurm in its family-wide form:
flow OpenMP has_mpi=True | dem OpenMP step_mpi=True | voro OpenMP VoronoiHalo=True | pnm OpenMP mpi=True | coupling=True
np=1: k=5.845422163491e+00 div=1.847e-13 block(rank0)=origin[0,0,0] size[32,32,32]
np=4: k=5.845422163491e+00 div=1.847e-13 block(rank0)=origin[0,0,0] size[16,16,32]
[OpenMP] np=4 grid=1x2x2 global=48x96x96 per-rank=48^3=110592 cells (~53 spheres/tile)
per-step: max 205.657 ms min 205.656 ms imbalance 0.0% pressure_iters=9 0.54 Mcell/s/rank
k identical to all thirteen digits at np=1 and np=4 — the distributed solve on the site install is
bit-exact to single-rank, which is what this job exists to prove.
Smoke certification, v1.1.0 a100 (job 26815101, 4 GPUs).
flow Cuda has_mpi=True | dem Cuda step_mpi=True | voro Cuda VoronoiHalo=True | pnm Cuda mpi=True | coupling=True
np=1: k=5.845422163491e+00 div=1.847e-13
np=4: k=5.845422163491e+00 div=1.847e-13
[Cuda] np=4 grid=1x2x2 global=48x96x96 per-rank=48^3
per-step: max 103.604 ms min 103.603 ms imbalance 0.0% pressure_iters=9 1.07 Mcell/s/rank
The CUDA k equals the OpenMP k to all thirteen digits (5.845422163491e+00), at the same
div = 1.847e-13 and the same 9 pressure iterations. Two backends, one answer — direct evidence for
the standing position that a backend change is a faithful port, not a re-derivation. The A100 runs
the tile at 103.6 ms/step against genoa's 205.7 ms (1.07 vs 0.54 Mcell/s/rank), which is a timing
difference and not a numerical one.
The h100 smoke is queued, not yet run (job 26813984, submitted 2026-09-17T00:03, 4 GPUs). Its
install is verified and the CUDA backend is already certified by the a100 run above, so this job is a
third confirmation rather than an open question — but until it completes, the h100 row is
install-verified, not smoke-certified. To finish it: read
$PROJ/suite-v1.1.0-h100/peclet-smoke-26813984.out, check the np=1 and np=4 k lines are identical
(expect 5.845422163491e+00, the same value both other backends gave), and fill in the row.
It took three submissions to get there, and the two failures were real defects in the repo, not the
install: benchmarks/profile_mpi_flow.py still called set_body_force(fx, fy, fz) (repacked into
one 3-sequence by 1.0.0) and then set_velocity_solver_params / last_pressure_iterations on the
Solver (moved to solver.diagnostics by the 1.0.0 tiering). Both had been broken since 1.0.0 and
neither is covered by CI. Five more of the same set_body_force defect were found in
pnm/scripts/. None of it was visible to a static audit — nothing was renamed — which is the
argument for this job existing at all.
The releases validated this way are also recorded in RELEASE_PREP (Snellius section) by the release procedure (RELEASE §7).
Building the development tree¶
cd <peclet-examples>/examples/wall-bounded-turbulence
sbatch install_snellius.sh h100 # -> peclet-build-<jobid>.out; CHECK "has_mpi: True"
sbatch install_snellius.sh cpu # the OpenMP/CPU build, for genoa
FRESH=1 sbatch install_snellius.sh h100 # clean rebuild — REQUIRED when CUDA version/arch changes
It pulls the umbrella, git submodule update --init --recursive (core and flow must be at
matching umbrella-pinned commits), rebuilds the venv, re-bootstraps Kokkos for the backend, and
configures flow with -DPECLET_FLOW_MPI=ON. Products:
| target | backend | build dir |
|---|---|---|
h100 |
nvidia-cuda, HOPPER90 |
$SUITE/flow/build_cuda_mpi |
a100 |
nvidia-cuda, AMPERE80 |
$SUITE/flow/build_cuda_mpi |
cpu |
host-openmp |
$SUITE/flow/build_omp_mpi |
Build gotchas that have bitten:
-DPython_EXECUTABLEwith a capital P. The lowercase spelling is silently ignored and CMake falls back to the system python, which has no nanobind.- A venv has no
Python.h— pass-DPython_INCLUDE_DIR=$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY"))'). - FindPython's artifact variables are sticky. Once a build dir has configured (even
unsuccessfully) with the wrong interpreter, re-running with the right
-DPython_EXECUTABLEchanges nothing. Alwaysrm -rfthe build dir. (Hit again on the workstation 2026-09-01: abuild_mpicached against the retired per-projectflow/.venvfailed with "Cannot run the interpreter" until wiped.) dem'sPecletDeps.cmakesilently falls back instead of failing: withnvccabsent (env not sourced)find_package(Kokkos)fails and it FetchContent-builds a vendored OpenMP+Serial host Kokkos without erroring. Read the configure output: no[peclet] building+installing kokkosline, and Kokkos reporting deviceCUDA.- Never
mva venv — absolute paths are baked intoactivateand every console script, and the failure is silent (pythondisappears;python3/pipfall through to/usr/bin). If the directory moves, delete and recreate.
Submitting jobs¶
Conventions every benchmark script in peclet-examples/benchmarks/*/snellius/ follows:
- Submit from inside the
snellius/directory. Scripts resolve../<driver>.py,../resultsand the sharedsnellius_env.shrelative to$SLURM_SUBMIT_DIR. - Rung selection is a positional argument, not an env var — SURF's
sbatchdrops leadingVAR=x sbatch …env vars.SUITE/BUILD/GPU_AWAREoverrides must beexported beforehand, or passed via--export=ALL,VAR=…. - Argument 2 is a result tag. Runs skip an existing JSON so a job is resumable after a timeout, which also means that after any solver change an untagged rerun silently reports the stale numbers. Change the tag.
- Nodes come from
--nodes=, the rank/GPU count from the argument; the script checks the two agree and refuses otherwise.
cd <peclet-examples>/benchmarks/<study>/snellius
sbatch --nodes=1 <study>_gpu.sh 4
sbatch --nodes=8 <study>_genoa.sh 1536 mgfix2
squeue -u $USER; scancel <jobid>; tail -f <job>-<jobid>.out
Launcher lines that work:
# GPU: one rank per GPU
srun --mpi=pmix --ntasks=$N --gpus-per-task=1 --gpu-bind=per_task:1 "$VENV/bin/python" driver.py
# CPU (genoa): pure MPI, one rank per core
srun --mpi=pmix --ntasks=$N --ntasks-per-node=$RPN --cpus-per-task=1 \
--distribution=block:block --cpu-bind=cores "$VENV/bin/python" driver.py
- Force
-DMPIEXEC_EXECUTABLE=/usr/bin/mpirunwhen configuring the ctest suites — FindMPI may pick ParaView's bundledmpiexecfromPATH, which launches the OpenMPI-linked binaries as singletons, so*_np4silently runs 4× np=1. - Bound the OpenMP pool:
OMP_NUM_THREADSexplicitly (=1for pure-MPI CPU runs),OMP_PROC_BIND=spread OMP_PLACES=coreswhen threading. An unbounded pool on a many-core host is a measured hour-long trap since the CUDA prefix gained an OpenMP host backend (2026-08-30). - genoa node-set variability is real — the same config measured 3.2 vs 8.0 s/step on different node sets. Report best-of or the spread, never a single draw.
- Memory:
--exclusivedoes NOT give you the node's memory. SLURM still caps the job at roughly 1792 MiB × ntasks (SURF's per-core default), so a job that holds a whole 336 GiB genoa node with only 24 ranks is limited to ~43 GiB and gets OOM-killed on a large grid — measured 2026-09-01, job 26280702,Detected 2 oom_kill events. Add#SBATCH --mem=0(all node memory) to any script whose low-rank rungs under-fill a node; at 192 ranks/node the per-task allowance already sums to the node, which is why only the sparse rungs fail and the fully packed ones pass. An OOM-killed rank also leaves the survivors hung in a collective, so drivers print a heartbeat; a run past ~10 min with no heartbeat progress is hung →scancel.
Result flow¶
Drivers write one JSON per (study, rung, variant) plus a .log beside it. Collect and plot on the
workstation; commit the JSONs (they are the reproducibility record), not the logs.
scp -r 'snellius:/projects/0/prjs1022/peclet/.../results/snellius-h100' results/
python plot_<study>.py
Pre-flight without a GPU¶
Decomposition and multigrid depth for any (grid, np) combination — a pure function of (ranks, grid, levels), so it needs no hardware:
PYTHONPATH=<build> python $SUITE/flow/scripts/check_decomposition.py \
--grid 400,400,400 --levels 5 --np 24 --mode 0,coarse
It is slow above ~a hundred ranks — run it in the background, one rank count at a time. Watch for: an odd axis never coarsens at all, and under MPI a level coarsens an axis only if every rank's block is even on it, so achievable depth is set by the per-rank block, not the global grid.
Pointers¶
docs/DECOMPOSITION_AND_MULTIGRID.md— the per-axis coarsening rule, aligned vs coarse-first partitions, and why grid dimensions' factors of two decide solver cost. Read before choosing a benchmark grid.docs/archive/COMMUNICATION_SCALING.md— the halo/smoother communication design the scaling studies test.peclet-examples/benchmarks/porous-scaling/README.md— the most complete worked runbook (two ladders, ablations, and a forensic history of a real GPU-only corruption bug).