the short version

Memory does not arrive one value at a time. It arrives in fixed-size chunks, and the hardware serves a whole thread group's requests together. When the lanes in a group ask for addresses that fall inside the same few chunks, those requests merge into a handful of transactions. When they ask for scattered addresses, each one drags in its own chunk and almost all of the bytes fetched are thrown away.

Same data, same instruction count, an order of magnitude difference in time. This is usually the largest single lever in a memory-bound kernel.

what the hardware actually does

A load instruction is issued for the whole group at once. The memory system looks at the addresses every lane wants, works out which chunks of memory cover them, and issues one transaction per distinct chunk.

So the cost of a load is not the number of lanes, it is the number of distinct chunks touched. Thirty-two lanes reading thirty-two consecutive floats touch a small number of chunks and cost a small number of transactions. Thirty-two lanes reading floats a kilobyte apart touch thirty-two different chunks and cost thirty-two transactions, of which you use four bytes each.

The useful mental model is bytes requested against bytes delivered. Coalesced access uses nearly all of what the memory system fetched. Scattered access can use a few percent of it, and the wasted fraction is bandwidth you paid for and threw away.

the rule

Consecutive threadIdx.x should touch consecutive addresses. That is the whole rule, and it is why the global index formula puts threadIdx.x in the least significant position:


int i = blockIdx.x * blockDim.x + threadIdx.x;
float v = data[i];      // coalesced: lane n reads element n
            
In multidimensional kernels the same rule applies to the flattened index, and .x varies fastest. So a 2D kernel should have threadIdx.x walking along the contiguous dimension of your array, not down it.

the three ways it breaks

Striding. Each lane reads every Nth element. The classic case is a column-major walk through a row-major array, or a matrix transpose where one of the two accesses is necessarily strided.


// coalesced read, strided write
out[x * height + y] = in[y * width + x];
            
You cannot make both sides contiguous at once. The standard fix is to route the transpose through shared memory: read coalesced, write to the scratchpad, synchronize, then read from the scratchpad in the other order and write coalesced. Both global accesses become contiguous and the awkward transposition happens on-chip, where scattered access is cheap.

Array of structs. Storing particles as {x, y, z, vx, vy, vz} and having each lane read one particle's x means the lanes touch addresses 24 bytes apart. Flipping to a struct of arrays, one array per field, makes every field access contiguous. This is often the single highest-value change available in a simulation kernel, and it is a data layout decision rather than a kernel one.

Indirection. Gather and scatter through an index array is inherently scattered and there is no trick that makes it contiguous. Here the goal shifts to improving locality, by sorting or partitioning the indices so that lanes in a group at least land near each other.

alignment, briefly

Beyond ordering, the base address matters. An allocation returned by the device allocator is generously aligned, so arrays start on a chunk boundary and a contiguous group access falls inside the minimum number of chunks. Slicing into the middle of an array at an arbitrary offset can push a group across one extra boundary and cost an additional transaction per access.

The effect is far smaller than the ordering problem and is usually not worth restructuring for, but it is why padding a row stride sometimes produces an unexplained few percent.

see it on your own machine

This is one of the easier things to confirm, because the profilers report the wasted fraction directly.

ncu --metrics l1tex__t_sectors_pipe_lsu_mem_global_op_ld.sum,l1tex__t_requests_pipe_lsu_mem_global_op_ld.sum ./my_app | https://docs.nvidia.com/nsight-compute/ | sectors moved against requests issued; a high ratio means each request is dragging in chunks you mostly discard |'mc_ncu'
ncu --set full ./my_app | https://docs.nvidia.com/nsight-compute/ | the full report names uncoalesced access explicitly in its memory workload analysis, which is the easier place to start |'mc_full'
rocprofv3 --kernel-trace --stats -- ./my_app | https://rocm.docs.amd.com/projects/rocprofiler-sdk/en/latest/ | kernel timings on AMD, to compare a before and after rather than read the counter directly |'mc_rocprof'

related topics

The GPU Memory Hierarchy — where these transactions land and what they cost.
Shared Memory and Bank Conflicts — the same idea one level up the hierarchy, with different rules.
Thread Indexing — why the index formula is written the way it is.

reference

CUDA C++ Best Practices Guide
CUDA Programming Guide: device memory accesses
AMD HIP documentation