CUDA Programming Series — Tutorial 06

Synchronisation & Atomics

__syncthreads(), warp-level primitives, atomic operations, race conditions, and parallel reduction patterns.

CUDA Synchronisation Atomics Reduction Warps Cooperative Groups
Why Sync → __syncthreads → Warp Primitives → Atomics → Reduction → Histogram → Coop Groups
00

Topics We'll Cover

This tutorial explores how CUDA threads coordinate their work. We cover barriers, warp-level intrinsics, atomic operations, and put them all together with parallel reduction and histogram patterns.

Prerequisites

Tutorials 01–04 (GPU Architecture, First Kernel, Thread Hierarchy, Memory Model). You should be comfortable writing kernels that use shared memory and understand the thread/block/grid hierarchy.

01

Why Synchronisation Matters

CUDA launches thousands of threads that execute concurrently. Without coordination, threads reading and writing shared data produce data races — the results depend on unpredictable execution order.

A Simple Data Race

Consider two threads both trying to increment a shared counter:

Thread A
read counter = 5
compute 5 + 1 = 6
write counter = 6
Thread B
read counter = 5
compute 5 + 1 = 6
write counter = 6
Expected: 7    Actual: 6    — Lost update!

Three Consequences of Unsynchronised Access

Data Races

Multiple threads read/write the same location without ordering. The final value is undefined.

Incorrect Results

Lost updates, partial writes, or stale reads lead to wrong answers that may look plausible.

Non-Determinism

Results change between runs. The bug may appear intermittently, making debugging extremely difficult.

CUDA's Synchronisation Toolkit

Mechanism Scope Cost
__syncthreads() All threads in a block Low (hardware barrier)
Warp-level primitives 32 threads in a warp Very low (single instruction)
Atomic operations Global or shared memory Medium (serialises at address)
Cooperative Groups Flexible (warp to grid) Varies by scope
Key Insight

Synchronisation always has a cost. The art of CUDA programming is choosing the narrowest scope that guarantees correctness — warp-level if possible, block-level if needed, grid-level as a last resort.

02

__syncthreads() — Block-Level Barrier

__syncthreads() is a barrier: every thread in the block must reach this point before any thread is allowed to proceed past it. It also acts as a memory fence — all shared memory writes before the barrier are visible to all threads after it.

How It Works

T0: write smem[0]
T1: write smem[1]
T2: write smem[2]
T3: write smem[3]
↓
__syncthreads() — BARRIER
↓
T0: read smem[3]
T1: read smem[2]
T2: read smem[1]
T3: read smem[0]
All writes guaranteed visible after barrier

Classic Pattern: Shared Memory Tile Load

shared_memory_pattern.cu
__shared__ float tile[BLOCK_SIZE];

// Phase 1: Each thread loads one element
tile[threadIdx.x] = input[blockIdx.x * blockDim.x + threadIdx.x];

// BARRIER: ensure all loads complete before any reads
__syncthreads();

// Phase 2: Now safe to read any element in the tile
float left  = (threadIdx.x > 0) ? tile[threadIdx.x - 1] : 0.0f;
float right = (threadIdx.x < BLOCK_SIZE - 1) ? tile[threadIdx.x + 1] : 0.0f;
output[idx] = 0.25f * left + 0.5f * tile[threadIdx.x] + 0.25f * right;

The Deadlock Danger: Conditional __syncthreads()

If __syncthreads() appears inside a conditional branch, all threads in the block must still reach it. If some threads take the branch and others don't, the block deadlocks — threads that reached the barrier wait forever for threads that will never arrive.

WRONG — Deadlock

if (threadIdx.x < 32) {
    smem[threadIdx.x] = data;
    __syncthreads(); // Threads 32+ never reach here!
}

CORRECT

if (threadIdx.x < 32) {
    smem[threadIdx.x] = data;
}
__syncthreads(); // All threads reach this
Rule

__syncthreads() must be reached by every thread in the block, or by no thread. Never place it inside an if that excludes some threads. Also note: it only synchronises within a single block — there is no built-in way to synchronise across blocks (until Cooperative Groups).

