# Hugging Face 用 LoRA 与 Storage Bucket 在 HF Jobs 上跑异步 GRPO，免 NCCL 并提速 3.9 倍

- 来源：Hugging Face：Blog（RSS）
- 作者：Amine Dirhoussi
- 发布时间：2026-09-10 08:00
- AIHOT 分数：61
- AIHOT 标记：精选
- AIHOT 链接：https://aihot.news/items/cmu10mxhc08m6ro2nafd8smam
- 原文链接：https://huggingface.co/blog/asyncgrpo-lora-hfjobs

## 精选理由

Hugging Face 官方用五个实验展示了如何在 Jobs 上分离训练与推理跑异步 GRPO，并通过逐项定位瓶颈把运行提速 3.9 倍，方法可迁移。

## AI 摘要

Hugging Face 在 TRL v1.14 的 AsyncGRPOTrainer 中支持只训练和同步 LoRA adapter，配合 Storage Bucket 挂载与代理路由，让训练 Job 和 vLLM 推理 Job 在不同机器上运行而无需 NCCL。

## 正文

TL;DR

AsyncGRPOTrainer 现在可以训练一个 LoRA 适配器，并仅将该适配器同步到 vLLM（TRL v1.14）。

一个 rank-1 适配器只有几兆字节，因此它可以通过挂载在每个 Job 中的 Storage Bucket 传输，而不必经由 NCCL。训练器和 vLLM 副本作为独立的 Hugging Face Jobs 运行在不同的机器上。

副本前端的一个小型代理会添加认证头，将每次 rollout 路由到已经持有其 KV 前缀的副本，并将适配器加载广播到每个副本。

AsyncGRPO 的指标揭示了瓶颈所在。五次运行采用相同的配方，500 步的耗时从 3 小时 27 分钟缩短到 53 分钟。

LoRA 支持最近落地到了 TRL 的AsyncGRPOTrainer随着PR #7017，并随 TRL v1.14 一同发布。异步训练器现在可以训练适配器而非完整模型，并且只将 LoRA 适配器同步到 vLLM。本文介绍了一个基于它构建的真实项目，在该项目中训练和推理不再共享同一台机器。

LoRA 训练特别适合 RL，正如 Thinking Machines 的博客 LoRA Without Regret 所示。他们表明，LoRA 在策略梯度 RL 中可以与全量微调相媲美，即使 rank 为 1 也是如此。这源于一个事实：优势函数每个 episode 只提供 ~O(1) 比特的信息，因此从信息总比特数的角度来看，每一步并没有太多可学习的内容。一个 rank-1 适配器就有足够的容量来吸收它。

LoRA 训练还会带来系统层面的影响。对于一个 1.5B 模型，rank-1 适配器只有几 MB，而完整模型约为 3 GB。我们不必在每次更新后把完整的策略发送给推理 worker，只需发送适配器即可。vLLM 还可以同时加载多个适配器。旧的 rollout 用它们启动时的策略跑完，而新的 rollout 则使用最新的策略。

TRL 的 AsyncGRPOTrainer 已经将训练和生成分离。训练器和 vLLM 可以运行在不同的机器上，并以各自的速率运行。在单节点或集群环境中，只要两个进程共享文件系统或能组成 NCCL 通信组，这就很容易实现。

我们想要的是用 Hugging Face Jobs 运行同样的配置。本质上，一个 HF Job 就是运行在一台 VM 上的一个容器。这意味着一个 Job 无法拉起多个节点（至少目前如此）来同时承载一个训练器和一组 vLLM 服务器（每个节点最多只能用到 8xH200）。AsyncGRPOTrainer 正是为这种规模而构建的，所以问题就变成了：如果我们放弃训练器和推理服务器必须共享同一节点这一要求，我们能走多远？

嗯，如果做全权重同步，答案会是"走不远"。每次更新都必须在机器之间传输数 GB 的数据，这正是 NCCL 在密集集群中的用途，但 Jobs 无法跨节点通信。没有共享的本地磁盘，显然也没有共享的 localhost。而使用 LoRA，一次同步只有几 MB。至于文件系统部分，HF Jobs 提供了由 Storage Buckets 支持的卷！这些 bucket 随后可以作为 FUSE 文件系统挂载到每个 Job 中，足以充当节点之间的共享文件系统。Job 之间完全不需要网络通路。

这套配置最终相当小巧：

一个 trainer Job，运行 AsyncGRPOTrainer 并使用 LoRA（以及 FSDP，稍后详述），

两个 vLLM Job，各自服务基础模型以及 trainer 最近发布的任何适配器，

一个 Storage Bucket，以相同路径挂载到这三者中，适配器正是通过这种方式从 trainer 传到服务器，

一个 代理服务器。我们会深入探讨为什么需要它，但从高层来看，我们需要一个代理，将每次 rollout 路由到最有可能持有其 KV cache 的副本，并将每次适配器更新广播到所有 vLLM 副本。

