Prhub

#49764 [Quantization] Share online weight scales across TP

原始 PR 作者 S1ro1 合并时间 2026-08-07 07:34 文件变更 5 提交数 9 评论 16 代码增减 +314 / -23

执行摘要

在线量化 amax 跨 TP 归约,保证权重量化跨拓扑一致

PR body 明确指出问题:"Online weight quantization currently derives scales from rank-local shards. Consequently, tensor-parallel packing can use a different recipe from unsharded or expert-parallel packing of the same BF16 checkpoint." 最终合入版本的重写提交(aoshen02 的 2f89363)进一步解释了动机:"The result is self-consistent but not reproducible across topologies, which breaks eval comparisons, bisection, and RL rollouts where the trainer holds BF16 or a different sharding." 即问题不只是精度,而是同一 checkpoint 在不同并行拓扑下不可复现,直接影响 RL 训练器与 BF16 权重之间的衔接。

值得精读。三个设计决策尤其有借鉴价值:(1) _is_tp_sharded 用"amax 归约维 vs 权重切分维"的交集判定是否需要通信,避免对列并行 per-channel、EP 等本就全局的场景做无谓 collective;(2) MoE 归约组直接复用 EP 进程组而非新建组,简洁且与 DP x PCP x TP 展平切分严格对应;(3) 测试用 stub 进程组 + fake all-reduce 在单进程中验证多卡语义,并把断言写进 fake 函数内部,方法可推广到其他分布式工具函数测试。

讨论亮点

核心 review 讨论集中在代码风格与导入时机,最终由作者在提交中完整回应:

  • Isotr0py 在 quant_utils.py 的 diff 上连续两次评论:"Can we move this to top-level import?" 与 "ditto.",针对两个 amax helper 函数体内的 from vllm.distributed.parallel_state import get_tp_group 局部导入。
  • aoshen02 在提交 0a0352b 中将其提升为模块级导入,并说明:vllm/distributed 下没有任何模块导入 quant_utils,不存在循环依赖风险;同时把测试的 monkeypatch 目标从 vllm.distributed.parallel_state.get_tp_group 改为 quant_utils.get_tp_group——因为模块级导入后,补丁必须打在名字实际被查找的位置。
  • Isotr0py 最终 APPROVED("LGTM, leave a nit")。另一个值得注意的讨论点来自 fork 协作:aoshen02 把整 PR 重写为 "Make online weight scales TP-invariant (rewrite of #49764)" 并通过 fork PR #7 / #8 回合,最终合入版本由重写 + 导入提升两个 fork 提交构成。

实现拆解

实现按 4 步拆解:

  1. 定义公共 amax 推导与归约工具(quant_utils.py,变更入口):新增 weight_amax(用 torch.aminmax 一次遍历同时取 lo / hi,再对二者取绝对值后 maximum,避免为全量权重物化 abs() 中间张量)、amax_for_tp_weight_quant(dense 权重在 get_tp_group().device_group 上做 ReduceOp.MAX all-reduce,仅在 is_sharded 时执行)、amax_for_moe_weight_quant(逐 expert amax 在 get_ep_group().device_group 上归约,moe_tp_size == 1 即 EP 模式时跳过)。选 EP 组的依据是:MoE 关闭 EP 时张量切分按 DP x PCP x TP 展平,span 恰等于 EP 进程组。

  2. FP8 在线量化改造(fp8.py,主战场,+79/-19):新增 _fp8_max、_fp8_scale、_fp8_channel_scale、_fp8_quant_per_channel、_is_tp_sharded 五个 helper。四个 process_weights_after_loading 入口都改为"先算 amax → 按需归约 → 再用固定 scale 量化":(a) per-tensor linear 对整体 amax 经 amax_for_tp_weight_quant 归约,行/列并行都需要;(b) per-channel linear 用 _is_tp_sharded(layer, reduces_output_dim=False) 判定,列并行分片的 per-channel 归约维(输入维)未被切分,可免通信;(c) per-tensor MoE 对逐 expert amax 归约后再算 scale;(d) PTPC MoE 的 w2 改为 per-channel amax 归约 + _fp8_quant_per_channel 量化,w13 保持原动态量化路径。实现细节上 _fp8_max 用 0 维 tensor 作除数以保持真除法语义(除以 Python float 会被 PyTorch 降级为乘倒数),_fp8_quant_per_channel 按 _QUANT_CHUNK_ELEMS = 16M 元素分块,限制 fp32 中间值峰值约 64 MB。

  3. NVFP4 与 INT8 MoE 同步对齐(nvfp4.py、int8.py):_quantize_moe_weight_to_nvfp4 增加 moe_tp_size: int = 1 参数,per-expert global scale 先经 amax_for_moe_weight_quant 归约再参与 FLOAT4_E2M1_MAX * FLOAT8_E4M3_MAX / amax 计算;INT8 的 w2 per-row scale 改为先整体算 w2_amax = weight_amax(layer.w2_weight, dim=-1) 经归约后逐 expert 复用(w2 的 amax 归约维正是 TP 切分维),w13 因归约维未被切分而保持逐 expert 原逻辑。TP=1 时两条路径数值均与原实现完全一致。

  4. 测试配套(tests/quantization/test_online.py,+179):新增 _patch_max_reduce 桩(stub 进程组 + 假 all_reduce 断言 ReduceOp.MAX 与 group,再把未分片权重算出的 full_amax 复制进 tensor,等价于真实多卡归约结果),以及 _quantize_linear、_quantize_moe 两个参考实现工具函数。三个 parity 测试分别覆盖 dense per-tensor / per-channel(含两种切分维)、MoE per-tensor / per-channel / NVFP4 w2 切分、INT8 w2 切分,全部断言打包权重与 scale 逐元素相等;另有 test_is_tp_sharded_false_when_scale_is_already_global 固化"无需通信"的判定语义。PR body 说明无需文档改动,无 schema / 部署配套变更。

