Recommended resources
The freely available resources this lesson will direct you to.
Introduction
Welcome back. In our previous lesson, we surveyed the GPU programming landscape, comparing the goals and ecosystems of CUDA, OpenCL, and ROCm/HIP. We established that while these frameworks differ in their approach to portability and vendor support, they share fundamental challenges in achieving high performance. A central challenge, regardless of the framework, is overcoming the "memory wall"—the growing gap between compute speed and memory bandwidth.
This lesson drills down into one of the most powerful techniques for tackling this problem. We will address the learning outcome: Explain how to use shared memory and synchronization primitives to facilitate efficient on-chip data sharing.
You will learn what shared memory is, its place in the GPU memory hierarchy, and why it's so critical for performance. We will then cover the essential synchronization primitives needed to use it correctly and explore the architectural nuances, like memory banks, that you must understand to use it efficiently. This knowledge is foundational for optimizing the data-intensive kernels that dominate AI and deep learning workloads.
1. The Role of Shared Memory in the GPU Memory Hierarchy
First, let's situate shared memory within the GPU architecture. As we've discussed, not all memory is created equal. There's a trade-off between size, speed, and proximity to the compute units.

As the diagram shows, shared memory is an on-chip resource, physically located on the same silicon as the Streaming Multiprocessor (SM). This makes it orders of magnitude faster (both in latency and bandwidth) than off-chip global memory (DRAM).
Key characteristics of shared memory:
- Fast: Access latency is comparable to L1 cache, around 20-30 cycles, versus 400+ cycles for global memory.
- User-Managed: Unlike a traditional cache that is managed automatically by hardware, shared memory is a "scratchpad" that you, the programmer, explicitly control. You decide what data to load into it, when to load it, and when to evict it.
- Per-Block Scope: Shared memory is allocated on a per-thread-block basis. All threads within a single block share access to the same region of shared memory, enabling them to cooperate and exchange data efficiently. However, threads in one block cannot see the shared memory of another block.
The primary purpose of shared memory is to enable data reuse. For algorithms where the same input data is needed for multiple calculations, the pattern is to pay the high cost of loading that data from global memory once, place it in fast shared memory, and then have multiple threads perform their calculations by accessing the data from there. This is a core strategy for moving an algorithm from being memory-bandwidth-bound to being compute-bound.
To see this in action, watch the following segment from the video '02 CUDA Shared Memory' by the channel cat blue. It introduces shared memory and uses a 1D stencil algorithm—a classic pattern in scientific computing and image processing—to demonstrate how caching data for reuse can improve performance.
Watch from 15:39 to 25:46. Focus on: The characteristics that distinguish shared memory from global memory (on-chip vs. off-chip, speed, user-managed). The concept of it being a 'per-block' resource. The explanation of data reuse in the stencil example and how loading data into shared memory reduces the number of slow global memory accesses.
2. Declaring and Using Shared Memory in CUDA
To use shared memory, you declare it inside a kernel using the __shared__ qualifier. CUDA provides two ways to allocate it: statically and dynamically.
Static Allocation
If the amount of shared memory needed is known at compile time, you can declare it like a standard array.
__global__ void myKernel() {
// Statically allocates an array of 256 floats in shared memory.
// The size must be a compile-time constant.
__shared__ float shared_data[256];
// ... kernel logic ...
}
Every thread block that executes this kernel will get its own private shared_data array.
Dynamic Allocation
If the amount of shared memory depends on runtime parameters, you can allocate it dynamically. This provides greater flexibility.
- In the kernel, declare the shared memory array as an unsized
externarray. - On the host, specify the size in bytes as the third parameter of the kernel launch configuration
<<<...>>>.
// Device-side kernel
__global__ void myDynamicKernel(float* data) {
// Declare a dynamically sized shared memory array.
// The name is just a pointer; the actual memory is allocated by the runtime.
extern __shared__ float dynamic_shared_data[];
// ... kernel logic using dynamic_shared_data ...
}
// Host-side launch
void launchKernel(int num_elements) {
int num_blocks = ...;
int threads_per_block = ...;
// Calculate the required shared memory size in bytes.
size_t shared_mem_bytes = num_elements * sizeof(float);
// Launch the kernel, passing the size as the 3rd launch parameter.
myDynamicKernel<<<num_blocks, threads_per_block, shared_mem_bytes>>>(...);
}
The video below gives a concise explanation of both static and dynamic allocation methods.
NVIDIA CUDA Tutorial 8: Intro to Shared Memory
The video 'NVIDIA CUDA Tutorial 8: Intro to Shared Memory' by Creel provides a clear walkthrough of the syntax for both static and dynamic shared memory allocation.
Watch the segment from 01:11 to 05:01, which covers the declaration syntax. Then, for a more detailed code example comparing static and dynamic allocation, you can watch the Q&A segment from 59:58 to 01:06:45.
3. Synchronization: Preventing Chaos with __syncthreads()
Because all threads in a block can access the same shared memory, we introduce the risk of race conditions. The CUDA programming model does not guarantee any specific execution order for threads within a block. One thread might race ahead of others, leading to incorrect results if, for example, it tries to read data from shared memory before another thread has finished writing it.
To prevent this, CUDA provides a synchronization primitive: __syncthreads().
__syncthreads() acts as a barrier. When a thread reaches this instruction, it pauses. It will only resume execution after all other threads in its block have also reached the same barrier.
This ensures that all memory operations issued before __syncthreads() are visible to all threads in the block after the barrier.
The canonical pattern for using shared memory is therefore:
- Load: Each thread loads a piece of data from global memory into shared memory.
- Synchronize: Call
__syncthreads()to ensure all data is loaded and visible to all threads. - Compute: Each thread performs computations using the now-complete dataset in shared memory.
The following sections from the '02 CUDA Shared Memory' and 'NVIDIA CUDA Tutorial 8' videos explain why synchronization is necessary and how __syncthreads() solves the problem of race conditions.
First, watch this clip from '02 CUDA Shared Memory' (33:39 - 42:51). It masterfully illustrates a data hazard that occurs without synchronization and then shows how __syncthreads() resolves it. This is a core concept.
NVIDIA CUDA Tutorial 8: Intro to Shared Memory
Next, for a slightly different explanation and example, watch this clip from 'NVIDIA CUDA Tutorial 8'.
Watch from 08:42 to 14:24. This section explains race conditions and demonstrates how __syncthreads() acts as a barrier, guaranteeing the correct execution order.
A critical rule for __syncthreads(): it must be called by all threads in a block. Placing it inside conditional code (if, switch) where some threads might not reach it will lead to deadlock, as the waiting threads will never be released.
4. Efficient Sharing: Understanding and Avoiding Bank Conflicts
Using shared memory and __syncthreads() ensures your kernel is correct. But to make it fast, you must understand its underlying hardware organization. Shared memory is not a single, monolithic memory block. It is divided into 32 parallel memory banks.
This design is intentional: since a warp consists of 32 threads, if each thread accesses a different bank, all 32 requests can be serviced in a single cycle. This achieves maximum bandwidth.
A bank conflict occurs when two or more threads in a warp try to access different addresses within the same memory bank simultaneously. When this happens, the hardware serializes the accesses, drastically reducing the effective memory bandwidth. In a 32-way conflict, a single memory instruction can take 32 cycles instead of 1.
The following article provides a superb, in-depth analysis of shared memory architecture and bank conflicts. Given your background, you'll appreciate its systematic and analytical approach.
A Comprehensive Analysis of CUDA Shared Memory, Bank ... - Uplatz
The article 'A Comprehensive Analysis of CUDA Shared Memory, Bank Conflicts, and Optimization Paradigms' breaks down the microarchitecture of shared memory, explains the mechanics of bank conflicts, and discusses common software patterns to avoid them.
Please read the following sections carefully. This is the theoretical core of this lesson: Section 2: Architectural Fundamentals of Shared Memory: Focus on the 32-bank structure and the address mapping formula: Bank Index = (A/4) % 32. Section 3: The Phenomenology of Bank Conflicts: Understand the mechanics of serialization and the exceptions (broadcast and multicast). Section 4: Analytical Deconstruction of Access Patterns: This is crucial. Pay close attention to why odd strides are conflict-free and the 'Power of Two Trap' for 2D arrays. Section 5: Software Optimization Strategies: Learn about the two primary solutions: Memory Padding and XOR Swizzling.
Here is a summary of the key takeaways from the article:
- Address Mapping: For 32-bit data (like
floatorint), successive words map to successive banks. Word 0 goes to Bank 0, Word 1 to Bank 1, ..., Word 31 to Bank 31, and Word 32 wraps around to Bank 0. - The Conflict: A bank conflict occurs when multiple threads in a warp access different words that map to the same bank (e.g., Thread 0 reads Word 0, Thread 1 reads Word 32).
- The Exception: If multiple threads access the exact same address, the hardware performs a broadcast, which is conflict-free.
- Common Problem: Accessing columns of a 2D array in shared memory where the width is a multiple of 32. For example, in a
__shared__ float tile[32][32], all threads accessing a column will target addresses that are 32 words apart (tile[0][c],tile[1][c], ...). Since32 % 32 = 0, all these addresses map to the same bank, causing a severe conflict. - Solutions:
- Padding: Declare the array with a width that is not a multiple of 32, e.g.,
__shared__ float tile[32][33]. The stride between rows becomes 33, which is an odd number and thus coprime to 32, eliminating the conflict. - Swizzling: Use bitwise operations (like XOR) to remap logical indices to physical indices in a way that breaks the conflict pattern. This is more complex but avoids wasting memory on padding.
- Padding: Declare the array with a width that is not a multiple of 32, e.g.,
5. A Glimpse into Advanced Synchronization and Data Transfer
On modern GPU architectures like NVIDIA's Hopper, the model for data movement has evolved further to maximize the overlap between computation and memory transfers. This is accomplished with features like the Tensor Memory Accelerator (TMA).
- Asynchronous Transfers: TMA allows you to initiate a bulk data copy from global memory to shared memory asynchronously. The CPU-equivalent analogy would be initiating a DMA transfer. The threads that launch the copy can immediately proceed to do other work while the transfer happens in the background.
- Hardware Swizzling: TMA can automatically swizzle data as it's being written to shared memory, arranging it in a layout that is guaranteed to be bank-conflict-free for subsequent compute operations (like matrix multiplications on Tensor Cores).
- Advanced Barriers: Simple barriers like
__syncthreads()are insufficient for coordinating these asynchronous operations. Modern CUDA C++ introduces more sophisticated barriers (cuda::barrier) that can synchronize based on the completion of these asynchronous data transfers.
This leads to a producer-consumer model inside the GPU kernel, where some threads or hardware units (like TMA) "produce" data by filling shared memory buffers, while other threads "consume" that data for computation, all pipelined for maximum efficiency.
Inside NVIDIA GPUs: Anatomy of high performance matmul kernels
The blog post 'Inside NVIDIA GPUs: Anatomy of high performance matmul kernels' has an excellent section illustrating how this advanced pipelining works. This will give you a sense of how shared memory and synchronization are used in state-of-the-art AI kernels.
Read the section titled 'Designing SOTA asynchronous matmul kernels on Hopper,' focusing specifically on the part that discusses pipelining and the producer-consumer model (it starts around the text 'The way we address the wasted cycles is by pipelining compute and data movement'). Study Figure 41, which visualizes this pipeline using a circular buffer in shared memory synchronized with barriers. You don't need to understand every line of code, but focus on the high-level design pattern.
Conclusion
In this lesson, we explored the critical role of shared memory and synchronization in writing high-performance GPU kernels. Understanding these concepts moves you from simply writing correct parallel code to writing efficient parallel code that thoughtfully manages the hardware's memory hierarchy.
Key Takeaways:
- Shared Memory is a fast, user-managed, on-chip scratchpad that is essential for exploiting data reuse and reducing costly global memory traffic.
- It is scoped to a thread block, enabling efficient communication and cooperation between threads within that block.
__syncthreads()is a fundamental barrier primitive required to prevent race conditions and ensure correct coordination when using shared memory.- Efficient access requires avoiding bank conflicts, which occur when multiple threads in a warp access different addresses in the same memory bank. This is often solved with padding or software swizzling.
- Modern GPUs use asynchronous copy engines (TMA) and advanced barriers to create complex pipelines that overlap computation with memory transfers, maximizing hardware utilization.
Preview of the Next Lesson:
Now that you have the tools of shared memory and synchronization, we will apply them to a foundational parallel algorithm. In the next lesson, we will focus on the parallel reduction primitive. You will learn a common and highly optimized pattern for implementing reductions on a GPU, a building block for countless algorithms in scientific computing and machine learning.