架构：利用 Hugging Face Jobs 和 Storage Buckets 🪣

AsyncGRPOTrainer 中新增的仅适配器同步路径是这样工作的。trainer 不向 vLLM 发送张量。每隔几个优化器步骤，它就把适配器保存到 <output_dir>/.vllm_lora/trl-policy-v{N} 下，通过原子重命名发布该目录，然后将其路径发送到 vLLM 的 /v1/load_lora_adapter 端点。vLLM 从磁盘加载这些文件，因此 rollout worker 随后可以请求 model="trl-policy-v{N}"。

这就是 vLLM 中运行时适配器加载的现有工作方式。该端点接收的是路径，而不是张量，因此训练器和服务器需要共享同一个文件系统。在 Slurm 集群上，那就是网络文件系统。在 Jobs 上，我们通过将 Storage Bucket 挂载为卷，使其在每个 Job 中的相同路径下可用，从而实现同样的效果，正如我们之前提到的。在底层，它使用了 hf-mount，将存储桶作为 POSIX 文件系统暴露在容器内部：

hf jobs run ... -v hf://buckets/aminediroHF/asyncgrpo-lora-buckets:/lora ...

TRL 或 vLLM 无需为此做任何改动。训练器写入 /lora/<run>/.vllm_lora/，服务器从同一路径读取。POST 请求中发送的路径在每个容器内部已经是有效的。

三个 Job 和存储桶。TRL 通过 localhost 与代理通信，代理通过 HTTPS 与副本通信，适配器目录通过存储桶挂载进行传输。

请注意，我们还将检查点和最终适配器存储在存储桶中。HF Jobs 是临时的，但被抢占的训练器可以恢复训练，因为最终适配器始终持久化到存储桶中，在 Job 停止时绝不会丢失。

三个 Job

vLLM 副本

每个副本使用一块 GPU 和原版 vllm/vllm-openai 镜像。我们只需要启用 运行时 LoRA 加载并预留足够的适配器槽位。

适配器槽位的数量由 max_staleness 决定。在 AsyncGRPOTrainer 中，每次权重同步都会将策略版本加一，而 max_staleness 指的是一个 rollout 样本在训练器将其丢弃之前，最多可以落后当前策略多少个版本。在 max_staleness=4 下，一个在 trl-policy-v3 下生成的样本，在训练器处于 v7 时仍会被用于训练。一个在 v3 下开始的 rollout 也必须能够在 v3 下完成。因此，在任何时刻，vLLM 都必须服务当前策略以及它之前的四个版本。这就是为什么训练器会保持注册 max_staleness + 1 个适配器版本，并卸载任何更旧的版本。每次同步都会先加载新版本，再卸载最旧的版本，这在交换期间需要多一个槽位。由此得到 --max-loras 6。如果只有五个，vLLM 会在每次同步时悄悄驱逐一个仍有 rollout 在途的策略。

for replica in 1 2; do hf jobs run --detach --flavor h200 --timeout 8h --secrets HF_TOKEN \ --expose 8000 \ -v "hf://buckets/${BUCKET}:/lora:ro" \ -e VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 \ -e VLLM_SERVER_DEV_MODE=1 \ -- vllm/vllm-openai:v0.27.1 \ vllm serve Qwen/Qwen2.5-Math-1.5B --host 0.0.0.0 --port 8000 \ --max-model-len 4096 --logprobs-mode processed_logprobs --generation-config vllm \ --enable-lora --max-lora-rank 1 --max-loras 6 done

我们将 vLLM 固定到 v0.27.1。vLLM 迭代很快，而上述标志以及运行时 LoRA 端点正是该版本所暴露的，因此请把版本视为这套方案的一部分。

还有一种可能的设计是，训练器只保留最新的适配器，并始终以同一个名称发布它。我们没有采用这种方式，因为 vLLM 的前缀缓存是以适配器名称为键的。如果只有一个名称，在权重替换后，先前权重下计算出的 KV 块仍然会匹配，因此 prefill 不会被重做，一个 rollout 可能从某个策略版本获取其前缀，而从下一个版本获取其 decode。训练器将无从分辨，而这会表现为 ratio 偏离 1。带版本的名称使这成为不可能：一个名称始终意味着同一组权重，而缓存的前缀永远无法匹配更新的版本。

数据集选择：Sanity 集

我们选择了 sail/Sanity-Test-R1D-1.5B，即来自 Defeating the Training-Inference Mismatch via FP16（Qi 等人，2025）的数据集。复现代码在 sail-sg/Precision-RL 中。

作者用 DeepSeek-R1-Distill-Qwen-1.5B 为每道 MATH 题生成了 40 个答案。他们保留了成功率在 20% 到 80% 之间的题目，最终得到 1,460 道题。这个数据集非常适合做 RL 验证，因为这些题目对该模型来说既不是已经解出来的，也不是完全没希望的，这意味着模型可以在训练早期获得良好的信号并加以改进。