文件 模块 状态 重要度
vllm/model_executor/layers/quantization/utils/quant_utils.py 量化工具 modified 7.99
vllm/model_executor/layers/quantization/online/fp8.py 在线量化 modified 8.74
tests/quantization/test_online.py 量化测试 modified 7.41
vllm/model_executor/layers/quantization/online/nvfp4.py 在线量化 modified 6.08
vllm/model_executor/layers/quantization/online/int8.py 在线量化 modified 5.84

关键符号

weight_amax amax_for_tp_weight_quant amax_for_moe_weight_quant _is_tp_sharded _fp8_max _fp8_scale _fp8_channel_scale _fp8_quant_per_channel _quantize_moe_weight_to_nvfp4 Int8OnlineMoEMethod._quantize_weights

关键源码片段

vllm/model_executor/layers/quantization/utils/quant_utils.py data-contract

公共工具层,新增 weight_amax、amax_for_tp_weight_quant、amax_for_moe_weight_quant 三个函数,是全部在线量化路径的归约入口;EP 组复用与 is_sharded 跳过语义都在这里定义。

# quant_utils.py:在线量化共用的 scale 推导工具
# 核心思路:同一份 BF16 checkpoint 在不同并行拓扑下必须量化出完全一致的
# 结果,因此在推导 scale 之前,把 amax(绝对值最大值)在对应归约组内做
# MAX all-reduce,且只在真正需要时发起通信。
​
from vllm.distributed.parallel_state import get_ep_group, get_tp_group
​
​
def weight_amax(
    weight: torch.Tensor, *, dim: int | None = None, keepdim: bool = False
) -> torch.Tensor:
    """计算 max(|weight|),且不物化全尺寸的 abs() 中间张量。
​
    torch.aminmax 一次遍历同时得到最小值与最大值,再分别取绝对值后
    maximum,内存占用远低于 weight.abs().amax(),适合加载期量化超大权重。
    """
    lo, hi = weight.aminmax(dim=dim, keepdim=keepdim)
    return torch.maximum(lo.abs(), hi.abs())
​
​
def amax_for_tp_weight_quant(amax: torch.Tensor, is_sharded: bool) -> torch.Tensor:
    """当权重确实沿 amax 归约的维度被 TP 切分时,在 TP 组内做 MAX
    all-reduce,使每个分片推导出的 scale 与整权重一致。
​
    is_sharded 由 fp8.py 的 _is_tp_sharded 判定:行并行切输入维、列并行切
    输出维;若 amax 只归约输入维(如 per-channel),列并行分片本就与未分片
    等价,无需通信。
    """
    if is_sharded:
        torch.distributed.all_reduce(
            amax,
            op=torch.distributed.ReduceOp.MAX,
            group=get_tp_group().device_group,
        )
    return amax
