Chapter 06

Offload:CPU / NVMe 卸载的代价与收益

📌 源码引用基于 deepspeed/runtime/zero/offload_config.py 、 deepspeed/runtime/zero/parameter_offload.py 、 deepspeed/nvme/ · commit 510ebe58e4e4 。

6.1 Offload 的核心思想

ZeRO 已经把每张卡的占用降到 $16\Psi/N$。如果卡还是不够呢?把更不常用的张量搬到 CPU 内存乃至 NVMe SSD,需要时再搬回 GPU。这就是 ZeRO-Offload / ZeRO-Infinity。

显存等级金字塔:

flowchart LR GPU["GPU HBM
80 GB
~3 TB/s"] RAM["CPU DRAM
1 TB
~50 GB/s"] NVMe["NVMe SSD
16 TB
~7 GB/s"] GPU --calls/s--> RAM RAM --calls/s--> NVMe

越往下,容量越大但带宽越低。Offload 的本质就是用更便宜的容量更小的吞吐损失

6.2 可以 offload 的对象

对象大小访问频率JSON 字段
优化器状态 ($m,v$) $8\Psi$ 每次 step 一次 offload_optimizer.device
fp32 master 参数 $4\Psi$ 每次 step 一次 同上(合并)
fp16 参数 $2\Psi$ 每层 forward 一次 offload_param.device(仅 stage 3)
梯度 $2\Psi$ 每次 backward 一次 不直接卸载,跟优化器走
激活 取决于 batch非常频繁 不建议 offload;用 activation_checkpointing 代替

6.3 三种 offload 配置组合

组合 A:ZeRO-2 + CPU offload(性价比最高)

仓库自带 examples/deepspeed/ds_z2_offload_config.json(在 LLaMA-Factory 仓库里也有镜像):

{
  "bf16": { "enabled": "auto" },
  "zero_optimization": {
    "stage": 2,
    "offload_optimizer": {
      "device": "cpu",
      "pin_memory": true
    },
    "overlap_comm": true,
    "contiguous_gradients": true,
    "allgather_partitions": true,
    "reduce_scatter": true,
    "reduce_bucket_size": "auto"
  }
}

显存收益(13B 模型,8 卡 A100 40G):

$$ M_\text{ZeRO-2} = 2\Psi + \tfrac{14\Psi}{8} \approx 3.75\Psi \quad\Rightarrow\quad M_\text{+ offload\_optim} = 2\Psi + \tfrac{2\Psi}{8} = 2.25\Psi $$

即从 $\approx$ 49 GB 降到 $\approx$ 29 GB,开 offload 后 13B 在 40G 卡上就能跑了。

前置 开 CPU offload 必须装 cpu_adam op:DS_BUILD_CPU_ADAM=1 pip install --force-reinstall deepspeed。 跑训练时 ds_report 看到 [OKAY] 才算就绪。

组合 B:ZeRO-3 + CPU offload(70B 神器)

{
  "bf16": { "enabled": "auto" },
  "zero_optimization": {
    "stage": 3,
    "offload_optimizer": {
      "device": "cpu",
      "pin_memory": true
    },
    "offload_param": {
      "device": "cpu",
      "pin_memory": true
    },
    "overlap_comm": true,
    "contiguous_gradients": true,
    "sub_group_size": 1e9,
    "reduce_bucket_size": "auto",
    "stage3_prefetch_bucket_size": "auto",
    "stage3_param_persistence_threshold": "auto",
    "stage3_max_live_parameters": 1e9,
    "stage3_max_reuse_distance": 1e9,
    "stage3_gather_16bit_weights_on_model_save": true
  }
}

用法:在单机 8 卡 A100 上训 70B 全参,offload_param=cpu + offload_optimizer=cpu 把每张卡占用压到能塞下的水平。代价是训练速度下降 30%-60%,因为每层 forward 都要从 CPU 拷参数到 GPU。

组合 C:ZeRO-Infinity(NVMe offload)

{
  "zero_optimization": {
    "stage": 3,
    "offload_optimizer": {
      "device": "nvme",
      "nvme_path": "/local_nvme/ds_swap",
      "pin_memory": true,
      "buffer_count": 4,
      "fast_init": false
    },
    "offload_param": {
      "device": "nvme",
      "nvme_path": "/local_nvme/ds_swap",
      "buffer_count": 5,
      "buffer_size": 1e8,
      "max_in_cpu": 1e9
    },
    "aio": {
      "block_size": 1048576,
      "queue_depth": 8,
      "thread_count": 1,
      "single_submit": false,
      "overlap_events": true
    }
  }
}

用法:CPU 内存也不够时,把对象进一步卸到 NVMe SSD。前置要求:

  1. libaioapt install libaio-dev
  2. 编 aio op:DS_BUILD_AIO=1 pip install --force-reinstall deepspeed
  3. 给个真的 NVMe 盘路径(不能是 HDD,否则会慢到没法用)。

实测 NVMe offload + ZeRO-3 让一台 4 × V100 32G 上能训 GPT-3 13B 全参 —— 本来连个 7B 都装不下。代价是吞吐降到 1/3 左右。

6.4 速度损失到底是多少

非常粗的"经验值"(A100 80G、PCIe Gen4、NVMe 7 GB/s):

组合相对 ZeRO-2 速度显存收益
ZeRO-2 1.00× 基准
ZeRO-2 + CPU offload optimizer 0.85–0.95× 能再装 ~30% 大模型
ZeRO-3 0.70–0.90× 能装 4×大模型
ZeRO-3 + CPU offload optimizer 0.55–0.75× 能装 6× 大模型
ZeRO-3 + CPU offload optimizer + param0.40–0.60× 能装 10× 大模型
ZeRO-3 + NVMe offload(Infinity) 0.20–0.40× 容量基本无上限

选择思路:

6.5 关键调优字段

字段含义调优建议
offload_optimizer.pin_memory 用 pinned host 内存加快 H2D
offload_param.buffer_count NVMe param 缓冲区数量 5–8
offload_param.buffer_size 单缓冲区大小(bytes) 1e85e8
offload_param.max_in_cpu CPU 内存里保留多少参数 视 RAM 而定
aio.block_size 每次 IO 块大小 1 MB
aio.queue_depth 异步 IO 队列深度 8
aio.thread_count IO 线程数 1–2
aio.overlap_events IO 与 compute 重叠

6.6 用 Profiler 看 offload 是不是瓶颈

DS 自带的 wall clock breakdown 是判断 offload 是否拖速度的最快方法:

{
  ...
  "wall_clock_breakdown": true
}

训练时会按 step 打印每个阶段耗时:

step= 100 fwd= 320ms bwd= 410ms bwd_inner=190 bwd_allreduce=110 bwd_other=110
                                step= 230ms ...
optim_step= 95ms allgather= 32ms reduce_scatter= 21ms ...

如果 optim_step 远大于 fwd+bwd,多半是 CPU offload 的 cpu_adam 慢了——升级 CPU、关 offload,或加 nvme_path

6.7 Offload 常见错误

现象原因修复
RuntimeError: cpu_adam not available没编 cpu_adam opDS_BUILD_CPU_ADAM=1 pip install --force-reinstall deepspeed
RuntimeError: aio not built 没装 libaio apt install libaio-dev + DS_BUILD_AIO=1
CPU offload 后训练速度跌 90% CPU 太弱 / 没 pin_memorypin_memory: true;考虑硬件升级
NVMe offload 报 "no space left" 路径满 清掉 nvme_path 下旧 swap;改盘
多机训练 NVMe offload 出错 nvme_path 不在所有节点每节点都挂一块;用本地路径,不要走 NFS
训完模型保存失败 ZeRO-3 + offload gather 时显存爆stage3_gather_16bit_weights_on_model_save: true;保存到 CPU

6.8 OffloadStateTypeEnum:到底能 offload 哪些"状态"

deepspeed/runtime/zero/offload_config.py 第 109–115 行能看到一个枚举,把"可被 offload 的对象"列得很清楚:

# offload_config.py:109–115
class OffloadStateTypeEnum(Enum):
    optim_states           = "optim_states"           # m, v 等 Adam 状态
    hp_params              = "hp_params"              # high-precision (fp32) master 权重
    lp_params              = "lp_params"              # low-precision 工作副本(fp16/bf16)
    lp_grads               = "lp_grads"               # 低精度梯度
    contiguous_grad_buffer = "contiguous_grad_buffer" # ZeRO 拍平后的连续梯度桶

这 5 种状态的关键区别:

状态大致大小访问频率offload 收益
optim_states $8\Psi$(Adam)每次 step 一次★★★(最大;首选)
hp_params $4\Psi$ 每次 step 一次★★★(和 optim 一起搬最划算)
lp_params $2\Psi$ 每层 forward ★(搬走会拖慢 forward)
lp_grads $2\Psi$ 每次 backward ★★
contiguous_grad_buffer ~ bucket 大小 每次 backward ★(搬走会失去拍平的优势)

新版 DS 允许通过 offload_states() / reload_states() 接口在运行时选择性 offload/reload 这些状态(RLHF 训完 actor、要释放显存做 reference rollout 时常用)。

6.9 NVMe AIO 缓冲区设计

开 NVMe offload 时容易遇到一个困惑:buffer_count / buffer_size / max_in_cpu 三个字段都跟 buffer 有关,到底谁管谁?翻 offload_config.py:21–50DeepSpeedZeroOffloadParamConfig 能把它们分清楚:

字段默认位置含义
buffer_count 5 GPU↔NVMe 之间的中转池数量多个池可以并行 IO("读这个池的同时写另一个")
buffer_size 1e8 (~100MB)单个中转池容量(bytes)调大可减少 IO 次数,但占 CPU 内存
max_in_cpu 1e9 (~1GB) CPU 内存中"还没写回 NVMe"的参数 numel 上限水位线:超过就开始往 NVMe 落盘
pin_memory False 是否用 pinned host buffer开了能让 H2D / D2H 走 DMA,强烈建议 True
nvme_path None NVMe 挂载点必须是本地 NVMe,不能 NFS、不能机械盘

数据流图:

flowchart LR GPU["GPU HBM"] Pool["CPU pinned
buffer_count 个 × buffer_size"] CPU["CPU 内存中
≤ max_in_cpu"] NVMe[NVMe SSD] GPU <-- DMA --> Pool Pool <-- 异步 IO --> NVMe CPU <-- 超水位 --> NVMe

调优经验:buffer_size 调到 5e8 上下,buffer_count 保持 4–8,max_in_cpu 不超过总 RAM 的 70%。aio.queue_depth=8 配合 overlap_events=true 通常打满 NVMe Gen4 的 7 GB/s。

6.10 SuperOffload 异步 CPU 进程模型

ch05.15 已经讲了 SuperOffload 跟 ZeRO-Infinity 在概念上的不同——这里展开它在 offload 字段里要怎么开:

"zero_optimization": {
  "stage": 3,
  "offload_optimizer": {
    "device": "cpu",
    "pin_memory": true,
    "super_offload":      true,    // 启用
    "cpuadam_cores_perc": 0.8,     // CPU adam worker 占用 80% CPU 核
    "ratio":              1.0      // 100% 走 SuperOffload;可调 0.5 等做 hybrid
  }
}

对应字段在 offload_config.pysuper_offload(line 96)、cpuadam_cores_perc(99)、ratio(93)。后台跑的就是 superoffload_utils.py:38superoffload_optimizer_worker()——一个独立 python 进程,通过 SimpleQueue 收任务、用 DeepSpeedCPUAdam 算 step、再把结果发回主进程。

注意:

6.11 DeepNVMe / GPU-Direct Storage

repos/deepspeed/csrc/gds/ 目录是 NVIDIA 的 GPU-Direct Storage(GDS)接入。区别于普通 AIO(GPU → CPU → NVMe 的三段拷贝),GDS 让 GPU 通过 NVLink/PCIe 直接读写 NVMe,跳过 CPU 内存

维度普通 AIO(libaio)GDS(DeepNVMe)
路径 GPU ↔ CPU pinned ↔ NVMeGPU ↔ NVMe(直接)
CPU 内存占用 需要 buffer_count × buffer_size几乎不需要
带宽上限 ~ PCIe 单方向 ~ NVMe SSD 极限(多个 NVMe 可叠加)
硬件要求 任意 需要支持 GDS 的 NVMe + driver
编译开关 DS_BUILD_AIO=1DS_BUILD_GDS=1

开启方法:把 "offload_optimizer.device": "nvme" 配合 GDS 编译的 wheel 即可;DS 在 op 装好时自动选 GDS 后端。校验:ds_report 输出里 gds .................. [OKAY]

6.12 ZenFlow 与 offload 的协议

普通认识里"开 offload = 慢"。ZenFlow 想颠覆的是这一点:

配置step 时 GPU 干什么典型吞吐相对纯 ZeRO-3
ZeRO-3 无 offload 正常 step 1.00×(基线)
ZeRO-3 + CPU offload 等 CPU 算完 step 0.55–0.75×
ZeRO-3 + CPU offload + ZenFlow10% top-k 梯度 GPU 立刻更新,90% 异步累积;GPU 没空闲0.85–1.00×
ZeRO-3 + CPU offload + SuperOffload worker 进程并行算 step0.85–0.95×

ZenFlow 与 SuperOffload 不冲突,可以叠加:ZenFlow 负责"只更新重要梯度",SuperOffload 负责"被更新的那部分也异步算"。但叠加目前还在 blog 验证阶段,生产用先开一个跑稳定再考虑组合。

心法 Offload 永远是"显存换速度"。先确认你真的需要它再开 —— 如果硬件足够,stage 2 不开 offload 就能跑,那就别加。