Atlas 300V VDEC 通道内存泄漏实测报告

一句话结论:在 Atlas 300V (Ascend 310P)上,「每轮新建 VDEC 通道 → 解码 → 销毁」会稳定泄漏约 97 MB / 次设备内存,且 aclvdecDestroyChannel 无条件永久卡死;跨 CANN 8.5.0 / 8.5.2 / 9.0.1、驱动 25.5.2 / 26.1.1、固件 7.8 / 9.0、内核 5.15 / 5.10 五组组合全部逐项复现。升级软件栈解决不了这个问题,唯一可行路线是「不销毁 + 通道复用」或「进程级回收」。


0. 摘要(先看这里)

项目实测结果
症状一每次「新建通道 → 解码 60 帧 → 关流」净泄漏 +97.19 ~ +97.34 MB(B 帧源),无 B 帧源 +84.63 ~ +85.15 MB,线性累加、不回落
症状二aclvdecDestroyChannel 永不返回,即使零帧新建后立即销毁;线程停在内核驱动模块的 CQ 等待上,S 态、可 SIGKILL
泄漏主体每轮新建通道本身,不是尾帧、不是 ring 槽、不是 Stream(同通道复用 12 轮仅 +0.04 MB/轮)
单卡寿命21.5 GB 可用设备内存 ÷ 97 MB ≈ 206 次开合;1000 次开合外推约 97 GB
版本矩阵5 组 / 4 个软件维度 / 2 个内核主线全部复现,斜率极差 ≤0.52 MB/轮(噪声量级)
结论平台级固有行为,非版本回归;不能称为「某个 CANN 版本的 bug」
推荐方案① 进程级 VDEC 服务 + 通道池(根治)② 不销毁通道 + 安全释放 + 开合预算(过渡)③ worker 进程池定期回收(兜底)

1. 背景与术语

在昇腾 CANN 的媒体处理(DVPP)体系中,VDEC 是硬件视频解码模块,应用层通过 aclvdec* 系列接口使用:

acldvppCreateChannelDesc → aclvdecCreateChannel     创建解码通道(同步接口,内部创建 2 个 Stream 并订阅回调)
aclvdecSendFrame / aclrtProcessReport              送帧 + 回调取帧
aclvdecDestroyChannel → acldvppDestroyChannelDesc  销毁通道(同步接口,需等待已发送帧与回调完成)

一个「通道」是解码资源的载体。很多视频分析服务在多路流接入、断线重连、按任务切片的场景下,会自然写出「每路流 / 每次重连 / 每个任务 建一次通道、用完销毁」的生命周期。本文的问题正出在这条路径上。

涉及三层软件:CANN(用户态运行库与头文件)、HDK 驱动(内核模块)、设备固件(NVRAM,不随虚拟机快照回滚)。三者可独立更换,这也是本次矩阵测试能拆出四个维度的原因。

测试硬件:Atlas 300V (Ascend 310P),NPU DDR 21525 MB,单卡以 PCIe 直通方式独占给 KVM 虚拟机。测试素材:2560×1440 / 25 fps / H.264(含 B 帧)及其 -bf 0 重编码的无 B 帧版本。


2. 现象

2.1 症状一:内存按轮次线性上涨,不回落

用探针在同一进程内循环 12 次「新建通道 → 送 60 帧 → 关流」,每轮用 aclrtGetMemInfo 记录设备空闲内存:

cycle  free_mb   used_mb   delta_mb  path  dev_frames  read  fallback
   0   19982.8    1542.3    +144.07  vdec    60         60    0     ← 第 0 轮含初始化摊销,不计入斜率
   1   19878.6    1646.4    +104.16  vdec    60         60    0
   2   19774.9    1750.1    +103.70  vdec    60         60    0
   3   19671.2    1853.9    +103.75  vdec    60         60    0
  ...
  10   18980.8    2544.2    +104.41  vdec    60         60    0
  11   18913.2    2611.8     +67.63  vdec    60         60    0

