HPC - Lecture 12 - Coalescence and Shared Memory GPU
Coalescence
Transactions to and from global memory are governed by coalescing rules. The goal of coalescence is to optimize read and write operations in global memory by improving memory access performance.
Definition of Coalescing
Coalescing refers to a set of conditions that allow multiple memory accesses to be merged into a single transaction.
This memory access pattern makes it possible to achieve very high performance when using global memory
Warp and Half-Warp Organization
Kernel memory accesses are said to be coalesced when threads with consecutive identifiers access contiguous memory locations using the same instruction
This follows from the way threads are organized into warps.
- Warp: Groups of 32 threads all executing the same instruction
- Hardware can combine memory accesses when threads with consecutive identifiers access contiguous memory locations using the same instruction.
- Threads in a warp execute the same instruction at the same time. In this way, the hardware can combine their memory accesses into a single transaction.
- Half-Warp: Memory accesses are specifically grouped into 16-thread units for transaction merging.

Guidelines for Coalescing
To achieve high performance, memory accesses should follow these rules:
- Contiguity: Accesses are most efficient when they are contiguous.
- Alignment: The starting address of a memory region must be a multiple of the region’s size (granularity).
- One-to-One Correspondence: The
-th thread of a half-warp should access the -th element of a block. - Thread Participation: Accesses are still coalesced even if some threads do not participate in the operation (inactive threads).
To achieve contiguity:
int indexRow=threadIdx.y + blockIdx.y*blockDim.y;
int indexCol=threadIdx.x + blockIdx.x*blockDim.x;
array_name[indexRow*col+indexCol]= valueAn example of succesfull coalescence:

An example of failed coalescence:

Failure Conditions
Coalescing fails when:
- Accesses are non-sequential.
- Access ranges overlap.
- Accesses start at an address that is not a multiple of the required granularity (e.g., starting at 132 instead of 128 for 64-byte granularity).
Exercise: Outer Product of Two Vectors
- Computing
for . - Input: array A of size M and array B of size N, integers M,N
- Output: matrix C of size MxN
Kernel Configuration:
- Use a two-dimensional structure for blocks and threads.
indexRow = threadIdx.y + blockIdx.y * blockDim.y(Iterates over rows ofand elements of ). indexCol = threadIdx.x + blockIdx.x * blockDim.x(Iterates over columns ofand elements of ).
Memory Allocation with Pitch:
For 2D matrices, cudaMallocPitch is used to ensure hardware-friendly alignment:
cudaMallocPitch((void**)&C_device, &pitch, N * sizeof(int), M);In the kernel, access the element using:
c[indexRow * pitch + indexCol] = a[indexRow] * b[indexCol];#include ...
int main(int argn, char * argv[])
{
//variables declaration...
// test on main arguments ...
// variables initialization ...
// host memory allocation(2D-1D)...
A_host=(int*)malloc(M*sizeof(int));
B_host=(int*)malloc(N*sizeof(int));
C_host=(int*)malloc(M*N*sizeof(int));
copy=(int*)malloc(M*N*sizeof(int));
// device memory allocation
cudaMalloc((void**)&A_device, M*sizeof(int));
cudaMalloc((void**)&B_device, N*sizeof(int));
cudaMallocPitch((void**)&C_device,&pitch,N*sizeof(int), M);
//data initialization on the host
//data transfer from host to device
cudaMemcpy(A_device, A_host, M*sizeof(int),
cudaMemcpyHostToDevice);
cudaMemcpy(B_device, B_host, N*sizeof(int),
cudaMemcpyHostToDevice);
//kernel launch
Outer_ProductGPU<<<nBlocks, nThreadsPerBlock>>>
(A_device,B_device,C_device,pitch/sizeof(int),M,N);
//results transfer from device to host
cudaMemcpy2D(copy, N*sizeof(int), C_device,pitch,
N*sizeof(int), M, cudaMemcpyDeviceToHost);
//call to the serial version………
//print results………
//correctness tests
//memory de-allocation
//exit
}
Implement the serial algorithm and its parallel version (kernel)
void OuterProductCPU (int*a, int*b, int*c,
int m, int n)
{
int i,j;
for(i=0;i<m;i++)
for(j=0;j<n;j++)
c[i*n+j]=a[i]*b[j];
}
__global__ void Outer_ProductGPU(int*a, int*b, int*c, int pitch, int m, int n)
{
int indexRow, indexCol;
indexRow=threadIdx.y+blockIdx.y*blockDim.y;
indexCol=threadIdx.x+blockIdx.x*blockDim.x;
if(indexRow<m && indexCol<n)
c[indexRow*pitch+indexCol] = a[indexRow]*b[indexCol];
}Shared Memory (SM)
Shared memory is on-chip memory with much lower latency than global memory. It is shared among all threads within the same block.
Stencil of a Vector
A stencil operation computes an output element as a sum of a neighborhood of radius
Pseudocode:
void stencilCPU(int *in, int *out, int n, int k)
{
int i,j;
for(i=0;i<n;i++)
for(j=-k;j<=k;j++)
out[i]+=(i+j)<0||(i+j)>=n?0:in[i+j];
}The i-th element of array B is them sum of the elements in the neighborhood of raiud
For example consider a vector with 


