第三方并行库"静默算错":一次 ring attention 的排查记录

背景

我最近在做一件很具体的事:把一个扩散模型(图像生成模型)的推理框架,从 CUDA 移植到另一种加速卡上。

这个模型是 20B 级别的 Transformer,出一次图要跑几十步迭代。序列长度也很夸张------图像不是按像素处理,而是先压到潜空间再切成小块,一张 1024 级别的图展开就是几千个 token。结果是两个问题一起压过来:

  • 单卡太慢:出图要几十秒到几分钟
  • 单卡装不下:模型权重加上中间激活,显存压力很大

解法是序列并行:把序列切成几段,分给多张卡同时算,算完再拼回来。

其中注意力那一段(序列并行里最关键的部分)我没有自己写,直接用了业界的开源实现------这也是常规做法。ring attention 涉及环形通信和流式 softmax,自己从零写很容易出错,用经过验证的实现本来是最省事的路径。

但接入之后,端到端结果不对。 而且诡异的是:不报错、不崩溃、日志干净、跑完 40 步正常出图------只是出的图跟预期完全是两张。

这篇文章记录我怎么把它揪出来的。核心结论是:这个库的注意力实现压根没做通信,它只算了本地那一段。

下文只谈技术,代码为示意。


一、这类 bug 为什么难发现

普通的 bug 会崩,会抛异常,至少给你一个堆栈。

但分布式并行库的 bug 往往长这样:

  • 通信确实发生了,只是算的是错的东西
  • 或者通信压根没发生,但代码路径看起来是通的
  • 张量形状对、dtype 对、内存不越界
  • 跑完 40 步,出图流程正常结束,日志干干净净

你唯一能察觉的是:结果不对。而"结果不对"这件事,在扩散模型里可以有一百种原因------采样随机性、精度差异、参考图过期、seed 不同......

所以第一步不是查库,是排除其他所有可能。


二、排查路径:从"怀疑自己"到"怀疑库"

我当时的顺序是这样的,后来证明这个顺序是对的------由外向内,一层层缩小范围。

第 1 步:确认单卡是对的

先把并行关掉,单卡跑一遍,和参考结果比。单卡能对上(或者差距在已知范围内),说明模型实现本身没问题,问题出在并行路径上。

这一步很重要,如果单卡就不对,那跟并行库没关系。

第 2 步:确认切分逻辑是对的

把"每张卡应该拿到哪一段数据"打印出来,人工核对。

具体做法:给输入序列打上标记(比如第 i 个位置填 i),然后看每张卡拿到的片段是不是连续的、有没有重叠或遗漏。

这一步能排除"分片算错了但注意力是对的"这种情况。

第 3 步:单层对比(关键一步)

不看整条链路,只拿出一个注意力层,喂小输入,把并行实现的输出和朴素实现的输出逐元素比。

python 复制代码
# 朴素实现:完整序列一次算完
def naive_attention(q, k, v):
    scale = q.shape[-1] ** -0.5
    s = torch.matmul(q, k.transpose(-2, -1)) * scale
    p = torch.softmax(s, dim=-1)
    return torch.matmul(p, v)

判定标准:

对比对象 指标 正常值(bf16) 说明
vs 全序列参考 max_abs_err 1e-3 ~ 1e-2 舍入误差,正常
vs 全序列参考 余弦相似度 > 0.999 同上
vs 只用本地数据 max_abs_err 必须 > 0 等于 0 说明根本没通信

最后一行是最有价值的判据,下一节专门讲。

第 4 步:验证"到底有没有通信"

如果第 3 步发现输出和朴素实现差很多,先别急着改算法,先问一句:它真的通信了吗?

验证方法极简单但极有效:

python 复制代码
# 并行实现的输出
out_sp = sp_attention(q_local, k_local, v_local)

# 只用本地分片算(完全不通信)
out_local = naive_attention(q_local, k_local, v_local)

print("max_err =", (out_sp - out_local).abs().max().item())

如果 max_err == 0,说明并行实现返回的就是"只看本地数据"的结果------它压根没把别的卡的数据算进去。

我第一次跑出这个 0 的时候,基本就确定问题在哪了。


三、验证脚本(可直接用)

下面这份脚本脱胎于当时的排查工具,做了通用化处理,不依赖特定硬件------把后端的 gloo 换成 nccl 或各家加速卡的通信后端就能直接跑。

