Prhub

#34484 [ROCm] Fix QuickReduce fp16 saturation corrupting bf16 all-reduces (106M non-finite -> 0, +0.3%)

原始 PR 作者 alexnails 合并时间 2026-08-30 13:08 文件变更 3 提交数 10 评论 1 代码增减 +184 / -5

执行摘要

修复 ROCm QuickReduce BF16→FP16 溢出造成的非有限输出

修复 #34473:在 MI350X(gfx950)官方 ROCm 镜像上,gpt-oss-120b 于 TP4 默认配置产生 62.7M 非有限残差元素。Issue 定量确认"the corruption is created by the all-reduce",根因是超过 64 MiB 的 all-reduce 默认落入 QuickReduce 的 bf16→fp16 路径(_DEFAULT_CAR_MAX_SIZE 64 MiB 门槛,ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16 默认 1),而 gpt-oss 规约激活值可达 333,824,是 FP16 上限的 5.1 倍。注入实验证明"One element above 65,504 destroys exactly 32——the CodecQ8 block",即饱和块 max 污染整个量化块。PR body 明确这是默认路径而非可选路径,且原生 BF16 方案虽数值精确但慢 22–46%,因此选择修复快速路径而非绕开。

值得精读。核心亮点是三层防御:FP16_OVFL 寄存器饱和兜底、per-codec 的 2 幂范围保护(分析清楚为什么量化 codec 不能用)、显式 ISA 转换阻断编译器重排。提交历史中的 S = 16 → 2 → 1 调参过程展示了如何用端到端 perplexity 和相对 L2 数据驱动数值设计决策,是内核级数值修复的可借鉴范本。建议同时阅读 #37132 的空 asm barrier 替代方案,理解 CDNA 系列 ISA 差异对可移植性的约束。

讨论亮点

合并前 review 只有一条 HaiShaw 的 APPROVED:"LGTM",未产生设计分歧。合并后 yctseng0211 在 Issue 评论中报告了构建回归:"the inline v_cvt_pk_f16_f32 here only assembles on gfx950, so every gfx942 build fails with error: instruction not supported on this GPU and AMD CI is currently red on main",并说明已开 PR #37132,"keeps the reassociation blocked with an empty asm barrier instead"。此外,提交历史本身记录了重要的数值权衡论证:ebc5e8b 与 3ef920e 用实测数据说明全局缩放对量化 codec 的伤害机制是 rcp 饱和悬崖而非 denormal,最终以 S = 1 收口。

实现拆解

变更入口是 ROCm QuickReduce 的 bf16→fp16 快速路径,核心文件为 python/sglang/kernels/aot/csrc/allreduce/quick_all_reduce_base.hquick_all_reduce.cuh

  1. 溢出模式修复:将 set_fp16_ovfl 的编译条件从仅 __gfx942__ 扩展到 __gfx942__ || __gfx950__,使 MODE.FP16_OVFL 在 gfx950 上生效——溢出的 FP16 结果(含 f32→f16 转换)饱和到 ±65504 而非 inf。同时为 s_setreg_imm32_b32 增加 "memory" clobber,防止编译器把 FP16 转换提升到寄存器设置之前执行。

  2. 按 codec 划分范围保护:在 quick_all_reduce_base.h 定义 kQRFp16CastScaleLog2Fp = 4(S = 16,仅 CodecFP)与 kQRFp16CastScaleLog2Quant = 0(S = 1,量化 codec)。AllReduceTwoshot 在加载侧把 bf16 除以 S 后再窄化到 FP16,在存储侧用 FP32 中间量乘回 S;由于是 2 的幂,sum(x_i / S) * S == sum(x_i) 精确成立。量化 codec 已有块级归一化,提高 S 反而会把 reciprocal-scale 饱和悬崖(blockmax = S * L / 65504)拉进真实数据。

  3. 编译期防重排:新增 scaled_bfloat162_to_half2,用内联 ISA 指令 v_cvt_pk_f16_f32 完成缩放后的窄化转换,使缩放后的 FP32 值成为显式输入,阻止 LLVM 将 (bf16_as_f32 * scale) -> fp16 重排为 fp16(bf16_as_f32) * scale。量化 codec 因 S = 1 保留原有 __float22half2_rn 路径,代码生成不变。

  4. 测试配套:新增 test/registered/kernels/test_quick_allreduce_bf16_range.py,四进程(gloo + spawn)TP4 回归测试,覆盖低值、普通值、和超上限、单输入超上限、负向超上限、混合块共 6 类场景 × FP/INT8/INT6/INT4 四种 codec,断言有限性及 FP/low/ordinary 的 bit-exact(rtol=0, atol=0),并注册到 stage-c-test-4-gpu-amdstage-c-test-large-8-gpu-amd-mi35x 两档 AMD CI 网格。

  5. 提交演化中的反复调参:初版全局 S = 16 在 GLM-5.2-FP8 上把 INT8 相对 L2 从 0.0057 恶化到 0.68、Qwen3.5-27B-FP8 端到端 perplexity 回归 3.1 倍(mean NLL 1.2955 → 2.4178);随后将量化 codec 降到 S = 2 仍有余波(GLM-5.2-FP8 +61.7%),最终定为 S = 1,全模型最优且 gpt-oss 也 -1.9%。

