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,并适当采用大页内存等策略,是保证程序稳定、高效运行的基础。

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

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

相关推荐
haiyu_y11 小时前
Day 46 TensorBoard 使用介绍
人工智能·深度学习·神经网络
阿里云大数据AI技术11 小时前
DataWorks 又又又升级了,这次我们通过 Arrow 列存格式让数据同步速度提升10倍!
大数据·人工智能
做科研的周师兄11 小时前
中国土壤有机质数据集
人工智能·算法·机器学习·分类·数据挖掘
IT一氪11 小时前
一款 AI 驱动的 Word 文档翻译工具
人工智能·word
lovingsoft11 小时前
Vibe coding 氛围编程
人工智能
百***074511 小时前
GPT-Image-1.5 极速接入全流程及关键要点
人工智能·gpt·计算机视觉
yiersansiwu123d11 小时前
AI二创的版权迷局与健康生态构建之道
人工智能
Narrastory12 小时前
拆解指数加权平均:5 分钟看懂机器学习的 “数据平滑神器”
人工智能·机器学习
SelectDB12 小时前
慢 SQL 诊断准确率 99.99%,天翼云基于 Apache Doris MCP 的 AI 智能运维实践
数据库·人工智能·apache
王中阳Go12 小时前
05 Go Eino AI应用开发实战 | Docker 部署指南
人工智能·后端·go