and so on.
Approach 1: Without Shared Memory
Let’s compute the stencil of an array of size
Each thread works on:
- the
-th element identified by threadIdx.x + blockIdx.x*blockDim.x - and its neighborhood of radius
around the element assigned to it. - Pay attention ! to the out-of-bonds errors for the first and last
elements of the input array.
__global__ void stencilWoutSMGPU(int *in, int *out,
int n, int radius)
{
int index=threadIdx.x+blockIdx.x*blockDim.x;
int i;
int value=0;
for(i=-radius;i<=radius;i++)
value+=(index+i)<0||(index+i)>=n?0: in[index+i];
out[index]=value;
}
// kernel launch
stencilWoutSMGPU <<<nBlocks, nThreadsPerBlock>>> (in_device, out_device_WoutSM, N,radius);Memory Accesses: Each thread performs
- For
, this results in 8 global memory accesses per thread.
Approach 2: With Shared Memory
Consider a number of threads of
Each block shares
- Loading Phase: Each thread loads its central element into SM. The first
threads also load the “halo” elements (previous and following ). - Computational Phase: Threads access only shared memory to compute the sum.

Global Memory Accesses: This approach requires only 4 global memory accesses (3 reads to load SM, 1 write for output) regardless of the value of
Consider this other examle where the first 
At this step:

__global__ void stencilWithSMGPU(int *in, int*out,
int n, int radius)
{
extern __shared__ int shMem[];
}
- extern indicates that the size of the shared memory variable is specified as a parameter when the kernel is launched.
int ShMemSize=(nThreadsPerBlock.x+2*radius)*sizeof(int);
stencilWithSMGPU <<<nBlocks, nThreadsPerBlock, ShMemSize>>> (in_device, out_device_conSM, N, radius);Shared memory allocation - Kernel parameter
If the size of shared memory is already known when the kernel is launched, it is possible to allocate it directly in the kernel launch configuration by specifying a third parameter in the triple angle brackets, indicating the size in bytes of the shared memory you want to use.
//kernel calling
nameKernel<<<nBlocks, nThreadsPerBlock, sizeShMemByte>>> (parameters)Alternatively, shared memory can be allocated directly inside the kernel at compile-time, but only statically i.e:
__shared__ int var[500];Kernel Pattern for Shared Memory (Stencil Example)
__global__ void stencilWithSMGPU(int *in, int *out, int n, int radius) {
extern __shared__ int shMem[];
int global_idx = threadIdx.x + blockIdx.x * blockDim.x;
int local_idx = threadIdx.x + radius;
// 1. Copy central element
shMem[local_idx] = in[global_idx];
// 2. Copy halo elements (previous and following k)
if (threadIdx.x < radius) {
shMem[local_idx - radius] = (global_idx - radius < 0) ? 0 : in[global_idx - radius];
shMem[local_idx + blockDim.x] = (global_idx + blockDim.x >= n) ? 0 : in[global_idx + blockDim.x];
}
// 3. Synchronize
__syncthreads();
// 4. Computation
int value = 0;
for (int i = -radius; i <= radius; i++) {
value += shMem[local_idx + i];
}
// 5. Write to Global Memory
out[global_idx] = value;
}Technical Implementation in CUDA
Shared Memory Allocation
- Dynamic Allocation: Specified at runtime during kernel launch.
- Kernel:
extern __shared__ int shMem[]; - Launch:
kernel<<<nBlocks, nThreads, sizeInBytes>>>(...);
- Kernel:
- Static Allocation: Specified at compile-time.
- Kernel:
__shared__ int var[500];
- Kernel:
Thread Synchronization
To prevent race conditions in shared memory, synchronization barriers are required.
CUDA provides two functions for thread synchronization:
__syncthreads(): Used inside the kernel to synchronize all threads within the same block. Essential after loading data into SM before processing it.cudaDeviceSynchronize(): Used on the Host side to wait for the kernel to complete before accessing results.
//finally the computational step…
int value = 0;
for(i = -radius; i<=radius; i++ )
value += shMem[local_idx + i];
// … e then the writing procedure in GM
out[global_idx] = value;How many accesses to global memory does each thread perform? 1 write to store the output value and 3 reads for uploading data in the shared memory.
Without SM: reads 2*k +1 central element + k before and after k. Writes 1.
- For k=3, 2*3 +1 + 1 = 6+2 = 8
With SM: reads 3 to upload data in the SM, writes 1. For any value of k it is always 4