文件 模块 状态 重要度
test/registered/kernels/test_quick_allreduce_bf16_range.py 数值回归 added 7.23
python/sglang/kernels/aot/csrc/allreduce/quick_all_reduce.cuh 规约内核 modified 5.04
python/sglang/kernels/aot/csrc/allreduce/quick_all_reduce_base.h 规约内核 modified 6.29

关键符号

scaled_bfloat162_to_half2 set_fp16_ovfl AllReduceTwoshot<...>::run _run_bf16_range_test TestQuickAllReduceBf16Range.test_bf16_range

关键源码片段

test/registered/kernels/test_quick_allreduce_bf16_range.py test-coverage

新增的四卡回归测试是本次修复的验收标准:覆盖 6 种数值场景 × 4 种 codec,首次把溢出范围行为固化为 CI 断言,并注册到两档 AMD CI 网格,防止该机制复现。

# 每个 rank 独立跑 QuickReduce,用 gloo + spawn 起 4 进程模拟 TP4
def _run_bf16_range_test(rank: int, world_size: int, port: int) -> None:
    # 强制走 bf16 -> fp16 快速路径,与生产镜像默认配置一致
    os.environ["ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16"] = "1"
​
    device = torch.device(f"cuda:{rank}")
    torch.cuda.set_device(device)
    dist.init_process_group(
        backend="gloo",
        init_method=f"tcp://127.0.0.1:{port}",
        rank=rank,
        world_size=world_size,
    )
​
    try:
        numel = 1 << 20
        cases = [
            ("low", 2**-8, False), # 低值:验证 S = 16 不压垮精度
            ("ordinary", 100.0, False), # 普通值:bit-exact 基线
            ("sum_above_fp16", 20_000.0, False), # 4 rank 求和 = 80k,越过 65504 上限
            ("input_above_fp16", 80_000.0, False), # 单个输入就超过 FP16 上限
            ("negative_sum_above_fp16", -20_000.0, False), # 负向溢出对称性
            ("mixed_input_above_fp16", 1.0, True), # 每 32 元素块注入一个异常点
        ]
        # 四种 codec 全验证:FP 走 S = 16 缩放路径,INT 系列保持 S = 1 旧路径
        for quant_mode in ("FP", "INT8", "INT6", "INT4"):
            os.environ["ROCM_QUICK_REDUCE_QUANTIZATION"] = quant_mode
            quick_all_reduce = QuickAllReduce(group=dist.group.WORLD, device=device)
            assert not quick_all_reduce.disabled
            assert quick_all_reduce.use_fp16_kernels
            try:
                for case_name, value, mixed in cases:
                    inp = torch.full((numel,), value, dtype=torch.bfloat16, device=device)
                    if mixed:
                        inp[::32] = 80_000.0 # 与 issue 中 32 元素块放大机制对齐
                    expected = (inp.float() * world_size).to(torch.bfloat16)
