Prhub

#50723 [Core][RL] Support sparse checkpoint updates through native weight loaders

原始 PR 作者 ShuoleiWang 合并时间 2026-08-23 01:39 文件变更 2 提交数 4 评论 7 代码增减 +383 / -0

执行摘要

模型加载器支持 checkpoint 坐标稀疏权重更新,RL 同步提速约 7 倍

PR body 明确指出:在线 RL trainer 通常产出 checkpoint/Hugging Face 坐标系下的更新(checkpoint 名 + 完整形状 + 稠密张量或展平变更索引与值),这与 vLLM 运行时参数坐标不一致——checkpoint 张量可能被 TP 切分、与另一张量打包、或映射进 expert-parallel 运行时权重;而现有 sparse_nccl 后端要求调用方已解析出运行时参数名与 rank 本地索引,无法充当通用桥。因此本 PR 新增一个小型模型加载器 API,让模型自身的 load_weights() 继续承担 checkpoint 名映射、TP/EP 放置与打包布局,以“原生 loader 即桥”的方式打通两种坐标系。

值得精读。这是一个设计文档化程度很高的 API 型 PR:用 NaN 哨兵 + 进程级替换 torch.Tensor.copy_ 复用原生 load_weights,在不动现有 loader 的前提下打通 checkpoint 坐标与运行时坐标;同时把支持边界(单 copy_ 契约、fail-closed、非事务性、非 O(nnz))写进 docstring 与 PR body,可作为“为通用机制做有界支持”的范例。若要采纳到生产,建议补充覆盖 loader 内部多次 copy_(如转置、分片后拷贝)的测试,并评估与未来多线程 weight-sync 的互斥。

讨论亮点

Review 整体很轻量。唯一实质讨论是 Isotr0py 对类命名的建议:把 CheckpointWeightPatch 改名为 CheckpointWeightMetadata(“I think metadata will be more proper here”),建议代码块同时给出 NamedTuple 写法(当时实现还是 @dataclass(frozen=True))。作者回复 Done,从提交历史看采纳了 NamedTuple 表示(提交 6fdf47e「Represent checkpoint weight patches as NamedTuple」),但最终合并版本仍保留 CheckpointWeightPatch 名称,命名建议未完全落实,属于 API 表面层的轻微取舍。此外 claude[bot] 提示 fork PR 自动评审被禁用,PR body 披露使用 OpenAI Codex 辅助实现与测试。

实现拆解

  1. 数据契约:新增 checkpoint_weight_patch.py,定义 CheckpointWeightPatch(NamedTuple),含 name(checkpoint 权重名)、shape(完整 checkpoint 张量形状)、dtype、values 与 indices 五字段;indices=None 表示稠密整体替换,否则为稀疏补丁,索引指向展平的完整 checkpoint 张量。
  2. 加载前强校验:_validate_patch_structure 校验非空名称、非负维度、1D values、稠密值数量与 numel 一致;稀疏补丁要求浮点 dtype(NaN 是哨兵,不能作为真实更新值)、int32/int64 索引、索引与值等长。主入口再按设备聚合 isnan、越界索引与重复索引检查,一次 .item() 同步完成整批校验,避免每个补丁一次 CPU 同步;校验通过前不触发任何写入。
  3. NaN 掩膜与 copy_ 拦截:稀疏补丁先展开为完整 checkpoint 形状的 NaN 张量(torch.full + index_copy_ 填充变更位置),_load_nan_masked_weights 在 model.load_weights 调用窗口内进程级替换 torch.Tensor.copy_ 为 copy_non_nan_,用 torch.where(isnan(source), destination, source, out=destination) 在最终同形状浮点拷贝处原地合并变更值,finally 恢复原始 copy_;不满足契约的拷贝(中间态、非浮点、形状不同)直接抛 NotImplementedError,fail-closed。
  4. 分批与保序:load_checkpoint_weight_patches 以 max_chunk_bytes(默认 512 << 20,512 MiB)为目标分批调用 load_weights;同名补丁强制开启新批次,保证同一权重多次补丁严格按输入顺序应用;返回所有 loader 报告的权重名并集,None 视为空集合。
  5. 测试配套:新增 test_checkpoint_weight_patch.py(CPU 测试),_PackedTPModel 模拟 Q/K 打包进同一参数并做 TP rank 1 切片,覆盖稠密种子、全局稀疏索引、TP 本地切片、打包目标、重复补丁顺序与 copy_ 恢复;NoLocalWeightModel 覆盖 loader 未写入任何权重的空结果路径。仓库内仅此 1 个聚焦测试,TP8+EP8 位精确对比与双节点性能验证在配套 VERL 集成中完成。