​
​
def amax_for_moe_weight_quant(amax: torch.Tensor, moe_tp_size: int) -> torch.Tensor:
    """把逐 expert 的 amax 在 MoE 张量切分组内做 MAX all-reduce。
​
    MoE 权重在 EP 关闭时按 DP x PCP x TP 展平切分,span 恰好等于 EP 进程
    组,因此复用 get_ep_group();EP 模式下 moe_tp_size == 1,每个 rank
    持有完整 expert,跳过通信。
    """
    if moe_tp_size > 1:
        torch.distributed.all_reduce(
            amax,
            op=torch.distributed.ReduceOp.MAX,
            group=get_ep_group().device_group,
        )
    return amax
vllm/model_executor/layers/quantization/online/fp8.py data-contract

主战场(+79/-19),新增 _fp8_max / _fp8_scale / _fp8_channel_scale / _fp8_quant_per_channel / _is_tp_sharded,四个 process_weights_after_loading 入口全部改造为 " 先算 amax、按需归约、再量化 "。

# fp8.py:在线 FP8 量化的 scale 推导辅助函数
# 关键点:scale 由全局 amax 推导;量化 kernel 的除法语义(真除法 vs 乘
# 倒数)必须被精确复刻,否则会在可表示中点附近产生不一致的舍入。
​
def _fp8_max(device: torch.device) -> torch.Tensor:
    # 用 0 维 tensor 而非 Python float 作除数:float 除法会被 PyTorch
    # 降级为乘倒数,与 kernel 内的真除法在舍入上不一致。
    return torch.tensor(get_fp8_min_max()[1], device=device, dtype=torch.float32)
​
​
def _fp8_scale(amax: torch.Tensor) -> torch.Tensor:
    """amax / fp8_max,即 per-tensor / per-expert 的 scale。"""
    amax = amax.to(torch.float32)
    return amax / _fp8_max(amax.device)
​
​
def _fp8_channel_scale(amax: torch.Tensor) -> torch.Tensor:
    """per-channel scale 增加下界保护,避免极小 amax 产生除零 / 下溢。"""
    fp8_max = _fp8_max(amax.device)
    return _fp8_scale(amax).clamp_min(1.0 / (fp8_max * 512))
​
​
def _is_tp_sharded(layer: Module, *, reduces_output_dim: bool = True) -> bool:
    """判断权重是否沿 amax 归约的维度被 TP 切分。
​
    行并行切输入维,列并行切输出维;per-output-channel 的 scale 只归约
    输入维,所以列并行层本身已与未分片等价,reduces_output_dim=False 时
    返回 False,从而跳过无谓的 collective。
    """
    if layer.tp_size == 1:
        return False
    is_row_parallel = layer.input_size != layer.input_size_per_partition
    is_column_parallel = layer.output_size != layer.output_size_per_partition
    return is_row_parallel or (reduces_output_dim and is_column_parallel)
