Building an LLM Inference Engine from ScratchPart IV / Module 10
Part IV · Building xinfer

Module 10:Phase 1 — 硬件抽象层(HAL)

把模型代码从 D3D12 细节中解耦出来:Device / Buffer trait、状态跟踪 buffer、Executor、通用 operator wrapper、compute kernel 路径与 GPU timer。

学习目标

  • 理解 HAL 的核心目标:让模型层依赖“能力”,而不是依赖具体 GPU API。
  • 能设计最小的 Device / Buffer trait,并说明每个方法的意义。
  • 理解 D3D12 resource state tracking 与 transition barrier 的必要性。
  • 理解 one-shot command executor 如何封装 allocator、command list 和 fence。
  • 区分 DirectML operator wrapper 与 custom compute kernel path。
  • 使用 GPU timestamp profiling 测量 kernel 时间。

10.1为什么需要 HAL?

如果 xinfer-model 直接操作 ID3D12Resource、root signature 和 descriptor heap,模型代码会被 Direct3D 12 的对象生命周期、资源状态和绑定规则占据。后续接入 Xbox host-created device、尝试其他 backend,或在测试中使用 CPU reference,都会被平台细节牵连。

HAL(Hardware Abstraction Layer)的作用是给模型层提供较小且稳定的能力接口。模型层依赖 Device / Buffer 等 trait;D3D12/DirectML、未来的其他 backend 或 CPU fallback 负责实现这些 trait。

ModelHAL traitsBackend implementation\text{Model} \rightarrow \text{HAL traits} \rightarrow \text{Backend implementation}
xinfer-model Qwen2 forward / KV cache xinfer-backend Device / Buffer traits xinfer-dml D3D12 + HLSL future Vulkan same model code CPU fallback tests / teaching HAL 的价值:把“模型需要什么”与“某个 API 怎样实现”分开。
图 10-1:HAL 让模型层不直接依赖某个具体 GPU API。

10.2Device / Buffer trait 的最小设计

Phase 1 中,xinfer-backend 的最小接口保持克制:Buffer 只暴露字节数;Device 负责分配、上传和下载 buffer。这个接口已经足以支撑权重加载、activation buffer、中间结果和 readback,同时不泄露 D3D12 类型。

pub trait Buffer {
    fn size_in_bytes(&self) -> usize;
}

pub trait Device {
    type Buffer: Buffer;

    fn alloc(&self, shape: &Shape, dtype: DType) -> Result<Self::Buffer>;
    fn upload(&self, data: &[u8], shape: &Shape, dtype: DType) -> Result<Self::Buffer>;
    fn download(&self, buffer: &Self::Buffer) -> Result<Vec<u8>>;
}

一个重要细节是:HAL 不直接暴露 D3D12 类型。模型层只需要知道“这是一个 buffer”,而不需要知道它背后是 ID3D12Resource、Vulkan buffer 还是 CPU vector。

接口设计原则

HAL 的接口不应过早抽象所有可能 backend 的能力。先抽象当前模型真正需要的最小能力;当新 backend 或新需求出现时再扩展。

10.3状态跟踪 GPU buffer:自动 transition barrier

D3D12 的资源有显式状态:同一个 buffer 在不同时间可能是 COPY_DESTCOPY_SOURCENON_PIXEL_SHADER_RESOURCEUNORDERED_ACCESS。如果状态不对,GPU 行为未定义,调试层会报错。

因此 DmlBuffer 除了保存 ID3D12Resource,还保存当前状态:

pub struct DmlBuffer {
    pub resource: ID3D12Resource,
    pub size: u64,
    pub shape: Shape,
    pub dtype: DType,
    state: Cell<D3D12_RESOURCE_STATES>,
}

