随着我们对CANN应用开发的探索不断深入,我们已经能够成功地让各种模型在昇腾硬件上运行起来。但是,仅仅"跑起来"是不够的。在真实的业务场景中,我们追求的是"跑得快、跑得稳"。一个AI应用如果因为内存使用不当而频繁崩溃,或者因为处理速度跟不上业务需求而导致延迟,那么它就是不合格的。因此,理解并掌握内存管理与性能优化技巧,是每一位CANN应用开发者进阶的必经之路。
这篇文章将聚焦于两个核心主题:
- AscendCL内存管理:我们将深入探讨在昇腾硬件上进行内存操作的"三部曲"------申请、拷贝、释放。理解这些底层机制,是避免内存泄漏、提升数据交互效率的基础。
- 并行计算与性能观测:我们将以一个经典的多线程视频处理案例为引,学习如何利用并行计算来榨干硬件性能,实现系统吞吐率的倍增,并了解一些简单的性能观测方法。
我们将主要通过分析 cplusplus/level2_simple_inference/n_performance/1_multi_process_thread/YOLOV3_coco_detection_4_thread 这个典型示例,来展开今天的讨论。
1. AscendCL内存管理:与NPU高效交互的基石
在CANN应用中,内存被主要划分为两类:主机(Host)内存 和设备(Device)内存。简单来说,Host内存就是我们常规的CPU内存,而Device内存则是在昇腾NPU芯片上专门划分出来的、用于模型计算的内存空间。AI推理的核心计算都发生在Device侧。因此,高效地管理Device内存,并实现Host与Device之间的数据快速交互,是性能优化的第一步。
AscendCL的内存管理遵循一个清晰的"三部曲"模式:申请 -> 拷贝 -> 释放。我们在 YOLOV3_coco_detection_4_thread 示例的 object_detect.cpp 文件中,可以清晰地看到这个流程。
1.1 申请设备内存:aclrtMalloc
在将任何数据用于NPU计算之前,我们必须先在Device上为它预留一块空间。这个操作由 aclrtMalloc 函数完成。
cpp
// object_detect.cpp -> CreateInput()
// 为模型的第一个输入(图像数据)申请设备内存
aclError aclRet = aclrtMalloc(&g_imageDataBuf_, (size_t)(g_imageDataSize_), ACL_MEM_MALLOC_HUGE_FIRST);
if (aclRet != ACL_SUCCESS) {
ACLLITE_LOG_ERROR("malloc device data buffer failed, aclRet is %d", aclRet);
return ACLLITE_ERROR;
}
这段代码的含义是:
&g_imageDataBuf_:这是一个指向指针的指针,用于接收申请到的Device内存地址。g_imageDataSize_:需要申请的内存大小。这个值通常通过AclLiteModel::GetModelInputSize(0)从模型定义中动态获取,确保不多不少,精确匹配。ACL_MEM_MALLOC_HUGE_FIRST:这是一个内存分配策略。它告诉系统,请优先尝试申请"大页内存"。
名词解释:大页内存 (Huge Pages)
操作系统管理内存的基本单位是"页"(通常是4KB)。当程序需要大量内存时,就需要管理成千上万个这样的小页,CPU在进行地址转换时开销较大。而大页内存(如2MB或1GB)则允许用更少的"页"来管理同样大小的内存,可以有效降低CPU的地址翻译开销,从而提升内存访问性能。对于需要处理大幅图像、视频等数据的AI应用来说,使用大页内存是一个简单而有效的优化手段。
1.2 数据拷贝:aclrtMemcpy
在Device上"圈好地"之后,下一步就是将Host上的数据"搬运"过去。这个过程由 aclrtMemcpy 函数负责。在示例中,开发者使用了AclLite库封装的 CopyDataToDeviceEx 函数,其底层调用的就是 aclrtMemcpy。
cpp
// object_detect.cpp -> Inference()
// 将CPU上经过预处理的图像数据 (reiszeMat) 拷贝到已申请的设备内存 (g_imageDataBuf_)
AclLiteError ret = CopyDataToDeviceEx(g_imageDataBuf_, g_imageDataSize_,
reiszeMat.ptr<uint8_t>(), g_imageDataSize_,
g_runMode_);
这个拷贝动作是同步的,意味着程序会在此处等待,直到数据完全从Host内存拷贝到Device内存后,才会继续执行下一步。因此,频繁或大量的数据拷贝会成为性能瓶颈,我们在设计程序时应尽量减少不必要的拷贝次数。
1.3 释放设备内存:aclrtFree
有借有还,是良好的编程习惯。在程序结束或不再需要某块Device内存时,必须使用 aclrtFree 将其归还给系统,否则就会造成内存泄漏。
cpp
// object_detect.cpp -> DestroyResource()
void ObjectDetect::DestroyResource()
{
// ... 销毁其他资源 ...
// 释放之前申请的所有Device内存
aclrtFree(g_imageDataBuf_);
aclrtFree(g_imageInfoBuf_);
}
在 ObjectDetect 类的析构函数中调用 DestroyResource 方法,确保了在对象生命周期结束时,所有相关的Device内存都能被正确回收。这是一个非常关键的步骤。
2. 并行计算:榨干硬件性能的利器
理解了内存管理的基础后,我们来看看如何通过并行计算来加速应用。YOLOV3_coco_detection_4_thread 示例为我们展示了一种经典且高效的并行处理架构------流水线生产者-消费者模型。
想象一下工厂的流水线:一个人负责把零件放到传送带上(生产者),一个人负责组装(消费者),一个人负责质检(另一个消费者)。他们可以同时工作,而不是等上一个人完全搞定才开始。这样整个工厂的效率就大大提高了。我们的示例就是这样做的。
2.1 流水线架构
在 main.cpp 中,整个处理流程被巧妙地分成了三个并行执行的阶段:
-
预处理阶段(生产者) :创建了4个线程(
task1到task4),每个线程绑定一个视频文件。它们的工作很简单:从视频中读取一帧画面,将其缩放到模型需要的尺寸,然后打包成一个消息,扔到一个共享的"预处理队列"(queue_pre)中。 -
推理阶段(消费者/生产者) :主线程不断地从"预处理队列"中取出帧数据,调用
detect.Inference()将其送入NPU进行推理。推理完成后,它又扮演起生产者的角色,将推理结果和原始帧画面打包成新消息,扔到对应视频的"后处理队列"(queue_post[i])中。 -
后处理阶段(消费者) :同样创建了4个线程(
task5到task8),每个线程监听一个"后处理队列"。一旦拿到推理结果,它们就负责解析数据、打印结果。在更复杂的应用中,这一步可能还包括在图上绘制检测框等操作。
cpp
// main.cpp
// ... 省略初始化代码 ...
// 创建一个共享的预处理队列和四个独立的后处理队列
Queue<message_pre> queue_pre;
Queue<message> queue_post[VIDEONUM];
// 创建4个预处理线程(生产者)
thread task1(Preprocess, ref(capture1), ref(context), &queue_pre, 0);
// ... task2, task3, task4 ...
// 创建4个后处理线程(消费者)
thread task5(Postprocess, ref(context), &queue_post[0]);
// ... task6, task7, task8 ...
// 主线程作为推理引擎(消费者/生产者)
while (1) {
// 1. 从预处理队列消费数据
queue_pre.Pop(premsg);
// 2. 执行推理
detect.Inference(inferenceOutput, premsg.reiszeMat);
// 3. 将结果生产到后处理队列
queue_post[i].Push(msg);
}
2.2 并行带来的好处
这种流水线架构的优势是显而易见的:
- 提高硬件利用率:当NPU正在埋头对第N帧进行推理时,CPU并没有闲着,它可能正在预处理第N+1帧,或者后处理第N-1帧。各个硬件单元都在忙碌,避免了"干等"的情况,最大化了系统资源的利用率。
- 提升系统吞吐率:由于可以同时处理4路视频流,整个系统的处理能力(如每秒处理的总帧数)理论上可以达到单路处理的4倍。对于需要同时监控多个摄像头的安防场景,或处理海量视频数据的云端服务来说,这种数据并行能力至关重要。
- 异步解耦:通过线程安全的队列进行通信,预处理、推理和后处理三个模块被完全解耦。任何一个模块的短暂阻塞(如磁盘IO延迟)不会立即卡死整个系统,增强了程序的健壮性。
3. 性能观测:简单的计时器
要进行性能优化,首先得有量化的指标。我们得知道程序运行花了多长时间,才能判断优化措施是否有效。在这个示例中,开发者使用了一个简单而直接的方法:clock_gettime。
cpp
// main.cpp
struct timespec g_time1 = {0, 0};
struct timespec g_time2 = {0, 0};
// ...
// 在主循环开始前记录起始时间
clock_gettime(CLOCK_REALTIME, &g_time1);
while (1) {
// ... 推理循环 ...
if (videoNeedPreprocess == 0) { // 当所有视频的预处理都已结束
break;
}
}
// 在主循环结束后记录结束时间
clock_gettime(CLOCK_REALTIME, &g_time2);
// 计算并打印总耗时
cout << "Execute time passed is: " << (g_time2.tv_sec - g_time1.tv_sec) * 1000
+ (g_time2.tv_nsec - g_time1.tv_nsec) / 1000000 << "ms" << endl;
clock_gettime 是一个Linux系统下的高精度计时函数。通过在核心逻辑的开始和结束分别记录时间戳,然后计算差值,我们就能得到这段代码的执行耗时。虽然简单,但它为我们提供了一个基础的性能度量衡。在实际项目中,我们可能还会使用更专业的性能分析工具(Profiler)来获得更详细的函数级耗时、内存占用等信息,但原理是相通的。
4. 总结
通过对YOLOV3_coco_detection_4_thread这个示例的剖析,我们学习了CANN应用性能优化的两大支柱:
-
高效的内存管理 :遵循"申请-拷贝-释放"三部曲,正确使用
aclrtMalloc、aclrtMemcpy和aclrtFree,并适当采用大页内存等策略,是保证程序稳定、高效运行的基础。 -
充分的并行计算:利用多线程构建生产者-消费者流水线,实现数据并行和任务并行,是榨干硬件性能、提升系统吞吐率的关键技术。异步和解耦的设计思想,值得在任何复杂的应用开发中借鉴。
性能优化是一个持续迭代的过程。从使用简单的计时器开始,到利用专业的分析工具,再到不断地重构代码、调整并行策略,这是一个从"能用"到"好用"的必经之路。