文件 模块 状态 重要度
vllm/model_executor/model_loader/checkpoint_weight_patch.py 模型加载 added 9.08
tests/model_executor/model_loader/test_checkpoint_weight_patch.py 加载测试 added 7.12

关键符号

load_checkpoint_weight_patches _load_nan_masked_weights copy_non_nan_ _validate_patch_structure _load_weight_chunk test_dense_and_sparse_patches_follow_packed_tp_loader

关键源码片段

vllm/model_executor/model_loader/checkpoint_weight_patch.py data-contract

本 PR 核心新增文件:定义 checkpoint 坐标补丁的数据契约与主入口,用 NaN 哨兵 + 临时替换 torch.Tensor.copy_ 的方式复用模型原生 load_weights,作为 checkpoint 坐标系与 vLLM 运行时参数坐标系之间的通用桥,并明确 fail-closed 契约与非事务性语义。

# vllm/model_executor/model_loader/checkpoint_weight_patch.py(新增)
​
class CheckpointWeightPatch(NamedTuple):
    """描述一次 checkpoint 坐标系下的稠密或稀疏权重更新。
​
    indices 为 None 时是稠密替换:values 为完整展平后的 checkpoint 张量;
    否则是稀疏补丁:values 只含 indices 位置的新值。
    vLLM 运行时参数坐标(TP 切片、打包、EP 映射)由模型原生 load_weights 负责。
    """
​
    name: str # 传给模型 loader 的 checkpoint 权重名
    shape: tuple[int, ...] # 完整 checkpoint 张量形状
    dtype: torch.dtype # checkpoint 张量 dtype
    values: torch.Tensor # 稠密:完整展平值;稀疏:indices 处的新值
    indices: torch.Tensor | None = None # 展平索引;None 表示稠密替换
​
​
def _load_nan_masked_weights(model, weights):
    # 保存原始 copy_,load_weights 返回或抛异常后都恢复,避免污染进程内其他拷贝
    original_copy = torch.Tensor.copy_
​
    # 拦截器:源为 NaN 的位置保留目标原值,其余位置写入源值。
    # NaN 是“保持不变”的哨兵,这就是稀疏补丁只改部分位置的原理。
    def copy_non_nan_(destination, source, non_blocking=False):
        # 只支持同形状浮点张量的最终 copy_;其余情况 fail-closed,
        # 防止组合 / 多阶段 loader 在未预期路径上悄悄产生错误语义
        if not (
            destination.is_floating_point()
            and source.is_floating_point()
            and destination.shape == source.shape
        ):
            raise NotImplementedError(
                "Sparse checkpoint patches require a same-shaped "
                "floating-point final copy"
            )
        source = source.to(dtype=destination.dtype, device=destination.device)
        # 直接原地合并进运行时目标张量,避免额外一次拷贝
        return torch.where(torch.isnan(source), destination, source, out=destination)
​
    # 在模型原生 loader 调用窗口内,进程级替换 Tensor.copy_
    torch.Tensor.copy_ = copy_non_nan_
    try:
        return model.load_weights(weights)
    finally:
        torch.Tensor.copy_ = original_copy
tests/model_executor/model_loader/test_checkpoint_weight_patch.py test-coverage

