HarmonyOS AscendC 算子:用 NPU 实现图像边缘检测

什么是 AscendC 算子

前面我们介绍了很多图形渲染相关的技术,这篇来看看 AI 领域的东西。AscendC 是华为提供的一种 NPU(神经网络处理器)编程框架,让你可以自己写算子在 NPU 上运行。

算子是什么?简单说就是一种计算操作。比如加法是一个算子,乘法也是一个算子。在 AI 和图像处理领域,有很多复杂的算子,比如卷积、池化等。AscendC 让你可以自己实现这些算子,然后在 NPU 上高效运行。

这篇我们用一个实际的例子来说明:图像边缘检测。边缘检测是图像处理中最基础的操作之一,用 Sobel 算子来实现。边缘检测的结果可以用于物体识别、图像分割等场景。

为什么要用 NPU

你可能会问:图像处理用 CPU 不就行了,为什么要用 NPU?

答案是性能。NPU 是专门为 AI 和图像处理设计的硬件,它有大量的计算单元,可以并行处理很多数据。对于 Sobel 这种需要对每个像素做相同操作的任务,NPU 的优势非常明显。

举个例子:CPU 就像一个很聪明的人,什么都能做,但一次只能做一件事;NPU 就像一群不太聪明但很勤奋的人,每个人只做很简单的事,但大家一起做,速度就快多了。

环境搭建

硬件要求

  • 设备类型:请参考 CANN Kit 开发指南的约束与限制
  • HarmonyOS 系统:HarmonyOS 5.0.5 Release 及以上

软件要求

  • DevEco Studio 版本:DevEco Studio 6.1.0 Release 及以上
  • HarmonyOS SDK 版本:HarmonyOS 6.1.0 Release SDK 及以上

开发环境要求

AscendC 的开发环境比较特殊,需要在 Linux 上搭建:

  • Ubuntu 版本:22.04 及以上(仅支持 x86)
  • Python 版本:3.7 到 3.10 之间
  • GCC/G++ 版本:7.0 及以上
  • CMake 版本:3.22.1 及以上

搭建步骤

  1. 安装 DevEco Studio:去华为开发者官网下载安装
  2. 下载 DDK Tools:下载 DDK_tools_5.1.1.0,并在 Linux 环境上解压
  3. 下载平台插件:下载需要的平台插件包,解压后拷贝到 ddk_external/tools/platform 目录下
  4. 安装工具:进入 ddk_external/tools/tools_ascendc 目录,执行安装脚本
  5. 设置环境变量:执行 set_ascendc_env.sh 设置环境变量
  6. 安装 Python 依赖:安装 toml、jinja2、numpy、torch 等依赖库

项目结构

├── build.sh                     // 编译入口脚本
├── build_devices.sh             // 编译devices侧交付件脚本
├── cmake 
│  ├── config.cmake
│  └── util                      // 算子工程编译所需脚本及公共编译文件存放目录
├── CMakeLists.txt               // 算子工程的CMakeLists.txt
├── CMakePresets.json            // 编译配置项
├── framework                    // 算子插件实现文件目录
├── op_host                      // host侧实现文件
│  ├── sobel_custom_tiling.h     // 算子Tiling定义文件
│  ├── sobel_custom.cpp          // 算子原型注册、shape推导、信息库、tiling实现等内容文件
│  └── CMakeLists.txt
├── op_kernel                    // Kernel侧实现文件
│  ├── CMakeLists.txt   
│  ├── sobel_custom_base.h       // 算子代码定义文件
│  └── sobel_custom.cpp          // 算子代码实现文件 
└── scripts                      // 自定义算子工程打包相关脚本所在目录

这个项目结构和前面的图形渲染项目很不一样。主要分为两部分:

  • op_host:Host 侧代码,负责算子的注册、shape 推导、Tiling 策略等
  • op_kernel:Kernel 侧代码,负责在 NPU 上实际执行的计算逻辑

边缘检测算法原理

在看代码之前,先了解一下 Sobel 边缘检测的原理。

处理流程

整个处理流程分四步:

  1. RGB 转灰度:把彩色图像转成灰度图像
  2. Sobel X 方向滤波:检测水平方向的边缘
  3. Sobel Y 方向滤波:检测垂直方向的边缘
  4. XY 方向融合:把两个方向的边缘信息合并

Sobel 滤波核

Sobel 算子使用 3x3 的滤波核。X 方向的滤波核是:

-1  0  1
-2  0  2
-1  0  1

Y 方向的滤波核是:

-1 -2 -1
 0  0  0
 1  2  1

简单说,X 方向的滤波核会检测左右像素的差异,Y 方向的滤波核会检测上下像素的差异。

XY 方向融合

