---
title: "HLSL wave intrinsics on Vulkan, Metal and WebGPU"
url: https://recursiveai.org/oneshader/articles/hlsl-wave-intrinsics-spirv-metal-wgsl/
description: "Every SM 6.0 wave and quad intrinsic with its SPIR-V instruction, Metal SIMD-group function, WGSL subgroup builtin and the device feature it needs."
date: 2026-10-26
author: OneShader team
---

# HLSL wave intrinsics on Vulkan, Metal and WebGPU

Every SM 6.0 wave and quad intrinsic with its SPIR-V instruction, Metal SIMD-group function, WGSL subgroup builtin and the device feature it needs.

October 26, 2026 · OneShader team

HLSL wave intrinsics such as `WaveActiveSum`, `WavePrefixCountBits` and `WaveReadLaneFirst` let the threads of one wave (a warp on NVIDIA, a wavefront on AMD, a subgroup in Vulkan and WebGPU, a SIMD-group in Metal) share values without memory or barriers. Each one has a direct equivalent on every modern API: a `GroupNonUniform` instruction in SPIR-V, a `simd_` or `quad_` function in Metal, and a `subgroup` builtin in WGSL. The table below lists all of them, with the device feature each one needs.

The table comes from compiling one small shader per intrinsic with [OneShader 1.2.0](https://www.nuget.org/packages/RecursiveAI.OneShader/1.2.0) to all three targets, and with DXC 1.9.0.5512 (Vulkan SDK 1.4.363.0) to SPIR-V. For every row, the two compilers emit the same SPIR-V instruction and group operation, and every module passes `spirv-val` for Vulkan 1.2.

## Wave terms in one paragraph

Microsoft’s [Shader Model 6.0 page](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/hlsl-shader-model-6-0-features-for-direct3d-12) (updated 28 August 2026) defines the vocabulary. A *wave* is “a set of lanes (threads) executed simultaneously”. An *active lane* is one currently executing, which “in pixel shaders […] may include any helper pixel lanes”. A *quad* is “a set of 4 adjacent lanes corresponding to pixels arranged in a 2x2 square”. Reductions such as `WaveActiveSum` combine the active lanes and give every one of them the result. Prefix functions such as `WavePrefixSum` combine only the lanes with smaller indices, so the first lane gets the identity. The wave size is the hardware’s choice. Read it with `WaveGetLaneCount()` instead of assuming 32 or 64. WebGPU reports the range as `subgroupMinSize` and `subgroupMaxSize` on the adapter ([WebGPU](https://www.w3.org/TR/webgpu/)).

## HLSL wave intrinsics mapping table

SPIR-V instructions that take a type prefix use the one for the operand: `F` for float, `I` for integer add and multiply, `S` or `U` for min and max. The Vulkan feature is the `VkSubgroupFeatureFlagBits` bit that `subgroupSupportedOperations` has to include.

### SPIR-V and the Vulkan feature

| HLSL | SPIR-V (DXC and OneShader) | Vulkan feature |
|---|---|---|
| `WaveGetLaneCount` | `SubgroupSize` built-in | Basic |
| `WaveGetLaneIndex` | `SubgroupLocalInvocationId` built-in | Basic |
| `WaveIsFirstLane` | `OpGroupNonUniformElect` | Basic |
| `WaveActiveAnyTrue` | `OpGroupNonUniformAny` | Vote |
| `WaveActiveAllTrue` | `OpGroupNonUniformAll` | Vote |
| `WaveActiveAllEqual` | `OpGroupNonUniformAllEqual` | Vote |
| `WaveActiveBallot` | `OpGroupNonUniformBallot` | Ballot |
| `WaveActiveCountBits` | `OpGroupNonUniformBallotBitCount` `Reduce` of a ballot | Ballot |
| `WaveActiveSum` | `OpGroupNonUniform*Add` `Reduce` | Arithmetic |
| `WaveActiveProduct` | `OpGroupNonUniform*Mul` `Reduce` | Arithmetic |
| `WaveActiveMin` | `OpGroupNonUniform*Min` `Reduce` | Arithmetic |
| `WaveActiveMax` | `OpGroupNonUniform*Max` `Reduce` | Arithmetic |
| `WaveActiveBitAnd` | `OpGroupNonUniformBitwiseAnd` `Reduce` | Arithmetic |
| `WaveActiveBitOr` | `OpGroupNonUniformBitwiseOr` `Reduce` | Arithmetic |
| `WaveActiveBitXor` | `OpGroupNonUniformBitwiseXor` `Reduce` | Arithmetic |
| `WavePrefixSum` | `OpGroupNonUniform*Add` `ExclusiveScan` | Arithmetic |
| `WavePrefixProduct` | `OpGroupNonUniform*Mul` `ExclusiveScan` | Arithmetic |
| `WavePrefixCountBits` | `OpGroupNonUniformBallotBitCount` `ExclusiveScan` of a ballot | Ballot |
| `WaveReadLaneAt` | `OpGroupNonUniformShuffle` | Shuffle |
| `WaveReadLaneFirst` | `OpGroupNonUniformBroadcastFirst` | Ballot |
| `QuadReadAcrossX` | `OpGroupNonUniformQuadSwap` (direction 0) | Quad |
| `QuadReadAcrossY` | `OpGroupNonUniformQuadSwap` (direction 1) | Quad |
| `QuadReadAcrossDiagonal` | `OpGroupNonUniformQuadSwap` (direction 2) | Quad |
| `QuadReadLaneAt` | `OpGroupNonUniformQuadBroadcast` | Quad |

### Metal and WGSL

| HLSL | Metal (MSL) | WGSL |
|---|---|---|
| `WaveGetLaneCount` | `[[threads_per_simdgroup]]` | `subgroup_size` built-in |
| `WaveGetLaneIndex` | `[[thread_index_in_simdgroup]]` | `subgroup_invocation_id` built-in |
| `WaveIsFirstLane` | `simd_is_first` | `subgroupElect` |
| `WaveActiveAnyTrue` | `simd_any` | `subgroupAny` |
| `WaveActiveAllTrue` | `simd_all` | `subgroupAll` |
| `WaveActiveAllEqual` | `simd_all` of a comparison with `simd_broadcast_first` | `subgroupAll` of a comparison with `subgroupBroadcastFirst` |
| `WaveActiveBallot` | `simd_ballot` | `subgroupBallot` |
| `WaveActiveCountBits` | `simd_sum` of `uint(condition)` | `subgroupAdd` of `u32(condition)` |
| `WaveActiveSum` | `simd_sum` | `subgroupAdd` |
| `WaveActiveProduct` | `simd_product` | `subgroupMul` |
| `WaveActiveMin` | `simd_min` | `subgroupMin` |
| `WaveActiveMax` | `simd_max` | `subgroupMax` |
| `WaveActiveBitAnd` | `simd_and` | `subgroupAnd` |
| `WaveActiveBitOr` | `simd_or` | `subgroupOr` |
| `WaveActiveBitXor` | `simd_xor` | `subgroupXor` |
| `WavePrefixSum` | `simd_prefix_exclusive_sum` | `subgroupExclusiveAdd` |
| `WavePrefixProduct` | `simd_prefix_exclusive_product` | `subgroupExclusiveMul` |
| `WavePrefixCountBits` | `simd_prefix_exclusive_sum` of `uint(condition)` | `subgroupExclusiveAdd` of `u32(condition)` |
| `WaveReadLaneAt` | `simd_shuffle` | `subgroupShuffle` |
| `WaveReadLaneFirst` | `simd_broadcast_first` | `subgroupBroadcastFirst` |
| `QuadReadAcrossX` | `quad_shuffle_xor` | `quadSwapX` |
| `QuadReadAcrossY` | `quad_shuffle_xor` | `quadSwapY` |
| `QuadReadAcrossDiagonal` | `quad_shuffle_xor` | `quadSwapDiagonal` |
| `QuadReadLaneAt` | `quad_shuffle` | `quadBroadcast` |

DXC’s own chart in its [SPIR-V mapping manual](https://github.com/microsoft/DirectXShaderCompiler/blob/main/docs/SPIR-V.rst) gives the same instructions. Two things in the table aren’t obvious. `WaveReadLaneFirst` needs the Ballot feature, not Basic, because SPIR-V puts `OpGroupNonUniformBroadcastFirst` under the Ballot capability. And the prefix functions are exclusive everywhere: HLSL has no inclusive scan, so add the lane’s own value if you need one.

## One shader, three targets

A compute shader that sums a buffer with one atomic per wave instead of one per thread, and a pixel shader that reads its quad neighbors to get a slope:

*Shaders/Waves.hlsl · HLSL*

```
StructuredBuffer<uint> Values : register(t0);
RWByteAddressBuffer Total : register(u1);
 
// Sum a buffer with one atomic per wave instead of one per thread.
[shader("compute")]
[numthreads(64, 1, 1)]
void CSSum(uint3 id : SV_DispatchThreadID)
{
    uint waveTotal = WaveActiveSum(Values[id.x]);
    if (WaveIsFirstLane())
        Total.InterlockedAdd(0, waveTotal);
}
 
Texture2D<float> Height : register(t2);
SamplerState Linear : register(s3);
 
// A screen-space slope from the neighboring pixel in the 2x2 quad.
[shader("pixel")]
float4 PSSlope(float4 pos : SV_Position, float2 uv : TEXCOORD0) : SV_Target
{
    float h = Height.Sample(Linear, uv);
    float dx = QuadReadAcrossX(h) - h;
    float dy = QuadReadAcrossY(h) - h;
    return float4(abs(dx), abs(dy), h, 1);
}
```

Compiled from C#, with the subgroup operations each entry point needs read from reflection:

*Program.cs · RecursiveAI.OneShader 1.2.0*

```
using OneShader;
using OneShader.Reflection;
 
CompileResult result = ShaderCompiler.CompileFile("Shaders/Waves.hlsl", new CompileOptions
{
    Targets = [CompileTarget.SpirV, CompileTarget.Msl, CompileTarget.Wgsl],
});
foreach (var d in result.Diagnostics) Console.WriteLine(d);
if (!result.Success) return 1;
 
foreach (EntryPointInfo entry in result.Reflection!.EntryPoints)
{
    // The values are Vulkan's VkSubgroupFeatureFlagBits: compare with subgroupSupportedOperations.
    Console.WriteLine($"{entry.Name} ({entry.Stage}): {entry.SubgroupOperations} = 0x{(uint)entry.SubgroupOperations:x}");
    File.WriteAllBytes($"{entry.Name}.spv", result.Find(entry.Name, CompileTarget.SpirV)!.ToArray());
    File.WriteAllText($"{entry.Name}.metal", result.Find(entry.Name, CompileTarget.Msl)!.AsText());
    File.WriteAllText($"{entry.Name}.wgsl", result.Find(entry.Name, CompileTarget.Wgsl)!.AsText());
}
return 0;
```

*Output*

```
CSSum (Compute): Basic, Arithmetic = 0x5
PSSlope (Pixel): Quad = 0x80
```

The values are Vulkan’s `VkSubgroupFeatureFlagBits`, so a host can test `(supported & needed) == needed` against `VkPhysicalDeviceVulkan11Properties::subgroupSupportedOperations` before it creates the pipeline. In `CSSum`, the wave sum is `OpGroupNonUniformIAdd` with `Reduce` on Vulkan, `simd_sum` on Metal and `subgroupAdd` on WebGPU. The SPIR-V declares only the capabilities it uses, here `GroupNonUniform` and `GroupNonUniformArithmetic`, and both modules pass `spirv-val`.

## What each API needs before you dispatch

### Vulkan

Wave intrinsics need SPIR-V 1.3, so Vulkan 1.1 or newer. With DXC, pass `-fspv-target-env=vulkan1.1` or later, because its default environment is Vulkan 1.0 and it stops with “Vulkan 1.1 is required for Wave Operation” ([details](https://recursiveai.org/oneshader/articles/hlsl-to-spirv-compiler/)). OneShader always targets Vulkan 1.2. Then check `subgroupSupportedOperations`, and `subgroupSupportedStages` for the stage you use, in `VkPhysicalDeviceVulkan11Properties`.

### Direct3D 12

Microsoft says to call `ID3D12Device::CheckFeatureSupport` with `D3D12_FEATURE_DATA_D3D12_OPTIONS1` ([SM 6.0 page](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/hlsl-shader-model-6-0-features-for-direct3d-12)). OneShader doesn’t write DXIL. For Direct3D 12, compile the same source with DXC.

### WebGPU

Wave intrinsics need the optional `"subgroups"` feature, requested when the device is created ([WebGPU](https://www.w3.org/TR/webgpu/)). OneShader’s WGSL starts with `enable subgroups;` and `diagnostic(off, subgroup_uniformity);`, since HLSL doesn’t require wave operations to sit in uniform control flow, and `PipelineLayout.TryToWebGpu` lists `subgroups` in `RequiredFeatures`. WebGPU’s subgroup functions exist only in compute and fragment shaders, so a vertex shader that uses one compiles for SPIR-V and Metal but is error OSH5202 for WGSL. naga 30 can’t check these files yet:

*Terminal · naga 30.0.1*

```
naga CSSum.wgsl
```

*Output*

```
Could not parse WGSL:
error: the `subgroups` enable-extension is not yet supported
  ┌─ CSSum.wgsl:1:8
  │
1 │ enable subgroups;
  │        ^^^^^^^^^ this enable-extension specifies standard functionality which is not yet implemented in Naga
  │
  = note: Let Naga maintainers know that you ran into this at <https://github.com/gfx-rs/wgpu/issues/5555>, so they can prioritize it!
```

The OneShader repository validates its wave shaders in a browser on a real WebGPU adapter instead ([WebGPU execution parity](https://recursiveai.org/oneshader/docs/webgpu-parity/)).

### Metal

SIMD-group functions need Metal 2.1 or later, which OneShader’s MSL 2.4 output already requires ([MSL backend](https://recursiveai.org/oneshader/docs/msl-backend/)). The lane index and count aren’t available to Metal vertex functions.

## What isn’t portable

- **`WaveMatch` and `WaveMultiPrefix*`** (Shader Model 6.5) need partitioned subgroup operations. SPIR-V has them only as a vendor extension (DXC’s manual lists `SPV_NV_shader_subgroup_partitioned`), and Metal and WGSL have none, so OneShader reports error OSH0350 with a suggestion: build the groups with `WaveActiveBallot` and `WaveReadLaneFirst` in a loop.
- **Quad operations in compute shaders.** In Shader Model 6.0 through 6.5 “the mapping of threads to quads is implementation-dependent” in compute shaders, per Microsoft. Use them in pixel shaders.
- **Helper lanes.** In pixel shaders the active lanes can include helper pixels, so a wave reduction may count lanes that write nothing.
- **Wave size.** A reduction is per wave, not per thread group. Combine the per-wave results with one atomic or a `groupshared` array, as `CSSum` does.

## Common questions

### Are HLSL wave intrinsics the same as Vulkan subgroups?

Yes, the same hardware feature under different names. Each HLSL wave function compiles to a SPIR-V `GroupNonUniform` instruction with Subgroup scope, which is what GLSL’s `subgroup*` functions compile to as well (glslang turns `subgroupAdd` into the same `OpGroupNonUniformIAdd` with `Reduce`).

### What does WaveActiveCountBits return?

The number of active lanes for which the condition is true, the same value in every lane. On SPIR-V it is a ballot followed by `OpGroupNonUniformBallotBitCount` with `Reduce`. `countbits`, by contrast, counts the set bits of one integer in one lane.

For a step-by-step example with a stream-compaction shader, see the [wave intrinsics recipe](https://recursiveai.org/oneshader/cookbook/hlsl-wave-intrinsics/). The instruction-level rules per target are in the [SPIR-V](https://recursiveai.org/oneshader/docs/spirv-backend/), [MSL](https://recursiveai.org/oneshader/docs/msl-backend/) and [WGSL](https://recursiveai.org/oneshader/docs/wgsl-backend/) backend pages.
