d2l::interactive

Chapter 13 // Training at Scale

Computational Performance

A good design can be an order of magnitude faster than a bad one — the difference between training in a week and missing the deadline. This chapter is the systems layer under everything you've built: how code becomes a graph, how work is queued, what the silicon can actually do, and how to spread a batch across many GPUs.

Adapted from the original chapter of Dive into Deep Learning · CC BY-SA 4.0 · the Python shown is the book's; the timelines run this site's cost model with disclosed units, and the data-parallel run is a verified TypeScript port

Every previous chapter cared about what the network computes. This one cares about how fast, because at scale that decides whether an idea is trainable at all. We start where your Python meets the machine — with the surprising fact that, on a GPU, almost none of your code runs when you think it does.

Act 1 · Two ways to run a program

01/04

fancy_func(1, 2, 3, 4)e = add(1, 2)3f = add(3, 4)7g = add(3, 7)10Pythoninterpreter3 statements · 3 round tripsprint(g) → 10

Imperative execution: Python runs fancy_func(1, 2, 3, 4) one statement at a time — e is 3, f is 7, g is 10 — bouncing back to the interpreter after every single operation, and storing every intermediate just in case.

Compilation removes the interpreter from the inside of a computation. The next idea removes it from between computations — and it is already switched on every time you touch a GPU.

Act 2 · The queue behind your code

01/04

x = ones(…)y = ones(…)z = x * y + 2→ task queuexy2×+→ zPythonbackendPython free · t = 6

When you run z = x * y + 2on a GPU, Python doesn't compute anything. Each statement drops a task into the backend's queueand returns immediately — the frontend is free at t = 6 while the C++ backend is still working. This is why the book's torch benchmark looks impossibly faster than NumPy.

That whole trade-off is a single inequality. Enqueue a task in t1t_1, compute it in t2t_2, return its result in t3t_3. Waiting on every one of nn results costs

n(t1+t2+t3),n \, (t_1 + t_2 + t_3),

while enqueueing them all and synchronizing once costs only

t1+nt2+t3t_1 + n \, t_2 + t_3

as long as compute keeps up with the frontend (t2t1t_2 \ge t_1, so the backend never starves). The enqueue and fetch overheads are paid once instead of nn times — which is exactly why you synchronize per minibatch, not per operation.

That queue has a second payoff: if the backend can reorder independent work, it can also run it on different devices at once.

Act 3 · Free parallelism

01/03

run(x) = 10 kernels per device (model ticks)synchronize between the two runsGPU 1GPU 2total = 20

Two GPUs, two independent workloads — but a synchronize between them forces the devices to take turns: 10 kernels each, 20 ticks total. The barrier, not the hardware, is the bottleneck.

Every speedup so far — folding, queueing, overlapping — is really about not letting an expensive resource sit idle. To know which resource is expensive, you have to know the machine.

Act 4 · The iron underneath

01/05

CPU8+ coresRAMDDR4GPU4352 coresGDDR6on-cardNVMe SSDNICEthernet100 GB/s32 GB/s5008 GB/s10 GB/severy pipe is a different width — and the data must cross them all

This is the machine your model actually runs on. Training shuffles bytes between RAM, CPU, GPU, disk and network — and each pipe has a different width, from 500 GB/son the GPU's own memory to 10 GB/s of high-end Ethernet. Whichever pipe you saturate first is your speed limit.

The latency ladder is also the argument for more hardware: when one GPU is the bottleneck, add GPUs. But a network is a single object, so how do you split it?

Act 5 · One batch, many GPUs

01/05

between layerstight handoffswithin layerssync every layersplit the datacopy Acopy Bsync once per batch ✓three ways to put a network on k GPUs

Three ways to spread training over k GPUs: cut the network between layers, cut every layer across its channels — both drown in synchronization — or give every GPU the whole model and split the data. The book, and practice, pick door three.

Data parallelism turns training into an allreduce after every minibatch. On a real cluster, how those gradients travel is the whole game.

Act 6 · The 160-megabyte question

01/05

CPUPCIe switch · 16 GB/s per linkGPU 0160 MB gradGPU 1160 MB gradGPU 2160 MB gradGPU 3160 MB gradgoal: every GPU ends up holding the SUM of all four gradients

The book's worked example: a 4-GPU server, 160 MB of gradients on each card after every minibatch, 16 GB/s per PCIe link. Same math as the last act — but now the route the bytes take decides how long training waits.

The lab

Three things from the acts, yours to drive: the async pipeline and where it stops helping, the eight-orders-of-magnitude latency ladder, and the ring allreduce whose cost refuses to grow with the number of GPUs.

▸ lab

Drive the pipeline

Drag the three costs — find where async stops helping, and what it does to the queue.

synchronize after every task — 56 ticks

Pythonbackendfetch

one final barrier28 ticks

Pythonbackendfetch

n·(t₁+t₂+t₃) = 56 vs t₁ + n·t₂ + t₃ = 28 · speedup ×2.00

