NVMe 卸载(ZeRO-Infinity):DeepSpeed 的“无限内存”黑科技

笔者的github:https://github.com/shizhengLi/deepspeed-learning

嘿,朋友!如果你对大模型训练的内存问题纠结已久(比如 ZeRO Stage 3 还不够,训 1T 参数模型时 GPU 爆满),那 ZeRO-Infinity(简称 ZeRO-∞)就是你的终极解药。它是 DeepSpeed 的扩展技术,用 NVMe SSD(高速固态硬盘)作为“外肾”存储,把模型参数、梯度、优化器状态“卸载”(offload)到磁盘上。简单说:GPU 内存不够?扔到硬盘!实现“近乎无限模型大小”,让单节点/小集群训万亿参数模型成为现实。

别慌,我从零基础开始,用生活比喻+原理+代码+例子一步步拆给你听。读完,你就懂怎么用它“解放” GPU 了。基于 DeepSpeed 2025 更新版文档,这技术已成熟,支持 Ascend NPU 等硬件。

1. 什么是 NVMe 卸载?为什么需要它?
  • NVMe 基础:NVMe(Non-Volatile Memory Express)是 SSD 的高速协议,像“闪电硬盘”——读写速度 3-7GB/s(比 CPU RAM 慢 10-100x,但比传统 HDD 快 10x)。DeepSpeed 用它存大块数据,避免 GPU OOM。
  • ZeRO-Infinity 是什么? ZeRO Stage 3 的“无限版”:Stage 3 只用 GPU 内存分区参数(O(ψ/N),ψ=参数大小)。Infinity 加 NVMe offload:GPU 只临时拉取计算的部分,闲时全扔硬盘。结果?模型大小不受 GPU 内存限(e.g., 8x A100 训 1T 模型,只需 40GB GPU + 几 TB NVMe)。

痛点:Stage 3 极限 ≈ ψ/N(N=8 时 1/8),但 ψ=1T 参数 ≈4TB,单 A100 (80GB) 还 OOM。Infinity:GPU 内存 O(激活值 + 临时参数) ≈10GB,硬盘扛大头。

比喻:GPU 像小书桌(80GB),模型参数像 100 本大百科(4TB)。Stage 3 是“分书到多桌”(8 桌各 12 本)。Infinity 是“书放仓库(NVMe),用时快递取回”——桌子上只放当前看的 1 本,仓库无限大。

益处:内存“无限”(硬盘 TB 级),速度损失 <20%(异步 IO 重叠)。缺点:需高速 NVMe(PCIe 4.0+),小模型 overhead 高。

2. 核心原理:异步 Swap In/Out + 零冗余分区

ZeRO-Infinity 结合 ZeRO-3 的分区 + NVMe 异步 IO:

  1. 分区:像 Stage 3,参数/梯度/优化器切 N 份(N=GPU 数),每个 GPU 管 1/N。
  2. 卸载(Swap Out):闲时(e.g., forward 后),异步写到 NVMe(不阻塞计算)。
  3. 拉取(Swap In):需时(e.g., 下一层 forward),异步读回 GPU 缓冲区,计算完释放。
  4. 协调:用“参数映射”(param → NVMe 路径),异步 IO 库(asyncio 或 libaio)管理读写队列,重叠通信/计算。

算法流程(伪代码):

初始化:分区参数 → 写 NVMe(swap_out 全参数)
Forward:预测下一层 → swap_in(下一分区) → 计算 → swap_out(当前)
Backward:swap_in(梯度分区) → reduce-scatter → swap_out
Step:swap_in(优化器分区) → 更新 → scatter 同步到 NVMe
  • 异步关键:用 callback(回调)或 future(asyncio),读写不等(e.g., 读时继续算前层)。
  • 零冗余:硬盘存分区副本(非全复制),总存储 O(ψ),不冗余。
  • 优化:分块(chunking)小参数打包,压缩(1-bit),优先级队列(热点参数常驻 GPU)。

内存节省:GPU 只需 O(B + 层ψ/N)(B=激活),硬盘 O(ψ)。基准:1T 模型,8 GPU + 16TB NVMe,训练时间 ≈ Stage 3 的 1.2x。

3. 代码详解:NVMeOffloadOptimizer 简化实现

你提供的代码是个伪代码示例,展示了核心“swap”机制。实际 DeepSpeed 用 C++/Python 混合,集成 libnvme。咱们逐行拆:

import asyncio  # 异步 IO
import torch  # GPU tensor