# 每轮平均净泄漏 = +97.23 MB(第 1..11 轮,11 个样本)
# 外推:1000 次开合 ⇒ 97233.0 MB

斜率是直线,没有随内存水位升高而加速,也没有在某一轮收敛。换成无 B 帧素材是 +85.12 MB/轮;换成软件解码(不建 VDEC 通道)对照是 −0.22 MB/轮——说明增长确实来自 VDEC 路径。

按起始可用约 20 100 MB 计算,约 206 次开合就会把 21.5 GB 设备内存吃满。对长驻视频服务来说,这不是「慢一点」的问题,而是必然 OOM。

2.2 症状二:销毁通道永久卡死

aclvdecDestroyChannel 不返回。取证方式是把每个变体放进 fork 出的子进程,父进程超时后读取 /proc/<pid>/task/*/{stack,wchan,stat} 再 SIGKILL:

[parent] A3 超过 25s 未返回 ⇒ 判定卡死,开始取证
[parent] tid=19183 state=S wchan=logic_ioctl_cq_wait
[parent]   [<0>] logic_ioctl_cq_wait+0x7b1/0x1c60 [drv_devdrv_host]
[parent]   [<0>] devdrv_ioctl+0x169/0x5d0          [drv_devdrv_host]
[parent]   [<0>] tsdrv_comm_ioctl+0xc8/0x1d0       [drv_devdrv_host]
[parent]   [<0>] drv_ascend_intf_ioctl+0x80/0x2d0  [drv_davinci_intf_host]

关键细节:

  • S 态(可中断睡眠),不是 D 态:SIGKILL 能回收进程,设备内存随之归零,不需要重启机器;但反过来,这意味着等它自己超时返回是不可能的。
  • 多线层栈里同时出现 hdcdrv_recv_peek_wait(host-device 通道等待)、futex_wait、nanosleep,主线程与另一线程都停在 logic_ioctl_cq_wait。
  • 零帧新建后立即销毁(变体 A3)同样卡死,因此「还有未解完的帧 / 回调没处理完」不是必要条件。

2.3 最小复现

// 伪代码:足以复现两个症状的最小循环
aclInit(nullptr);
aclrtSetDevice(0);
for (int cycle = 0; cycle < 12; ++cycle) {
    auto *desc = acldvppCreateChannelDesc();
    // 设置 codec / width / height / refFrameNum / 回调 / threadId
    aclvdecCreateChannel(desc);            // 不注册回调与 threadId 会直接返回 100000
    aclrtCreateStream(&stream);
    aclrtSubscribeReport(hostThreadId, stream);

    for (int i = 0; i < 60; ++i) aclvdecSendFrame(...);   // 送 60 个 Annex-B AU
    // 等待回调 done == 60

    aclvdecDestroyChannel(desc);           // ← 永久卡死;注释掉它则每轮泄漏 ~97 MB
    acldvppDestroyChannelDesc(desc);
    aclrtGetMemInfo(ACL_HBM_MEM, &free, &total);  // 记录
}

对应探针命令(探针为自写的 4 个独立小程序,互不共享状态):

# 斜率
ATLAS300V_ENABLE_VDEC=1 ./probe_vdec_leak --cycles 12 --frames 60
# 卡死取证(timeout 必须放在 sudo 内层,见 §8)
timeout -k 10 90 sudo env LD_LIBRARY_PATH="$LD_LIBRARY_PATH" \
  ./probe_vdec_destroy_hang --variant A3 --timeout 25

3. 根因定位(第一轮排查)

3.1 拆开 97 MB:泄漏主体是「新建通道」

用「不喂帧、只建通道」的零风险探针做分项累加:

实验配置每轮净增量推论
C1只建通道,ring 4 / ref 8+48.35 MB通道 + 4 槽的成本
C2只建通道,ring 2 / ref 8+32.14 MB与 C1 相差 16.2 ÷ 2 ⇒ 每槽 ≈8.1 MB
C3只建通道,ring 4 / ref 2+48.43 MB与 C1 相同 ⇒ RefFrameNum 不是决定因素
C5只创建 / 销毁 Stream ×12+0.04 MBStream 与订阅本身不泄漏
复用同一通道连续喂 12 轮+0.04 MB/轮(无 B 帧 +0.01)泄漏主体就是「新建通道」

据此可以给出 97 MB 的构成:通道固定 ≈16 MB + 解码期内部池 ≈47 MB + 在途槽 4×8.1 ≈32 MB。这个数量级也与官方给出的单路 VDEC 内存参考值一致(1080p H.265 / ref8 约 58 MB,4K 最大参考约 182 MB)——即「每轮留下接近一个通道规模的内存块」是数量级合理、但不符合「销毁应归还」预期的行为。

复用实验还有一个重要发现:通道与创建线程强绑定。对已建通道调用 aclvdecSetChannelDescThreadId 会失败,换线程后回调不再投递。所以「通道池」不能设计成「多个任务线程各取一个通道」,见 §6.1。

3.2 销毁路径:哪些步骤安全,哪一步必挂

变体动作(在途 ≥1 帧)结果
A1只销通道,不 free 任何缓冲卡死
A3零帧:建完立刻销通道卡死,logic_ioctl_cq_wait+0x7b1
A6A1 成功后再 free 全部在途槽卡死(卡在 A1)
A2只 acldvppFree 一个在途槽的 out_dev0 ms,rc=0
A4只销毁在途槽的 host 侧 desc0 ms,rc=0
A5只退订 + 销毁 Stream1 ms,rc=0
A7/A8换辅助线程销通道8 ms 返回 500001(invalid param)
A14/A15/A16清回调 / 不建 Stream / 先销 Stream 再销通道全部卡死

结论:卡死与在途帧数、Stream、回调注册、调用顺序、调用线程都无关,是「销毁通道」这条 ioctl 本身在本平台不可完成。而 acldvppFree、desc 释放、退订并销毁 Stream 都是安全的——这条结论直接决定了 §6.2 的过渡方案能做到什么程度。

3.3 几个「想当然」的解释已被实测否证

猜测否证证据
尾帧没冲刷干净,导致 destroy 一直等aclvdecSendSkippedFrame 连发 16 次,done 递增但 ring 在途恒为 4/8 不降;对 IPPP 顶到 0 槽后销毁仍然卡死
设备内存不足 → AI CPU 超时 → 回调不触发A3 零帧卡死时设备仅占用 1850 / 21525 MB,距耗尽差一个数量级;全程无 aicpu timeout / 0x28 日志
参考帧数配置太大ref 2 与 ref 8 的每轮增量相同(±0.08 MB)
跨 Context / 跨线程销毁导致资源不归还A7/A8 跨线程直接返回 500001;A14/A15/A16 改变顺序仍卡死
进程退出后设备残留SIGKILL 后 npu-smi 显示无残留进程,内存归零

3.4 一次真实的代码修复:有效,但不够

把析构逻辑改成「只释放可证明安全的部分」:

  1. 有界泵回收已完成帧;
  2. 释放已归还的槽(acldvppFree、pic/stream desc)——A2/A4 证明安全;
  3. aclrtUnSubscribeReport + aclrtDestroyStream——A5 证明安全;
  4. 不调用 aclvdecDestroyChannel,把 channel_ 置空以切断迟到回调入口。

顺带修掉了 4 个确定性缺陷(ring 扩容回滚漏回收、空输出指针、槽位匹配失败落到 0 号槽、RefFrameNum 硬编码)。结果:

素材修复前修复后变化
含 B 帧+130.01 MB/轮+97.25 MB/轮−25%
无 B 帧(IPPP)+94.83 MB/轮+85.03 MB/轮−10%

功能回归正常(150 帧视频跑通,解码均值 4.91 ms,零推理失败、零重连)。但预算只从约 154 次提升到约 206 次,依然不能进长驻服务。 这也是为什么要做下面这轮版本矩阵测试:得先确认「是不是某个软件版本坏了」。


4. 五组版本矩阵测试(本文重点)

4.1 实验设计

目标:判断这两个症状是不是某个软件层的版本缺陷,以及是否存在「destroy 正常 + 单次开合泄漏可接受」的可长驻组合。

  • 单变量:每格只改一个维度(CANN / 驱动+固件 / OS+内核),其余全部冻结,包括代码、探针、素材。
  • 串行执行:只有一张卡且独占直通,无法并行对照;每格全量重编译、跑完整套探针。
  • 固件单向升高:设备固件写在卡上 NVRAM,不随虚拟机快照回滚,且旧驱动不认新固件。因此把「CANN 降级」排在「固件升级」之前,避免不可逆的回退组合。
  • 判据先定死,再看数据:
判据定义
D1(挂死)destroy_hang 退出码:0 = 全部变体正常返回 = 通过;2 = 超时但可杀 = 失败;3 = D 态不可杀 = 最严重失败
D2(泄漏)aclrtGetMemInfo 空闲内存增量,第 0 轮不计入斜率。≤5 MB/轮 = 根治(与复用基线同量级);≤20 MB/轮 = 可用(F0/1000,支撑 1000 次开合);>20 MB/轮 = 失败

4.2 五格矩阵

格OS / 内核CANN驱动固件主要作用
A(基线)Ubuntu 20.04.5 / 5.158.5.225.5.27.8.0.7.220基线复现 + 内存水位观测
D同上8.5.025.5.27.8检验「8.5.1/8.5.2 补丁回归」
C同上9.0.126.1.19.0.0.9.220检验「CANN 大版本跃迁」
B同上8.5.226.1.19.0.0.9.220检验「驱动 / 固件」
GopenEuler 22.03 SP4 / 5.108.5.226.1.19.0.0.9.220检验「OS / 内核主线」

G 格是一台全新安装的虚拟机(kickstart 无人值守装机),从驱动开始重装整套软件栈——排除了「原系统残留状态」这一干扰。

4.3 结果:五格几乎逐项重合

泄漏斜率(MB/轮)

格B 帧源无 B 帧源chopen 分项stream_only 分项
A+97.23+85.12+51.48−0.17
D+97.26+84.71+51.42−0.16
C+97.34+85.15+52.02−0.16
B+97.19+84.63+51.23−0.17
G+97.19+85.11+51.42−0.15
极差0.150.520.790.02

挂死行为

观测A / D / C / B(5.15)G(5.10)
destroy_hang A3exit 2exit 2
变体矩阵A1 / A3 / A6 卡死,A2 / A4 / A5 正常完全同构
chopen_close60 s 超时,无任何 cycle 输出同
skipflush 16 次后 ring 在途4/8 不降4/8 不降
挂死内核栈logic_ioctl_cq_wait+0x7b1 [drv_devdrv_host]logic_cq_wait_event+0x2a2 → logic_wait_cq_report+0x158 → logic_ioctl_cq_wait+0x11c [drv_devdrv_host]
线程状态S 态,SIGKILL 可回收S 态,SIGKILL 可回收

最强的一条证据:跨 25.5.2 与 26.1.1 两个大版本驱动,挂死点在同一函数、同一偏移(+0x7b1)命中;换成 5.10 内核后函数链依然一致,只是因内核编译差异导致内联展开与偏移不同。这不是某版本引入的回归,而是两条发布线、两个内核主线上长期一致的行为。

4.4 有效性控制(这部分决定了结论能不能信)

跨格比较最怕「测了个寂寞」,所以每格都强制校验:

  1. probe_vdec_leak 的 path 列必须全为 vdec;出现 vdec->ffmpeg_fallback 该次作废;
  2. 回调完成帧数 == 发送帧数(dev_frames=60、read=60、fallback=0),防空跑假阴性;
  3. 素材三件套 md5 与基线逐字节一致(源文件、Annex-B 裸流、无 B 帧重编码);
  4. 三元组自证:CANN 软链指向、npu-smi 驱动 / 固件版本、内核版本逐项打印并核对;
  5. npu-smi health=OK、单卡在位;全量重编译后 ctest 全绿(基线格与驱动升级格实测 16/16);
  6. 第 0 轮不计入斜率;每格 11 个样本取均值。

G 格的两个测试程序因发行版依赖差异(gtest 1.8.1 无 GTEST_SKIP、spdlog 使用系统 fmt)编不过,ctest 11/16 失败——但它们只是单元测试程序,四个 VDEC 探针二进制均正常构建并执行,不影响上述任何指标。这一点在证据包里有单独说明。


5. 结论

5.1 五个假设的判决

ID假设判决依据
H1驱动层缺陷(特定版本)排除25.5.2 与 26.1.1 同函数同偏移命中
H2CANN 层缺陷 / 回调链断裂排除8.5.0 / 8.5.2 / 9.0.1 三条发布线无差异
H38.5.x 补丁回归排除D 格(8.5.0)与基线逐项等价
H4资源耗尽 → AI CPU 超时排除零帧即卡且设备仅占用 1850 / 21525 MB
H5OS 内核 / 直通环境特有排除全新 openEuler 5.10 + 全新装机,全部复现

5.2 该怎么表述这个结论

可以确认的是:在这张 310P 上,aclvdec* 通道销毁路径的 CQ 等待条件不被满足,且每次新建通道留下的约 97 MB 不会通过任何已测试的销毁手段归还;该行为不随 CANN、驱动、固件、内核版本变化。

需要克制的措辞:

  • 现有证据不足以区分「驱动实现」「固件微码」「硬件逻辑」三者中哪一个是最终责任点,因此更准确的说法是平台级固有行为,而不是「某某版本的 bug」或「硬件缺陷」。
  • 结论仅在本文的硬件型号、接口路径与调用方式下成立,不能外推到其它 Ascend 型号、其它解码接口或新版 SDK。
  • 官方故障手册里「VDEC 解码异常导致进程卡死(0x28 aicpu timeout)」是另一条触发路径(设备内存不足),本案已明确排除。

5.3 与公开资料的对照

检索公开渠道,未找到与「310P + 每轮建毁固定涨 83–130 MB + 线性累加」完全一致的报告,也没有找到官方缺陷编号或针对该现象的修复说明。最接近的公开记录有三类:

  • 反复建毁 VDEC 通道后 event 资源耗尽(207007)(Gitee Ascend/pytorch#I6WUNN);
  • 通道异常后 aclvdecDestroyChannel 阻塞,官方回复承认该接口有同步等待(Gitee Ascend/samples#I4TTM0);
  • 解码器释放后显存残留 237 MB、重建后继续增长(Gitee ffmpeg-ascend#I9S7C4)。

官方文档的两个要点值得注意:

  1. aclvdecCreateChannel 明确写着「同一个通道可以重复使用,销毁后不再可用」;DVPP 总体说明也建议「根据实际场景管理通道,反复创建与销毁会影响业务性能」——这与本文实测的规避方向一致;
  2. aclvdecDestroyChannel 「会等待已发送帧解码完成且用户回调函数处理完成后再销毁」——这能解释「有在途帧时不返回」,但解释不了零帧即卡,公开资料里也没有对零帧场景的说明。

换句话说:官方推荐的做法本来就是复用,而本文用五组数据说明了「不复用」在这张卡上的代价有多大。


6. 参考资料

官方文档

社区记录(部分相似,非同一问题)

Logo

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

更多推荐