训练营简介
2025年昇腾CANN训练营第二季,基于CANN开源开放全场景,推出0基础入门系列、码力全开特辑、开发者案例等专题课程,助力不同阶段开发者快速提升算子开发技能。获得Ascend C算子中级认证,即可领取精美证书,完成社区任务更有机会赢取华为手机,平板、开发板等大奖。

报名链接:https://www.hiascend.com/developer/activities/cann20252#cann-camp-2502-intro

前言

在 YOLO、SSD、Faster R-CNN 等检测网络中,NMS 是最后一道关卡。 它的算法逻辑非常“贪心”:

  1. Sort:按置信度从高到低排序。

  2. Pick:拿出得分最高的框 A。

  3. Compare:计算 A 与剩余所有框的 IoU(交并比)。

  4. Suppress:如果 IoU > 阈值,删掉那个框。

  5. Loop:从剩下的框里再选最高的,重复步骤 2。

这个过程之所以难优化,是因为第 i 轮的结果依赖于第 i-1 轮的删除操作。这直接打破了 SIMD 的并行性。 但在 Ascend C 中,我们可以利用 Vector 强大的批量计算能力 来加速最耗时的 IoU 计算 环节——虽然挑选框是串行的,但“拿一个框跟一万个框比”是可以并行的!

一、 核心图解:像剥洋葱一样过滤

NMS 的过程就像是剥洋葱,一层一层去掉外面的皮(重叠框),只留下核心。

二、 算法优化:并行 IoU + 串行 Mask

既然外层循环逻辑无法避免,我们就优化循环体内部。 在每一次迭代中,我们需要计算 1 个主框 vs N 个剩余框 的 IoU。这是一个典型的向量运算!

Ascend C 策略

  1. 预处理:先完成 Sort(利用第 41 期学的 Bitonic Sort 或 AI CPU,或者假设输入已排序)。

  2. Kernel 侧

    • 将排好序的框搬入 UB。

    • 循环:取出一个有效框,广播(Broadcast),一次性计算它与剩余所有框的 IoU。

    • 更新:生成 Suppression Mask,更新状态位。

三、 实战:Ascend C 实现 NMS

3.1 Kernel 类定义

输入是 boxes $[N, 4]$ 和 scores $[N]$(假设已排序)。输出是 keep_indices

class KernelNMS {
public:
    __aicore__ inline void Init(GM_ADDR boxes, GM_ADDR output, uint32_t num_boxes, float iou_threshold) {
        // ... Init ...
        this->num_boxes = num_boxes;
        this->thresh = iou_threshold;
        // 申请 UB 空间存放 Boxes (Proposal)
        // 假设 N 较小 (如 2048),可以一次性放入 UB
        // 如果 N 很大,需要分块 NMS (这里演示一次性加载逻辑)
    }
    
    __aicore__ inline void Process() {
        // 1. CopyIn Boxes
        // boxesLoc: [num_boxes, 4]
        DataCopy(boxesLoc, boxesGm, num_boxes * 4);
        
        // 2. 初始化状态 (Keep Mask)
        // 全部置为 1 (有效)
        // keepMaskLoc: [num_boxes] (使用 half 或 uint8 存储 0/1)
        Duplicate(keepMaskLoc, (half)1.0, num_boxes);
        
        // 3. 执行 NMS
        ComputeNMS();
        
        // 4. CopyOut Indices
        // 根据 keepMask 导出索引 (这一步通常涉及 Select 或 Compact)
    }
};

3.2 Compute 核心逻辑 (IoU 计算)

IoU 计算公式: $$ \text{IoU} = \frac{\text{Inter}}{\text{Area}_A + \text{Area}_B - \text{Inter}} $$ 其中 $\text{Inter} = \max(0, \min(x2_a, x2_b) - \max(x1_a, x1_b)) \times \dots$

我们需要用到大量的 Min, Max, Sub, Mul 指令。

