Delta Weight Sync:当 RL 的权重同步从硬件问题变成软件问题

TL;DR

在 LLM 的 RL post-training 中,trainer 每完成一个 optimizer step,就需要把更新后的权重同步给负责 rollout 的 inference engine。在解耦(disaggregated)架构下,这一步长期被视为必须依赖高带宽 RDMA 的环节——同步开销随模型规模线性增长,并主导整个 sync 阶段。

2026 年上半年,学术界、工业界与开源社区几乎同时给出了同一个观察并加以利用:在典型的 RL 学习率下,每个 step 之后真正发生变化的权重只占很小一部分(在 BF16 表示下,超过 99% 的元素逐字节未变)。因此只传输变化的部分(delta),即可将通信量降低约两个数量级,且重建无损、bit-identical。这把权重同步从一个依赖 RDMA 网络的硬件问题,转化为一个如何编码稀疏 delta 的软件问题——使得 RL 训练能够运行在普通以太网,甚至跨数据中心的共享存储之上。

本文先说明这一观察的来源与依据,再以 slime 的实现为完整实例,拆解要落地 delta weight sync 需要在系统的哪些环节进行改动。


1. 背景:weight sync 为什么是瓶颈

现代 RL 框架普遍将 trainer(Megatron / FSDP)与 rollout/inference engine(SGLang / vLLM)解耦:两者采用不同的并行策略与算子实现,往往运行在不同的进程、节点乃至数据中心。由此产生一个不可回避的步骤:每次策略更新后,必须将 trainer 的新权重同步给 inference engine,否则采样所用的是过期策略。

默认做法是 full broadcast:把全部参数从 trainer 广播给所有 inference rank(典型实现是 trainer 的 rank 0 与 inference engine 的所有 rank 组成一个 NCCL 通信组)。其开销随模型规模线性增长,在解耦架构中通常主导整个 sync 阶段。

社区早期的优化方向是「把搬运做快」而非「少搬一些」。例如 slime 团队将 full-sync 延迟从约 60s 优化到约 7s——通过 async tensor gathering、bucketing(将约 2000 次 HTTP 调用降到约 120 次)、tensor flattening 以及 weight loading 缓存等手段(详见 Biao He 的博客)。但这本质上仍在传输全量权重。

当网络从 RDMA 退化为普通商用网络(带宽量级为数百 MB/s)时,full broadcast 便不再可行。SparseRL-Sync 论文(arXiv 2605.07330)指出,现有 RL 系统(OpenRLHF、veRL、StreamRL 等)的 rollout 优化大多依赖集群内高带宽网络;一旦迁移到商用网络,full broadcast 的有效吞吐很低,同步一个 8B 模型即需要超过 100 秒。模型规模更大、且需要跨 region 时,full broadcast 在工程上已不具可行性。

2. 关键观察:RL 的权重更新本身高度稀疏

转折来自 PULSE(arXiv 2602.03839,Erfan Miahi @Covenant AI,Eugene Belilovsky @Mila)对这一现象的系统化分析。其核心论点是:

在典型的 RL post-training 学习率下,Adam 的许多更新量小到在 cast 回 BF16 之后不可见——更新落在了当前权重值的 BF16 舍入阈值以下。因此一个 step 之后,绝大多数权重元素的字节表示并未改变。

PULSE 将此称为 compute-visible sparsity,并报告约 99% 的 per-step 更新在 BF16 cast 后不可见。其他独立工作也给出了高度一致的测量:

  • SparrowRLarXiv 2602.11456)报告每个 step 约 1% 的参数元素发生变化;
  • FireworksFrontier RL Is Cheaper Than You Think)报告相邻 checkpoint 之间超过 98% 的 bf16 权重 bit-equivalent,平均 delta 约为全量的 1.98%;
  • slime 文档(delta-weight-sync.md)以约 3% 的密度为典型工作点,对应 355B 模型约 5GB 的 delta。

