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 年上半年被学术界、工业界、开源社区近乎同时、各自独立地落地。各家的核心做法其实一致——发送稀疏 delta、无损重建、压缩比几十到上百倍(具体数字见下表),真正的区别只在切入的角度:

学术 / 论文(自 2 月起)

  • PULSE / PULSESyncarXiv 2602.03839)最早把它讲清楚:提出 compute-visible sparsification,并论证 sparse BF16 patch 能 bit-identical 重建、对传输错误鲁棒。
  • SparrowRLarXiv 2602.11456)把目标网络从 RDMA 拉到普通以太网 / WAN:配多流传输 + 与 rollout 生成重叠,论证这套在 commodity network 上也能打——tokens/$ 甚至高过预留的 RDMA 集群。
  • SparseRL-Sync / HelixarXiv 2605.07330,Scitix,Megatron + SGLang)给出最极端的稀疏度证据:元素级稀疏度超过 99%。

工业界系统(自 3 月起)

  • FireworksFrontier RL Is Cheaper Than You Think,3-23)把它跑进生产:rollout/training 解耦、跨 region 更新,且 diff 基准放在相邻 checkpoint 之间,区别于多数实现的「step 前后」。
  • Cursor(Composer 2 技术报告)多了一个独特能力:mid-trajectory 更新——同一条序列里靠后的 token 可以由比靠前 token 更新的 checkpoint 生成。

开源框架(自 5 月起)

  • Hugging Face TRLDelta Weight Sync in TRL,5-27)把传输介质做到最轻:权重只经一个 HF Hub bucket 流转,完全解耦,无需 RDMA 或 VPN。
  • slime(THUDM) 是本文下一节详细拆解的实例。