作为一个稳健的端到端测试，这非常棒：如果某个 vLLM 副本悄悄以适配器名称提供基础模型，我们希望能在几十步内从曲线上看到这一点。此外，这个数据集足够小，不到两小时就能完整跑一遍。

我们还采用了论文 LoRA 脚本中的超参数，见 oat/scripts/lora：Qwen/Qwen2.5-Math-1.5B，LoRA rank 为 1、alpha 为 2，学习率为 4e-5，每个提示词 8 个样本，每步 128 个补全，最多生成 3,000 个 token，上下文为 4,096 个 token。

训练器

训练器使用同一个 vllm/vllm-openai:v0.27.1 镜像，并在其上安装了 TRL。我们当时运行的是 PR 分支；同样的代码现在已随 TRL v1.14 发布。训练脚本是一个普通的 AsyncGRPOTrainer 脚本。唯一与 Job 相关的值是输出目录和服务器 URL。

from peft import LoraConfig from trl.experimental.async_grpo import AsyncGRPOConfig, AsyncGRPOTrainer

config = AsyncGRPOConfig( output_dir="/lora/sanity-lora-r1", vllm_server_base_url="http://localhost:8000", max_staleness=4, weight_sync_steps=4, save_strategy="steps", save_steps=50, ... ) trainer = AsyncGRPOTrainer( model="Qwen/Qwen2.5-Math-1.5B", args=config, peft_config=LoraConfig(r=1, lora_alpha=2, target_modules="all-linear"), ... )

在初始化期间，TRL 会调用 /server_info。如果它发现 lora_config，就会使用仅适配器同步。vLLM 无法直接服务的配置，例如 DoRA、modules_to_save，或秩高于 --max-lora-rank 的情况，会回退到合并权重同步，并给出警告。日志中应包含 Adapter-only vLLM sync enabled。

代理

现在进入有趣的部分。我们需要在训练器和 vLLM Jobs 之间加一个代理，原因有两个：

暴露的 Job 端口要求每个请求都带上 Authorization: Bearer <HF token> 头。代理正是添加该头的地方，因此 TRL 不需要知道它的存在。

我们希望不止一块 GPU 参与生成。在单个 vLLM 服务器上，通常的做法是 --data-parallel-size > 1，但 TRL 在这种模式下拒绝仅适配器同步，理由很充分：对 /v1/load_lora_adapter 的调用只会到达响应它的那个 DP rank，因此其他 rank 会继续以新的策略名称提供基础模型。而在 Jobs 上，这个问题根本不会出现，因为每个副本都是独立的机器。所以数据并行必须上移一层，放在某个能把适配器负载分发到每个副本的东西里。

因此，我们在训练器 Job 的 127.0.0.1:8000 上运行一个小型代理，并让 TRL 指向它，就好像它是一个单独的 vLLM 服务器。除了添加请求头之外，该代理在功能上还做两件事：

它将每个补全请求发送到一个副本，选择副本的方式使得某个提示词的八次 rollout 都落在其前缀已被缓存的地方（详情见下文）。

它会把每一个改变状态的请求，例如适配器加载、暂停与恢复，广播到所有副本，这样同一个策略名称在任何地方都代表相同的含义。

按 KV 前缀路由 rollout

快速回顾一下为什么这很重要。生成一次补全有两个阶段，其工作负载特征截然不同：

prefill 阶段会一次性处理整个提示词，并为每一个提示词 token 计算注意力键和值。

随后 decode 阶段一次生成一个 token，每个新 token 都会关注它之前所有 token 的键和值。

这些键和值就是 KV cache。由于注意力是因果的，一个 token 的 KV 只取决于它之前的 token，而不取决于它之后的 token。因此，两个共享前缀的请求会共享该前缀的 KV，而一个已经将其缓存在本地的副本可以完全跳过那部分 prefill。现在整个关键就在于找到那个副本，这样请求就能受益于落到已经见过其前缀的副本上。

vLLM 将其 prefix KV cache 以 16 个 token 为一个块进行存储。由于 GRPO，rollout worker 会发送 G 个具有相同提示词的请求（在我们的场景中是 G=8）。如果它们全部到达同一个副本，第一个请求会计算 prefill，接下来的七个请求则复用它。在轮询路由下，其中一半会发往没有缓存该前缀的副本，那四个请求将重新执行 prefill 工作，浪费宝贵的 GPU 算力。

我们的路由器的职责是追踪哪个副本已经见过哪个 block hash。一个重要细节是，这些哈希是链式的，因此第 3 个块的哈希代表的是第 1、2、3 个块，而不仅仅是第 3 个块。这对应了因果注意力：第 3 个块的 KV 只有在第 1 和第 2 个块也相同的情况下才有效。我们还用 adapter 名称作为链的种子，因为 KV cache 也取决于生成它的 adapter：为 policy v3 缓存的前缀对 policy v4 毫无用处！

