Debugging a kernel with cuda-gdb
A kernel doubles a 61 by 37 image, and arithmetic on its transposed store
index says 2,244 of its 2,257 pixels come out wrong.
Every call around it is wrapped in
CUDA_CHECK and every one returns success.
Nothing faults because the bad index never leaves the buffer, so memcheck has
nothing to report.
The tools from days 61 and 62 cannot identify this error. The program uses legal addresses but produces wrong output, and the relevant variable exists inside a running kernel. You will compile for cuda-gdb, stop one chosen thread, print its local variables, and find the wrong index.
What -G buys you and what it charges
gdb cannot step through code it has no line table for, and an optimized
kernel barely has lines: registers get renamed, statements fuse and reorder.
nvcc -g -G fixes that by generating debug information for device code and
turning device optimizations off
(https://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/index.html#device-debug-g ,
checked 2026-09-01). Breakpoints bind to kernel source lines, and a local like
dst exists in a place the debugger can read instead of being folded into an
address calculation.
-G reduces speed because it disables device optimizations. The program
times its fixed kernel in both builds to measure the difference. That is
also the rule for when to use which
build: -G for stepping, -lineinfo for profilers and
compute-sanitizer, never -G numbers on a
results page.
With tens of thousands of threads, "stop" needs a clear subject. cuda-gdb
keeps one thread in focus, named by its kernel, its
block and its thread coordinates, and every command that reads state reads it from that
focus. When a breakpoint in device code hits, everything stops, the focus
lands on the thread that hit it, and cuda kernel block thread prints where
you are.
cuda thread (10,3,0) moves the focus without running anything;
info cuda threads lists the whole grid, grouped into runs of threads
stopped at the same spot. The coordinates are the
built-in index variables you have used
since day 4, which is what makes the next section's arithmetic possible.
Diagram: one thread, two indices. A 61-wide, 37-tall pixel grid on the right, the 16 by 8 threads of block (1,0,0) on the left, with two arrows leaving thread (2,3,0). Band 1, the load: thread (2,3,0) is col 18, row 3, and reads pixel row * width + col. Caption "src = 3 * 61 + 18 = 201". Band 2, the store as the bug writes it: the same thread writes pixel col * height + row. Caption "dst = 18 * 37 + 3 = 669, still inside the 2,257-pixel buffer". Band 3, the 13 pixels where the two formulas agree, marked on the line from (0,0) through (5,3) to (60,36). Caption "3 * col == 5 * row: 13 fixed points, 2,244 misses". Alt text: "Thread (2,3,0) of block (1,0,0) reads pixel 201 and writes pixel 669. Both are legal addresses, so only thirteen of the 2,257 pixels come out right and nothing crashes."
A debugger does not find bugs in code that stops all threads politely
In CPU gdb, you can run to a bad line and inspect its state. On a GPU, first choose the thread whose state you need. A breakpoint with no condition stops the first warp to arrive, which may not be the thread whose arithmetic you worked out on paper.
cuda-gdb evaluates breakpoint conditions per thread on the device, so
break scaleBuggy followed by
condition 1 threadIdx.x==2 && threadIdx.y==3 && blockIdx.x==1 && blockIdx.y==0 stops exactly the thread you pre-computed, and the
transcript below is about that thread rather than about luck.
Once stopped, you can inspect another thread in the same warp without
running the program. A warp moves in lockstep, so when the focus thread, lane
18 of its warp, sits on the store line, lane 26, which is thread (10,3,0),
sits there too, with its own col and dst already computed.
Switching focus gives a second data point. If both threads show the same index error, the formula is wrong rather than one thread's state.
The debugger inspects a known failure; it does not detect one. A CPU reference first shows that this kernel's output is wrong. cuda-gdb then identifies the line and variable that caused the mismatch.
The bug is supplied, the session is scripted
Full program in
code/day63-cuda-gdb/scale_debug.cu,
session script in
commands.gdb. The pair is
built to be replayed, not just read.
The bug is deliberate, labelled, and shipped next to its fix. It is
day 7's transposed index, col * height + row where row * width + col belongs, on a store into
global memory. Both formulas land inside the
buffer for every thread, so the result is a scramble, not a crash:
// out = 2 * in, one thread per pixel. The load index follows the course
// formula. The store index is the same formula retyped from memory, and
// retyped wrong.
__global__ void scaleBuggy(const float* in, float* out, size_t width,
size_t height) {
const size_t col =
blockIdx.x * static_cast<size_t>(blockDim.x) + threadIdx.x;
const size_t row =
blockIdx.y * static_cast<size_t>(blockDim.y) + threadIdx.y;
if (col < width && row < height) {
const size_t src = row * width + col;
// DELIBERATE BUG (day 63): transposed store index, the day 7
// classic. Every value it produces is still inside the buffer,
// so nothing faults; the value just lands in the wrong pixel.
// The fix is dst = src, as scaleFixed writes it.
const size_t dst = col * height + row;
out[dst] = 2.0f * in[src];
}
}
The harness gates both kernels, in opposite directions. scaleFixed must
match the CPU reference everywhere. scaleBuggy must miss on exactly the
count the transposition predicts, computed by a host loop over the identity
rather than hard-coded:
static size_t expectedBuggyMismatches() {
size_t fixedPoints = 0;
for (size_t row = 0; row < kHeight; ++row) {
for (size_t col = 0; col < kWidth; ++col) {
if (col * kHeight + row == row * kWidth + col) {
++fixedPoints;
}
}
}
return kElems - fixedPoints;
}
Every debugger command lives in a file, and the session runs in batch.
cuda-gdb --batch -x commands.gdb ./scale_debug_g produces the same
transcript every time: one conditional breakpoint, run, the focus report,
info cuda threads, five nexts to walk the kernel's five statements, then
print for col, row, src, dst, in[src] and the correct
row * width + col, a focus switch to thread (10,3,0), and three of those
prints again (col, dst and the correct formula). A session you drove by hand teaches once; a script teaches every
reader the same thing and lets CI re-derive the page.
With in[i] = i the misplaced values are self-labelling,
which real data never is. The input is chosen so the transcript reads
plainly, not because the technique needs it.
Results
Run on the project's Tesla T4 (driver 595.84, CUDA 12.6 V12.6.85) on
2026-09-01. Transcripts in code/day63-cuda-gdb/evidence/. Every
predicted number came back exactly as the arithmetic said it would.
CUDA 13.0 re-verified only the two builds and their bare runs. Both repeated
the exact 2,244-of-2,257 buggy mismatch count and the fixed-build gate. The
release timing stayed close at 0.0039 ms, while the standalone -G timing
moved from 0.0064 to 0.0122 ms.
No CUDA 13 cuda-gdb session was captured, so the debugger transcript and thread-level claims below remain sourced from the older session rather than being promoted to CUDA 13 evidence.
| Transcript | What it holds |
|---|---|
batch session, -G build |
[Switching focus to CUDA kernel 0, grid 1, block (1,0,0), thread (2,3,0), device 0, sm 2, warp 1, lane 18], then the breakpoint hit in scaleBuggy<<<(4,5,1),(16,8,1)>>> |
| the six prints, focused thread | col 18, row 3, src 201, dst 669, in[src] 201, row * width + col 201 |
after cuda thread (10,3,0) |
col 26, dst 965, row * width + col 209, focus switched with no stepping |
./scale_debug, both builds |
scaleBuggy: 2244 of 2257 pixels wrong (transposition predicts 2244), then scaleFixed matches the CPU reference on all 2257 pixels |
| the two timing lines | release 0.0037 ms per launch, -G 0.0064 ms, so -G costs 1.7x here |
The diagnosis is the two prints sitting next to each other: dst is 669
and row * width + col, the index the store should have used, is 201.
The thread loaded the right pixel and put it in the wrong place, which is
the whole bug in two numbers.
info cuda threads collapsed the grid to one line,
(0,0,0) (0,0,0) to (3,4,0) (15,7,0) 2560, all 2,560 threads stopped at
the same PC on scale_debug.cu:73. The second thread makes the pattern
rather than the instance: 209 against 965 is the same transposition at a
different lane, and it cost one focus switch and two prints, no stepping,
because a warp moves together and its locals were already computed.
One number the page did not predict. The -G build under the
debugger reported 0.1470 ms per launch against 0.0064 ms standalone.
Being watched costs about 23x more than being built for watching. Time a
kernel outside the debugger.
Two toolchain notes for anyone reproducing this. The nvidia channel's
CUDA 12.6 cuda-gdb package ships only a wrapper that searches for
cuda-gdb-python3.X-tui binaries it never installs, so it cannot start
at all; the conda-forge 13.3 build debugs a 12.6-built binary fine under
this driver. That build also spawns cuobjdump from its own bin
directory when it needs to disassemble, so cuda-cuobjdump has to be
installed and visible there or the first next fails.
Run it yourself
Install cuda-gdb with the toolkit on a CUDA-capable system, then run the
three commands in the README. The debugger reads no performance counters, so
day 42's permission error does not apply, as
day 61 also explains. Batch mode also works in an
environment without an interactive terminal.
Exercise
Pick thread (7,1,0) of block (2,3,0). On paper, compute its col, row,
src and dst, then edit the condition in commands.gdb to stop that
thread and check all four with print.
Time: 20 to 30 minutes. Submit: the four predicted numbers and the matching lines of your transcript, then the day63 quiz.
Check: the quiz below is keyed to the shipped transcript, and your own session either prints your four numbers or it does not. If the breakpoint never hits, your condition names a thread outside the 61 by 37 image's live region; that failure mode is question four's subject.
Hint 1
The condition has four clauses and you are changing all four. Before touching the debugger, decide whether the thread you picked survives the kernel's guard at all.
Hint 2
Block (2,3,0) starts at col 32, row 24. Add the thread offsets, then run
both formulas: row * 61 + col and col * 37 + row.
Solution
col is 2 * 16 + 7 = 39, row is 3 * 8 + 1 = 25. So src is 25 * 61 + 39 =
1564 and dst is 39 * 37 + 25 = 1468. Both are legal indices, but only
src is correct for this store.
Compute the expected values before starting the debugger. If a print differs, inspect the formula or state that produced it.
Pitfalls
Your breakpoint on the kernel never hits. The build has no device debug
info, so the breakpoint bound to the host side or nowhere. Compile with
-g -G for a debug session; -lineinfo is for profilers and sanitizers and
is not enough here. The shipped session against the release build exists to
show this failure on purpose.
You stopped in the kernel but in the wrong thread. An unconditional
break stops the first warp to arrive. Put the block and thread coordinates
in the breakpoint condition, as commands.gdb does, and verify with
cuda kernel block thread before trusting any print.
You stepped once and several threads moved. next advances the focus
thread's warp in lockstep, not one thread in isolation. That is the hardware,
not a debugger defect. Neighbours in the warp provide additional data
points.
You timed or profiled the -G build. Device optimizations are off, so
every number is about the flag, not the kernel. Time the -O3 build; the
program prints which build it is on its second line so a pasted transcript
cannot hide the flavour.
You expected a tool to flag the bug for you. Every index the transposed formula produces is in range, so there is no illegal access to report and no race for day 62's tools to find. Only a reference tells you the output is wrong; day 12 turns this same index swap into the intended behaviour, which is why no tool can call it a bug.
You debugged the first launch and drew timing conclusions. Under -G
everything is slow and the first launch also pays module load, the day 9
cost. Keep debugging and measuring in separate runs of separate builds.
Go deeper
- CUDA-GDB user manual, for
break ... if,cuda thread,info cuda threadsand batch operation: https://docs.nvidia.com/cuda/cuda-gdb/index.html (checked 2026-09-01) - nvcc reference,
--device-debug (-G)and--generate-line-info (-lineinfo): https://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/index.html (checked 2026-09-01) - Compute Sanitizer manual, for where the sanitizer stops and a debugger starts: https://docs.nvidia.com/compute-sanitizer/ComputeSanitizer/index.html (checked 2026-09-01)
- Day 7, where this exact bug was first named, and day 27, where a wrong kernel passed one hundred runs.
Next
Day 64 stays inside the kernel but swaps the debugger for printf and
device-side assert. It shows how buffering can delay, reorder, or discard
printf output. Day 65 takes on the case where nothing prints because
the kernel never finishes.
The three days share one lesson: what a GPU shows you by default is almost nothing. Each tool exposes a different type of state and has a different cost.