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

Module 14:Phase 5 — 运行大于 VRAM 的模型:Layer Streaming

当全部权重无法常驻显存时,将 decoder layer 从 host RAM 流式上传到少量 GPU slot,并用 copy queue 与 compute overlap。

学习目标

  • 理解 resident weights 的显存瓶颈,以及为什么大模型需要 streaming。
  • 掌握 per-layer weight streaming:host RAM 保存完整权重,GPU 只保留少量 layer slots。
  • 理解 dedicated copy queue、persistently-mapped staging buffer 与 fence 的协作。
  • 理解 double-buffering:为什么上传 l+Nl+N 可以与当前计算重叠。
  • 理解 cross-queue resource state correctness。
  • 完成 Lab 14:用小 slot pool 跑模型,并证明 streaming 输出等于 resident 输出。

14.1Residency problem:权重不一定都放得下

Resident GPU forward 在实现上最直接:加载时上传所有权重,推理期间不再移动。它的限制也很明确:模型规模增大后,权重本身可能超过可用 VRAM;即使权重勉强放下,KV cache、activation 与临时 buffer 仍需要显存预算。

一个粗略估算:

VRAMweightstensors#elements×bytes(dtype)\text{VRAM}_{\text{weights}} \approx \sum_{\text{tensors}} \#\text{elements}\times \text{bytes(dtype)}

Decoder-only 模型的权重天然按 layer 组织。一次 forward 中,layer 按 0 到 L1L-1 顺序执行;第 ii 层计算完成后,本轮 forward 不再读取它的权重。这个访问模式允许只在 GPU 上保留少量 layer slots,并在层间复用这些 slots。

图 14-1:resident 模式显存随层数线性增长;streaming 模式由 resident slot 数决定。

14.2Per-layer weight streaming

QwenModel::load_streaming 实现 per-layer weight streaming。完整 ModelWeights 保留在 host RAM;GPU 只分配一个有限的 LayerSlot 池。其基本策略如下:

  • 完整权重保存在 CPU host RAM / mmap 文件中;
  • GPU 上只分配 NNLayerSlot
  • ii 层执行前,把第 ii 层权重上传到 slot imodNi\bmod N
  • 执行完第 ii 层后,这个 slot 可以被第 i+Ni+N 层重用。
slot(i)=imodNslots\operatorname{slot}(i)=i\bmod N_{\text{slots}}
Host RAM / mmap weights Layer 0, 1, 2, ... 23 完整权重都在这里 GPU resident slots (N=4) slot 0 slot 1 slot 2 slot 3 第 i 层权重上传到 slot(i mod N);slot 循环复用。
图 14-2:Layer streaming:完整权重在 host,GPU 上只有少量可复用 slots。

14.3Copy queue 与 persistently-mapped staging buffer

D3D12 default heap 中的权重 buffer 不能直接由普通 CPU 内存填充。xinfer-dml::upload 为 streaming 提供 UploadHeapCopyEngine:前者是 persistently-mapped upload heap,后者拥有 D3D12 COPY-type command queue、command list 与 fence。上传流程如下:

  1. 创建 upload heap staging buffer,并保持 mapped;
  2. CPU 把 layer 权重写入 staging;
  3. copy queue 记录 CopyBufferRegion,从 staging 拷贝到 GPU default heap;
  4. copy queue signal fence;
  5. direct queue 在使用该 layer 前等待对应 fence。
Host weights ModelWeights UploadHeap persistently mapped Copy queue CopyBufferRegion Layer slot default heap Fence target wait before use
图 14-3:copy queue 上传路径:host → staging → GPU slot,并用 fence 表示完成。

14.4Double-buffering:上传与计算重叠

若每层都串行为“上传 → 等待 → 计算 → 下一层”,streaming 的时间会接近上传时间与计算时间之和。Double-buffering 将上传放到 dedicated copy queue:direct queue 计算当前层时,copy queue 预取未来会使用的层。

如果 GPU 有 NN 个 slots,那么执行完第 ii 层后,就可以让 slot (imodN)(i\bmod N) 开始上传第 i+Ni+N 层:

prefetch target=i+Nslots\text{prefetch target}=i+N_{\text{slots}}

当执行推进到第 i+Ni+N 层时,该层权重通常已经在对应 slot 中,direct queue 只需等待该 slot 的 fence 达到目标值。load_streaming 将 resident pool clamp 到至少 2 个 slots,以保证存在基本的双缓冲机会;若传入的 pool 覆盖全部层,则退回 resident 加载。

计算队列与拷贝队列的时间线(示意) Direct queue compute L0 compute L1 compute L2 compute L3 Copy queue upload L4 upload L5 upload L6 upload L7 slot 0..3 正在计算 L0..L3;同时为下一轮 slot 0..3 上传 L4..L7。
图 14-4:Double-buffering 的目标是让权重上传隐藏在计算时间里。

14.5Cross-queue resource-state correctness