视频 · 前往原文观看

两个提示词和四个请求在两个副本上的路由决策：16-token 块、链式哈希、公共前缀、一次亲和命中以及一次溢出。

视频完整演示了选择副本的整个决策过程。以下步骤通过一个真实的 135-token 补全请求示例（来自 Sanity 数据集问题）进行讲解：

1. 将提示词拆分为块。 路由器接收 token id 并将其切分为 16-token 块，与 vLLM 的做法一致。它只对完整的块进行哈希，因此这里最后 7 个 token 被忽略。

2. 对前缀进行哈希。 每个块都与前一个哈希一起进行哈希，从适配器种子开始。h3 因此按顺序标识块 1、2 和 3。两个提示词如果前 k 个块相同，则它们直到 hk 的哈希都相同。一旦某个块发生变化，它之后的每个哈希也会随之变化。这就是我们用适配器名称作为哈希种子的原因：同一个提示词在 trl-policy-v4 下会从另一个种子开始，无法与来自 v3 的条目匹配。这正是我们想要的，因为旧的 KV 块是用不同的权重计算出来的。

3. 比较两个提示词。 问题 1 有 103 个 token。两个提示词都以相同的 23 个 token 的聊天模板开头。它们的第一个块完全相同，但块 2 已经包含了问题文本。从那里开始哈希就不同了。

4. 存储所有者。 对于每个哈希，路由器会记住哪些副本为其提供了服务，以及哪些哈希排在它之后（我们将后继集合上限设为两个，因为我们只需要知道一个块是有一个后续还是多个后续）。经过几个提示词之后，模板块 h1 由两个副本共同拥有，并且已经有多个后继，h2 到 h8 仅由 A 拥有且各自只有一个后继，而 h2' 到 h6' 仅由 B 拥有。

在实践中，一次运行中的每个提示词都以相同的 token 开头。在这里，就是聊天模板和系统提示词，它们构成了全部 1,460 个问题的前 23 个 token。在智能体场景中，它会是工具描述；在多轮环境中，它会是共享的对话历史。这些块在几秒内就存在于每个副本的缓存中，因此对它们进行匹配并不能告诉我们某个特定提示词位于何处。

如果一个块被每个副本都服务过，或者它有不止一个后继，那么它就是公共块。公共块在路由过程中会被忽略，因为它们无法标识某个特定的提示词。详见下文！

5. 选择一个副本。路由器会统计每个副本上有多少个前导块匹配，并移除公共前缀。剩下的就是该提示词特有的块数量。然后：

如果一个副本拥有特有块，并且它没有被淹没——也就是说它最多比负载最低的副本多 8 个请求——那么请求就会发往那里。我们称之为一次亲和命中。

如果一个副本拥有特有块，但它比负载最低的副本多出 8 个以上的请求，我们就放弃缓存，把请求发送到负载最低的副本。我们称之为一次溢出。

如果没有任何副本拥有特有块，这就是一个新提示词。它会发往负载最低的副本，若出现并列则采用轮询。我们称之为未匹配。

下面是将该规则应用于四个请求的过程。从副本 A 和 B 都各有 3 个请求在途的状态开始，并且两个副本上都只有模板块 h1 是已知的。

请求 1，问题 0，rollout 1。 两个副本都匹配一个块，即模板，而该块是共有的。因此没有任何特定块在任何地方匹配。该请求未被匹配，两个副本负载相同，轮询将其发送到 A。路由器记录 h2 到 h8 归 A 所有。A 现在有 4 个请求在途。

请求 2，问题 0，rollout 2。 相同的提示词。A 匹配全部 8 个块，B 只匹配模板。去掉那一个共有块后，A 有 7 个特定块，B 没有。A 只比 B 领先 1 个请求，远在 8 的限制之内，因此该请求发往 A。这是一次亲和性命中：A 的 KV cache 中已经拥有整个提示词。

请求 3，问题 1，rollout 1。 一个新的提示词。两个副本都只匹配模板块，因此没有任何特定块匹配。该请求未被匹配，发往负载最低的副本 B，B 有 3 个在途请求，而 A 有 5 个。路由器记录 h2' 到 h6' 归 B 所有。

请求 4，问题 0，第 9 次 rollout。假设此时 A 已有 12 个请求在途，而 B 回到了 3 个。A 仍然持有那 7 个特定块，但它现在领先 B 9 个请求，超过了上限。该请求溢出到 B。B 对问题 0 执行一次预填充，路由器将 h2 到 h8 记录为同样归 B 所有。现在每个副本都已服务过这些块，因此问题 0 也变为公共块，其后续 rollout 仅按负载进行分配。