03

Warp-Level Primitives

Since all 32 threads in a warp execute in lockstep (SIMT), CUDA provides warp-level intrinsics that let threads communicate directly through registers — no shared memory needed, no barrier needed.

The Mask Parameter

All warp primitives take a mask parameter — a 32-bit integer where each bit indicates which threads participate. Use 0xFFFFFFFF for all 32 threads, or a custom mask for a subset.

Shuffle: __shfl_sync

Directly read a register value from another thread in the warp. Four variants:

Intrinsic Description Use Case
__shfl_sync(mask, val, src) Read val from lane src Broadcast a value to all lanes
__shfl_up_sync(mask, val, delta) Read from lane id - delta Prefix sums (scan)
__shfl_down_sync(mask, val, delta) Read from lane id + delta Reductions
__shfl_xor_sync(mask, val, mask) Read from lane id ^ mask Butterfly reductions

Warp-Level Reduction (No Shared Memory!)

warp_reduce.cu
__device__ float warpReduceSum(float val) {
    for (int offset = 16; offset > 0; offset >>= 1) {
        val += __shfl_down_sync(0xFFFFFFFF, val, offset);
    }
    return val;  // Result valid in lane 0 only
}

This completes a full 32-element reduction in 5 instructions, entirely in registers:

__shfl_down_sync reduction across 8 lanes (simplified)
L0
L1
L2
L3
L4
L5
L6
L7
offset = 4: L0+=L4   L1+=L5   L2+=L6   L3+=L7
L0-4
L1-5
L2-6
L3-7
-
-
-
-
offset = 2: L0+=L2   L1+=L3
L0-6
L1-7
-
-
-
-
-
-
offset = 1: L0+=L1
SUM
-
-
-
-
-
-
-

Ballot & Predicate Intrinsics

Intrinsic Returns Use Case
__ballot_sync(mask, predicate) Bitmask of threads where predicate is true Count matching threads, compact
__all_sync(mask, predicate) 1 if all threads' predicate is true Early exit optimisation
__any_sync(mask, predicate) 1 if any thread's predicate is true Detect if work remains
__popc(__ballot_sync(...)) Population count of ballot result Count how many threads match
Performance

Warp-level primitives are the fastest synchronisation mechanism in CUDA. Each compiles to a single hardware instruction. Always prefer shuffle-based reductions over shared memory when operating within a single warp.

04

Atomic Operations

Atomic operations perform a read-modify-write as a single, indivisible operation. No other thread can see the intermediate state. They solve the data race problem from Slide 01 — but at a performance cost.

Common Atomic Functions

Function Operation Types
atomicAdd(&addr, val) *addr += val int, unsigned, float, double*
atomicSub(&addr, val) *addr -= val int, unsigned
atomicMax(&addr, val) *addr = max(*addr, val) int, unsigned
atomicMin(&addr, val) *addr = min(*addr, val) int, unsigned
atomicExch(&addr, val) *addr = val (returns old) int, unsigned, float
atomicCAS(&addr, compare, val) If *addr == compare, set to val int, unsigned, unsigned long long

* atomicAdd for double requires compute capability 6.0+

Global vs Shared Memory Atomics

Global Memory Atomics

  • All blocks can atomically update the same address
  • High latency (~100s of cycles)
  • Contention: many threads hitting the same address serialises execution

Shared Memory Atomics

  • Only threads within the same block
  • Much lower latency (~few cycles)
  • Common pattern: accumulate in shared, then one atomic to global

atomicCAS — The Universal Building Block

Compare-And-Swap is the most powerful atomic: it can implement any atomic operation. Here's how to build an atomic double-precision add on older hardware:

atomicAdd_double_via_CAS.cu
__device__ double atomicAddDouble(double* addr, double val) {
    unsigned long long* addr_as_ull = (unsigned long long*)addr;
    unsigned long long old = *addr_as_ull, assumed;
    do {
        assumed = old;
        old = atomicCAS(addr_as_ull, assumed,
            __double_as_longlong(__longlong_as_double(assumed) + val));
    } while (assumed != old);
    return __longlong_as_double(old);
}
Performance Tip