引擎层也在跟进:vLLM 已把 sparse in-place weight update 落成原生能力(#40096,2026-06 merged,目前仅 NCCL、TP=PP=1)。

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

实现 传输介质 编码 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 改动落在系统的哪些位置

落地 delta sync,改动分两头,且有一个容易被忽视的关键点:发送端和接收端都要改,而接收端的改动落在引擎里,不在 slime。

  • 发送端(slime 框架内):新增一个继承 full-sync 的子类,复用父类已有的 NCCL group、TP/EP gather 与 HF 格式转换,只重写 diff / 编码 / 发送这几步。真正新增的资源只有一样——一份常驻 host 的 pinned-CPU 全量权重快照,作为每步 bytewise diff 的基准。这是 delta 相对 full broadcast 唯一的净增开销(full broadcast 不需要任何快照),且首次启动要做一次完整遍历来 seed(slime 文档标注 355B 上约 50s)。剩下就是入口处的模式选择,加一组新的 --update-weight-* 参数。
  • 接收端(引擎内,不在 slime):真正把 sparse delta 写回权重的逻辑在 SGLang,由一个独立 PR(sgl-project/sglang#26519)提供;slime 这边只有一层很薄的 shim 去 import 它的数据结构。需要注意的是,跑 slime delta sync 要用一个包含这个 receiver 的 SGLang build——该 PR 进主分支前,先自备一个即可。

所以第一个要点是:delta weight sync 并非纯框架特性。发送端要能编码 sparse delta、接收端引擎要能 apply sparse delta,两者缺一不可;后者由 SGLang 侧提供,配套上对应的 build 即可。

下面分两头展开:发送端(§4.1)与接收端(§4.2)。

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

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

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

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

4.1.1 一次 sync 的四步

每次 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)

    为什么 deltas 用 2 字节就够、而 indices 要 4 字节?区别在于存的是绝对位置还是相邻间隔indices 存的是每个变化元素在 param.view(-1) 里的绝对下标——一个权重张量动辄上千万个元素,下标范围就有上千万,uint16(上限 65535)根本装不下,只能用 int32(4 字节)。

    deltas 改存相邻两个变化位置之间的间隔 idx[k] - idx[k-1] - 1mask.nonzero() 出来的位置天然升序,间隔恒为正;而在密度 p≈2% 下,平均每约 50 个元素才有一个变化(间隔的期望正是 1/p),绝大多数间隔只有几十、极少超过几百。一个间隔要超过 65535,意味着连续 6 万多个元素一个都没变——在 2% 密度下这概率约等于零,所以 uint16 足够,位置 blob 体积直接减半。

    万一某个参数稀疏到间隔真爆了 uint16,就按参数粒度回退到 uint32 兜底(pos_width 是 per-param 的,见 §4.2.4);接收端再用 idx = cumsum(gap + 1) - 1 把间隔累加还原成绝对下标。

  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 重叠。

发送端真正的复杂度,几乎全在一件事上:怎么把「搬数据」和「等接收端」这两段时间藏进「算 diff + 编码」里。一次 sync 的耗时可以拆成「算 + 搬 + 等」三块,delta 用三级重叠尽量让后两块不单独占 wall-clock。下面从最内层(CUDA stream)到最外层(段间)逐级展开。

4.1.2 三条 CUDA stream:把搬快照藏进 compute

snapshot 既要(当作 diff 的基准),也要(发送后更新成新基准给下一步用),而它常驻 CPU pinned 内存,每次都要跨 PCIe/NVLink 搬一趟。DeltaState 为此用了三条 stream 分工:

  • 默认 stream:对当前 chunk 算 bytewise diff + 稀疏编码;
  • h2d_stream(读,预取):把下一个 chunk 的旧快照从 CPU 搬上 GPU,给它将要做的 diff 当输入;
  • d2h_stream(写,回写):把当前 chunk 刚发出去的新值异步搬回 CPU 快照,作为下一步的 diff 基准。

两条拷贝 stream 必须分开,原因有三:

  1. 彼此无依赖,合一条就被迫串行:H2D 预取下一块和 D2H 回写这一块之间没有数据依赖,放同一条 stream 会按入队顺序排队,预取被回写堵在后面,重叠就没了。
  2. copy engine 是全双工的:GPU 的 H2D / D2H 拷贝引擎相互独立,PCIe/NVLink 上下行能同时跑,两条 stream 才能占满双向带宽。
  3. 不能挤默认 stream:拷贝若放默认 stream,会和 diff/encode 抢同一条执行队列,等于退回到同步搬运。

顺序靠 CUDA event 兜底,并非裸跑:预取在 h2d_stream 上 record event,compute_diffsevent.wait() 之后才读那份快照;回写先在默认 stream record、d2h_stream 等到这个 event 之后才搬(保证新值在默认 stream 上算定稿了再回写);flush_snapshot 在进入下一次 sync 前 synchronize() 掉所有回写——这一步漏了的话,下一步的快照会是半新半旧,diff 出来全是错的。

4.1.3 chunk 级重叠:错一步的预取

§4.1.2 里「预取下一个 chunk」具体靠 _pipeline_pass 的一个 1-step lookahead:每轮循环先给当前 chunk 发起异步 H2D 预取,再回头处理上一个 chunk。展开看就是「处理 chunk N−1 的 compute+encode」和「chunk N 的旧快照 H2D」同时在跑,等轮到处理 chunk N 时它的快照早已就位,那句 event.wait() 基本不用等。搬快照的时间就这样被藏进了相邻 chunk 的 compute。

4.1.4 段级重叠:expert 切 4 个 sub-pass,让接收端 apply 与编码并行

更外层的一级重叠发生在「段」之间。_send_weights 把参数分成非 expert 一段、expert 四段(_EXPERT_SUBPASSES = 4)——MoE 的 expert 参数量是大头,单独拆开。每段末尾的 _flush_and_publish 是一次对接收端的交付:发完这段就通知引擎 apply。切成四段之后,第一段发出去、引擎开始 apply 的同时,发送端已经在编码第二段了——接收端的 apply 和发送端的 encode 并行起来,apply 的耗时不再整块堆在 sync 末尾。

切分能成立有个前提:Megatron 把 MoE 层在 PP rank 间均匀切,所以按各 rank 自己的 expert 列表平均切四份,每个 rank 的 flush/publish 次数都一致,段末的 barrier 不会有人多等。4 这个数字是个折中——段越多重叠越细,但每段都要多付一次 barrier + 通知的开销。

4.1.5 disk transport 的异步落盘与发布

disk 路(跨 DC、或引擎与 trainer 之间没有 NCCL 连通时用)的异步更重,比 NCCL 多两层:

  • 后台写盘线程AsyncSafetensorsWriter):flush 只把 (positions, values, metadata) 入队,safetensors 编码、可选 zstd 压缩、fsyncos.replace 原子改名都在工作线程里做,不占编码主线程。用原子改名是为了让引擎永远读不到写了一半的文件。
  • 异步发布 RPC:每个段末 _publish_batch 把各 rank 这批的文件名 all-gather 到 rank 0,由一个单线程池异步发出 update_weights_from_disk 通知,发完不等引擎读完返回就继续编码下一段。跨 DC 时还能挂一个 _pre_push_hook(返回一个 future),把「等共享 FS 真正持久化到对端可见」这一步插在发通知之前、通知排在这个 future 之后——既不阻塞编码主线程,又保证引擎不会去读还没同步过去的文件。