融合的方式很简单:用曼哈顿距离 G = |Gx| + |Gy| 来替代欧几里得距离。这样计算更快,效果也差不多。

第一步:实现 RGB 转灰度

RGB 转灰度的公式是:Gray = 0.299 * R + 0.587 * G + 0.114 * B。

在 AscendC 中,这个操作可以用向量指令高效完成。

第二步:实现 Sobel 滤波

这是核心部分。Sobel 滤波需要对每个像素,用它周围的 3x3 邻域和滤波核做卷积。

使用 Gather 操作

由于数据不是 32 字节对齐的,需要用 Gather 操作来获取特定位置的数据。

// 生成index: [2, 3, 4, ..., w - 1]
AscendC::CreateVecIndex(index, int16_t(2), w - 2);
AscendC::Muls(index, index, int16_t(2), w - 2);
// index:int16->uint32
AscendC::Cast(newindex, index, AscendC::RoundMode::CAST_NONE, w - 2);

这段代码生成一个索引数组,用来获取每个像素右侧第 2 个位置的数据。CreateVecIndex 创建一个连续的索引序列,Muls 把索引乘以 2(因为每个像素有两个字节),Cast 把索引转成 uint32 类型。

for (int i = 0; i < h - 2; i++) {
    // 将data[i][2,3,4,...,w-1]存储到tmpBuf0[0, 1, 2, ..., w-2]
    AscendC::Gather(tmpBuf0, data[i * w], newindex, 0, w - 2);
    AscendC::Gather(tmpBuf1, data[(i + 1) * w], newindex, 0, w - 2);
    AscendC::Gather(tmpBuf2, data[(i + 2) * w], newindex, 0, w - 2);

对每一行,用 Gather 获取右侧第 2 个位置的像素值。这样就得到了 Sobel 滤波核中 “j+2” 位置的数据。

    // part1
    AscendC::Muls(dx[i * w], data[i * w], half(-1), w - 2);
    AscendC::Muls(tmpBuf3, data[(i + 1) * w], half(-2), w - 2);
    AscendC::Muls(tmpBuf4, data[(i + 2) * w], half(-1), w - 2);
    AscendC::Add(dx[i * w], dx[i * w], tmpBuf3, w - 2);
    AscendC::Add(dx[i * w], dx[i * w], tmpBuf4, w - 2);

计算 Sobel X 方向的第一部分:左侧列的加权和。对应滤波核的 [-1, -2, -1] 这一列。

    // part2
    AscendC::Muls(tmpBuf1, tmpBuf1, half(2), w - 2);
    AscendC::Add(dx[i * w], dx[i * w], tmpBuf0, w - 2);
    AscendC::Add(dx[i * w], dx[i * w], tmpBuf1, w - 2);
    AscendC::Add(dx[i * w], dx[i * w], tmpBuf2, w - 2);
}

计算 Sobel X 方向的第二部分:右侧列的加权和。对应滤波核的 [1, 2, 1] 这一列。两部分加起来就是完整的 Sobel X 方向滤波结果。

第三步:实现 XY 方向融合

// G = |Gx| + |Gy|
AscendC::Abs(dx, dx, totalSize);
AscendC::Abs(dy, dy, totalSize);
AscendC::Add(result, dx, dy, totalSize);

先对 Gx 和 Gy 取绝对值,然后相加。这就是曼哈顿距离融合。

第四步:归一化

// 归一化为[0,255]
AscendC::Cast(output, result, AscendC::RoundMode::CAST_NONE, totalSize);

把 half 类型的结果转成 uint8 类型,输出范围 [0, 255]。

Vector 编程范式

AscendC 使用 Vector 编程范式,把算子的实现分为三个基本任务:

  1. CopyIn:把输入数据从 Global Memory 搬运到 Local Memory
  2. Compute:在 Local Memory 上进行计算
  3. CopyOut:把计算结果从 Local Memory 搬运回 Global Memory
__aicore__ inline void Process()
{
    cntH = SobelCustom::CeilDiv(this->H, h);
    cntW = SobelCustom::CeilDiv(this->W, w);
    for (int32_t i = 0; i < cntH; i++) {
        for (int32_t j = 0; j < cntW; j++) {
            CopyIn(i, j);
            Compute(i, j);
            CopyOut(i, j);
        }
    }
}

整个处理过程是:把图像分成很多小块(tile),对每个小块依次执行 CopyIn、Compute、CopyOut。

内存管理

AscendC 使用 TQue 和 TBuf 来管理内存:

  • TQue:队列,用于 CopyIn 和 CopyOut 的数据传输
  • TBuf:缓冲区,用于计算过程中的临时数据
constexpr int32_t BUFFER_NUM = 2;
const uint32_t h = 9;
const uint32_t w = 256;

