CANN Samples(九):内存管理与性能优化

随着我们对CANN应用开发的探索不断深入,我们已经能够成功地让各种模型在昇腾硬件上运行起来。但是,仅仅"跑起来"是不够的。在真实的业务场景中,我们追求的是"跑得快、跑得稳"。一个AI应用如果因为内存使用不当而频繁崩溃,或者因为处理速度跟不上业务需求而导致延迟,那么它就是不合格的。因此,理解并掌握内存管理与性能优化技巧,是每一位CANN应用开发者进阶的必经之路。

这篇文章将聚焦于两个核心主题:

  1. AscendCL内存管理:我们将深入探讨在昇腾硬件上进行内存操作的"三部曲"------申请、拷贝、释放。理解这些底层机制,是避免内存泄漏、提升数据交互效率的基础。
  2. 并行计算与性能观测:我们将以一个经典的多线程视频处理案例为引,学习如何利用并行计算来榨干硬件性能,实现系统吞吐率的倍增,并了解一些简单的性能观测方法。

我们将主要通过分析 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 中,整个处理流程被巧妙地分成了三个并行执行的阶段:

  1. 预处理阶段(生产者) :创建了4个线程(task1task4),每个线程绑定一个视频文件。它们的工作很简单:从视频中读取一帧画面,将其缩放到模型需要的尺寸,然后打包成一个消息,扔到一个共享的"预处理队列"(queue_pre)中。

  2. 推理阶段(消费者/生产者) :主线程不断地从"预处理队列"中取出帧数据,调用 detect.Inference() 将其送入NPU进行推理。推理完成后,它又扮演起生产者的角色,将推理结果和原始帧画面打包成新消息,扔到对应视频的"后处理队列"(queue_post[i])中。

  3. 后处理阶段(消费者) :同样创建了4个线程(task5task8),每个线程监听一个"后处理队列"。一旦拿到推理结果,它们就负责解析数据、打印结果。在更复杂的应用中,这一步可能还包括在图上绘制检测框等操作。

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应用性能优化的两大支柱:

  • 高效的内存管理 :遵循"申请-拷贝-释放"三部曲,正确使用aclrtMallocaclrtMemcpyaclrtFree,并适当采用大页内存等策略,是保证程序稳定、高效运行的基础。

  • 充分的并行计算:利用多线程构建生产者-消费者流水线,实现数据并行和任务并行,是榨干硬件性能、提升系统吞吐率的关键技术。异步和解耦的设计思想,值得在任何复杂的应用开发中借鉴。

性能优化是一个持续迭代的过程。从使用简单的计时器开始,到利用专业的分析工具,再到不断地重构代码、调整并行策略,这是一个从"能用"到"好用"的必经之路。

相关推荐
Dev7z1 小时前
基于深度学习的中文手写数字识别系统研究与实现
人工智能·深度学习
wshzd1 小时前
LLM之Agent(三十六)|AI Agents(五):Workflow vs Agent
人工智能
创客匠人老蒋1 小时前
AI正在重塑IP:创客匠人峰会探讨智能体与未来趋势
人工智能·创始人ip·创客匠人全球ip+ai高峰论坛
极智视界1 小时前
目标检测数据集 - 卫星图像船舶检测数据集下载
人工智能·目标检测·目标跟踪
宇来风满楼1 小时前
U-KAN复现
人工智能·深度学习·神经网络·算法·机器学习
糖葫芦君1 小时前
One-rec强化学习部分
人工智能·深度学习
极客BIM工作室1 小时前
稀疏混合专家(Sparse MoE)架构论文全景
人工智能
IT_陈寒1 小时前
SpringBoot3.0实战:5个高并发场景下的性能优化技巧,让你的应用快如闪电⚡
前端·人工智能·后端
秋邱1 小时前
AR 定位技术深度解析:从 GPS 到视觉 SLAM 的轻量化实现
开发语言·前端·网络·人工智能·python·html·ar