既然如此,便无需每步传输全量,只需传输变化位置的索引与新值(indices + values),由接收端在对应位置覆盖。由于是逐元素覆盖、写入 trainer 的精确字节,整个过程不涉及任何算术运算,因而无损、bit-identical——也不会出现 additive delta 方案中的浮点累积漂移问题。通信量大致与密度成正比:约 1–3% 的密度对应约两个数量级的通信量下降。

3. 三条线在四个月内独立收敛

这一思路在 2026 年上半年被多方近乎同时、独立地落地,形成了清晰的时间线。

学术 / 论文(自 2 月起)

  • PULSE / PULSESyncarXiv 2602.03839。提出 compute-visible sparsification,从 trainer 向 inference worker 发送无损的 sparse BF16 weight patch,通信量降低 100× 以上,bit-identical 重建,并对传输错误具有鲁棒性。
  • SparrowRLarXiv 2602.11456(“RL over Commodity Networks”)。面向普通以太网 / WAN:sparse delta checkpoint 加多流传输,并与 rollout 生成重叠。论文报告 Qwen3-8B 每步 payload 降低 79×,WAN 上吞吐较 full broadcast 提升 2.4–9.5×,tokens/$ 较预留 RDMA 集群高 1.21–1.59×。
  • SparseRL-SyncarXiv 2605.07330,对应框架 Helix(Scitix,Megatron + SGLang)。报告元素级稀疏度超过 99%,传输 indices + values,100% 保真,约 100× 通信量下降。

工业界系统(自 3 月起)

  • Fireworks — Frontier RL Is Cheaper Than You Think(3-23)。在 rollout/training 解耦下,用相对上一 checkpoint 的 delta 跨 region 更新 rollout fleet。其样例中 1024 GiB 的全量,平均 delta 仅 20.3 GiB(占 1.98%),跨 region 传输量较每次搬全量减少约 94%。
  • Cursor — Composer 2 技术报告。training 与 inference 部署在不同 region,每个 training step 通过共享 S3 进行 delta 压缩上传,并按 training rank 分片;同时支持 mid-trajectory 更新(同一序列中靠后的 token 可由比靠前 token 更新的 checkpoint 生成)。

开源框架(自 5 月起)

  • Hugging Face TRL — Shipping a Trillion Parameters With a Hub Bucket: Delta Weight Sync in TRL(5-27)。trainer 比较 optimizer step 前后的 BF16 权重,将变化元素存为 sparse safetensors,上传至 HF(Xet)Bucket,再由 vLLM 下载并 apply。博客报告 Qwen3-0.6B 每步 payload 从 1.2GB 降至 20–35MB;其 demo 完全解耦,权重仅通过一个 Hub bucket 流转,无需 RDMA 或 VPN。
  • slime(THUDM) — 本文下一节详细拆解的实例。

引擎层(跟进中)

下表概括各家的工程取舍(数据来源见上文各条链接):

实现 传输介质 编码 diff 基准 无损 报告的压缩比
PULSE NCCL sparse BF16 patch step 前后 100×+
SparrowRL 多流 / commodity net sparse delta step 前后 79×(Qwen3-8B payload)
Fireworks / Cursor 跨 region 对象存储 (S3) 压缩 delta 相邻 ckpt ~94% 传输量下降(1.98% delta)
TRL HF Hub Bucket (Xet) sparse safetensors step 前后 1.2GB→20–35MB(Qwen3-0.6B)
slime NCCL 或 disk/共享FS indices / deltas / deltas_zstd pinned-CPU snapshot ~3% 密度(355B 约 5GB)

分歧主要集中在三个维度:传输介质(NCCL / 对象存储 / 共享 FS)、位置编码方式,以及 diff 的基准。下面以 slime 将这些具体化。

4. slime 的 delta weight sync 实现详解

slime(THUDM 的 RL 后训练框架,Megatron + SGLang)在其文档 delta-weight-sync.md 中给出了一套完整且可读的实现,适合作为「落地 delta sync 需改动哪些环节」的参考标本。以下分析基于 slime 主分支的源码与文档。

