Non-Coalesced SSBO Memory Access in GLSL

Non-coalesced memory access in OpenGL Shading Language (GLSL) occurs when threads within an execution group simultaneously read from or write to disparate, non-contiguous addresses in a Shader Storage Buffer Object (SSBO). Because modern GPUs execute work in lockstep groups called warps or wavefronts, memory hardware is optimized to merge adjacent memory operations into a single wide transaction. When indexing patterns scatter memory requests across different cache lines, the memory controller must serialize these requests into multiple distinct transactions. This latency multiplication degrades throughput, stalls the pipeline, and saturates the GPU memory bandwidth.

How GPU Memory Coalescing Operates

Modern GPUs execute compute shader invocations in fixed-size batches—typically 32 threads (a warp on NVIDIA) or 32 to 64 threads (a wavefront on AMD). When every thread within a warp issues a read or write instruction to an SSBO, the memory controller evaluates the targeted memory addresses:

  • Coalesced Access: If thread \(0\) reads byte \(0\), thread \(1\) reads byte \(4\), and so on, the memory requests fall into a single, contiguous \(32\)-, \(64\)-, or \(128\)-byte segment. The controller combines these reads into a single hardware transaction.
  • Non-Coalesced Access: If the addresses accessed across the warp fall into multiple disjoint memory lines, the controller cannot merge them. The warp stalls until each required cache line is fetched sequentially.

Primary Causes of Non-Coalesced SSBO Indexing

1. Array-of-Structures (AoS) Layouts

The most common cause of non-coalesced access is using an Array of Structures instead of a Structure of Arrays (SoA). Consider this GLSL layout:

struct Particle {
    vec4 position; // 16 bytes
    vec4 velocity; // 16 bytes
    vec4 color;    // 16 bytes
    float mass;    // 4 bytes (+ 12 padding under std430)
};

layout(std430, binding = 0) buffer ParticleBuffer {
    Particle particles[];
};

If thread \(i\) reads particles[i].position, thread \(0\) reads byte \(0\), while thread \(1\) reads byte \(64\) (the size of one Particle). Because the requested elements are spaced \(64\) bytes apart, a single \(128\)-byte cache line can only satisfy two threads. A warp of 32 threads requires 16 separate memory transactions instead of just two, wasting up to \(87.5\%\) of fetched data per transaction.

2. Strided Indexing and Dimension Inversion

Non-coalesced access frequently stems from flattened multi-dimensional arrays where thread IDs traverse memory down columns rather than along rows:

// Inefficient: Strided access down columns
uint index = gl_GlobalInvocationID.x * height + gl_GlobalInvocationID.y;
float value = data[index];

Because adjacent invocations within the same warp vary along the \(X\)-axis (gl_GlobalInvocationID.x), adjacent threads request addresses separated by height * sizeof(float). Unless height is \(1\), this introduces large strides that scatter addresses across different cache lines.

3. Indirect and Data-Dependent Indexing

Gather and scatter algorithms rely on dynamic lookup tables:

uint targetIndex = indices[gl_GlobalInvocationID.x];
float result = values[targetIndex];

If targetIndex values are unsorted or randomized, adjacent threads access unpredictable, non-contiguous regions of the SSBO. This forces the memory subsystem to issue up to 32 separate read requests for a single instruction, causing extreme memory pipeline serialization.

4. Misalignment with std430 Rules

The std430 layout standard enforces alignment based on the base type:

  • Scalars (float, int) align to 4 bytes.
  • vec2 aligns to 8 bytes.
  • vec4 aligns to 16 bytes.

If arrays or sub-struct members are misaligned relative to the hardware transaction boundary (commonly 32, 64, or 128 bytes), an access that should conceptually fit within a single transaction may straddle a boundary, forcing two transactions per fetch.

Remediation Strategies

  • Transition to Structure-of-Arrays (SoA): Separate large structs into individual arrays (e.g., vec4 positions[], vec4 velocities[]). Adjacent threads will read contiguous elements, restoring optimal memory coalescing.
  • Map Invocations to Contiguous Memory: Ensure that gl_GlobalInvocationID.x directly maps to the fastest-moving index in a linearized array.
  • Stage Data in Shared Memory (shared): For indirect or transposed access patterns, load contiguous SSBO chunks into workgroup-local shared memory first, synchronize using barrier(), and subsequently perform non-coalesced or strided access within shared memory, which features significantly lower access penalties and higher banking throughput.