Efficiency
This strategy is efficient only if the array size N and the number of blocks M satisfy
mod(M,N)==0
Dot Product of Two Vectors
- Computing the scalar
. - Input: array A of size N, array B of size M, integers N and M
- Output: scalar k
The dot product of two vectors is the sum of the elements of the vector obtained by pointwise multiplication of the two vectors.
void DotProductArrayCPU(int*a, int *b,int N,int*ris)
{
int i;
for(i=0;i<N;i++)
*ris+=(a[i]*b[i]);
}Without Shared Memory
Let us denote
We use
Steps:
- Each thread computes a partial sum of a portion of the vectors.
- The
partial sums computed by each thread are written to a supporting array in Global Memory. - The Host (CPU) performs a final serial loop to sum all partial results.
- Bottleneck: The CPU must process
elements (Total Blocks Threads per Block).

// To simplify the main, we use a function that handles memory allocation and kernel parameter definitions, then launches the kernel.
void Kconfiguration_DotProductArrayWoutSMGPU (int* in1, int* in2, int nBlocks,int nThreadsPerBlock, int N, int *res)
{
int *in1_device, *in2_device;
int *PartialSums_host, *PartialSums_device;
int size=N*sizeof(int);
int k= N/(nThreadsPerBlock*nBlocks);
cudaMalloc((void**)&in1_device, size);
cudaMalloc((void**)&in2_device, size);
cudaMalloc((void**)&PartialSums_device,
nBlocks*nThreadsPerBlock*sizeof(int));
PartialSums_host=(int*)malloc(nBlocks*nThreadsPerBlock*sizeof(int));
cudaMemcpy(in1_device, in1, size, cudaMemcpyHostToDevice);
cudaMemcpy(in2_device, in2, size, cudaMemcpyHostToDevice);
cudaMemset(PartialSums_device, 0, nThreadsPerBlock*nBlocks*sizeof(int));
DotProductArrayWoutSMGPU<<<nBlocks, nThreadsPerBlock>>>(in1_device, in2_device,PartialSums_device, k,N);
cudaDeviceSynchronize();
cudaMemcpy(PartialSums_host, PartialSums_device, nBlocks*nThreadsPerBlock*sizeof(int), cudaMemcpyDeviceToHost);
for(int i=0;i<nThreadsPerBlock*nBlocks;i++)
*ris+=PartialSums_host[i];
free(PartialSums_host);
cudaFree(in1_device);
cudaFree(in2_device);
cudaFree(PartialSums_device);
}
The main function only contains input reading, checks, and output printing.
__global__ void DotProductArrayWoutSMGPU (int *in1, int *in2, int *PartialSums, int k, int N)
{
int i;
int PartialSumsIndex = threadIdx.x+blockIdx.x*blockDim.x;
int inIndex=(threadIdx.x+blockIdx.x*blockDim.x)*k;
if(inIndex<N)
for(i=0;i<k;i++)
if(inIndex+i<N)
PartialSums[PartialSumsIndex]+= in1[inIndex+i]*in2[inIndex+i];
else
break;
}With Shared Memory
It is possible to modify the previous code in order to exploit the low latency of the Shared Memory and to reduce the number of elements to sum into the host (i.e. by the CPU).
The strategy based on dividing the work into portions and computing partial sums remains the same, but here the M threads of the same block can write their partial sums to an array stored in shared memory.
- Threads in a block compute partial sums and write them into an array in Shared Memory.
- Thread 0 of each block sums the values in SM and writes a single block-level result to global memory.
- The Host only needs to process
(number of blocks) partial sums (instead of )


