Multi-GPU Execution
torchlinops can distribute a linear operator across multiple GPUs. This is useful when a single GPU does not have enough memory for the full operator, or when independent tiles can run in parallel for faster execution.
This page covers both halves of multi-GPU execution:
- Splitting and placement — how a linop is tiled, moved to target devices, and reassembled.
- Synchronization — how
SyncContext, CUDA events, and streams ensure that parallel tiles do not read data before it is ready.
We assume CUDA devices with peer-to-peer memory access.
The splitting mechanism
At its core, multi-GPU distribution is built on the ability to split a linop into smaller sub-linops that each operate on a slice of the data.
split
Every NamedLinop implements split(linop, tile), where tile is a dictionary mapping dimension names to slices. The method returns a new linop that operates only on the specified slice.
For example, a Diagonal linop with shape (Nx, Ny) -> (Nx, Ny) and a weight tensor of shape (256, 256) can be split along Nx into two sub-linops, each with a weight of shape (128, 256).
from torchlinops.nameddim import NamedDimension as ND
tile = {ND("Nx"): slice(0, 128)}
sub_linop = NamedLinop.split(A, tile)
For adjoint splitting, use adj_split(linop, tile) which constructs the adjoint, splits it according to tile, and returns the adjoint of the split.
split_linop
The split_linop() function automates the tiling process. Given a linop and a dictionary of batch sizes, it:
- Queries
linop.size(dim)for every dimension to determine the total size. - Creates a grid of tiles, where each tile maps dimensions to
(index, slice)pairs. - Calls
split()for each tile, producing an nd-array of sub-linops.
from torchlinops.linops.split import split_linop
# Split a linop into chunks of 128 along Nx and 64 along Ny
linops, ibatches, obatches = split_linop(A, {"Nx": 128, "Ny": 64})
# linops is a 2D numpy array of sub-linops
Chain splitting
When splitting a Chain (a composition of linops), each constituent linop is split independently according to the slices over its own dimensions. This means that a chain \(C \circ B \circ A\) is split into tiles where each tile is a chain of the corresponding sub-linops.
The Chain.split() method distributes the tile dictionary to each constituent linop based on dimension names.
BatchSpec
BatchSpec is a dataclass that bundles all the information needed to split and distribute a linop:
| Field | Description |
|---|---|
batch_sizes |
dict[dim, int] -- how large each chunk should be |
device_matrix |
Optional array of torch.device objects, one per tile |
base_device |
The device where input/output tensors live |
The device_matrix is broadcast to match the tile grid shape. For example, if splitting creates a 4-tile grid and device_matrix = ["cuda:0", "cuda:1"], it is repeated to ["cuda:0", "cuda:1", "cuda:0", "cuda:1"]. The broadcasting uses a fuzzy strategy that tiles and truncates as needed, so the device list does not need to exactly match the number of tiles.
BatchSpec has a broadcast_device_matrix(linop) method that computes the number of tiles along each batched dimension and broadcasts the device matrix accordingly.
create_batched_linop
This is the main entry point for multi-GPU distribution. It takes a linop and one or more BatchSpec objects and returns a new composite linop that transparently handles splitting, device placement, and reassembly.
How it works
- Split the linop into tiles according to
batch_sizes. - Place each tile on its target device using
ModuleMemoryMap.memory_aware_to(), which preserves tensor storage topology (see Copying Linops). - Wrap each tile with
ToDevicelinops for input transfer (base -> target) and output collection (target -> base). - Reassemble tiles by reducing along each split dimension:
- If the dimension appears in both
ishapeandoshape: useConcat(the tiles partition the data along that dim). - If the dimension appears only in
ishapeor only inoshape: useConcatalong the relevant side. - If the dimension appears in neither: use
Add(the tiles produce partial results that must be summed).
- If the dimension appears in both
The result is a single composite linop that behaves identically to the original but executes across multiple devices.
Recursive batching
create_batched_linop accepts a list of BatchSpec objects and processes them recursively. This enables multi-level splitting -- for example, first splitting across GPUs along one dimension, then splitting within each GPU along another dimension for memory management.
Data transfer
DeviceSpec
DeviceSpec is a lightweight dataclass that holds useful CUDA-related objects for multi-GPU computation:
| Field | Description |
|---|---|
device |
The torch.device for this specification |
compute_stream |
Stream used for computation on this device |
transfer_stream |
Stream used for data transfers from this device |
DeviceSpec has a p2p_setup(other_device) method that configures compute and transfer streams for peer-to-peer transfers between devices. This is called automatically when creating ToDevice linops between CUDA devices. For a CUDA device, p2p_setup lazily creates:
compute_stream = default_stream(device)transfer_stream = Stream(device)(a new dedicated stream)
There is no global registry of transfer streams; each DeviceSpec owns its own transfer_stream.
ToDevice
ToDevice is a specialized linop that moves tensors between devices. It is the glue between the base device (where input/output data lives) and the target devices (where computation happens).
Key attributes:
| Attribute | Type | Description |
|---|---|---|
ispec |
DeviceSpec |
Source (input) device specification |
ospec |
DeviceSpec |
Target (output) device specification |
is_gpu2gpu |
bool |
True if both source and target are CUDA devices |
The adjoint of ToDevice(A -> B) is ToDevice(B -> A) -- it simply reverses the direction and swaps the device specs.
For CUDA-to-CUDA transfers, ToDevice uses non-blocking operations on dedicated streams:
Input on base_device
-> transfer_stream: non-blocking .to(target_device)
-> target_stream: wait for transfer, run computation
-> base_stream: collect output back to base_device
Key implementation details:
x.record_stream(transfer_stream)prevents PyTorch's caching allocator from freeing the source tensor's memory before the transfer completes.target_stream.wait_stream(transfer_stream)ensures the target stream does not start computation until the data has arrived.- The transfer stream is the
ispec.transfer_streamattribute set byp2p_setup.
Synchronization with SyncContext
Splitting a linop across GPUs is only half the story. The other half is making sure that asynchronous transfers do not outrun the producer of the data.
The problem
Suppose you build a multi-GPU linop with a CUDA base device:
base = torch.device("cuda:0")
gpu1 = torch.device("cuda:1")
OnDevice = Add(
Chain(ToDevice(base, base), A0),
Chain(ToDevice(base, gpu1), A1),
threaded=True,
)
Add runs its two children in parallel threads. The second child transfers the input tensor from cuda:0 to cuda:1 on a dedicated transfer stream. If the input tensor is still being produced by earlier GPU work on cuda:0's default stream, that transfer must wait until the work is done. Without a barrier, the transfer could copy partial or stale data.
SyncContext is the lightweight object that carries that barrier.
The SyncContext dataclass
SyncContext lives in torchlinops.linops.namedlinop. It is created fresh on every forward() call and carries four fields:
@dataclass
class SyncContext:
linop: type[NamedLinop] # Class of the linop that owns this context
input_device: torch.device # Device of the input tensor
parent: Optional[SyncContext] # Calling context (None at the top level)
start_event: torch.cuda.Event | None # Barrier event for CUDA sync
start_event is the important field. It marks "everything before this point is done" on the input device's stream.
How events are created
SyncContext.__post_init__ decides whether to create, reuse, or skip the event:
- CPU input (
input_device.type != "cuda") → no event. Synchronization is a no-op on CPU. - Reuse parent's event → if
parentis a container (is_container=True) andparent.start_eventexists, reuse it. This means: "I am a child of a parallel container; my siblings and I all share the same starting barrier." - Record a fresh event → if no event was reused and the current linop is a container (
is_container=True), record a fresh event on the current stream ofinput_device. This means: "I am a parallel container; I am the barrier for my children." - No event → otherwise. This happens for non-container children of non-containers (sequential execution on the same stream).
Only four linops are containers: Add, Concat, Stack, and Chain. They set is_container = True.
Opt-in via signature inspection
Not every linop participates in synchronization. NamedLinop.forward() inspects the signature of self.fn at runtime:
if "context" in inspect.signature(self.fn).parameters:
context = SyncContext(...)
return self._run(x, context)
return self._run(x)
Only linops whose fn declares a context parameter receive a SyncContext. This keeps simple linops (Dense, Diagonal, FFT, Identity, ...) completely free of sync overhead.
| Linop | Declares context? |
is_container? |
Role |
|---|---|---|---|
Add |
yes | yes | records barrier, shares it with children |
Concat |
yes | yes | records barrier, shares it with children |
Stack |
yes | yes | records barrier, shares it with children |
Chain |
yes | yes | records barrier, forwards to first child only |
ToDevice |
yes | no | consumer — waits on start_event |
Dense, Diagonal, FFT, Identity, ... |
no | no | unaffected, zero overhead |
How parallel_execute shares one event
Add, Concat, and Stack run their children through parallel_execute() from torchlinops.linops.schedule. The key detail is that every child is called with the same container context:
return parallel_execute(
linops,
inputs,
context, # the container's own SyncContext
reduce_fn=...,
threaded=...,
num_workers=...,
)
Each child's forward() then builds a new SyncContext with parent=context. Because the parent is a container with a start_event, the child reuses that event. The result is N children waiting on one barrier, rather than each child recording its own event.
When threaded=True, the children run in a ThreadPoolExecutor; when threaded=False, they run sequentially. In both cases the same context is passed, so synchronization is correct either way.
How Chain propagates context
Chain is a container, but it runs its children sequentially. It therefore forwards the context to only the first child:
def fn(chain, x, context=None):
x = chain[0](x, context) # first child inherits the barrier
for linop in chain.linops[1:]:
x = linop(x) # remaining children run sequentially
return x
This is correct for the usual multi-GPU pattern Chain(ToDevice(base, gpu), A, ToDevice(gpu, base)):
- The leading
ToDeviceneeds the barrier so it does not transfer input before it is ready. Aruns sequentially after the transfer, so it needs no explicit sync.- The trailing
ToDevicealso runs sequentially afterA; it creates a fresh context withparent=Noneand falls back to waiting on the current stream of its input GPU, which already includesA's work.
ToDevice as the event consumer
ToDevice.fn is the only place start_event is actually consumed:
def fn(todevice, x, context):
return todevice._fn(
x,
todevice.ispec,
todevice.ospec,
wait_for_event=context.start_event,
)
For GPU-to-GPU transfers, _gpu2gpu_transfer does:
if wait_for_event is not None:
transfer_stream.wait_event(wait_for_event)
else:
transfer_stream.wait_stream(current_stream(x.device))
with torch.cuda.stream(transfer_stream):
out = x.to(odevice, non_blocking=True)
x.record_stream(transfer_stream)
target_stream.wait_stream(transfer_stream)
If context.start_event is present, the transfer stream waits on that event. If it is absent (for example, a ToDevice called directly without a container), the transfer stream waits on the current stream of the source device instead.
A complete worked example
The following pattern is the heart of multi-GPU execution in torchlinops (adapted from the test suite). The base device is cuda:0; one tile runs locally on cuda:0, the other is transferred to cuda:1:
import torch
from torchlinops import Add, Chain, Dense, Dim, Sleep, ToDevice
from torchlinops.nameddim import NamedDimension as ND
base = torch.device("cuda:0")
gpu1 = torch.device("cuda:1")
N = 8192
def slow_linop(N: int, sleep_duration: float = 0.1):
weight = torch.randn(N, N)
dense = Dense(weight, Dim("MN"), Dim("N"), Dim("M"))
sleep = Sleep(sleep_duration, ioshape=(ND("M"),))
return sleep @ dense
A0 = slow_linop(N)
A1 = slow_linop(N)
OnDevice = Add(
Chain(
ToDevice(base, base, ioshape=A0.ishape),
A0.to(base),
ToDevice(base, base, ioshape=A0.oshape),
),
Chain(
ToDevice(base, gpu1, ioshape=A1.ishape),
A1.to(gpu1),
ToDevice(gpu1, base, ioshape=A1.oshape),
),
threaded=True,
)
x = torch.randn(N, device=base)
y = OnDevice(x) # runs A0 and A1 in parallel on two GPUs
Here is the event trace for the CUDA synchronization:
OnDevice(x)→Add.forward(x, context=None).Add.fnacceptscontext, soforward()createsSyncContext(linop=Add, input_device=base, parent=None).Addis a container with CUDA input and no parent event, so it records a fresh eventE0onbase's current stream.-
E0marks: "the input tensorxis ready oncuda:0." -
Add.fncallsparallel_execute([Chain0, Chain1], [x, x], AddCtx, threaded=True). - Two worker threads start.
-
Each worker calls
Chain_i(x, AddCtx)with the sameAddCtx. -
Worker for
Chain0(local,cuda:0): Chain0.forward(x, AddCtx)createsSyncContext(linop=Chain, input_device=base, parent=AddCtx).- Parent
Addis a container with eventE0, soChain0reusesE0. Chain0.fncallsChain0[0](x, Chain0Ctx)=ToDevice(base, base).- Same device →
_gpu2gpu_transferreturnsxunchanged (no transfer needed). -
A0runs onbase's default stream; the trailingToDevice(base, base)is also a no-op. -
Worker for
Chain1(remote,cuda:1): Chain1.forward(x, AddCtx)createsSyncContext(linop=Chain, input_device=base, parent=AddCtx).- Parent
Addis a container withE0, soChain1reusesE0. Chain1.fncallsChain1[0](x, Chain1Ctx)=ToDevice(base, gpu1).ToDevice.forward(x, Chain1Ctx)createsSyncContext(linop=ToDevice, input_device=base, parent=Chain1Ctx).- Parent
Chainis a container withE0, soToDevicereusesE0. ToDevice.fn→_gpu2gpu_transfer→transfer_stream.wait_event(E0). The transfer togpu1cannot start untilE0completes.- After the transfer,
A1runs ongpu1's default stream. - The trailing
ToDevice(gpu1, base)transfers the result back tobase.
The key result: both workers share the same event E0. As soon as E0 completes, A0 starts on cuda:0 and the transfer to cuda:1 begins concurrently. Once the transfer arrives, A1 starts on cuda:1, overlapping with A0.
Nested composites
Events propagate correctly through nested containers. Consider:
Concatrecords eventE0.- The inner
AddreceivesConcat's context, reusesE0, and passesE0toAandB. CreceivesConcat's context and reusesE0.
One barrier synchronizes the entire tree.
Edge cases
- CPU inputs:
SyncContext.__post_init__skips all event logic wheninput_device.type != "cuda". The context is created, butstart_eventstaysNone, so there is no overhead. - Direct
ToDevicecalls: callingToDevice(gpu0, gpu1)(x)outside any container creates a context withparent=None.ToDeviceis not a container, so no event is recorded;wait_for_event=None, and the transfer falls back totransfer_stream.wait_stream(current_stream(x.device)). threaded=False:parallel_executestill passes the same context to every child, so synchronization remains correct. Sequential mode is useful for debugging.
Visualizing the sync graph
For debugging, you can log the logical synchronization graph with torchlinops.cuda_trace.cuda_logger:
import torchlinops.config as config
from torchlinops.cuda_trace import cuda_logger
with config.using(log_cuda_events=True, log_device_transfers=True):
y = OnDevice(x)
print(cuda_logger.display(reset=True))
The output shows:
●record — an event was recorded (marks completion of prior work).◇wait — a stream is waiting for an event or another stream.○implicit — an auto-created node such as the default stream at operation start.←blocks on — a node cannot start until the target completes.
Nodes are grouped into topological stages; operations in the same stage can run in parallel, and later stages are blocked on earlier ones. This is the easiest way to confirm that multi-GPU transfers really do wait on the shared barrier and that compute kernels overlap across devices.
Base device and stream layout
The base device is the device on which the input is required and on which the final output is produced. It orchestrates all transfers. For a two-GPU setup with cuda:0 as base, the default stream layout is:
cuda:0(base)- default stream: computation on
cuda:0 - transfer stream: moving tensors between
cuda:0andcuda:1 cuda:1- default stream: computation on
cuda:1
Configuration
Two config flags are useful for debugging multi-GPU execution:
torchlinops.config.log_device_transfers(defaultTrue): logs device-transfer messages fromToDeviceand related utilities.torchlinops.config.log_cuda_events(defaultFalse): records CUDArecord_event/wait_event/wait_streamcalls as a labeled dependency graph forcuda_logger.display().
import torchlinops.config as config
config.log_device_transfers = True # log transfers
config.log_cuda_events = True # log event graph
Limitations and future work
- Peer-to-peer access: The current implementation assumes efficient P2P memory access between GPUs. Systems without P2P will fall back to staging through host memory, which is slower.
- Manual tuning: Choosing optimal
batch_sizesanddevice_matrixrequires understanding the model's memory footprint and the hardware topology. No auto-tuning is provided. - Single-node only: Running computations on distributed GPU nodes across multiple servers is possible in principle via standard PyTorch distributed APIs, but no simplified API is provided within this library.
- Autograd and shared linops: Parallel containers such as
Add(A, A)use the same linop object in multiple threads. For inference this is safe, but backpropagating through such a construct can race on parameter gradients because PyTorch's gradient accumulation is not atomic across threads. If you need gradients from a threaded multi-GPU linop, give each tile its own copy of the weights (for example, viacreate_batched_linop, which places independent copies on each device).