当 custom kernel 需要把 buffer 作为 SRV 读取时,ComputeKernel::dispatch_grid 会调用 ensure_state(..., NON_PIXEL_SHADER_RESOURCE);作为 UAV 写入时调用 ensure_state(..., UNORDERED_ACCESS)download_buffer 会把源 buffer 转到 COPY_SOURCEupload_buffer 则先在 default heap 中创建 COPY_DEST,拷贝后转为 UNORDERED_ACCESS。如果当前状态已经匹配,ensure_state 不记录 barrier;否则写入 transition barrier 并更新状态。

COPY_DEST upload writes NON_PIXEL_SHADER_RESOURCE kernel reads (SRV) UNORDERED_ACCESS kernel writes (UAV) 状态跟踪把这些 transition 从“手工记忆”变成 buffer 自己维护的规则。
图 10-2:一个 buffer 在上传、读取、写入之间需要显式状态转换。

10.4One-shot Executor:封装 command list + fence

D3D12 提交命令需要 command allocator、command list、command queue、fence 和 event 协同工作。若每个调用点都直接操作这些对象,代码会重复,并且容易遗漏 close、signal、wait 或 reset。

crates\xinfer-dml\src\resource.rs 将它们封装为 Executor

let mut exec = dev.executor()?;
kernel.dispatch(&mut exec, ...)?;
exec.submit_and_wait()?;
exec.reset()?;

Executor::submit_and_wait 关闭 command list、提交到 queue、递增 fence 并阻塞等待完成;reset 复用 allocator 和 list;uav_barrier 用于在同一 command list 中排序相互依赖的 UAV 写读。后续把一个 decoder layer 的多个 dispatch 合并提交时,这个封装是关键基础。

Recorddispatch / barriers Close + Submitqueue executes FenceCPU waits Resetreuse list Executor 是教学用的清晰封装;生产级 runtime 会进一步批处理和复用命令。
图 10-3:Executor 把 D3D12 命令提交的样板代码收拢起来。

10.5通用 DirectML operator wrapper 与 compute-kernel path

Phase 1 中保留两条执行路径,分别服务于 DirectML 已提供的 operator 和项目自写 kernel:

  • DmlOperator:封装 DirectML operator 的 create / compile / initialize / bind / dispatch。
  • ComputeKernel:封装自写 HLSL compute shader 的 root signature / PSO / root descriptors / dispatch。

二者都向 D3D12 command list 记录工作,但绑定方式不同。DmlOperator 需要 DirectML binding table、temporary resource 与 persistent resource;ComputeKernel 使用 root constants 以及 SRV/UAV root descriptors,适合 RMSNorm、RoPE、SwiGLU、attention 等自写 HLSL kernel。

路径适合主要封装
DmlOperatorDirectML 提供的通用 operatorbinding table、temp/persistent resource
ComputeKernel自写 HLSL kernelroot constants、root SRV/UAV descriptors、PSO

10.6GPU timestamp profiling

CPU wall-clock 包含命令提交、驱动调度、queue 排队和等待开销。若要测量一段 GPU 工作本身的耗时,需要在 command stream 中写 timestamp query。GpuTimer 的实现包含两个 query、一段 readback buffer,并使用 command queue 的 timestamp frequency 换算毫秒。D3D12 timestamp 的基本流程是:

  1. 创建 timestamp query heap;
  2. 在 command list 中写 begin timestamp;
  3. 记录被测 dispatch;
  4. 写 end timestamp;
  5. resolve query data 到 readback buffer;
  6. 用 queue timestamp frequency 换算毫秒。
elapsed ms=ticksendticksbegintimestamp frequency×1000\text{elapsed ms} = \frac{\text{ticks}_{end}-\text{ticks}_{begin}} {\text{timestamp frequency}}\times 1000
测量纪律

性能优化应以测量结果为依据。xinfer 后续优化显示,早期主要瓶颈并非单个 matmul,而是过多 GPU submission 和 logits readback。GPU timestamp 能把这类问题从推测转化为可比较的数据。

Lab 10实现 upload / download + GPU timer

