How Memory Coalescing Optimizes GLSL Buffer Reads

Memory coalescing is a hardware-level performance optimization that merges multiple simultaneous global memory access requests from adjacent GPU threads into a single, unified memory transaction. In GLSL compute shaders, when threads within a single execution unit (such as an NVIDIA warp or an AMD wavefront) access consecutive locations in a Shader Storage Buffer Object (SSBO), the GPU hardware reads the data as a single memory burst. This article explains how memory coalescing functions, why it drastically improves read throughput in GLSL, and how to structure buffer access patterns to ensure full memory coalescing.

GPU Memory Architecture and Transactions

Modern GPUs handle global memory access through memory controllers connected to high-bandwidth DRAM via wide buses and L2/L1 cache hierarchies. Physical reads from global memory are not fetched byte-by-byte; instead, they are transferred in fixed-size segments, typically 32, 64, or 128 bytes (a cache line).

Compute shaders execute threads in lockstep groups of 32 or 64 threads. When all threads in an execution group execute an instruction that reads from global memory, the memory controller evaluates the memory addresses requested by all threads concurrently before issuing requests to the memory bus.

The Mechanism of Memory Coalescing

Memory coalescing occurs when contiguous threads request contiguous, aligned memory addresses:

  1. Address Evaluation: If Thread 0 requests address 0x00, Thread 1 requests 0x04, Thread 2 requests 0x08, and so on, the memory controller identifies that all requested 4-byte values reside within a single 128-byte cache line.
  2. Transaction Merging: Instead of issuing 32 separate read commands across the bus, the memory controller issues a single 128-byte memory fetch.
  3. Distribution: The hardware receives the single 128-byte payload and routes the appropriate 4-byte slice to each corresponding thread's register space.

If memory access is strided (for instance, Thread \(N\) accesses index \(N \times 16\)), the requested addresses span across multiple disparate cache lines. In that scenario, the memory controller must issue multiple independent transactions to fetch the same total amount of useful data, creating a massive bottleneck, saturating memory bus bandwidth, and dramatically increasing latency.

Coalesced Access in GLSL Compute Shaders

In GLSL, coalesced memory access is achieved by mapping thread IDs directly to contiguous buffer indices.

#version 450
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

layout(std430, binding = 0) readonly buffer InputBuffer {
    float data[];
};

layout(std430, binding = 1) writeonly buffer OutputBuffer {
    float result[];
};

void main() {
    uint id = gl_GlobalInvocationID.x;

    // Coalesced Read:
    // Contiguous threads read contiguous floats. 
    // Threads 0-31 read 128 bytes in a single memory transaction.
    float value = data[id];

    result[id] = value * 2.0;
}

In the example above, gl_GlobalInvocationID.x increases by 1 for each neighboring thread. Because each float is 4 bytes, 32 consecutive threads access exactly 128 contiguous bytes, resulting in optimal coalescing.

Common Pitfalls: Strided Access and AoS

Memory coalescing is frequently broken by two main structural patterns:

  • Strided Indexing: Multiplying gl_GlobalInvocationID.x by an integer stride forces adjacent threads to access non-contiguous memory segments, generating multiple cache misses.
  • Array-of-Structures (AoS): Defining a buffer of complex structures in GLSL (e.g., struct Particle { vec4 pos; vec4 vel; float mass; };) causes threads reading a single field like mass to skip the intervening fields. This turns what could be a contiguous read into a strided read.

To maximize memory coalescing in GLSL, use a Structure-of-Arrays (SoA) layout with the std430 layout qualifier. Storing each component in its own independent array ensures that adjacent threads always access perfectly contiguous memory offsets.