Skip to main content

Welcome to a Grasp lesson

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

Lesson 38

Atomic Operations for Thread-Safe Memory Updates

Recommended resources

Introduction

In our last lesson, we tackled thread divergence, learning how to structure our code to keep the threads within a warp executing in lockstep. We have begun to master the control flow of parallel threads. Now, we must turn our attention to an equally critical challenge: managing the data flow. When thousands of threads are running concurrently, what happens when multiple threads attempt to read and modify the same piece of data at the same time?

This lesson confronts this problem, known as a race condition. You will learn how to use atomic operations to ensure that updates to shared memory locations are safe, correct, and predictable. We will explore what makes an operation "atomic," see how to use built-in CUDA functions like atomicAdd, and then dive deeper to understand the fundamental building block of many atomic operations: the compare-and-swap (atomicCAS) primitive. This is a foundational concept in any parallel system, from GPU kernels to high-frequency trading engines.


The Anatomy of a Data Race

In sequential programming, an operation like counter++ is conceptually simple. In parallel programming, this seemingly innocuous increment becomes a source of critical bugs. The expression counter++ is not a single, indivisible operation. At the machine level, it's a sequence:

  1. Read: Load the current value of counter from memory into a register.
  2. Modify: Increment the value in the register.
  3. Write: Store the new value from the register back into memory.

Now, imagine two threads trying to execute this sequence on the same counter variable, which initially holds the value 0.

  • Thread A reads 0.
  • Thread B reads 0.
  • Thread A increments its register to 1.
  • Thread B increments its register to 1.
  • Thread A writes 1 back to counter.
  • Thread B writes 1 back to counter.

The final value is 1, even though two increments occurred. One of the updates has been lost. This is a classic data race. The final result depends on the non-deterministic interleaving of thread executions. To understand why this happens at a hardware level, the following talk provides an excellent explanation.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

This segment from Fedor Pikus's CppCon talk explains the concept of atomicity and vividly illustrates how the read-modify-write problem manifests in hardware with multiple CPUs and their caches.

Watch from the beginning of the talk. Focus on the definition of an atomic operation as a "single transaction" and the visualization of how two threads attempting a non-atomic increment can lead to a lost update due to their separate caches.


The Solution: Atomic Operations in CUDA

To prevent data races, we need to ensure that the read-modify-write sequence is atomic, meaning it executes as a single, uninterruptible, indivisible unit from the perspective of all other threads. CUDA provides hardware-accelerated atomic operations directly for this purpose.

The following short video provides a practical demonstration. It shows a kernel suffering from a race condition and then fixes it using atomicAdd.

Atomic Memory Operations - Intro to Parallel Programming

This video from Udacity clearly demonstrates a race condition in a CUDA kernel and how to resolve it using atomicAdd.

Watch the segments that introduce atomic operations (introduction), show the atomicAdd implementation (code example), and demonstrate the correct result and performance impact (results). Notice that atomicAdd replaces the standard g[i] = g[i] + 1; operation.

As the video shows, replacing the unsafe + operation with a call to atomicAdd serializes the updates to that specific memory location, guaranteeing that no two threads can perform the read-modify-write cycle at the same time. The operation becomes thread-safe.

CUDA provides a family of atomic functions for both global and shared memory. These are hardware-level instructions, making them far more efficient than higher-level synchronization mechanisms like mutexes.

Parallel Computing Stanford CS149, Fall 2025 Lecture 16:

This slide from a Stanford course on parallel computing lists the most common atomic operations available in CUDA.

Review the list under the heading "Atomic operations (provided by CUDA)". You'll see functions for addition, subtraction, min/max, exchange, and bitwise operations. This shows you have a rich toolkit for various scenarios.

A forum discussion on the NVIDIA developer site confirms that these operations are, for the most part, directly implemented in the GPU hardware, which is key to their performance.


The Fundamental Primitive: Compare-and-Swap

While functions like atomicAdd and atomicMin are convenient, many are built upon a more fundamental and powerful primitive: Compare-and-Swap (CAS). Understanding CAS is crucial as it forms the basis for implementing arbitrary atomic operations and lock-free data structures.

In CUDA, the function is atomicCAS(address, compare, val). It works as follows, all in one indivisible step:

  1. It reads the value at address.
  2. It compares this value to compare.
  3. If they are equal, it writes val to address.
  4. If they are not equal, it does nothing.
  5. It returns the original value that was at address before the operation began.

