0%

verl 中 FSDP2 的异步 offload bug

今天用 verl 试一下 PPO+SC 这个 recipe。然而模型输出一直乱码。🤔 我模型都还没开始训练呢,所以和梯度什么的都无关,不是训崩了。我赶紧关了 SC,情况还是一样的。

🥲 于是,我烧了 20% Codex 额度去 debug,修好后才展开训练。但是,Codex 后来去翻了翻 verl 的 PR,发现这个 bug 5 天前就已经被修好了。要是能早知道就不用烧这么多额度了,这是我的疏忽。

虽说实验确实要固定 verl 版本,不能实时 git pull,但是如果能提前检查一下 PR,也不会烧那么多 token 去检查日志。吃到教训了。


Bug

我查看了日志,具体问题是:

【prompt 大意】

请你写个 grabscrab(word, possible_words),例如用 “ortsp” 匹配 “sport”“ports”。

【模型输出】

udasates查明timeofday唿ívelawaysponsor.githubusercontent.emplace[__变化髦徕audgestingiable(essthrough,[], challengesriegaponatriuls/autoloadviceiations.githubusercontent именно总局总局总局总局总局总局总局тратpheric驾, itself态势 Lafayette[t yếuutherland妫蝽袢 educationquared

看到模型输出乱码,你可能第一时间怀疑推理框架。

这样想就错了。虽然生成回答的是 vLLM,但它使用的 actor 权重需要从 FSDP2 管理的训练端同步过来的。

训练端的权重在 CPU offload 时已经损坏,随后错误权重被传给 vLLM。具体来说:

  1. 训练端用 FSDP2 管理 actor 的分片权重。
  2. verl 把这些权重同步给 vLLM,让它用当前策略生成回答。
  3. 同步前,训练端权重在 CPU offload 时损坏,vLLM 接收损坏后的权重。
  4. vLLM 用错误参数算出错误的 token。

总之,FSDP2 管理的权重在搬运时损坏,随后传给了负责生成的 vLLM。

所以问题在哪?一言以蔽之:FSDP2 的异步 GPU→CPU 拷贝。

PPO 不用像 GRPO 那样大量推理,所以我就分了 3 张卡。(GRPO 我之前是 4 卡推理 4 卡训练。)

用 FSDP2,三卡需要分片。分片(shard)是什么?模型里有参数矩阵,FSDP2 切分模型参数到多张 GPU 上;为什么要分片?这样可以节省每张卡的显存。

现在假设矩阵有 128 行。假设我的三张卡里,每个分片:

  • GPU 0 保存第 1–43 行,共 43 行。
  • GPU 1 保存第 44–86 行,共 43 行。
  • GPU 2 保存第 87–128 行,共 42 行,这是尾分片。

现在要尾部补齐。为什么补齐?因为 FSDP2 需要等大小的分片缓冲区。所以,最后一块有 43 行,其中 42 行是真实参数,额外一行是 padding,底层存储中补齐到相同大小。

Bug 就在这里,补齐时,提前读取了尚未从 GPU 搬完的数据。

拷贝还没结束,CPU 就开始读取并补齐,错误数据被复制到了另一块内存。此时再 synchronize 也晚了。

有点难理解,这样说,为了补齐,程序需要把原有的 42 行复制到新的 43 行缓冲区里,这一步真实参数复制错了。具体来说:

  1. 要准备推理了,训练端准备卸载到主机内存,腾出显存来推理。
  2. GPU 2 上的尾分片有 42 行真实参数。现在,开始异步搬到 CPU 的 A 区域。
  3. FSDP2 一看,怎么才 42 行,立刻重建补齐后的存储,申请一块能放 43 行的 CPU 缓冲区 B。
  4. 它需要把 A 的 42 行复制到 B 的前 42 行,剩下第 43 行作为 padding。
  5. 但第一步还没搬完!
  6. 所以第三步读到尚未就绪的数据,B 的前 42 行真实参数写错了。
  7. 后来 synchronize 等到 GPU→A 搬运结束时,只保证 A 正确。
  8. 然而后续使用的是 B。

就是尾分片触发了第二步「重新分配缓冲区,再复制真实参数」的操作。


社区修复

☺️不过,开源社区还是很活跃的,5 天前已经有人修好了:

verl PR #8187

PyTorch 上,也有人提了 issue:

pytorch issue #201049


Codex 修复

我的 Codex 怎么 debug 的?

原来 verl/utils/fsdp_utils.py 的代码里,offload_fsdp2_model_to_cpu 的卸载调用是:

1
model.to("cpu", non_blocking=True)

它允许 GPU→CPU 拷贝还在进行时,CPU 继续执行。但这里,FSDP2 在 model.to() 内部就会调用 reset_sharded_param() 来补齐。

CPU offload 改为阻塞拷贝,等数据搬完再继续。没了。伪代码就是:

1
2
3
4
if has_uneven_shards(model):
model.to("cpu", non_blocking=False)
else:
model.to("cpu", non_blocking=True)

为什么之前没有触发这个 bug

因为许多模型维度能被 4 整除,却不能被 3 整除。如果分到 4 张卡没有不均匀尾分片。以上。