Atomics serialise at the address level. If 1000 threads all atomicAdd to the same global address, they execute one at a time. Solution: reduce locally first (within warp, then block), then perform a single atomic per block to global memory.

05

Parallel Reduction — Step by Step

Reduction is the canonical example for synchronisation: combine N values into one (sum, max, min, etc.). Let's build it from naive to optimal.

Approach Comparison

Sequential (CPU)

O(N) steps, 1 thread. No parallelism at all.

Tree Reduction

O(log N) steps, N/2 threads. Each step halves active threads.

Warp + Tree

Tree in shared memory, then warp shuffle for final 32 elements. Fastest.

Tree Reduction in Shared Memory

Eight values reduced in 3 steps (log₂8 = 3):

Tree Reduction: 8 values → 1 result
3
1
7
0
4
1
6
3
Step 1: stride = 4 — add pairs separated by 4
3+4=7
1+1=2
7+6=13
0+3=3
-
-
-
-
Step 2: stride = 2 — add pairs separated by 2
7+13=20
2+3=5
-
-
-
-
-
-
Step 3: stride = 1 — final add
25
-
-
-
-
-
-
-
3 + 1 + 7 + 0 + 4 + 1 + 6 + 3 = 25 ✓

Complete Parallel Reduction Program

parallel_reduction.cu — Complete, compilable: nvcc -o reduce parallel_reduction.cu
#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define BLOCK_SIZE 256

// ── Warp-level reduction using shuffle ──
__device__ float warpReduceSum(float val) {
    for (int offset = 16; offset > 0; offset >>= 1)
        val += __shfl_down_sync(0xFFFFFFFF, val, offset);
    return val;
}

// ── Block-level reduction: shared memory tree + warp shuffle finish ──
__device__ float blockReduceSum(float val) {
    __shared__ float warp_sums[32]; // max 32 warps per block (1024/32)

    int lane = threadIdx.x & 31;       // thread index within warp (0-31)
    int wid  = threadIdx.x >> 5;       // warp index within block

    // Step 1: reduce within each warp
    val = warpReduceSum(val);

    // Step 2: lane 0 of each warp writes its result to shared memory
    if (lane == 0) warp_sums[wid] = val;
    __syncthreads();

    // Step 3: first warp reduces the warp_sums
    int num_warps = blockDim.x >> 5;
    val = (threadIdx.x < num_warps) ? warp_sums[threadIdx.x] : 0.0f;
    if (wid == 0) val = warpReduceSum(val);

    return val;
}

// ── Kernel: each block reduces its portion, atomicAdd to global result ──
__global__ void reduceSum(const float* input, float* output, int N) {
    float sum = 0.0f;

    // Grid-stride loop: each thread accumulates multiple elements
    for (int i = blockIdx.x * blockDim.x + threadIdx.x;
         i < N;
         i += blockDim.x * gridDim.x) {
        sum += input[i];
    }

    // Reduce within block
    sum = blockReduceSum(sum);

    // Thread 0 of each block adds to global result
    if (threadIdx.x == 0)
        atomicAdd(output, sum);
}

int main() {
    const int N = 1 << 20;  // 1M elements
    size_t bytes = N * sizeof(float);

    // Host allocation and initialisation
    float* h_input = (float*)malloc(bytes);
    float h_output = 0.0f;
    float expected = 0.0f;
    for (int i = 0; i < N; i++) {
        h_input[i] = 1.0f;  // all ones: sum should be N
        expected += h_input[i];
    }

    // Device allocation
    float *d_input, *d_output;
    cudaMalloc(&d_input, bytes);
    cudaMalloc(&d_output, sizeof(float));

    // Copy input and zero the output
    cudaMemcpy(d_input, h_input, bytes, cudaMemcpyHostToDevice);
    cudaMemset(d_output, 0, sizeof(float));

    // Launch: use enough blocks to keep the GPU busy
    int numBlocks = (N + BLOCK_SIZE - 1) / BLOCK_SIZE;
    if (numBlocks > 1024) numBlocks = 1024;  // cap for grid-stride loop
    reduceSum<<<numBlocks, BLOCK_SIZE>>>(d_input, d_output, N);

    // Copy result back
    cudaMemcpy(&h_output, d_output, sizeof(float), cudaMemcpyDeviceToHost);

    printf("Sum = %.0f (expected %.0f) — %s\n",
           h_output, expected,
           (fabsf(h_output - expected) < 1.0f) ? "PASS" : "FAIL");

    // Cleanup
    cudaFree(d_input);
    cudaFree(d_output);
    free(h_input);
    return 0;
}
How It All Fits Together