仓库内唯一的聚焦测试:用打包 TP 模型验证稠密种子、全局稀疏索引、TP 本地切片、打包目标、重复补丁顺序与 copy_ 恢复等契约行为,并覆盖 loader 未写入任何权重的空结果路径。TP8+EP8 级正确性验证在配套 VERL 集成中。

# tests/model_executor/model_loader/test_checkpoint_weight_patch.py(节选)
​
class _PackedTPModel(torch.nn.Module):
    """模拟把 TP rank 1 的 Q、K 分片打包进同一个参数的模型。"""
​
    def __init__(self):
        super().__init__()
        self.packed_weight = torch.nn.Parameter(torch.full((4,), -1.0))
        self.load_calls: list[list[str]] = []
​
    def load_weights(self, weights):
        weights = list(weights)
        self.load_calls.append([name for name, _ in weights])
        loaded_names = set()
        for name, loaded_weight in weights:
            if name == "q_proj.weight":
                destination = self.packed_weight.data[:2] # Q 占打包参数前半
            elif name == "k_proj.weight":
                destination = self.packed_weight.data[2:] # K 占打包参数后半
            else:
                raise AssertionError(name)
            # 每个完整 checkpoint 张量先 narrow 出 TP rank 1 的分片再拷贝,
            # 最终 copy_ 仍是同形状浮点拷贝,满足稀疏补丁的契约
            destination.copy_(loaded_weight.narrow(0, 2, 2))
            loaded_names.add(name)
        return loaded_names

评论区精华

CheckpointWeightPatch 命名与表示形式建议 设计

Isotr0py 建议将类名改为 CheckpointWeightMetadata,理由是 metadata 更贴切,建议代码块同时给出了 NamedTuple 写法(当时实现为 @dataclass(frozen=True);作者回复 Done)。

结论:作者采纳了 NamedTuple 表示(提交 6fdf47e),但最终合并版本仍保留 CheckpointWeightPatch 名称;命名建议未完全落实,属 API 命名层面的轻微取舍,不影响语义。 · 已解决(部分采纳)

风险与影响

  1. 全局副作用:稀疏加载窗口内 torch.Tensor.copy_ 被进程级替换,任何并发线程的拷贝都会被改写语义;当前假设串行、独占的权重更新,若未来引入并发 weight-sync 需要互斥。
  2. loader 契约假设:只支持最终同形状浮点 Tensor.copy_ 写入;组合/多阶段 loader、自定义写路径会抛 NotImplementedError。现有模型 loader 未逐一审计,实际兼容面未知,存在接入失败风险。
  3. 非事务性:loader 中途失败会留下部分更新,PR 明示调用方必须恢复基线或重启再 seed,生产接入需配套恢复机制。
  4. 内存与算力成本:稀疏路径非 O(nnz),每个补丁分配完整 checkpoint 形状 NaN 张量并跑稠密 torch.where;30B 规模实测单次稀疏负载约 1129.4 MiB。
  5. 测试覆盖:仓库内仅 1 个 CPU 单测;TP8+EP8、双节点、CUDA IPC/ZMQ 等关键正确性证据全部位于外部 VERL 集成,未进入 vLLM CI。

对用户/系统:RL 训练系统(VERL 的 delta_sharded wire format)获得 vLLM 侧消费能力,稳态权重同步约 7.2 倍提速(51.8 s → 7.2 s),且无需调用方自行解析 TP/EP 映射。对团队:新增公共 API 与明确的 loader 契约约束,未来新增或改造模型 loader 时需保证最终写入为同形状浮点 copy_ 才能支持稀疏补丁;monkey-patch 全局方法带来隐性约束,需要文档化并保持加载路径串行。对合并影响面:仅新增 2 个文件、383 行,无任何现有文件改动,回归面小。

进程级替换 torch.Tensor.copy_ 非事务性更新 稀疏路径非 O(nnz) 依赖单次同形状浮点 copy_ 契约 仓库内测试覆盖有限

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论