4.0 改动落在系统的哪些位置

slime 的 delta 实现高度内聚,但有一个容易被忽视的关键点:发送端与接收端需要分别改动,而且接收端的改动落在引擎里。

发送端(slime 框架内,全部为 delta 新增):

  • 新增文件 slime/backends/megatron_utils/update_weight/update_weight_from_distributed_delta.py(约 860 行),定义 class UpdateWeightFromDistributedDelta(UpdateWeightFromDistributed),继承原有的 full-sync 类(基类约 386 行)。它复用父类的 NCCL group 创建、TP/EP gather 与 HF 格式转换,仅重写 update_weightsconnect_rollout_engines 及发送逻辑。
  • 新增的状态:一份 pinned-CPU 的全量权重快照(snapshot)。 这是 delta 相对 full-sync 最主要的新增资源。DeltaStatetorch.empty_like(tensor, device="cpu", pin_memory=True) 为「每一个已广播的 HF 张量」保存一份快照。它是全量的——每个 PP-source rank 保存它所负责的那部分 HF 张量,合计约等于一份完整模型,常驻 host 内存(pinned)。快照在第一次 update_weights 时一次性 seed(对参数做一遍完整遍历,文档标注在 355B 上约 50s 的阻塞初始化),之后每步 diff 都以它为基准。注意这是纯增的内存开销:full broadcast 不需要任何快照。
  • 选择逻辑actor.py 中约 15 行——if update_weight_mode == "delta": cls = UpdateWeightFromDistributedDelta(采用 lazy import,旧镜像不受影响)。
  • 参数arguments.py 中新增一组 --update-weight-{mode,transport,encoding,disk-dir,buffer-size} 及相应校验。

接收端(引擎内,不在 slime 中):

真正将 sparse delta 写回权重的逻辑位于 SGLang,由一个独立的 PR 提供:sgl-project/sglang#26519 “Add delta weight update receiver”(约 339 行新增,其中约 280 行在 model_executor/model_runner.py)。slime 侧的 sglang.py 仅是约 43 行的 shim,从 sglang.srt.managers.io_struct 导入 DeltaEncoding / DeltaParam / DeltaSpec值得强调的是,截至本文写作时该 PR 仍处于 open 且 conflicting 状态、尚未合入 SGLang 主分支——也就是说,跑 slime delta sync 需要一个带这个 receiver 的自定义 / 打补丁的 SGLang build(slime 源码注释也指出旧 sglang 镜像不含 delta-sync 的 io_struct)。

因此第一个要点是:delta weight sync 并非纯框架特性。发送端框架需要能编码 sparse delta,接收端引擎需要能 apply sparse delta,两者缺一不可;目前后者还依赖一个未合入的 SGLang PR。

4.1 发送端 pipeline(仅在 PP-source rank 上执行)

下图概括发送端一次 sync 的整体数据流,下面再逐步展开。

slime delta sync 发送端流水线(PP-source rank):GPU 上的当前权重 Wₜ 与 CPU pinned 的全量快照 Wₜ₋₁ 做 bytewise diff,得到稀疏 mask(约 1–3% 元素),编码成 __positions__ + __values__,bucket 后经 NCCL 广播或 disk safetensors 发出;发送完成后再用 side stream 做 D2H,把快照更新为当前权重,作为下一步的 diff 基准。

slime delta sync 发送端流水线(PP-source rank):GPU 上的当前权重 Wₜ 与 CPU pinned 的全量快照 Wₜ₋₁ 做 bytewise diff,得到稀疏 mask(约 1–3% 元素),编码成 __positions__ + __values__,bucket 后经 NCCL 广播或 disk safetensors 发出;发送完成后再用 side stream 做 D2H,把快照更新为当前权重,作为下一步的 diff 基准。

