Dynamic Sampler Indexing Costs in GLSL
Dynamically indexing arrays of samplers in GLSL fragment shaders introduces significant performance penalties that can severely degrade rendering efficiency. When a shader indexes a sampler array using a non-uniform variable—such as one derived from per-vertex attributes, mathematical operations, or fragment coordinates—the GPU cannot optimize texture fetching in the same way it does with uniform access. The primary performance costs stem from warp divergence, hardware-enforced instruction serialization, compiler-generated switch emulation, increased register pressure, and severe texture cache thrashing.
Warp Divergence and Execution Serialization
Modern GPUs execute fragment shaders in parallel lockstep groups known as warps (NVIDIA) or wavefronts (AMD), typically consisting of 32 or 64 threads. These threads share a single instruction pointer. When samplers are indexed statically or with dynamically uniform values (where every thread in the warp uses the exact same index), the warp fetches texture data collectively in a single, coordinated memory transaction.
When the index varies across threads within the same warp (non-uniform dynamic indexing), the hardware cannot execute a single shared texture fetch instruction. Instead, it must serialize the memory fetches. The GPU effectively masks inactive threads and iterates through every distinct texture requested across the active warp. If four neighboring fragments in a quad request four different textures, the texture fetch instructions must execute four times instead of once, reducing warp throughput proportionally.
Emulated Control Flow and Code Bloat
Not all GPU hardware or graphics APIs support native non-uniform
resource indexing at the microcode level. Historically, the OpenGL
Shading Language standard required sampler array indices to be
dynamically uniform expressions. While modern GLSL and extensions like
GL_EXT_nonuniform_qualifier permit non-uniform access,
underlying drivers frequently handle this by unrolling the dynamic index
into a hardware switch statement:
// What the developer writes:
vec4 color = texture(mySamplers[index], uv);
// What the compiler often generates behind the scenes:
vec4 color;
if (index == 0) color = texture(mySamplers[0], uv);
else if (index == 1) color = texture(mySamplers[1], uv);
else if (index == 2) color = texture(mySamplers[2], uv);
// ...repeated for the entire array sizeThis dynamic branch unrolling bloats the shader's instruction cache footprint, disrupts hardware instruction prefetching, and incurs heavy branching overhead.
Increased Register Pressure and Occupancy Loss
The structural overhead of evaluating dynamic branches and managing variable texture descriptors directly increases general-purpose register (VGPR) allocation per thread. As a shader requires more registers to track divergent state and intermediate fetch results, the GPU has fewer available registers for other concurrent workloads.
This drop in available registers reduces GPU occupancy—the ratio of active warps to the theoretical maximum supported by the compute unit. Lower occupancy diminishes the GPU’s ability to hide memory latency, amplifying stalls caused by high-latency global memory fetches.
Texture Cache Inefficiency
Texture sampling units rely heavily on spatial and temporal locality to maximize L1 and L2 texture cache hit rates. When adjacent fragments sample from the same texture at nearby UV coordinates, data is fetched once from memory and shared via cache lines.
Dynamic sampler indexing disrupts this paradigm. When adjacent fragments within a pixel quad pull from entirely separate texture allocations, the cache cannot leverage spatial locality. Each thread requests cache lines from completely disconnected address spaces, saturating memory buses and causing frequent cache line evictions (cache thrashing), which forces the GPU to repeatedly stall on high-latency VRAM fetches.
High-Performance Alternatives
To avoid the performance degradation of dynamic sampler indexing, modern rendering pipelines leverage alternative structures:
- 2D Texture Arrays (
sampler2DArray): Texture arrays share dimensions, format, and mip-map levels across layers. They use a standard 3D coordinate (vec3(u, v, layer)), allowing native, hardware-level layer switching with zero warp divergence or branch unrolling. - Bindless Textures
(
GL_ARB_bindless_texture): Bindless rendering converts textures into 64-bit GPU memory handles stored in standard uniform buffers or SSBOs, bypassing traditional descriptor binding limits with native pointer-like access. - Texture Atlases: Packing multiple textures into a single large texture avoids array indexing entirely, trading index calculations for UV coordinate offset math.