python 复制代码
"""
verify_sp_attention.py

用途:验证序列并行注意力实现的数值正确性。

用法:
    torchrun --nproc_per_node=2 verify_sp_attention.py
    torchrun --nproc_per_node=2 verify_sp_attention.py --backend nccl

会依次执行三项检查:
    [1] 分片正确性:每张卡拿到的序列片段是否连续无重叠
    [2] 数值正确性:并行输出 vs 朴素实现
    [3] 通信真伪:并行输出是否等价于"只用本地数据"
"""
import argparse

import torch
import torch.distributed as dist


def naive_attention(q, k, v):
    """参考实现:完整序列的朴素注意力。q/k/v: (B, S, H, D)"""
    scale = q.shape[-1] ** -0.5
    scores = torch.matmul(q, k.transpose(-2, -1)) * scale
    probs = torch.softmax(scores, dim=-1)
    return torch.matmul(probs, v)


def make_inputs(b, s, h, d, seed=0, device="cpu"):
    """同一个种子,保证每张卡造出一模一样的完整张量"""
    g = torch.Generator().manual_seed(seed)
    q = torch.randn(b, s, h, d, generator=g)
    k = torch.randn(b, s, h, d, generator=g)
    v = torch.randn(b, s, h, d, generator=g)
    return q.to(device), k.to(device), v.to(device)


def check_shard_cover(rank, world, seq_len):
    """[1] 每张卡分到的 [start, end) 是否连续、无重叠、无遗漏"""
    per = seq_len // world
    start, end = rank * per, (rank + 1) * per if rank < world - 1 else seq_len
    print(f"  rank{rank}: [{start}, {end})  len={end - start}")
    return start, end


def check_numeric(out_par, out_ref, tag):
    """[2] 数值对比"""
    a, b = out_par.float(), out_ref.float()
    max_err = (a - b).abs().max().item()
    cos = torch.nn.functional.cosine_similarity(
        a.flatten(), b.flatten(), dim=0
    ).item()
    ok = max_err < 1e-2 and cos > 0.999
    print(f"  {tag:<28} max_err={max_err:.6f}  cos={cos:.8f}  {'OK' if ok else 'FAIL'}")
    return ok


def check_did_communicate(out_par, out_local, tag):
    """[3] 并行输出如果和"只用本地数据"完全相同,说明没通信"""
    diff = (out_par.float() - out_local.float()).abs().max().item()
    printed = "完全相同 -> 疑似未通信" if diff == 0 else f"有差异({diff:.6f})"
    print(f"  {tag:<28} vs 本地结果: {printed}")
    return diff > 0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--backend", default="gloo")
    ap.add_argument("--seq", type=int, default=64)
    ap.add_argument("--heads", type=int, default=2)
    ap.add_argument("--dim", type=int, default=32)
    args = ap.parse_args()

    dist.init_process_group(backend=args.backend)
    rank, world = dist.get_rank(), dist.get_world_size()
    device = torch.device("cpu")

    b, s, h, d = 1, args.seq, args.heads, args.dim
    q, k, v = make_inputs(b, s, h, d, device=device)
    ref = naive_attention(q, k, v)          # 全序列参考结果

    if rank == 0:
        print(f"\n[1] 分片覆盖检查 (world={world}, seq={s})")
    dist.barrier()
    start, end = check_shard_cover(rank, world, s)
    dist.barrier()

    # 本卡分片
    q_l = q[:, start:end].contiguous()
    k_l = k[:, start:end].contiguous()
    v_l = v[:, start:end].contiguous()

    # ---- 这里替换成你正在验证的那个库的实现 ----
    # 约定:输入本卡的 q/k/v 分片,返回本卡分片对应的输出 (B, S_local, H, D)
    from your_lib import ring_attention          # noqa: F401
    out_par = ring_attention(q_l, k_l, v_l, dist.group.WORLD)
    # ------------------------------------------

    out_ref = ref[:, start:end]             # 参考结果里对应的那一段
    out_local = naive_attention(q_l, k_l, v_l)   # 只用本地数据

    if rank == 0:
        print("\n[2] 数值正确性")
    dist.barrier()
    ok_num = check_numeric(out_par, out_ref, f"rank{rank} vs 全序列参考")
    dist.barrier()

    if rank == 0:
        print("\n[3] 通信真伪")
    dist.barrier()
    ok_comm = check_did_communicate(out_par, out_local, f"rank{rank} 并行 vs 本地")
    dist.barrier()

    dist.destroy_process_group()

    if rank == 0:
        print("\n结论:")
        print(f"  数值      {'通过' if ok_num else '失败'}")
        print(f"  通信      {'发生' if ok_comm else '未发生(几乎可以确定是 bug)'}")