6. 复用预填充。请求 2 正是做这一切的原因。它复用了请求 1 计算出的预填充：块 1 到 8 已经在 A 的 KV cache 中，因此 A 直接跳到解码补全。如果它去了 B，B 会重新预填充全部 135 个 token，而 A 的缓存则闲置不用。请求 3 说明了为什么需要 common 规则。没有它，共享的聊天模板会让每个新提示词看起来都像是缓存命中。请求 4 则将负载保持在有界范围内。节省一次预填充并不值得让某个副本远远落后。

def choose(self, upstreams, model, prompt): hashes = self.block_hashes(model, prompt) matched = self.matched_prefix(hashes) common = self.common_prefix_len(hashes) specific = [max(0, m - common) for m in matched] least = min(u.inflight for u in upstreams) best = max(range(self.n), key=lambda i: (specific[i], -upstreams[i].inflight))

if specific[best] > 0 and upstreams[best].inflight - least <= self.cfg.imbalance: pick = best else: candidates = [i for i in range(self.n) if upstreams[i].inflight == least] pick = candidates[self.rr % len(candidates)] self.rr += 1 ...record `pick` as an owner of every block, and each block's successor... return upstreams[pick]

common 前缀是最麻烦的部分。每个请求都以相同的系统提示词和聊天模板开头。简单的最大前缀匹配会让第一个副本几乎匹配上每一个新提示词。我们改为通过扇出来检测共享前缀：一个有多个不同后继的块是公共块，而一个总是通向同一个后继的块则属于某个特定提示词。只有该公共前缀之后的块才计入亲和性。

广播适配器

代理还需要将适配器加载广播到每个副本。我们将该操作视为全有或全无。每个副本都有自己的存储桶挂载，因此它们不一定会在完全相同的时刻看到新适配器。No adapter found for <path> 错误通常意味着某个存储桶挂载尚未跟上，我们只重试该副本。对于任何其他错误，我们会从已接受该适配器的副本上将其卸载，以确保某个策略名称绝不会只存在于部分副本上。

async def load_one(u): while True: status, _, out = await send(u, "POST", "/v1/load_lora_adapter", headers, body) if status == 200 or "No adapter found" not in out.decode() or time.monotonic() > deadline: return u, status, out await asyncio.sleep(cfg.lora_retry_s)

results = await asyncio.gather(*(load_one(u) for u in ups)) if any(st != 200 for _, st, _ in results): await asyncio.gather(*(send(u, "POST", "/v1/unload_lora_adapter", headers, unload) for u, st, _ in results if st == 200)) return web.Response(status=504 if timed_out else st, text="rolled back on the others")

我们同样以相同方式广播 /pause、/resume 和 /v1/unload_lora_adapter。/health 只有在每个副本都健康时才返回 200。/server_info 和 /v1/models 只需要一个应答。从 TRL 的角度来看，代理就是一个单一的 data_parallel_size=1 服务器，因此它选择仅适配器同步。

我们起初怀疑 Python asyncio 代理是否会成为瓶颈。它并不会（至少在这个规模下不会）。最多有 128 个非流式 JSON 请求在途，而路由只计算几个哈希。一个线程就能轻松处理。一个需要处理更多流量的更精细路由器，可能需要用更快的语言来编写（我们看到你了 🦀）。

完整运行结果

下面的数字来自训练器的记录指标，位于 trackio 上。该次运行使用Qwen/Qwen2.5-Math-1.5B、LoRAr=1在all-linear上，每步 128 个补全，每个提示词 8 次 rollout。它运行 500 步，每 50 步保存一次检查点。训练器使用一个h200x2Job，两个 vLLM 副本各使用一个h200Job。运行这三者每小时约花费 $20。

权重同步

每次同步，训练器时钟，126 次同步 之前 现在（p50）

整个同步 30.8 s 8.5 s（最小 6.6，最大 9.2）

其中：暂停两个副本 0.3 s 0.3 s

adapter 全收集并保存到 bucket 0.6 s 1.1 s

两个副本都接受 adapter ~29 s ~7 s

全部 252 次 adapter 加载均成功 🎉：126 次同步乘以 2 个副本。其中 6 次在第二次尝试时成功，246 次在第三次尝试时成功。

路由

在运行结束时，经过 64,728 次 rollout 之后，代理的计数器显示：

routed [31928, 32800] affinity 54712 spilled 820 unmatched 9196

每个提示词有 8 次 rollout，八个请求中至少有一个必然是冷启动。因此理论最小值是 12.5%。路由器得到 14.2% 未匹配请求、84.5% 亲和性命中和 1.3% 溢出。除非我们开始基于真实测量负载、使用更深入的推理侧指标来观察每个副本的负载，否则这里已经没有多少可优化的空间了。

时间都花在哪里了

第一个配置有一个相当明显的问题：瓶颈在训练器，而不是生成。在 500 步中，我们有：

每个优化器步，p50

步 22.9 s

前向 + 反向 21.9 s

等待 rollout 0.02 s

rollout 队列占用率 512 中的 476

