Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
569 changes: 521 additions & 48 deletions docker/patch/latest/sglang.patch

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions docs/en/advanced/delta-weight-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Delta Weight Sync

- [Why](#why)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [Encoding Choice](#encoding-choice)
- [Why Not Colocated](#why-not-colocated)

## Why

Slime's default sync broadcasts every parameter every step. The cost scales linearly with model size and dominates the sync phase, even though only a few percent of weights change between consecutive RL steps. Delta sync keeps a pinned-CPU snapshot of the last broadcast and ships only the positions whose bytes differ.

The motivating use case is **training/inference disaggregation** — running the trainer and the rollout engines in *different datacenters* over a shared filesystem with bandwidth on the order of 100s of MB/s, where a full broadcast is infeasible but a sparse delta (~3% density, ~5 GB for a 355B model) is. The same delta machinery also runs over NCCL inside a single datacenter, where it serves as the validation baseline that proves the wire encoding and apply logic are correct.

Prior art: selective overwrite is inspired by [arXiv:2509.19128](https://arxiv.org/abs/2509.19128); the cross-DC disaggregation motivation is from [Fireworks AI — Frontier RL Is Cheaper Than You Think](https://fireworks.ai/blog/frontier-rl-is-cheaper-than-you-think).

## Quick Start

Disk transport (training/inference disaggregation — the main use case):

```bash
--update-weight-mode delta
--update-weight-transport disk
--update-weight-encoding deltas_zstd # best for ≤ 300 MB/s shared FS
--update-weight-delta-dir /shared/fs/delta-updates
```

NCCL transport (intra-datacenter validation baseline):

```bash
--update-weight-mode delta
--update-weight-transport nccl
--update-weight-encoding indices # lowest compute, no compression
```

Receiver-side tuning (applies to both transports):

```bash
--sglang-update-weight-delta-chunk-bytes $((2 * 1024 * 1024 * 1024)) # byte cap per load_weights call
--sglang-update-weight-delta-read-workers 4 # parallel I/O threads (disk only)
```

See [examples/delta_weight_sync/run-glm4.7-355B-A32B-delta.sh](../../../examples/delta_weight_sync/run-glm4.7-355B-A32B-delta.sh) for a complete launcher.

## How It Works

Both transports share one sender pipeline, one wire layout, and one receiver-side decoder; only the per-flush carrier differs.

**Sender (per sync, PP-source rank only):**

1. **Diff** the current weights against the pinned-CPU snapshot via bytewise compare (`current.view(int_dtype) != snapshot.view(int_dtype)`) — lossless, dtype-agnostic, no arithmetic.
2. **Encode** changed (position, value) pairs into a packed `__positions__` byte blob + `__values__` tensor + per-param decoding manifest. The encoding (`indices`, `deltas`, `deltas_zstd`) governs only how positions are packed; values are sent verbatim in the param's dtype.
3. **Bucket** per-chunk encodes up to `--update-weight-buffer-size` bytes, then flush:
- NCCL: broadcast `(__positions__, __values__)` to the rollout engines with a `DeltaSpec` (encoding + per-param manifest) carried in the Ray RPC.
- Disk: write one safetensors file per flush under `weight_v{N:06d}/`. Async background thread does the I/O + optional zstd compression off the critical path.
4. **Snapshot the just-sent values** via a D2H copy on a side stream so it overlaps with the next chunk's encode.

**End-of-sync (disk only):** write a `DONE` marker, then rank 0 fires one HTTP push per engine and removes the directory after every engine acknowledges.

**Receiver:**

For both transports, the receiver ends up calling the same `_apply_delta_payload(encoding, params, positions, values)` helper. It decodes each param's slice into a full-shape tensor with NaN at unchanged positions, then routes it through `model.load_weights(...)` under a `_delta_apply_context` that patches `Tensor.copy_` / `Tensor.fill_` to perform NaN-masked overwrite. Auxiliary writes (scratch buffers, fp8 scales, MoE biases via `post_load_weights`) keep their normal semantics.

Selective overwrite has no arithmetic — the receiver writes the trainer's exact bytes at changed positions — so it's lossless by construction and there's no notion of drift to fight with periodic base re-syncs.

## Encoding Choice

`--update-weight-encoding` picks how positions are packed. All three share the same on-wire layout (`__positions__` uint8 blob + `__values__` tensor + per-param manifest); decoder dispatches on the metadata.

| value | positions | when to pick |
|---|---|---|
| `indices` | int32 absolute positions (4 bytes / nnz) | NCCL or fast intra-cluster FS (≥ ~600 MB/s) |
| `deltas` | uint16 gap-deltas with uint32 fallback (~2 bytes / nnz at 2% density) | medium FS bandwidth (~300-500 MB/s) |
| `deltas_zstd` | `deltas` wrapped in zstd L1 on disk | cross-DC / cross-region shared FS (≤ ~300 MB/s) |

**Why gap-encoded positions are smaller**: positions come out of `mask.nonzero()` already sorted ascending. At density `p`, the expected gap between consecutive nonzero positions is `1/p`, and `P(gap > 65535) ≈ exp(-p · 65535)`. At p = 2% that's effectively zero, so uint16 fits with a uint32 per-param fallback for pathological inputs. Half the position bytes of `indices`, lossless.

**Break-even with `indices`** at our density (~2%): `deltas` halves the positions blob (which dominates the wire); `zstd` shaves another ~35-40% on top by compressing the gap byte stream, at the cost of ~250ms/file compress + ~150ms/file decompress. The crossover with `indices` is where compress/decompress compute exceeds the bandwidth savings — empirically around 500 MB/s for `deltas` and 300 MB/s for `deltas_zstd`.

## Why Not Colocated

Colocated weight sync uses CUDA IPC: only a memory handle (~64 B) crosses processes. Delta encoding's "bytes saved on the wire" benefit is zero, while the bookkeeping (snapshot + diff + sparse encode) is pure overhead. Slime rejects `--update-weight-mode delta --colocate` at argparse time.
1 change: 1 addition & 0 deletions docs/en/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ slime is the RL-framework behind GLM-4.7, GLM-4.6 and GLM-4.5. Apart from models
advanced/reproducibility.md
advanced/fault-tolerance.md
advanced/pd-disaggregation.md
advanced/delta-weight-sync.md
advanced/sglang-config.md
advanced/megatron-config.md
advanced/arch-support-beyond-megatron.md
Expand Down
80 changes: 80 additions & 0 deletions docs/zh/advanced/delta-weight-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Delta 权重同步

- [背景](#背景)
- [快速开始](#快速开始)
- [工作原理](#工作原理)
- [编码选择](#编码选择)
- [为何不支持 colocated](#为何不支持-colocated)

## 背景

slime 默认的权重同步会在每一步广播全部参数,开销随模型规模线性增长,即使每步真正变化的权重只有几个百分点。Delta 同步在内存中保留上一次同步后的参数快照(pinned CPU),只发送字节发生变化的位置。

最主要的应用场景是 **训练 / 推理跨数据中心解耦** —— 训练器和推理引擎运行在不同数据中心,通过共享文件系统通信(带宽通常在百 MB/s 级别)。在这种环境下,全量广播不可行,而 ~3% 密度的稀疏 delta(355B 模型约 5 GB)是可行的。同一套 delta 机制在数据中心内部跑 NCCL,作为验证基线,确认 wire 编码和 apply 逻辑正确。

参考资料:选择性覆写借鉴自 [arXiv:2509.19128](https://arxiv.org/abs/2509.19128),跨数据中心的动机来自 [Fireworks AI — Frontier RL Is Cheaper Than You Think](https://fireworks.ai/blog/frontier-rl-is-cheaper-than-you-think)。

## 快速开始

磁盘传输(跨数据中心训推解耦,主要场景):

```bash
--update-weight-mode delta
--update-weight-transport disk
--update-weight-encoding deltas_zstd # ≤ 300 MB/s 共享 FS 推荐
--update-weight-delta-dir /shared/fs/delta-updates
```

NCCL 传输(数据中心内部验证基线):

```bash
--update-weight-mode delta
--update-weight-transport nccl
--update-weight-encoding indices # 计算最少,无压缩
```

接收端调优(两种传输都适用):

```bash
--sglang-update-weight-delta-chunk-bytes $((2 * 1024 * 1024 * 1024)) # 每次 load_weights 字节上限
--sglang-update-weight-delta-read-workers 4 # 并行 I/O 线程数(仅磁盘传输)
```

完整启动脚本见 [examples/delta_weight_sync/run-glm4.7-355B-A32B-delta.sh](../../../examples/delta_weight_sync/run-glm4.7-355B-A32B-delta.sh)。

## 工作原理

两种传输共用同一条发送管线、同一种 wire 布局以及同一套接收端解码器;只有每个 bucket 的承载层不同。

**发送端(每次同步,仅 PP 源 rank):**

1. **求差**:通过逐字节比较 `current.view(int_dtype) != snapshot.view(int_dtype)` 检测变化。无算术、无损、与 dtype 无关。
2. **编码**:将变化的 (位置, 值) 对打包成 `__positions__` 字节块 + `__values__` 张量 + per-param 解码 manifest。编码方式(`indices` / `deltas` / `deltas_zstd`)只影响位置如何打包,值始终按参数本身的 dtype 原样发送。
3. **打包并发送**:每个 chunk 编码后累积至 `--update-weight-buffer-size` 字节再 flush:
- NCCL:广播 `(__positions__, __values__)`,Ray RPC 同时携带 `DeltaSpec`(编码 + per-param manifest)。
- 磁盘:每个 flush 写一个 safetensors 文件到 `weight_v{N:06d}/` 目录,后台线程负责 I/O 和可选的 zstd 压缩,不阻塞关键路径。
4. **更新快照**:刚发送的值在 side stream 上 D2H 拷贝,与下一个 chunk 的编码重叠。

**同步结束(仅磁盘):** 写 `DONE` 标记,rank 0 对每个引擎触发一次 HTTP push,所有引擎确认后清理目录。

**接收端:** 两种传输最终都进入同一个 `_apply_delta_payload(encoding, params, positions, values)` 帮助函数。它把每个参数的切片解码成全形状张量,未变化位置填 NaN,然后通过 `model.load_weights(...)` 应用;过程中 `_delta_apply_context` 替换 `Tensor.copy_` / `Tensor.fill_`,对参数存储执行 NaN 掩码覆写。辅助写入(scratch buffer、fp8 scale、MoE bias 等通过 `post_load_weights` 写入的派生张量)保留正常语义。

选择性覆写没有任何算术运算 —— 接收端在变化位置直接写入训练端的精确字节 —— 因此天然无损,也不存在数值漂移问题,无需周期性 base 同步。

## 编码选择

`--update-weight-encoding` 决定位置如何打包。三种编码共用同一种 wire 布局(`__positions__` uint8 块 + `__values__` 张量 + per-param manifest),解码端根据 metadata 分派。

| 取值 | 位置编码 | 推荐场景 |
|---|---|---|
| `indices` | int32 绝对位置(4 字节 / nnz) | NCCL 或高速集群内 FS(≥ ~600 MB/s) |
| `deltas` | uint16 增量(异常时 uint32 兜底,2% 密度下约 2 字节 / nnz) | 中等带宽 FS(~300-500 MB/s) |
| `deltas_zstd` | `deltas` 文件再用 zstd L1 压缩 | 跨数据中心 / 跨区共享 FS(≤ ~300 MB/s) |

**为何 gap 编码更省**:`mask.nonzero()` 返回的位置已经升序排列。密度 `p` 时连续非零位置的期望间隔为 `1/p`,且 `P(gap > 65535) ≈ exp(-p · 65535)`,p = 2% 时这个概率实际上为零,所以 uint16 完全够用,uint32 仅作 per-param 兜底。位置开销比 `indices` 减半,且无损。

**`deltas_zstd` 的额外收益**:在 gap 字节流上做 zstd L1 还能再减少 ~35-40%,代价是每文件约 250ms 压缩 + 150ms 解压。当共享 FS 带宽 ≤ 300 MB/s 时,带宽节省超过额外计算开销。

## 为何不支持 colocated

Colocated 同步通过 CUDA IPC:进程间传递的只是一个内存句柄(~64 B)。Delta 编码的"wire 节省"在此为零,而其簿记开销(快照 + 求差 + 稀疏编码)反而是纯损失。slime 在参数校验阶段拒绝 `--update-weight-mode delta --colocate`。
1 change: 1 addition & 0 deletions docs/zh/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ slime 是 GLM-4.7、GLM-4.6、GLM-4.5 背后的 RL 训练框架。除此之外
advanced/reproducibility.md
advanced/fault-tolerance.md
advanced/pd-disaggregation.md
advanced/delta-weight-sync.md
advanced/sglang-config.md
advanced/megatron-config.md
advanced/arch-support-beyond-megatron.md
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ These examples provide concrete examples to leverage slime in your own RL workfl
- **[low_precision](./low_precision)**: Examples of FP8 training and inference for improved throughput and stability.
- **[multi_agent](./multi_agent)**: Example of running multi-agent RL with `slime`.
- **[on_policy_distillation](./on_policy_distillation)**: Example implementation for on-policy distillation, extending the reinforcement learning pipeline to support teacher–student distillation directly within on-policy training.
- **[delta_weight_sync](./delta_weight_sync)**: Non-colocated weight sync that ships only changed positions + values over disk (training/inference disaggregation) or NCCL.
- **[reproducibility](./reproducibility)**: Guides on achieving bitwise experiment reproduction using deterministic modes.
- **[retool](./retool)**: Demonstrates the retool functionality for tool-enabled language model generation.
- **[search-r1](./search-r1)**: A minimal reproduction of Search-R1, featuring multi-turn conversation and tool-calling.
Expand Down
67 changes: 67 additions & 0 deletions examples/delta_weight_sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Delta Weight Sync

Non-colocated weight sync that ships only changed positions + values instead of every parameter. Two transports over one wire format and one receiver-side decoder:

- **Disk** (the point) — write per-flush safetensors to a shared filesystem; one HTTP push per sync. Designed for **training/inference disaggregation** across datacenters where bandwidth between trainer and rollout is on the order of 100s of MB/s.
- **NCCL** (the baseline) — broadcast each per-flush bucket directly. Used intra-datacenter to validate that the wire encoding and apply logic are correct, separate from any shared-FS variable.

Both modes are lossless by construction (selective overwrite via NaN sentinel; no arithmetic).

## Files

- `run-glm4.7-355B-A32B-delta.sh`: 16-node (8 actor + 8 rollout) GLM-4.7-355B-A32B launcher. Disk transport active by default; NCCL block commented below it.

## Usage

```bash
bash examples/delta_weight_sync/run-glm4.7-355B-A32B-delta.sh
```

**Disk (default):**

```bash
DELTA_ARGS=(
--update-weight-mode delta
--update-weight-transport disk
--update-weight-encoding deltas_zstd
--update-weight-delta-dir /shared/fs/delta-updates
)
```

**NCCL (baseline):**

```bash
DELTA_ARGS=(
--update-weight-mode delta
--update-weight-transport nccl
--update-weight-encoding indices
)
```

Receiver-side byte cap (both transports):

```bash
--sglang-update-weight-delta-chunk-bytes $((2 * 1024 * 1024 * 1024))
```

See [docs/en/advanced/delta-weight-sync.md](../../docs/en/advanced/delta-weight-sync.md) for the wire protocol, encoding choice, and design.

## Results

W&B traces comparing delta sync against the full-sync baseline on GLM-4.7-355B-A32B / DAPO-Math-17k.

![Raw reward](./raw_reward.png)

![Train/rollout logprob abs diff](./train_rollout_logprob_abs_diff.png)

![Update weights time](./update_weights_time.png)

> **Note on the small curve-to-curve gap.** RL training is inherently non-deterministic (cuBLAS reductions, FlashAttention split-K, NCCL all-reduce ordering, dynamic-batch token assignment). Two identically-configured *full*-sync runs would diverge the same way. Delta sync's selective overwrite is bit-exact with full sync per step (no arithmetic, no drift); the trajectory matches, the bits don't.

![Update weights density](./update_weights_density.png)

*Per-sync change density (`perf/update_weights_density`) — fraction of weight positions that moved between consecutive syncs. Sync 0 is omitted: it's the snapshot-seeding pass with density = 1.0, which would compress the y-axis.*

## Why these encoding defaults

Per-sync change density during RL fine-tuning at conservative LRs sits around **2-3%** ([arXiv:2602.03839](https://arxiv.org/pdf/2602.03839) reports ~1% on a related setup; we measured ~2-3% on this run). Below the 3.125% break-even point, gap-encoded positions are smaller than absolute indices — the disk default `deltas_zstd` adds zstd L1 on top to squeeze the gap byte stream further (~35-40%), which is the right tradeoff when shared-FS bandwidth is ≤ 300 MB/s. Intra-datacenter NCCL has no bandwidth pressure, so `indices` (lowest compute, biggest payload) is the cleaner default there.
Loading
Loading