Closed
[Bug-Report|缺陷反馈]: MoeDistributeDispatch V1/V2/V3/V4 声明 epWorldSize 大于实际通信域 rank 数时两段返回 0 但输出全未写(ep=16/8rank 直接挂死) #6531
liuhanwen8Add on  24 days agoClosed at  18 days ago
liuhanwen8
liuhanwen8
24 days ago Create

问题描述

aclnnMoeDistributeDispatchV2 在声明的 epWorldSize 大于实际 EP 通信域 rank 数时:

  • 第一段 aclnnMoeDistributeDispatchV2GetWorkspaceSize 返回 0,第二段也返回 0;
  • 但 expandX、epRecvCounts、expertTokenNums、tpRecvCounts 全部保持调用前的内容——输出 buffer 预填 0xA5 金丝雀,执行后仍是 0xA5,一个字节都没被写过;
  • 若声明 epWorldSize=16 而只起 8 个 rank,算子挂死(进程进入 D 状态,kill -9 无效)。

同一现场在 aclnnMoeDistributeDispatch / V3 / V4 上一致复现(原始日志见下方"实测输出")。

环境信息

  • 仓库:cann/ops-transformer,源码核对点 origin/master@4dc31cf4
  • 硬件:Atlas A2 训练系列(910B2)×8
  • 软件:CANN Toolkit / Ops 9.2.0(/home/l00797864/cann/ascend-toolkit)
  • 通信域:HcclCommInitRootInfo(2, ...),即真实 EP 通信域只有 2 个 rank

复现步骤

双卡 2-rank 起通信域,声明 epWorldSize=8(资料 A2/V2 明确支持 8),moeExpertNum=8,commAlg="fullmesh",
BS=8 / H=512 / K=1,输出 buffer 预填 0xA5 后调用两段接口:

[rank 0] 第一段 rc=0 第二段 rc=0 expandX[0..7]=A5A5A5A5A5A5A5A5 -> 输出未写出(仍是 0xA5 金丝雀)
[rank 1] 第一段 rc=0 第二段 rc=0 expandX[0..7]=A5A5A5A5A5A5A5A5 -> 输出未写出(仍是 0xA5 金丝雀)

ep=8 + 真实 8 rank 时同参数可正常输出(逐字节校验通过),说明不是形状/属性本身非法,问题在"声明与实际通信域不一致时无校验"。

moe_disp_probe 侧同现场(epRecvCounts/expertTokenNums 也全是 0xA5A5A5A5 对应的 -1515870811 / -6510615555426900571):

[probe] op=v2 ranks=2 epWs=8 BS=8 H=256 K=1 ... globalBs=64 ok=11
[A] rc1=0/0/ rc2=0/0/ A=64 local=1 moe=8
[r0] rows=64 expected=2 missing=2 extra=0 allCanary=1 sum(epRecv)=-12126966488 sum(etn)=-6510615555426900571
[fail] expected=4 missing=4 extra=0

复现代码(自包含,mode=mismatch)

#include <cstdio>
#include <cstring>
#include <thread>
#include <vector>
#include "acl/acl.h"
#include "hccl/hccl.h"
#include "aclnnop/aclnn_moe_distribute_dispatch_v2.h"

static HcclRootInfo g_root;
static char g_group[128] = {0};

static aclTensor *Make(std::vector<int64_t> sh, aclDataType dt, size_t bytes, void **dev, bool canary = false)
{
    aclrtMalloc(dev, bytes, ACL_MEM_MALLOC_HUGE_FIRST);
    if (canary) {                                   // 金丝雀:算子写过任何字节都能看出来
        std::vector<unsigned char> pat(bytes, 0xA5);
        aclrtMemcpy(*dev, bytes, pat.data(), bytes, ACL_MEMCPY_HOST_TO_DEVICE);
    }
    std::vector<int64_t> st(sh.size(), 1);
    for (int64_t i = (int64_t)sh.size() - 2; i >= 0; --i) st[i] = sh[i + 1] * st[i + 1];
    return aclCreateTensor(sh.data(), sh.size(), dt, st.data(), 0, ACL_FORMAT_ND, sh.data(), sh.size(), *dev);
}