​
                    dist.barrier()
                    out = quick_all_reduce.quick_all_reduce(inp)
                    torch.cuda.synchronize()
                    # 核心断言:任何溢出场景都不允许出现非有限输出
                    assert (
                        torch.isfinite(out).all().item()
                    ), f"{quant_mode=} {case_name=} produced non-finite output"
                    # FP codec 以及低值 / 普通值要求 bit-exact(rtol=0, atol=0)
                    if quant_mode == "FP" or case_name in ("low", "ordinary"):
                        torch.testing.assert_close(
                            out,
                            expected,
                            rtol=0,
                            atol=0,
                            msg=lambda msg: f"{quant_mode=} {case_name=}\n{msg}",
                        )
            finally:
                quick_all_reduce.close()
    finally:
        dist.destroy_process_group()
python/sglang/kernels/aot/csrc/allreduce/quick_all_reduce.cuh core-logic

核心实现文件:新增 scaled_bfloat162_to_half2 显式 ISA 转换、per-codec 的 kCastScaleLog2 接入,以及在 AllReduceTwoshot 加载 / 存储两侧的乘除还原逻辑。

// CodecFP 没有块缩放,因此 S = 16 是它唯一的范围保护;
// 量化 codec 已有块级归一化,S 提高只会把 rcp 饱和悬崖拉进真实数据,故保持 S = 1。
// (常量定义见 quick_all_reduce_base.h:kQRFp16CastScaleLog2Fp = 4、kQRFp16CastScaleLog2Quant = 0)// 在 FP32 侧完成缩放后再窄化到 FP16,避免 LLVM 把
// (bf16_as_f32 * scale) -> fp16 重排成 fp16(bf16_as_f32) * scale,
// 那样会先裁剪掉超过 65504 的值,绕开溢出保护。
// 这里用内联 ISA 转换让缩放后的 FP32 值成为显式输入,编译器无法再做 reassociation。
__quickreduce_device_inline__ half2 scaled_bfloat162_to_half2(nv_bfloat162 value, float scale) {
  float2 scaled = __bfloat1622float2(value);
  scaled.x *= scale;
  scaled.y *= scale;  int packed;
  asm volatile("v_cvt_pk_f16_f32 %0, %1, %2" : "=v"(packed) : "v"(scaled.x), "v"(scaled.y));
  return *reinterpret_cast<half2*>(&packed);
}template <typename T, class Codec, bool cast_bf2half>
struct AllReduceTwoshot {
  // 2 的幂缩放:加载除以 S、存储乘回 S,移位精确,
  // 因此 sum(x_i / S) * S == sum(x_i) 严格成立。
  static constexpr float kCastScale = static_cast<float>(1 << Codec::kCastScaleLog2);
  static constexpr float kCastInvScale = 1.0f / kCastScale;  // 加载侧:bf16 -> fp16 之前先除以 S。S = 1 的量化 codec 走原路径,代码生成不变
  if constexpr (Codec::kCastScaleLog2 == 0) {
    float2 f = __bfloat1622float2(bf_buf[j]);
    f.x *= kCastInvScale;
    f.y *= kCastInvScale;
    half_buf[j] = __float22half2_rn(f);
  } else {
    half_buf[j] = scaled_bfloat162_to_half2(bf_buf[j], kCastInvScale);
  }  // 存储侧:规约完成后在 FP32 中乘回 S,FP32 中间量不会溢出
  float2 f = __half22float2(half_buf[j]);
  f.x *= kCastScale;
  f.y *= kCastScale;
  bf16_buf[j] = __float22bfloat162_rn(f);
};
python/sglang/kernels/aot/csrc/allreduce/quick_all_reduce_base.h core-logic

定义了 per-codec 的 FP16 转换缩放常量,并把 FP16_OVFL 寄存器设置扩展到 gfx950,同时新增 memory clobber 防指令重排,是整个修复的配置锚点。

