Prhub

#1793 feat(optimizer): NVMe optimizer-state streaming as a miles plugin

原始 PR 作者 yueming-yuan 合并时间 2026-08-01 13:57 文件变更 9 提交数 9 评论 1 代码增减 +872 / -10

执行摘要

NVMe 优化器状态流式插件,step 峰值显存降约 52%

PR body 明确指出:--offload-train-target=disk(#1575)只能在阶段边界 spill 暂停的 actor,"That cannot help when the optimizer state does not fit the GPU while the step runs — by the time the Adam kernel launches, everything is resident again"。当优化器状态在 step 运行期间就放不下 GPU 时,offload 无能为力;streaming 才是解法。该机制先在 Megatron-LM#63 完成验证(GLM-5.2 744B 8xGB300、Qwen3.5-35B-A3B 8xH200),本 PR 把它移入 miles 并让 Megatron 侧只剩两个 checkpoint 钩子。

值得精读。该 PR 展示了跨仓库功能下沉的完整工程范式:契约方法收敛、启动期守卫替代运行期故障、以及“以 assert 把静默错误变成响亮错误”的防御思路。尤其推荐关注 _Stager.transfer 的跨 dtype 设备中转设计、plan_buckets 与 DDP 解耦的动机,以及 actor_factory 对 torch_memory_saver 二进制的字节级检查——这类细节是大型 RL 训练系统稳定性的关键。

讨论亮点

核心讨论集中在三点:

  • 合并顺序:Zhichenzzz 在 PR 上留言 “I will retarget this PR to main and merge #1575 first”,先落基础 PR 再合本 PR,最终按此执行。
  • 功能扩展建议:Zhichenzzz 在 approve 时提问 “qq: could we also add the cpu-offload streaming as the choice?”,即是否把流式优化器状态也做成 CPU 备份选项;该建议未在本 PR 中实现,当前设计强制与磁盘 offload 成对部署。
  • 非侵入性确认:fzyzcjy 受邀 review,表示 “not check in detail, but looks not invasive and thus lgtm”。

实现拆解

  1. 实现迁移与实例绑定:将 Megatron-LM#63 的 megatron/core/optimizer/nvme_state_store.py 迁入 miles_plugins/optimizers/nvme_stream.py(+522 行),并把 DistributedOptimizer 内五处 if self._nvme_state_store is not None 分支改为 _bind() 时的实例绑定:step_with_ready_grads 绑到 store.step()(含参数 all-gather 尾部)、reload_model_params 路由到 store.refresh_main_from_model_paramsstate_dict/load_state_dict/sharded_state_dict 返回空,由 save_to/load_from 承载真实字节。miles 侧在 miles/backends/megatron_utils/model.pysetup_model_and_optimizer 中通过两个 lazy import 完成装配,Megatron 侧最终只保留 checkpointing.py 里的一对钩子。
  2. 参数面与静默失效防御:在 miles/utils/arguments.py 新增 --stream-optimizer-state-to-disk--stream-optimizer-state-moment-dtype(默认 fp32,可选 bf16/fp16/fp8e4m3/fp8e5m2,fp8 不推荐),并复用 --offload-train-disk-dir/--offload-train-disk-chunk-mb(两机制各自取子目录)。校验块点名拒绝所有会读到空 storage/空 state 的开关:reset_optimizer_statessave_local_weight_checksumenable_witness,以及互斥项 optimizer_cpu_offloadoffload_optimizer_states、precision-aware optimizer、单 LoRA 与 multi-LoRA、--indep-dp(rank 目录跨 cell 冲突),并强制要求 --offload-train-target=disk
  3. 流式存储核心NVMeOptimizerStateStore 按 DDP bucket 聚合 entry,再用 plan_buckets 按 200M numel 上限切分,与 DDP 大 bucket 解耦;每个 _Bucket 拥有独立文件(main/exp_avg/exp_avg_sq 三段按 4096 对齐)、独立克隆的 FusedAdam 与 I/O 偏移表。step() 逐个 bucket 执行“物化 storage -> 读入 -> 逐 bucket Adam 更新 -> 更新后的主参数拷回参数缓冲 -> 写回 -> storage 释放”。首次 fetch 只读 main,moment 由 FusedAdam 惰性创建后再落盘;_Stager 用固定大小 pinned 缓冲中转,跨 dtype 拷贝先在设备侧转换(bf16 约快 40 倍、fp8 约快 100 倍)。目录启动时清除、退出时 atexit 回收。
  4. 正确性护栏与配套miles/ray/train/actor_factory.py 对 LD_PRELOAD 的 torch_memory_saver 二进制做字节级断言(必须含 TMS_INIT_ENABLE_DISK_BACKUP),防止旧版二进制忽略 disk 变量、pause 后无备份、resume 返回未初始化内存,最终在 rollout 中表现成 NaN/grad-norm 故障。新增 tests/e2e/megatron/test_qwen3_4B_offload_disk_stream.py,除完整 colocate RL 循环外,还逐 rank 断言 worker 日志出现 NVMe streaming step:,阻止“开关丢失但训练照常完成”的假阳性;tests/ci/labels.py 新增 miles-plugin 标签,LoRA 测试夹具补 stream_optimizer_state_to_disk=Falsedocs/advanced/disk-offload.md 新增 streaming 章节(含同拓扑 resume、同步 checkpoint、fp8 不推荐等限制)。
文件 模块 状态 重要度
miles_plugins/optimizers/nvme_stream.py 优化器插件 added 8.89
tests/e2e/megatron/test_qwen3_4B_offload_disk_stream.py E2E 测试 added 7.37
miles/utils/arguments.py 参数校验 modified 6.81
miles/backends/megatron_utils/model.py 模型装配 modified 5.56
miles/ray/train/actor_factory.py Actor 工厂 modified 5.13
miles_plugins/optimizers/__init__.py 插件入口 added 3.95
tests/fast/backends/megatron_utils/test_lora_model_branches.py LoRA 测试 modified 3.49
docs/advanced/disk-offload.md 高级文档 modified 3.43
tests/ci/labels.py CI 标签 modified 2.88

关键符号

setup_optimizer_state_streaming NVMeOptimizerStateStore.__init__ NVMeOptimizerStateStore.step NVMeOptimizerStateStore.refresh_main_from_model_params NVMeOptimizerStateStore.save_to NVMeOptimizerStateStore.load_from _Bucket.fetch _Bucket.flush _Bucket.materialize_main _Bucket.allocate_moments _Stager.transfer plan_buckets _rw_full _allocate_file _build_fp32_optimizer _copy_main_to_model_params

关键源码片段

miles_plugins/optimizers/nvme_stream.py dependency-wiring

本 PR 的核心:NVMe 优化器状态流式存储的完整实现,包含 _Stager 中转、_Bucket 生命周期、checkpoint 四方法契约与所有构造守卫。

# NVMe 优化器状态流式:fp32 主参数与 Adam 动量按 bucket 存于节点本地文件。
# step() 逐个 bucket 处理:物化 storage -> 读入 -> 逐 bucket FusedAdam 更新 ->
# 更新后的主参数拷回参数缓冲 -> 写回 -> storage 释放,峰值驻留被限在一个 bucket 内。class _Stager:
    # 固定大小 pinned 主机缓冲作为 GPU <-> NVMe 中转站,
    # 无论一次搬移多少数据,主机内存占用都有上界。
    def __init__(self, nbytes: int):
        self._buf = torch.empty(nbytes, dtype=torch.uint8, pin_memory=True)
        self._bytes = self._buf.numpy()
        self._device_buf = None
​
    def _device_staging(self, dtype, numel, like):
        # 跨 dtype 的 GPU <-> pinned 主机拷贝不会走 DMA 路径;
        # 先在设备侧完成转换、再搬运同 dtype 字节,bf16 约快 40 倍、fp8 约快 100 倍。
        size = self._buf.numel()
        if self._device_buf is None or self._device_buf.device != like.device:
            self._device_buf = torch.empty(size, dtype=torch.uint8, device=like.device)
        return self._device_buf[: numel * dtype.itemsize].view(dtype)
​
    def transfer(self, fd, offset, tensor, dtype, *, to_disk):
        flat = tensor.view(-1)
        cast = dtype != flat.dtype
        chunk = self._buf.numel() // dtype.itemsize
        pos = 0
        while pos < flat.numel():
            numel = min(chunk, flat.numel() - pos)
            host = self._buf[: numel * dtype.itemsize].view(dtype)
            at = offset + pos * dtype.itemsize
            nbytes = numel * dtype.itemsize
            if to_disk:
                # 写盘:必要时先在设备侧转换 dtype,再经 pinned 缓冲 pwritev 落盘
                if cast:
                    staged = self._device_staging(dtype, numel, flat)
                    staged.copy_(flat[pos:pos + numel])
                    host.copy_(staged)
                else:
                    host.copy_(flat[pos:pos + numel])
                _rw_full(os.pwritev, fd, at, self._bytes[:nbytes])
            else:
                # 读回:preadv 到 pinned 缓冲,再拷回 GPU(同样先转换 dtype)
                _rw_full(os.preadv, fd, at, self._bytes[:nbytes])
                if cast:
                    staged = self._device_staging(dtype, numel, flat)
                    staged.copy_(host)
                    flat[pos:pos + numel].copy_(staged)
                else:
                    flat[pos:pos + numel].copy_(host)
            pos += numel
        return flat.numel() * dtype.itemsize
​
​
class _Bucket:
    # 一个 bucket 是流式的最小工作单元:一个文件、一段依次排布的段布局
    # (main / exp_avg / exp_avg_sq),以及为它独立克隆的 FusedAdam 实例。
​
    def fetch(self) -> int:
        # 首次 fetch 只读 main:moment 由 FusedAdam 在 step 1 惰性创建,
        # 未就绪前文件中还没有 exp_avg / exp_avg_sq 段的数据。
        return self._move(SEGMENTS if self.moments_ready else SEGMENTS[:1], to_disk=False)
​
    def flush(self, segments=SEGMENTS) -> int:
        # 写回后立即把 storage 释放为 0 大小,GPU 驻留回到只占一个 bucket 的量级。
        moved = self._move(segments, to_disk=True)
        self.moments_ready = self.moments_ready or tuple(segments) == SEGMENTS
        return moved
​
    def _move(self, segments, *, to_disk):
        # 上载(读回)前先为 storage 分配空间;卸载(写盘)后调用 _resize 释放。
        # 跨 dtype 场景(如 bf16 moment)由 _Stager.transfer 内部的设备中转完成转换。
        moved = 0
        for segment in segments:
            for tensor, offset in self._tensors(segment):
                if not to_disk:
                    _resize(tensor, tensor.numel())
                moved += self._stager.transfer(
                    self.fd, offset, tensor, self.dtypes[segment], to_disk=to_disk
                )
                if to_disk:
                    _resize(tensor, 0)
        return moved
tests/e2e/megatron/test_qwen3_4B_offload_disk_stream.py test-coverage

新增 E2E 测试:验证流式与磁盘 offload 组合在真实 colocate RL 循环中工作,并逐 rank 断言流式真的发生,防静默失效。

# —— E2E 断言:streaming 必须真的发生,而不是静默未启用 ——
# 跑完训练只是半个检查:如果开关在传向 actor 途中丢失、或插件导入失败被吞掉,
# 训练照样能完成。因此这里逐 rank 核对 worker 日志中是否出现真实的
# "NVMe streaming step:" 记录,缺一个 rank 就判失败。
def _assert_streamed():
    logs = glob.glob("/tmp/ray/session_latest/logs/worker-*")
    assert logs, "no Ray worker logs to check for the streaming path"
​
    streamed = set()
    for path in logs:
        with open(path, errors="ignore") as f:
            if any("NVMe streaming step:" in line for line in f):
                streamed.add(path)
​
    assert len(streamed) == NUM_GPUS, (
        f"expected {NUM_GPUS} ranks to log streaming steps, saw {len(streamed)}"
    )
    print(f"optimizer state streaming ran on {len(streamed)} ranks")
miles/utils/arguments.py dependency-wiring

参数面接入点:新增两个 switch、扩展两个既有磁盘参数语义,并集中书写整套互斥与前置条件断言,是静默失效防御的主战场。

# —— 参数校验:streaming 的静默失效防御 ——
# 流式开启后,主参数 storage 会在 step 之间被释放为 0,master optimizer 的
# 状态也由 store 持有;所有直接读这些位置的开关都必须在这里被点名拒绝,
# 否则它们会读到空值而静默返回,而不是报错。
if args.stream_optimizer_state_to_disk:
    # 只与 --offload-train-target=disk 成对部署:两者回答同一个压力
    assert args.offload_train_target == "disk", (
        "--stream-optimizer-state-to-disk requires --offload-train-target=disk"
    )
    assert args.use_distributed_optimizer, "requires the distributed optimizer"
    assert args.optimizer == "adam", "--optimizer adam required"
    assert not (args.multi_lora or is_lora_enabled(args)), (
        "LoRA checkpoint 路径绕过 Megatron 的 save/load, store 的 save_to/load_from 不会触发"
    )
    assert not args.optimizer_cpu_offload, "mutually exclusive with CPU offload"
    assert not args.offload_optimizer_states, "mutually exclusive with --offload-optimizer-states"
    assert not args.use_precision_aware_optimizer, "fp32 main params must be held by mcore"
    assert not args.reset_optimizer_states, "reset would silently do nothing"
    assert not args.save_local_weight_checksum, "param.main_param storage is resized to 0"
    assert not args.enable_witness, "witness reads the master optimizer per-param state"

评论区精华

合并顺序与 base 分支调整 other

Zhichenzzz 在 PR 上评论:'I will retarget this PR to main and merge #1575 first',即先合并基础 PR #1575,再将本 PR 重定基到 main。

结论:按该计划执行,最终以 main 为 base 完成合并。 · 已解决

是否补充 CPU offload 流式选项 设计

Zhichenzzz 在 approve 时提问:'qq: could we also add the cpu-offload streaming as the choice?'——即是否把流式优化器状态也做成 CPU 备份的选项,而不只绑定磁盘 offload。

结论:PR 中未新增该选项;当前设计强制 --stream-optimizer-state-to-disk 依赖 --offload-train-target=disk,CPU 流式未被实现,属于可能的后续扩展。 · unresolved

非侵入性确认 other

fzyzcjy 受 Zhichenzzz 邀请 review:'not check in detail, but looks not invasive and thus lgtm'。

结论:通过,未提出具体修改意见。 · 已解决

风险与影响

  • 跨仓库契约风险:_copy_main_to_model_params 以 commit+行区间注释锁定 Megatron 侧实现,Megatron 升级后可能漂移;代码里 assert world_range.size == shard_main_param.nelement() 作为 tripwire,但若上游重构导致该断言被绕过,fp8 权重更新可能被跳过且无报错。
  • 回归风险:store 会 _resize(main_param, 0) 释放 storage,任何未纳入守卫的路径(如未来新增的 main_param 读取者)都会静默读到空值;本 PR 只能覆盖当下已知的 reset_optimizer_statessave_local_weight_checksumenable_witness
  • 性能风险:步进时间明显变长,8xH200 实测 actor_train +39%(bf16 moments),checkpoint save 是同步的、绕过 --async-save,保存大模型时阻塞时长不可忽略。
  • 兼容性风险:同拓扑 resume only,改变 TP/PP/DP/EP 会直接 layout assert 失败;开启流式后无法恢复启用流式之前写的 checkpoint(需 --no-load-optim 接受全新优化器状态)。
  • 环境风险:--indep-dp 下 rank 目录没有 cell 命名空间,两个 cell 共享路径会互相清除 bucket 文件,已在启动期拒绝;torch_memory_saver 旧二进制无 disk 后端时会在 actor_factory 启动即失败,而不是 mid-run 才暴露。
  • 用户影响:面向 DP=1 大模型 RL 训练(如 GLM-5.2 744B 8xGB300),流式开启后峰值显存大幅下降(Qwen3-30B-A3B 实测 -42.3 GB/-52%),默认 fp32 位等价保证结果不变,bf16 是显式的性能 opt-in。
  • 系统影响:新增 miles_plugins/optimizers 插件目录与 miles-plugin CI 标签;与 Megatron-LM#63 形成四方法 duck-typed 契约,Megatron 侧代码缩减到 +19 行;miles/utils/arguments.py 的互斥校验成为新功能的准入清单。
  • 团队影响:确立了 miles 与上游 Megatron 的协作模式(pinned commit 注释 + 断言 tripwire + 显式契约方法),后续任何触碰 optimizer state 的新特性都需对照此清单补齐守卫。
核心优化器路径变更 跨仓库契约依赖 同拓扑 resume 限制 step 耗时增加 checkpoint save 同步阻塞 fp8 存储不受推荐

关联 Issue

#80 feat: disk backup backend, spill paused allocations to node-local disk
#63 feat(optimizer): NVMe streaming of DistributedOptimizer state
#32385 fix: zero-init fp32 UE8M0 activation scales in the masked MoE down-GEMM

完整报告

参与讨论