Memory Consistency: Slang Barriers and Pipeline Stalls
In the previous sections, we’ve explored the "what" and "where" of synchronization. Now, we’ll focus on the "how"—specifically, how to balance safety with performance to keep your GPU’s pipeline full.
The All-In-One Barrier: GroupMemoryBarrierWithGroupSync
Most developers start with Slang’s GroupMemoryBarrierWithGroupSync(). This is a high-level function that combines two critical operations:
-
Execution Sync: It forces every thread in the current workgroup to wait at this line. No thread can proceed until its neighbors have also reached the barrier.
-
Memory Barrier: It ensures that all memory writes performed by the workgroup (to both shared and global memory) are made available and visible.
This function is essentially the "Safe Mode" of synchronization. Use it when you need to be 100% sure that all data is ready for the next step of an algorithm.
GLSL: The Explicit Barriers
In GLSL, you don’t have a single "magic" function that does everything. Instead, you have to be explicit about what you are synchronizing. This is where many bugs creep in, but it’s also where you can find performance wins.
A common idiom calls a memoryBarrier*() function immediately before barrier(). Under the Vulkan Memory Model this ordering doesn’t synchronize what it looks like it should an acquire operation must happen after the control barrier, not before it. barrier() on its own is already exactly the right tool here — for compute shaders it’s defined as a control barrier that also performs a full acquire-release memory barrier over shared memory, so a preceding memoryBarrierShared() call is redundant.
|
// barrier() alone already does everything GroupMemoryBarrierWithGroupSync() does for shared memory
barrier();
If you are working with Global Memory (SSBOs), barrier() alone only covers shared memory. To also synchronize a workgroup’s buffer writes, use controlBarrier() from the GL_KHR_memory_scope_semantics extension, which performs the release, the barrier, and the acquire together as a single, correctly-ordered operation:
#extension GL_KHR_memory_scope_semantics : enable
// Ensuring global memory is ready for other threads in the workgroup
controlBarrier(gl_ScopeWorkgroup, gl_ScopeWorkgroup,
gl_StorageSemanticsBuffer, gl_SemanticsAcquireRelease);
Vulkan 1.4 further refines this with Memory Semantics, allowing you to specify exactly which "domain" (Uniform, Buffer, Image, or Shared) you are synchronizing, avoiding the "sync everything" penalty of a general barrier.
The Cost of Syncing
Synchronization is not free. Every time you call a barrier, you are essentially telling the GPU: "Stop what you are doing and wait."
-
Workgroup Barriers are expensive because they involve many threads (e.g., 256 or 1024). The hardware must track all these threads and ensure they have all reached the same point.
-
Pipeline Stalls: If some threads finish their work quickly but others are delayed by slow memory fetches, the fast threads sit idle, wasting potential TFLOPS (trillions of floating-point operations per second).
Reducing the Impact
To minimize the performance penalty of synchronization, consider these strategies:
-
Batch Your Work: Try to do as much work as possible between barriers. One large kernel with two barriers is often faster than two small kernels with one barrier each.
-
Double-Buffering Shared Memory: Instead of reading and writing to the same shared memory array (which requires a barrier), use two arrays. Write to
Awhile reading fromB, then swap. -
Atomic Operations: For simple tasks like incrementing a global counter, use
InterlockedAdd(which Slang inherits from HLSL). Atomic operations handle synchronization at the hardware level, which is often much faster than a manual barrier because they are "uninterruptible" by other threads.
Fine-Grained Control in Vulkan 1.4
Modern Vulkan (1.3+) and Synchronization 2 allow for even more granular control. In your shader, you can use more specific barrier types if your language supports them:
-
GroupMemoryBarrier(): Only synchronizes memory, without forcing an execution sync. -
Subgroup Barriers: Synchronizing within a bundle of 32/64 threads (a subgroup) is significantly faster than synchronizing an entire workgroup because it doesn’t need to involve the GPU’s global scheduler.
What’s Next?
We’ve covered the fundamentals of how GPUs execute code and how they manage memory. But there is a hidden layer of performance that many developers miss.
In the next chapter, we’ll dive into Subgroup Operations. By learning how to communicate between threads within a bundle, we can bypass shared memory altogether and perform high-speed data exchange directly through registers.