How can we use this to implement, say, our own atomicAdd? We can't do it in one shot. Instead, we use a loop, often called a "CAS loop":

// Pseudocode for atomic_add using atomicCAS
void my_atomic_add(int* address, int amount) {
    int old_value = *address; // Read the current value (non-atomically is ok for first guess)
    int new_value = old_value + amount;

    // Keep trying to swap until we succeed
    // atomicCAS will return the value that was in memory *before* the attempt
    // If that returned value is not what we expected (old_value), it means another thread changed it.
    // So, we loop, update our values, and try again.
    while (atomicCAS(address, old_value, new_value) != old_value) {
        old_value = *address; // The memory was modified, so re-read the latest value
        new_value = old_value + amount;
    }
}

This pattern is the heart of lock-free programming. You optimistically compute a new value and then use CAS to atomically "commit" it, but only if no other thread has interfered in the meantime. If there was interference, you simply retry the computation with the latest data.

The following resources explain the CAS concept and show a practical implementation.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

This part of the CppCon talk introduces compare-and-swap and explains the retry loop that is central to nearly all lock-free algorithms.

Watch the segment from this timestamp. Pay close attention to the explanation of how compare_exchange_strong works and the structure of the while loop used to implement a generic atomic update.

Now let's see this pattern in a CUDA context. The following slide shows how to build an atomic_min function using atomicCAS.

Parallel Computing Stanford CS149, Fall 2025 Lecture 16:

These slides provide a concrete CUDA implementation of a CAS loop to create atomic_min.

Study the code example under the heading "Implementing atomic fetch-and-op". Trace the logic of the atomic_min function. It reads the old value, calculates the new minimum, and then enters a while loop that repeatedly tries to swap the old value for the new one until it succeeds. This perfectly mirrors the general pattern we just discussed.

Using this same atomicCAS primitive, you can even construct other synchronization tools like a spinlock, demonstrating its versatility.


Performance and Pitfalls

Atomic operations are a powerful tool, but they are not free.

  1. Performance Cost: An atomic operation forces serialization for a specific memory location. While vastly cheaper than OS-level locks, it is still significantly more expensive than a non-atomic read or write. If many threads from different warps contend for the same memory address, they will be forced to access it one by one, creating a bottleneck. As the Udacity video noted, the atomic version of the kernel ran slightly slower. Always use atomics only when truly necessary to resolve a data race.

  2. Mixing Atomics and Non-Atomics: This is a subtle but critical pitfall. An atomic operation guarantees its own indivisibility. It does not prevent a regular, non-atomic write from occurring on the same location. If one thread uses atomicAdd while another thread uses a simple *address = 0;, you still have a data race. The non-atomic write can happen between the read and write steps of another thread's atomic operation, or between two separate atomic operations.

This NVIDIA Developer Forum thread discusses this exact issue.

How's atomic operations in CUDA implemented? - CUDA / CUDA Programming and Performance - NVIDIA Developer Forums

This discussion highlights the danger of mixing atomic and non-atomic operations and provides a code example to prove the point.

Read the replies starting from the C++ standard explanation and continuing through the section containing the code example t150.cu. The key takeaway is that the non-atomic write (*d = 0;) "succeeds" but happens outside the boundary of any single atomicAdd operation, leading to an incorrect final result.

To correctly synchronize memory access between threads, atomicity alone is sometimes not enough. You also need to control the visibility and ordering of memory operations. This is the role of memory fences, which we will explore in our next lesson.


Conclusion

You have now added a critical tool to your parallel programming toolkit. Race conditions are an unavoidable consequence of concurrent memory access, and atomic operations are the hardware-level solution.

Key Takeaways:

  • A data race occurs when multiple threads perform a non-atomic read-modify-write sequence on the same memory location, leading to lost updates and incorrect results.
  • Atomic operations (atomicAdd, atomicMin, etc.) solve this by performing the entire read-modify-write cycle as a single, indivisible hardware instruction.
  • Compare-and-Swap (atomicCAS) is a fundamental atomic primitive that can be used in a retry loop to implement arbitrary thread-safe updates and other synchronization mechanisms.
  • Atomics carry a performance cost due to serialization and should only be used where necessary.
  • Mixing atomic and non-atomic operations on the same memory location still constitutes a data race and leads to undefined behavior.

In this lesson, we ensured that a single memory update is indivisible. In the next lesson, we will expand on this to control the ordering of multiple memory operations across different threads using memory fences. This will give us the complete set of tools needed to build complex and correct synchronization logic.