已关闭
[Bug-Report|缺陷反馈]: mhcPreSinkhornBackward 梯度精度漂移 / B=0输出未写 / 并发输出污染(910B3, CANN 9.2.0) #6619
majinglan创建于  17 天前关闭于  9 天前
majinglan
majinglan
17 天前 创建

Describe the current behavior / 问题描述 (Mandatory / 必填)

A2(Ascend 910B3)+ CANN 9.2.0,通过 libopapi 两段式纯 C 调用(ctypes + numpy,无 torch 依赖)实测 aclnnMhcPreSinkhornBackward 存在 3 个缺陷:

缺陷 1:大 shape 反向梯度与前向有限差分(FD)存在 ~2e-2 系统偏差

  • 触发条件:M·K ≥ 2M(M=B·S,K=N·C)起;bf16/fp16 均复现;B/S 划分不变性已验证(偏差只由 M、K 决定)
  • 复现参数:B=4 S=256 N=4 C=1024 numIters=20(numIters 为前向生成参数),seed=20261023
  • FD eps 0.2→0.005 平滑收敛 → 偏差非 FD 截断误差,在 backward 侧;前向本身 bitwise 可复现(排除前向噪声主导):
bias[12]    backward=+0.010901   FD(eps=0.05)=-0.012569   diff=-0.0235   ← 符号相反
bias[8]     backward=+0.290012   FD(eps=0.05)=+0.277309   diff=-0.0127
bias[23]    backward=-0.911003   FD(eps=0.05)=-0.892372   diff=+0.0186
phi[12,2048] backward=-0.046537  FD(eps=0.01)=-0.027466   diff=+0.0191
phi[23,4095] backward=+1.475439  FD(eps=0.01)=+1.487541   diff=+0.0121

缺陷 2:B=0 空批次返回成功但不写非空输出

  • B=0 时两段式调用返回 0,非空输出 gradPhi/gradAlpha/gradBias 完全未写(保留预填金丝雀 0xAB):gradPhi 49152/49152、gradAlpha 12/12、gradBias 96/96 字节全残留
  • 数学上 B=0 空和应写全 0;头文件亦无空 tensor 语义声明
  • mssanitizer initcheck 交叉验证:fp16 B=0(gradPhi [24,4096])触发 393216+12+96 字节 GM 未初始化读(D2H 读到 kernel 从未写入的输出缓冲)
  • 一键复现脚本见「重现步骤」(自包含,仅 ctypes+numpy)

缺陷 3:共享 context 8 线程并发调用,输出被跨调用污染

  • 8 线程共享 aclrt context(每调用独立输入/输出/workspace,同 stream)并发重放 8 条不同 shape 语料,输出位模式与单线程基准不一致(74 次 FAIL)
  • 2 条 base 形状 case 出现稳定错误摘要(并发结果自一致但 ≠ 基准);seq512 大形状与空批次 case 出现随机摘要
  • 隔离实验:单 case × 8 线程并发干净 → 污染需多个不同 case 同时在飞(跨调用污染,非同 case 自竞争)
  • 对照实验:aclnnAdd 同参数 shared×8 并发完全干净 → 本算子特异
  • kernel 侧佐证(mssanitizer):racecheck 16 处同 block 同址 WAR(mhc_pre_grad_kernel.h:938 WholeReduceSum 读 dpreLocalList_[outPos] vs :826 Duplicate 清零,跨调用间无 barrier);initcheck 28 处 UB 越界读(BlockReduceSum :467/:539 读越已写区域进未初始化尾部——串行下残渣稳定不影响输出,并发下随核间调度变化,与 seq512 case 随机摘要机制互证)

另附一项 P3:头文件约束缺口——N=4 / numIters=20 / C%128==0 三项约束未在头文件声明,违反时返回 161002。

Environment / 环境信息 (Mandatory / 必填)

  • Ascend 910B3 ×8,openEuler 22.03 LTS aarch64
  • CANN 9.2.0(已核对 ops-transformer master 分支:arch22 kernel+tiling 与 9.2.0 零功能改动,上述缺陷 master 同样存在)
  • 全部复现为 libopapi 两段式纯 C 调用(ctypes + numpy),无 torch/torch_npu 依赖