4.1.6 一次 sync 的延迟怎么拆

发送端把整个 update_weights 用两个计时块切开,正好对应用户能观测到的延迟:

  • delta_encode:算 diff + 编码 + 发送(disk 还要等写盘落地、回写快照);
  • delta_finalize:等最后一批的接收端 apply 落地,再恢复推理(resume generation)。

两段之和就是这一步训练观测到的 sync 延迟。拆成两段的好处是一眼能看出瓶颈在哪:堆在 delta_encode 说明卡在发送端编码,堆在 delta_finalize 说明卡在接收端 apply。§4.1.4 的 sub-pass 重叠,本质就是把本来会落在 delta_finalize 的那段 apply 时间,尽量挪进 delta_encode 期间并行掉。

checksum 这一步也在发送端:每个 bucket flush 前用 torch.hash_tensor(XOR-reduce)算一次,随 DeltaSpec 走元数据通道发给接收端,后者重算比对(见 §4.2.2)。它不参与上面任何重叠,只在关键路径上加一次规约加一次 .item() 同步,用来兜住 encode 到 apply 之间的传输损坏。

下面这张图把上面几级重叠合起来看——切到 Naive 模式对比一下,就能直观看到流水线把搬运和 apply 藏进 compute 之后省下了多少 wall-clock:

Delta weight sync — 流水线重叠演示 同样的工作量,pipelined 把「搬运 / 发送 / 接收端 apply」藏进 compute,总时长大幅缩短。
已用时间 0 当前模式总时长

说明:每个 chunk 走 预取→compute→send→receiver apply 四步。Pipelined 下 chunk N+1 的快照预取与 chunk N 的 compute 重叠(h2d / d2h 独立 stream),receiver apply 又与后一批的 encode 重叠;Naive 下每个 chunk 等上一个彻底做完才开始。两者工作量相同。

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

这一节的细节都来自 SGLang PR #26519。这个 PR 现在混了不少无关提交,真正实现 delta receiver 的只有其中一个 commit——直接看 3f118378 即可,核心逻辑集中在 model_executor/model_runner.py 的几个函数里。

两种 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 不稀疏)→ 复用原生 model.load_weights,外面用 _delta_apply_context 临时 monkeypatch Tensor.copy_,做 dst[~isnan(src)]=src 的 in-place masked 覆盖,直接写回已有 GPU 权重。如何判断「目标是模型权重」见 §4.2.7。

slime delta sync 接收端 apply(在 SGLang 内,sgl PR #26519):稀疏 payload 上 GPU → 校验 checksum → 逐参数 decode 时 densify 回全尺寸 NaN 张量(wire 稀疏但 apply 不稀疏)→ 复用原生 model.load_weights,外面用 _delta_apply_context 临时 monkeypatch Tensor.copy_,做 dst[~isnan(src)]=src 的 in-place masked 覆盖,直接写回已有 GPU 权重。如何判断「目标是模型权重」见 §4.2.7。

4.2.1 两个 transport 入口

NCCL 走 _apply_delta_from_distributed:在 sender broadcast 前,先通过 RPC 拿到一组 (name, dtype, shape, group_name, DeltaSpec) 元数据,根据其中的 shape/dtype 预先在 GPU 上 alloc 两块 empty buffer(NCCL 是无 schema 的,必须双方都知道接什么),再异步发起两次 broadcast(positions / values),最后 wait() 齐了交给 _apply_delta_payload。这里复用的是 slime 原本 full-sync 用的同一个 NCCL groupself._model_update_group[group_name]),不另建组。

Disk 走 _apply_delta:根据 --update-weight-delta-read-workers(默认 4)开一个 ThreadPoolExecutor 并发读 safetensors 文件;读出来的二进制 blob 通过 _maybe_zstd_decompress 自动检测 zstd magic(0x28B52FFD)按需解压;然后 _decode_and_apply_delta_blob 手动 parse safetensors 头(int.from_bytes(blob[:8]) 拿 header_len,再 json.loads__metadata__)从中提取 DeltaSpec,最后调 _apply_delta_payload

两条路在「数据怎么到 GPU」上分叉,但「拿到后怎么 apply」共用同一个 _apply_delta_payload——这是设计上的关键汇流点。