if __name__ == "__main__":
    main()

怎么读这份脚本的输出

跑两卡,正常的情况应该是这个样子:

ini 复制代码
[1] 分片覆盖检查 (world=2, seq=64)
  rank0: [0, 32)  len=32
  rank1: [32, 64)  len=32
[2] 数值正确性
  rank0 vs 全序列参考        max_err=1e-3 量级   cos>0.999   OK
  rank1 vs 全序列参考        max_err=1e-3 量级   cos>0.999   OK
[3] 通信真伪
  rank0 并行 vs 本地          vs 本地结果: 有差异
  rank1 并行 vs 本地          vs 本地结果: 有差异

我当时跑出来是这样(只贴 rank0,最直接的证据在这里):

csharp 复制代码
[2] 数值正确性
  rank0 vs 全序列参考        FAIL(偏差远超 bf16 量级)
[3] 通信真伪
  rank0 并行 vs 本地          vs 本地结果: 完全相同 -> 疑似未通信

两个信号叠在一起,结论就很清楚:rank0 从头到尾没看过 rank1 的数据。 它既对不上全序列参考,又和自己的本地结果逐位相等------这两件事同时成立,只可能是"没通信"。


四、根因:两个必须做对的事

翻实现的时候,问题出在两处,都很典型。

问题 1:跳过条件漏了一个前置条件

环形注意力的循环大概长这样:

python 复制代码
for step in range(1, world_size):
    if step <= comm.rank:      # ← 这里
        continue
    ...

这行的本意是处理 causal(因果)注意力:因果掩码下,位置 i 只能看到 j ≤ i,所以"后面的块"可以跳过。

但它只对 causal 成立。扩散模型的注意力是非因果的(每个位置都能看到全序列),这个"跳过"就变成了"少算"。

正确写法是:

python 复制代码
if is_causal and step <= comm.rank:
    continue

少一个 is_causal,非因果场景下每张卡会静默地少算若干块,且不报错。

问题 2:分块结果没有正确合并

这是更本质的一个。

注意力输出的本质是加权平均,权重来自 softmax。分块计算时,每块只能算出"以本块为分母"的局部 softmax,直接相加是错的------因为各块的归一化因子不同。

正确做法是用 log-sum-exp 逐步合并(online softmax):

python 复制代码
def combine(out1, lse1, out2, lse2):
    """把两块的结果按 softmax 权重合并"""
    lse = torch.logaddexp(lse1, lse2)
    w1 = torch.exp(lse1 - lse)
    w2 = torch.exp(lse2 - lse)
    return out1 * w1 + out2 * w2, lse

完整循环:

python 复制代码
# 初始化:先用本地块
out, lse = local_attn_with_lse(q, k_l, v_l)

for step in range(1, world_size):
    k_r, v_r = ring_send_recv(k_l, v_l)        # 收上一张卡的,发给下一张
    o_r, l_r = local_attn_with_lse(q, k_r, v_r)
    out, lse = combine(out, lse, o_r, l_r)     # ← 少了这行就只保留最后一块

return out

那个库的问题就是从来没有调用过合并函数。所以每张卡的输出要么是"只算了本地块",要么是"只保留了循环最后一次的结果"------两种都能跑完、都不报错。


五、给合并逻辑补一个单元测试

上面说"要合并",但怎么验证你的合并写对了?

不需要多卡环境,单机就能测。思路是:分块算的结果,必须等于一次算完的结果。

python 复制代码
"""
test_combine.py ------ 验证分块 LSE 合并与一次性计算等价
纯单机、CPU 即可运行,不需要分布式环境。
"""
import torch


def naive_attn(q, k, v):
    """一次算完的参考实现"""
    scale = q.shape[-1] ** -0.5
    s = torch.matmul(q, k.transpose(-2, -1)) * scale
    return torch.softmax(s, dim=-1) @ v