Steps to reproduce the issue / 重现步骤 (Mandatory / 必填)

缺陷 2 一键复现:保存以下脚本为 repro_b0.py,python3 repro_b0.py [device_id]:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""aclnnMhcPreSinkhornBackward 空批次(B=0)输出未写缺陷最小复现(自包含)。

现象:B=0 时两段式调用返回 0(成功),但非空输出 gradPhi/gradAlpha/gradBias
     完全未被写出(保留预填金丝雀 0xAB)。数学上 B=0 的空和应写出全 0。
环境:Ascend 910B3(A2)+ CANN 9.2.0;仅依赖 ctypes + numpy,无需 torch。
"""
import ctypes as C
import os
import sys

import numpy as np

ACL_FLOAT, ACL_BF16 = 0, 27
ACL_FORMAT_ND = 2
DTYPESZ = {ACL_FLOAT: 4, ACL_BF16: 2}
H2D, D2H = 1, 2
HUGE_FIRST = 0


def find_cann():
    cands = [os.environ.get("ASCEND_HOME_PATH"),
             "/usr/local/Ascend/ascend-toolkit/latest",
             "/usr/local/Ascend/nna/latest"]
    for p in cands:
        if not p:
            continue
        for sub in ("aarch64-linux/lib64", "lib64"):
            if os.path.isfile(os.path.join(p, sub, "libopapi.so")):
                return os.path.join(p, sub)
    raise SystemExit("CANN runtime (libopapi.so) not found; set ASCEND_HOME_PATH")


class MiniAcl:
    def __init__(self):
        libdir = find_cann()
        self.ascendcl = C.CDLL(os.path.join(libdir, "libascendcl.so"), mode=C.RTLD_GLOBAL)
        self.nnopbase = C.CDLL(os.path.join(libdir, "libnnopbase.so"), mode=C.RTLD_GLOBAL)
        self.opapi = C.CDLL(os.path.join(libdir, "libopapi.so"), mode=C.RTLD_GLOBAL)

        rt = self.ascendcl
        for fn, at in (("aclInit", [C.c_void_p]),
                       ("aclrtSetDevice", [C.c_int32]),
                       ("aclrtResetDevice", [C.c_int32]),
                       ("aclrtCreateStream", [C.POINTER(C.c_void_p)]),
                       ("aclrtDestroyStream", [C.c_void_p]),
                       ("aclrtSynchronizeStream", [C.c_void_p]),
                       ("aclrtFree", [C.c_void_p])):
            getattr(rt, fn).argtypes = at
            getattr(rt, fn).restype = C.c_int32
        rt.aclrtMalloc.argtypes = [C.POINTER(C.c_void_p), C.c_size_t, C.c_int32]
        rt.aclrtMalloc.restype = C.c_int32
        rt.aclrtMemcpy.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_int32]
        rt.aclrtMemcpy.restype = C.c_int32

        nn = self.nnopbase
        nn.aclCreateTensor.argtypes = [
            C.POINTER(C.c_int64), C.c_uint64, C.c_int32,
            C.POINTER(C.c_int64), C.c_int64, C.c_int32,
            C.POINTER(C.c_int64), C.c_uint64, C.c_void_p]
        nn.aclCreateTensor.restype = C.c_void_p
        nn.aclDestroyTensor.argtypes = [C.c_void_p]

        op = self.opapi
        self.bwd_gws = op.aclnnMhcPreSinkhornBackwardGetWorkspaceSize
        self.bwd_gws.argtypes = [C.c_void_p] * 12 + [C.c_double] + [C.c_void_p] * 4 + \
            [C.POINTER(C.c_uint64), C.POINTER(C.c_void_p)]
        self.bwd_gws.restype = C.c_int32
        self.bwd_run = op.aclnnMhcPreSinkhornBackward
        self.bwd_run.argtypes = [C.c_void_p, C.c_uint64, C.c_void_p, C.c_void_p]
        self.bwd_run.restype = C.c_int32

    def check(self, rc, what):
        if rc != 0:
            raise RuntimeError("%s failed, rc=%d" % (what, rc))
        return rc


class T:
    """张量:0 元素用 NULL 指针;非空输出预填金丝雀后可检查残留。"""

    def __init__(self, acl, dtype, shape):
        self.acl, self.dtype, self.shape = acl, dtype, tuple(shape)
        self.nbytes = (int(np.prod(self.shape)) if self.shape else 1) * DTYPESZ[dtype]
        self.dev, self.handle = C.c_void_p(0), None
        if self.nbytes:
            p = C.c_void_p()
            acl.check(acl.ascendcl.aclrtMalloc(C.byref(p), self.nbytes, HUGE_FIRST),
                      "aclrtMalloc")
            self.dev = p

    def upload(self, raw):
        if self.nbytes:
            self.acl.ascendcl.aclrtMemcpy(self.dev, self.nbytes, raw.ctypes.data,
                                           self.nbytes, H2D)

    def download(self):
        if not self.nbytes:
            return np.zeros(0, dtype=np.uint8)
        buf = np.zeros(self.nbytes, dtype=np.uint8)
        self.acl.ascendcl.aclrtMemcpy(buf.ctypes.data, self.nbytes, self.dev,
                                      self.nbytes, D2H)
        return buf

    def handle_(self):
        dims = (C.c_int64 * len(self.shape))(*self.shape)
        st = []
        acc = 1
        for d in reversed(self.shape):
            st.append(acc)
            acc *= d
        st = (C.c_int64 * len(st))(*reversed(st))
        h = self.acl.nnopbase.aclCreateTensor(
            dims, len(self.shape), self.dtype, st, 0, ACL_FORMAT_ND,
            dims, len(self.shape), self.dev)
        if not h:
            raise RuntimeError("aclCreateTensor returned NULL")
        self.handle = C.c_void_p(h)
        return self.handle


def main():
    dev = int(sys.argv[1]) if len(sys.argv) > 1 else 0
    B, S, N, Ch = 0, 8, 4, 128      # B=0 触发
    nc, sk = 2 * N + N * N, 40      # nc=24, sk=2*numIters(20)
    SENT = 0xAB

    acl = MiniAcl()
    acl.check(acl.ascendcl.aclInit(None), "aclInit")
    acl.check(acl.ascendcl.aclrtSetDevice(dev), "SetDevice")
    stream = C.c_void_p()
    acl.check(acl.ascendcl.aclrtCreateStream(C.byref(stream)), "CreateStream")

    rng = np.random.default_rng(20261023)
    ins = [T(acl, ACL_BF16, (B, S, Ch)),            # gradHin
           T(acl, ACL_FLOAT, (B, S, N)),            # gradHPost
           T(acl, ACL_FLOAT, (B, S, N * N)),        # gradHRes
           T(acl, ACL_BF16, (B, S, N, Ch)),         # x
           T(acl, ACL_FLOAT, (nc, N * Ch)),         # phi
           T(acl, ACL_FLOAT, (3,)),                 # alpha
           T(acl, ACL_FLOAT, (nc,)),                # bias
           T(acl, ACL_FLOAT, (B, S, N)),            # hPre
           T(acl, ACL_FLOAT, (B, S, nc)),           # hcBeforeNorm
           T(acl, ACL_FLOAT, (B, S, 1)),            # invRms
           T(acl, ACL_FLOAT, (sk, B, S, N)),        # sumOut
           T(acl, ACL_FLOAT, (sk, B, S, N, N))]     # normOut
    ins[4].upload(np.ascontiguousarray(
        rng.standard_normal((nc, N * Ch)) * 0.05).astype(np.float32))
    ins[5].upload(np.zeros(3, np.float32))
    ins[6].upload(np.zeros(nc, np.float32))
    outs = [T(acl, ACL_BF16, (B, S, N, Ch)),        # gradX(0 元素)
            T(acl, ACL_FLOAT, (nc, N * Ch)),        # gradPhi 非空
            T(acl, ACL_FLOAT, (3,)),                # gradAlpha 非空
            T(acl, ACL_FLOAT, (nc,))]               # gradBias 非空
    for t in outs[1:]:
        t.upload(np.full(t.nbytes, SENT, dtype=np.uint8))

    print("[cfg] B=%d S=%d N=%d C=%d: gradPhi=%dB gradAlpha=%dB gradBias=%dB"
          % (B, S, N, Ch, outs[1].nbytes, outs[2].nbytes, outs[3].nbytes))

    args = [t.handle_() for t in ins] + [C.c_double(1e-6)] + [t.handle_() for t in outs]
    ws_size, executor = C.c_uint64(0), C.c_void_p()
    acl.check(acl.bwd_gws(*(args + [C.byref(ws_size), C.byref(executor)])),
              "GetWorkspaceSize")
    ws = C.c_void_p(0)
    if ws_size.value:
        acl.check(acl.ascendcl.aclrtMalloc(C.byref(ws), ws_size.value, HUGE_FIRST),
                  "workspace malloc")
    acl.check(acl.bwd_run(ws, ws_size.value, executor, stream), "Execute")
    acl.check(acl.ascendcl.aclrtSynchronizeStream(stream), "Sync")
    print("[bwd] two-stage call OK (rc=0), workspaceSize=%d" % ws_size.value)

    bug = False
    for name, t in zip(("gradPhi", "gradAlpha", "gradBias"), outs[1:]):
        raw = t.download()
        left = int((raw == SENT).sum())
        frac = left / len(raw) if len(raw) else 0.0
        v = "UNWRITTEN" if frac == 1.0 else ("partial" if frac > 0 else "written")
        print("[chk] %-9s %5d/%-5d bytes still canary 0x%02X -> %s"
              % (name, left, len(raw), SENT, v))
        bug = bug or frac > 0

    print("=" * 60)
    if bug:
        print("RESULT: BUG - rc=0 but non-empty outputs unwritten (should be all-zero)")
        sys.exit(1)
    print("RESULT: PASS - all non-empty outputs written")
    sys.exit(0)


if __name__ == "__main__":
    main()

910B3 + CANN 9.2.0 实测输出:

[cfg] B=0 S=8 N=4 C=128: gradPhi=49152B gradAlpha=12B gradBias=96B
[bwd] two-stage call OK (rc=0), workspaceSize=0
[chk] gradPhi   49152/49152 bytes still canary 0xAB -> UNWRITTEN
[chk] gradAlpha    12/12    bytes still canary 0xAB -> UNWRITTEN
[chk] gradBias     96/96    bytes still canary 0xAB -> UNWRITTEN
RESULT: BUG - rc=0 but non-empty outputs unwritten (should be all-zero)

缺陷 1 复现方法:前向(aclnnMhcPreSinkhorn)生成 sumOut/normOut 等输入 → 调 backward → 对 x/phi/alpha/bias 逐元素 ±eps 前向差分求 FD 梯度,eps 从 0.2 扫至 0.005;FD 各条目均平滑收敛而 backward 输出不变 → 系统偏差在 backward 侧(完整脚本 test_aclnn_mhc_presinkhorn_backward.py + fd_sweep.py 可提供,同样为纯 ctypes 自包含实现)。

缺陷 3 复现方法:8 线程共享同一 aclrt context(每调用独立 malloc 输入/输出/workspace、独立 executor),循环随机调用 8 条不同 shape 的 case,输出位模式(sha256 over raw bytes)与单线程基准比对;base 形状 case 稳定 FAIL、seq512/空批次 case 随机 FAIL;同 case 单独并发与 aclnnAdd 对照均 PASS(完整 harness 可提供)。

Describe the expected behavior / 预期结果 (Mandatory / 必填)

  1. 大 shape 下 backward 梯度与前向 FD 参考一致(偏差随 eps 收敛至数值噪声水平,不出现符号相反等系统偏差)
  2. B=0 时 gradPhi/gradAlpha/gradBias 写出全 0(数学空和)
  3. 并发调用输出与单线程一致(aclnn 接口线程安全契约)

见上方证据摘录。FD 全表、mssanitizer(initcheck/racecheck)原始日志、并发比对明细均可提供。

Special notes for this issue/备注 (Optional / 选填)

已检索本仓相关 issue:#3929(算子支持需求建议)、#4706(format 校验一致性)、#5609(前向 mhc_pre_sinkhorn 的 HF32 精度声明与状态还原,已修复)——均与本问题不重叠(本 issue 全部针对反向算子 mhc_pre_sinkhorn_backward)。

likedislike
周云飞
周云飞成员
17 天前 评论:

感谢提供详细的复现脚本、有限差分结果和并发对照实验。针对 Ascend 910B3、CANN 9.2.0 下的三类异常及头文件约束缺口,目前可以区分如下。

问题分析

  1. B=0 时非空输出未写入,有明确的代码线索。 接口的空输入分支在发现 gradHin、x 等张量为空后,直接跳过计算,并将 workspaceSize 设为 0;该分支没有为非空参数梯度安排清零操作。正常计算路径中的输出拷贝也不会在这里执行。这与您报告的 gradPhi、gradAlpha、gradBias 分别保留 49152、12、96 字节预填值的现象一致。这里需要区分:gradX 本身为空,但三个参数梯度并不为空,不能仅因输入为空就省略它们的输出处理。相关实现见空输入判断及正常输出拷贝路径。

  2. 大形状梯度偏差需要继续定位具体计算环节。 您提供的 eps 扫描、前向重复性和 B/S 划分对照,是定位反向计算的重要依据。不过,仅凭这些摘录,还不能确定偏差来自哪一步归约、精度设置或中间数据处理,也不能直接认定它与并发异常具有相同根因。

  3. 混合形状并发异常需要与内核告警分别核对。 不同 case 混合并发失败、同 case 并发及 aclnnAdd 对照通过,为缩小排查范围提供了有效线索。但同 block 内的 WAR 告警本身不能证明存在跨调用共享数据污染;UB 未初始化读取是否影响最终输出,也需要结合实际读写范围确认。另外,B=0 输出未写入会使摘要依赖缓冲区原有内容,应与非空 case 的并发结果分开判断。

  4. 头文件确实缺少所述约束说明。 当前接口注释列出了形状和类型,但没有声明 N=4、迭代次数为 20、C 为 128 的倍数,也没有说明空输入时的输出语义。这三项限制的实际适用范围及返回 161002 的具体检查位置,仍需结合校验和 tiling 实现确认。

处理方向

对于 B=0,一个可行的调整是在空输入路径中,为非空的 gradPhi、gradAlpha、gradBias 安排写零操作,同时保留 gradX 为空的行为,并确保写零工作纳入两段式接口的执行流程。

对于精度和并发问题,建议分别沿大形状计算分支、归约有效范围与初始化范围,以及不同调用之间的状态隔离继续定位,暂不预设统一根因。

请补充您提到的有限差分完整脚本、混合形状并发 harness,以及对应的 mssanitizer 原始日志。前者用于核对参考梯度构造和中间量生成方式;后两者用于确认线程中的 context 设置、提交与同步顺序,并将告警对应到具体访问范围。B=0 的最小复现和环境信息已提供,无需重复补充。

likedislike
renruhai
14 天前 评论:

/assign

likedislike
CANN-robotCANN-robot成员
14 天前 将 renruhai 设为负责人
Rrenruhai
12 天前 关联了pull request:fix(mhc_pre_sinkhorn): 修复c对齐校验缺失/needBackward=false空指针561103/B=0梯度输出未写
renruhai
12 天前 评论:

已提交缺陷2(B=0 输出未写)的修复 PR:https://gitcode.com/cann/ops-transformer/merge_requests/14770

修复内容:backward 空 tensor 早退分支(任一输入为空,含 x 为空的 B=0 场景)现在对非空的 gradX/gradPhi/gradAlpha/gradBias 通过 l0op::Fill + ViewCopy 写零后再返回(空输入梯度数学上为全 0),workspaceSize 相应更新;与维护者分析的方向一致。

实机验证(910B3 A/B,金丝雀 0xAB 预填 49152B):修复前 gradPhi 全残留,修复后 全 0;有效路径(bs=2 正常 backward)gradPhi checksum 与修复前逐位一致,行为不变。

缺陷1(大 shape 梯度漂移)与缺陷3(并发输出污染)需要 FD 完整脚本、并发 harness 与 mssanitizer 原始日志复现定位,请提供后继续跟进;本 PR 暂不涉及。

likedislike
CANN-robotCANN-robot成员
9 天前 关闭了 issue
CANN-robotCANN-robot成员
9 天前 添加了label:resolved