训练器 MFU 3.9 %

部署队列始终处于满载状态，而工作进程主要被背压阻塞。在这种配置下，第二个副本基本上毫无用处。我们将在后文中看到，我们如何通过一系列运行在训练和生成之间转移瓶颈，使运行速度提升了 3.9 倍。

奖励

图 1. trackio 运行 r1-dp2。面板：reward 及其 20 步滚动均值和 50 步分块均值，以及 ratio 在 0.99 到 1.01 的坐标轴上。奖励在 500 步内从 0.15 攀升至 0.44；ratio 全程保持在 0.9993 到 1.0004 之间。

500 步耗时 3 小时 27 分钟。平均奖励从前 20 步的 0.145 上升到后 20 步的 0.438。对于本次测试更重要的是，ratio 每一步都保持在 1.000！由 vLLM 服务的策略始终与训练器用于给 rollout 打分的策略一致。这在全部 126 次同步中都成立。平均陈旧度为 1.5 个策略版本，最大值为 4。trackio 仪表盘中有完整曲线。

我们有了无可辩驳的证据，证明 LoRA AsyncGRPO 有效！现在让我们通过查看近期详细的 AsyncGRPO 指标，来看看如何改进训练运行。

追逐瓶颈的乒乓

异步 RL 是训练与生成之间的流水线。如果一侧跟不上，让另一侧变快也无济于事。幸运的是，在 AsyncGRPOTrainer 中我们添加了足够多的计时和指标，可以直接看到这一点。

所有有用的指标都记录在已记录指标部分。perf/rollout_wait_s告诉我们训练器等待样本的时间有多长。rollout/backpressure_s告诉我们生成在 rollout 队列中等待空间的时间有多长。两者截然相反，不应同时处于高位。结合队列大小，它们能告诉我们哪一侧是瓶颈。

我们运行了五次实验。每一次都从上一轮运行仪表盘中可见的问题出发。除非另有说明，模型、配方和三 Job 布局保持不变。以下名称是 trackio 运行名称。

阅读仪表盘

我们保持以下四组指标可见：

perf/step_s和perf/fwd_bwd_s：优化器一步需要多长时间，以及其中前向+反向传播占多少。如果一步的耗时几乎与前向+反向传播相当，那么训练器显然受算力限制。

perf/rollout_wait_s：训练器在开始一步之前等待样本的时间有多长。接近零意味着生成领先于训练。样本可以立即用于训练。

sample/rollout_queue_size对比queue_maxsize：两侧之间的缓冲区。满意味着生成正在被限流；空意味着训练器正在挨饿。

rollout/backpressure_s 和 rollout/score_block_s：rollout worker 因该缓冲区已满而被阻塞了多长时间。该 worker 是一个两阶段流水线：生成阶段将完成的组交给评分阶段，评分阶段将已评分的样本推入 rollout 缓冲区。当缓冲区已满时，评分阶段无法入队并发生阻塞，这就是 rollout/backpressure_s。随后评分阶段停止排空自己的输入队列，因此生成阶段也无法移交下一组，这就是 rollout/score_block_s。两者是同一个停滞，先在评分阶段被观察到，然后反向传播到生成阶段。

诊断很简单：队列已满且 rollout 等待为零、背压很高，意味着训练器太慢。队列为空且 rollout 等待上升、没有背压，意味着生成太慢。将 perf/mfu_wall_clock 与 perf/mfu_fwd_bwd 进行比较，还能看出训练器 GPU 有多少时间花在等待上而非训练上。

运行 1，r1-dp2：一个跟不上节奏的训练器

图 2. trackio 运行 r1-dp2。面板：perf/step_s、perf/fwd_bwd_s、sample/rollout_queue_size、rollout/backpressure_s。步进时间与前向+反向几乎完全重叠；队列几乎钉在 512 中的 476 附近，背压从未低于每个 rollout 组 11 秒：受训练器制约。

perf/step_s 为 22.9 秒，perf/fwd_bwd_s 为 21.9 秒。前向与反向传播占用了步进时间的 96%。队列始终处于满载状态，训练器等待 rollout 仅需 0.02 秒，而 rollout worker 每组因背压阻塞耗时 15 秒。两个 vLLM 副本的生成速度快于训练器的消费速度。所报告的 4.6k tokens/s 并非它们的实际极限；它们只是无处安放更多输出。

批次指标解释了糟糕的 3.9% MFU。batch/microbatches_per_step 为 64，batch/samples_per_row 为 1.0。每个 rank 处理一条约 1.2k tokens 的序列，每步处理 64 次。这来自参考配方的 per_device_train_batch_size=1。对于 H200 上的 1.5B 模型而言，这完全是延迟受限的。

运行 2，r1-dp2-tb16k：打包微批次

