昇腾CANN ops-cv NMS:非极大值抑制的 NPU 并行化实战
本文介绍了目标检测中NMS(非极大值抑制)算法的优化方法。传统串行NMS算法在CPU上时间复杂度为O(N²K),当处理1000个边界框时耗时约5ms,成为NPU推理管道的瓶颈。作者提出了一种并行NMS算法,通过矩阵运算一次性计算所有框对的IoU,将复杂度降至O(N²)。算法分为三个阶段:1)使用Bitonic Sort并行排序边界框;2)并行计算所有框对的IoU矩阵;3)二次筛选消除间接抑制。其中
目标检测模型的输出是几百个边界框 + 置信度分数。NMS(Non-Maximum Suppression)筛选这些框:保留最高分的框,删除和它重叠超过阈值的框,重复直到所有框都处理完。这个流程在 CPU 上是 O(N²K)(N 个框循环选 K 次)——当 N=1000 时,CPU 上 ~5ms。在 NPU 的推理管道里,5ms 的后处理是瓶颈——前 50 层的计算只要 2ms。
ops-cv 的 NMS 算子在 NPU 上做并行筛选——把 O(N²K) 压到 O(N²)。
NMS 的串行算法
标准 NMS(串行)
输入:boxes [N, 4], scores [N]
输出:keep_indices [M](M ≤ N)
1. 按 scores 降序排列 boxes
2. 取出最高分框 b_max → 加入 keep_indices
3. 计算 b_max 和所有未处理框的 IoU
4. 删除 IoU > threshold 的框(被抑制)
5. 如果还有未处理的框 → 回到步骤 2
6. 返回 keep_indices
步骤 2-4 循环 K 次(K 是最终保留的框数)。每次循环扫描所有未处理框计算 IoU——O(N²K)。
NPU 的并行 NMS
核心思路:把串行 NMS 的「选择 → 抑制」循环改成矩阵运算——所有框互相计算 IoU,一次性做完所有抑制。
// ops-cv/kernels/nms_parallel.cpp
__aicore__ void NMSKernel(
GlobalTensor<float16>& boxes, // [N, 4] (x1,y1,x2,y2)
GlobalTensor<float16>& scores, // [N]
GlobalTensor<uint8>& suppressed, // [N] 输出:0=保留, 1=被抑制
int N, float iou_threshold
) {
// ===== Step 1:按 scores 排序(并行 Bitonic Sort)=====
// 降序排列 → boxes[0] 是最高分框
SortByScores(boxes, scores, N);
// ===== Step 2:计算所有框对之间的 IoU(并行)=====
// IoU 矩阵 [N, N]——每个元素是 boxes[i] 和 boxes[j] 的交并比
// 256 个 lane 各算一个 (i,j) 对
// 分配 L1 中的 IoU 矩阵(N×N 上三角部分,下三角是对称的)
for (int i = 0; i < N; i++) {
for (int j = i + 1; j < N; j++) {
// 获取 lane_id(对应 (i,j) 对)
int lane = (i * (N - 1) + j - 1) % 256;
if (__lane_id__ == lane % 256) {
// 获取两个框
float16 x1_i = boxes[i * 4 + 0];
float16 y1_i = boxes[i * 4 + 1];
float16 x2_i = boxes[i * 4 + 2];
float16 y2_i = boxes[i * 4 + 3];
float16 x1_j = boxes[j * 4 + 0];
float16 y1_j = boxes[j * 4 + 1];
float16 x2_j = boxes[j * 4 + 2];
float16 y2_j = boxes[j * 4 + 3];
// 交集面积
float ix1 = max(float(x1_i), float(x1_j));
float iy1 = max(float(y1_i), float(y1_j));
float ix2 = min(float(x2_i), float(x2_j));
float iy2 = min(float(y2_i), float(y2_j));
float inter_area = max(0.0f, ix2 - ix1) * max(0.0f, iy2 - iy1);
// 并集面积
float area_i = float(x2_i - x1_i) * float(y2_i - y1_i);
float area_j = float(x2_j - x1_j) * float(y2_j - y1_j);
float union_area = area_i + area_j - inter_area;
// IoU
float iou = inter_area / union_area;
// ===== Step 3:判断是否抑制 =====
// 规则:如果 boxes[i].score > boxes[j].score(因为降序)
// 且 IoU > threshold → 抑制 boxes[j]
if (iou > iou_threshold) {
suppressed[j] = 1; // j 被 i 抑制
}
}
}
}
// ===== Step 4:二次筛选(消除间接抑制)=====
// 场景:box[0] 抑制 box[2],box[1] 抑制 box[3]
// 但 box[2] 和 box[3] 之间也高 IoU → box[3] 被 box[2] 第二次抑制
// 实际上 box[0] 已经抑制了 box[2] → box[2] 是无效的
// box[3] 不应该被无效的 box 抑制
// 修正:从高分到低分扫描,跳过已被抑制的框
for (int i = 0; i < N; i++) {
if (suppressed[i]) continue; // 跳过已抑制的框
for (int j = i + 1; j < N; j++) {
if (suppressed[j]) continue;
// 重新算 IoU 并判断
float iou = ComputeIoU(boxes[i], boxes[j]);
if (iou > iou_threshold) {
suppressed[j] = 1;
}
}
}
}
并行 NMS 的三个阶段:
- 排序:Bitonic Sort 并行排序(256 个 lane 并行比较-交换)
- IoU 矩阵:所有 (i,j) 对并行计算 IoU(Cube 单元处理乘法)
- 二次筛选:消除间接抑制(串行扫描,但 O(N²) 过滤掉大量无效计算)
Bitonic Sort 并行排序
// ops-cv/kernels/bitonic_sort.cpp
// Bitonic Sort:O(log²N) 并行排序
__aicore__ void BitonicSortByScore(
LocalTensor<float16>& scores, // [N] 排序 key
LocalTensor<float16>& boxes, // [N, 4] 跟随 key 交换
int N
) {
// 需要填充到 2 的幂
int padded = 1;
while (padded < N) padded *= 2;
// Bitonic Sort 的主循环
for (int k = 2; k <= padded; k *= 2) { // 阶段大小翻倍
for (int j = k / 2; j > 0; j /= 2) { // 比较距离折半
// 并行比较-交换:所有 lane 同时比较
for (int i = 0; i < N; i += k) {
int idx = i + (i / j % 2 == 0 ? j : -j) + j % j;
// 每个 lane 负责一对元素的比较-交换
int lane_id = (i / k) * (k / 2) + (j > 1 ? idx % (j/2) : 0);
if (lane_num == __lane_id__) {
int a_idx = i + idx;
int b_idx = i + idx + j;
// 降序比较:a_idx 对应前面的元素(应该更大)
if (a_idx < N && b_idx < N) {
if (float(scores[a_idx]) < float(scores[b_idx])) {
// 交换 scores
Swap(scores[a_idx], scores[b_idx]);
// 交换 boxes
Swap(boxes[a_idx * 4 + 0], boxes[b_idx * 4 + 0]);
Swap(boxes[a_idx * 4 + 1], boxes[b_idx * 4 + 1]);
Swap(boxes[a_idx * 4 + 2], boxes[b_idx * 4 + 2]);
Swap(boxes[a_idx * 4 + 3], boxes[b_idx * 4 + 3]);
}
}
}
}
__sync_lanes(); // 同步所有 lane
}
}
}
Bitonic Sort 的并行特性:在 256 个 AI Core 上对 1024 个框排序——O(log²1024) = 100 次并行比较-交换——纯并行执行,每次比较-交换 1 cycle。
IoU 计算的精度优化
IoU = intersection / union,涉及浮点除法。FP16 除法有 0.2% 的误差,可能导致 IoU 阈值判断错误(IoU=0.501 被当成 0.499 → 导致漏抑制)。
修复:IoU 计算用乘法代替除法,避免浮点除法的精度损失
// 避免除法:浮点除法的精度损失
float iou_div = inter_area / union_area;
bool suppress = (iou_div > iou_threshold); // FP32 误差可能 1e-7
// 用乘法:更稳定
// iou > threshold <==> inter_area / union_area > threshold
// <==> inter_area > threshold * union_area
bool suppress = (inter_area > iou_threshold * union_area);
// FP32 乘法误差远小于除法误差
踩坑一:NPU 上的坐标格式统一
目标检测的边界框有三种常见格式:
格式 1:xywh(中心点 + 宽高)
格式 2:xyxy(左上角 + 右下角)
格式 3:cxcywh(归一化中心点 + 宽高)
大多数模型输出 xywh 或 cxcywh,IoU 计算需要 xyxy
ops-cv NMS 的输入要求 xyxy 格式。用户经常忘记转换:
# 错误:直接送 xywh 进去
boxes = model_output # shape [N, 4],但格式是 xywh
nms_output = ops.nms(boxes, scores, threshold=0.5) # 算出的 IoU 是错的
# 正确:先转换
boxes_xyxy = ops.xywh2xyxy(boxes) # [cx,cy,w,h] → [x1,y1,x2,y2]
nms_output = ops.nms(boxes_xyxy, scores, threshold=0.5)
踩坑二:Soft-NMS 的衰减函数选择
标准 NMS 是硬删除(IoU>threshold → 直接删除)。Soft-NMS 用衰减函数降低重叠框的分数——适合密集目标场景(行人检测、细胞检测)。
Soft-NMS 三种衰减函数
1. 线性衰减:score = score * (1 - IoU)
当 IoU=0.9 → score *= 0.1 → 分数大幅降低
2. 高斯衰减:score = score * exp(-IoU² / σ)
σ=0.5 → 当 IoU=0.9 → score *= exp(-0.81/0.25) = 0.04
3. 自适应衰减(ops-cv 支持)
如果框尺寸比 > 2(一大一小),用线性衰减
如果框尺寸比 < 2(大小接近),用高斯衰减
// ops-cv/kernels/soft_nms.cpp
float SoftNMSDecay(float iou, float box_size_ratio) {
if (box_size_ratio > 2.0f) {
// 一大一小 → 线性衰减
// 小框被大框遮挡 → 分数降低,但不完全删除
return 1.0f - iou;
} else {
// 大小接近 → 高斯衰减
// 两个接近的框检测到同一个目标 → 分数快速降低
float sigma = 0.5f;
return expf(-iou * iou / sigma);
}
}
踩坑三:单类 vs 多类别 NMS 的参数差异
目标检测模型有多个类别(如 COCO 的 80 类)。标准做法是「同类同阈值」——同一类别内的框用相同的 IoU 阈值做 NMS。
多类别 NMS 的并行化问题:N 个框可能属于 C 个类别——需要 C 次并行 NMS(每类一次)。但 NPU 的 block 数量有限——120 个类别就需要 120 个 block。
ops-cv 的优化:按类别分组 + 分组内并行
// 多类别 NMS:类别分组 + 组内并行
__aicore__ void MultiClassNMS(
GlobalTensor<float16>& boxes, // [N, 4]
GlobalTensor<float16>& scores, // [N]
GlobalTensor<int>& class_ids, // [N]
GlobalTensor<uint8>& suppressed, // [N]
int N, int num_classes, float iou_threshold
) {
// Step 1:按类别分组(并行计数排序)
LocalTensor<int> class_counts(num_classes);
class_counts = 0;
for (int i = 0; i < N; i++) {
AtomicAdd(class_counts[class_ids[i]], 1); // 统计每类框数
}
// Step 2:分组内 NMS
int offset = 0;
for (int c = 0; c < num_classes; c++) {
int class_N = class_counts[c];
if (class_N == 0) continue;
// 对该类的 class_N 个框做 NMS
// 类内的 box 索引: offset ~ offset+class_N-1
ClassNMS(boxes + offset, scores + offset, suppressed + offset,
class_N, iou_threshold);
offset += class_N;
}
}
分类计数的并行化——用 AtomicAdd 同时更新 class_counts——Cube 单元的原子操作保证数据正确性。
NMS 看似简单——筛选重叠框。但 NPU 上的并行实现涉及 Bitonic Sort(并行排序)、IoU 矩阵并行计算、二次筛选(消除间接抑制)、多类别分组。ops-cv 的 NMS 算子把这些技术融合进一个 kernel——从 CPU 上 5ms 的串行后处理,变成 NPU 上几十微秒的矩阵运算。NMS 不再是目标检测的推理瓶颈。
鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。
更多推荐



所有评论(0)