Multi-GPU guide
This guide shows how to evaluate forces and run a jz-fmm simulation across multiple GPUs. We first use four GPUs controlled by one Python process, which is convenient for interactive development. The same calculation is then adapted to multiple hosts, and finally extended with distributed snapshot output.
The complete example is available as a
Python script.
A brief shard_map recap
The distributed implementation runs inside a JAX
shard_map. Code in
the mapped function sees the arrays stored on one GPU and can explicitly
communicate with the other shards. This local view is useful for particle
distributions because every device can have a different number of valid
particles in fixed-size padded arrays.
jz-fmm uses the shard_map helpers provided by
jz-tree. That guide
contains a more detailed introduction to the programming model. The
.smap helper constructs a mapped function with the expected
partitioning and static arguments.
Single-host multi-GPU execution
In the single-host case, one Python process controls all local GPUs. Start by creating a one-dimensional mesh containing every visible device:
import jax
import jax.numpy as jnp
from jax.sharding import AxisType, PartitionSpec as P
import jztree
import jzfmm
mesh = jax.sharding.Mesh(jax.devices(), ("gpus",), axis_types=(AxisType.Auto,))
print(mesh)
Example output on four GPUs:
Mesh('gpus': 4, axis_types=(Auto,))
Here, P("gpus") would partition an ordinary array along the named mesh axis.
jz-tree additionally accepts P(-1), meaning that particle arrays are split
over every axis of the supplied mesh. This keeps the code independent of the
mesh-axis names and also works for meshes with more than one axis.
Create distributed particle data
For simplicity, we generate 20 Gaussian clumps per GPU, with random centers throughout the same volume and a different random key on each device.
def make_gaussian_clumps(npart, npad, nclumps=20, seed=0):
rank, ndev, _ = jztree.comm.get_rank_info()
key = jax.random.fold_in(jax.random.key(seed), rank)
key_center, key_bulk, key_label, key_pos, key_vel = jax.random.split(key, 5)
centers = 30.0 * jax.random.normal(key_center, (nclumps, 3))
bulk_velocities = -2.0 * centers + 20.0 * jax.random.normal(key_bulk, (nclumps, 3))
labels = jax.random.randint(key_label, (npart,), 0, nclumps)
pos = centers[labels] + jax.random.normal(key_pos, (npart, 3))
vel = bulk_velocities[labels] + 20.0 * jax.random.normal(key_vel, (npart, 3))
particles = jzfmm.data.Particles(
pos=pos,
vel=vel,
mass=jnp.full(npart, 1.0 / (npart * ndev), dtype=pos.dtype),
num=jnp.asarray(npart, dtype=jnp.int32),
num_total=npart * ndev,
)
return jztree.data.pad_particles(particles, npad)
make_gaussian_clumps.smap = jztree.jax_ext.shard_map_constructor(
make_gaussian_clumps,
in_specs=(None, None, None, None),
out_specs=P(-1),
static_argnames=("npart", "npad", "nclumps", "seed"),
)
num is the number of valid particles in the local padded array and may change at various points of the simulation, while
num_total is the total number of valid particles on all devices and considered static. Both are
required for distributed particle data. Padding provides temporary capacity
when tree construction redistributes particles between devices.
particles = make_gaussian_clumps.smap(mesh, jit=True)(
npart=10_000_000,
npad=2_000_000,
nclumps=20,
seed=7,
)
print(particles.pos.shape)
print(particles.num)
print(particles.num_total)
On four GPUs, the arrays outside the mapped function have an additional leading device axis:
(4, 12000000, 3)
[10000000 10000000 10000000 10000000]
40000000
Invalid entries beyond num are padding and must not be interpreted as
particles. If redistribution exhausts this capacity, increase npad. This is
separate from the allocation factors in FMMConfig, which control internal
tree, interaction-list, and communication buffers.
Evaluate distributed forces
fast_multipole_method.smap evaluates the global particle interaction while
returning one result shard per device. The FMM performs the required
communication internally.
cfg = jzfmm.SimConfig(units=jzfmm.UnitConfig(mass_in_msol=1.0e12))
loc = jzfmm.fmm.fast_multipole_method.smap(mesh, jit=True)(
particles,
cfg_fmm=cfg.force,
G=cfg.units.G(),
)
loc.values.block_until_ready()
print(loc.values.shape)
(4, 12000000, 4)
The last axis contains the potential and its three spatial derivatives.
The local expansion has the same padded particle layout as the input. Its potential and force values are globally correct: they include source particles held by every device, not only particles in the local shard.
Run the simulation
The simulation interface differs from the single-GPU case only in the use of
.smap:
ts = jnp.linspace(0.0, 0.2, 21)
particles = jzfmm.time_integration.simulate.smap(mesh, jit=True)(particles, ts=ts, cfg=cfg)
particles.pos.block_until_ready()
Sharded code typically takes longer to compile than single-GPU code; expect
roughly 10 seconds for the first simulation call on the tested setup. Later calls with the same
particle shapes, number of time steps, and configuration reuse the compiled
program. simulate calls the distributed FMM at every step because it is
executing inside the mapped context.
Keeping the complete simulation inside shard_map is preferable to repeatedly
moving data between global and device-local views. Ordinary JAX operations can
be used inside the mapped function, while global reductions can be expressed
with collectives such as jax.lax.psum.
Multi-host multi-GPU execution
In a multi-host run, several Python processes jointly control the global device
mesh. Call jax.distributed.initialize() before querying devices or performing
any other JAX computation:
import jax
jax.distributed.initialize()
mesh = jax.sharding.Mesh(
jax.devices(),
("gpus",),
axis_types=(jax.sharding.AxisType.Auto,),
)
The remainder of the Python program is unchanged. jax.devices() describes
the global device set after distributed initialization, and every process must
execute the same mapped operations in the same order.
If you encounter an error, check the known issues page for possible workarounds.
Launch details depend on the cluster. A common arrangement is one process per GPU. Assign several CPU cores to each process: communication can be very slow with too few CPU cores. For example, request eight CPUs per task in the batch allocation and launch with:
srun --cpus-per-task=8 python multi_gpu_simulation.py
The batch allocation must provide the matching numbers of tasks, GPUs, and CPU cores. A single process controlling several GPUs is useful for interactive development, but one process per GPU will often provide better communication performance on a cluster.
See jz-tree’s example Slurm script for a complete batch allocation example. With the downloaded example, run:
srun --cpus-per-task=8 python multi_gpu_simulation.py
For one Python process controlling all local GPUs, use
python multi_gpu_simulation.py --single-host. If your Slurm launch uses
--gpus-per-task=1 and exposes only one GPU per process, pass
--local-device-id=0 to the script.
Writing distributed snapshots
Gathering a 40-million-particle snapshot onto one host would require additional memory and communication. Instead, each device can write its valid local shard through a host callback. The files belonging to one output time together form the complete snapshot.
The callback below writes a .npz file with keys pos and vel.
Including both the snapshot index and global device rank prevents
different processes from writing to the same file.
from pathlib import Path
import numpy as np
from jax.experimental import io_callback
output_dir = Path("snapshots")
def write_snapshot(snapshot, rank, pos, vel, num):
output_dir.mkdir(parents=True, exist_ok=True)
num = int(num)
filename = output_dir / f"snapshot_{int(snapshot):04d}_rank_{int(rank):04d}.npz"
np.savez(filename, pos=pos[:num], vel=vel[:num])
def simulate_and_write(particles, tstart, tend, nout, steps_per_output, cfg):
rank, _, _ = jztree.comm.get_rank_info()
def step(i, particles):
t0 = tstart + i * (tend - tstart) / nout
t1 = tstart + (i + 1) * (tend - tstart) / nout
ts = jnp.linspace(t0, t1, steps_per_output + 1)
particles = jzfmm.time_integration.simulate(particles, ts=ts, cfg=cfg)
io_callback(
write_snapshot, None, i + 1, rank,
particles.pos, particles.vel, particles.num,
)
return particles
return jax.lax.fori_loop(0, nout, step, particles)
simulate_and_write.smap = jztree.jax_ext.shard_map_constructor(
simulate_and_write,
in_specs=(P(-1), None, None, None, None, None),
out_specs=P(-1),
static_argnames=("nout", "steps_per_output", "cfg"),
)
Continue from the previous simulation’s final time, writing five snapshots
spaced by 100 integration steps.
The entire loop runs inside the jitted shard_map:
particles = simulate_and_write.smap(mesh, jit=True)(
particles, tstart=0.2, tend=1.2, nout=5, steps_per_output=100, cfg=cfg,
)
particles.pos.block_until_ready()
jax.effects_barrier()
On four NVIDIA A100 GPUs, expect roughly 80 seconds for this example, including compilation and snapshot output. This estimate is based on the measured interval timings; runtime depends on the system and filesystem.
Only the final particle state is returned; snapshots are written after each
interval. jax.effects_barrier() waits for outstanding callbacks before the
program exits. The resulting files can be loaded with
np.load; for example, snapshot 4 on rank 2 is stored in
snapshot_0004_rank_0002.npz.
For a serious large simulation, use a format supporting compression, such as HDF5, and include additional data such as masses, particle IDs, snapshot time, units, and simulation parameters.