每次 sync,发送端执行四步:

  1. Diff:将当前权重与 pinned-CPU snapshot(上一次广播的快照)做逐字节比较——current.view(int_dtype) != snapshot.view(int_dtype)。即按整数位重新解释后逐字节比较,因而与 dtype 无关、不涉及算术、无损。

  2. Encode:将变化的 (position, value) 打包为 __positions__ 字节 blob、__values__ tensor 以及每个参数一份 manifest。三种编码仅决定 position 的压缩方式,value 始终按原 dtype 原样发送:

    编码 position 表示 适用场景
    indices int32 绝对位置(4 B/nnz) NCCL 或快速集群内 FS(≥ 约 600 MB/s)
    deltas uint16 gap-delta(uint32 兜底,约 2 B/nnz @2%) 中等带宽 FS(约 300–500 MB/s)
    deltas_zstd deltas 再套一层 zstd L1 跨 DC / 跨 region 共享 FS(≤ 约 300 MB/s)

    gap 编码更省空间的原因:mask.nonzero() 得到的位置天然升序;密度 p 下相邻间隔的期望为 1/p,p=2% 时 gap>65535 的概率几乎为零,故 uint16 足够(uint32 仅作兜底),位置 blob 体积减半。

  3. Bucket 与 flush:按 --update-weight-buffer-size 累积到一个 bucket 再发送。值得明确的是,bucket 内的两部分分别住在不同的地方——positions 在 encode 阶段就已经 positions.cpu().numpy().tobytes() 落成 host 字节序列(小、且本来就要 pack 成 wire 格式),values 则始终保留为 GPU 张量(大、避免不必要的 D2H)。flush 时按 transport 各自补上最后一步搬运:NCCL transport 把 positions 经一次 H2D 推回 GPU,两者一起在 GPU 上 broadcast;disk transport 反过来把 values 从 GPU 拉回 CPU,再由后台线程写 safetensors(I/O 与可选 zstd 压缩在工作线程做,不阻塞关键路径)。

  4. Snapshot:将刚发送的 value 通过 side-stream 做 D2H 拷贝以更新 snapshot,与下一 chunk 的 encode 重叠。

几个值得说明的流水线设计:

  • H2D snapshot prefetch lookahead:chunk N+1 的 snapshot 传输与 chunk N 的 compute+encode 重叠;
  • expert pass 切分为 4 个 sub-pass:使前一批的接收端 apply 与后一批的 encode 重叠,避免集中堆积在 sync 末尾;
  • checksum:用 torch.hash_tensor(XOR-reduce)在发送前与接收后各计算一次,用于检测 encode 到 apply 之间的传输损坏。

4.2 接收端:NaN-masked overwrite(位于 SGLang)