本实验对应 Phase 1。目标是通过 HAL 完成 buffer round-trip,并用 GPU timestamp 测量一段已记录的 GPU 工作。

  1. 实现 DmlDevice::alloc_buffer
  2. 实现 DmlDevice::upload_buffer:用 upload heap → default heap。
  3. 实现 DmlDevice::download_buffer:default heap → readback heap。
  4. 实现 Executor:record、submit、wait、reset。
  5. 实现 GpuTimer,给一个 GEMM 或 affine kernel 计时。
# 验收测试
cargo test -p xinfer-dml --test hal

# 期望包含:
buffer_roundtrip ... ok
gpu_timer_reports_nonnegative ... ok

小结

Module 10 将 Phase 0 的 GPU 闭环提升为 HAL:xinfer-backend 定义 Device / Buffer trait,xinfer-dmlDmlBuffer 跟踪 D3D12 resource state,以 Executor 统一 command list 与 fence,以 DmlOperatorComputeKernel 分别封装 DirectML operator 与 HLSL kernel。GpuTimer 提供 GPU 侧时间戳,为后续定位 submission、readback 和 kernel 耗时奠定基础。

思考与练习

基础为什么模型层不应该直接依赖 ID3D12Resource

因为那会把模型逻辑和 D3D12/Windows 绑死,破坏可移植性与可测试性。模型层应只依赖抽象的 Buffer trait;这样换 backend(Vulkan/Metal)或在 CPU 上做参考测试时无需改模型代码,HAL 边界清晰。

基础upload heap、default heap、readback heap 分别用于什么?

upload heap:CPU 可写、GPU 可读,用于把数据从 CPU 传到 GPU(权重/输入上传的中转)。default heap:GPU 本地显存,访问最快,计算用的 buffer 常驻于此(CPU 不可直接访问)。readback heap:GPU 可写、CPU 可读,用于把结果从 GPU 拷回 CPU。典型流:upload→default(计算)→readback。

进阶解释 resource state tracking 如何减少人为 barrier 错误。

每个 buffer 携带其当前状态(如 UAV / COPY_DEST / COPY_SOURCE / COMMON)。当要做某操作时,HAL 比较“当前状态”与“所需状态”,仅在不一致时自动插入正确的 transition barrier 并更新记录。这样开发者不必手算每处该加什么 barrier,避免漏加(数据竞争)或加错(状态不匹配导致 device removed),把易错的状态机交给统一封装。xinfer 的 DmlBuffer 即采用状态跟踪。

进阶为什么 GPU timestamp 比 CPU wall-clock 更适合测单个 kernel?

CPU wall-clock 测的是“提交到等待返回”的端到端时间,包含命令提交、驱动开销、CPU/GPU 不同步、队列排队等噪声,且 GPU 异步执行使 CPU 计时点对不准。GPU timestamp query 直接在命令流中、kernel 前后打 GPU 时间戳,按 GPU 时钟测真正的执行耗时,排除 CPU 侧噪声,分辨率更高,适合逐 kernel 剖析。xinfer 的 GpuTimer 即基于 timestamp query。

挑战扩展 Device trait,使其能创建一个“未初始化但指定用途”的 buffer,并说明对 D3D12 状态的影响。

新增方法如 fn alloc_uninit(&self, bytes: usize, usage: BufferUsage) -> Buffer,其中 usage 是抽象枚举(如 Storage/CopyDst/CopySrc)。它分配 default heap 资源但不上传数据。

D3D12 影响:创建时需给一个初始 resource state(如 COMMON 或按 usage 选 UNORDERED_ACCESS/COPY_DEST),并在 Buffer 的状态字段里记录。由于内容未初始化,首次使用前要么先写(作为 UAV/COPY_DEST),要么明确语义上的“先写后读”;状态跟踪据此在首次操作时插入合适 barrier。usage 提示让 HAL 选更优初始状态,减少首个 barrier。这类 buffer 适合做计算中间结果或 KV cache 等“先分配后填充”的场景。