D3D12 的 copy queue 与 direct queue 是独立队列。copy queue 完成 layer slot 写入后,direct queue 才能把这些 buffer 作为 shader resource 读取。因此 streaming 的正确性包含两个层面:

  • **同步正确:**direct queue 使用 slot 前,必须等待 copy queue signal 的 fence。
  • **状态正确:**copy queue 写入后,buffer 的 tracked state 要回到合适的基础状态,后续 direct queue 再 transition 到 SRV。

StreamingLayers::ensure 会等待 slot 对应的 copy fence,然后对该层所有 buffer 调用 DmlBuffer::assume_common()。这一步把跨队列写入后的资源状态重新纳入本地状态跟踪,使后续 direct queue 能从 COMMON 过渡到 SRV 等实际使用状态。

常见 bug

当 streaming 与 resident 输出不一致时,除检查上传数据外,还应检查 slot 是否被过早覆盖、copy fence 是否被等待、buffer state 是否在跨队列后被错误假设。

Lab 14用小 slot pool 证明 streaming == resident

本实验不依赖超大模型。使用 synthetic 6-layer Qwen2 checkpoint,并将 resident pool 设为 2 个 slots,就会强制发生 slot reuse。测试目标是证明 streaming 只改变权重驻留方式,不改变模型计算结果。真实 CLI 中对应开关为 --stream-layers <N>,表示 resident pool 的 layer 数。

# 关键验收测试
cargo test -p xinfer-model --test forward_parity -- --nocapture

# 重点观察:
streaming_matches_resident ... ok
streaming vs resident max abs diff = 0

正确性目标:

logitsstreaming=logitsresident\operatorname{logits}_{\text{streaming}} = \operatorname{logits}_{\text{resident}}

测试输出中的 streaming vs resident max abs diff = 0 表明上传、slot 轮转、fence 等待与资源状态处理均与 resident 路径一致。

小结

本章讨论的核心问题是显存 residency。Resident 模式把所有 decoder layer 权重常驻 GPU;streaming 模式把完整权重保留在 host RAM,只在 GPU 上维护少量可复用 LayerSlotUploadHeapCopyEngine、copy queue fence 与 assume_common() 共同保证跨队列上传的顺序和资源状态;double-buffering 使未来层的上传尽可能与当前层计算重叠。streaming_matches_resident 的 diff=0 是该路径的主要正确性证据。

思考与练习

基础为什么 streaming 可以复用 layer slot?

因为前向是逐层顺序进行的:算完第 \ell 层后,它的权重就不再需要,可被后续层覆盖。于是只需一个小的 GPU slot 池轮流装载不同层的权重,而不必为全部 24 层各留一份显存。这让显存占用与池大小(而非层数)成正比,从而能跑下大于 VRAM 的模型。

基础若模型有 24 层,slot 数为 4,第 17 层使用哪个 slot?

slot index = 17mod4=117 \bmod 4 = 1。即第 17 层装入 slot 1。一般地 slot jj 承载层 j,j+4,j+8,j, j+4, j+8,\dots(这里 slot 1 承载层 1,5,9,13,17,21)。

进阶解释为什么 prefetch target 是 i+Nslotsi+N_{\text{slots}}

因为层 ii 与层 i+Nslotsi+N_{\text{slots}} 共用同一个 slot(modN\bmod N 相同)。在层 ii 开始 compute 后,它占用的那个 slot 一旦计算完成就可以被下一个用同一 slot 的层(即 i+Ni+N)覆盖。所以恰好预取 i+Ni+N:它是下一个需要这个 slot 的层,提前在 copy 队列上传,等轮到它时数据已就绪,实现上传与计算重叠。预取更远的层会覆盖还在用的 slot,预取更近的层 slot 还没空。

进阶如果忘记等待 copy queue fence,可能出现什么现象?

compute 队列可能在权重还没上传完时就开始读该 slot,读到上一层的旧权重或半写入的数据,导致结果错误且不确定(race)。表现为输出乱码、与 resident 路径 diff 很大、或时好时坏(取决于上传与计算的相对快慢)。正确做法是 compute 该层前等待对应 copy fence,确保该 slot 的上传已完成。

挑战设计一个测试来故意把 slot 数设为 1,分析它是否仍然正确、是否还能 overlap。

测试:用 load_streaming(.., max_resident_layers=1) 跑一遍,与 resident 路径比较输出,断言 diff=0(或 f16 容差内)。

正确性:仍然正确。slot=1 时所有层串行复用唯一 slot,只要每层 compute 前等待其上传 fence,结果与 resident 一致。

overlap:基本无法重叠。slot=1 意味着层 i+1i+1 要用的 slot 正是层 ii 正在用的那个,必须等层 ii compute 完才能开始上传层 i+1i+1,于是上传与计算被强制串行,吞吐退化为“上传时间+计算时间”之和。至少需要 2 个 slot 才能让层 ii 计算时并行预取层 i+1i+1。实际 load_streaming 会把传入的 resident pool clamp 到至少 2,因此 slot=1 更适合作为思想实验或单独改造后的边界测试。这个测试既验证正确性下限,也揭示 overlap 对池大小 ≥2 的依赖。