static void RankRun(int rank)
{
    aclrtSetDevice(rank);
    aclrtContext ctx = nullptr;
    aclrtCreateContext(&ctx, rank);
    aclrtStream stream = nullptr;
    aclrtCreateStream(&stream);
    HcclComm comm = nullptr;
    if (HcclCommInitRootInfo(2, &g_root, rank, &comm) != HCCL_SUCCESS) return;   // 真实通信域只有 2 个 rank
    char name[128] = {0};
    HcclGetCommName(comm, name);
    if (!g_group[0]) snprintf(g_group, sizeof(g_group), "%s", name);

    const int BS = 8, H = 512, K = 1, EP = 8, MOE = 8, A = BS * EP;              // 声明 epWorldSize=8
    std::vector<int16_t> xh((size_t)BS * H, 0x3C00);
    std::vector<int32_t> idh((size_t)BS * K, 0);
    for (int i = 0; i < BS; ++i) idh[i] = (rank * BS + i) % MOE;

    void *px = nullptr, *pid = nullptr, *pes = nullptr, *pout = nullptr, *pdyn = nullptr, *pas = nullptr;
    void *petn = nullptr, *pep = nullptr, *ptp = nullptr, *pexs = nullptr;
    aclTensor *x = Make({BS, H}, ACL_FLOAT16, (size_t)BS * H * 2, &px);
    aclTensor *ids = Make({BS, K}, ACL_INT32, (size_t)BS * K * 4, &pid);
    aclrtMemcpy(px, (size_t)BS * H * 2, xh.data(), (size_t)BS * H * 2, ACL_MEMCPY_HOST_TO_DEVICE);
    aclrtMemcpy(pid, idh.size() * 4, idh.data(), idh.size() * 4, ACL_MEMCPY_HOST_TO_DEVICE);
    aclTensor *eScales = Make({BS, K}, ACL_FLOAT, (size_t)BS * K * 4, &pes);
    aclTensor *expandX = Make({A, H}, ACL_FLOAT16, (size_t)A * H * 2, &pout, true);
    aclTensor *dyn = Make({A}, ACL_FLOAT, (size_t)A * 4, &pdyn, true);
    aclTensor *assist = Make({(int64_t)A * 128}, ACL_INT32, (size_t)A * 128 * 4, &pas, true);
    aclTensor *etn = Make({MOE / EP}, ACL_INT64, (size_t)(MOE / EP) * 8, &petn, true);
    aclTensor *epRecv = Make({MOE}, ACL_INT32, (size_t)MOE * 4, &pep, true);
    aclTensor *tpRecv = Make({1}, ACL_INT32, 4, &ptp, true);
    aclTensor *exScales = Make({A}, ACL_FLOAT, (size_t)A * 4, &pexs, true);

    uint64_t ws = 0;
    aclOpExecutor *exec = nullptr;
    aclnnStatus rc1 = aclnnMoeDistributeDispatchV2GetWorkspaceSize(
        x, ids, nullptr, nullptr, eScales, g_group, EP, rank, MOE, "", 0, 0, 0, 0, 0, 0, BS * EP, 0, "fullmesh",
        expandX, dyn, assist, etn, epRecv, tpRecv, exScales, &ws, &exec);
    int rc2 = -999;
    if (rc1 == 0) {
        void *wsDev = nullptr;
        if (ws > 0) aclrtMalloc(&wsDev, ws, ACL_MEM_MALLOC_HUGE_FIRST);
        rc2 = (int)aclnnMoeDistributeDispatchV2(wsDev, ws, exec, stream);
        aclrtSynchronizeStream(stream);
    }
    unsigned char head[8] = {0};
    aclrtMemcpy(head, 8, pout, 8, ACL_MEMCPY_DEVICE_TO_HOST);
    bool canary = true;
    for (unsigned char c : head) if (c != 0xA5) canary = false;
    printf("[rank %d] rc1=%d rc2=%d expandX[0..7]=%02X%02X%02X%02X%02X%02X%02X%02X %s\n", rank, (int)rc1, rc2,
           head[0], head[1], head[2], head[3], head[4], head[5], head[6], head[7],
           canary ? "(输出未写出,仍是 0xA5)" : "(已写出)");
    HcclCommDestroy(comm);
}

int main()
{
    aclInit(nullptr);
    aclrtSetDevice(0);
    aclrtContext c0 = nullptr;
    aclrtCreateContext(&c0, 0);
    if (HcclGetRootInfo(&g_root) != HCCL_SUCCESS) return 1;
    std::thread t0(RankRun, 0), t1(RankRun, 1);
    t0.join();
    t1.join();
    aclFinalize();
    return 0;
}

