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. vec2aligns to 8 bytes.vec4aligns 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.xdirectly 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 usingbarrier(), and subsequently perform non-coalesced or strided access within shared memory, which features significantly lower access penalties and higher banking throughput.