4.2.2 Checksum:随 DeltaSpec 走「元数据通道」

_apply_delta_payload 的第一件事是 verify:

actual = _delta_checksum(positions, values)   # torch.hash_tensor XOR-reduce
if actual != expected_checksum:
    raise RuntimeError("delta checksum mismatch: ...")

checksum 不在 wire 上的数据张量里,而是和 DeltaSpec 一起走元数据通道:

  • NCCL:作为 delta= RPC 参数的 JSON 字符串字段({"encoding": ..., "params": [...], "checksum": "..."});数据张量另走 NCCL broadcast。
  • Disk:写进 safetensors 文件的 __metadata__ 字段(safetensors header 里专门 dict[str, str] 元数据 slot)。

收到时 sender 端的 int 已经在 DeltaSpec 里反序列化好,receiver 重算后比对即可。

4.2.3 单参数解码:densify 回全尺寸 NaN 张量

DeltaSpec.params 里的每个 DeltaParam p_decode_delta_one_paramGPU 上做 4 步:

  1. 开一块全 shape 的 NaN bufferflat = torch.full((numel,), nan, dtype=p.dtype, device=self.device)。这是 receiver 侧最大的临时显存开销——「wire 稀疏但 apply 不稀疏」就是这里来的。
  2. 从 bucket 大数组里切出这个 param 的 slicepos_bytes = positions[p.pos_start:p.pos_end]val_slice = values[p.val_start:p.val_end]
  3. 字节反 pack 成整数pos_bytes.view(n, width).to(torch.int64) 后做位运算 b[:,0] | (b[:,1]<<8) | ...,向量化、零 CPU 同步。
  4. 反算成绝对 index 并散射
    • indices 编码:idx = unpacked(本来就是绝对位置)
    • deltas / deltas_zstd 编码:idx = (unpacked + 1).cumsum() - 1(gap 编码的代数逆,pure prefix-sum trick)
    • flat.index_copy_(0, idx, val_slice.to(p.dtype)) 把值写到对应位置。

返回 flat.view(*p.shape)——一个未变位置=NaN、变化位置=trainer 的精确字节的全尺寸张量。

这里有个容易忽略的代价:wire 上是稀疏的,但 apply 时被「densify」回了全尺寸——每个参数都要临时分配一块和原参数等大的 GPU 张量作为 source。为控制峰值显存,PR 按 --update-weight-delta-chunk-bytes(默认 512MB)把多个 param 攒成 chunk 再统一 apply(详见 §4.2.5)。理想情况下这次 densify 是能省掉的——vLLM 的 #40096(2026-06 merged)就直接 param.data.view(-1).index_copy_(indices, values) 把稀疏值写回 runtime 权重,全程不 densify、也不需要发送端那份 CPU 快照。

不过说实话,slime 这种「densify 一下 + 复用原生 load_weights」的做法反而更优雅:它顺带复用了引擎里处理 TP/EP/PP 分片、格式转换、量化的全部逻辑,一上来就能跑各种并行;而 vLLM #40096 为了省掉 densify 绕开了 loader,代价是目前只支持 TP=1/PP=1。况且 densify 已经按 512MB 分了 chunk,对峰值显存的实际贡献其实很有限——省掉它换来的那点显存,远不如丢掉 loader 里现成的并行处理来得可惜。

4.2.4 wire layout & 「索引和数据分离」

为了让上一步的切片成立,wire 上一个 flush 其实是 3 样东西(不是 2 样):

__positions__   uint8 字节 blob       多个 param 的 positions 拼接
__values__      param-dtype 张量       多个 param 的 values 拼接
DeltaSpec       小元数据:encoding + List[DeltaParam] + checksum

DeltaParam 是「地址簿」(里面不带数据),只带切片偏移和形状:

DeltaParam(
    name="layers.0.q.weight",
    dtype="bfloat16", shape=[4096, 4096],
    pos_start=1024, pos_end=1056, pos_width=2,   # 字节偏移 + 每位置 2/4 字节
    val_start=512,  val_end=528,                  # 元素偏移
)

