Multi-GPU scalability
June 15, 2026 · View on GitHub
PathGennie's swarm is embarrassingly parallel: the N sampler segments in a
cycle are independent. v0.2.0 spreads them across every GPU on the machine
through one backend-agnostic abstraction, the ParallelExecutor.
What was wrong before
The AMBER/GROMACS backends launched their swarm through a bare
ThreadPoolExecutor with no device assignment, so every worker inherited the
same environment and contended for GPU 0; the extra GPUs sat idle. Trials also
wrote files with colliding names into a single scratch directory.
How it works now
ThreadDevicePool round-robins trial i onto devices[i % len(devices)] and
runs them in a thread pool. Each MD segment:
- exports
CUDA_VISIBLE_DEVICES=<device>for its assigned GPU, and - writes into an isolated per-device scratch subdirectory (
dev0/,dev1/, …) with a unique file stem.
The subprocess backends (pmemd.cuda, gmx mdrun) spend their wall-time inside
subprocess.run, which releases the GIL, so the threads genuinely run
concurrently across GPUs. For the in-process OpenMM engine, construct one engine
per worker process (one CUDA context per GPU).
SerialExecutor is the single-device reference path; with one device a
ThreadDevicePool reproduces it exactly for a fixed seed.
Enabling it
In input.yaml:
pathgennie:
max_trial: 16
devices: [0, 1, 2, 3] # spread 16 samplers across 4 GPUs
workers_per_device: 1 # raise for small systems that under-fill a GPU
seed: 42
workers_per_device allows more than one concurrent segment per GPU (useful when
a single small system cannot saturate a card). The legacy tau1_workers key is
still accepted as an alias.
In Python
from pathgennie.core.parallel import ThreadDevicePool, SerialExecutor
from pathgennie.core.driver import PathGennieDriver
executor = ThreadDevicePool(devices=[0, 1, 2, 3], workers_per_device=2)
driver = PathGennieDriver(engine, progress, convergence_fn,
executor=executor, sigma=0.2, seed=7)
# ... driver.run(...)
Reproducibility
A single master seed seeds the driver's RNG, which produces both the selection
draw and every per-segment seed. With a deterministic integrator, G-device and
serial runs match bit-for-bit. (A stochastic thermostat's noise stream is not
reliably re-seedable per segment in OpenMM, so only the selection/velocity draws
are guaranteed reproducible there — see backends/openmm/engine.py.)
Benchmarking
benchmarks/scaling.py measures cycles/second versus the number of devices using
a GIL-releasing sleep engine that models a GPU segment of a known cost, so the
dispatch overhead of the executor itself is what's measured:
python benchmarks/scaling.py --devices 1 2 4 8 --max-trial 16 --max-cycle 20 --segment-ms 15
Representative output (overhead-dominated toy, not a physics result):
devices wall (s) cycles/s speedup
1 3.894 3.85 1.00x
2 2.080 7.21 1.87x
4 1.170 12.82 3.33x
8 0.730 20.54 5.33x
Real speedup is bounded by min(N, G) and by the serial runner (τ2) step.