// 范围保护常量:bf16 -> fp16 快速路径(AllReduceTwoshot<..., true>)中 FP16 在 65504 饱和。
// 采用 2 的幂缩放:加载除以 S、存储乘回 S,因此 sum(x_i / S) * S == sum(x_i) 精确成立。
//
// 为什么按 codec 分开:CodecFP 没有块缩放,S 是它唯一的范围保护;
// 量化 codec 已按 32 个值一组用块 max 归一化,MODE.FP16_OVFL 保证超上限元素不会变成 inf
// 污染整个块。提高 S 会把 reciprocal 缩放饱和悬崖
// (blockmax = S * L / 65504,L = 8 / 32 / 128,对应 Q4 / Q6 / Q8)移向真实数据——
// 实测 S: 1 -> 2 在 GLM-5.2 上 perplexity 恶化 62%,因此保持 S = 1。
static constexpr int kQRFp16CastScaleLog2Fp = 4; // S = 16,仅 CodecFP
static constexpr int kQRFp16CastScaleLog2Quant = 0; // S = 1,CodecQ4 / CodecQ6 / CodecQ8// MODE.FP16_OVFL 把溢出的 FP16 结果(包括 v_cvt_pk_f16_f32 转换)饱和到
// ±MAX_FP16,而不是产生 inf。asm 上的 "memory" clobber 是必须的:
// 没有它编译器可能把一次转换提升到 s_setreg 之前执行,那个转换仍会产生 inf。
__quickreduce_device_inline__ static void set_fp16_ovfl(bool const value) {
#if defined(__gfx942__) || defined(__gfx950__)
  if (value) {
    asm volatile("s_setreg_imm32_b32 0xdc1, 1;" ::: "memory");
  } else {
    asm volatile("s_setreg_imm32_b32 0xdc1, 0;" ::: "memory");
  }
#endif
}

评论区精华

gfx942 上 v_cvt_pk_f16_f32 无法汇编导致 CI 变红 正确性

yctseng0211 在合并后评论:"the inline `v_cvt_pk_f16_f32` here only assembles on gfx950, so every gfx942 build fails with `error: instruction not supported on this GPU` and AMD CI is currently red on main",并指出已提交 #37132 用空 asm barrier 保持 reassociation 阻断。

结论:本 PR 引入的内联 ISA 指令不具备 CDNA3 可移植性;#37132 以空 asm barrier 替代,既保留防重排语义,又恢复 gfx942 构建。 · 已解决

风险与影响

  1. gfx942 构建回归(已发生):scaled_bfloat162_to_half2 中的内联 v_cvt_pk_f16_f32 是 gfx950 专属指令,gfx942 无法汇编,导致合并后主分支 AMD CI 变红;后续 #37132 用空 asm barrier 替代,风险已解除,但这说明本 PR 的 CI 验证未覆盖 CDNA3 的实际编译。
  2. 依赖编译器与 ISA 屏障:s_setreg 的 memory clobber 和显式 ISA 转换都依赖编译器不再重排窄化转换;一旦未来 LLVM 重新获得 reassociation 机会,溢出会复发。测试只验证运行结果,不验证指令顺序。
  3. 量化 codec 的遗留截断:PR body 明确量化规约在 FP16 累加器超过 65,504 时仍会 clamp 有限值,这是预先存在的有损行为,未在本 PR 消除;对 gpt-oss 这类大规约幅值模型,仍可能损失精度,只是不再产生 inf。
  4. 低值精度盲区:S = 16 把低值压向 FP16 的 denormal 区间,测试覆盖 2^-8 但未覆盖更小量级;对极小激活的模型可能有额外精度损失(body 声明 CodecFP 在已测场景 bit-exact)。
  5. 性能波动:实测 INT 路径无回归(16 MiB 0.4% 内、90 MiB 0.1% 内),FP 路径反而快 1.1–9.6%,但这是 MI350X 单机结果,其他 CDNA 架构上的调度差异存在不确定性。

影响用户:所有 ROCm(gfx942/gfx950)上超过 64 MiB 门槛、默认走 QuickReduce 快速路径的 bf16 all-reduce 都受益;gpt-oss-120b 默认配置的非有限输出从 62.7M 降到 0,同类大规约幅值模型不再产生 NaN。影响系统:避免了切换到慢 22–46% 的原生 BF16 内核,保持了 all-reduce 吞吐;CodecFP 在溢出场景下保持 bit-exact,量化 codec 的低值/普通值路径不变。影响团队:新增 4-GPU 与 8-GPU 两档 AMD CI 套件(est_time=30 秒),后续 QuickReduce 修改需要同时验证 gfx942/gfx950 双架构编译与数值范围,并因本 PR 的构建疏漏产生了下游修复 #37132。

gfx942 构建回归 依赖编译器屏障 量化截断遗留 新增 4 卡 CI 测试

关联 Issue

#34473 [ROCm] QuickReduce bf16→fp16 cast corrupts gpt-oss-120b residuals: 62.7M non-finite elements on the default config

完整报告

参与讨论