在CANN(Compute Architecture for Neural Networks)的开发实践中,图像与视频预处理是一个关键环节。无论是图像分类、目标检测还是其他视觉任务,都离不开对输入图片或视频流进行解码、缩放、裁剪等预处理操作。为了在昇腾(Ascend)芯片上高效完成这些任务,CANN提供了一套硬化的处理单元------DVPP(Digital Vision Pre-Processing)。
然而,与DVPP交互的API并非一成不变。它经历了从V1版本的acldvpp接口到V2版本himpi接口(通常通过AclLite库进行封装和调用)的演进。这两个版本的接口在设计哲学、使用方式和开发体验上存在差异较大。对于开发者来说,理解它们的区别,并知道在何种场景下如何选择与迁移,就成了一项非常重要的技能。
这篇文章,我们就来深入聊聊DVPP V1和V2接口的那些事儿,通过具体的代码实例,让你深入理解它们各自的特点,并为你提供一份实用的迁移指南。
1. V1接口 (acldvpp):细粒度的手动管理模式
V1接口,我们通常指的是以acldvpp开头的一系列C风格API。这套接口的设计思路是"过程式"的,它把DVPP的每一个操作步骤都分解成独立的函数调用,给了开发者较高的控制能力。但与此同时,这也意味着开发者需要手动处理大量的细节,就像开手动挡汽车,换挡、离合、油门都得自己来。
我们通过一个具体的例子来看看V1接口是如何工作的。下面的代码片段来自项目cplusplus/level2_simple_inference/0_data_process/smallResolution_cropandpaste/src/dvpp_process.cpp,它展示了使用V1接口实现一个"裁剪并拼接"(Crop and Paste)操作的完整流程。
1.1 初始化:创建DVPP通道
首先,任何DVPP操作都需要在一个"通道"(Channel)中进行。你需要先创建一个通道描述符,然后用它来创建通道。
cpp
// 位于 cplusplus/level2_simple_inference/0_data_process/smallResolution_cropandpaste/src/dvpp_process.cpp
Result DvppProcess::InitResource()
{
// 1. 创建通道描述符
dvppChannelDesc_ = acldvppCreateChannelDesc();
if (dvppChannelDesc_ == nullptr) {
ERROR_LOG("acldvppCreateChannelDesc failed");
return FAILED;
}
// 2. 基于描述符创建通道
aclError aclRet = acldvppCreateChannel(dvppChannelDesc_);
if (aclRet != ACL_SUCCESS) {
ERROR_LOG("acldvppCreateChannel failed, errorCode = %d", static_cast<int32_t>(aclRet));
return FAILED;
}
INFO_LOG("dvpp init resource success");
return SUCCESS;
}
acldvppChannelDesc: 这是一个描述DVPP通道属性的结构体。你可以把它看作是一个配置单,告诉系统你要创建一个什么样的通道。acldvppCreateChannel: 这个函数负责根据你的"配置单"在昇腾芯片上实际开辟出一个DVPP处理通道。
1.2 繁琐但必要:配置输入输出描述
接下来,你需要详细地描述你的输入数据和期望的输出数据长什么样。这包括图片格式、宽高、内存地址、内存对齐(Stride)等一系列信息。每一种信息都需要通过一个acldvppSetPicDesc*系列的函数来设置。
cpp
// 位于 cplusplus/level2_simple_inference/0_data_process/smallResolution_cropandpaste/src/dvpp_process.cpp
Result DvppProcess::InitCropAndPasteOutputDesc()
{
// 计算输出内存需要的对齐宽度和高度
uint32_t vpcOutWidthStride = AlignSize(outWidth_, 16); // 16字节对齐
uint32_t vpcOutHeightStride = AlignSize(outHeight_, 2); // 2字节对齐
// 计算输出Buffer大小
vpcOutBufferSize_ = vpcOutWidthStride * vpcOutHeightStride * 3 / 2; // YUV420SP格式
// 关键:为DVPP操作申请专门的内存
aclError aclRet = acldvppMalloc(&vpcOutBufferDev_, vpcOutBufferSize_);
if (aclRet != ACL_SUCCESS) {
ERROR_LOG("malloc output image buffer failed, errorCode = %d", static_cast<int32_t>(aclRet));
return FAILED;
}
// 创建图片描述符
vpcOutputDesc_ = acldvppCreatePicDesc();
if (vpcOutputDesc_ == nullptr) {
ERROR_LOG("acldvppCreatePicDesc vpcOutputDesc_ failed");
return FAILED;
}
// 设置一大堆描述信息...
(void)acldvppSetPicDescData(vpcOutputDesc_, vpcOutBufferDev_); // 内存地址
(void)acldvppSetPicDescFormat(vpcOutputDesc_, outFormat_); // 格式
(void)acldvppSetPicDescWidth(vpcOutputDesc_, outWidth_); // 宽度
(void)acldvppSetPicDescHeight(vpcOutputDesc_, outHeight_); // 高度
(void)acldvppSetPicDescWidthStride(vpcOutputDesc_, vpcOutWidthStride); // 对齐后的宽度
(void)acldvppSetPicDescHeightStride(vpcOutputDesc_, vpcOutHeightStride);// 对齐后的高度
(void)acldvppSetPicDescSize(vpcOutputDesc_, vpcOutBufferSize_); // 内存大小
return SUCCESS;
}
acldvppMalloc: 这是V1接口中最需要注意的一个函数。DVPP处理需要使用特殊的物理连续内存,普通的aclrtMalloc申请的Device内存是不能直接用于DVPP的。acldvppMalloc就是用来申请这种专用内存的。acldvppPicDesc: 图片描述符,一个非常核心的结构。它像一张图片的"元数据名片",记录了关于这张图片内存的所有元数据。无论是输入还是输出,都需要这样一份"元数据名片"。
1.3 提交任务与资源销毁
配置好一切后,就可以调用具体的DVPP功能函数(如acldvppVpcCropAndPasteAsync)来异步执行任务了。任务完成后,还需要手动销毁所有创建的描述符、通道,并释放DVPP内存。
cpp
// 提交异步任务
aclError aclRet = acldvppVpcCropAndPasteAsync(dvppChannelDesc_, vpcInputDesc_,
vpcOutputDesc_, cropAndPasteRoiCfg_, cropAndPasteCfg_, stream_);
// ... 在任务结束后 ...
// 销毁各种描述符和配置
acldvppDestroyPicDesc(vpcInputDesc_);
acldvppDestroyPicDesc(vpcOutputDesc_);
acldvppDestroyRoiConfig(cropAndPasteRoiCfg_);
acldvppDestroyCropAndPasteConfig(cropAndPasteCfg_);
// 销毁通道
if (dvppChannelDesc_ != nullptr) {
aclRet = acldvppDestroyChannel(dvppChannelDesc_);
// ...
acldvppDestroyChannelDesc(dvppChannelDesc_);
dvppChannelDesc_ = nullptr;
}
// 释放DVPP内存
if (vpcOutBufferDev_ != nullptr) {
(void)acldvppFree(vpcOutBufferDev_);
vpcOutBufferDev_ = nullptr;
}
V1接口小结:
- 优点:控制粒度极细,可以精确地配置每一步,适合需要深度定制和优化的底层开发。
- 缺点:API繁琐,代码量大,需要手动管理大量资源(描述符、内存、通道),对齐、内存类型等概念复杂,极易出错,开发效率较低。
2. V2接口 (AclLite封装):自动管理模式
V2接口,即himpi,在设计上更加现代化。不过,我们通常不直接使用原生的himpi,而是通过官方提供的AclLite库来间接使用它。AclLite将V2接口进行了面向对象的封装,进一步简化了开发流程,使开发者可以更专注于业务逻辑本身。这类似自动化的资源管理,底层细节由系统处理。
我们来看看在inference/modelInference/sampleResnetDVPP/cppACLLite/src/sampleResnetDVPP.cpp中,使用AclLite封装的V2接口是如何完成图片预处理的。
2.1 一切从一个对象开始
在AclLite中,所有的DVPP操作都围绕一个核心类AclLiteImageProc展开。你不再需要手动创建通道,只需要实例化这个类的一个对象即可。
cpp
// 位于 inference/modelInference/sampleResnetDVPP/cppACLLite/src/sampleResnetDVPP.cpp
class SampleResnetDVPP {
// ...
private:
AclLiteImageProc imageProcess_; // 核心处理对象
// ...
};
这个imageProcess_对象的构造和析构函数会自动处理DVPP通道的创建和销毁,遵循了C++中RAII(Resource Acquisition Is Initialization)的最佳实践,有效避免了资源泄漏。
2.2 高度封装的功能调用
有了imageProcess_对象后,解码、缩放等操作就变成了简单的成员函数调用。你不再需要关心PicDesc、acldvppMalloc这些底层细节。
cpp
// 位于 inference/modelInference/sampleResnetDVPP/cppACLLite/src/sampleResnetDVPP.cpp
Result SampleResnetDVPP::ProcessInput(const string testImgPath)
{
// 1. 读取JPG图片数据到内存
ImageData image;
AclLiteError ret = ReadJpeg(image, testImgPath);
// ...
// 2. 将图片数据从Host拷贝到Device(DVPP专用内存)
// AclLite内部封装了acldvppMalloc的逻辑
ImageData imageDevice;
ret = CopyImageToDevice(imageDevice, image, runMode_, MEMORY_DVPP);
// ...
// 3. 解码:将JPEG格式解码为YUV格式
ImageData yuvImage;
ret = imageProcess_.JpegD(yuvImage, imageDevice);
if (ret != ACL_SUCCESS) {
ACLLITE_LOG_ERROR("Convert jpeg to yuv failed, errorCode is %d", ret);
return FAILED;
}
// 4. 缩放:将YUV图片缩放到模型需要的尺寸
ret = imageProcess_.Resize(resizedImage_, yuvImage, modelWidth_, modelHeight_);
if (ret != ACL_SUCCESS) {
ACLLITE_LOG_ERROR("Resize image failed, errorCode is %d", ret);
return FAILED;
}
return SUCCESS;
}
CopyImageToDevice: 这是一个AclLite提供的便利函数。你只需要告诉它目标是MEMORY_DVPP,它就会自动调用acldvppMalloc申请内存并完成数据拷贝,屏蔽了底层的复杂性。imageProcess_.JpegD()和imageProcess_.Resize(): 看,这就是V2接口的优势在于。解码和缩放两个复杂的操作,现在都只是一行函数调用。AclLite在函数内部为你完成了创建输入输出描述符、配置参数、提交异步任务、等待任务完成、销毁临时资源等所有V1接口中需要手动完成的繁琐工作。
V2接口 (AclLite封装) 小结:
- 优点:API高度封装,面向对象,代码简洁,开发效率高,不易出错。开发者可以更专注于实现业务逻辑。
- 缺点:控制粒度较粗,对于一些极度复杂的、非标准的DVPP操作组合,可能不如V1接口灵活。
3. 核心差异对比与迁移指南
现在,我们可以清晰地总结出V1和V2接口(AclLite封装)的核心差异了。
| 特性 | V1 (acldvpp) |
V2 (AclLite 封装) |
|---|---|---|
| API风格 | 过程式 C 风格 API | 面向对象 C++ 风格 API |
| 资源管理 | 手动创建/销毁 (通道, 描述符) | 自动管理 (RAII) |
| 内存管理 | 需显式调用acldvppMalloc |
隐式处理 (e.g.,CopyImageToDevice) |
| 代码复杂度 | 高,代码冗长 | 低,代码简洁 |
| 易用性 | 低,学习曲线陡峭 | 高,直观易用 |
| 灵活性 | 非常高,可精细控制每一步 | 较高,但部分底层细节被封装 |
3.1 如何选择与迁移?
那么,在实际项目中,我们应该如何选择?迁移的成本又如何呢?
-
对于新项目 :
一般场景可选用AclLite封装的V2接口。这有助于简化开发流程、降低错误率,并提升代码可维护性。除非项目对底层DVPP特性有特殊要求,否则无需使用更复杂的V1接口。 -
对于旧项目维护 :
如果你的项目已经基于V1接口构建,并且运行稳定,那么不一定非要立即迁移。V1接口虽然复杂,但它依然是CANN支持的一部分。只有当你需要为旧项目添加新的图像/视频预处理功能,或者发现V1接口的复杂性已经成为维护的瓶颈时,再考虑进行重构和迁移。
-
迁移步骤指南 :
如果你决定从V1迁移到V2(AclLite),可以遵循以下步骤:
- 第一步:引入AclLite。在你的项目中包含AclLite的头文件,并链接相应的库。
- 第二步:替换资源管理 。移除所有
acldvppCreateChannel、acldvppDestroyChannel等相关代码,替换为AclLiteImageProc对象的实例化。 - 第三步:重构功能调用 。找到项目中调用
acldvppVpc*Async或acldvppJpeg*Async等功能函数的地方。用AclLiteImageProc对象的成员函数(如Resize,JpegD,Crop等)来替换它们。 - 第四步:简化内存操作 。将
acldvppMalloc和aclrtMemcpy的组合,替换为AclLite提供的CopyImageToDevice、CopyImageToHost等更高级的函数。同时,移除所有手动调用acldvppFree的代码。 - 第五步:移除描述符 。删除所有
acldvppCreatePicDesc、acldvppSetPicDesc*和acldvppDestroyPicDesc的代码。这些都由AclLite在内部自动处理了。
4. 总结
DVPP的V1和V2接口代表了两种不同的设计哲学。V1 acldvpp接口提供了很高的灵活性和控制能力,但代价是极高的复杂性。而通过AclLite封装的V2接口,则以牺牲少量灵活性为代价,带来了开发效率与代码健壮性的提高。
对于绝大多数应用开发者而言,使用AclLite的V2接口,是更为简洁、高效的选择。它使你无需再关注繁琐的底层细节,可将精力投入到业务逻辑与算法实现。提升硬件性能与简化软件使用,是计算框架演进的目标。