def local_attn_lse(q, k, v):
    """算局部注意力,同时返回 softmax 的 log-sum-exp

    q: (B, Sq, H, D) 本卡的 query(完整或分片)
    k/v: (B, Sk, H, D) 当前这一块的 KV
    """
    scale = q.shape[-1] ** -0.5
    s = torch.matmul(q, k.transpose(-2, -1)) * scale
    m = s.max(dim=-1, keepdim=True).values          # 减去最大值防溢出
    p = torch.exp(s - m)
    l = p.sum(dim=-1, keepdim=True)
    o = torch.matmul(p, v) / l
    lse = (m + torch.log(l)).squeeze(-1)            # (B, Sq, H)
    return o, lse


def combine(o1, lse1, o2, lse2):
    """按 softmax 权重合并两块结果(online softmax 的核心)"""
    lse = torch.logaddexp(lse1, lse2)
    w1 = torch.exp(lse1 - lse).unsqueeze(-1)
    w2 = torch.exp(lse2 - lse).unsqueeze(-1)
    return o1 * w1 + o2 * w2, lse


def chunked_attn(q, k, v, n_chunks):
    """模拟 ring:q 固定,KV 分块遍历并逐步合并"""
    per = k.shape[1] // n_chunks
    out = lse = None
    for i in range(n_chunks):
        sl = slice(i * per, (i + 1) * per)
        oi, li = local_attn_lse(q, k[:, sl], v[:, sl])
        out, lse = (oi, li) if out is None else combine(out, lse, oi, li)
    return out


def test_chunked_equals_full(dtype=torch.float32):
    torch.manual_seed(0)
    B, S, H, D = 1, 128, 2, 32
    q, k, v = (torch.randn(B, S, H, D, dtype=dtype) for _ in range(3))
    ref = naive_attn(q, k, v)

    print(f"\n精度 = {dtype}")
    for n in (1, 2, 4, 8, 16):
        got = chunked_attn(q, k, v, n)
        err = (got.float() - ref.float()).abs().max().item()
        cos = torch.nn.functional.cosine_similarity(
            got.float().flatten(), ref.float().flatten(), dim=0
        ).item()
        flag = "OK" if cos > 0.999 else "FAIL"
        print(f"  {n:>2} 块  max_err={err:.2e}  cos={cos:.8f}  {flag}")
        assert cos > 0.999, f"{n} 块合并后与整体计算不一致"


def test_wrong_merge_is_caught(dtype=torch.float32):
    """反例:如果不用 LSE 加权,而是简单平均,误差有多大"""
    torch.manual_seed(0)
    B, S, H, D = 1, 128, 2, 32
    q, k, v = (torch.randn(B, S, H, D, dtype=dtype) for _ in range(3))
    ref = naive_attn(q, k, v)

    per, outs = S // 4, []
    for i in range(4):
        sl = slice(i * per, (i + 1) * per)
        oi, _ = local_attn_lse(q, k[:, sl], v[:, sl])
        outs.append(oi)

    wrong_avg = sum(outs) / len(outs)            # 错法 A:简单平均
    wrong_last = outs[-1]                        # 错法 B:只用最后一块

    for name, o in (("简单平均", wrong_avg), ("只用最后一块", wrong_last)):
        err = (o.float() - ref.float()).abs().max().item()
        cos = torch.nn.functional.cosine_similarity(
            o.float().flatten(), ref.float().flatten(), dim=0
        ).item()
        print(f"  错误做法[{name}]  max_err={err:.3f}  cos={cos:.4f}  "
              f"{'看起来还行(危险)' if cos > 0.99 else '明显错误'}")


if __name__ == "__main__":
    test_chunked_equals_full(torch.float32)
    test_chunked_equals_full(torch.bfloat16)
    print()
    test_wrong_merge_is_caught()

这个测试为什么有用

第一,它不需要多卡。 合并逻辑的正确性和通信无关------把 KV 切成几块、逐块算、逐步合并,单进程就能完整复现 ring attention 的数据流。这样 CI 里随时能跑,几十毫秒出结果。

公式本身我用纯 Python 单独验证过一遍:切 1 / 2 / 4 / 8 块,合并结果和一次性计算的误差都在 1e-16 量级(float64 的机器精度)。说明这套合并公式是严格的数学等价,不是近似。

第二,它能抓住"看起来还行"的错误。 用同样的方法跑两种错误写法,相对误差是这样的:

ini 复制代码
简单平均         max_err=0.620   相对误差=58.5%
只用最后一块      max_err=1.255   相对误差=118.3%

