Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 31

Synchronizing Threads with `__syncthreads()`

Recommended resources

Introduction

In our previous lesson, we introduced shared memory as a fast, on-chip, user-managed cache. You learned its fundamental purpose: to reduce costly global memory traffic by enabling data reuse among threads in a block. We established the canonical "Load -> Synchronize -> Compute" pattern as the standard way to use it.

This lesson focuses squarely on that critical middle step: synchronization. We will move beyond simply knowing that we must synchronize and dive into the "first principles" of why. You will learn how the GPU's execution model necessitates synchronization to prevent subtle and catastrophic errors. By the end of this lesson, you will master the __syncthreads() intrinsic, understanding not only its function as a barrier but also the critical rules that govern its correct and safe usage.


The Problem: Race Conditions and Data Hazards

The core of the issue lies in a fundamental principle of the CUDA programming model: there is no guaranteed order of execution for threads. The hardware scheduler is free to run, pause, and resume threads in any sequence to maximize throughput and hide latency. From a programmer's perspective, you must always assume a "worst-case" scenario where threads can race ahead or fall behind one another.

When threads communicate by writing to and reading from a shared space—like shared memory—this non-guaranteed order creates the potential for race conditions. A race condition occurs when the correctness of an operation depends on the unpredictable sequence or timing of concurrent threads.

A classic analogy is two processes trying to update a bank account balance.

A simple illustration of a race condition. Both processes read the initial balance of 100. Process 2 updates the balance to 90. Then, Process 1, still working with the outdated value of 100, adds 10 and overwrites the shared variable with 110. The final result is incorrect because the operations were not properly synchronized.

In the context of a CUDA kernel using shared memory, the most common race condition is a Read-After-Write (RAW) data hazard. This happens when one thread attempts to read a value from shared memory before another thread has finished writing the correct value to that location. The reading thread gets stale or uninitialized data, leading to incorrect calculations.

Let's watch a segment that masterfully illustrates this exact problem in a CUDA kernel.

02 CUDA Shared Memory

This video from the "cat blue" channel demonstrates why simply loading data into shared memory and then immediately using it is flawed.

Watch the segment from "Why it doesn't work". The presenter constructs a compelling "worst-case scenario" where one thread (thread 15) races ahead. Pay close attention to how this leads it to compute with uninitialized data from the shared memory halo, which other threads were supposed to populate.


The Solution: The __syncthreads() Execution Barrier

To prevent these data hazards, CUDA provides a synchronization primitive: __syncthreads(). This intrinsic function creates an execution barrier for all threads within a single thread block.

A barrier is a point in your code that threads cannot cross until every thread in the block has reached it. Think of it as a mandatory rendezvous point.

  1. A thread reaches the __syncthreads() call and halts its execution.
  2. It waits there while other threads in the same block continue to run.
  3. Once the very last thread in the block reaches the barrier, the barrier is cleared.
  4. All threads in the block are then free to resume execution past that point.

This mechanism guarantees that any operations before the __syncthreads() call are completed by all threads before any thread can begin an operation after it.

Intro to CUDA (part 6): Synchronization

This short video by Josh Holloway provides a clear, animated explanation of a barrier.

Please watch the first part of the video from the introduction to the keyword. It will help you visualize how threads pause and then resume together at a synchronization point.

By placing __syncthreads() between the data loading phase and the computation phase, we enforce the "Load -> Synchronize -> Compute" pattern and eliminate the Read-After-Write hazard.

This diagram illustrates where `__syncthreads()` fits into a typical tiled computation loop. It acts as a wall, ensuring that the "Load global" and "Store shared" phases are complete for all threads before any thread can proceed to the "Load shared" and "Math" phases that depend on that data.

Now, let's see how adding this single line of code fixes the defective kernel from our first video.

02 CUDA Shared Memory

Returning to the "cat blue" video, we'll now see the solution applied.

Watch the segment from "How sync threads helps". The key is understanding where the __syncthreads() call is placed and why that specific location guarantees correctness.


__syncthreads() in Practice and A Critical Rule

You will find this pattern—load to shared memory, synchronize, compute from shared memory—in almost every high-performance kernel that performs tiling. In more complex algorithms, like parallel reductions or the GEMM kernels we will build later, you may even see multiple synchronization points within a single kernel.

Let's look at a few practical examples from a parallel reduction algorithm. You don't need to understand the algorithm's details yet; just observe the recurring pattern.

7 Step Optimization of Parallel Reduction with CUDA - Medium

This article shows several versions of a parallel reduction kernel. We'll look at two to see the __syncthreads pattern.

First, examine the code for REDUCTION 0. Notice the __syncthreads(); after loading from global to shared memory, and another one inside the for loop. Next, scroll down and look at the code for REDUCTION 2. Despite changes to the algorithm, the synchronization pattern remains: a sync after the load, and a sync within the processing loop. This reinforces its fundamental importance.

The Rule of Uniform Conditionals

There is one rule for using __syncthreads() that is absolutely critical to avoid bugs:

__syncthreads() is allowed in conditional code (if, for, while) only if the condition evaluates identically across the entire thread block.

If a conditional statement causes some threads in a block to execute __syncthreads() while others skip it, the program will deadlock. The threads that hit the barrier will wait forever for the other threads to arrive, but those other threads are on a different execution path and never will. The entire block, and potentially your whole application, will hang.

[PDF] NVIDIA CUDA Programming Guide

The official CUDA Programming Guide provides the most precise and authoritative definitions.

Please read the section on the Synchronization Function. Focus on the final paragraph that begins, "__syncthreads() is allowed in conditional code...". This is the formal statement of the rule and the consequence of breaking it.

A safe way to handle this is to have all threads execute the __syncthreads() call, even if only some threads are doing work inside a preceding if statement.

Incorrect (Deadlock if tid < 32):

if (tid < 32) {
    // some work
    __syncthreads(); 
}
// Other threads skip the if and may continue,
// while threads with tid < 32 wait forever.

Correct:

if (tid < 32) {
    // some work
}
__syncthreads(); // All threads reach here regardless of tid.

Conclusion

In this lesson, we dissected the "synchronize" step of our shared memory pattern. You now understand that it is not merely a suggestion but a requirement rooted in the fundamental nature of the GPU's parallel execution model. Without explicit synchronization, you are inviting race conditions that will corrupt your results in unpredictable ways.

Key Takeaways:

  • Non-Guaranteed Order: The CUDA programming model does not guarantee the execution order of threads, making synchronization essential for correctness when threads communicate.
  • Race Conditions: This lack of guaranteed order can lead to data hazards, like Read-After-Write (RAW), where a thread reads data before it has been properly written by another thread.
  • __syncthreads() is a Barrier: It acts as a block-level barrier, forcing all threads within a block to wait at that point until every thread has arrived.
  • Canonical Pattern: The correct pattern for tiled algorithms is to load data from global to shared memory, call __syncthreads(), and then compute using the data in shared memory.
  • Uniformity is Key: To avoid deadlock, __syncthreads() must be executed by either all threads in a block or no threads in a block. It cannot be placed within a divergent branch.

Now that you have a firm grasp of both shared memory and the synchronization required to use it safely, you are ready to apply these concepts. In our next lesson, we will implement a complete, tiled matrix transposition kernel, putting this powerful optimization pattern into practice.