Splitting a computation across many GPUs means the GPUs cannot work in complete isolation. At some point they need to exchange information — a gradient, an updated tensor, a piece of an image — before they can continue. That exchange is what we call communication, and this page looks inside it: not to ask how much communication there is, but what that communication is actually made of. That distinction is what lets you tell why adding more GPUs to a given section keeps paying off, slows down, or stops helping altogether — and, when it stops, whether the fix is rebalancing work across GPUs or something inherent to how much data has to move, rather than just knowing “communication is expensive” without knowing which problem you actually have.
What do we mean by “communication,” concretely? Every GPU-to-GPU exchange
in this benchmark happens through a collective operation — specifically an
all_reduce: every participating rank contributes its own tensor, the
collective combines the contributions (by summing them), and every rank ends
up with the same combined result. None of the actual workload — the neural
network, the physics operator — happens inside that operation; it exists
purely to keep every GPU’s copy of the data consistent. So the time spent on
one section of one iteration splits cleanly in two:
section time = compute + communication
compute is the useful work — the forward pass, the gradient evaluation —
and it is the part we are trying to speed up by adding more GPUs.
communication is everything else: the price paid for keeping those GPUs
coordinated.
Communication itself is not one thing. It splits again, into transfer
and wait:
communication = transfer + wait
transferis the real data movement — the actual bytes of a tensor travelling over NVLink or the network from one GPU to another. This part is genuinely necessary: the collective cannot produce a correct result without moving the data, and it would still happen even in a perfectly balanced, perfectly synchronized run.waitis idle time — a GPU sitting inside the collective operation doing nothing, because the operation cannot finish until every participating GPU has arrived at it.waithas up to three distinct causes, worth telling apart:- Uneven work. If the computation is split unevenly across GPUs, the
GPU given less work finishes its
computeearlier, then simply sits idle at the collective until its slower neighbor catches up — for instance, one GPU getting a different number of denoiser tiles, or a different number of physics operators, than another. More on this in “Are the GPUs actually staying balanced?” below. - Same work, different finishing time. Even when every GPU is handed an identical job, they do not necessarily finish at exactly the same wall-clock moment. Small hardware and system effects — scheduling jitter, memory contention, clock variation — mean any one GPU can randomly lag behind on a given iteration, with no actual imbalance in what it was asked to do.
- Different starting point. A GPU that enters the section already behind — because it was still catching up at the end of the previous section — carries that lag into this collective too, even if its own work here is perfectly balanced. Not a factor in the runs measured on this page: every iteration is closed out with a barrier, so every GPU starts each section from the same aligned point regardless of how the previous one went.
- Uneven work. If the computation is split unevenly across GPUs, the
GPU given less work finishes its
These causes all produce the same symptom — a GPU parked inside a collective, waiting — but they point to different fixes: the first is a workload assignment problem, the second is closer to irreducible system noise, and the third is a synchronization question that this benchmark’s barrier already rules out. What follows can tell you how much wait there is, separately from transfer, which is already far more than the profiler’s raw, fused number gives you.
Put together, one section’s time now has three additive parts:
section time = compute + transfer + wait
This split matters because reading the fused transfer + wait number as
“communication cost” can be badly wrong. In one of the runs measured below,
3D denoising looked like 0.87s of communication at 2 GPUs — but the data
actually being moved cost only 0.05s. The other 0.82s was one GPU waiting
for the other, not moving a single byte.
This page reruns the measurement for both inference and training with the collective time properly split into
compute / transfer / wait, to answer what that growing share is actually
made of.
- How does communication cost grow as GPU count increases, and what’s actually driving that growth — pure data movement, or one GPU waiting on another?
- Are the GPUs actually staying balanced, or is idle time quietly eating into what looks like healthy scaling?
- How much of the critical path is communication, and does the measured transfer time correspond to real, physical bandwidth — the sanity check that this methodology measures data movement and not leftover skew?
Experimental setting
What is being distributed. Every run on this page is the same kind of
distributed architecture: a plug-and-play (PnP) solver that alternates a
physics step — a gradient step through the acquisition model — with a
denoising step, a DRUNet acting as the learned image prior. The two are
parallelized in completely different ways. The acquisition operators are
distributed whole across the workers, one or more per GPU, so a section’s
critical path is set by the busiest GPU’s operator count. The image handed to
the denoiser is instead split into overlapping tiles, one group of tiles per
GPU; the overlap gives the network the spatial context it needs at tile
boundaries, at the cost of extra computation and extra data movement. Between
the two steps, the workers reconcile their copies with an all_reduce — that
collective is the communication this page takes apart. The inference runs
execute that alternation as a plain loop with fixed denoiser weights. The
training runs unroll it instead: a fixed number of iterations — five in 2D,
one in 3D — is treated as a single differentiable network and backpropagated
through, which is why training’s sections are forward and backward, each
containing both physics and denoiser work, rather than one section per step.
Experiments. Four result sets: inference’s denoiser and gradient
sections, and training’s forward and backward sections, each run on a 2D
problem (8192² multiframe super-resolution for inference, 4096² synthetic for
training) and a 3D (512³ volume) problem. All profiled every
iteration with torch.profiler, on Tesla V100 GPUs, 4 per node, up to 64
GPUs.
Configurations. Four benchmark configurations produce everything below —
one per result set, two under benchmark_inference/configs/experiments/ and
two under benchmark_training/configs/experiments/. Each figure on this page
names the ones it came from:
| Configuration | Problem | Operators | Patch / halo | GPU counts |
|---|---|---|---|---|
comm_inference_2D.yml | PnP, multiframe super-resolution, 8192², 3 channels, DRUNet | 8 | 512 / 32, batch 4 | 1, 2, 4, 8, 16, 32, 64 |
comm_inference_3D.yml | PnP, simulated 512³ volume, 1 channel, DRUNet | 2 | 8×256×256 / 2×16×16, batch 4 | 1, 2, 4, 8, 16, 32, 64 |
comm_time_2D.yml | Unrolled PnP (5 iterations), synthetic 4096², 3 channels, DRUNet | 4 | 512 / 32, batch 1, checkpointing on | 1, 2, 4, 8, 16, 32, 64 |
comm_time_3D.yml | Unrolled PnP (1 iteration), synthetic 512³, 1 channel, DRUNet | 4 | 64³ / 8, batch 4, checkpointing on | 4, 8, 16, 32, 64 |
Two details to read off that table before the figures. The operator counts differ by configuration — 8 and 2 for inference, 4 for both training runs — and that single number turns out to explain most of the wait behaviour below, so it is worth keeping in view. And 3D training starts at 4 GPUs, not 1: its baseline in the scaled charts is its own 4-GPU run, so its bars are not directly comparable in absolute terms to the three configurations that start from a single GPU.
Metrics, per the POP methodology:
transfer= the minimum kernel time across ranks — the last rank to arrive waits for nobody, so its time is pure transfer.wait= the average gap between that minimum and each rank’s own raw time.compute= the mean per-rank section time minus its own collective time.compute+transfer+waitare additive — they sum exactly to the section’s CUDA time.- Per-section load balance:
LB = compute / compute_max, wherecompute_maxis the critical-path (slowest) rank. - Combined load balance, summed across every section instead of one at a
time:
LB = sum(compute) / sum(compute_max). - Communication efficiency, summed the same way:
CommE = sum(compute_max) / total_sec, wheretotal_seciscuda_secsummed across every section. LB x CommEis the overall parallel efficiency — the fraction of each GPU-second spent on useful, balanced work.
How does communication cost grow, and what’s driving it?
Each bar below is scaled relative to the smallest GPU count tested, in GPU-seconds. The baseline bar is always 1.0 by construction — perfect linear scaling would keep every bar at 1.0 regardless of GPU count. A bar taller than 1.0 means that configuration spent more total GPU-seconds than ideal scaling would have, and the color split shows exactly how much of that excess is compute versus communication. The charts further down break that excess down again, into compute, transfer, and wait individually, in absolute seconds.
Inference: compute vs communication cost, scaled to baseline
Compute vs communication (transfer+wait) GPU-seconds per section, relative to the 1-GPU baseline.
Loading interactive figure…
Configurations: comm_inference_2D.yml (2D), comm_inference_3D.yml (3D).
View figure data
| Section | Dim | GPUs | Scaled compute | Scaled comm |
|---|---|---|---|---|
| denoiser | 2D | 1 | 0.985 | 0 |
| gradient | 2D | 1 | 0.015 | 0 |
| denoiser | 2D | 2 | 0.975 | 0.006 |
| gradient | 2D | 2 | 0.015 | 0.002 |
| denoiser | 2D | 4 | 0.961 | 0.007 |
| gradient | 2D | 4 | 0.015 | 0.003 |
| denoiser | 2D | 8 | 0.968 | 0.031 |
| gradient | 2D | 8 | 0.016 | 0.021 |
| denoiser | 2D | 16 | 0.969 | 0.154 |
| gradient | 2D | 16 | 0.019 | 0.157 |
| denoiser | 2D | 32 | 0.967 | 0.271 |
| gradient | 2D | 32 | 0.024 | 0.318 |
| denoiser | 2D | 64 | 0.971 | 0.542 |
| gradient | 2D | 64 | 0.035 | 0.676 |
| denoiser | 3D | 1 | 1 | 0 |
| gradient | 3D | 1 | 0 | 0 |
| denoiser | 3D | 2 | 1.007 | 0.004 |
| gradient | 3D | 2 | 0 | 0 |
| denoiser | 3D | 4 | 0.998 | 0.005 |
| gradient | 3D | 4 | 0 | 0 |
| denoiser | 3D | 8 | 1.006 | 0.037 |
| gradient | 3D | 8 | 0 | 0.002 |
| denoiser | 3D | 16 | 1.006 | 0.041 |
| gradient | 3D | 16 | 0.001 | 0.01 |
| denoiser | 3D | 32 | 1.003 | 0.043 |
| gradient | 3D | 32 | 0.001 | 0.021 |
| denoiser | 3D | 64 | 1.002 | 0.069 |
| gradient | 3D | 64 | 0.002 | 0.045 |
Training: compute vs communication cost, scaled to baseline
Compute vs communication (transfer+wait) GPU-seconds per section, relative to the smallest-GPU-count baseline.
Loading interactive figure…
Configurations: comm_time_2D.yml (2D), comm_time_3D.yml (3D). The 3D baseline is 4 GPUs, the smallest count that configuration runs.
View figure data
| Section | Dim | GPUs | Scaled compute | Scaled comm |
|---|---|---|---|---|
| forward | 2D | 2 | 0.238 | 0.013 |
| backward | 2D | 2 | 0.747 | 0.003 |
| forward | 2D | 4 | 0.227 | 0.004 |
| backward | 2D | 4 | 0.751 | 0.009 |
| forward | 2D | 8 | 0.225 | 0.023 |
| backward | 2D | 8 | 0.744 | 0.045 |
| forward | 2D | 16 | 0.227 | 0.103 |
| backward | 2D | 16 | 0.744 | 0.117 |
| forward | 2D | 32 | 0.231 | 0.182 |
| backward | 2D | 32 | 0.749 | 0.228 |
| forward | 2D | 64 | 0.235 | 0.388 |
| backward | 2D | 64 | 0.755 | 0.535 |
| forward | 3D | 4 | 0.221 | 0.003 |
| backward | 3D | 4 | 0.765 | 0.011 |
| forward | 3D | 8 | 0.221 | 0.009 |
| backward | 3D | 8 | 0.761 | 0.023 |
| forward | 3D | 16 | 0.219 | 0.016 |
| backward | 3D | 16 | 0.755 | 0.019 |
| forward | 3D | 32 | 0.22 | 0.025 |
| backward | 3D | 32 | 0.758 | 0.025 |
| forward | 3D | 64 | 0.22 | 0.05 |
| backward | 3D | 64 | 0.754 | 0.052 |
The pattern is consistent across both charts: 3D scales better than 2D, and training scales better than inference. By 64 GPUs, the combined cost sits at 1.08× ideal for training 3D, 1.12× for inference 3D, 1.91× for training 2D, and 2.22× for inference 2D — the gap between 3D and 2D (roughly 2×) dwarfs the gap between training and inference (a few percent either way).
The reason is a race between compute and transfer. The fair comparison
here is compute_max against transfer alone, not against transfer+wait:
compute_max is the critical-path rank’s own compute, and that rank (by
construction) has no wait of its own, so this comparison isn’t muddied by
idling — it’s asking whether the best-case rank still has more real work to
do than the unavoidable data it has to move. As GPU count grows, each GPU’s
share of compute shrinks, but transfer does not shrink at the same rate, so
the two get closer together. 2D inference comes right to the edge of that
crossover by 64 GPUs — combined critical-path compute (0.33s) is only 4%
above combined transfer (0.32s) — without quite crossing it in the range
tested. Training 2D keeps more distance (1.85s vs 1.13s, compute 64% above
transfer). Both 3D cases stay solidly compute-bound (inference 3D: 2.79s vs
0.21s, 13x; training 3D: 10.13s vs 0.66s, 15x) — the 3D problem is simply
much larger, leaving far more compute to outrun transfer with. This is why
2D inference is the closest of the four to becoming transfer-bound: its
compute shrinks fastest of all, bringing it nearest to the point where
transfer alone would exceed it.
Splitting the total between sections, the denoiser is the main
contributor: it makes up 98.5% of the 2D baseline and effectively all of
the 3D baseline (99.99%), and its own cost only grows modestly by 64 GPUs
(1.54× in 2D, 1.07× in 3D). The physics gradient starts from a tiny
baseline share (1.5% in 2D, 0.01% in 3D) but grows far faster in relative
terms — 47× its own baseline in 2D, 367× in 3D — making it the most
communication-exposed section even though its absolute contribution stays
small. Training’s forward and backward sit between these two extremes
(forward grows 2.49× in 2D vs 1.20× in 3D; backward grows 1.72× in 2D vs
1.04× in 3D).
To see exactly where that growth comes from — pure data transfer, or GPUs waiting on each other — the next figure breaks compute, transfer, and wait apart individually, in absolute seconds per section, 2D (solid) and 3D (dashed) overlaid on the same axes.
Inference: what's driving the growth -- compute, transfer, or wait?
Critical-path compute, transfer, and wait time per section vs GPU count, log scale. 2D and 3D overlaid on the same axes.
Loading interactive figure…
compute_max is the slowest rank's own compute time (the critical path), not the mean. Together with transfer, it reconstructs the section's real wall-clock time exactly, since the critical-path rank has zero wait of its own -- unlike figure 1's segments, these three lines are independent diagnostics and do not sum to anything: wait here is the average idle time across all ranks (mostly the non-critical-path ones), not a third term on top of compute_max + transfer. Configurations: comm_inference_2D.yml (2D), comm_inference_3D.yml (3D).
View figure data
| Section | Dim | GPUs | Compute (critical path, s) | Transfer (s) | Wait (s) |
|---|---|---|---|---|---|
| denoiser | 2D | 1 | 18.3257 | 0 | 0 |
| gradient | 2D | 1 | 0.2828 | 0 | 0 |
| denoiser | 2D | 2 | 9.1053 | 0.0201 | 0.0343 |
| gradient | 2D | 2 | 0.1418 | 0.0198 | 0.0026 |
| denoiser | 2D | 4 | 4.4934 | 0.0101 | 0.0235 |
| gradient | 2D | 4 | 0.0731 | 0.01 | 0.002 |
| denoiser | 2D | 8 | 2.2767 | 0.0484 | 0.0248 |
| gradient | 2D | 8 | 0.0384 | 0.0484 | 0.001 |
| denoiser | 2D | 16 | 1.158 | 0.1482 | 0.0315 |
| gradient | 2D | 16 | 0.0377 | 0.1645 | 0.0181 |
| denoiser | 2D | 32 | 0.5746 | 0.1479 | 0.0098 |
| gradient | 2D | 32 | 0.0376 | 0.1571 | 0.0281 |
| denoiser | 2D | 64 | 0.2903 | 0.1503 | 0.0072 |
| gradient | 2D | 64 | 0.0375 | 0.1653 | 0.0311 |
| denoiser | 3D | 1 | 171.9025 | 0 | 0 |
| gradient | 3D | 1 | 0.0218 | 0 | 0 |
| denoiser | 3D | 2 | 86.8749 | 0.0133 | 0.3444 |
| gradient | 3D | 2 | 0.0132 | 0.0139 | 0.0002 |
| denoiser | 3D | 4 | 43.0823 | 0.0067 | 0.1979 |
| gradient | 3D | 4 | 0.0132 | 0.0068 | 0.0047 |
| denoiser | 3D | 8 | 22.3888 | 0.031 | 0.7681 |
| gradient | 3D | 8 | 0.013 | 0.0319 | 0.0071 |
| denoiser | 3D | 16 | 11.1587 | 0.096 | 0.3494 |
| gradient | 3D | 16 | 0.0132 | 0.1004 | 0.0092 |
| denoiser | 3D | 32 | 5.5254 | 0.0989 | 0.1318 |
| gradient | 3D | 32 | 0.0131 | 0.1032 | 0.0094 |
| denoiser | 3D | 64 | 2.7725 | 0.1054 | 0.0793 |
| gradient | 3D | 64 | 0.0131 | 0.1059 | 0.0145 |
Training: what's driving the growth -- compute, transfer, or wait?
Critical-path compute, transfer, and wait time per section vs GPU count, log scale. 2D and 3D overlaid on the same axes.
Loading interactive figure…
Configurations: comm_time_2D.yml (2D), comm_time_3D.yml (3D).
View figure data
| Section | Dim | GPUs | Compute (critical path, s) | Transfer (s) | Wait (s) |
|---|---|---|---|---|---|
| forward | 2D | 2 | 12.798 | 0.0707 | 0.5991 |
| backward | 2D | 2 | 38.4217 | 0.0774 | 0.067 |
| forward | 2D | 4 | 5.882 | 0.0392 | 0.071 |
| backward | 2D | 4 | 19.4865 | 0.0434 | 0.1876 |
| forward | 2D | 8 | 2.9576 | 0.168 | 0.125 |
| backward | 2D | 8 | 9.7486 | 0.2148 | 0.3647 |
| forward | 2D | 16 | 1.6458 | 0.4547 | 0.2058 |
| backward | 2D | 16 | 4.9741 | 0.5419 | 0.2084 |
| forward | 2D | 32 | 0.8648 | 0.4194 | 0.1638 |
| backward | 2D | 32 | 2.5761 | 0.5296 | 0.203 |
| forward | 2D | 64 | 0.4761 | 0.4788 | 0.1443 |
| backward | 2D | 64 | 1.3703 | 0.647 | 0.212 |
| forward | 3D | 4 | 36.0704 | 0.0298 | 0.5019 |
| backward | 3D | 4 | 124.7272 | 0.016 | 1.74 |
| forward | 3D | 8 | 18.4556 | 0.133 | 0.5804 |
| backward | 3D | 8 | 62.8766 | 0.0838 | 1.7651 |
| forward | 3D | 16 | 8.9395 | 0.3874 | 0.2405 |
| backward | 3D | 16 | 30.9039 | 0.1916 | 0.5552 |
| forward | 3D | 32 | 4.5096 | 0.4022 | 0.1071 |
| backward | 3D | 32 | 15.5299 | 0.2123 | 0.2883 |
| forward | 3D | 64 | 2.2884 | 0.4037 | 0.1037 |
| backward | 3D | 64 | 7.8442 | 0.2552 | 0.2718 |
- Compute — denoiser vs. gradient. The denoiser’s compute keeps roughly
halving every time GPU count doubles, all the way to 64 GPUs (2D: 18.3s →
0.29s; 3D: 172s → 2.8s). The physics gradient does not: its compute
plateaus — 2D flattens at ~0.038s from 8 GPUs onward, 3D flattens at
~0.013s from 2 GPUs onward, and stays there no matter how many more GPUs
are added. This isn’t noise: the inference configs distribute a fixed
number of acquisition operators — 8 for 2D, 2 for 3D
(
comm_inference_2D.yml/comm_inference_3D.yml) — each assigned whole to one GPU. Once GPU count exceeds the operator count, the busiest GPU still holds exactly one operator, so more GPUs beyond 8 (2D) / 2 (3D) cannot shrink the critical-path compute any further. - Compute — training’s
forward/backward. Training distributes the same fixed operator count too (4, for both 2D and 3D), so the same plateau exists in principle — butforwardandbackwardbundle physics and the denoiser’s model computation into one section, and the denoiser dominates that total. Its larger, smoothly-scaling compute masks the smaller physics-operator plateau underneath it, so neither section shows a visible flattening here — both keep decreasing all the way to 64 GPUs. - Transfer. Grows with GPU count, in every section and dimensionality — then levels off at the largest sizes. One exception on the way up: 4 GPUs is consistently lower than 2 GPUs. We come back to both in What sets the transfer time? below.
- Wait. Generally a small percentage of compute — except for the physics gradient, for the same reason as above: once GPU count exceeds the operator count, some GPUs hold zero physics operators, finish instantly, and sit idle in the collective until the others catch up.
Are the GPUs actually staying balanced?
The physics gradient loses load balance well before any other section does
Wait time as a percentage of compute time, all four sections overlaid, per GPU count. Values above 100% mean the GPUs spent more time waiting than computing. Symlog scale.
Loading interactive figure…
Wait/compute is a different question from the share chart above: a section can have most of its absolute time in transfer (real data movement) while also having a large wait share (rank skew) at the same time -- the two are not mutually exclusive. Configurations: all four (comm_inference_2D.yml, comm_inference_3D.yml, comm_time_2D.yml, comm_time_3D.yml).
View figure data
| Study | Section | Dim | GPUs | Load balance (%) | Wait / compute (%) |
|---|---|---|---|---|---|
| inference | denoiser | 2D | 1 | 100 | 0 |
| inference | gradient | 2D | 1 | 100 | 0 |
| inference | denoiser | 2D | 2 | 99.6 | 0.4 |
| inference | gradient | 2D | 2 | 98.1 | 1.9 |
| inference | denoiser | 2D | 4 | 99.5 | 0.5 |
| inference | gradient | 2D | 4 | 97.4 | 2.8 |
| inference | denoiser | 2D | 8 | 98.9 | 1.1 |
| inference | gradient | 2D | 8 | 97.6 | 2.6 |
| inference | denoiser | 2D | 16 | 97.3 | 2.8 |
| inference | gradient | 2D | 16 | 58 | 83.1 |
| inference | denoiser | 2D | 32 | 97.9 | 1.7 |
| inference | gradient | 2D | 32 | 37.7 | 198.1 |
| inference | denoiser | 2D | 64 | 97.3 | 2.5 |
| inference | gradient | 2D | 64 | 27.5 | 301.8 |
| inference | denoiser | 3D | 1 | 100 | 0 |
| inference | gradient | 3D | 1 | 100 | 0 |
| inference | denoiser | 3D | 2 | 99.6 | 0.4 |
| inference | gradient | 3D | 2 | 99.6 | 1.4 |
| inference | denoiser | 3D | 4 | 99.5 | 0.5 |
| inference | gradient | 3D | 4 | 66.6 | 53.6 |
| inference | denoiser | 3D | 8 | 96.6 | 3.6 |
| inference | gradient | 3D | 8 | 50.7 | 106.9 |
| inference | denoiser | 3D | 16 | 96.9 | 3.2 |
| inference | gradient | 3D | 16 | 42.4 | 164.8 |
| inference | denoiser | 3D | 32 | 97.6 | 2.4 |
| inference | gradient | 3D | 32 | 38.5 | 185 |
| inference | denoiser | 3D | 64 | 97.1 | 2.9 |
| inference | gradient | 3D | 64 | 36.8 | 301.3 |
| training | forward | 2D | 2 | 95.4 | 4.9 |
| training | backward | 2D | 2 | 99.8 | 0.2 |
| training | forward | 2D | 4 | 99 | 1.2 |
| training | backward | 2D | 4 | 99 | 1 |
| training | forward | 2D | 8 | 97.8 | 4.3 |
| training | backward | 2D | 8 | 98.1 | 3.8 |
| training | forward | 2D | 16 | 88.5 | 14.1 |
| training | backward | 2D | 16 | 96.1 | 4.4 |
| training | forward | 2D | 32 | 85.7 | 22.1 |
| training | backward | 2D | 32 | 93.4 | 8.4 |
| training | forward | 2D | 64 | 79.4 | 38.2 |
| training | backward | 2D | 64 | 88.4 | 17.5 |
| training | forward | 3D | 4 | 98.6 | 1.4 |
| training | backward | 3D | 4 | 98.6 | 1.4 |
| training | forward | 3D | 8 | 96.4 | 3.3 |
| training | backward | 3D | 8 | 97.3 | 2.9 |
| training | forward | 3D | 16 | 98.4 | 2.7 |
| training | backward | 3D | 16 | 98.2 | 1.8 |
| training | forward | 3D | 32 | 98.1 | 2.4 |
| training | backward | 3D | 32 | 98.2 | 1.9 |
| training | forward | 3D | 64 | 96.5 | 4.7 |
| training | backward | 3D | 64 | 96.7 | 3.6 |
The denoiser stays under 4% wait at every GPU count, in both dimensionalities. That means each GPU genuinely gets the same amount of tile work — the residual few percent is just timing noise between otherwise identical GPUs, not real imbalance. Worth keeping in mind, though, that this balance is a property of the tiling, not something guaranteed: the denoiser’s load balance depends on how many tiles the image is split into relative to the number of GPUs. These runs are tiled favourably; a poorly chosen tiling — too few tiles to spread evenly across the ranks — would leave some GPUs with more tile work than others and degrade performance in exactly the same way the physics gradient does below.
The physics gradient tells a completely different story, and it tracks the operator count exactly. In 2D (8 operators), wait stays under 3% through 8 GPUs — one operator per GPU, perfectly balanced — then jumps to 83% at 16 GPUs and keeps climbing to 302% at 64, because every GPU added past 8 has zero operators and nothing to do but wait. In 3D (2 operators), the same thing happens two steps earlier: wait is low at 2 GPUs (one operator each), then jumps to 54% by 4 GPUs, for the identical reason.
This is normal and expected behaviour rather than a defect in the implementation, and with the current design it is essentially unavoidable. The PnP iteration is serialized — physics, then denoising, then physics again — so every GPU has to be present at the end of the physics step before the denoising step can start, whether or not it was given an operator to work on. With a fixed number of acquisition operators, once GPU count passes that number the surplus GPUs have nothing to do but wait at the collective. How far this can be avoided — overlapping the physics step with denoising, or splitting the operators themselves — is an open question, not something these measurements settle.
Training’s forward and backward are a mix of both stories, because — as
established earlier — they bundle physics and denoiser computation into one
section. When the denoiser dominates (3D, at any GPU count tested; 2D, at low
GPU counts), wait stays small, tracking the denoiser’s behavior. Once the
physics portion becomes a larger share of the work (2D, at higher GPU
counts), wait grows and lands between the pure-gradient and pure-denoiser
curves — forward reaches 38% and backward 17% at 64 GPUs in 2D, well
below gradient’s 302% but well above denoiser’s 3%.
Per-section wait tells you where imbalance shows up; load balance (LB) below restates it as an efficiency, and puts each section’s own LB (solid) next to the value summed across every section (dashed) on the same axis — so the gap between the worst section and the number you would quote for the run as a whole is directly visible.
Combined load balance stays high -- the physics gradient's does not
Load balance (LB) vs GPU count. Solid lines are inference's denoiser and gradient, each on its own LB; dashed lines are the combined value per study.
Loading interactive figure…
Section LB = compute / compute_max for that section alone. Combined LB = sum(compute) / sum(compute_max), where both sums add across every section first (denoiser+gradient, or forward+backward) before the ratio is taken -- it is a weighted average, not the mean of the solid lines, which is why it can sit far above the worst section. Only inference's sections are drawn individually: training's forward and backward stay close to each other, so its combined line stands in for both. Per-section values for all four sections are in the table below. Configurations: all four (comm_inference_2D.yml, comm_inference_3D.yml, comm_time_2D.yml, comm_time_3D.yml).
View figure data
| Study | Section | Dim | GPUs | Section LB (%) | Combined LB (%) |
|---|---|---|---|---|---|
| inference | denoiser | 2D | 1 | 100 | 100 |
| inference | gradient | 2D | 1 | 100 | 100 |
| inference | denoiser | 2D | 2 | 99.6 | 99.6 |
| inference | gradient | 2D | 2 | 98.1 | 99.6 |
| inference | denoiser | 2D | 4 | 99.5 | 99.4 |
| inference | gradient | 2D | 4 | 97.4 | 99.4 |
| inference | denoiser | 2D | 8 | 98.9 | 98.9 |
| inference | gradient | 2D | 8 | 97.6 | 98.9 |
| inference | denoiser | 2D | 16 | 97.3 | 96 |
| inference | gradient | 2D | 16 | 58 | 96 |
| inference | denoiser | 2D | 32 | 97.9 | 94.2 |
| inference | gradient | 2D | 32 | 37.7 | 94.2 |
| inference | denoiser | 2D | 64 | 97.3 | 89.3 |
| inference | gradient | 2D | 64 | 27.5 | 89.3 |
| inference | denoiser | 3D | 1 | 100 | 100 |
| inference | gradient | 3D | 1 | 100 | 100 |
| inference | denoiser | 3D | 2 | 99.6 | 99.6 |
| inference | gradient | 3D | 2 | 99.6 | 99.6 |
| inference | denoiser | 3D | 4 | 99.5 | 99.5 |
| inference | gradient | 3D | 4 | 66.6 | 99.5 |
| inference | denoiser | 3D | 8 | 96.6 | 96.5 |
| inference | gradient | 3D | 8 | 50.7 | 96.5 |
| inference | denoiser | 3D | 16 | 96.9 | 96.8 |
| inference | gradient | 3D | 16 | 42.4 | 96.8 |
| inference | denoiser | 3D | 32 | 97.6 | 97.4 |
| inference | gradient | 3D | 32 | 38.5 | 97.4 |
| inference | denoiser | 3D | 64 | 97.1 | 96.8 |
| inference | gradient | 3D | 64 | 36.8 | 96.8 |
| training | forward | 2D | 2 | 95.4 | 98.7 |
| training | backward | 2D | 2 | 99.8 | 98.7 |
| training | forward | 2D | 4 | 99 | 99 |
| training | backward | 2D | 4 | 99 | 99 |
| training | forward | 2D | 8 | 97.8 | 98 |
| training | backward | 2D | 8 | 98.1 | 98 |
| training | forward | 2D | 16 | 88.5 | 94.2 |
| training | backward | 2D | 16 | 96.1 | 94.2 |
| training | forward | 2D | 32 | 85.7 | 91.4 |
| training | backward | 2D | 32 | 93.4 | 91.4 |
| training | forward | 2D | 64 | 79.4 | 86.1 |
| training | backward | 2D | 64 | 88.4 | 86.1 |
| training | forward | 3D | 4 | 98.6 | 98.6 |
| training | backward | 3D | 4 | 98.6 | 98.6 |
| training | forward | 3D | 8 | 96.4 | 97.1 |
| training | backward | 3D | 8 | 97.3 | 97.1 |
| training | forward | 3D | 16 | 98.4 | 98.2 |
| training | backward | 3D | 16 | 98.2 | 98.2 |
| training | forward | 3D | 32 | 98.1 | 98.2 |
| training | backward | 3D | 32 | 98.2 | 98.2 |
| training | forward | 3D | 64 | 96.5 | 96.6 |
| training | backward | 3D | 64 | 96.7 | 96.6 |
By 64 GPUs, combined load balance stays relatively high everywhere (86–97%) —
even 2D inference, the worst performer overall, keeps 89%. That might look
surprising given the physics gradient’s own load balance collapses to 27%
(2D) / 37% (3D) at 64 GPUs, the lowest solid line on the chart — but combined
LB is a weighted
average (sum(compute) / sum(compute_max)), and by 64 GPUs the gradient is
still only 11% of the combined compute_max in 2D, and just 0.5% in 3D. Even
at that weight, an 11% share pulls the near-perfect denoiser figure (97%)
down to 89% — visible, but nowhere near as severe as the gradient’s own 27%
would suggest in isolation.
That weighting is the whole argument, and it rests on a property of this
setup: the acquisition models used here are not computationally heavy compared
with the denoiser, which is why the gradient can collapse to 27% load balance
and still barely move the combined figure. A heavier physics operator would
carry a much larger share of compute_max, and the same 27% would then drag
combined load balance down with it. We have no such example in this benchmark
yet, but the conclusion above should not be read as holding for any acquisition
model.
How much of the critical path is communication — and does it check out?
Communication efficiency asks a different question from load balance above.
Load balance is about imbalance between GPUs — mainly a wait, mainly-
gradient story, as just shown. CommE instead asks: even on a perfectly
balanced system, how much of the critical path would still be spent moving
data rather than computing? This isolates the transfer-driven cost of
scaling, which is a separate mechanism from imbalance.
Communication efficiency drops far more than load balance does
Communication efficiency (CommE) vs GPU count. Solid lines are inference's denoiser and gradient, each on its own CommE; dashed lines are the combined value per study.
Loading interactive figure…
Section CommE = compute_max / cuda_sec for that section alone. Combined CommE = sum(compute_max) / total_sec, where compute_max is summed across every section first (denoiser+gradient, or forward+backward) and total_sec is cuda_sec summed the same way -- one value per GPU count. As with load balance, only inference's sections are drawn individually; training's combined line stands in for its two, and per-section values for all four are in the table below. LB x CommE = parallel efficiency -- consistent with (though not identical to, since it's a different formula) the 1/scale figures in the first chart on this page. Configurations: all four (comm_inference_2D.yml, comm_inference_3D.yml, comm_time_2D.yml, comm_time_3D.yml).
View figure data
| Study | Section | Dim | GPUs | Section CommE (%) | Combined CommE (%) |
|---|---|---|---|---|---|
| inference | denoiser | 2D | 1 | 100 | 100 |
| inference | gradient | 2D | 1 | 100 | 100 |
| inference | denoiser | 2D | 2 | 99.8 | 99.6 |
| inference | gradient | 2D | 2 | 87.8 | 99.6 |
| inference | denoiser | 2D | 4 | 99.8 | 99.6 |
| inference | gradient | 2D | 4 | 87.8 | 99.6 |
| inference | denoiser | 2D | 8 | 97.9 | 96 |
| inference | gradient | 2D | 8 | 44.2 | 96 |
| inference | denoiser | 2D | 16 | 88.7 | 79.1 |
| inference | gradient | 2D | 16 | 18.4 | 79.1 |
| inference | denoiser | 2D | 32 | 79.8 | 66.6 |
| inference | gradient | 2D | 32 | 18.8 | 66.6 |
| inference | denoiser | 2D | 64 | 66 | 50.7 |
| inference | gradient | 2D | 64 | 18.2 | 50.7 |
| inference | denoiser | 3D | 1 | 100 | 100 |
| inference | gradient | 3D | 1 | 100 | 100 |
| inference | denoiser | 3D | 2 | 100 | 100 |
| inference | gradient | 3D | 2 | 48.5 | 100 |
| inference | denoiser | 3D | 4 | 100 | 100 |
| inference | gradient | 3D | 4 | 65 | 100 |
| inference | denoiser | 3D | 8 | 99.9 | 99.7 |
| inference | gradient | 3D | 8 | 28.6 | 99.7 |
| inference | denoiser | 3D | 16 | 99.1 | 98.3 |
| inference | gradient | 3D | 16 | 11.4 | 98.3 |
| inference | denoiser | 3D | 32 | 98.3 | 96.5 |
| inference | gradient | 3D | 32 | 11.2 | 96.5 |
| inference | denoiser | 3D | 64 | 96.4 | 92.8 |
| inference | gradient | 3D | 64 | 10.5 | 92.8 |
| training | forward | 2D | 2 | 99.4 | 99.7 |
| training | backward | 2D | 2 | 99.8 | 99.7 |
| training | forward | 2D | 4 | 99.2 | 99.6 |
| training | backward | 2D | 4 | 99.8 | 99.6 |
| training | forward | 2D | 8 | 92.8 | 95.3 |
| training | backward | 2D | 8 | 96.1 | 95.3 |
| training | forward | 2D | 16 | 77.7 | 86.6 |
| training | backward | 2D | 16 | 90 | 86.6 |
| training | forward | 2D | 32 | 65.3 | 77.1 |
| training | backward | 2D | 32 | 82.1 | 77.1 |
| training | forward | 2D | 64 | 47.6 | 60.1 |
| training | backward | 2D | 64 | 66.2 | 60.1 |
| training | forward | 3D | 4 | 99.9 | 100 |
| training | backward | 3D | 4 | 100 | 100 |
| training | forward | 3D | 8 | 99.7 | 99.7 |
| training | backward | 3D | 8 | 99.7 | 99.7 |
| training | forward | 3D | 16 | 94.9 | 98.3 |
| training | backward | 3D | 16 | 99.4 | 98.3 |
| training | forward | 3D | 32 | 91.4 | 96.9 |
| training | backward | 3D | 32 | 98.6 | 96.9 |
| training | forward | 3D | 64 | 84.2 | 93.6 |
| training | backward | 3D | 64 | 96.7 | 93.6 |
Communication efficiency drops much further than load balance does: it falls to 51% for 2D inference and 60% for 2D training, versus 93–94% for both 3D cases. Split by section, the same ordering as load balance holds — at 64 GPUs the denoiser keeps 66% in 2D and 96% in 3D, while the gradient is down to 18% and 10% — but note the denoiser’s own CommE falls a long way in 2D even though its 2D load balance never left 97%: this is a different problem from imbalance, and the next paragraph is about why. By 64 GPUs, 2D inference’s critical-path compute (0.33s) is nearly matched by transfer alone (0.32s); 2D training still has more room (1.85s vs 1.13s). Neither 3D case comes close: 2.79s compute vs 0.21s transfer for inference, 10.13s vs 0.66s for training — compute stays far larger because the 3D problem itself is much bigger.
This is a genuinely different mechanism from the gradient’s load-balance problem, not a restatement of it: summed across every section, transfer — not wait — is the majority of the non-compute time (89% for 2D inference, 76% for 2D training; even 3D, where CommE barely drops, is 64–69% transfer). The CommE drop is a data-movement story, concentrated in the dominant denoiser/backward sections, which stay well balanced but still have to move more data relative to their shrinking compute as GPU count grows. The gradient’s load-balance collapse, from the wait chart earlier, is a separate problem in a separate (small) section — both are real, but they are not the same thing, and fixing one would not fix the other.
What sets the transfer time?
Transfer time is bytes divided by a rate, so it is worth knowing what each of those is. It turns out both follow simple rules, and together they explain the shape of every transfer measurement on this page — including why transfer time stops growing once there are enough GPUs.
Each all_reduce shares one image across all the GPUs. That image is the
payload: 8192 x 8192 x 3 channels x 4 bytes ≈ 805 MB for 2D inference,
and 512³ x 4 bytes ≈ 537 MB for 3D. But no GPU ever sends the whole
805 MB, and how much it does send depends on which route the GPUs take.
Two ways to add up numbers spread across many GPUs
A ring. The GPUs are arranged in a logical circle. The image is divided into one slice per GPU, and the slices travel around the ring twice: once accumulating the partial sums, once propagating the completed result. Each GPU therefore sends just under two payloads. The transfers are numerous and small, and every link is active simultaneously, which is what makes the ring efficient for large messages.
A tree. Each node first reduces across its own 4 GPUs over the fast intra-node links. One node then acts as the root: the others send it their partial sums, it completes the reduction, and the result is propagated back down. Far less data crosses the slower inter-node network, and the saving is largest when few nodes participate.
The choice is not made by the application. NCCL constructs both topologies at
startup and selects one per call, from the message size and the cluster
topology. Seven algorithms are implemented in total, but only these two are
available here — the remainder require NVSwitch hardware or a network plugin
this cluster does not provide. Running with NCCL_DEBUG_SUBSYS=INIT,TUNING
reports the algorithm selected:
| Nodes | GPUs | What NCCL chose |
|---|---|---|
| 1 | 2, 4 | Ring |
| 2 | 8 | Tree |
| 4 | 16 | Ring |
The 8-GPU row is the one that matters for the byte accounting, because the two algorithms do not move the same volume:
ring: bytes = 2 x payload x (gpus - 1) / gpus -> 1.4 GB at 8 GPUs
tree: bytes = 2 x payload x (nodes - 1) / nodes -> 805 MB at 2 nodes
Attributing the ring’s 1.4 GB to a run that in fact used the tree overestimates the implied bandwidth by a factor 1.75, so every point below is computed with the formula for the algorithm NCCL reported. The distinction only affects the 2-node case: from 16 GPUs onward the two byte counts agree to within 5%.
Both expressions are bounded above. (gpus - 1) / gpus and
(nodes - 1) / nodes increase towards 1 as GPUs are added, so under either
algorithm the volume moved per GPU converges to two payloads and never
exceeds it, however many GPUs participate. This bound is what produces the
flat tail of the chart below.
The byte counts are exact for inference, where denoiser and gradient each
issue exactly one all_reduce per iteration. Training’s sections carry
additional messages the formula does not account for, so the chart is
restricted to inference.
Transfer time stops growing once the byte count stops growing
Transfer time and implied bandwidth vs GPU count, inference's denoiser and gradient sections -- the cases where the collective count is exactly known.
Loading interactive figure…
Implied bandwidth = bytes moved by the all-reduce / measured transfer time, using the byte count of the algorithm NCCL actually chose (tree at 2 nodes, ring elsewhere -- read from its own logs). 4 GPUs/node, so 4 GPUs stay on a single node (NVLink-class interconnect) and 8+ GPUs span multiple nodes (cluster network). Configurations: comm_inference_2D.yml (2D), comm_inference_3D.yml (3D).
View figure data
| Section | Dim | GPUs | Nodes | Bandwidth (GB/s) |
|---|---|---|---|---|
| denoiser | 2D | 1 | 1 | — |
| gradient | 2D | 1 | 1 | — |
| denoiser | 2D | 2 | 1 | 40 |
| gradient | 2D | 2 | 1 | 40.6 |
| denoiser | 2D | 4 | 1 | 119.4 |
| gradient | 2D | 4 | 1 | 120.6 |
| denoiser | 2D | 8 | 2 | 16.6 |
| gradient | 2D | 8 | 2 | 16.7 |
| denoiser | 2D | 16 | 4 | 10.2 |
| gradient | 2D | 16 | 4 | 9.2 |
| denoiser | 2D | 32 | 8 | 10.6 |
| gradient | 2D | 32 | 8 | 9.9 |
| denoiser | 2D | 64 | 16 | 10.5 |
| gradient | 2D | 64 | 16 | 9.6 |
| denoiser | 3D | 1 | 1 | — |
| gradient | 3D | 1 | 1 | — |
| denoiser | 3D | 2 | 1 | 40.3 |
| gradient | 3D | 2 | 1 | 38.5 |
| denoiser | 3D | 4 | 1 | 120 |
| gradient | 3D | 4 | 1 | 118.4 |
| denoiser | 3D | 8 | 2 | 17.3 |
| gradient | 3D | 8 | 2 | 16.8 |
| denoiser | 3D | 16 | 4 | 10.5 |
| gradient | 3D | 16 | 4 | 10 |
| denoiser | 3D | 32 | 8 | 10.5 |
| gradient | 3D | 32 | 8 | 10.1 |
| denoiser | 3D | 64 | 16 | 10 |
| gradient | 3D | 64 | 16 | 10 |
The rate works out at about 40 GB/s at 2 GPUs, 120 GB/s at 4, 17 GB/s at 8 and 10 GB/s from 16 GPUs on. Those numbers are measured, not looked up: each is the bytes above divided by the transfer time beside it. What makes them useful is that they depend only on the route the data takes, not on how many GPUs are involved:
NVLink, 2 GPUs 40 GB/s
NVLink, 4 GPUs 119 GB/s
network, tree 17 GB/s (2 nodes, the only size NCCL uses a tree)
network, ring 10.5 GB/s <- the same at 2, 4, 8 and 16 nodes
With a byte rule and a rate, the chart can be read from left to right.
2 and 4 GPUs — inside one node. This is the oddity flagged earlier: 4 GPUs
transfers faster than 2. With 2 GPUs a pair is joined by only 2 of the V100’s
6 NVLink cables; with 4 GPUs all 6 are in play, so the rate triples. Bytes rise
too, but only by half (1.0 → 1.5 payloads, as (gpus - 1) / gpus goes from ½ to
¾). Three times the rate against 1.5× the bytes halves the time, and the
measurement obliges: 20.1 ms down to 10.1 ms. That the measured ratio is 2.97 to
3.07 across all four series — following the cable count that exactly — is also
the sharpest sign these rates really are cables and not bookkeeping.
8 GPUs — the data leaves the node. A node holds only 4 GPUs, so beyond that every byte has to cross the network, which is far slower than the cables inside a box. Transfer time jumps accordingly. But this is the one size where NCCL picks a tree, and the dip in the middle of the chart is exactly that: one payload across the network instead of nearly two, at a higher rate, so a little over a third of the time a ring would have needed.
16 to 64 GPUs — a ring over the network. Here the rate is a single number,
10.5 GB/s, which is worth testing since one constant covering four node counts
is a real claim rather than a restatement. It can be tested without
circularity: force a ring onto the 2-node job — a configuration that appears
nowhere in the chart — and it gives 10.5 GB/s. Feeding only that number back
through the byte formula predicts every ring row, in both 2D and 3D
(denoiser shown here):
| GPUs | bytes | 2D predicted | 2D measured | 3D predicted | 3D measured |
|---|---|---|---|---|---|
| 16 | 1.88 × payload | 144 ms | 148 ms | 96 ms | 96 ms |
| 32 | 1.94 × payload | 149 ms | 148 ms | 99 ms | 99 ms |
| 64 | 1.97 × payload | 151 ms | 150 ms | 101 ms | 105 ms |
gradient is not shown but is predicted by the same constant, a little less
tightly: twelve predictions in all, mostly inside 5% and never worse than 13%
(gradient 2D, the noisiest series). Skew leaking through the min-reduction
could not do that — it has no reason to scale with a byte count that changes
when the image size does. transfer really is transfer.
Which brings the flat tail of the chart, and the point of all this. The bytes stall at two payloads; the rate does not change. So transfer time stops growing — roughly 150 ms per iteration from 16 GPUs on for a 2D image, no matter how many more GPUs are added. Compute per GPU keeps falling as the job grows. Transfer does not. That gap is the whole mechanism behind the communication-efficiency drop earlier on this page, and it is arithmetic rather than a quirk of the ring: a tree’s byte count converges to the same two payloads, so it flattens out too, just at a lower level.
Conclusions
“Communication” is not one thing, and this study is an illustration of why that distinction matters. Splitting it into transfer and wait exposes two different failure modes, each with its own remedy — and this study finds a clear example of each, playing out in different sections.
On the wait side, the lesson is that scaling holds up exactly where work is divided evenly across GPUs, and breaks down exactly where it isn’t. The physics gradient distributes a fixed number of acquisition operators, one whole operator to a GPU — 8 in 2D, 2 in 3D — so once GPU count passes that number, every extra GPU gets zero operators and nothing to do but idle: by 64 GPUs its wait time is three times its compute time, in both 2D and 3D, and its own load balance collapses to 27% (2D) / 37% (3D). That ceiling is a property of this physics problem having only a handful of operators to divide, not a general communication limit — a physics operator that decomposes more finely (tomography’s per-angle or per-slice split, for instance) would not hit the same wall at these GPU counts. The denoiser is the counterexample inside this same study: its patch-based split can cut the image into as many pieces as there are GPUs, keeping every GPU’s share essentially equal, which is exactly why its own load balance never drops below 96.6% and wait never exceeds 4% of its compute time at every GPU count tested here.
On the transfer side, though, being well balanced isn’t enough. More GPUs means less compute per GPU, but the data that has to move doesn’t shrink at the same rate — so transfer eats a growing share of the critical path regardless of balance. Summed across every section, transfer — not wait — is already the majority of the non-compute time by 64 GPUs (89% for 2D inference, 76% for 2D training; 64–69% even in 3D). That’s why communication efficiency drops much further than load balance does in 2D, down to 51% for inference and 60% for training — a separate, data-movement mechanism from the gradient’s wait problem above, one rebalancing can’t fix. What does help is more compute per GPU to hide the same transfer behind — exactly why the bigger 3D problem stays compute-dominated at every GPU count tested while 2D does not.
Put together, these two mechanisms are why 2D pays roughly double the ideal GPU-seconds cost that perfect linear scaling would by 64 GPUs (2.22× for inference, 1.91× for training), while 3D stays close to ideal (1.12× and 1.08×) — and in every one of these four cases, the compute portion itself barely moves (0.97–1.01×). Communication, in one form or the other, is responsible for essentially all of the gap between measured and ideal scaling.