修复方法不是改变批次大小。我们保持每个优化器步进 128 个补全，只改变它们在 GPU 上的排布方式：不再每个微批次放一条序列，而是将多条序列密集地打包进每一行。训练器通过 token-budget batching 支持这一点。借助 token_budget > 0，它将多个样本打包进每个 rank 的一行无填充行中。一个优化器步进处理 gradient_accumulation_steps 行。我们设置 token_budget=16384 和 gradient_accumulation_steps=6。

图 3. trackio 运行 r1-dp2 和 r1-dp2-tb16k 在其前 154 步的叠加对比。面板：batch/samples_per_row、batch/microbatches_per_step、perf/step_s、perf/fwd_bwd_s、perf/mfu_fwd_bwd、rollout/generated_tok_s。打包使每行样本数从 1 增至 13，微批次从 64 降至 6，步进时间从 23 秒降至 5.9 秒，生成速度从 4.2k 提升至 27.5k tok/s，而 vLLM 侧毫无变化。

batch/samples_per_row 从 1.0 提升到约 12.7，微批数量从 64 降到 6。各行填充率达到 95%！前向和反向传播从 21.9 秒降到 5.6 秒，而 MFU 从 3.9% 升到 19%。现在每一步大约训练 150 个样本，因为各行的打包效果比平均长度估计所预测的更好。

生成速度也从 4.6k tokens/s 跃升到 25k tokens/s，尽管我们在 vLLM 侧没有做任何改动。队列不再持续满载，因此副本终于能够运行起来。这就是为什么我们不喜欢孤立地优化流水线各阶段。我们需要谨慎地评估整个系统，因为一个缓慢的阶段可能会掩盖它之前所有环节的真实性能。

第 3 次运行，r1-dp2-tb16k-nockpt：停止重新计算前向

perf/fwd_s 为 1.34 秒，而 perf/fwd_bwd_s 为 5.6 秒。正常的反向传播开销大约是前向的两倍，而在基础权重冻结的情况下应该更接近一倍。3.2 的比值很可疑。

原因是 AsyncGRPOConfig 默认使用 gradient_checkpointing=True。每个微批在反向传播期间都会重新计算其前向。这也解释了为什么一个 16k token 的行在 141 GB 的 H200 上只占用 25 GB！对这个训练器来说这是一种内存优化，但在这个特定场景下我们不需要它：模型足够小，可以在保留激活值用于反向传播的情况下装入显存。

图 4. trackio 运行 r1-dp2-tb16k 和 r1-dp2-tb16k-nockpt 在其前 134 步上的叠加对比。面板：perf/fwd_s、perf/fwd_bwd_s、perf/weight_sync_s、sample/rollout_queue_size、perf/rollout_wait_s、perf/mfu_fwd_bwd。前向+反向下降了一次前向的时间；队列从约 420 降至约 60，rollout 等待从 0.02 s 升至 0.5 s：瓶颈转移到了生成环节。

使用 gradient_checkpointing=False 后，前向和反向降至 4.6 s，几乎正好少了一次前向的时间，MFU 达到 23%。队列现在降至 71，rollout 等待从 0.04 s 升至 0.6 s。训练器消耗样本的速度快于两个副本生成样本的速度。我们成功将瓶颈转移到了生成环节。

这又暴露出两项额外开销。每四步进行一次的 7.6 秒权重同步现在占用了 25% 的墙钟时间。而当每步耗时 23 秒时，这一比例仅为 8%。此外，反向传播仍然比前向传播慢 2.5 倍。在基础权重冻结的情况下，每步约有 2 秒的时间不符合正常的模型计算特征。

运行 4，r1-dp3-tb16k-nockpt：三个副本，以及一个意外发现

由于生成现在太慢了，我们增加了第三个副本。我们还将代理中的适配器重试间隔从 2 s 缩短到 0.5 s，并禁用了 fsdp_reshard_after_forward，以检查 FSDP2 的重新聚合是否导致了反向传播中多出的那 2 秒。

图 5. trackio 运行 r1-dp2-tb16k-nockpt 和 r1-dp3-tb16k-nockpt 在其前 134 步上的叠加对比。各面板：perf/weight_sync_s、rollout/generated_tok_s、rollout/inflight、perf/fwd_bwd_s。同步从 7.6 s 降至 5.8 s；生成和前向+反向没有变化；rollout/inflight 在两次运行中都显示为 128，这正是第三个副本撞上的上限。

权重同步从 7.6 s 降至 5.8 s，因此更短的 retry 起了作用。前向和反向保持在 4.6 s，这排除了重新分片（resharding）的可能。生成从 25k 仅提升到 26k tokens/s。第三个副本基本上什么都没做。

原因就在 rollout/inflight 里：每次运行都是 128。代理显示这些请求在三个副本上拆分为 44 + 43 + 41。max_inflight_tasks 限制的是整个 rollout worker 的并发数，而不是每个副本的并发数。H200 上的一个 1.5B 模型处理 43 个和 130 个并发序列时，每个 token 的成本几乎相同。把 128 个请求分到三块 GPU 上，吞吐量与分到两块 GPU 上几乎一样。

