7 · Running on GPU servers¶
Smoke runs and tests fit on any CPU; paper-scale runs belong on a CUDA server. nefi is CUDA-first
but device-agnostic: device="auto" picks $NEFI_DEVICE if set, else CUDA if available, else
CPU (Apple MPS only when requested explicitly, since it lacks float64 and some FFT kernels). The workflow is: develop and smoke-test locally → sync code → run on
the server (directly or via SLURM) → pull back the small reports.
1. Sync the code¶
HOST=gpu-server scripts/sync_to_server.sh # rsync to gpu-server:~/nefi (REMOTE_DIR=... to change)
HOST=gpu-server scripts/sync_to_server.sh --dry-run # see what would be sent
HOST=gpu-server DELETE=1 scripts/sync_to_server.sh # also delete remote files removed locally
Never sent: runs/, outputs/, .git/, caches, virtualenvs, build artefacts and tensors
(*.pt, *.npz, …). HOST is any ssh alias from ~/.ssh/config. (Alternatively git clone
on the server.)
2. Environment (once)¶
ssh gpu-server
cd ~/nefi
conda env create -f environment.yml && conda activate nefi # python 3.11, torch, nefi[dev]
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
nefi list
The pip torch wheel on Linux bundles its CUDA runtime; to pin a CUDA version install torch from
the matching index (pip install torch --index-url https://download.pytorch.org/whl/cu121) before
pip install -e ".[dev]". requirements.txt lists the same dependencies for plain virtualenvs.
3. Run¶
scripts/run_server.sh configs/nv_relaxometry_paper.yaml # nefi run … --device cuda
scripts/run_server.sh thermal_tomography --set preset=paper --scene layered --seed 1
NEFI_CMD=bench scripts/run_server.sh configs/toy1d.yaml --n 16 --seeds 0,1,2
CUDA_VISIBLE_DEVICES=1 SKIP_INSTALL=1 scripts/run_server.sh deconvolution --plot
run_server.sh activates the conda env nefi (creating it from environment.yml if missing; set
VENV=/path for a virtualenv), installs the package (skip with SKIP_INSTALL=1), prints the
torch/CUDA versions and runs nefi $NEFI_CMD <target> --device cuda --out
runs/<target>-<cmd>-<timestamp>, teeing the log to <out>.log. Everything after the target is
passed to nefi. For long jobs without SLURM use tmux/nohup; nefi runs are independent
per measurement, so several GPUs are used by launching several jobs with different
CUDA_VISIBLE_DEVICES.
4. SLURM¶
mkdir -p runs/slurm # SLURM does not create the log directory
sbatch --export=ALL,TARGET=configs/nv_relaxometry_paper.yaml scripts/slurm_template.sbatch
# three seeds as an array job (seed = array index)
sbatch --array=0-2 --export=ALL,TARGET=thermal_tomography,NEFI_ARGS="--set preset=paper" \
scripts/slurm_template.sbatch
# a whole benchmark in one job (values with commas cannot go inside --export=...: export them)
export NEFI_CMD=bench TARGET=configs/toy1d.yaml NEFI_ARGS="--n 16 --seeds 0,1,2"
sbatch --export=ALL scripts/slurm_template.sbatch
The template requests 1 GPU, 8 CPUs, 32 GB and 12 h (edit partition/account for your cluster),
activates the conda env, sets NEFI_DEVICE=cuda, OMP_NUM_THREADS and unbuffered output, prints
the GPU, and writes to runs/slurm/<target>-<cmd>-<jobid>-s<seed>.
Large benchmarks: shards as an array job, then merge¶
A benchmark is classes × samples × seeds independent (class, sample, seed) units, each solved
by every method. nefi bench --shard i/n runs the units k ≡ i (mod n), so the shards are
balanced and every method of a unit runs in the same job. Each shard writes
shard-III-of-NNN.json, and nefi bench-merge DIR concatenates the shards into the table the
unsharded run would have produced (BenchmarkResult.merge in Python):
export NEFI_CMD=bench NEFI_SHARDS=8 TARGET=configs/deconvolution_full.yaml
export NEFI_ARGS="--n 32 --seeds 0,1,2 --batched"
JOB=$(sbatch --parsable --array=0-7 --export=ALL scripts/slurm_template.sbatch)
DIR=runs/slurm/deconvolution_full-bench-${JOB} # all shards land here (array job id)
sbatch --dependency=afterok:${JOB} --export=ALL,NEFI_CMD=bench-merge,TARGET=${DIR} \
scripts/slurm_template.sbatch
# or by hand once the array is done: nefi bench-merge runs/slurm/deconvolution_full-bench-<id>
With NEFI_SHARDS set, the template passes --shard ${SLURM_ARRAY_TASK_ID}/${NEFI_SHARDS} and
writes every task into runs/slurm/<target>-bench-<array job id>. --batched solves all runs of
a batchable method (FFT convolution, NV, deconvolution, Poisson, Born, holography, Radon, …) in one
optimization loop (nefi.batch_invert), which fills a GPU that single small problems leave
launch-bound. The heat, wave and elliptic operators are not batchable and run one problem at a
time; for them, shard across array tasks or GPUs instead.
5. Bring results back¶
Pull mode copies only reports — *.md, *.json, *.csv, *.yaml, *.png, *.log,
*.out — never result.pt or checkpoints, so the local copy stays small. Every run directory is
self-describing: config.yaml is the effective configuration (nefi run runs/<dir>/config.yaml
reproduces the run), metrics.json has metrics, timings and the result's config_hash, and
benchmark directories have summary.md / rows.csv / benchmark.json.
Time and memory expectations¶
Measured by the papers (single GPU, full budgets):
| workload | settings | time | memory |
|---|---|---|---|
| NeTMY, per measurement | 64², 2 stages, 10k steps, 4.4 × 10⁵ params (A6000) | median 273 s, trimmed mean 426 s, mean 780 s (dense many/close scenes take longest) | small (FFT operators) |
| NeTMY free-density baselines | Tikhonov / ADMM | 1.5 s / 1.1 s | — |
| NeTMY GaussianSplat / DeepDecoder | 402 s / 259 s | — | |
| NeFTY, per specimen | 64×64×16, 100 frames, 10k steps, 2.4 M params (RTX PRO 6000) | 9.6 min (57.5 ms/step, incl. ≈ 11 s torch.compile) |
4.3 GB peak |
| NeFTY Grid Opt. | same solver | 8.0 min | 1.2 GB |
| NeFTY solver level (50 steps) | discrete adjoint vs autograd | fwd 0.46 s / bwd 0.50 s vs 1.43 s / 1.30 s | 21.9 MB vs 18.63 GB |
Before launching a campaign, measure your own problem:
nefi bench <target> --n 1 --seeds 0 --budget-scale 0.02 --device cuda # ms/step, peak memory
python tools/profile_instance.py <instance> --device cuda --steps 100 # per-step profile
and extrapolate linearly in steps (efficiency_table() reports ms/step and peak memory);
nefi.bench.runtime_table times the operator alone (forward/backward, peak memory). Memory of
time-stepping operators grows with the number of steps unless they use an adjoint
(HeatOperator(grad_mode="adjoint")) or checkpointing (TimeStepper(grad_mode="checkpoint")).
Performance knobs¶
See docs/performance.md for measurements, the memory table and CUDA estimates.
torch.compile: set it withSolver(compile="field" | "step"),solver: {compile: step}in a config file, or--set solver.compile=step."field"compiles the field;"step"compiles field → operator → losses (forward and backward) as one graph, once per curriculum stage (annealing does not recompile). The first step of each stage runs eagerly to fill caches. It pays a one-time compile of seconds to minutes and helps on long CUDA runs.compile=Truestill means"field". Addcuda_graphs=True(--set solver.cuda_graphs=true) formode="reduce-overhead", which replays each step as one CUDA graph. Leave all of this off for tests, CPU and MPS. If compilation fails, nefi logs a warning and runs eagerly.- Heat solver (NeFTY):
configs/thermal_tomography_paper.yamlnow setscompile_solver: true, so the Jacobi sweeps are fused (paper setting; one-time compile of roughly 10–40 s).--set solver=chebyshev --set jacobi_iters=20reaches the accuracy of the paper's 50 Jacobi sweeps with about 2.5× fewer sweeps (opt-in). - Mixed precision:
Solver(autocast="bf16")or--set solver.autocast=bf16runs the field MLP on tensor cores while the physics stays fp32."fp16"adds loss scaling. This is a CUDA feature; on a CPU bf16 is slower. - Throughput:
nefi bench --batched [--batch-size N]andnefi.batch_invert(problems)solve many measurements and seeds of a batchable problem in one loop.--shard i/ntogether withnefi bench-mergesplits a benchmark over SLURM array tasks (above). - dtype: float32 by default;
--set solver.dtype=float64for stiff physics (not on MPS). Data generators simulate in float64 on the CPU by default. - Budgets — the curriculum dominates run time;
--budget-scaleandCurriculum.scaled(f)scale every stage,Curriculum(time_budget_s=...)stops at a wall-clock budget,early_stop_patience/discrepancy_taustop when the data stop improving or the noise level is reached (tutorial 8). - Threads: on shared CPU nodes set
OMP_NUM_THREADS; the SLURM template does. CPU runs of the time-stepping instances (heat, wave) are fastest single-threaded. For those, useOMP_NUM_THREADS=1and parallel shards rather than intra-op threads. - Profile before optimizing: run
python tools/profile_instance.py <instance> --config configs/<instance>_smoke.yaml --device cuda. It reports ms/step, ops/step (≈ kernel launches) and the top ops; add--table/--trace.
Checkpoints and resuming¶
from nefi.solve import CheckpointCallback
ckpt = CheckpointCallback("runs/nv-long/ckpt", every=500) # ckpt_step500.pt, …, ckpt_stage0.pt
result = nefi.Solver(problem, cur, device="cuda", seed=0, callbacks=[ckpt]).run()
Each checkpoint stores the field and operator state_dicts and the history. Stage boundaries are
exact resume points, because the solver builds a fresh optimizer and resets the annealing at every
stage start:
state = torch.load("runs/nv-long/ckpt/ckpt_stage0.pt", map_location="cpu")
problem = inst.build_problem(meas) # same config, same measurement (same data seed)
problem.field.load_state_dict(state["field"])
problem.operator.load_state_dict(state["operator"])
remaining = nefi.Curriculum(cur.stages[1:], optim=cur.optim)
result = nefi.Solver(problem, remaining, device="cuda", seed=0).run()
(Mid-stage checkpoints restore the parameters but not the optimizer moments or the position in
the learning-rate schedule; for a GridField, call field.on_stage_start(stage, domain) with the
checkpoint's stage first so the parameter shapes match.)
Reproducibility¶
Runs are seeded (--seed; benchmarks seed before building every problem), configurations are saved
next to results and hashed (Result.config_hash), and data generation is deterministic per data
seed. GPU kernels are not bitwise deterministic by default; for exact reproduction call
nefi.utils.seed_everything(seed, deterministic=True) (slower).