训练不稳,可能是推理引擎的锅:Miles 的三条对齐路线

2026-09 基于 radixark/miles 源码(HEAD 7daba3a,2026-09-14)与其 arXiv 技术报告(2609.08368)逐条核对。文中源码引用以 file:line 标注;性能数字全部为厂商口径,截至写作时没有任何第三方独立复现

一、同一份权重,两个概率

把一个训练好的模型权重同时交给两个系统(SGLang 做推理、Megatron 做训练),让它们对同一个 token 序列各算一遍 log-probability,得到的数字不会一样。

这不是 bug。两边用不同的 kernel、不同的数值精度、不同的归约顺序做同一件事,而浮点加法不满足结合律,结果自然不同。

问题在于,RL 后训练要求它们相同。

RL 的每一步都要算这样一个比值:当前策略生成某个 token 的概率,除以生成它时所用策略的概率。这正是 PPO 一类的算法的立足点:只有分子分母出自同一个策略,这个比值才是无偏的。而 rollout 是 SGLang 产的、概率是 Megatron 重算的,两者只要对不上,策略梯度的方向就不再是你以为的那个方向

Miles 的文档把这件事写成了一句话:

A common failure mode in MoE RL is precision drift between training and inference. Pipelines that train in BF16 and serve in FP8 accumulate per-layer numerical disagreement, which compounds into divergent log-probabilities and gradients pointing in unintended directions.

docs/advanced/low-precision.md

先看这个差异的真实量级。Miles 技术报告里的 GLM-5.2 参考运行,训练与推理对同一批采样 token 的 log-prob 分歧,100 步均值是 0.0369;LMSYS 博客里 DeepSeek-V4.1 那次运行,平均绝对差是 0.017–0.025 nats

两个数字都不大,也都没有到 0。后面会看到,「到 0」是有专门做法的,而那套做法目前还只能用在很小的模型上

二、裂缝从四个地方来

要理解「对齐」为什么会成为一个工程问题,先得知道不一致是怎么攒出来的。

精度路径不同。 训练侧常跑 BF16,推理侧为了吞吐跑 FP8 甚至 FP4。同一个权重,一边按 BF16 舍入、一边按 FP8 舍入,每一层就多出一点偏差;几十层叠上去,log-prob 的差就出来了。Miles 文档用的词是「compounds」。

kernel 实现不同。 矩阵乘法在 GPU 上怎么切块、怎么累加、用哪条指令路径,不同实现给出的有效数字不同。Miles 在实现逐位对齐时踩到的正是这一层:它必须让两侧都走 FlashAttention 3,因为只有这个后端在 prefill 与 decode 之间是逐位相等的。

batch 组成会影响结果。 大多数 kernel 的输出依赖 batch 里的其他样本,归约顺序随 batch 形状变。这类 kernel 叫 batch-variant,是数值对齐的前提性障碍,Miles 用的是 Thinking Machines Lab 开源的那组 batch-invariant 算子。

MoE 的路由可能不同。 专家路由靠 Top-k 打分,一旦出现分数并列,两个系统可能选中不同的专家。走错专家意味着换了一条计算路径

前三条是数值问题,第四条是结构问题,处理方式也不一样。

三、路线一:把差异消掉

Miles 最常见的那条路,目标定得很绝对:

True on-policy ensures that the log probs generated by inference engine (SGLang) is strictly equal to the one generated by the training Engine.

examples/infra_features/true_on_policy/README.md:3

实现思路是让训练与推理的每一个操作都逐位相等(bitwise equal):

  • 注意力:两侧都用 FlashAttention 3,因为它在 prefill 与 decode 之间逐位相等;
  • GEMM:用 DeepGEMM 保吞吐,同时靠它对 tensor core 指令的选择保持一致性;
  • batch-invariant 算子:逐位对齐的前提,采用 Thinking Machines Lab 的开源实现;
  • torch.compile:把大量小 kernel 合并,减少逐位对齐需要覆盖的面。

再往下是磨细节:对齐两侧的 op dtype、替换 Megatron 侧 TE 的 fused 实现、关掉 fused rotary embedding、SGLang 开 deterministic inference、把 cuBLAS/TE/NCCL 都配成确定性、rollout 侧改用一次 prefill pass 重新打分而不是回报 decode kernel 的 log-prob(miles/true_on_policy/config.py:204-207)。