class NVMeOffloadOptimizer:
    def __init__(self, optimizer, config):
        self.aio_handler = AsyncIOHandler(config)  # 异步 IO 处理器(管理 NVMe 读写队列)
        self.param_map = {}  # 参数到 NVMe 路径的映射 {param_id: '/nvme/param_123.bin'}
        # 初始化:分区并全 swap_out 到 NVMe
        self._init_offload(optimizer)
        
    def _init_offload(self, optimizer):
        for param in optimizer.param_groups[0]['params']:  # 遍历参数
            path = f"/nvme/zero_inf/param_{id(param)}.bin"  # 路径映射
            self.param_map[id(param)] = path
            self.swap_out([param])  # 初始全卸载
        
    def swap_out(self, params):
        # 异步将参数写入 NVMe(不阻塞)
        futures = []
        for param in params:
            path = self._get_nvme_path(param)  # 从 map 取路径
            future = self.aio_handler.write_async(param.data.cpu().numpy(), path)  # CPU 转 numpy 写盘
            futures.append(future)
            param.data = None  # GPU 释放
        asyncio.gather(*futures)  # 等待全写完(异步)
        
    def swap_in(self, params):
        # 异步从 NVMe 读取参数
        futures = []
        for param in params:
            path = self._get_nvme_path(param)
            buffer = self._get_buffer(param.shape)  # 分配 GPU 缓冲 (torch.empty_like(param))
            future = self.aio_handler.read_async(path, buffer, callback=self._swap_in_callback)
            futures.append(future)
        asyncio.gather(*futures)  # 拉回 GPU
        
    def _swap_in_callback(self, buffer, param):
        param.data = buffer.to('cuda')  # 回调:填回 tensor,ready for 计算
        
    def _get_nvme_path(self, param):
        return self.param_map[id(param)]  # 路径查询
    
    def _get_buffer(self, shape):
        return torch.empty(shape, dtype=torch.float32, device='cuda')  # 临时缓冲

# AsyncIOHandler 伪实现(实际用 libaio 或 DeepSpeed C++ 绑定)
class AsyncIOHandler:
    def __init__(self, config):
        self.nvme_device = config['nvme_path']  # e.g., '/dev/nvme0n1'
    
    async def write_async(self, data, path):
        # 异步写(asyncio + aiofiles 或 mmap)
        with open(path, 'wb') as f:
            await asyncio.to_thread(f.write, data.tobytes())  # 非阻塞写
    
    async def read_async(self, path, buffer, callback):
        # 异步读
        data = await asyncio.to_thread(open(path, 'rb').read)
        buffer.copy_(torch.from_numpy(np.frombuffer(data, dtype=np.float32)))
        callback(buffer, buffer)  # 简化回调

关键解析

  • swap_out:参数闲时“打包”写 NVMe(numpy 序列化),GPU data=None 省内存。
  • swap_in:需时“快递”读回缓冲,回调填 tensor。异步 gather 确保不等。
  • 实际用:DeepSpeed config:{"zero_optimization": {"stage": 3, "offload_param": {"device": "nvme", "nvme_path": "/dev/nvme0n1", "buffer_size": "2GB"}}}。一键开!

运行示例:训 70B 模型,forward Layer 1 后 swap_out(Layer1),swap_in(Layer2)。IO 时间 <1ms/GB(NVMe 快)。

4. 具体例子:8 GPU 训 500B 模型

假设 Llama-500B(ψ≈2TB)。无 Infinity:8x A100 (640GB 总) OOM。

  • 配置:Stage 3 + NVMe offload,硬盘 4TB NVMe。
  • 流程
    1. Init:分区 500B /8 =62.5B/GPU,全 swap_out 到 NVMe(路径 /nvme/p0.bin ~ p7.bin)。
    2. Forward:GPU0 swap_in(p0 分区,≈8GB 临时),算 Layer1,swap_out;并行 swap_in(p1 for Layer2)。
    3. Backward:swap_in 全梯度分区,reduce-scatter(本地留 8GB),swap_out。
    4. Step:类似,优化后 scatter 新参数到 NVMe。
  • 结果:GPU 峰 15GB(激活+临时),硬盘读写 1TB/epoch。时间:≈ Stage 3 的 1.15x(IO overhead),MFU 50%+。
  • 基准(DeepSpeed 2025):MT-NLG 530B 模型,8 GPU + NVMe,训 1 epoch 需 2 小时(vs. 512 GPU 无 offload 的 4 小时)。
5. 小结 & 入门建议

ZeRO-Infinity 让“内存无限”从科幻变现实:GPU 专注计算,NVMe 当仓库。适合超大模型 finetune/RLHF(如 VERL PPO)。入门:DeepSpeed GitHub 跑 demo(deepspeed --num_gpus=1 examples/nvme_offload.py),配 PCIe 4.0 NVMe。

后记

2025年10月24日在grok 4 fast辅助下完成。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