Queue peaks at 4 pending tasks. Barriers cap memory; each one also pays t₁+t₃ again.

▸ lab

How long is a nanosecond?

Pick a reference — every other latency rescales as if the reference took one second.

if L1 cache hit (1.5 ns) took one second

actionvs referencefeels like
L1 cache hit
1 s (ref)
Float add / multiply / FMA
1.0 s
L2 cache hit
3.3 s
Branch mispredict
4.0 s
L3 cache hit (unshared)
11 s
Mutex lock/unlock
17 s
GPU shared memory access
20 s
Memory ref. (local CPU, 64 MB)
31 s
Memory ref. (remote socket, 64 MB)
47 s
GPU global memory access
2 min
Intel Optane random read
3 min
Send 4 KB over 100 Gbps fabric
11 min
Compress 1 KB (Google Snappy)
33 min
Launch a CUDA kernel
2 h
Send 4 KB over 10 Gbps Ethernet
2 h
Transfer 1 MB over NVLink
6 h
Transfer 1 MB over PCIe
15 h
Read 4 KB randomly (NVMe SSD)
22 h
Read 1 MB sequentially (NVMe SSD)
2 days
Read 4 KB randomly (SATA SSD)
4 days
Round trip in the same datacenter
4 days
Read 1 MB sequentially (disk)
39 days
HDD random access (seek + rotation)
77 days
Packet CA → Netherlands → CA
3.2 years

The table is the book's (after Eliot Eshelman). With L1 as the reference, a random disk seek takes ~2.5 months and a transatlantic packet ~3 years — now click HDD random access and watch the fast rows collapse to nothing. Every performance trick in this chapter is a way to stay near the top of this table.

▸ lab

Walk the ring

Step the allreduce by hand — watch partial sums travel, and the total time refuse to grow with n.

chunks →GPU 01111GPU 12222GPU 23333GPU 34444

start — GPU i holds its own gradient (value i+1) in every chunk · target: every chunk = 10

steps: 2(n−1) = 6

per link per step: 160 MB / 4 = 40 MB

naive (all → GPU 0): 60 ms

ring, same links: 15 ms

Click step until the grid fills. Scatter builds each column's sum in one place; gather walks the finished chunks around.

The real code

Every act above carries a </> chip with its section's exact PyTorch — scripting a model, benchmarking the async backend, dropping a barrier to parallelize two GPUs, and the from-scratch train_batch with split_batch + allreduce:

multiple-gpus.md (PyTorch)
def allreduce(data):
    for i in range(1, len(data)):
        data[0][:] += data[i].to(data[0].device)   # sum onto GPU 0
    for i in range(1, len(data)):
        data[i][:] = data[0].to(data[i].device)     # broadcast back
 
def train_batch(X, y, device_params, devices, lr):
    X_shards, y_shards = split_batch(X, y, devices)
    ls = [loss(lenet(X_shard, device_W), y_shard).sum()
          for X_shard, y_shard, device_W in zip(
              X_shards, y_shards, device_params)]
    for l in ls:                 # backward on each GPU independently
        l.backward()
    with torch.no_grad():        # sum + broadcast every gradient
        for i in range(len(device_params[0])):
            allreduce([device_params[c][i].grad for c in range(len(devices))])
    for param in device_params:  # identical SGD step on every replica
        d2l.sgd(param, lr, X.shape[0])
The concise version — one line does all of it
multiple-gpus-concise.md (PyTorch)
net = nn.DataParallel(net, device_ids=devices)   # scatter + gather + allreduce
trainer = torch.optim.SGD(net.parameters(), lr)
loss = nn.CrossEntropyLoss()
for X, y in train_iter:
    trainer.zero_grad()
    X, y = X.to(devices[0]), y.to(devices[0])
    l = loss(net(X), y)
    l.backward()                 # gradients allreduced automatically
    trainer.step()
# scale the batch k-fold and nudge lr up when you add GPUs

Three things to remember

  1. Your framework is lazier than it looks. PyTorch enqueues GPU work and returns immediately; compilation (torch.jit.script) folds the graph and drops the interpreter, while the async backend overlaps independent work for free. Blocking calls — print, .item(), .numpy() — end the party, so synchronize per minibatch, not per op.
  2. Performance is a hardware story. Bandwidth and latency span eight orders of magnitude; the wins come from few, large, contiguous transfers that stay in cache and overlap with compute. Sketch the bottleneck on paper before you profile.
  3. Data parallelism is allreduce, and routing is everything. Give every GPU the whole model, split the batch, sum the gradients — the math is unchanged. How you sum them (to one GPU, via the CPU, sharded, or around a ring) can swing a step from 80 ms to 6 ms.

Adapted from Chapter 13 of Dive into Deep Learning by Zhang, Lipton, Li, and Smola (CC BY-SA 4.0). The figures run this site's verified TypeScript port; all computation happens live in your browser.