Grid-stride loop → each thread accumulates a partial sum. Warp shuffle → reduces 32 partial sums within each warp. Shared memory + __syncthreads → reduces warp results within each block. atomicAdd → combines block results into a single global sum.

06

Histogram Example

Histograms are a classic use case for atomics: many threads categorise data into bins concurrently. The naive approach (global atomics for every element) is slow. The optimised approach uses shared memory per-block histograms merged into global memory at the end.

Strategy: Two-Phase Histogram

Each thread reads data
→
atomicAdd to shared histogram
→
__syncthreads()
→
Merge: atomicAdd shared → global
Per-Block Shared Histogram → Merge to Global
Block 0 smem hist
12
8
15
9
Block 1 smem hist
10
14
7
11
→
Global histogram
22
22
22
20

Complete Histogram Program

histogram.cu — Complete, compilable: nvcc -o histogram histogram.cu
#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define NUM_BINS   256
#define BLOCK_SIZE 256

// ── Phase 1: per-block shared memory histogram ──
// ── Phase 2: merge per-block histogram into global ──
__global__ void histogram(const unsigned char* data, int N,
                           unsigned int* globalHist) {
    // Shared memory histogram for this block
    __shared__ unsigned int smemHist[NUM_BINS];

    // Cooperatively zero the shared histogram
    for (int i = threadIdx.x; i < NUM_BINS; i += blockDim.x)
        smemHist[i] = 0;
    __syncthreads();

    // Phase 1: each thread processes elements via grid-stride loop
    for (int i = blockIdx.x * blockDim.x + threadIdx.x;
         i < N;
         i += blockDim.x * gridDim.x) {
        atomicAdd(&smemHist[data[i]], 1);
    }
    __syncthreads();

    // Phase 2: merge shared histogram into global histogram
    for (int i = threadIdx.x; i < NUM_BINS; i += blockDim.x) {
        if (smemHist[i] > 0)
            atomicAdd(&globalHist[i], smemHist[i]);
    }
}

int main() {
    const int N = 1 << 22;  // 4M bytes
    size_t dataBytes = N * sizeof(unsigned char);
    size_t histBytes = NUM_BINS * sizeof(unsigned int);

    // Host allocations
    unsigned char* h_data = (unsigned char*)malloc(dataBytes);
    unsigned int* h_hist  = (unsigned int*)calloc(NUM_BINS, sizeof(unsigned int));
    unsigned int* h_ref   = (unsigned int*)calloc(NUM_BINS, sizeof(unsigned int));

    // Generate random data and compute CPU reference
    srand(42);
    for (int i = 0; i < N; i++) {
        h_data[i] = (unsigned char)(rand() % NUM_BINS);
        h_ref[h_data[i]]++;
    }

    // Device allocations
    unsigned char* d_data;
    unsigned int*  d_hist;
    cudaMalloc(&d_data, dataBytes);
    cudaMalloc(&d_hist, histBytes);

    cudaMemcpy(d_data, h_data, dataBytes, cudaMemcpyHostToDevice);
    cudaMemset(d_hist, 0, histBytes);

    // Launch kernel
    int numBlocks = (N + BLOCK_SIZE - 1) / BLOCK_SIZE;
    if (numBlocks > 1024) numBlocks = 1024;
    histogram<<<numBlocks, BLOCK_SIZE>>>(d_data, N, d_hist);

    // Copy results back
    cudaMemcpy(h_hist, d_hist, histBytes, cudaMemcpyDeviceToHost);

    // Verify
    int errors = 0;
    for (int i = 0; i < NUM_BINS; i++) {
        if (h_hist[i] != h_ref[i]) {
            printf("Mismatch at bin %d: GPU=%u CPU=%u\n", i, h_hist[i], h_ref[i]);
            errors++;
        }
    }
    printf("Histogram: %d/%d bins correct — %s\n",
           NUM_BINS - errors, NUM_BINS,
           (errors == 0) ? "PASS" : "FAIL");

    // Print first 10 bins
    printf("First 10 bins: ");
    for (int i = 0; i < 10; i++)
        printf("[%d]=%u ", i, h_hist[i]);
    printf("\n");

    // Cleanup
    cudaFree(d_data);
    cudaFree(d_hist);
    free(h_data);
    free(h_hist);
    free(h_ref);
    return 0;
}
Why Shared Memory Histograms Win