几个关键的精确细节:

  • positions 用字节偏移,values 用元素偏移——因为 positions 是 uint8 blob、且不同 param 的 pos_width 可以不同(uint16 vs uint32 fallback),字节是自然单位;values 是固定 dtype 的张量,元素更直观。
  • pos 和 val 不在 wire 上同一个 offset——它们是「第 k 个 position 对应第 k 个 value」的 ordinal 配对,不是「同字节 offset」配对:
    positions[start:end] 解出 nnz 个 idx
    values[start:end]    取出 nnz 个 val
    → idx[k]  ↔  val[k]
    
  • pos_width 是 per-param 不是 per-bucket——同一 bucket 里大多数 param 用 uint16(密度 1-3% 下 gap 几乎总 < 65535),个别极端稀疏的 param 退 uint32。decoder 看每个 DeltaParam.pos_width 各自切。
  • position 是 1D flat index,不是多维坐标——decode 出来的索引直接落在 param.view(-1) 上,最后一步 view(*shape) 才变回多维。

这是个经典「索引和数据分离」模式(CSR 稀疏矩阵 / Apache Arrow / safetensors 自己也是这样):一份大数据扁平 + 一份小索引描述结构。好处是 NCCL 可以一次 broadcast 整块大数组、safetensors 一个文件搞定,而不是每个 param 一段碎数据。

4.2.5 在原生 load_weights 上做 NaN-masked overwrite

_apply_delta_payload 的核心循环:

with _delta_apply_context(self.model):
    chunk = []                 # list[(name, tensor)]
    chunk_bytes = 0
    for param in params:
        tensor = self._decode_delta_one_param(...)        # 全 shape NaN tensor
        if chunk and chunk_bytes + size > chunk_byte_cap:  # 512MB cap
            self.model.load_weights(chunk)                 # ← 原生 loader
            chunk = []; chunk_bytes = 0
        chunk.append((param.name, tensor))
        chunk_bytes += size
    if chunk:
        self.model.load_weights(chunk)

几点:

  • chunklist[(str, torch.Tensor)],不是 torch param——它是 SGLang model.load_weights(weights: Iterable[(name, tensor)]) 这个接口期望的「权重流」。name 就是 DeltaParam.name(HF 名字,从 sender 端 manifest 透传过来)。
  • 接口只要两个东西:HF 名字 + 全 shape 源张量。每个模型的 load_weights 内部自己负责 HF→内部参数名映射、QKV split/fuse、quantize 等,最终一定落到 tensor.copy_(src) 写 GPU 权重——这是下一节 monkeypatch 拦截的关键点。
  • chunk_byte_cap 控的是接收端 VRAM 峰值,和发送端 bucket size 是两层独立 batching:sender bucket size = --update-weight-buffer-size(控 wire 频率);receiver chunk size = --update-weight-delta-chunk-bytes(控 receiver VRAM 峰值)。互不相关。

4.2.6 _delta_apply_context:临时 monkeypatch copy_ / fill_

这是整套接收端设计里最妙的一步。要把稀疏 delta 写回权重,直觉上得自己写一条「稀疏感知」的权重加载路径——可每个模型 family 的 load_weights 都不一样,还各自带着 fused QKV 拆分、量化、TP/PP 处理。这里的做法正相反:引擎原生的 load_weights 一行不改、完整复用,只在它最终会落到的 Tensor.copy_ / Tensor.fill_ 上 hook 一下,把「整张覆盖」换成「只覆盖非 NaN 的位置」。稀疏语义从最底层悄悄注入,上面那套 per-model 转换、量化、并行处理就全部原样拿来用了——接收端几乎没有重复实现任何模型相关的加载逻辑。

实现上,_delta_apply_context 是一个 contextmanager,进入时临时改写进程级的 torch.Tensor.copy_Tensor.fill_,退出时还原:

@contextlib.contextmanager
def _delta_apply_context(model):
    is_param_target = _param_storage_index(model)        # 见 §4.2.7
    original_copy_, original_fill_ = Tensor.copy_, Tensor.fill_

    def patched_copy_(self, src, *args, **kw):
        if is_param_target(self) is not None:             # 目标是模型权重?
            mask = ~torch.isnan(src)
            self[mask] = src[mask]                         # in-place 只写非 NaN 位置
            return self
        return original_copy_(self, src, *args, **kw)     # 不是权重 → 原样

    def patched_fill_(self, value):
        if is_param_target(self) is not None and isnan(value):
            return self                                    # fill_(NaN) 到权重 → no-op
        return original_fill_(self, value)

    Tensor.copy_ = patched_copy_
    Tensor.fill_ = patched_fill_
    try: yield
    finally: 复原