验收标准是一个数:wandb 里的 train/train_rollout_logprob_abs_diff 必须严格等于 0。这个指标在训练循环里实时算并上报(miles/backends/training_utils/loss_hub/losses.py:340-374),CI 里的断言用的是无容差的 Python ==

# miles/backends/training_utils/log_utils.py:267-302
if args.ci_test and args.true_on_policy_mode and not args.ci_disable_logprobs_checker:
    assert log_dict["log_probs"] == log_dict["rollout_log_probs"], (
        f"CI check failed: true_on_policy_mode is enabled, but log_probs ...")

同一个文件里其他检查都留了余地:训练侧与参考模型之间 1e-8,训练侧与推理引擎之间 0.03。唯独这一条不留。「逐位对齐」在这里是会挂 CI 的断言。

代价,以及它现在能覆盖多远

代价很直接:确定性 kernel 放弃一部分优化空间,rollout 会变慢。Miles 官方示例的结论是「rollout time shows an acceptable slowdown」(同 README),但没给具体百分比。

更硬的边界在代码里:

第一,只有 FSDP 后端支持。 Megatron 是 Miles 的默认后端、也是万亿参数级唯一现实的选择,而它直接抛异常:

# miles/backends/megatron_utils/arguments.py:14-20
def set_default_megatron_args(args):
    if getattr(args, "true_on_policy_mode", False):
        raise NotImplementedError(
            "--true-on-policy-mode is not supported on the megatron backend with this Megatron "
            "version; support lands in a follow-up PR. Use --train-backend fsdp for true-on-policy."
        )

第二,只有 Qwen3 dense 一族注册了对齐契约。 启用它的前提是存在对应模型族的 parity contract:

# miles/true_on_policy/schema.py:26-34
QWEN3_DENSE_TRUE_ON_POLICY_V1_SCHEMA = TrueOnPolicyContractSchema(
    name="qwen3_dense_true_on_policy_v1",
    model_family="qwen3_dense",
    logprob_contract="sglang_prefill",
    sglang_attention_backend="fa3",
    fsdp_attention_implementation="flash_attention_3",
    disable_megatron_sequence_parallel=True,
)

超出已注册的 profile 直接拒绝启动。也就是说,逐位对齐目前是「小模型 + FSDP + Qwen3 dense」的可用路径,不是通用能力。

四、路线二:让两侧走同一条量化路径

逐位对齐很难推到大规模,Miles 于是做了第二条路:不追求「消除差异」,而是让 rollout 和 trainer 的前向用同一套量化逻辑。既然两侧都在做同样精度的舍入,偏差就不会单向累积。

这条路的成品是三种端到端低精度配方:

格式 块布局 硬件 成熟度
FP8 block-wise(DeepSeek 风格) 128×128,FP32 scale Hopper + Blackwell 已可用
MXFP8 1×32,UE8M0 scale 仅 Blackwell Beta
NVFP4(E2M1) 1×16,两级 scale(FP8 + FP32) 仅 Blackwell Beta

关键不在于选了哪种格式,而在于契约覆盖了整条链路:checkpoint 转换、trainer 前向、SGLang rollout、在线权重导出,四个阶段必须共用同一套量化规则;声明为高精度的张量在这四段里必须一致地保持高精度。

Miles 把这件事做到了量化器一级:FlashInfer(推理侧)与 TransformerEngine(训练侧)的量化器对齐到同一个 bit-level contract,单元测试对参考实现做逐字节校验。这背后是一个很容易踩的坑:

serving 环境默认会在 FP4 量化路径上开 fast math。This is a reasonable serving default, but RL weight updates benefit from exact agreement with the training-side quantizer.

所以 RL 配方反过来把 fast math 关掉(FLASHINFER_DISABLE_FP4_QUANT_FAST_MATH=1)。推理侧为了吞吐开的优化,在这里必须为训练侧的一致性让路。

同一条逻辑还体现在 NVFP4 配方的取舍上。NVFP4 预训练会用随机舍入(SR)和随机 Hadamard 变换(RHT)当稳定器,RL 配方把两者都去掉,理由是量化噪声必须压得比真实更新信号更低,否则会把脆弱能力直接覆盖掉,改为按 token 在线算 FP32 scale。

