Building an LLM Inference Engine from ScratchPart II / Module 6
Part II · GPU Compute

Module 6:HLSL 与 Direct3D 12 编程模型

把 compute shader 写出来、编译出来、绑定资源、提交到 GPU,并正确处理资源状态与同步。

学习目标

  • 理解 HLSL compute shader 的入口、thread id 与 dispatch grid。
  • 区分 StructuredBufferRWStructuredBufferByteAddressBuffer
  • 能解释 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 GG 个 group 时,最多启动 64G64G 个线程;每个线程用 SV_DispatchThreadID 得到全局编号,并用边界检查处理 NN 不能被 64 整除的情况。

global_thread_id=group_idnumthreads+group_thread_id\text{global\_thread\_id} = \text{group\_id}\cdot\text{numthreads}+\text{group\_thread\_id}
thread id 的三种视角 Group 0 0 1 2 3 Group 1 64 65 66 67 Group 2 128 129 130 131 SV_GroupID 是组编号;SV_GroupThreadID 是组内编号;SV_DispatchThreadID 是全局编号。
图 6-1:HLSL compute shader 中常用的线程编号。

6.2资源类型:SRV、UAV 与 raw byte access

HLSL 中常见的 buffer 类型可按访问权限和寻址方式区分:

类型读写典型用途xinfer 示例
StructuredBuffer<T>只读输入 activation、权重、KV cacheA, 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; };
为什么 f16 用 ByteAddressBuffer?

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。典型模式如下:

  1. 每个线程计算一个 partial result;
  2. 写入 groupshared 数组;
  3. 调用 GroupMemoryBarrierWithGroupSync()
  4. 再做树形归约。
#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();
    }
}
threadgroup 归约:每个线程写 partial,然后同步,再合并 t0 t1 t2 t3 groupshared partials[]所有线程写入后必须 barrier 没有 barrier 时,某些线程可能还没写完,其他线程就开始读,结果不确定。 xinfer 的 RMSNorm 与 GEMV kernel 都使用这种模式。
图 6-2:groupshared 让同组线程合作;barrier 保证合作时序正确。

6.4编译 Shader:FXC / SM5 与 DXC / SM6

HLSL 源码不能直接提交给 GPU 执行,必须先编译为驱动可接受的 shader 字节码。Windows 生态中常见两条路线:

工具目标特点本项目状态
FXCShader Model 5传统、兼容好;支持 f16tof32当前使用
DXCShader 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 完成:

Device创建资源 Allocator命令内存 Command List记录 dispatch Queue提交给 GPU Fence等待完成 xinfer 的 Executor 把 allocator + list + fence 包成一组 one-shot 提交器。
图 6-3:D3D12 command submission 的基本对象关系。

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:少量参数,如 M,K,NM,K,N、count、offset;
  • root descriptors:直接把 buffer GPU virtual address 绑定给 shader;
  • descriptor tables:更适合大量资源,本教程前半部分暂不重点使用。
为什么 root descriptors 适合本项目?

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_DispatchThreadIDSV_GroupIDSV_GroupThreadID 定位数据,通过 SRV/UAV/constant buffer 访问资源;groupsharedGroupMemoryBarrierWithGroupSync() 用于同一 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:

  1. 创建输入 buffer 与输出 buffer;
  2. 把输入数据上传到 GPU;
  3. 编译 HLSL compute shader;
  4. 创建 root signature 和 compute pipeline state;
  5. 绑定 count、SRV、UAV;
  6. dispatch;
  7. 把结果读回 CPU,验证 out[i]=in[i]×2+1out[i]=in[i]\times2+1

这条路径包含 custom HLSL kernel 的必要步骤,也是后续 RMSNorm、RoPE、attention、linear 和 argmax kernel 的最小原型。

思考与练习

基础SV_DispatchThreadIDSV_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==0shared[0]InterlockedAdd 累加到全局输出,或写各组部分和再做二级归约。

需要:一个 groupshared float shared[GROUP_SIZE];写入 LDS 后、每轮读取相邻槽位前都要执行 group sync barrier,避免读到尚未写入或上一轮尚未更新的值。若迁移到 DXC/SM6,可进一步考虑 wave-level reduction 来减少部分 barrier。