为什么 copy_ 要 patch:模型原生 load_weights 最后一定调 tensor.copy_(src)。如果不 patch,整张含 NaN 的 src 会直接覆盖到权重上,所有未变位置全变 NaN,模型报废。patch 后 copy_ 看 mask,只覆盖非 NaN 位置——in-place 直接写在已有 GPU 权重上,不新建权重张量

为什么 fill_ 也要 patch(只拦 NaN 一种 case)copy_ 不是写权重的唯一通路。切片赋值 param[:] = scalar 底层、Tensor.zero_() 底层、PyTorch 内部某些 __setitem__ 都会走 fill_只要其中任何一处出现 param.fill_(NaN),整张权重就被打成 NaN。但 patched_fill_ 只拦 NaN 一种情况——因为模型 loader 合法地会调 param.fill_(0)zero_()scale.fill_(1.0) 之类做初始化,把所有 fill_ 都 no-op 反而会破坏 loader。NaN 是 delta apply 的 sentinel,正常 load_weights 永远不会用 NaN 当 fill value,所以只拦 NaN 安全。

post_load_weights(fp8 scale / MoE bias 等后处理)会被包一层,在其中临时还原原生 copy_/fill_——这部分是纯算术后处理,不能被 mask 拦。这套设计保证:主 load_weights 阶段走 patched,后处理阶段走原生。

4.2.7 怎么认得「目标是模型权重」:_param_storage_index

patched_copy_ 里那一句 is_param_target(self) is not None 怎么实现?靠预先建一张 「GPU 内存地址区间 → owner param」 的查找表:

def _param_storage_index(model):
    starts, ends, owners = [], [], []
    for tensor in named_parameters() + named_buffers():
        ptr = tensor.data_ptr()                              # GPU 内存指针(int)
        starts.append(ptr); ends.append(ptr + tensor.nbytes)
        owners.append(tensor)
    # 按 start 排序,bisect 做 O(log n) 区间查找
    def find_parent(dst):
        idx = bisect.bisect_right(starts, dst.data_ptr()) - 1
        if 0 <= idx and starts[idx] <= dst.data_ptr() < ends[idx]:
            return owners[idx]
        return None
    return find_parent

关键性质

  • data_ptr() 是 GPU 设备指针(一个 int,如 0x7f8c01a40000),不是 ordinal 索引。
  • slice / view 自动命中:PyTorch 的 view 只改 metadata,底层 storage 不变,param[:, :half].data_ptr() 自然落在父 param 的 [start, end) 区间内。不用手动枚举所有 view 形式
  • scratch buffer 自动排除:临时张量的指针落在 gap 里,不命中,走原生 copy_——不被 mask 干扰。
  • 纯 CPU + O(log n):n 通常几百到几千,bisect 一次几百纳秒,相对 copy_ 的 kernel launch 可忽略。

4.2.8 为什么无损 + 无 drift

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

4.2.9 已知缺陷:失败时 silent drift(THUDM/slime#2104

apply 失败(checksum / 读盘 / decode 出错)时 receiver 只是 catch + log + 返回 (False, msg),而 sender 的 _finalize_sync 把这个返回值直接丢了。由于 sender 的 snapshot 在发送前就已推进,一旦某次 apply 静默失败,sender 就以为这些位置已经同步、之后不再 diff 出来,receiver 上这部分权重便永久 drift,直到重启重新 seed。NCCL 下要么成功要么整个 job 挂、基本触发不到;disk 跨 DC 时概率非零。我们开了 issue #2104 跟进,已有修复 PR #2119;verl 实现里我们直接在失败时触发一次 force full re-sync 补上。

4.3 改动的侵入性评估

  • 对训练侧代码侵入很小:纯子类化加新增参数,不改动 loss、optimizer 及已有的 full-sync 路径,默认行为不变(全部集中在一个新文件里,独立于已有路径)。
  • 但有一项实打实的资源开销:发送端那份 pinned-CPU 全量快照,约等于在 host 内存里常驻一份完整模型;初始 seed 在 355B 上约 50s 阻塞。这是用「host 内存 + 一次性初始化」换「每步通信量」。
  • 需要配套的引擎 build:接收端能力来自 SGLang 的 PR #26519,部署时用一个包含它的 SGLang build 即可(该 PR 进主分支前先自备一个)。
  • 一处明确限制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 仍可解耦。

5. 小结

一个朴素的观察——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 #40096(sparse NCCL in-place,merged)、#44353(weight sync 重构)、issues #31848 / #39451
  • Biao He, “Optimizing Weight Sync in slime”