With 1024 blocks, a naive global-only histogram has thousands of threads contending on each bin. The shared-memory approach reduces contention to at most BLOCK_SIZE threads per bin, and the merge phase only has numBlocks atomic operations per bin on global memory.

07

Cooperative Groups (CUDA 9+)

Before CUDA 9, synchronisation was limited to __syncthreads() (block-level) and atomics. Cooperative Groups provides a flexible, composable API for synchronising at any granularity — from sub-warp tiles to entire grids.

Key Group Types

Group Type Scope How to Create
thread_block All threads in a block this_thread_block()
tiled_partition<N> Sub-warp tile (N = 1, 2, 4, 8, 16, 32) tiled_partition<16>(block)
coalesced_group Active threads only (after divergence) coalesced_threads()
grid_group All threads in the grid this_grid()

Example: Tiled Partition Reduction

cooperative_groups_example.cu
#include <cooperative_groups.h>
namespace cg = cooperative_groups;

__device__ float tileReduceSum(float val, cg::thread_block_tile<32> tile) {
    for (int offset = tile.size() / 2; offset > 0; offset /= 2)
        val += tile.shfl_down(val, offset);
    return val;  // valid in thread_rank() == 0
}

__global__ void kernel(float* data) {
    auto block = cg::this_thread_block();
    auto tile  = cg::tiled_partition<32>(block);

    float val = data[block.thread_rank()];
    float sum = tileReduceSum(val, tile);

    if (tile.thread_rank() == 0)
        data[tile.meta_group_rank()] = sum;
}

Grid-Level Synchronisation

For the first time, all blocks in a grid can synchronise without launching a new kernel:

grid_sync.cu
#include <cooperative_groups.h>
namespace cg = cooperative_groups;

__global__ void multiPhaseKernel(float* data, int N) {
    auto grid = cg::this_grid();

    // Phase 1: all blocks process data
    int idx = grid.thread_rank();
    if (idx < N) data[idx] *= 2.0f;

    grid.sync();  // All blocks must reach here before continuing

    // Phase 2: all blocks can safely read Phase 1 results
    if (idx < N && idx > 0)
        data[idx] += data[idx - 1];
}

// Must launch with cudaLaunchCooperativeKernel:
// void* args[] = { &d_data, &N };
// cudaLaunchCooperativeKernel((void*)multiPhaseKernel,
//     numBlocks, blockSize, args);
Limitation

Grid-level sync requires that all blocks fit on the GPU simultaneously (the GPU cannot context-switch blocks). Use cudaOccupancyMaxActiveBlocksPerMultiprocessor to determine the maximum safe grid size. If you exceed it, the kernel will deadlock.

08

Summary & Next Steps

What We Covered

Key Takeaways

Minimise Sync Scope

Warp shuffle > shared memory barrier > atomics > grid sync. Use the narrowest scope that guarantees correctness.

Reduce Locally First

Always combine values at the warp and block level before touching global memory. This is the single most important optimisation pattern.

Next Tutorial

Up Next — Tutorial 07

Profiling & Performance Analysis — use Nsight Systems, Nsight Compute, and nvprof to identify bottlenecks, measure occupancy, and systematically optimise your CUDA kernels.