Skip to content

Parallelism

Chromatix is designed to be parallelized, both on a single device as well as across multiple devices (e.g. multiple GPUs).

What this parallelism looks like is heavily application-dependent, so chromatix does not enforce any particular mode of parallelism, but is written with the potential for multiple kinds of parallelism through jax.vmap, jax.pmap, and distributed jax.Array.

We describe two major categories of parallelization here, and show some examples of how specific simulations in chromatix might be parallelized.

The first category is explicit parallelization, where we directly specify along which dimensions a computation will be parallelized. This is useful when we have some knowledge about what parts of a simulation can be run independently, and can actually be simpler to reason about and program than implicit parallelization.

The second category is implicit parallelization across multiple devices, which allows jax.jit to automatically parallelize a simulation and has the advantage of allowing for functions to be written as if the computation were being performed on a single device after sharding the inputs across multiple devices. However, this style of parallelization can require some experimentation to choose the correct initial placement of arrays across multiple devices so that jax.jit can choose the optimal parallelization.

Explicit parallelization

A common style of parallelism is across a batch dimension. Chromatix already allows Field objects to have arbitrary batch dimensions. This means that on a single device, any computations across the batch and wavelength dimensions are already performed in parallel. It is possible to parallelize additional dimensions using jax.vmap on a single device.

But here, we are concerned with parallelization across multiple devices (e.g. 4 NVIDIA A100 GPUs). For example, we can look at the widefield PSF example from the README (but adjusted so that the workload is large enough to observe some benefit from parallelization). Here is the single device version:

import jax
import jax.numpy as jnp
import numpy as np

from chromatix.systems import OpticalSystem
from chromatix.elements import FFLens, ObjectivePointSource, PhaseMask

num_devices = 4
num_planes_per_device = 32
num_planes = num_devices * num_planes_per_device
shape = (1536, 1536)  # number of pixels in simulated field
spacing = 0.3  # spacing of pixels for the final PSF, microns
spectrum = 0.532  # microns
f = 100.0  # focal length, microns
n = 1.33  # refractive index of medium
NA = 0.8  # numerical aperture of objective
z = jnp.linspace(-4, 4, num=num_planes)  # planes to compute PSF at
optical_model = OpticalSystem(
    [
        ObjectivePointSource(shape, spacing, spectrum, f, n, NA),
        PhaseMask(jnp.ones(shape)),
        FFLens(f, n),
    ]
)


@jax.jit
def compute_psf(z):
    return optical_model(z).intensity


widefield_psf = compute_psf(z)

This takes 25.06ms to run on an NVIDIA A100 GPU (averaged over 10 runs, not including the time to JIT compile the function).

We can explicitly parallelize on multiple devicess by running different chunks of the batch dimension (which in this simulation represents depth) on different devices, because we know each plane can be simulated independently. We can do this using jax.pmap:

@jax.pmap
def compute_psf(z):
    return optical_model(z).intensity


# Place our input (z) on multiple devices
z = jax.device_put_sharded(
    [
        chunk_z
        for chunk_z in jnp.linspace(-4, 4, num=num_planes).reshape(
            num_devices, num_planes_per_device
        )
    ],
    jax.devices()[:num_devices],
)
widefield_psf = compute_psf(z)

This takes 6.45ms to run on 4 NVIDIA A100 GPUs (averaged over 10 runs). This means we have scaled in speed almost linearly with the number of devices, which makes sense because this computation is totally independent.

Note that we did not have to change anything in the definition of the optical system in order to obtain this parallelization! We were able to simply transform the function that computes the intensity and correspondingly change how we pass the z values that we want to simulate. The point of the reshaping is to allow jax.pmap to map the function compute_psf over z values in chunks of 32 across all 4 devices to simulate the same 128 planes of the PSF.

Let's look at an example where the computation isn't completely independent across multiple devices: simulating a PSF and then simulating imaging with this PSF. We can also show how to do this when there are parameters that must be initialized on each device; in this case, we initialize the phase mask pixels of the 4f system on each device. First, let's look at the single device version:

from functools import partial

import jax
import jax.numpy as jnp
import numpy as np