Kernel configuration
void Kconfiguration_DotProductArrayWithSMGPU (int* in1, int* in2, int nBlocks, int nThreadsPerBlock, int stride, int N, int *res)
{
int *in1_device, *in2_device;
int *PartialSums_host, *PartialSums_device;
int size=N*sizeof(int);
cudaMalloc((void**)&in1_device, size);
cudaMalloc((void**)&in2_device, size);
cudaMalloc((void**)&PartialSums_device,nBlocks*sizeof(int));
PartialSums_host=(int*)malloc(nBlocks*sizeof(int));
cudaMemcpy(in1_device, in1, size, cudaMemcpyHostToDevice);
cudaMemcpy(in2_device, in2, size, cudaMemcpyHostToDevice);
int ShMemSize=nThreadsPerBlock*sizeof(int);
DotProductArrayWithSMGPU<<<nBlocks, nThreadsPerBlock, ShMemSize>>>
(in1_device, in2_device, PartialSums_device, stride,N);
cudaThreadSynchronize();
cudaMemcpy(PartialSums_host,PartialSums_device, nBlocks*sizeof(int), cudaMemcpyDeviceToHost);
for(int i=0;i<nBlocks;i++)
*res+=PartialSums_host[i];
free(PartialSums_host);
cudaFree(in1_device);
cudaFree(in2_device);
cudaFree(PartialSums_device);
}Dot product of two vettors with SM
__global__ void DotProductArrayWithSMGPU (int *in1, int *in2, int *PartialSums, int stride, int N)
{
extern __shared__ int shMem[];
int i;
int PartialSumsIndex=blockIdx.x;
int shMemIndex=threadIdx.x;
int inIndex = (threadIdx.x+blockIdx.x*blockDim.x)*stride;
shMem[shMemIndex]
if(inIndex<N){
for(i=0;i<stride;i++)
if(inIndex+i<N)
shMem[shMemIndex]+=in1[inIndex+i]*in2[inIndex+i];
else
break;
__syncthreads();
int value=0;
if(threadIdx.x==0){
for(i=0;i<blockDim.x;i++)
value+=shMem[i];
PartialSums[PartialSumsIndex]=value;
}
}
}Note:
Check the computation of
Parallel kernel and the serial version
void RowScaling(int*matrix, int*vector,
int*ris,int rows, int columns)
{
int i,j;
for(i=0;i<rows;i++)
for(j=0;j<columns;j++)
ris[i*columns+j]= matrix[i*columns+j]*vector[j];
}
__global__ void RowScalingGPU(int*mat,int*vec,
int*ris,int M,int N, int pitch)
{
int indexX=threadIdx.x + blockDim.x*blockIdx.x;
int indexY=threadIdx.y + blockDim.y*blockIdx.y;
if(indexX <M && indexY<N)
ris[indexX*pitch+indexY] = mat[indexX*pitch+indexY] * vec[indexY];
}
Exercise
Implement the followings:
- Outer product of two vectors
- Stencil of a vector using SM
- Dot product of two vectors using SM
- Element-wise matrix-vector multiplication using SM
Implement all the CUDA codes with timing and error checks.