Using more than one GPU
Kaggle hands you two T4s and nvidia-smi lists both. Your program uses
one of them, and it will go on using one of them however big the array
gets, because a CUDA program picks a device once and then stops asking.
The repair looks like two calls: count the devices, set the device. Both
are real and they are not enough. Which device is current when you create
a stream, an event or an
allocation decides which device owns it for life, and the card next to
yours is a separate machine with a bus in between. By the end of this
page you will have split one vector add across both cards, printed each
card's own time rather than one number for the pair, and measured what it
costs to move a buffer from one card to the other.
Hardware. This day needs two GPUs and the project's verification node has one Tesla T4, so the two-GPU numbers cannot come from it. The free two-GPU tier is Kaggle's T4 x2, and it is the only one (accelerator list at https://www.kaggle.com/docs/notebooks , checked 2026-08-29). The program counts the devices and runs a single-GPU fallback on one card, printing which path it took, so a Colab T4 still exercises the code.
Two devices, two of everything
The API surface people expect is the whole API surface: cudaGetDeviceCount
tells you how many devices the process can see, and cudaSetDevice picks
one. "A host thread can set the device it is currently operating on at
any time by calling cudaSetDevice()", and until it does, the current
device is device 0 (CUDA C++ Programming Guide, "Programming Systems with
Multiple GPUs",
https://docs.nvidia.com/cuda/cuda-programming-guide/03-advanced/multi-gpu-systems.html
, checked 2026-09-01). Every program in this course so far has been using
that default without knowing it.
What the two calls hide is that "current device" is ownership, not a
hint. A cudaMalloc puts the allocation on whichever device is current,
a stream created under device 1 belongs to device 1, and "a kernel launch
will fail if it is issued to a stream that is not associated to the
current device" (same page). Events are stricter: cudaEventRecord()
"will fail if the input event and input stream are associated to
different devices", and cudaEventElapsedTime() fails when its two
events belong to different devices. That is why the program on this page
cannot print one elapsed time for the pair. It prints one per device,
takes the larger, and says why that is a lower bound.
So a second GPU is not a flag. It is a second set of allocations, a second stream, a second event pair, a copy in and a copy out, and host code deciding which half of the array each set is looking at. The kernel does not change: it is handed a base pointer and a count and has no idea it is looking at half of something.
Diagram: what belongs to which device. Two columns, one per device, with the host array drawn as a single bar across the top and dashed lines showing which slice feeds which column. Column 0, device 0: elements 0 to 8,388,913, its own stream, its own event pair, its own a, b and c allocations. Caption "8,388,914 elements, one shard." Column 1, device 1: elements 8,388,914 to 16,777,826, a second stream, a second event pair, a second set of allocations. Caption "8,388,913 elements, the shorter shard." Between the columns, a single arrow labelled with the host: every cross-device byte goes up to host memory and back down. Caption "no shared address space by default." Alt text: "One host array cut into two shards of 8,388,914 and 8,388,913 elements. Each device owns its own stream, events and three allocations, and the only path between the two columns runs through host memory."
The second GPU is not a bigger GPU
The intuition that fails here comes from adding SMs. Going from 40 SMs to
80 sounds like the same move as going from a T4 to a bigger card, and for
the arithmetic it is. For the memory it is not. Two GPUs do not share an
address space, and the road between them is the same
PCIe bus day 53 already
priced: on the project's T4,
pinned host-to-device copies ran at 12.2 GB/s while a kernel reading
device-resident memory ran at 244.8 GB/s
(code/day53-pinned/evidence/run-2026-09-01.txt). A byte that has to
cross from one card to the other through host memory pays that bus twice.
Which makes the first version people write wrong in a way the compiler will not catch:
cudaSetDevice(0);
vectorAdd<<<blocks, 256>>>(d_a, d_b, d_c, half);
cudaSetDevice(1); // the current device moved
vectorAdd<<<blocks, 256>>>(d_a + half, d_b + half, d_c + half, half);
d_a was allocated on device 0. The second launch runs on device 1 and
dereferences it. There is no second allocation and no second stream, and
both launches went to a default stream, so nothing would overlap even if
the pointers were right. The two-line fix changes which device runs the
kernel and nothing about where the data is.
Peer access is the mechanism that makes one device's pointer usable on
another. cudaDeviceCanAccessPeer answers whether the topology allows
it, cudaDeviceEnablePeerAccess turns it on, and "access granted by this
call is unidirectional"
(https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__PEER.html ,
checked 2026-09-01), so the reverse direction needs its own call. The
guide states the payoff plainly: with peer access enabled, "peer-to-peer
memory copies between these two devices no longer need to be staged
through the host and are therefore faster". Without it the copy still
works, which is the part that catches people out; it just takes the long
road. And on the free tier there is no NVLink at the
end of that road either: the T4 datasheet lists its system interface as
"x16 PCIe Gen3" and names no NVLink connector
(https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/tesla-t4/t4-tensor-core-datasheet.pdf
, checked 2026-09-01).
Splitting an array that does not divide by two
Full program:
code/day91-multi-gpu/multi_gpu.cu.
The kernel is day 5's vector add, untouched
apart from __restrict__. The element count is 16,777,827, which is odd
on purpose. An even count
lets n / 2 be written twice and still add up, and that is the bug: the
last element belongs to nobody and nothing notices. The split gives the
remainder away one element at a time and computes each shard's start from
the previous shard's end, never from a formula:
const size_t base = n / static_cast<size_t>(used);
const size_t rem = n % static_cast<size_t>(used);
size_t begin = 0;
for (int d = 0; d < used; ++d) {
shards[d].device = d;
shards[d].begin = begin;
shards[d].count = base + (static_cast<size_t>(d) < rem ? 1ull : 0ull);
begin += shards[d].count;
}
Each device gets its own everything, created while it is current. The
stream, the event pair and the three allocations are made inside a
cudaSetDevice for that device and are never used under another one:
// Everything below is created while device d is current, and
// belongs to device d for good. A stream, an event and an
// allocation are not portable objects: "a kernel launch will fail
// if it is issued to a stream that is not associated to the
// current device".
CUDA_CHECK(cudaSetDevice(s.device));
CUDA_CHECK(cudaStreamCreate(&s.stream));
CUDA_CHECK(cudaEventCreate(&s.start));
CUDA_CHECK(cudaEventCreate(&s.stop));
CUDA_CHECK(cudaMalloc(&s.d_a, bytes));
CUDA_CHECK(cudaMalloc(&s.d_b, bytes));
CUDA_CHECK(cudaMalloc(&s.d_c, bytes));
The correctness check never looks at the inputs. a[i] is i % 1024
and b[i] is twice that, so the answer is 3 * (i % 1024), a whole
number under 3072 that a float holds exactly. The check recomputes that
from the index alone, so a shard copied back at the wrong offset fails
instead of agreeing with itself, and the output starts full of a sentinel
so an element nobody wrote fails too. There is no tolerance because there
is nothing to absorb: one float add of two exact small integers is exact,
and a tolerance could only hide a misplaced shard.
Then the peer question, asked per ordered pair because the answer is not symmetric:
std::printf("\npeer access, as reported by cudaDeviceCanAccessPeer\n");
int canPeer = 0;
for (int i = 0; i < used; ++i) {
for (int j = 0; j < used; ++j) {
if (i == j) {
continue;
}
int can = 0;
CUDA_CHECK(cudaDeviceCanAccessPeer(&can, i, j));
std::printf(" device %d -> device %d: %s\n", i, j,
can ? "yes" : "no");
if (i == 0 && j == 1) {
canPeer = can;
}
}
}
The program then moves 32 MiB from device 0 to device 1 four ways: down
to pinned host memory and back up as two
separately timed legs, then cudaMemcpyPeerAsync with peer access off
and, if the query said yes, on. It does not overlap copies with compute,
which is day 54's job, so this is the simple schedule rather than the
good one.
Results
Partially re-run, not verified as a multi-GPU lesson. The single-GPU fallback ran again on the project's Tesla T4 with driver 580.173.02 and CUDA 13.0 (V13.0.88), matched exactly and exited 0. The required Kaggle T4 x2 transcript still does not exist, so the split-scaling and peer-copy claims remain predictions. Both fallback transcripts are listed in front matter; this table is CUDA 13.0.
| Path | Time (ms) | GB/s | vs whole array |
|---|---|---|---|
| whole array, with copies | 16.789 | 12.0 | 1.00 |
| split, with copies | 16.793 | 12.0 | 1.00 |
| whole array, kernel only | 0.785 | 256.6 | 1.00 |
| split, kernel only | 0.784 | 256.7 | 1.00 |
| Route, 32 MiB from device 0 to device 1 | Time (ms) | GB/s |
|---|---|---|
| leg 1: device 0 to pinned host | not run | not run |
| leg 2: pinned host to device 1 | not run | not run |
| staged total | not run | not run |
cudaMemcpyPeer, access off |
not run | not run |
cudaMemcpyPeer, access on |
not run | not run |
What the fallback settled, and what remains:
- Held for the fallback only. All 16,777,827 elements passed the exact check and the process exited 0. The two-GPU boundary check is pending.
- Pending. One visible device gives the split one shard, so its kernel-only ratio was 1.00 by construction, not evidence for scaling.
- Held for the fallback clause. With copies, split over whole was 16.793 / 16.789, which rounds to 1.00. The predicted 1.0x to 1.8x two-GPU band is pending.
- Pending. No device-to-device route exists with one visible GPU.
- Pending. Peer access and both
cudaMemcpyPeerrows were skipped.
The fallback proves the one-device branch and prices its machinery at noise: 0.784 ms split against 0.785 ms whole for the kernel. It does not prove that arithmetic scales or what crossing a bus costs.
Run it yourself
Kaggle, with the accelerator set to T4 x2, is the only free way to
run the two-GPU path; the free P100 option is compute capability 6.0 and
an sm_75 binary will not load on it (00-CORRECTIONS.md section 9).
The quota is 30 GPU hours a week and sessions run up to 12 hours
(https://www.kaggle.com/docs/notebooks , checked 2026-08-29), and this
program needs about a minute of that. Write the file with %%writefile,
build with !nvcc -std=c++17 -O3 -arch=sm_75 -o multi_gpu multi_gpu.cu,
run it, and paste the transcript. On a Colab T4 or any single card you
own, the same build runs the fallback path and produces every row except
the peer table. See free GPU tiers for what
each one gives you.
Exercise
The split hands each device an equal share, which is right only when the
devices and their links are identical. Replace it with a weighted split:
shard 0 takes a fraction you choose, shard 1 takes n minus what you
already gave away rather than a second formula. Then sweep the weight and
find where the two per-device times meet.
Time: 30 to 45 minutes. Submit: your createShards, the
per-device rows at 0.50 and at your best weight, and one sentence saying
whether the balance point moved and why.
Check: the program's own gate. Every element must equal
3 * (i % 1024) exactly; a miss prints the count of wrong elements, the
first bad index, and got against want, then exits nonzero. A weight that
loses or duplicates an element fails there, beside a shard boundary,
before any timing prints.
Hint 1
You are looking for the point where neither device is waiting for the other. The program already prints the thing you are optimizing; you do not need a new measurement, only a second column of the one it has.
Hint 2
Two shards must satisfy exactly one invariant: begin[1] + count[1] == n
and begin[1] == count[0]. If you compute both counts from the weight,
rounding breaks that; if you compute the second as a subtraction, it
cannot. Where does the sentinel show up when you get it wrong?
Solution
Take count[0] = static_cast<size_t>(w * n) and count[1] = n - count[0],
with begin[1] = count[0], so the tail is a subtraction and the exact
check cannot fail for arithmetic reasons. On two identical T4s the sweep
should find 0.50, and the exercise then teaches nothing about balancing
and everything about measuring: the per-device rows sit within noise of
each other and the weight does not matter. Run it where the two cards
differ, or where the second sits behind a slower link, and the same sweep
pays for itself. A split is a guess about the hardware, and the
per-device times are the only thing that knows whether it was right.
Pitfalls
The second GPU sits idle while the first works. A plain cudaMemcpy
returns only when the copy is done, so device 0's whole sequence finishes
before device 1's is issued and the split runs slower than one card. Use
cudaMemcpyAsync on each device's own stream, which needs pinned host
memory to be asynchronous at all: day 53
measured a pageable
cudaMemcpyAsync call occupying 99.4 percent of its own copy's
completion time, which is a blocking call wearing an async name. Both
cards then pull from one host bandwidth
anyway, which is what a with-copies ratio near 1.0 is telling you.
The launch fails as soon as you touch the second device. A stream
created under device 0 and launched into while device 1 is current is
refused with cudaErrorInvalidResourceHandle, and the same rule catches
cudaEventRecord and cudaEventElapsedTime across a device boundary.
Keep a device's stream, events and allocations in one struct, as this
program does, and the mistake gets hard to type.
One element is missing and every test passes. With an even element
count, n / 2 written twice adds up; on a real input length it does not.
Compute the last shard by subtraction and pre-fill the output with a
sentinel, so an element nobody wrote fails.
You expected the copy to fail without peer access and it succeeded.
cudaMemcpyPeer works whether or not cudaDeviceEnablePeerAccess has
been called; without it the runtime stages through host memory, which is
correct, slow and silent. The query and the enable call are how you learn
which one you got.
Your program suddenly sees one GPU, or the wrong one.
CUDA_VISIBLE_DEVICES hides devices
and renumbers the survivors from zero, so CUDA_VISIBLE_DEVICES=1 makes
that card device 0 inside the process. A launcher, a scheduler or a stale
notebook cell will all make cudaGetDeviceCount return 1 on a two-GPU
box.
Go deeper
- CUDA C++ Programming Guide, "Programming Systems with Multiple GPUs", on device selection, stream and event association, peer-to-peer access and peer-to-peer copies: https://docs.nvidia.com/cuda/cuda-programming-guide/03-advanced/multi-gpu-systems.html (checked 2026-09-01)
- CUDA Runtime API, "Peer Device Memory Access", for
cudaDeviceCanAccessPeer,cudaDeviceEnablePeerAccessand the unidirectional rule: https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__PEER.html (checked 2026-09-01) cuda-samples,cpp/0_Introduction/simpleMultiGPUandcpp/0_Introduction/simpleP2P: https://github.com/NVIDIA/cuda-samples/tree/master/cpp/0_Introduction (checked 2026-09-01)- PMPP 4th edition, chapter 20, "Programming a heterogeneous computing cluster: An introduction to CUDA streams": https://shop.elsevier.com/books/programming-massively-parallel-processors/hwu/978-0-323-91231-0 (checked 2026-09-01)
Next
Day 92 takes the case this program dodged: work where the devices have to talk to each other. An all-reduce over a gradient means every card needs every other card's numbers, and NCCL is the library that arranges those exchanges into a ring instead of a crowd. The peer table you print today is the input to that decision, because a collective on a link that stages through the host is a different animal from one on NVLink.