编译运行:

g++ -std=c++17 repro.cpp -o repro -pthread -I$ASCEND_HOME_PATH/include -L$ASCEND_HOME_PATH/lib64 \
    -lascendcl -lhccl -lnnopbase -lopapi -lacl_op_compiler
ASCEND_RT_VISIBLE_DEVICES=4,5 ./repro

期望行为

第一段接口应对 epWorldSize 与实际 EP 通信域 rank 数做交叉校验,不一致时返回 561002(参数取值不在支持范围);
至少不能"返回 0 且输出未写",否则调用方会拿未初始化显存当 token 特征继续算 combine/FFN。

影响

P1:静默成功 + 输出为未初始化显存。典型触发方式是启动的 rank 数少于 epWorldSize(漏起进程、扩缩容、调度失败),
调用侧只看返回码无法发现,错误会以"特征全为脏数据"的形式扩散到下游,且难以定位。

其他

  • V1/V3/V4 同现场复现(同一进程结构,仅 API 不同);
  • epWorldSize=16 + 8 rank 时进程进入 D 状态、kill -9 无效,会占用 device 上下文,需要等待设备侧 I/O 自行返回。
likedislike
周云飞
周云飞Collaborators
24 days ago Comments:

感谢提供完整的复现代码和对照结果。您报告的关键差异是:在 Atlas A2、CANN 9.2.0 环境中,声明 epWorldSize=8、实际 EP 通信域为 2 rank 时,两段接口均返回 0,但输出保留 0xA5;相同参数在真实 8 rank 通信域中能够正常输出。这为排查通信域规模与算子参数不一致提供了明确线索。V1/V3/V4 的同类现象,以及 epWorldSize=16、实际 8 rank 时的挂死,目前按您提供的实测情况记录。

问题分析

这里需要区分接口返回成功与设备侧执行成功。复现代码调用了 aclrtSynchronizeStream,但没有检查其返回值,随后读取输出的 aclrtMemcpy 返回值也未检查。因此,两段返回 0 仍不足以区分“执行结束但未写输出”和“异步执行失败、同步或回读发生异常”。这不影响您反馈的问题值得排查,但会影响对静默成功机制的判断。

目前的证据还不能确定校验具体缺失在哪一层,也不能将挂死直接归因于等待不存在的 rank。声明的通信规模与实际通信域不一致,和通信域规模正确但部分进程未参与执行,也需要分别处理;后者不能仅靠比较 rank 数发现。

处理方向

一个可行方向是在能够取得实际 EP 通信域信息的主机侧入口,将其 rank 数与 epWorldSize 比较,不一致时提前返回错误,避免继续提交设备任务。如果第一段已具备查询条件,放在第一段拒绝更有利于调用方及时发现配置错误。具体是否返回您建议的 561002,还需结合各版本公开接口的错误码定义及底层错误传递方式确认。

当前可先在调用侧检查实际 EP 通信域大小与 epWorldSize 一致,并在同步、输出回读失败时停止使用输出,避免继续传给 combine/FFN。请补充现有复现中 aclrtSynchronizeStream 和输出回读 aclrtMemcpy 的返回码;若有失败,再附对应的运行时错误日志片段即可。这些信息有助于区分异步失败与执行完成后未写输出,无需为补充信息再次运行已知会挂死的配置。

likedislike
游震Collaborators
23 days ago Comments:

/assign @luobaiqing

likedislike
CANN-robotCANN-robotCollaborators
23 days ago Assign luobaiqing as responsible person
luobqluobqCollaborators
22 days ago Assign SuperYuan as responsible person
luobqluobqCollaborators
22 days ago Removed responsible person luobaiqing
Simple_Bright&ManSimple_Bright&Man
20 days ago Associated pull request
CANN-robotCANN-robotCollaborators
18 days ago Close issue
CANN-robotCANN-robotCollaborators
18 days ago Added label: resolved
周云飞
周云飞Collaborators
4 days ago Comments:

PR #14593 在 A2 Dispatch 的 tiling 路径中增加了 EP 通信域 rank 数查询:查询失败或查询结果与 epWorldSize 不一致时记录告警,但仍使用声明的 epWorldSize 继续执行,不会因此返回错误。相关单元测试也覆盖了不一致时继续 tiling 的情况。

因此,该改动提供了告警信息,但不能确认已解决参数不一致导致输出未写入或挂死的问题。

关联 PR:PR #14593

likedislike