执行摘要
- 一句话:在线量化 amax 跨 TP 归约,保证权重量化跨拓扑一致
- 推荐动作:值得精读。三个设计决策尤其有借鉴价值:(1)
_is_tp_sharded 用"amax 归约维 vs 权重切分维"的交集判定是否需要通信,避免对列并行 per-channel、EP 等本就全局的场景做无谓 collective;(2) MoE 归约组直接复用 EP 进程组而非新建组,简洁且与 DP x PCP x TP 展平切分严格对应;(3) 测试用 stub 进程组 + fake all-reduce 在单进程中验证多卡语义,并把断言写进 fake 函数内部,方法可推广到其他分布式工具函数测试。
功能与动机
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 权重之间的衔接。
实现拆解
实现按 4 步拆解:
-
定义公共 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 进程组。
-
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。
-
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 时两条路径数值均与原实现完全一致。
-
测试配套(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(模块 量化工具;类别 source;类型 data-contract;符号 weight_amax, amax_for_tp_weight_quant, amax_for_moe_weight_quant): 公共工具层,新增 weight_amax、amax_for_tp_weight_quant、amax_for_moe_weight_quant 三个函数,是全部在线量化路径的归约入口;EP 组复用与 is_sharded 跳过语义都在这里定义。
vllm/model_executor/layers/quantization/online/fp8.py(模块 在线量化;类别 source;类型 data-contract;符号 _fp8_max, _fp8_scale, _fp8_channel_scale, _fp8_quant_per_channel): 主战场(+79/-19),新增 _fp8_max / _fp8_scale / _fp8_channel_scale / _fp8_quant_per_channel / _is_tp_sharded,四个 process_weights_after_loading 入口全部改造为"先算 amax、按需归约、再量化"。
tests/quantization/test_online.py(模块 量化测试;类别 test;类型 test-coverage;符号 _patch_max_reduce, fake_all_reduce, test_is_tp_sharded_false_when_scale_is_already_global, _quantize_linear): 测试配套(+179),新增 fake all-reduce 桩与三个跨分片 parity 测试,用单进程验证多卡归约语义,是本 PR 正确性论证的核心证据。
vllm/model_executor/layers/quantization/online/nvfp4.py(模块 在线量化;类别 source;类型 data-contract;符号 _quantize_moe_weight_to_nvfp4): NVFP4 MoE 量化入口同步改造:_quantize_moe_weight_to_nvfp4 增加 moe_tp_size 参数,per-expert global scale 先经 EP 组归约再参与打包。
vllm/model_executor/layers/quantization/online/int8.py(模块 在线量化;类别 source;类型 data-contract;符号 Int8OnlineMoEMethod._quantize_weights): INT8 MoE w2 的 per-row scale 改为整体 amax + 归约后复用,修复 w2 中间维被 TP 切分时 scale 不一致的问题;w13 因归约维未切分保持原逻辑。
关键符号: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
公共工具层,新增 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
主战场(+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
测试配套(+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)
评论区精华
核心 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 提交构成。
-
quant_utils 内函数级导入应提升为模块级 (style): 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): 两次 fork PR(#7、#8)均被 S1ro1 合并,head 最终态为 aoshen02 主导的重写版本;中间经历多轮 CI(Buildkite #82582 / #82661 / #82672 / #82684)与 pre-commit 修复。
风险与影响
- 风险:
- 分布式归约挂起风险:
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 进程组"这一前提。
- 量化输出数值变更:TP > 1 场景下在线量化产出的打包权重与 scale 会与合入前不同(更正确但不兼容),同一 checkpoint 的生成输出与 benchmark 数值会变化,需要用户重新评估回归结果。
- INT8 w2 scale 路径重构:
Int8OnlineMoEMethod._quantize_weights 中 w2 从逐 expert 循环内计算 scale 改为整体 amax + 归约后复用,TP=1 时数值不变,但属于行为敏感路径的代码重构,单测仅有 fake 归约覆盖,缺少真实多卡 parity 测试。
- 性能:仅为加载期一次性通信(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 归约
关联脉络
- PR #49601 [Weight processing] Copy over
new_data attributes in replace_parameter: 同属权重加载/量化链路:本 PR 的 FP8 / NVFP4 / INT8 在线量化路径大量使用 replace_parameter 替换权重参数,49601 正是为 replace_parameter 补齐 new_data 属性拷贝并清理量化后端重复打标,两者共同构成在线量化权重替换的基础设施。
- PR #47106 [Kernel] Support Nvfp4 Cutedsl Moe Swiglu-oai and Relu2(non-gated) Activation: NVFP4 MoE 量化同一功能线:47106 扩展 FlashInfer CuteDSL NVFP4 MoE 的激活与打包支持,本 PR 修改同一 MoE 权重打包入口 _quantize_moe_weight_to_nvfp4 的 scale 推导范围,二者都围绕 NVFP4 MoE 权重布局与量化正确性。
参与讨论