Module 6:HLSL 与 Direct3D 12 编程模型
把 compute shader 写出来、编译出来、绑定资源、提交到 GPU,并正确处理资源状态与同步。
学习目标
- 理解 HLSL compute shader 的入口、thread id 与 dispatch grid。
- 区分
StructuredBuffer、RWStructuredBuffer、ByteAddressBuffer。 - 能解释 groupshared memory 与
GroupMemoryBarrierWithGroupSync的必要性。 - 理解 FXC/SM5 与 DXC/SM6 的差异,以及
f16tof32与 native f16 的关系。 - 理解 D3D12 的核心对象:device、queue、allocator、command list、fence、descriptor heap。
- 能区分 transition barrier 与 UAV barrier,并知道 root signature 解决了什么问题。
6.1HLSL compute shader 的最小形态
Compute shader 是由 GPU 执行的程序单元。它的入口函数虽然通常命名为 main,但参数不来自命令行;数据来自绑定到 SRV/UAV/CBV 槽位的 GPU 资源,执行规模由 CPU 侧记录的 dispatch 命令决定。
// shaders/affine.hlsl:out[i] = in[i] * 2 + 1
StructuredBuffer<float> In : register(t0);
RWStructuredBuffer<float> Out : register(u0);
cbuffer Params : register(b0)
{
uint count;
};
[numthreads(64, 1, 1)]
void main(uint3 tid : SV_DispatchThreadID)
{
uint i = tid.x;
if (i < count)
Out[i] = In[i] * 2.0f + 1.0f;
}
[numthreads(64,1,1)] 表示每个 threadgroup 有 64 个线程。CPU 侧 dispatch 个 group 时,最多启动 个线程;每个线程用 SV_DispatchThreadID 得到全局编号,并用边界检查处理 不能被 64 整除的情况。
6.2资源类型:SRV、UAV 与 raw byte access
HLSL 中常见的 buffer 类型可按访问权限和寻址方式区分:
| 类型 | 读写 | 典型用途 | xinfer 示例 |
|---|---|---|---|
StructuredBuffer<T> | 只读 | 输入 activation、权重、KV cache | A, Q/K/V |
RWStructuredBuffer<T> | 读写 | 输出 activation、临时结果 | Out, Y |
ByteAddressBuffer | 按 byte 地址只读 | packed f16/int4 权重 | linear_f16.hlsl |
RWByteAddressBuffer | 按 byte 地址读写 | 原子、压缩输出、特殊布局 | 后续高级优化 |
在 HLSL binding 语法中,t0 通常表示 SRV(read-only),u0 表示 UAV(read/write),
b0 表示 constant buffer:
StructuredBuffer<float> A : register(t0); // SRV
ByteAddressBuffer W : register(t1); // SRV, raw bytes
RWStructuredBuffer<float> Out : register(u0); // UAV
cbuffer Params : register(b0) { uint M; uint K; uint N; };
linear_f16.hlsl 把权重存为 raw bytes。当前 ComputeKernel 使用 FXC/SM5 编译到 cs_5_0,SM5 没有 SM6 中更完整的 native f16 路径;shader 因此用 ByteAddressBuffer.Load 读取 32-bit packed 数据,再用 f16tof32 解出两个 f16 权重。
6.3groupshared memory 与 barrier
groupshared 是同一 threadgroup 内线程共 享的片上内存(AMD 文档中常称 LDS)。它适合归约、tile 缓存和线程间交换临时结果。由于同组线程不会在每条指令后自动同步,写入后被其他线程读取前必须显式使用 barrier。典型模式如下:
- 每个线程计算一个 partial result;
- 写入
groupshared数组; - 调用
GroupMemoryBarrierWithGroupSync(); - 再做树形归约。
#define TG 64
groupshared float partials[TG];
[numthreads(TG, 1, 1)]
void main(uint3 lid : SV_GroupThreadID) {
uint t = lid.x;
partials[t] = local_sum;
GroupMemoryBarrierWithGroupSync();
for (uint s = TG / 2; s > 0; s >>= 1) {
if (t < s) partials[t] += partials[t + s];
GroupMemoryBarrierWithGroupSync();
}
}
6.4编译 Shader:FXC / SM5 与 DXC / SM6
HLSL 源码不能直接提交给 GPU 执行,必须先编译为驱动可接受的 shader 字节码。Windows 生态中常见两条路线:
| 工具 | 目标 | 特点 | 本项目状态 |
|---|---|---|---|
| FXC | Shader Model 5 | 传统、兼容好;支持 f16tof32 | 当前使用 |
| DXC | Shader Model 6 / DXIL | 现代;wave ops、native f16、更多优化 | 未来优化方向 |
项目源码中的 ComputeKernel 当前调用 D3DCompile,目标为 cs_5_0。因此 f16 权重路径使用 SM5 的 f16tof32:
uint packed = W.Load(byte_offset);
float w0 = f16tof32(packed & 0xFFFF);
float w1 = f16tof32(packed >> 16);
这使当前代码可在 FXC/SM5 路径下运行。现代 D3D12 shader 通常由 DXC 编译为 DXIL;若后续要使用 wave intrinsics、native half arithmetic 或更完整的 SM6 优化,迁移到 DXC/SM6 更合适。
6.5D3D12 核心对象:从 device 到 fence
D3D12 是显式 API。程序需要创建资源和管线对象,向 command list 记录命令,把 command list 提交到 queue,并用 fence 等待 GPU 完成:
6.6资源状态与 barrier:Transition vs UAV
D3D12 要求程序明确资源状态:copy source、copy destination、shader resource 或 unordered access 等。状态与实际访问不一致时,调试层会报告错误;在更严重的情况下,驱动可能移除设备。
| Barrier | 作用 | xinfer 中的例子 |
|---|---|---|
| Transition barrier | 把资源从一种状态切到另一种状态 | COPY_DEST → NON_PIXEL_SHADER_RESOURCE |
| UAV barrier | 保证前面的 UAV 写入对后续读写可见 | 一个 layer 内多个 dependent dispatch 之间 |
例如,前一个 dispatch 写入 activation 或 KV cache,后一个 dispatch 立即读取同一资源时,必须保证写入已完成且资源状态正确。源码中的 DmlBuffer::ensure_state 负责按需记录 transition barrier;Executor::uav_barrier() 负责同一 command list 内 UAV 写后的可见性和顺序。
6.7Root signature:shader 如何看到资源?
Shader 中的 register(t0)、register(u0) 只是逻辑槽位;运行时还必须说明这些槽位如何映射到实际 GPU 资源。Root signature 描述 compute pipeline 可访问的资源布局。xinfer 的 ComputeKernel 为 custom kernel 构造的布局主要包括:
- root constants:少量参数,如 、count、offset;
- root descriptors:直接把 buffer GPU virtual address 绑定给 shader;
- descriptor tables:更适合大量资源,本教程前半部分暂不重点使用。
xinfer 的 custom HLSL kernel 通常只需要少量常量和固定数量的 buffer。ComputeKernel 源码中 root 参数顺序为:可选的 32-bit root constants,随后是 t0.. SRV root descriptors,再后是 u0.. UAV root descriptors。这样的布局不需要为每次 kernel 绑定维护 descriptor heap,适合教学和早期实现。
小结
本章把 HLSL compute 与 D3D12 执行对象连接起来。HLSL shader 通过 SV_DispatchThreadID、SV_GroupID 与 SV_GroupThreadID 定位数据,通过 SRV/UAV/constant buffer 访问资源;groupshared 与 GroupMemoryBarrierWithGroupSync() 用于同一 threadgroup 内的协作归约。xinfer 当前的 ComputeKernel 使用 FXC/SM5 编译 cs_5_0,而现代 D3D12 shader 通常由 DXC 生成 DXIL。D3D12 侧的核心工作是创建 device、buffer、root signature 与 PSO,记录 dispatch,维护 transition/UAV barrier,并通过 fence 等待完成。
Lab 6写并 dispatch 一个 affine kernel
Lab 6 对应仓库中的 shaders/affine.hlsl。目标是完成一个最小 GPU compute path:
- 创建输入 buffer 与输出 buffer;
- 把输入数据上传到 GPU;
- 编译 HLSL compute shader;
- 创建 root signature 和 compute pipeline state;
- 绑定
count、SRV、UAV; - dispatch;
- 把结果读回 CPU,验证 。
这条路径包含 custom HLSL kernel 的必要步骤,也是后续 RMSNorm、RoPE、attention、linear 和 argmax kernel 的最小原型。
思考与练习
基础SV_DispatchThreadID 和 SV_GroupThreadID 有什么区别?
SV_DispatchThreadID 是线程在整个 dispatch 网格中的全局索引(= group ID × group 大小 + group 内索引),常用于定位全局数组元素。SV_GroupThreadID 是线程在所属 threadgroup 内的局部索引,常用于索引 groupshared 数组或决定组内归约角色。
基础为什么写入 RWStructuredBuffer 后,后续读它之前通常需要 barrier?
因为不同 dispatch(或不同 threadgroup)之间没有自动的写后读顺序保证。前一个 dispatch 的 UAV 写可能尚未对后一个 dispatch 的读可见。插入 UAV barrier 确保所有写完成并对后续读可见,避免读到旧数据,保证数据依赖的正确顺序。
进阶解释 ByteAddressBuffer 为什么适合 packed f16 权重。
ByteAddressBuffer 以字节偏移寻址,配合 Load/Load2/Load4 读取原始 uint。packed f16 是两个 16-bit 权重打包进一个 32-bit uint,用 ByteAddressBuffer 可一次读一个 uint 再用 f16tof32 解出两个权重,无需固定 struct stride,也方便做对齐的宽加载(float4)以提升带宽。这正是 xinfer f16 权重路径的做法。
进阶如果两个 dispatch 没有数据依赖,是否需要 UAV barrier?为什么?
不需要。UAV barrier 的作用是强制写后读/写后写的顺序与可见性。若两个 dispatch 操作互不相关的数据(无依赖),插入 barrier 只会阻止它们重叠执行,白白损失并行度。只有当后者读/写前者写过的同一块内存时才需要 barrier。
挑战把 affine kernel 改成并行 reduction:计算输入数组的 sum,并说明需要哪些 groupshared 与 barrier。
方案:每个 threadgroup 处理一段输入。① 每个线程把自己负责的元素(可跨步累加多个)写入 groupshared 数组 shared[tid];② GroupMemoryBarrierWithGroupSync();③ 树形归约:for (s = groupSize/2; s>0; s>>=1) { if (tid < s) shared[tid]+=shared[tid+s]; GroupMemoryBarrierWithGroupSync(); };④ tid==0 把 shared[0] 用 InterlockedAdd 累加到全局输出,或写各组部分和再做二级归约。
需要:一个 groupshared float shared[GROUP_SIZE];写入 LDS 后、每轮读取相邻槽位前都要执行 group sync barrier,避免读到尚未写入或上一轮尚未更新的值。若迁移到 DXC/SM6,可进一步考虑 wave-level reduction 来减少部分 barrier。