__aicore__ inline void ComputeNMS() {
    // 预计算所有框的 Area
    // Area = (x2 - x1) * (y2 - y1)
    // 这一步是 Vector 并行的,一次算完所有
    Sub(tmpW, boxesX2, boxesX1, num_boxes);
    Sub(tmpH, boxesY2, boxesY1, num_boxes);
    Mul(areasLoc, tmpW, tmpH, num_boxes);

    // 主循环:串行遍历每一个框
    for (int i = 0; i < num_boxes; i++) {
        // 1. 检查当前框 i 是否已被抑制 (Scalar Check)
        // 这是一个标量操作,GetValue 会打断流水线,但在 NMS 中无法避免
        if (keepMaskLoc.GetValue(i) == 0) continue;
        
        // 2. 准备广播当前框 i (Current Box)
        // currentBox: [x1, y1, x2, y2]
        // Ascend C 提供了 Brcb (Broadcast from Block) 或 Duplicate 指令
        // 将第 i 个框的坐标广播成向量,与所有框对齐
        half curX1 = boxesX1.GetValue(i);
        half curY1 = boxesY1.GetValue(i);
        half curX2 = boxesX2.GetValue(i);
        half curY2 = boxesY2.GetValue(i);
        
        Duplicate(vecCurX1, curX1, num_boxes);
        Duplicate(vecCurY1, curY1, num_boxes);
        Duplicate(vecCurX2, curX2, num_boxes);
        Duplicate(vecCurY2, curY2, num_boxes);
        
        // 3. 并行计算 IoU (Vector)
        // 计算 Intersection 坐标
        // inter_x1 = max(cur_x1, all_x1)
        Max(interX1, vecCurX1, boxesX1, num_boxes);
        Max(interY1, vecCurY1, boxesY1, num_boxes);
        Min(interX2, vecCurX2, boxesX2, num_boxes);
        Min(interY2, vecCurY2, boxesY2, num_boxes);
        
        // 计算 Inter Area
        Sub(interW, interX2, interX1, num_boxes);
        Sub(interH, interY2, interY1, num_boxes);
        // relu: 宽高必须 > 0
        Max(interW, interW, (half)0.0, num_boxes); 
        Max(interH, interH, (half)0.0, num_boxes);
        Mul(interArea, interW, interH, num_boxes);
        
        // IoU = inter_area / (area_i + area_all - inter_area)
        Duplicate(vecCurArea, areasLoc.GetValue(i), num_boxes);
        Add(unionArea, vecCurArea, areasLoc, num_boxes);
        Sub(unionArea, unionArea, interArea, num_boxes);
        
        // 避免除零,加个 epsilon
        Adds(unionArea, unionArea, (half)1e-6, num_boxes);
        Div(iouVec, interArea, unionArea, num_boxes);
        
        // 4. 生成抑制掩码 (Suppress Mask)
        // mask = (IoU > thresh)
        // Compare 生成的是 cmpMask (bit mask)
        Compare(cmpMask, iouVec, threshVec, CMP_GT, num_boxes);
        
        // 5. 更新全局 Keep Mask
        // 如果 IoU > thresh,则对应的框应当被抑制 (置0)
        // 注意:只更新 i 之后的框,且不能把当前框 i 给抑制了
        // 这里可以使用 Select 指令配合 cmpMask 更新 keepMaskLoc
        // keepMaskLoc = select(cmpMask, 0, keepMaskLoc)
        Select(keepMaskLoc, cmpMask, (half)0.0, keepMaskLoc, SEL_MODE_NE, num_boxes);
    }
}

四、 进阶:性能优化的深水区

NMS 的性能瓶颈在于标量(循环控制)与向量(IoU 计算)的频繁交互。

4.1 减少 Scalar 交互

在上面的代码中,keepMask.GetValue(i)boxesX1.GetValue(i) 是标量读取操作,非常慢。 优化策略

  • 延迟判断:不每次都 check keepMask,而是每隔 K 个(比如 32)才回读一次状态,虽然多算了一些无效 IoU,但流水线更顺畅。

  • Block NMS:一次处理一个 Block 的主框,计算 Block vs Block 的 IoU 矩阵(利用 Cube 单元),但这比较复杂。

4.2 坐标精度 (FP16 vs FP32)

检测框坐标对精度敏感。 如果用 FP16 计算 x2 - x1,当物体很小或坐标很大时,可能会因为精度不够导致 IoU 计算偏差。 建议:在计算 IoU 的中间步骤(特别是 Area 和 Sub 操作),临时转为 FP32

4.3 硬件加速指令

部分昇腾芯片(如 Ascend 310 系列)内置了专门的 Region Proposal 硬件单元。 如果你的场景是标准的检测后处理,可以考虑直接调用 acl.op.NMSWithMask 等融合大算子接口,而不是自己手写 Kernel。但如果你需要魔改 NMS(如 Soft-NMS),手写 Kernel 是唯一的路。

五、 总结

NMS 是算法逻辑与硬件特性的博弈。

  1. 矛盾:串行贪心逻辑 vs 并行硬件架构。

  2. 解法“串行遍历主框,并行计算 IoU”

  3. 关键:利用 Vector 单元一次算完 128 个框的重叠度,极大地摊薄了标量循环的开销。

攻克了 NMS,你就能处理几乎所有 CV 领域的后处理逻辑,让 AI Core 真正接管端到端流程。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