HuggingFace展示大规模模型训练的工程优化技术。对从事LLM开发的工程师有深度参考,但受众相对专业。
异步强化学习有一个不为人知的秘密:每一步训练时,训练器都必须将整个模型发送给推理引擎。对于采用 bf16 的 7B 模型,这意味着 14 GB;对于前沿的 1T 参数模型检查点,则大约是 1 TB。每一步都是如此。
事实证明,其实没有必要这样做。在连续两次强化学习优化器步骤之间,大约 99% 的 bf16 权重在比特层面完全相同(即使在最坏情况下,也不会低于 98%)。真正的增量非常小。
我们合入了一个 TRL PR:它仅将发生变化的元素编码为稀疏 safetensors 文件,上传到 Hugging Face Bucket,然后通知 vLLM 获取该文件。在 Qwen3-0.6B 上,每一步的传输负载从 1.2 GB 降至 20~35 MB。
锦上添花的是:我们完成了一次完整的解耦训练——训练器运行在一台机器上,vLLM 位于一个 Hugging Face Space 中,Wordle 环境位于另一个 Space 中,权重则通过单个 Hub bucket 流转。不需要共享集群,不需要 RDMA,也不需要 VPN。
异步强化学习的成本刚刚大幅降低。请继续阅读。
如果你读过我们上一篇关于异步强化学习训练现状的文章,应该已经知道结论了。每一个异步强化学习库,无论它如何称呼自己的“actor 模型”,也无论它的 NCCL 后端刷成了什么颜色,最终都会在同一个根本问题上绊倒:权重同步。
推理引擎使用的是第 N 步的策略,而训练器刚刚完成第 N+1 步。新的权重必须从一端传到另一端,否则推理引擎很快就会无可挽回地偏离当前策略。无论你运行的是同步还是异步训练,这都位于关键路径上:阻塞式传输会浪费 GPU 的空闲算力,让它们无法生成 token。通过稀疏增量路径,可以将这段空闲时间压缩到几秒;训练器甚至不必等待推理引擎准备就绪:优化器步骤一完成,它就发布“权重就绪”信号,并将权重上传到共享 bucket,而推理引擎可以按照自己的节奏获取权重。
Fireworks 在其文章《Frontier RL Is Cheaper Than You Think》中给出了一个令人印象深刻的数字:对于一个采用 fp8 的前沿 1T 参数模型检查点(这是他们的设置),完整快照大小为 1024 GiB。按照传统认知,每次更新 rollout 集群时,都必须传输这么多数据。这样的数字足以让人开始绘制包含超大规模集群、RDMA 网络和专用跨区域链路的架构图。他们测得,相邻检查点之间的平均增量为 20.3 GiB,相当于完整模型的 1.98%;并且“连续检查点之间,超过 98% 的 bf16 格式权重在比特层面保持等价”。
Cursor 的 Composer 2 报告讲述了一个类似的故事。他们让训练和推理分别运行在不同区域,再通过一个共享 S3 bucket(这是他们的原话)将两者连接起来,训练器会在每个训练步骤中向其中上传压缩后的权重差异。每个集群独立下载共享增量链并据此重建权重,“无需与训练集群建立直接连接”。双方从不直接相互传递参数。bucket 就是传输线路。
两篇论文在三点上达成了一致。我们想慢慢地再重复一遍,因为本文余下的内容,本质上就是对此进行忠实的开源实现:
大多数权重在相邻的两个强化学习步骤之间其实并未发生变化。
如果只发送发生变化的部分,带宽成本将降低大约两个数量级。
如果通过共享对象存储传输这些极小的差异,训练器和推理集群就不再需要位于同一个数据中心。
唯一缺少的,就是一个可以通过 pip 安装的实现。因此,我们写了一个。
在连接任何组件之前,有必要先理解为什么这件事能够成功。“98% 的权重没有变化”听起来很像某种只在演示中有效、到了真实环境就会崩溃的数字。但事实并非如此。它是 bf16 算术特性与强化学习所用学习率共同作用的自然结果。
一个 bf16 数字有 7 个尾数位。在两个连续的 2 的幂之间,恰好存在 27=1282^7 = 12827=128 个可表示值,因此,在 ∣w∣|w|∣w∣ 附近,相邻 bf16 数字之间的间距大约为 ∣w∣⋅2−7|w| \cdot 2^{-7}∣w∣⋅2−7。当更新量小于该间距的一半时,它就会被 bf16 类型转换吸收,也就是说,当 ∣Δw∣<∣w∣/256|\Delta w| < |w|/256∣Δw∣<∣w∣/256 时会发生这种情况。这就是 PULSE 在其图 3 中绘制的“bf16 可见性阈值”。
现在看看 Adam 做了什么。假设强化学习的学习率为 3×10−63 \times 10^{-6}3×10−6,单个权重的更新为:
Δw=−η⋅m^v^+ϵ\Delta w = -\eta \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon}Δw=−η⋅v^+ϵm^
归一化步长 m^/(v^+ϵ)\hat{m}/(\sqrt{\hat{v}}+\epsilon)m^/(v^+ϵ) 大致为 1 的量级,因此 ∣Δw∣≈η≈3×10−6|\Delta w| \approx \eta \approx 3 \times 10^{-6}∣Δw∣≈η≈3×10−6。对于大多数权重,∣w∣|w|∣w∣ 通常处于 10−210^{-2}10−2 到 10−110^{-1}10−1 之间(PULSE 报告称,具有代表性的 LLM 权重中位数为 0.019)。在这个数量级上,阈值 ∣w∣/256|w|/256∣w∣/256 大约为 4×10−54 \times 10^{-5}4×10−5 到 4×10−44 \times 10^{-4}4×10−4,比更新量更大。
换句话说:优化器正在低声耳语,而 bf16 听不见。更新被舍入过程吸收,www 的字节表示不会改变;从推理引擎的角度来看,这个权重根本没有移动。将这种情况乘以数亿个参数,就能免费得到超过 99% 的稀疏率,而且完全没有任何近似。
这正是 PULSE 论文(Mihai 与 Belilovsky,2026)所形式化论证的观点。他们定义了两个阈值。吸收边界 10η10\eta10η 是 Adam 更新的保守最坏情况,而有效边界 η\etaη 则是实际运行时所处的区域。bf16 可见性阈值为 ∣w∣/256|w|/256∣w∣/256。只要更新量低于可见性阈值,它就会被吸收,bf16 字节也不会发生变化。他们的图 3 将这两个边界与一组具有代表性的 LLM 权重绘制在一起,结论非常明确:当 η=3×10−6\eta = 3 \times 10^{-6}η=3×10−6 时,即使是吸收边界本身,也已经低于模型中几乎每个权重的可见性阈值。他们在 Qwen2.5(0.5B/1.5B/7B)、Llama-3.2-3B 和 Gemma-3-4B 上进行了实证测量,结果始终显示:每一步的平均稀疏率约为 99%,在 400 个训练步骤中的标准差为 0.2%~0.4%。即使是最坏的步骤,稀疏率也保持在 98% 以上。因此,变化比例低于 1% 并非一次幸运的测量结果,而是算术性质所保证的结果。
我们不必通过分析来预测这一点(实际上,我们曾尝试根据 Adam 的 mmm 和 vvv 统计量预测变化掩码,但召回率只有令人沮丧的 30%,稍后会详细介绍)。我们只需要观察哪些字节发生了变化即可。这对应于每个参数的一个极小布尔张量,可以在执行优化器步骤前后计算出来。
接下来是这个故事的第二部分。从这里开始,本文不再只是对 Fireworks/Cursor 方案的转述,而开始呈现 Hugging Face 自己的特色。
Bucket 是 Hub 上一种专为高频对象存储设计的仓库类型。不需要提交仪式,不需要 PR 工作流,也没有 LFS 的各种特殊问题。你可以添加文件、列出文件以及下载文件。它的 Python 接口只有两个函数:
from huggingface_hub import batch_bucket_files, download_bucket_files
# Trainer side
batch_bucket_files("my-org/wordle-deltas", add=[(buffer, "deltas/step_000042.safetensors")])
# Inference side
download_bucket_files("my-org/wordle-deltas", files=[("deltas/step_000042.safetensors", local_path)])
就是这样。只需要调用两个函数,权重就已经开始传输了。
在底层,buckets 由 Xet 提供支持。Xet 是 Hub 的内容定义分块存储层。Xet 会检查你上传的每个文件,根据文件的实际内容对其进行分块(而不是按照固定偏移量切分),并与 bucket 中已有的所有内容进行去重。在这里,它带来的实际效果令人惊喜:即使我们懒得编写稀疏编码,而是在每一步都上传完整锚点,Xet 仍然只会传输发生变化的块。稀疏编码与 Xet 可以叠加工作:我们只为发生变化的内容付出传输成本,而且只需要付出一次。
这相当于 Fireworks 和 Cursor 都采用的“共享 S3 bucket”的开源版本。不同之处在于,该存储层已经具备内容哈希能力,你现有的 HF token 已经拥有访问权限,而且它可以与技术栈中的其他部分原生组合,包括 Spaces、datasets 和 models。
完整架构恰好由三个组件和一个共享底层组成:
训练器。可以放在你想放的任何地方。一块 GPU、八块 GPU,甚至是一台通过 USB 连接 H100 的笔记本电脑——我们不会评判。它拥有模型权重、运行优化器,并生成稀疏增量。
HF Bucket。一个仓库,包含两个前缀:anchors/ 用于存放偶尔生成的完整快照,deltas/ 用于存放这些快照之间的稀疏补丁。这是双方唯一需要达成一致的东西。
vLLM 推理服务器。无论你在哪里,最关键的是它不一定要和训练器在同一位置。从 bucket 中拉取数据,应用 delta,然后提供推理。
环境。以通常的方式(HTTP、函数调用,或任何你的环境能接受的方式)接入推理服务器。
关键的内在特性——这是 Cursor 论文重点强调的,在这里完全成立:训练器和推理服务器从不直接交换权重数据。它们只交换一个微小的 POST 请求,包含 {"repo_id": ..., "filename": ...},这就是整个控制平面。实际的字节传输发生在各方和 bucket 之间,平行进行,无需共享网络基础设施。
这在实践中为什么很重要:
推理服务器可以在另一个地域、另一个云,或 Hugging Face Space 内位于 NAT 后面。它不在乎。
N 个推理副本可以从同一个 bucket 中拉取相同的 delta,Xet 可以跨所有副本去重字节。
训练器永远不需要知道有多少推理副本存在、它们在哪里,或其中某个副本是否刚刚崩溃。
训练器写入。副本读取。Hub 负责管道工作。
现在我们来揭开黑箱。协议有四个部分:线格式、bucket 布局、一个 30 行的 vLLM 扩展,以及一个训练器端的变化检测器。说实话,代码量比听起来要少。
我们选择了 safetensors 作为磁盘和网络传输格式。它已经是 Hub 上的规范 checkpoint 格式,每个合理的框架都能读取它,而且头部带有任意字符串元数据。我们用元数据字段来隐藏协议。
bucket 中有两种文件。
锚点(Anchors)看起来像一个普通的 checkpoint:每个参数一个张量,完整的 bf16 权重,每隔 NNN 次同步写入一次(我们默认 N=10)。
anchors/step_000010.safetensors
├── model.layers.0.self_attn.q_proj.weight (bf16, full)
├── model.layers.0.self_attn.k_proj.weight (bf16, full)
└── ...
metadata:
sparse=False, model_version=10, sparsity=0.0
有意思的部分是 delta。对于每个实际发生变化的参数,我们存储两个条目:一个元素索引的扁平 int32 张量,以及这些索引处的 bf16 值张量。
deltas/step_000011.safetensors
├── model.layers.0.self_attn.q_proj.weight.indices (int32, [num_changed])
├── model.layers.0.self_attn.q_proj.weight.values (bf16, [num_changed])
├── model.layers.0.mlp.gate_proj.weight.indices
├── model.layers.0.mlp.gate_proj.weight.values
└── ...
metadata:
sparse=True, model_version=11, sparsity=0.9938, changed_params=[...]
这个选择带来了几个不错的结果:
Delta 就是一个文件。你可以在 Python 中用 safe_open(...) 打开它,检查其中的每个张量。没有专有的帧结构,没有长度前缀,没有版本握手。
元数据是自描述的。接收方读取 sparse=True/False 并分支处理。不需要单独的清单。
在推理端可以通过 mmap 实现零复制,当你每隔几秒钟做一次这个操作时这很重要。
节奏很直观:每隔第 N 步创建锚点,中间创建 delta。两者都最终被存储在同一个 bucket 中,分别在 anchors/ 和 deltas/ 前缀下。每个新的推理副本只需要获取最新的锚点,然后重放之后的所有 delta。
训练器需要知道哪些 bf16 元素实际改变了。我们用一个微小的 BF16ChangeDetector 在优化器上注册前置和后置步骤钩子来做这个:
class BF16ChangeDetector:
def __init__(self, model, optimizer):
self._pre_step_bf16: dict[str, torch.Tensor] = {}
self._validated_masks: dict[str, torch.Tensor] = {}
optimizer.register_step_pre_hook(self._pre_step_hook)
optimizer.register_step_post_hook(self._post_step_hook)
def _pre_step_hook(self, opt, args, kwargs):
for p in self._params:
self._pre_step_bf16[name_of(p)] = p.detach().to(torch.bfloat16).cpu().clone()
def _post_step_hook(self, opt, args, kwargs):
for p in self._params:
self._validated_masks[name_of(p)] = (
p.detach().to(torch.bfloat16).cpu() != self._pre_step_bf16[name_of(p)]
)
PR 中的实际代码有更多的管道代码(通过 data_ptr() 将优化器参数对象匹配到模型参数,因为 Accelerate 把它们包装成了不同的 Python 对象),但这个想法可以写在餐巾纸上:快照、步骤、差异。
这是基本事实。我们尝试过更优雅的路径——从 Adam 的 mmm 和 vvv 统计量预测掩码,直接使用 bf16 ULP 阈值。原理上是行得通的。但在实践中,召回率只有大约 30%,这意味着我们会发送一个缺少三分之二实际更新的 delta。Adam 的归一化足够复杂,使得解析阈值不够严格。所以我们就直接比较字节。这在训练器端只需要一次 bf16 CPU 快照,我们愿意为此付出代价。
新的 _sync_weight 流的四个阶段是:
上传,同时推理保持运行。训练器将掩码元素编码为 safetensors 缓冲区并推送到 bucket。vLLM 在整个这个步骤期间仍在愉快地提供旧策略。
暂停 vLLM。一个短暂的 HTTP 调用,几百毫秒。
发送 /update_weights 信号。发送 bucket 坐标。vLLM 下载、应用、返回。
恢复。vLLM 重新上线。
日志行说明了一切:
Delta: 1234567/200000000 elements changed (sparsity=99.38%)
[delta_engine] uploaded user/wordle-deltas/deltas/step_000042.safetensors (27.4 MB, ...)
Weight sync: done. Total 9.4s (inference paused 1.1s)
关键在于括号里的内容。推理被暂停了 1.1 秒。剩下的 9.4 秒用于上传,这发生在推理服务器仍在生成 token 的时候。使用 NCCL,我们要为整个同步时间付出暂停代价。这里我们只支付暂停时间,其余的时间是后台时间。
vLLM 有一个干净的抽象叫做 WeightTransferEngine。我们实现了一个 DeltaWeightTransferEngine,其 receive_weights 方法大概是这样:
def receive_weights(self, update_info, load_weights):
download_bucket_files(update_info.repo_id, files=[(update_info.filename, local_path)])
with safe_open(local_path, framework="pt", device="cpu") as f:
meta = PatchMetadata.from_metadata_dict(f.metadata())
if not meta.sparse:
# Anchor: feed every tensor and snapshot for future deltas
for name in f.keys():
tensor = f.get_tensor(name)
self._bf16_snapshot[name] = tensor.clone()
load_weights([(name, tensor)])
else:
# Delta: apply (indices, values) to snapshot, hand full tensor to vLLM
for name in json.loads(meta.changed_params):
indices = f.get_tensor(f"{name}.indices").long()
values = f.get_tensor(f"{name}.values")
snap = self._bf16_snapshot[name].flatten()
snap[indices] = values
self._bf16_snapshot[name] = snap.reshape(self._bf16_snapshot[name].shape)
load_weights([(name, self._bf16_snapshot[name])])
我们通过 vLLM 的 --worker-extension-cls 标志注册它,这意味着不需要 fork vLLM。你在和 vLLM 相同的镜像中安装 TRL,将 CLI 指向我们的类,就完成了。
值得一提的是:vLLM 自己有一个进行中的工作来原生支持稀疏权重传输,vllm-project/vllm#40096。它直接在 WeightTransferEngine 基类上添加 receive_sparse_weights() 和 trainer_send_sparse_weights(),补丁编码为 (indices, values),通过 index_copy_() 原地应用,完全避免 GPU/CPU 验证往返。PR 报告对 Qwen3-1.7B 的稀疏补丁传输为 0.16 MB,耗时 0.40 ms,而完整密集发送为 942 MB,耗时 192 ms。
我们在推理端的实现中有一个实话实说的注意事项:我们保持一个 CPU bf16 模型快照,这样我们就能从稀疏的 (indices, values) 补丁重建完整张量,因为 vLLM 中的 load_weights 目前期望完整张量。一旦 #40096(或其后继)落地并暴露一个原地稀疏 load_weights 路径,我们就能直接在 GPU 上应用索引,并丢弃快照!
这是我们自豪的部分。到目前为止我们描述的所有内容都可以在你的笔记本电脑上工作,但通过 Hub bucket 路由权重的重点是训练器和推理服务器不必彼此靠近。所以我们用三台没有共享网络的机器运行了一个完全分解的训练:
一台装有一个 GPU 的机器运行训练器。
一个 Hugging Face Space(Docker SDK,L4 GPU)用我们的扩展类运行 vLLM。
第二个 Hugging Face Space(CPU)运行 Wordle 环境服务器,有 256 个并发会话容量。
中间的一个 Hub bucket。
设置这一切基本上就是几个 hf CLI 调用。vLLM Space 的 Dockerfile 本质上是上游 vLLM 镜像加上 pip install trl@... 再加上入口点:
FROM vllm/vllm-openai:latest
RUN pip install "trl @ git+https://github.com/huggingface/trl.git@delta-weight-sync"
ENV VLLM_SERVER_DEV_MODE=1
EXPOSE 7860
ENTRYPOINT ["vllm", "serve", "Qwen/Qwen3-1.7B", \
"--host", "0.0.0.0", "--port", "7860", \
"--worker-extension-cls", "trl.experimental.async_grpo.delta_engine.DeltaWorkerExtension", \
"--weight-transfer-config", "{\"backend\":\"nccl\"}", \
"--max-model-len", "32768", \
"--gpu-memory-utilization", "0.8"]
将其部署为 Space:
hf repos create $USER/vllm-wordle-inference \
--type space --space-sdk docker --flavor l4x1 \
--secrets HF_TOKEN=$HF_TOKEN
hf upload $USER/vllm-wordle-inference examples/scripts/openenv/vllm_space/ --type space
然后可以从地球上任何能通过 HTTPS 通信的地方启动训练:
python examples/scripts/openenv/async_wordle.py \
--vllm-server-url https://$USER-vllm-wordle-inference.hf.space \
--env-url https://openenv-wordle.hf.space \
--delta-sync-repo-id $USER/wordle-deltas \
--model Qwen/Qwen3-1.7B
训练器从不打开端口。Space 看不到训练器的 IP。Wordle 环境不知道它们存在。它们都通过 Hub 通信。训练在即时 EOS 合理性检查上收敛,然后在真实的 Wordle 轮出上:奖励上升,增量负载保持在 20 到 35 MB 范围内,每次同步的推理暂停窗口保持在约一秒钟。完整的运行日志链接在配套的 PR 中。
几件事,我们认为它们很重要。
不需要集群的异步强化学习训练。 如果你有一个 GPU 和一个 Hugging Face 账户,你现在可以进行真正的分布式训练。你的训练器在 GPU 上;你的轮出部队驻守在 Space 中;你的环境在另一个 Space 中;权重通过一个 bucket 流动。这曾经需要要么是共置的设置(带来了所有的吞吐量折衷),要么是一个具有共享网络的真正集群。现在都不需要了。
多副本推理,免费的。 启动两个 vLLM Space,或十个。它们都从同一个 bucket 中拉取。Xet 通过内容寻址存储,因此连续的锚点在静态时共享块(这可以防止你的 bucket 爆炸),Hub 的边缘缓存使得相同文件的重复下载成本低廉。想要一个全球分布的轮出部队?现在这是一个小型 DevOps 工作,而不是一个研究项目。
一个可以用你现有工具调试的有线格式。 一个增量就是一个 safetensors 文件。你可以从笔记本中 safe_open 它,列出它的键,检查索引,自己计算稀疏性。我们已经在不透明的 NCCL 流上花了足够多的小时在 tcpdump 上,所以很欣赏这一点。
通向前沿规模的道路。 20 到 35 MB 的数字是针对 Qwen3-0.6B 的。有趣的问题是一旦你转动拨盘会是什么样的曲线。让我们做餐巾纸数学。
取 Llama-3.1-405B。在 bf16 中这是磁盘上的 810 GB。PULSE 在强化学习学习率下测量约 99% 的平均每步稀疏性,所以实际的增量约为参数的 1%。它们部署测量的编码在 7B 模型上达到 108 MB,这是 PULSE 报告的约 130× 缩减。线性扩展到 405B,增量约为每步 6 GB。
这在挂钟时间上能为你购买什么?NCCL 在集群内是快的,没错。假设一个慷慨的 100 GB/s 聚合广播带宽(多节点、RDMA,等等)。完整同步是 810 GB / 100 GB/s ≈ 每步 8 秒的推理暂停。通过增量路径,训练器在生成继续运行时在后台向 bucket 流 6 GB,轮出服务器的实际暂停窗口只是应用步骤,这在这个规模上约为几秒钟。所以即使在我们离开集群之前,增量也会将可见暂停减少 4× 并将线路上的字节数减少约 130×。
现在离开集群。NCCL 根本不能跨越云。一旦你想要在 us-east 的轮出部队,另一个在 eu-west,也许还有一个在 Hugging Face Space,基于 bucket 的路径是唯一的路径。在 1 GB/s 的可用互联网带宽下,单次完整广播需要 13 分钟;增量在 6 秒内完成。
对于 Fireworks 框架中的 1 TB 级模型,他们自己的测量数字显示 20.3 GiB 增量对比 1024 GiB 完整快照,约 50× 的缩减。PULSE 的更紧凑的稀疏编码会进一步推进这一点(外推约 15 GB 每个增量,更接近约 65×)。无论如何,你处于这样一个体制,通过商品对象存储运送权重不再是一个黑客行为,而是开始成为唯一的合理架构。
我们不是假装这已经完成了。这是诚实的列表。
两个 CPU bf16 快照,一个太多了。 训练器保留一个(用于变化检测器),轮出服务器保留一个(用于重构 vLLM 的 load_weights 的完整张量)。第一个我们被卡住了,直到有人找到一个紧密的分析掩码,这比看起来要困难。第二个消失当 vLLM 获得一个稀疏 load_weights API 时。PR 即将推出。
固定锚点节奏。 我们目前每 NNN 步转储一个完整的锚点。一个自适应策略("当累积漂移超过 X 时锚定")会在长时间运行中减少锚点成本。
多节点 FSDP2 训练器。 BF16ChangeDetector 是围绕每进程优化器钩子构建的。它应该干净地推广到 FSDP2,但我们还没有在多节点规模上测量它。PR 中有一个 TODO 带上我们的名字。
钩入优化器。 我们从 (m,v) 单独预测掩码的尝试给出了低回忆,这意味着分析 bf16 阈值做的事情比教科书公式建议的更微妙。我们很想听到任何破解这个的人的意见。
与有线压缩堆叠。 稀疏 safetensors 和每块 gzip 是正交的。我们还没有尝试过组合它们。尽管我们不期望有巨大的压缩收益。
PR:huggingface/trl#5417。分支是 delta-weight-sync。
完整的 Wordle 示例:examples/scripts/openenv/async_wordle.py。
Spaces Dockerfiles:examples/scri