一条可以直接用的经验结论

Miles 试了按层把一部分权重留在 BF16,结论很具体:保留最后 15% 的层用 BF16,能明显降低训练-推理偏差、稳定梯度;而保留最前面的层没有帮助。

另一条同样实用:MLA 结构里有几个投影张量(kv_b_proj 以及 absorbed 的 k_up_proj / v_up_proj)要留在 BF16,因为 absorbed 与非 absorbed 模式的收缩轴不同,而 MXFP8 的块是一维的,套不上。

五、路线三:消不掉就修

前面两条都要付代价:一条要让 rollout 变慢、还只支持小模型,一条要上 Blackwell。所以在更多场景下,Miles 走的是第三条:接受差异存在,然后修正它

修正分两个层次。

5.1 先修结构性的:R3 重放专家路由

MoE 的路由不一致不是数值噪声,是走错路径,重要性采样修正不了。Miles 的做法(--use-rollout-routing-replay)是:让 SGLang 在生成时把选中的专家一并返回,训练前向时直接回放,跳过自己的 Top-k gating

rollout 侧打开 enable_return_routed_expertsmiles/backends/sglang_utils/sglang_engine.py:133-136),返回的张量形状是 (num_tokens-1, num_layers, moe_router_topk)、int32(miles/utils/types.py:99-104)。训练侧用一个双游标的回放缓冲消费它:

# miles/utils/replay_base.py:28-41
def pop_forward(self) -> torch.Tensor:
    top_indices = self.top_indices_list[self.forward_index]
    self.forward_index += 1
    return top_indices

def pop_backward(self) -> torch.Tensor:
    top_indices = self.top_indices_list[self.backward_index]
    self.backward_index += 1
    return top_indices

两个独立游标是为了应对激活重算:同一批路由要被前向和反向各消费一次。

开销是明码标价的:每个 routing 张量占 (num_tokens − 1) × layers × top_k × 4 字节。32K token、60 层、top_k = 8 的序列,每条轨迹约 60 MB。所以它不是无脑开的。文档明确列出什么时候不需要 R3:模型是 dense 的,或者已经用 TIS 把 off-policy 项屏蔽掉了。

5.2 再修数值残余:TIS

数值层面消不掉的残余偏差,用截断重要性采样兜底。Miles 内置的实现很短:

# miles/backends/training_utils/loss_hub/corrections.py:7-32
def vanilla_tis_function(...):
    """Truncated importance sampling: clamp `exp(train - rollout)` to
    `[tis_clip_low, tis_clip]` and multiply into `pg_loss`."""
    tis = torch.exp(old_log_probs - rollout_log_probs)
    tis_weights = torch.clamp(tis, min=args.tis_clip_low, max=args.tis_clip)
    pg_loss = pg_loss * tis_weights

思路是给每个 token 的比值算一个权重,再把它夹在区间里:偏差大的样本不被完全信任,也不被完全丢弃。默认上界是 2.0。需要更细的策略时,可以挂自定义函数(--custom-tis-function-path),示例目录里给了一套带 12 个监控指标的 MIS 实现。

异步 RL 里这个修正不是可选项。 Miles 在启动时强制要求显式选择行为策略:

# miles/utils/arguments.py:3696-3714
def validate_async_off_policy_correction(args) -> None:
    """Require an explicit behavior-policy choice for async PPO training."""
    assert args.use_rollout_logprobs or args.use_tis or args.keep_old_actor, (...)

因为异步 rollout 天然跨多个权重版本,样本本来就不是严格 on-policy 的,必须说清楚用哪种方式处理。

六、这三条路各自走到哪了

把边界收拢一下。

逐位对齐(路线一)的适用范围最窄。 FSDP 后端 + Qwen3 dense + 0.6B/4B 两个 profile,超出就拒绝启动。官方示例的 README 里那句「Potentially scale it to larger scale models if it is proven to be effective」,写在 Future Works 一节。

