Chapter 09: Subgroups¶
Overview¶
Subgroups (also called waves or warps) are groups of invocations that execute in lockstep. This chapter covers:
- Understanding the subgroup execution model
- Subgroup operations (vote, ballot, arithmetic)
- Efficient reductions and scans
What you'll learn:
- Hardware-level parallelism
- Avoiding shared memory for communication
- Writing efficient reduction kernels
What is a Subgroup?¶
Within a workgroup, invocations are grouped into subgroups that execute together:
Workgroup (256 invocations)
├── Subgroup 0 (32 invocations) ─── Execute in lockstep
├── Subgroup 1 (32 invocations) ─── Execute in lockstep
├── Subgroup 2 (32 invocations) ─── Execute in lockstep
├── ...
└── Subgroup 7 (32 invocations) ─── Execute in lockstep
Subgroup size varies by GPU:
| Vendor | Subgroup Size |
|---|---|
| NVIDIA | 32 (warp) |
| AMD | 32 or 64 (wave) |
| Intel | 8, 16, or 32 (SIMD width) |
| Apple | 32 |
Querying Subgroup Properties¶
VkPhysicalDeviceSubgroupProperties subgroup_props = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES
};
VkPhysicalDeviceProperties2 props2 = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
.pNext = &subgroup_props
};
vkGetPhysicalDeviceProperties2(physical_device, &props2);
printf("Subgroup size: %u\n", subgroup_props.subgroupSize);
printf("Supported stages: %x\n", subgroup_props.supportedStages);
printf("Supported operations: %x\n", subgroup_props.supportedOperations);
Required Extensions¶
#version 450
#extension GL_KHR_shader_subgroup_basic : require
#extension GL_KHR_shader_subgroup_arithmetic : require
#extension GL_KHR_shader_subgroup_ballot : require
Built-in Variables¶
// Your position within the subgroup (0 to subgroupSize-1)
uint gl_SubgroupInvocationID;
// Size of the subgroup
uint gl_SubgroupSize;
// Which subgroup you're in (within workgroup)
uint gl_SubgroupID;
// Total number of subgroups in workgroup
uint gl_NumSubgroups;
Subgroup Operations¶
Vote Operations¶
All invocations vote on a condition:
bool condition = (value > threshold);
// True if all invocations have condition = true
bool all_true = subgroupAll(condition);
// True if any invocation has condition = true
bool any_true = subgroupAny(condition);
// True if all invocations have same value
bool all_equal = subgroupAllEqual(value);
Broadcast¶
Share a value from one invocation to all:
// Get value from invocation 0
float shared = subgroupBroadcastFirst(my_value);
// Get value from specific invocation
float from_5 = subgroupBroadcast(my_value, 5);
Arithmetic Reductions¶
Reduce across the entire subgroup:
float sum = subgroupAdd(my_value); // Sum all values
float product = subgroupMul(my_value); // Multiply all
float minimum = subgroupMin(my_value); // Find minimum
float maximum = subgroupMax(my_value); // Find maximum
Inclusive/Exclusive Scans¶
Prefix operations:
// Inclusive: includes current invocation
float prefix_sum = subgroupInclusiveAdd(my_value);
// Invocation 0: v0
// Invocation 1: v0 + v1
// Invocation 2: v0 + v1 + v2
// ...
// Exclusive: excludes current invocation
float exclusive_sum = subgroupExclusiveAdd(my_value);
// Invocation 0: 0
// Invocation 1: v0
// Invocation 2: v0 + v1
// ...
Ballot¶
Get a bitmask of which invocations satisfy a condition:
bool condition = (my_value > 0);
uvec4 ballot = subgroupBallot(condition);
// Count how many invocations satisfy condition
uint count = subgroupBallotBitCount(ballot);
Shuffle¶
Exchange values between invocations:
// Get value from invocation at delta offset
float neighbor = subgroupShuffleDown(my_value, 1); // From next invocation
float prev = subgroupShuffleUp(my_value, 1); // From previous
float specific = subgroupShuffle(my_value, 5); // From invocation 5
Example: Parallel Reduction¶
Sum 1 million values efficiently:
#version 460
#extension GL_KHR_shader_subgroup_arithmetic : require
layout(local_size_x = 256) in;
layout(set = 0, binding = 0) readonly buffer Input {
float input_data[];
};
layout(set = 0, binding = 1) buffer Output {
float output_data[];
};
layout(push_constant) uniform PushConstants {
uint element_count;
};
shared float shared_data[256];
void main() {
uint global_idx = gl_GlobalInvocationID.x;
uint workgroup_idx = gl_WorkGroupID.x;
uint stride = gl_NumWorkGroups.x * gl_WorkGroupSize.x;
// Grid-stride load: each invocation accumulates every element it can
// reach, so the dispatch need not cover one invocation per element.
float value = 0.0;
for (uint i = global_idx; i < element_count; i += stride) {
value += input_data[i];
}
// Step 1: Reduce within subgroup (no shared memory needed!)
float subgroup_sum = subgroupAdd(value);
// Step 2: First invocation of each subgroup writes to shared
uint lane = gl_SubgroupInvocationID;
uint num_subgroups = gl_NumSubgroups;
if (lane == 0) {
shared_data[gl_SubgroupID] = subgroup_sum;
}
barrier();
// Step 3: First subgroup folds the per-subgroup sums, looping in case
// there are more subgroups than the subgroup is wide.
if (gl_SubgroupID == 0) {
float workgroup_sum = 0.0;
for (uint base = 0; base < num_subgroups; base += gl_SubgroupSize) {
uint slot = base + lane;
float partial = (slot < num_subgroups) ? shared_data[slot] : 0.0;
workgroup_sum += subgroupAdd(partial);
}
if (lane == 0) {
output_data[workgroup_idx] = workgroup_sum;
}
}
}
Size the dispatch, or stride the load
A reduction pass that launches fewer invocations than there are elements will silently sum only the part it covers. The grid-stride loop makes the kernel correct for any dispatch size — the second pass here reduces 1024 partial sums with a single 256-thread workgroup.
Indexing the second stage
Step 3 must index shared_data by gl_SubgroupInvocationID, not
gl_LocalInvocationID. They coincide only for subgroup 0 on the first
iteration, so the wrong one produces subtly wrong sums.
Running the Example¶
Output from an Apple M3 Pro:
=== Subgroup Properties ===
Subgroup Size: 32
Supported Stages: COMPUTE FRAGMENT
Supported Operations:
- Basic (elect, barrier)
- Vote (all, any, equal)
- Arithmetic (add, mul, min, max)
- Ballot
- Shuffle
- Shuffle Relative
- Clustered
- Quad
This GPU uses 32-wide subgroups (like CUDA warps of 32 threads)
=== Test Data ===
Array size: 262144 elements
Expected sum: 262144
=== Running Subgroup Reduction ===
Pass 1: 262144 elements -> 1024 partial sums
Pass 2: 1024 partial sums -> 1 final result
=== Results ===
Computed sum: 262144
Expected sum: 262144
Match: YES
Time: 3.758 ms
=== Benchmark ===
Iterations: 1000
Time per reduction: 0.2212 ms
Throughput: 4.42 GB/s
Elements/second: 1.19 billion
The input is 262144 copies of 1.0, so the expected sum is just the element
count — an easy value to eyeball when the reduction goes wrong.
Performance Comparison¶
The sample measures only the subgroup path (0.22 ms per reduction of 262144 elements on an M3 Pro); it does not implement shared-memory or CPU baselines to compare against. Published comparisons generally put subgroup reductions ahead of the classic shared-memory tree, but the margin is hardware-specific — benchmark your own target before relying on a number.
Subgroups are faster because: - No shared memory writes/reads - No barriers within subgroup - Hardware-optimized operations
Subgroup Barriers¶
Sometimes you need explicit synchronization:
// Wait for all subgroup invocations
subgroupBarrier();
// Memory-specific barriers
subgroupMemoryBarrier(); // All memory
subgroupMemoryBarrierBuffer(); // Buffer memory
subgroupMemoryBarrierShared(); // Shared memory
subgroupMemoryBarrierImage(); // Image memory
Elect Operation¶
Choose one invocation to do special work:
Exercises¶
-
Parallel Max: Implement finding the maximum value using subgroups.
-
Prefix Sum: Implement an exclusive prefix sum (scan) for a large array.
-
Histogram: Use subgroup ballot to count values in ranges.
Common Errors¶
Extension Not Enabled¶
// Error: subgroupAdd not found
#version 450
// Missing: #extension GL_KHR_shader_subgroup_arithmetic : require
Assuming Subgroup Size¶
// Wrong: Assumes 32
if (gl_SubgroupInvocationID < 32) { ... }
// Right: Use actual size
if (gl_SubgroupInvocationID < gl_SubgroupSize) { ... }
Cross-Subgroup Communication¶
Subgroup operations only work within a subgroup:
// This won't share data between subgroups!
float wrong = subgroupBroadcast(my_value, 40); // Invalid if size is 32
Feature Support¶
Check what your GPU supports:
VkSubgroupFeatureFlags ops = subgroup_props.supportedOperations;
if (ops & VK_SUBGROUP_FEATURE_BASIC_BIT) printf("Basic\n");
if (ops & VK_SUBGROUP_FEATURE_VOTE_BIT) printf("Vote\n");
if (ops & VK_SUBGROUP_FEATURE_ARITHMETIC_BIT) printf("Arithmetic\n");
if (ops & VK_SUBGROUP_FEATURE_BALLOT_BIT) printf("Ballot\n");
if (ops & VK_SUBGROUP_FEATURE_SHUFFLE_BIT) printf("Shuffle\n");
What's Next?¶
We've covered the major compute concepts. In Chapter 10, we'll learn how to debug and profile Vulkan applications effectively.