【Bug已解决】[BUG] It seems that SuperOfflload is unable to overlap the CPU (param update) and GPU (backw
【Bug已解决】[BUG] It seems that SuperOfflload is unable to overlap the CPU (param update) and GPU (backwawrd). 解决方案
一、现象长什么样
DeepSpeed 的SuperOffload(一种把参数/优化器状态卸载到 CPU、在 CPU 上做参数更新、同时让 GPU 继续反向传播的优化策略)本应带来「CPU 更新参数」与「GPU 算反向」的时间重叠(overlap),从而加速。但用户实测发现:两者并没有重叠,而是串行执行——GPU 反向算完、干等 CPU 更新;或 CPU 更新时 GPU 空闲。时间线如下:
期望(重叠): GPU backward |########| CPU param upd |########| <- 与 GPU 并行 实际(串行): GPU backward |########| CPU param upd |########| <- GPU 干等表现:开启 SuperOffload 后,单步时间 ≈ GPU 反向时间 + CPU 更新时间(无加速),甚至因 CPU↔GPU 同步开销反而变慢。本期讲清根因并给三层修复。
二、背景
2.1 SuperOffload 的设计目标
ZeRO-Offload 把优化器状态放 CPU,参数更新在 CPU 做。SuperOffload 进一步想做到:当某一层/某批参数的 GPU 反向完成时,立刻把梯度搬 CPU 启动参数更新,而 GPU 趁机去算下一层的反向——也就是「CPU 更新」与「GPU 反向」流水线化重叠。
2.2 为什么重叠需要异步
重叠的前提是:CPU 更新和 GPU 反向跑在不同设备、且用异步拷贝(non-blocking H2D/D2H)+ 不同 CUDA stream,彼此不被对方的同步点卡住。任何「CPU 更新前强制等 GPU 全部反向完」或「GPU 反向前强制等 CPU 更新完」的同步,都会打破重叠。
三、根因
3.1 错误的同步点(stream/event 缺失)
SuperOffload 实现里,参数更新步骤在进入前调用了torch.cuda.synchronize()或在主 stream 上等 CPU 结果,导致 GPU 反向被「拦停」等 CPU;或者相反,CPU 更新前future.result()阻塞等 GPU 全部梯度就绪,使 CPU 无法提前开始。
3.2 梯度未分块 / 未尽早释放
重叠需要「梯度一边算一边传」。若实现是「等所有层反向完、把所有梯度一次性搬 CPU 再更新」,就失去了「逐层流水线」的可能,退化为串行。
3.3 CPU 更新用同步拷贝
CPU→GPU 的参数回传若用param.copy_(updated_cpu)的同步版本(没non_blocking=True也没独立 stream),GPU 会等这次拷贝完成,打断重叠。
3.4 一句话根因
SuperOffload 无法重叠 CPU 参数更新与 GPU 反向,根因是实现中存在错误的全局同步点(在主 stream 上等 CPU 结果 / 等全部梯度就绪才启动更新)、梯度未分块尽早搬运、以及 CPU↔GPU 拷贝用同步方式,导致 GPU 反向与 CPU 更新退化为串行,预期的流水线重叠失效。
四、最小可运行复现
下面用纯 CUDA 流模拟「有/无正确同步」下两段任务能否重叠:
import torch def simulate_overlap(use_proper_overlap: bool, seconds=0.05): """模拟: GPU 反向(流 g) 与 CPU 更新(流 c) 能否并行。 use_proper_overlap=False -> 主 stream 上强制同步, 串行 use_proper_overlap=True -> event 同步, 真正重叠 """ if not torch.cuda.is_available(): print("无 CUDA, 仅展示逻辑") return g = torch.cuda.Stream() # GPU 反向流 c = torch.cuda.Stream() # CPU 更新流(概念上) buf = torch.zeros(1024, device="cuda") main = torch.cuda.current_stream() if not use_proper_overlap: # bug: 先等 GPU 全部反向完, 再让 CPU 更新 -> 串行 with torch.cuda.stream(g): buf += 1 main.synchronize() # 拦停, 等 GPU with torch.cuda.stream(c): buf += 1 main.synchronize() else: # 正确: GPU 反向发起后, 不等, 立即发起 CPU 更新(不同流) with torch.cuda.stream(g): buf += 1 with torch.cuda.stream(c): buf += 1 # 仅在真正需要结果时同步 main.wait_stream(g); main.wait_stream(c) main.synchronize() print(f"use_proper_overlap={use_proper_overlap} -> 两段任务已{'重叠' if use_proper_overlap else '串行'}") if __name__ == "__main__": simulate_overlap(False) simulate_overlap(True)两段任务若落在不同 stream 且中间无synchronize阻断,就能并行;若主 stream 中途synchronize,就退化为串行——正是 SuperOffload 重叠失效的微观机理。
五、解决方案(第一层:最小直接修复)
5.1 移除错误的全局同步点
检查 SuperOffload 实现里是否有「反向结束后统一torch.cuda.synchronize()再更新」的代码,改为逐层、异步:反向完一层就异步把该层梯度搬 CPU 并启动更新,不 overall synchronize。
5.2 用 non_blocking 拷贝 + 独立 stream
# GPU -> CPU (梯度搬运) grad_cpu = torch.empty_like(grad_gpu, device="cpu") grad_cpu.copy_(grad_gpu, non_blocking=True) # 不阻塞 GPU 流 # CPU 更新后 -> GPU (参数回传) with torch.cuda.stream(param_stream): # 独立 stream param_gpu.copy_(param_cpu, non_blocking=True)六、解决方案(第二层:结构性 / 抽象改进)
第一层是「去同步点」,更稳的是用 async future / CUDA event 把「梯度就绪」与「参数更新」解耦,实现真正的流水线。
6.1 基于 event 的分层重叠
import torch class OverlapSuperOffload: def __init__(self): self.gpu_stream = torch.cuda.Stream() self.cpu_stream = torch.cuda.Stream() # 概念 def step(self, layers): """逐层: GPU 反向与 CPU 更新重叠。""" events = [] for layer in layers: # GPU 反向(在 gpu_stream) with torch.cuda.stream(self.gpu_stream): grad = layer.backward() # 异步搬梯度到 CPU, 不阻塞 gpu_stream grad_cpu = torch.empty_like(grad, device="cpu") grad_cpu.copy_(grad, non_blocking=True) # CPU 更新异步发起(用 torch.cuda.Event 标记梯度就绪) ev = torch.cuda.Event() ev.record(self.gpu_stream) events.append((ev, grad_cpu, layer)) # CPU 侧: 等各自 event 后立即更新, 不 overall 同步 for ev, grad_cpu, layer in events: ev.synchronize() # 只等这一层的 GPU 工作 layer.cpu_update(grad_cpu) # CPU 更新参数 layer.param_gpu.copy_(layer.param_cpu, non_blocking=True)6.2 用 torch.futures 解耦
import torch.futures as futures def async_cpu_update(grad_gpu, layer): grad_cpu = grad_gpu.to("cpu", non_blocking=True) fut = futures.Future() # CPU 更新在独立线程/流完成, 完成后 set_result def _do(): layer.cpu_update(grad_cpu) fut.set_result(layer.param_cpu) # 简化: 实际应在 CPU 流或线程跑 _do _do() return fut七、解决方案(第三层:断言 / CI 守护)
把「重叠确实发生」变成可测量不变量。
7.1 用事件计时验证重叠
import torch, time def test_overlap_actually_happens(): if not torch.cuda.is_available(): return g = torch.cuda.Stream(); c = torch.cuda.Stream() buf = torch.zeros(1024, device="cuda") # 测: g 与 c 两流任务并发, 总时长应接近 max 而非 sum t0 = torch.cuda.Event(enable_timing=True); t1 = torch.cuda.Event(enable_timing=True) t0.record() with torch.cuda.stream(g): for _ in range(50): buf += 1 with torch.cuda.stream(c): for _ in range(50): buf += 1 t1.record(); t1.synchronize() elapsed = t0.elapsed_time(t1) # 若重叠, elapsed 应明显小于两段串行之和 print(f"[metric] 重叠路径耗时 {elapsed:.2f} ms (应接近单段而非两段之和)")7.2 CI 性能回归监控
jobs: overlap-bench: runs-on: [self-hosted, gpu] if: github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4 - run: python bench_superoffload.py # 断言单步时间 < GPU反向+CPU更新 串行和三层叠加:直接去同步点 + non_blocking 拷贝(救急)+ 结构改(event/ futures 分层重叠)+ 守护(计时验证重叠 + 性能 CI),把「假重叠」变成「真并行可测」。
八、补充:如何判断重叠到底有没有发生
光看代码不够,要实测时间线:
- Nsight Systems / nsys抓 timeline,看 CPU 更新 kernel 与 GPU 反向 kernel 是否在同一时间轴并行;
- 打印各阶段耗时:分别测「仅 GPU 反向」「仅 CPU 更新」「两者一起」,若「一起 ≈ max(反向, 更新)」说明重叠成功,若「一起 ≈ 反向 + 更新」说明串行;
- 检查 stream:确认 CPU 更新相关拷贝不在 GPU 主 stream 上阻塞;
- 逐层 profile:若只有第一层重叠、后面退化,往往是某层之后插入了整体同步。
另外,SuperOffload 的重叠收益依赖「CPU 更新耗时 ≈ GPU 反向耗时」。若 CPU 远慢于 GPU,重叠也救不回,此时应考虑减小卸载比例或换更快的 CPU/内存带宽。
九、排查清单
当 SuperOffload 看起来没重叠时:
- nsys 抓 timeline,确认 CPU 更新与 GPU 反向是否真并行。
- 分别测「仅反向」「仅更新」「一起」耗时,对比判断是否串行。
- 搜实现里的
torch.cuda.synchronize(),移除反向后/更新前的全局同步。 - 梯度用
non_blocking=True+ 独立 stream异步搬 CPU。 - 逐层尽早搬运梯度,不要等全部反向完再更新。
- 用 event / torch.futures 解耦「梯度就绪」与「参数更新」。
- 写计时单测,CI 断言重叠路径耗时接近 max 而非 sum。
- 评估 CPU/GPU 速度比,CPU 过慢时重叠收益有限,需调整卸载比例。
十、小结
SuperOffload is unable to overlap the CPU (param update) and GPU (backward)是一个重叠失效 bug:本应「CPU 更新参数」与「GPU 反向」流水线并行,却因实现里存在错误的全局同步点(反向后 overall synchronize 再更新)、梯度未分块尽早搬运、CPU↔GPU 拷贝用同步方式,导致两者退化为串行,预期加速落空。
修复分三层:第一层,移除全局同步点、用non_blocking=True+ 独立 stream 异步拷贝救急;第二层,用 CUDA event /torch.futures把「梯度就绪」与「参数更新」解耦,实现逐层流水线重叠;第三层,用 nsys/事件计时实测重叠是否真发生 + 性能回归 CI。记住:重叠的前提是「无整体同步 + 异步拷贝 + 不同 stream」,任何 overall synchronize 都会让 GPU 与 CPU 退化为串行——是否真重叠,要用 timeline 实测而非只看代码。