CUDA stencils and the heat equation
Here is the loop almost everyone writes the first time they put a simulation on a GPU.
for (int step = 0; step < 1000; ++step) {
heatStep<<<grid, block>>>(d_cur, d_next, n, r);
cudaMemcpy(h_grid, d_next, bytes, cudaMemcpyDeviceToHost);
std::swap(d_cur, d_next);
}
It is correct. Every step is right and the picture it draws is the picture. It also spends most of its time on line three.
A 2048 by 2048 grid of floats is 16 MiB, so a thousand steps copy 15.6 GiB to the host, one blocking copy at a time, to fetch a grid that was already on the device. Day 9 measured that cost: a vector add whose kernel took 0.786 ms took 45.822 ms once its copies were counted.
This page builds the stencil that loop is calling, gives it a shared-memory tile with a halo, and then measures the part that sets the run time. It is not the kernel.
What a stencil asks memory for
A stencil computes each cell's new value from its own old value and its neighbours' old values. The two-dimensional heat equation, solved explicitly, is the smallest interesting one:
u'[i][j] = u[i][j] + r * (u[i-1][j] + u[i+1][j] + u[i][j-1] +
u[i][j+1] - 4 * u[i][j])
Five cells in, one cell out, every cell, every step. That is the memory cost. Each updated cell costs five reads from global memory, and four of those five are cells some neighbouring thread also wants.
The reads themselves are fine: the 32 lanes of a warp hold 32 consecutive columns of one row, so each of the five loads is a contiguous 128-byte span and coalesces into four sectors, exactly as day 11 measured. The access pattern is fine, but the read count is high.
A stencil is the natural shape for shared memory
because its reuse is small, local and known before the kernel runs. A
block that owns a 32 by 8 patch of output needs
exactly one extra cell on each side to compute it, so it loads a 34 by 10
patch, waits at one __syncthreads(), and then
every thread reads its five neighbours out of shared memory. That ring of
extra cells is the halo, also called the apron, and it is the same object
day 20 loaded for a 5 by 5 filter.
The arithmetic is the design. 34 times 10 is 340 loads to produce 32 times 8 = 256 updated cells, which is 1.33 global reads per cell against five. The 84 extra loads are the halo, and a neighbouring block also loads each one.
A tiled matmul reuses a whole row and a whole column; a stencil reuses a ring one cell wide. That is why the tile only has to be two cells bigger in each direction, and also why the win is smaller than a matmul's.
The thousand steps are the program
You cannot update a grid in place. A thread that writes u[i][j] before its
neighbour reads it gives that neighbour a value from the wrong step, and the
answer then depends on the block schedule.
So a stencil runs on two buffers and swaps them after every step. CUDA code often calls this ping-pong buffering. The swap changes two host pointers; it does not move data.
Both buffers need the boundary. These kernels never write the boundary ring,
because a Dirichlet wall holds its temperature forever, so a run that filled
one device buffer and left the other as cudaMalloc returned it reads real
wall values on even steps and whatever was in that allocation on odd ones.
cudaMemcpy(d_a, h_init, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_init, bytes, cudaMemcpyHostToDevice); // both, every time
Now the loop around all that. The CPU habit is that a step is a function which takes data and returns data, so you bring the result back to look at it.
On a GPU, keep the loop on the device and let the host choose when to copy a result. Kernel launches are cheap: day 9 measured 0.016 ms of host time to queue one, so a thousand of that overhead is a rounding error. The copies are not, because they cross PCIe rather than the card's own memory, and a synchronous one also stops the host until it finishes.
Part 2 measures the three copy policies.
When the timestep is the bug
Write the update as a weighted sum instead of a correction. The stability limit is then clear:
u'[i][j] = (1 - 4r) * u[i][j] + r * (four neighbours)
r is alpha * dt / h^2, the diffusion constant times the timestep over the
squared cell spacing. The five weights sum to one. While 1 - 4r is not
negative, every weight is between zero and one.
The new value is then an
average of five old values and cannot be larger than the largest of them.
Nothing in the grid can grow. This discrete maximum principle gives the CFL
condition r <= 1/4 for this two-dimensional scheme.
Past a quarter the centre weight goes negative, the sum stops being an
average, and the scheme amplifies instead of smoothing. The pattern it
amplifies fastest is the checkerboard, the one that alternates sign between
neighbouring cells, and it is multiplied by |1 - 8r| on every step. At
r = 0.26 that is 1.08, which sounds harmless until you compound it: a
factor of 2,200 over a hundred steps and more than a trillion over four
hundred.
What a learner sees is a picture that goes chequered, then to numbers with no
physical meaning, growing without bound toward inf and nan if the run is
long enough.
The first guess is often a kernel bug, so the next check focuses on indices and barriers. But the kernel can be correct. Halve the timestep and the same binary produces a stable simulation.
One dimension
allows r <= 1/2, two allows a quarter, three allows a sixth: the bound is
1 / (2d), because each extra dimension puts two more neighbours' weight on
the same centre.
Measuring it without measuring the copies
Full program in
code/day34-stencils/heat.cu. The
harness checks its numbers four ways.
The baseline is measured here, not borrowed. copyGrid reads every cell and
writes it back with no neighbours and no arithmetic, from the same grid in
the same process, so no correct stencil can beat it. Taking a copy
bandwidth from day 11 instead would fold that
day's block shape, buffer size and clock state into this answer.
Only one thing changes between the rows of part 2. Same kernel, same thousand steps, same arithmetic, and the only variable is how often the grid is copied to the host. The program then requires the three final grids to be bit-identical, so "the copy policy changed the answer" is a failure rather than a footnote.
Correctness is checked before anything is timed. Both kernels run four steps and are compared cell by cell against a double-precision CPU reference, and every cell of the boundary ring has to hold exactly the value it started with.
Events, and a warm-up per kernel. Day 9 covers why a host clock around a launch measures the launch. Part 2 is timed once rather than ten times, because a thousand steps is not a microbenchmark.
The global-memory version is the stencil written the obvious way:
__global__ void heatStepGlobal(const float* __restrict__ in,
float* __restrict__ out, int n, float r) {
const int col = static_cast<int>(blockIdx.x * blockDim.x + threadIdx.x) + 1;
const int row = static_cast<int>(blockIdx.y * blockDim.y + threadIdx.y) + 1;
if (col < n - 1 && row < n - 1) {
const size_t stride = static_cast<size_t>(n);
const size_t c = static_cast<size_t>(row) * stride + col;
const float centre = in[c];
out[c] = centre + r * (in[c - 1] + in[c + 1] + in[c - stride] +
in[c + stride] - 4.0f * centre);
}
}
The tiled version differs only in where the five reads come from. Inspect the fill loop: 256 threads have 340 apron cells to load, so it is strided by the block size rather than written as one load per thread, and 84 threads take a second cell.
__global__ void heatStepTiled(const float* __restrict__ in,
float* __restrict__ out, int n, float r) {
__shared__ float tile[kSharedY][kSharedX];
const int tileCol = static_cast<int>(blockIdx.x) * kTileX + 1;
const int tileRow = static_cast<int>(blockIdx.y) * kTileY + 1;
const int threads = static_cast<int>(blockDim.x * blockDim.y);
const int flat = static_cast<int>(threadIdx.y * blockDim.x + threadIdx.x);
for (int s = flat; s < kSharedCells; s += threads) {
const int sy = s / kSharedX;
const int sx = s - sy * kSharedX;
const int gy = tileRow - kHalo + sy;
const int gx = tileCol - kHalo + sx;
const bool inside = (gx >= 0 && gx < n && gy >= 0 && gy < n);
tile[sy][sx] =
inside ? in[static_cast<size_t>(gy) * static_cast<size_t>(n) + gx]
: kCold;
}
__syncthreads();
const int sx = static_cast<int>(threadIdx.x) + kHalo;
const int sy = static_cast<int>(threadIdx.y) + kHalo;
const int col = tileCol + static_cast<int>(threadIdx.x);
const int row = tileRow + static_cast<int>(threadIdx.y);
if (col < n - 1 && row < n - 1) {
const float centre = tile[sy][sx];
out[static_cast<size_t>(row) * static_cast<size_t>(n) + col] =
centre + r * (tile[sy][sx - 1] + tile[sy][sx + 1] +
tile[sy - 1][sx] + tile[sy + 1][sx] - 4.0f * centre);
}
}
There is no padding column on that tile. Day 15
added one to a transpose because a warp there walked a column of the tile;
here all five shared reads walk a row with consecutive lanes on consecutive
words, so the 32 lanes already land in 32 different banks and a 35th column
would buy nothing but shared memory. Check that before adding the + 1.
Bank conflicts are the day that teaches it.
Note. The tiled kernel may not win. Five reads per cell is a small amount of reuse, and every row is read three times within a few microseconds by threads on the same SM, so L1 and L2 have a good chance of serving most of it already. Shared memory would then be doing by hand what the cache was doing for free. That is what part 1 is for, and on this card it settled the question: the tiled kernel lost, 0.283 ms against 0.173. The Results section takes it apart.
Results
Measured. Tesla T4, driver 595.84, CUDA 12.6 (V12.6.85), built with nvcc -O3 -arch=sm_75. Captured 2026-09-01 on the project's verification node; full transcript in the page's evidence file.
GPU: Tesla T4 (compute capability 7.5)
Shared memory per block: 49152 bytes. Max threads per block: 1024
Grid: 2048 x 2048 cells, 16.0 MiB per buffer, two buffers on the device
Tile: 32 x 8 output from a 34 x 10 apron, 1360 bytes of shared memory
Interior cells updated per step: 4186116
Global reads per updated cell, from the constants
heatStepGlobal 5.000
heatStepTiled 1.328 (340 apron cells for 256)
Part 1: one step, mean of 10 runs after 3 warm-ups
kernel time (ms) GB/s % of copy
--------------------- ---------- -------- -----------
copyGrid, no stencil 0.138 244.0 100.0
heatStepGlobal 0.173 194.2 79.6
heatStepTiled 0.283 118.6 48.6
Part 2: 1000 steps of heatStepTiled. Only the copy policy changes.
policy total (ms) per step (ms) to host (GiB) vs row 1
--------------------------- ---------- ------------ ------------ ----------
never copied back 200.894 0.201 0.0 1.0
copied back every 100 steps 230.566 0.231 0.2 1.1
copied back every step 4117.288 4.117 15.6 20.5
All three rows above produced a bit-identical final grid. The kernel,
the step count and the arithmetic were the same; only the copies moved.
Part 3: 400 steps at three timesteps
r 1 - 4r |1 - 8r| max |u| at the end finite
------- --------- ----------- --------------------- --------
0.240 0.040 0.920 1 yes
0.250 0.000 1.000 1 yes
0.260 -0.040 1.080 4.83157e+09 yes
The grid started with max |u| = 1.000. A row whose centre weight 1 - 4r is
negative is no longer averaging its neighbours, and |1 - 8r| is what the
checkerboard pattern is multiplied by on every one of the 400 steps.
Tiling made it slower, and that is the result worth keeping. The global version reads 5.000 values per updated cell and runs in 0.173 ms. The tiled version reads 1.328 and runs in 0.283 ms.
Fewer global reads took more time.
The reason is that the reads the tile eliminates were not expensive. A five-point stencil re-reads its neighbours, and those neighbours were just read by an adjacent thread, so they are sitting in L1 and L2. The tile replaces a cheap cache hit with shared memory traffic, a barrier, and an apron that is 33 percent larger than the output it serves: 340 cells loaded for 256 updated.
This is the counterweight to day 13, where tiling nearly doubled a transpose. There, the reads being eliminated were uncoalesced DRAM traffic.
Here they are cache hits. Shared memory is worth it when it replaces slow reads, not merely repeated ones, and the only way to know which you have is to measure both.
The copies cost 20.5x, the page's main result. A thousand steps with the grid left on the device took 200.894 ms. The same thousand steps copied back after every one took 4117.288 ms and moved 15.6 GiB to the host.
All three policies produced a bit-identical final grid.
The row in between, copied back every 100 steps, reads 230.566 ms, 1.1x. An earlier capture of this table had the middle row beating the first, which made no sense for a row doing strictly more work; a discarded warm-up pass before part 2 removed it, and the rows now sit in the only order the work supports.
The blow-up is smaller than the compounding suggests. The model
says the checkerboard component is multiplied by 1.080 per step at
r = 0.260, which compounds to about 2e13 over 400 steps. The run ends at
4.83157e+09, still finite, because the initial grid's checkerboard component
is far smaller than 1: the multiplier is exact, but the starting amplitude is
not 1.
The sign of 1 - 4r correctly predicts growth or decay, as the
table shows, and the exact magnitude at step 400 depends on where the
pattern started.
Run it yourself
Use a CUDA GPU that supports the lesson's minimum compute capability. The README gives this build line:
nvcc -std=c++17 -O3 -arch=sm_75 -o heat heat.cu
An embed is out: two 16 MiB grids on the device, about three thousand timed launches and a thousand 16 MiB copies to the host will not finish in the 20 seconds Compiler Explorer allows.
Shrinking the grid until it fits would change the copy cost this page measures. If you have no GPU, read /setup/learn-cuda-without-a-gpu.
Exercise
Add a kernel that takes two timesteps per launch. Load a two-cell halo, run the stencil once inside shared memory, run it again, and write the tile out, so a thousand steps cost five hundred launches and five hundred passes over global memory. Then say what those two steps cost you on the way in.
Time: 35 to 50 minutes. Submit: heat.cu with the new kernel, your
two new rows, and one sentence on when you would ship it.
Check: the harness runs your kernel for the same thousand steps and
compares every cell against the shipped single-step run, printing the row,
the column and both values for the first cell that disagrees. It counts your
global reads per updated cell and holds you to 1.5 per timestep, which the
naive five-read version fails and a correct two-step tile passes with room.
It reports your time per timestep against heatStepTiled either way, because
the ratio is the answer and the pass is only the gate.
Hint 1
The block already has everything it needs to compute its own tile once. What would it need to have loaded to compute that tile a second time without asking global memory again?
Hint 2
Work backwards. The second step needs a one-cell ring around the output tile, and that ring has to be correct after the first step, so the first step has to compute it, so the load has to cover a ring around the ring. Count the apron, then count how many cells each of the two steps updates.
Solution
A 36 by 12 apron. The first step updates the inner 34 by 10 into a second shared array, the second updates the 32 by 8 tile from that. Any cell whose global coordinates put it on the domain boundary keeps its value in both steps, which is the same rule the single-step kernel follows and the reason the edge tiles need no special case.
That is 432 loads for 256 cells and two timesteps: 1.69 per launch, which looks worse than 1.33, and 0.84 per cell-step, which is the useful ratio. You pay for it in redundant loads, in shared memory (two arrays now), and in a first step where 256 threads have 340 cells to update, so some of them go round twice before the barrier.
The general trade is this: a stencil tile can cover more time steps as well as more grid cells. That needs a larger halo and more repeated loads, but it reduces trips to memory. Whether that trade is worth taking is a measurement, not a rule, and on a kernel already running at the copy rate there is little left to gain.
Pitfalls
Your simulation goes chequered, then to huge numbers, then to nan. The
timestep is past the CFL bound, so the centre weight 1 - 4r is negative and
the checkerboard pattern grows by |1 - 8r| every step.
Halve dt and run the same binary. Halving the cell spacing to get a finer picture
means quartering dt, which is why refining a grid gets expensive faster
than it looks.
You updated the grid in place. One buffer, and a thread reads a neighbour that has already advanced a step.
It does not crash, and the picture still looks like heat, but the error changes with the block schedule. Use two buffers and swap them after each step.
You filled one device buffer and not the other. The kernels never write
the boundary ring, so the wall temperature alternates between the set value
and whatever cudaMalloc handed you. Copy the initial condition into both,
and check the ring exactly rather than within a tolerance.
You guarded the barrier instead of the load. On a grid that is not a
whole number of tiles, some threads own no output cell, and the tempting
move is to return early or to put the bounds check around the
__syncthreads().
It may not hang. The threads that stay then read a tile that the others did not finish filling, so the error changes with the schedule.
Guard the load and the store, never the barrier. Day 14 takes apart what a barrier does and does not promise.
You copied the grid back so you could watch it. That is part 2 of this page. Keep the loop on the device and snapshot every hundredth step, or move the copy to a second stream with pinned memory so it overlaps the next step.
Day 51 is where the copies start hiding.
You added a padding column out of habit. Padding costs shared memory and buys nothing when every warp access walks a row of the tile, which is the case for all five reads here. Work out which bank each lane lands in before you spend the memory.
Go deeper
- CUDA Programming Guide 2.3.4.2, "Shared Memory", for the tile-and-barrier pattern this kernel is an instance of: https://docs.nvidia.com/cuda/cuda-programming-guide/02-basics/writing-cuda-kernels.html (checked 2026-08-30)
- CUDA C++ Best Practices Guide 10.2.3.1, "Shared Memory and Memory Banks", for the bank map that says why this tile needs no padding column: https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html (checked 2026-08-30)
- CUDA Programming Guide, "Compute Capabilities", Table 31, for the shared memory a block can hold on each architecture: https://docs.nvidia.com/cuda/cuda-programming-guide/05-appendices/compute-capabilities.html (checked 2026-08-29)
cuda-samples,cpp/2_Concepts_and_Techniques/convolutionSeparable, the same halo load with a filter instead of a Laplacian: https://github.com/NVIDIA/cuda-samples/tree/master/cpp/2_Concepts_and_Techniques/convolutionSeparable (checked 2026-08-30)- Programming Massively Parallel Processors, 4th edition, chapter 8, on stencil patterns and tiling with halo cells: https://shop.elsevier.com/books/programming-massively-parallel-processors/hwu/978-0-323-91231-0
Next
Day 35 builds a radix sort on the scan from day 32, which uses data in a different way. This topic returns twice: day 51 moves the measured copy onto a second stream so it overlaps the next step, and day 43 asks the same question about matmul, where the answer comes out the other way because the kernel, not the loop, is what is left to fix.