from chromatix.elements import BasicSensor
from chromatix.systems import Microscope, Optical4FSystemPSF

num_devices = 4
num_planes_per_device = 32
num_planes = num_devices * num_planes_per_device
shape = (1536, 1536)  # number of pixels in simulated field
spacing = 0.3  # spacing of pixels for the final PSF, microns
spectrum = 0.532  # microns
f = 100.0  # focal length, microns
n = 1.33  # refractive index of medium
NA = 0.8  # numerical aperture of objective


def init_microscope(phase):
    microscope = Microscope(
        system_psf=Optical4FSystemPSF(
            shape=shape, spacing=spacing, phase=phase
        ),
        sensor=BasicSensor(
            shape=shape,
            spacing=spacing,
            resampling_method=None,
            reduce_axis=0,
        ),
        f=f,
        n=n,
        NA=NA,
        spectrum=spectrum,
    )
    return microscope


@jax.jit
def compute_image(microscope, volume, z):
    return microscope(volume, z)


volume = jnp.ones((num_planes, *shape))  # fill in your volume here
z = jnp.linspace(-4, 4, num=num_planes)
phase = jnp.zeros(shape)
microscope = init_microscope(phase)
widefield_image = compute_image(microscope, volume, z)

Here, we constructed a Microscope with a 4f system PSF, but this time we initialized the Microscope on each device with its own phase parameter. Note that this Microscope also accepted a BasicSensor with a reduce_axis argument, which we have specified to sum across the batch dimension (axis 0) to simulate a camera collecting light from multiple planes. This computation ran in 172.86ms on a single NVIDIA A100 GPU (average over 10 runs).

Just like last time, we can parallelize this to multiple devices along the batch dimension by using jax.pmap:

@partial(jax.pmap, axis_name="devices")
def init_microscope(phase):
    microscope = Microscope(
        system_psf=Optical4FSystemPSF(
            shape=shape, spacing=spacing, phase=phase
        ),
        sensor=BasicSensor(
            shape=shape,
            spacing=spacing,
            resampling_method=None,
            reduce_axis=0,
            reduce_parallel_axis_name="devices",
        ),
        f=f,
        n=n,
        NA=NA,
        spectrum=spectrum,
    )
    return microscope


@partial(jax.pmap, axis_name="devices")
def compute_image(microscope, volume, z):
    return microscope(volume, z)


volume = jnp.ones(
    (num_devices, num_planes_per_device, *shape)
)  # fill in your volume here
volume = jax.device_put_sharded([chunk for chunk in volume], jax.devices()[:num_devices])
z = jax.device_put_sharded(
    [
        chunk_z
        for chunk_z in jnp.linspace(-4, 4, num=num_planes).reshape(
            num_devices, num_planes_per_device
        )
    ],
    jax.devices()[:num_devices],
)
phase = jnp.zeros((num_devices, *shape))
microscope = init_microscope(phase)
widefield_image = compute_image(microscope, volume, z)

This time, we ended up having to make a change to how we define the optics. That is because each device first computes a partial image of just the chunk that it received, which are summed across the batch dimension on each device (because we specified reduce_axis). So, we need to make sure that we are summing these partial images together across all the devices. We can tell jax to do that by using jax.lax.psum, which happens internally in the BasicSensor because we specified reduce_parallel_axis_name in addition to reduce_axis. Now, each device has a copy of the same final image.

That means that if we look at the shape of widefield_image, we'll see that it has shape (4 1536 1536) because we ran on 4 devices. Each of those 4 2D images is identical. However, because we computed these images in parallel, this version ran in 51.71ms on 4 NVIDIA A100 GPUs (average over 10 runs). Because of the fact that we have to sum the image across all the devices, you can see this computation does not scale as well (though is still many times faster than the single device version).

Implicit parallelization

Using jax.Array, it is possible to have jax.jit handle the parallelism across multiple devices.

This requires that you specify how jax should split up any input arrays across multiple devices (this is referred to as "sharding" an array). Then, any function written as if for a single device will be automatically parallelized across multiple devices when it is compiled through jax.jit with a sharded input array.

For now, we still recommend explicitly parallelizing your simulations for maximum performance.