Prefer to read without ads? Become a member — from $10/month — and support the work. Already a member? Log in to read ad-free on this device.

5.4 Local Memory

Local memory contains the stack for every thread in a CUDA kernel. It is used as follows:

A kernel parameter passed by value can also land in local memory. Parameters arrive in a fast, read-only bank of constant memory, but if the code takes the address of a by-value parameter – or passes it by reference to a __device__ function – the compiler must first copy it into the thread’s own local memory so that the pointer has a per-thread object to refer to. For a large parameter structure, that copy is pure overhead. The __grid_constant__ qualifier, applied to a const by-value parameter, promises the compiler that every thread in the grid observes the same unmodified value, so its address resolves directly to the constant-memory copy and the per-thread duplication is skipped.

In early implementations of CUDA hardware, any use of local memory was the “kiss of death” – it slowed things down so much that developers were encouraged to take whatever measure was needed to get rid of the local memory usage. With the advent of an L1 cache in Fermi, these performance concerns are less urgent, provided the local memory traffic is confined to L114.

Developers can make the compiler report the amount of local memory needed by a given kernel with the nvcc options: -Xptxas –v,abi=no

At runtime, the amount of local memory used by a kernel may be queried with cuFuncGetAttribute(CU_FUNC_ATTRIBUTE_LOCAL_SIZE_BYTES).

Paulius Micikevicius of NVIDIA gave a good presentation on how to determine whether local memory usage was impacting performance, and what to do about it. Register spilling can incur two costs: an increased number of instructions, and an increase in the amount of memory traffic.

The L1 and L2 performance counters can be used to determine if the memory traffic is impacting performance. Strategies to improve performance in this case include:

When launching a kernel that uses more than the default amount of memory allocated for local memory, the CUDA driver must allocate a new local memory buffer before the kernel can launch. As a result, the kernel launch may take extra time; may cause unexpected CPU/GPU synchronization; and, if the driver is unable to allocate the buffer for local memory, may fail15. By default, the CUDA driver will free these larger local memory allocations after the kernel has launched. This behavior can be inhibited by specifying the CU_CTX_RESIZE_LMEM_TO_MAX flag to cuCtxCreate(), or calling cudaDeviceSetFlags() with the cudaDeviceLmemResizeToMax flag set.

It is not difficult to build a templated function that illustrates the “performance cliff” when register spills occur. The templated GlobalCopy() kernel of Listing 5-10 implements a simple memcpy routine that uses a local array temp to stage global memory references. The template parameter n specifies the number of elements in temp, and hence the number of loads and stores to perform in the inner loop of the memory copy.

As a quick review of the SASS microcode emitted by the compiler will confirm, the compiler can keep temp in registers until n becomes too large.

template<class T, const int n> __global__ voidGlobalCopy( T *out, const T *in, size_t N ){    T temp[n];    size_t i;    for ( i = n*blockIdx.x*blockDim.x+threadIdx.x;           i < N-n*blockDim.x*gridDim.x;           i += n*blockDim.x*gridDim.x ) {        for ( int j = 0; j < n; j++ ) {            size_t index = i+j*blockDim.x;            temp[j] = in[index];        }        for ( int j = 0; j < n; j++ ) {            size_t index = i+j*blockDim.x;            out[index] = temp[j];        }    }    // to avoid the (index<N) conditional in the inner loop,     // we left off some work at the end    for ( int j = 0; j < n; j++ ) {        for ( int j = 0; j < n; j++ ) {            size_t index = i+j*blockDim.x;            if ( index<N ) temp[j] = in[index];        }        for ( int j = 0; j < n; j++ ) {            size_t index = i+j*blockDim.x;            if ( index<N ) out[index] = temp[j];        }    }}
Listing 5-10. GlobalCopy kernel (source on GitHub)

Listing 5-11 shows an excerpt of the output from globalCopy.cu on a GK104 GPU: the copy performance of 64-bit operands only. The degradation in performance due to register spilling becomes obvious in the row corresponding to a loop unroll of 12, where the delivered bandwidth decreases from 117 GB/s to less than 90 GB/s, and degrades further to under 30 GB/s as the loop unroll increases to 16.

Table 5-9 summarizes the register and local memory usage for the kernels corresponding to the unrolled loops. The performance degradation of the copy corresponds to the local memory usage. In this case, every thread always spills in the inner loop; presumably, the performance wouldn’t degrade so much if only some of the threads were spilling (for example, when executing a divergent code path).

Unroll factor Registers Local memory (bytes)
1 20 None
2 19 None
3 26 None
4 33 None
5 39 None
6 46 None
7 53 None
8 58 None
9 62 None
10 63 None
11 63 None
12 63 16
13 63 32
14 63 60
15 63 96
16 63 116

Table 5-9. globalCopy() register and local memory usage.

Operand size: 8 bytesInput size: 16M operands                      Block SizeUnroll	32	64	128	256	512	maxBW	maxThreads1	75.57	102.57	116.03	124.51	126.21	126.21	5122	105.73	117.09	121.84	123.07	124.00	124.00	5123	112.49	120.88	121.56	123.09	123.44	123.44	5124	115.54	122.89	122.38	122.15	121.22	122.89	645	113.81	121.29	120.11	119.69	116.02	121.29	646	114.84	119.49	120.56	118.09	117.88	120.56	1287	117.53	122.94	118.74	116.52	110.99	122.94	648	116.89	121.68	119.00	113.49	105.69	121.68	649	116.10	120.73	115.96	109.48	99.60	120.73	6410	115.02	116.70	115.30	106.31	93.56	116.70	6411	113.67	117.36	111.48	102.84	88.31	117.36	6412	88.16	86.91	83.68	73.78	58.55	88.16	3213	85.27	85.58	80.09	68.51	52.66	85.58	6414	78.60	76.30	69.50	56.59	41.29	78.60	3215	69.00	65.78	59.82	48.41	34.65	69.00	3216	65.68	62.16	54.71	43.02	29.92	65.68	32
Listing 5-11. globalCopy.cu output (64-bit only)

The measurements in Listing 5-11 and Table 5-9 were taken on a Kepler-class GK104, whose kernels were capped at 63 registers; once the staging array outgrew that budget, at an unroll of 12, the overflow spilled to local memory and copy bandwidth fell off a cliff. Ampere and later GPUs carry a much larger register file – up to 255 registers per thread – so the same globalCopy.cu kernel no longer spills across this range. On a GeForce RTX 3060, the 64-bit copy climbs from 10 registers at an unroll of 1 to 126 at an unroll of 16 with no local memory whatsoever, and its best-case bandwidth holds flat at roughly 305 GB/s throughout. The performance cliff is still real – it simply takes a larger register footprint, or an explicit --maxrregcount limit, to provoke it.


  1. The L1 cache is per-SM, and physically implemented in the same hardware as shared memory.↩︎

  2. Since most resources are preallocated, an inability to allocate local memory is one of the few circumstances that can cause a kernel launch to fail at runtime.↩︎