​
​
def _fp8_quant_per_channel(weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor:
    """按 channel 做真除法量化,逐块执行以控制 fp32 中间值峰值约 64 MB。"""
    fp8_min, fp8_max = get_fp8_min_max()
    out = torch.empty_like(weight, dtype=current_platform.fp8_dtype())
    chunk_rows = max(1, _QUANT_CHUNK_ELEMS // weight.shape[-1])
    for start in range(0, weight.shape[0], chunk_rows):
        rows = slice(start, start + chunk_rows)
        out[rows] = (weight[rows] / scale[rows]).clamp_(fp8_min, fp8_max).to(out.dtype)
    return out
tests/quantization/test_online.py test-coverage

测试配套(+179),新增 fake all-reduce 桩与三个跨分片 parity 测试,用单进程验证多卡归约语义,是本 PR 正确性论证的核心证据。

# test_online.py:用桩进程组 + 假 all_reduce 为单进程单元测试提供归约语义
# 注意:helper 提升为模块级导入后,测试补丁点也要跟随到名字被查找的位置,
# 即 quant_utils.get_tp_group / get_ep_group,而不是 vllm.distributed.parallel_state。
​
def _patch_max_reduce(monkeypatch, full_amax) -> None:
    """替身 MAX all-reduce:断言 op 为 ReduceOp.MAX、group 为预期桩对象,
    然后把未分片权重算出的 full_amax 复制进被归约的 tensor —— 等价于多
    rank 环境中 MAX 归约后的最终结果。
    """
    expected = cast(ProcessGroup, object())
    stub = SimpleNamespace(device_group=expected)
    monkeypatch.setattr(quant_utils, "get_tp_group", lambda: stub)
    monkeypatch.setattr(quant_utils, "get_ep_group", lambda: stub)
​
    def fake_all_reduce(tensor, op, group):
        assert op == torch.distributed.ReduceOp.MAX
        assert group is expected
        tensor.copy_(full_amax)
​
    monkeypatch.setattr(torch.distributed, "all_reduce", fake_all_reduce)

评论区精华

quant_utils 内函数级导入应提升为模块级 style

Isotr0py 在 quant_utils.py 的 diff 上连续两次评论:"Can we move this to top-level import?" 与 "ditto.",针对两个 amax helper 函数体内的 from vllm.distributed.parallel_state import get_tp_group / get_ep_group 局部导入。

结论:aoshen02 在提交 0a0352b 中提升为模块级导入,说明 vllm/distributed 无反向依赖 quant_utils、不存在循环导入;同时把测试的 monkeypatch 目标从 vllm.distributed.parallel_state.get_tp_group 改为 quant_utils.get_tp_group,保证补丁点在名字实际被查找的位置。Isotr0py 随后 APPROVED。 · 已解决

PR 由 aoshen02 重写并经 fork PR 回合 other

aoshen02 在 issue 评论中说明要把自己的重写并入本 PR("Hi matej, here's the pr I want to merge to yours https://github.com/S1ro1/vllm/pull/7"),随后提交 2f89363 以 "[Quantization] Make online weight scales TP-invariant (rewrite of #49764)" 合入,最终合入版本由 aoshen02 的重写与导入提升提交构成。

结论:两次 fork PR(#7、#8)均被 S1ro1 合并,head 最终态为 aoshen02 主导的重写版本;中间经历多轮 CI(Buildkite #82582 / #82661 / #82672 / #82684)与 pre-commit 修复。 · 已解决

风险与影响

  1. 分布式归约挂起风险:amax_for_tp_weight_quant / amax_for_moe_weight_quant 在 process_weights_after_loading 中发起 all-reduce,该路径由所有 rank 并行执行;若进程组未初始化(如非标准单进程加载路径)或实际并行拓扑与假设不符(MoE 张量切分 span 不等于 EP 组),可能出现挂起或错误归约。当前正确性依赖"关闭 EP 时 MoE 张量切分按 DP x PCP x TP 展平,恰等于 EP 进程组"这一前提。
  2. 量化输出数值变更:TP > 1 场景下在线量化产出的打包权重与 scale 会与合入前不同(更正确但不兼容),同一 checkpoint 的生成输出与 benchmark 数值会变化,需要用户重新评估回归结果。
  3. INT8 w2 scale 路径重构:Int8OnlineMoEMethod._quantize_weights 中 w2 从逐 expert 循环内计算 scale 改为整体 amax + 归约后复用,TP=1 时数值不变,但属于行为敏感路径的代码重构,单测仅有 fake 归约覆盖,缺少真实多卡 parity 测试。
  4. 性能:仅为加载期一次性通信(dense 为单个 scalar,MoE 为 per-expert 向量),热路径无影响;_fp8_quant_per_channel 分块把 fp32 中间值峰值控制在约 64 MB,无显存风险。

用户侧:使用在线量化(如 fp8_per_tensor、fp8_per_block、nvfp4_per_token)且 TP > 1 的服务,加载后的权重数值会变化,但跨拓扑一致性显著提升;EP 用户数值不变且无额外通信开销。系统侧:改动全部集中在模型加载期的权重处理(process_weights_after_loading),不进入 serving 热路径,推理延迟与吞吐无影响。团队侧:为"同一 checkpoint 跨拓扑可复现量化"建立了公共工具(quant_utils 中的三个函数)与单进程内模拟多卡归约的测试范式,RL rollout、eval 对比、bisection 场景直接受益;后续新增在线量化路径可直接复用这套归约约定。

引入分布式 collective 量化权重数值行为变更 依赖 EP 组拓扑假设 单元测试用 fake 归约

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论