这一节的细节都来自上面那个 SGLang PR(#26519)。两种 transport(disk 读文件、distributed 经 NCCL broadcast 收)最终都汇入同一个 _apply_delta_payload(encoding, params, positions, values, expected_checksum)。整体流程如下图,下面逐点展开。

slime delta sync 接收端 apply(在 SGLang 内,sgl PR #26519):稀疏 payload 上 GPU → 校验 checksum → 逐参数 decode 时 densify 回全尺寸 NaN 张量(wire 稀疏但 apply 不稀疏,按 512MB 分 chunk)→ 复用原生 model.load_weights,外面用 _delta_apply_context 临时 monkeypatch Tensor.copy_,做 dst[~isnan(src)]=src 的 in-place masked 覆盖,直接写回已有 GPU 权重 W;靠 data_ptr 区间索引判断目标是否为模型权重。

slime delta sync 接收端 apply(在 SGLang 内,sgl PR #26519):稀疏 payload 上 GPU → 校验 checksum → 逐参数 decode 时 densify 回全尺寸 NaN 张量(wire 稀疏但 apply 不稀疏,按 512MB 分 chunk)→ 复用原生 model.load_weights,外面用 _delta_apply_context 临时 monkeypatch Tensor.copy_,做 dst[~isnan(src)]=src 的 in-place masked 覆盖,直接写回已有 GPU 权重 W;靠 data_ptr 区间索引判断目标是否为模型权重。

先校验再 apply。 接收端用 torch.hash_tensor 重算 positions/values 的 checksum,与发送端带来的值比对,不一致直接抛错——拦截 encode 到 apply 之间的传输损坏。

逐参数解码成一个全尺寸(dense)张量,在 GPU 上。 对每个参数,_decode_delta_one_paramtorch.full((numel,), nan, dtype=param_dtype, device=self.device) 开一个新的、full-shape 的张量self.deviceGPU;positions/values 也都先 .to(self.device) 搬上 GPU。位置 blob 在 GPU 上按宽度做位运算还原(gap 编码用 idx = (unpacked+1).cumsum()-1 反推),再 flat.index_copy_(0, idx, values) 把变化值填进去,未变化位置保持 NaN。

这里有个容易忽略的代价:wire 上是稀疏的,但 apply 时被「densify」回了全尺寸——每个参数都要临时分配一块和原参数等大的 GPU 张量作为 source。为控制峰值显存,PR 按 --update-weight-delta-chunk-bytes(默认 512MB)把多个参数攒成 chunk 再统一 apply;disk 路径另有 --update-weight-delta-read-workers(默认 4)并行读文件。(顺带一提,vLLM 的 RFC #39451 想做的「sparse in-place」正是要省掉这次 densify 和发送端那份 CPU 快照。)

apply 到旧权重上、in-place,且复用引擎原生的 load_weights——靠的是 monkeypatch 而非另写一套 loader。 解码出的全尺寸 NaN 张量作为 source,过的是模型原生的 model.load_weights(chunk)。关键技巧在 _delta_apply_context 这个 context manager:进入时临时把进程级的 torch.Tensor.copy_ / torch.Tensor.fill_ 打补丁,退出时还原。打补丁后的 copy_ 做的是 mask = ~isnan(src); dst[mask] = src[mask]——只在变化位置覆盖、直接写在已有的那块 GPU 权重上(in-place),不新建权重张量,未变化位置原样保留。

它怎么知道某次 copy_ 的目标是不是模型权重?_param_storage_index 预先按 data_ptr 给所有 param/buffer 的存储区间建了张索引,copy_ 里用 bisect 判断目标张量的指针是否落在某个已知参数的存储范围内——是才走 NaN-masked 分支,否则走原生 copy_(这样切片 / view 出来的权重也能正确命中,而临时 scratch buffer 不受影响)。此外 post_load_weights(fp8 scale、MoE bias 等后处理)会被包一层、在其中临时还原原生 copy_/fill_,保证后处理语义不变。

为什么这套是无损的、且没有 drift。 全程是「按位覆盖、写入 trainer 的精确字节」,不做任何算术(不是 w += delta),因此天生 bit-identical,也不存在 additive delta 方案那种跨 step 的浮点累积漂移——所以不需要周期性 full re-sync 来纠偏。这正是它与 additive 方案的根本区别。

4.3 改动的侵入性评估

  • 对训练侧代码侵入很小:纯子类化加新增参数,不改动 loss、optimizer 及已有的 full-sync 路径,默认行为不变(约 860 行集中在一个新文件里)。
  • 但有一项实打实的资源开销:发送端那份 pinned-CPU 全量快照,约等于在 host 内存里常驻一份完整模型;初始 seed 在 355B 上约 50s 阻塞。这是用「host 内存 + 一次性初始化」换「每步通信量」。
  • 对引擎侧是硬依赖,且当前未合入:必须使用包含 delta receiver 的 SGLang build(PR #26519,约 339 行)。该 PR 截至本文写作仍未合入主分支,需要自行 cherry-pick / 打补丁——这是目前落地这套方案最现实的门槛。
  • 一处明确限制delta + colocate 在 argparse 阶段即被拒绝。colocate 经由 CUDA IPC,进程间仅传递约 64 B 的内存 handle,delta 无字节可省,反而徒增 snapshot + diff + encode 的开销。

4.4 它与 RDMA 能否同时使用

可以,且二者正交。slime 将同步拆为两个独立的维度:

  • 「发送什么」= modefull / delta
  • 「如何传输」= transportnccl / disk
mode transport 行为
full nccl 默认路径:将每个 HF 权重 chunk 经 trainer-engine NCCL group 广播
full disk 写出完整 HF checkpoint,再由引擎 update_weights_from_disk 重载
delta nccl 将稀疏的变化位置与值经 NCCL 广播
delta disk 将稀疏 safetensors 写至共享 FS,再 push 给引擎 apply

关键在于:

  • delta + nccl 即 “delta over NCCL”。NCCL 底层同样运行在 IB / RoCE,即 RDMA fabric 之上——因此可同时获得 RDMA 的高带宽与仅传输约 1–3% 字节这两项收益,二者叠加而不冲突。slime 文档将 NCCL transport 定位为同数据中心内的验证基线,用于验证 wire 编码与 apply 逻辑的正确性。
  • delta + disk(共享 FS / 对象存储) 则面向不具备 RDMA 的跨 DC / 跨 region 场景。此时 full broadcast 不可行,而稀疏 delta(355B 模型约 3% 密度,约 5GB)在数百 MB/s 的共享 FS 上是可接受的。

结论:delta 并非 RDMA 的替代品,而是在有 RDMA 时进一步减少传输字节、在无 RDMA 时使 RL 仍可解耦。

4.5 配置示例

跨 DC(主要用例,disk transport):

--update-weight-mode delta
--update-weight-transport disk
--update-weight-encoding deltas_zstd          # ≤ 300 MB/s 共享 FS 最优
--update-weight-disk-dir /shared/fs/delta-updates

集群内验证基线(NCCL transport):

--update-weight-mode delta
--update-weight-transport nccl
--update-weight-encoding indices              # 计算开销最低,不压缩

5. 引擎层正在将其纳为原生能力

如上节所示,接收端的 apply 逻辑落在引擎中。目前各框架多以「各自为引擎打补丁」的方式实现。vLLM 的 RFC #31848issue #39451 正推动将 sparse in-place weight update 做成引擎的原生 API:直接接收 (indices, values)、原地 apply,将传输从 O(numel) 降至 O(nnz),并免去用户自行维护 CPU snapshot。

这意味着 delta weight sync 正从「框架各自实现」走向「引擎标准能力」。一旦引擎侧 API 稳定,框架侧的接入成本将显著降低。

6. 小结

一个朴素的观察——RL 一个 step 之后大部分权重并未改变——在数月内改变了 RL 的成本结构:使 trainer 与 rollout 能够运行在普通网络乃至跨 region,tokens/$ 甚至可超过预留的 RDMA 集群(见 SparrowRL 的报告)。

几个值得继续探讨的开放问题:

  • 更激进的有损 delta(例如舍弃极小的变化)能否在不影响收敛的前提下进一步压缩?
  • 长时间训练中,若采用 additive 而非 overwrite,跨 step 的累积漂移边界如何界定?(slime 以纯 overwrite 回避了该问题。)
  • 在 MoE 场景下,专家权重的稀疏性是否依然成立?不同专家被激活的频率差异是否会使 delta 分布不均?

这些可能是下一阶段工作的方向。


参考资料

  • PULSE / PULSESync, arXiv 2602.03839
  • SparrowRL(RL over Commodity Networks),arXiv 2602.11456
  • SparseRL-Sync(Helix),arXiv 2605.07330
  • Fireworks, “Frontier RL Is Cheaper Than You Think”
  • Cursor, Composer 2 Technical Report
  • Hugging Face TRL, “Shipping a Trillion Parameters With a Hub Bucket: Delta Weight Sync in TRL”
  • slime, docs/en/advanced/delta-weight-sync.md
  • vLLM issues #31848, #39451
  • Biao He, “Optimizing Weight Sync in slime”