pipe.InitBuffer(inQueueX, BUFFER_NUM, tileLength * sizeof(T));
pipe.InitBuffer(outQueueY, BUFFER_NUM, tileLength * sizeof(T));

这里使用 double buffer(BUFFER_NUM = 2),可以在计算当前块的同时搬运下一块的数据,提高效率。

Tiling 策略

Tiling 是把大任务分成小块的策略。每个 tile 的大小是 h x w,其中:

  • w 必须是 32 的倍数(对齐约束)
  • h * w * 34 + w * 12 <= 120KB(UB 大小限制)

选 h=9, w=256 是一个合理的值。

数据搬运

__aicore__ inline void CopyIn(int32_t i, int32_t j)
{
    LocalTensor<T> xLocal = inQueueX.AllocTensor<T>();
    DataCopyExtParams dataCopyParams;
    // ... 设置参数
    DataCopyPadExtParams<T> padParams(false, 0, 0, 0);
    DataCopyPad(xLocal, xGm[offset], dataCopyParams, padParams);
    inQueueX.EnQue(xLocal);
}

DataCopyPad 是一个支持非对齐搬运的接口。因为图像的宽度不一定是 32 的倍数,所以需要用这个接口来处理边界情况。

dataCopyParams 设置了搬运的参数:

  • blockCount:搬运多少行
  • blockLen:每行搬运多少字节
  • srcStride:源地址的行间距
  • dstStride:目标地址的行间距

编译和运行

创建算子工程

msopgen gen -i ./SobelCustom.json -c ai_core-kirin9020 -f ONNX -out ./SobelCustom

msopgen 工具根据配置文件自动创建算子工程。

编译算子

cd SobelCustom/SobelCustom
./build.sh

编译成功后会显示 “Install the project…Build and install success”。

调试算子

# NPU 仿真调试
ascendebug kernel --backend simulator --repo-type customize --json-file ../ascendebug/temp.json --core-type AiCore --chip-version kirin9020 --work-dir ../ascendebug/myWorkDir/ --block-num 1 --rel-err-thd 0.5

# NPU 实际调试
ascendebug kernel --backend npu --repo-type customize --json-file ../ascendebug/temp.json --core-type AiCore --chip-version kirin9020 --work-dir ../ascendebug/myWorkDir/ --block-num 1 --rel-err-thd 0.5

可以先在仿真器上调试,再在实际 NPU 上调试。调试成功后会生成 .omc 文件,这就是编译好的算子。

集成到应用

把生成的 .omc 文件放到应用的 resources/rawfile 目录下,就可以在应用中调用了。

AscendC 的优势

  1. 性能:在 NPU 上运行,比 CPU 快很多
  2. 可复用:端侧和云侧的算子代码可以复用
  3. 可调试:提供了仿真器和实际 NPU 两种调试方式
  4. 标准化:使用统一的编程范式,学习成本低

适用场景

AscendC 算子开发适合以下场景:

  • 图像处理:边缘检测、滤波、变换等
  • AI 推理:自定义的神经网络层
  • 信号处理:FFT、滤波器等
  • 科学计算:矩阵运算、数值计算等

注意事项

  1. 环境搭建:AscendC 的开发环境比较复杂,需要在 Linux 上搭建
  2. 对齐约束:数据宽度必须是 32 的倍数,否则需要特殊处理
  3. 内存管理:UB 大小有限(120KB),要合理规划 Tiling 策略
  4. 调试工具:建议先用仿真器调试,再在实际 NPU 上调试
  5. 平台插件:不同芯片需要不同的平台插件,要确保下载正确

核心流程图

Sobel 边缘检测算法的处理流程:

渲染错误: Mermaid 渲染失败: Parse error on line 7: ... --> G[XY 方向融合: G = |Gx| + |Gy|] F - -----------------------^ Expecting 'SQE', 'TAGEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PIPE'

AscendC 算子开发的完整流程:

搭建 Linux 开发环境

下载 DDK Tools 和平台插件

使用 msopgen 创建算子工程

实现 Host 侧: 算子注册与 Tiling 策略

实现 Kernel 侧: CopyIn / Compute / CopyOut

配置 Tiling 参数

编译算子工程

仿真器调试

调试是否通过?

修复问题

实际 NPU 调试

生成 .omc 文件集成到应用

总结

AscendC 是一个强大的 NPU 编程框架,让你可以自己实现算子在 NPU 上运行。核心流程:

  1. 搭建开发环境(Linux + DDK Tools)
  2. 设计算法(RGB 转灰度、Sobel 滤波、XY 融合)
  3. 实现算子(CopyIn、Compute、CopyOut)
  4. 配置 Tiling 策略
  5. 编译、调试、集成

如果你的应用需要高性能的图像处理或 AI 推理,AscendC 是一个值得学习的技术。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