注意第二个------比不合并更离谱,因为它把前面所有块的信息全丢了。

而这个量级的错误,在单层上看起来往往是"结果还算合理":张量形状对、数值范围正常、没有 NaN。既不是"明显崩了"(那样一眼就能发现),也不是"接近正确"。它就落在那个最难受的区间里。

这也解释了为什么端到端要跑几十步才暴露:单层几成的相对误差,经过 40 步迭代放大,就变成了完全不同的一张图。

第三,它给了你一个可以钉死的验收标准。 以后换库、升级版本、改实现,先跑这个测试,cos > 0.999 过不了就别往下接。


六、一份可复用的检查清单

这类"静默算错"的问题,我总结了几条通用检查法,遇到新的并行库可以直接套:

数值层

  • 小输入(seq=64,heads=2,dim=32)先跑通,别一上来就上真实尺寸
  • 和朴素实现逐元素比,容差设成 bf16 级别(1e-2)
  • 分块数与整体结果必须等价(第 5 节那个单测)
  • 检查每张卡的误差是否一致------如果只有部分 rank 错,基本能定位到通信路径

通信层

  • "并行结果 vs 只用本地数据的结果"必须有差异
  • 把远端数据换成特殊值(比如全 1e4),看输出是否随之改变
  • 检查通信次数:world_size - 1 次还是 world_size 次,写错也能跑

结构层

  • 分片是否连续、无重叠、无遗漏(打印区间核对)
  • 输出拼回去的顺序对不对(常见错误:按 rank 顺序拼 vs 按原始位置拼)
  • 有没有 causal 掩码相关的条件分支,前置条件是否完整

环境层

  • 单卡先验证库本身(有些库单卡路径和多卡路径是两套代码)
  • 换后端(gloo 换 nccl/各家加速卡后端)行为是否一致
  • 进程数不是 2 的时候(比如 3 卡)是否还成立------很多实现在 2 的幂次上没问题

七、最后一点体会

第三方高性能库最大的问题不是"质量差",而是它的失败模式太安静。

一个手写的 for 循环写错了,你大概率会看到崩或者 NaN。但一个精心优化过的并行 kernel 写错了,它照样能输出形状正确、数值合理的张量------只是那个张量不是你要的。

所以凡是引入第三方的高性能实现,都要在接入的第一天做数值验证,别等到端到端结果不对了再回头查。那时代码已经写了上千行,怀疑的对象太多了。

三十行的验证脚本,能省三天。


文中代码为示意,接口按通用约定设计,接入时替换为你实际使用的库。

📎 本文的排查经验,也浓缩进了另一篇更体系化的踩坑总结:《扩散模型推理加速的三个方向,以及移植到国产 NPU 时踩的坑》

相关推荐
陈嘿萌7 小时前
ACM MM 2026|告别密集交互!ProtoHGF-Net:基于原型超图的RGBT目标检测新范式
图像处理·人工智能·深度学习·目标检测·计算机视觉·学术研究与理论基础
FanfanZainuli7 小时前
减肥APP智能化技术演进:从“内容推荐“到“AI驱动的个性化闭环“
人工智能·深度学习·机器学习
试剂界的爱马仕8 小时前
α-Cyano-4-hydroxycinnamic acid(CHCA)用作 MALDI-TOF 基质的配制与使用
大数据·人工智能·深度学习·学习
大熊背8 小时前
人工智能中的数学之美---从向量角度理解深度学习中的感知机
人工智能·深度学习
小蒋观天下9 小时前
网络抖动、乱序、丢包:UDP/RTSP/TCP对AI抓拍、轨迹跟踪的不同影响
大数据·人工智能·深度学习
高洁019 小时前
大模型检索增强生成(RAG)的技术原理与架构设计
人工智能·深度学习·机器学习·django·迁移学习
数聚天成DeepSData9 小时前
两份名录里的同名机构,主键该怎么合并
人工智能·深度学习·机器学习·数据集·deepsdata
陕西亿鑫鸿物联技术有限公司10 小时前
传统安防利旧升级实战:如何基于边缘计算接入海康大华IPC构建AI分析系统?
深度学习·计算机视觉·边缘计算·物联网架构·流媒体技术
拜小白10 小时前
【论文解读】Ray-casting MLC:用几何感知光线投射做自训练房间布局估计
深度学习·计算机视觉·全景图