大模型上跑的都不是路线一。 GLM-5.2 那次运行的 log-prob 分歧是 0.0369,V4.1 那次是 0.017–0.025 nats,都不是 0。这两个数字来自 Miles 自己的参考运行,说明在万亿参数规模上,走的是「对齐 + 修正」而不是「消除」

DeepSeek-V4.1 在 Miles 的公开代码里不存在。 仓库里有一份 225 行的 V4.1 训练侧文档,写明了 Megatron 插件位置、模型定义、启动脚本,甚至给了 checkpoint 转换命令,但它引用的 miles_plugins/models/deepseek_v41/scripts/models/deepseek-v4.1.pyscripts/run_deepseek_v41.pytools/dsv41_cut_cast.py 全部不存在,全仓 engram 相关文件为 0,v41 在代码目录下零命中。文档自己还写着那个 cast 工具「is kept with the bring-up scripts」。

也就是说,LMSYS 博客里描述的 V4.1 集成,从公开仓库既无法复现也无法验证。这在开源系统软件里并不罕见,文档先行、代码滞后,但它决定了我们能把哪些说法当一手证据。

没有独立验证。 截至 2026-09-14,没有找到任何第三方复现或独立基准。所有性能数字(步时、权重同步加速比、显存降幅、reward 曲线)无一例外来自 RadixArk 自己的运行,论文也自陈「some measurements come from one configuration rather than many」。

顺带一提,Miles 自己的文档有四处引用了不存在的文件。其中包括 miles/ray/train/actor_factory.py,本文第四节引用的那份低精度文档,正是引用它的两处之一。这份文档的机制描述可信(能在源码里对上),但具体路径引用要自己核

七、为什么这对做推理的人重要

过去几年,「推理引擎怎么选 kernel」被当成一个纯吞吐问题:哪个后端快、哪条路径省显存。这篇材料给出的是另一面。

推理引擎的每一次数值选择,都是上游训练的输入。

具体地:

  • 推理侧为吞吐默认打开的 fast math,会让 RL 的权重更新失去与训练侧量化器的精确一致,所以 RL 配方得把它关掉;
  • 推理侧选哪个注意力后端,决定了训练侧能不能做逐位对齐:FA3 正是因为它在 prefill 与 decode 之间逐位相等才被选中;
  • 推理侧的精度格式(FP8 / MXFP8 / NVFP4)不再是推理单方面的决定,而要和 trainer 的量化规则签一份覆盖四个阶段的契约。

Miles 之所以把 SGLang 和 Megatron 缝在一起,一部分原因就在这:这两个系统的一致性,是它自己的正确性问题,不是别人的。

源文件索引

本文的「源码」是 Miles 仓库(radixark/miles,HEAD 7daba3a)。

文件 关键内容
docs/advanced/low-precision.md 训练-推理精度漂移的问题陈述、三种低精度格式、兼容性矩阵与例外清单
examples/infra_features/true_on_policy/README.md True On-Policy 的定义、bitwise 声明、验收指标、实现要点
miles/true_on_policy/schema.py QWEN3_DENSE_TRUE_ON_POLICY_V1_SCHEMA 等 parity contract
miles/true_on_policy/config.py --true-on-policy 展开出的参数与环境变量
miles/backends/megatron_utils/arguments.py Megatron 后端对 true-on-policy 的 NotImplementedError
miles/backends/training_utils/loss_hub/losses.py train_rollout_logprob_abs_diff / train_rollout_kl 的计算与上报
miles/backends/training_utils/log_utils.py CI 断言(true-on-policy 用无容差的 ==
miles/backends/training_utils/loss_hub/corrections.py vanilla_tis_functionicepop_function
miles/utils/replay_base.py R3 的 Replay 双游标缓冲与 Top-k 拦截
miles/backends/sglang_utils/sglang_engine.py rollout 侧打开路由返回
miles/utils/types.py routing 张量的形状与长度不变式
miles/utils/arguments.py TIS / R3 的 flag 定义与跨 flag 校验(含异步 PPO 的行为策略断言)
docs/models/deepseek/deepseek-v4-1-flash.md V4.1 训练侧文档(引用的实现文件均在公开树中缺失)
miles/backends/megatron_utils/megatron_to_hf/processors/quantizer_{fp8,mxfp8,nvfp4}.py 三种精度的导出量化器

参考资料

相关阅读