所以 vLLM 并不是瓶颈。我们自己客户端侧的常量才是。我们当初把它设得很保守，因为不知道数百个长 HTTPS 请求经过公共 Jobs 代理会表现如何。而到这时，已有 130,000 次 rollout 完成通过了它，没有出现一次传输错误。

运行 5，r1-dp3-inflight384：解除对在途请求的上限

max_inflight_tasks=384 和 queue_maxsize=768，没有别的。

图 6。全部五次 trackio 运行（r1-dp2、r1-dp2-tb16k、r1-dp2-tb16k-nockpt、r1-dp3-tb16k-nockpt、r1-dp3-inflight384）叠加显示，x 轴以步数为单位。各面板：perf/step_s、reward、sample/rollout_queue_size、sample/staleness_mean。在整个序列中，单步时间从 22.9 s 降至 4.8 s，而奖励曲线始终彼此重合；最后一次运行的队列重新填充至 768 中的约 690，其陈旧度稳定在 2。一旦仪表盘回答了问题，运行 2 到 4 就被提前停止。

在有 384 个请求在途的情况下，每个副本分到 128 个。队列迅速填充到 768 中的约 690 并保持在那里。背压回到 5 秒，rollout 等待降至 0.03 秒。训练再次成为瓶颈！前向和后向传播耗时 4.6 秒，权重同步摊销增加 1.5 秒，单步时间中位数为 4.8 秒。

平均陈旧度从 1.5 个版本升至 2.0 个版本，因为样本在更大的队列中等待更久。这仍低于 max_staleness=4，且 ratio 仍非常接近 1.000。

记分板

500 步 运行 1 r1-dp2 运行 5 r1-dp3-inflight384

挂钟时间 3 小时 27 分钟 53 分钟

perf/step_s，p50 22.9 s 4.8 s

perf/fwd_bwd_s，p50 21.9 s 4.6 s

perf/mfu_fwd_bwd 3.9 % 23.5 %

batch/samples_per_step 128 168

训练样本数 64 000 84 078

perf/weight_sync_s，p50 8.5 s 6.2 s

sample/staleness_mean 1.5 2.0

奖励，前 20 → 后 20 步 0.145 → 0.438 0.145 → 0.416

图 7. trackio 运行 r1-dp2 和 r1-dp3-inflight384，奖励相对于自首次优化器步骤以来的挂钟分钟数。相同的配方，相同的 500 步，相同的最终奖励；运行 5 在 52 分钟内达到，而不是 3 小时 26 分钟。

最终运行快了 3.9 倍，并在多 31 % 的样本上进行训练，奖励曲线基本相同。打包、禁用检查点以及提高在途限制带来了差异。在每种情况下，仪表盘都在前十分钟内清楚地显示了这一点。

试一试

git clone https://github.com/AmineDiro/hfjobs-lora-buckets && cd hfjobs-lora-buckets hf auth login MAX_STEPS=20 RUN_TAG=smoke ./run_all.sh --wait MAX_STEPS=500 ./run_all.sh --wait TOKEN_BUDGET=16384 GRAD_ACCUM=6 GRADIENT_CHECKPOINTING=0 PROXY_LORA_RETRY_S=0.5 \ MAX_INFLIGHT=384 QUEUE_MAXSIZE=768 MAX_STEPS=500 ./run_all.sh --wait

参考文献

John Schulman 等人，LoRA Without Regret，Thinking Machines Lab，2025 年 9 月。论证 rank-1 LoRA 在策略梯度 RL 中可与全量微调相媲美，以及原因。

TRL，AsyncGRPOTrainer 及其 记录的指标。

TRL PR #7017：对 AsyncGRPOTrainer 的 PEFT/LoRA 支持，并带有仅适配器 vLLM 同步，已在 TRL v1.14 中发布。

Hugging Face Jobs 和 Storage Buckets；hf-mount。

hf-mount-repro：30 秒负缓存卡顿的双脚本复现。

本文中每次运行的 trackio dashboard。

Penghui Qi、Zichen Liu、Xiangxin Zhou、Tianyu Pang、Chao Du、Wee Sun Lee、Min Lin，Defeating the Training-Inference Mismatch via FP16，arXiv:2510.26788，2025。Sanity 数据集 sail/Sanity-Test-R1D-1.5B 与 LoRA 配方 sail-sg/Precision-RL 的来源，oat/scripts/lora/bf16_grpo_tis_lora.sh。

@article{qi2025precisionrl, title={Defeating the Training-Inference Mismatch via FP16}, author={Qi, Penghui and Liu, Zichen and Zhou, Xiangxin and Pang, Tianyu and Du, Chao and Lee, Wee Sun and Lin, Min}, journal={arXiv preprint arXiv:2510.26788}, year={2025} }
