
1. 八路 1080p 同时解码瓶颈到底卡在谁身上先把结论甩在前面在 RK3588 上用 Python 做 8 路视频硬解码Python 本身不会是瓶颈VPU 也不会是瓶颈真正的瓶颈是内存搬运次数。我见过太多人一上来写八个cv2.VideoCapture线程跑起来 CPU 直接拉满、帧率掉到个位数然后得出结论Python 不适合做视频。其实问题不在 Python在于每一帧都在用 CPU 做解码、做色彩转换、做拷贝硬解单元压根没被用上。1.1 先算一笔账8 路 1080p25 到底要吃多少算力和带宽不谈数字的方案都是耍流氓。1080p 一帧是 1920×1080 2.07M 像素25fps 下每秒 51.8M 像素8 路加起来是 414M 像素/秒。RK3588 的 VPU 标称能扛 8K60 的 H.265 解码换算成像素吞吐是每秒接近 2G 像素。也就是说8 路 1080p25 只吃掉了它大约 20% 的解码能力理论上还有四倍余量。指标单路 1080p258 路合计像素吞吐51.8 M pixel/s414 M pixel/s解码输出写带宽NV12约 78 MB/s约 0.62 GB/s叠加参考帧读取后的近似总线压力150~300 MB/s1.2~2.4 GB/s占 8K60 理论解码能力的比例约 2.6%约 20%NV12 的推算方式很简单宽 × 高 × 1.5 字节。一帧 1080p 是 3.11MB 左右25fps 就是 78MB/s八路 622MB/s。这还只是写出去的量H.264/H.265 解码过程中每解一个宏块都要读参考帧外加运动补偿的重复读取实际总线压力通常是输出量的 2~4 倍。RK3588 配 LPDDR4x 4266、32bit 位宽理论带宽约 17GB/s实际可用打对折还有 8GB/s 以上所以带宽够用但不宽裕——这也是为什么少拷贝一次的价值比多优化一行 Python大得多。1.2 MPP 在 Rockchip 这套软件栈里到底站在哪一层很多人第一次听到 MPP 会以为是个播放器或者框架其实它是 Rockchip 官方的用户态媒体处理库全称 Media Process Platform。往下它通过/dev/mpp_service厂商内核或内核里注册的 rkvdec 驱动节点跟 VPU 通信往上它给 ffmpeg、GStreamer、以及你自己的程序提供统一的MppCtx/MppApi接口。它提供的东西本质上就三样一个解码上下文、一个喂数据的口decode_put_packet、一个收帧的口decode_get_frame。剩下的缓冲池管理、分辨率变化通知、EOS 冲刷都是围绕这三个动作展开的状态机。理解这一点很重要——MPP 不是调用一下返回一帧的同步函数而是需要你自己维护输入输出节奏的异步管道。那为什么不直接用 ffmpeg 的h264_rkmpp解码器非要自己调 MPP两个理由。第一帧格式和生命周期完全可控你能拿到dma_buf的 fd可以直接交给 RGA、DRM、OpenGL ES 或者算法推理框架做到全程不落地到 CPU 内存。第二多路并发时每一路的状态是隔离的不会因为某一路的抖动影响到其他路。当然代价是你得自己写循环这也是后面几节的主要内容。1.3 Python 的天花板在哪什么情况下不该用它我给自己的判断标准很粗暴Python 负责控制面硬件负责数据面。喂包、收帧、转发 fd、统计帧率、重连、配置下发这些是控制面Python 干得非常舒服每帧大概 5~10 次 C 调用200fps 的场景下也就每秒两千次调用对 CPython 来说连热身都算不上。但如果你打算在 Python 里对每一帧做cv2.cvtColor、np.mean、逐像素遍历那 8 路基本可以直接放弃了。一帧 1080p NV12 转 RGB 是 3M 像素的运算量8 路 200fps 就是每秒 6 亿次像素操作纯 Python 层根本不可能扛住。这类活要么交给 RGA 做要么交给 NPU 的推理框架内部做别让 Python 碰像素。还有个容易被忽略的边界如果你只需要 1~2 路解码而且对延迟不敏感、帧率只要 15fps那用 OpenCV 或者 ffmpeg 拉流就足够了没必要折腾 MPP。上 MPP 的合理场景是三路以上、1080p 以上、要求低延迟、或者后面还要接算法与多路推流。2. 上车之前确认 VPU 和 MPP 是真装上了我踩过的第一个坑就是以为自己装好了。板子上能import cv2、能播视频但那是软解跟硬解一毛钱关系没有。所以在写任何业务代码之前先花十分钟把底层通道验证清楚后面能省掉一整天的无效排查。2.1 三条命令判断硬解通道是不是活的第一条看设备节点。厂商内核一般暴露/dev/mpp_service纯主线化的 6.1 内核则可能是/dev/rkvdec、/dev/rkvenc这类名字。执行ls -l /dev/mpp_service /dev/rkvdec* /dev/dri只要有输出、权限正常通常需要把用户加进video组第一步就过了。第二条看中断计数。同样一段视频用软解和硬解各跑十秒前后各执行一次cat /proc/interrupts | grep -i -E vdec|vpu|mpp硬解路径下计数会明显增长软解路径下几乎不动。这是最直接、最不依赖日志的判定方式——中断涨了就说明 VPU 真的在干活。第三条看 CPU 占用。硬解 1080p25 时单核占用通常只有个位数百分比如果top里某一路的 CPU 稳在 100% 以上那基本可以确定你在走软解。把这三条当成开胃菜验证完再往下走。# 1. 设备节点 ls -l /dev/mpp_service /dev/dri # 2. 红外计数跑视频前后各看一次 cat /proc/interrupts | grep -i -E vdec|vpu|mpp # 3. 运行时占用 top -H -p $(pgrep -f your_app)2.2 拿到 MPP 库的三条路apt、源码编译、交叉编译产物最省事的是 apt。Rockchip 的 Ubuntu 镜像里通常带了打包好的库包名基本是librockchip-mpp1、librockchip-mpp-dev、rockchip-mpp-demos这一组装完之后/usr/lib/aarch64-linux-gnu/下会有librockchip_mpp.so头文件在/usr/include/rockchip/。顺便提醒一句rockchip-mpp-demos里那几个可执行文件非常值钱尤其是多路解码的 demo可以直接拿来当性能基线先跑它看看板子能扛几路比你自己写完再调快得多。如果镜像里没有或者你需要新的解码器支持比如 AV1、或者某个新版本修掉的 bug那就自己编。关键参数只有两个-DRKPLATFORMON打开 Rockchip 平台路径-DHAVE_DRMON打开 DRM 缓冲支持——后者直接决定了你能不能拿到dma_buf的 fd做零拷贝。少了HAVE_DRM编译能过、能解码但帧只能走 CPU 内存后面想接 RGA 就会很别扭。sudo apt install cmake build-essential libdrm-dev pkg-config git clone --depth 1 https://github.com/rockchip-linux/mpp.git cd mpp mkdir build cd build cmake -DRKPLATFORMON -DHAVE_DRMON -DCMAKE_BUILD_TYPERelease .. make -j$(nproc) sudo make install sudo ldconfig # 验证 pkg-config --modversion rockchip_mpp find /usr -name rk_mpi.h 2/dev/null装完一定要确认头文件的实际路径。不同版本安装位置不一样有的是/usr/local/include/rockchip/有的是直接顶层的/usr/local/include/写代码时#include的路径跟着改就行别硬记。2.3 Python 侧四条技术路线各有各的活法Python 调 MPP 不止一种方式我按上手难度和可控性列了个对照表你可以按自己的项目阶段挑。方案上手成本单路 CPU可控性能否零拷贝适合的场景ctypes C 薄封装直调 MPP中最低最高能拿 fd8 路以上、要接 RGA/算法/推流官方源码里的 Python 绑定低最低中部分版本支持快速验证思路PyAVffmpeg 的 rkmpp 解码器很低低中取决于 ffmpeg 编译选项两三路以内、要 numpy 帧GStreamer mppvideodec低低中appsink 通常拿不到 fd管道式快速搭原型我的建议是原型阶段用 PyAV 或 GStreamer 先把业务跑通确认需求以后再切到直调 MPP。因为一旦上了 8 路中间层每多一次内存拷贝都是乘 200fps 的开销。官方源码树里通常会带可选的 Python 绑定目录不同 tag 下路径不完全一样用find . -name *.py | head找一下就有能用就用用不了就走下面的 ctypes 路线。3. 单路跑通MPP 解码状态机里必须踩的那几个点把 8 路拆开其实就是把 1 路写对然后复制 8 份。但 1 路本身有坑而且是那种程序不报错、就是不给你画面的坑。这一节按调用顺序讲。3.1 从 mpp_create 到 mpp_init 之间最容易漏的两件事第一件是编码类型的数值。mpp_init要传一个MppCodingTypeH.264 是 7H.265 是0x01000004VP9 是 10AV1 排在更后面。这些值不要凭记忆写死用grep -n MPP_VIDEO_Coding inc/mpp_rc.h抄一遍十秒钟的事写错了只会得到一个没有明确报错的失败。第二件是缓冲组的挂载。MPP 默认会用内部的 buffer group 分配解码输出但那种模式下你拿到的帧不一定有可共享的 fd。正确做法是先mpp_buffer_group_get_internal建一个MPP_BUFFER_TYPE_DRM类型的组再通过MPP_DEC_SET_EXT_BUF_GROUP挂上去同时设置组的容量上限。这一步做对了后面零拷贝才有基础做错了一样能出画面但帧只能在 CPU 侧用性能差一个台阶。/* 关键初始化片段 */ mpp_create(ctx, mpi); mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); /* H.264 7 */ MppDecCfg cfg NULL; mpp_dec_cfg_init(cfg); mpp_dec_cfg_set_u32(cfg, base:split_parser, 1); /* 开流式切分 */ mpi-control(ctx, MPP_DEC_SET_CFG, cfg); mpp_dec_cfg_deinit(cfg); mpp_buffer_group_get_internal(grp, MPP_BUFFER_TYPE_DRM); mpp_buffer_group_limit_config(grp, 0, 16); /* 每组最多 16 个 buffer */ mpi-control(ctx, MPP_DEC_SET_EXT_BUF_GROUP, grp); mpi-control(ctx, MPP_DEC_SET_INFO_CHANGE_READY, NULL);注意mpp_dec_cfg_*这套配置接口是较新版本才有的。如果你的头文件里找不到说明版本偏老得改用MPP_DEC_SET_PARSER_SPLIT_MODE这类直接 control 的写法。编译报错先看头文件别怀疑人生。3.2 喂包和收帧的节奏什么时候该 put什么时候该 getMPP 解码的循环节奏是固定的喂一个包然后把帧收干净再喂下一个。不是喂一个收一个因为解码器有流水线延迟也因为有 B 帧重排序你喂进去的第一包可能要好几个包之后才出画面。所以最稳的骨架长这样decode_put_packet返回成功之后进入一个内层循环反复调decode_get_frame直到返回的帧指针为空才回到外层喂新包。如果输入队列满了老版本会返回 buffer full 一类的错误码不要死磕切到内层循环把帧收完再喂——收帧本身就是给输入队列腾空间。split_parser这个开关值得单独说。打开它你可以把从网络收到的任意长度字节流直接丢进去MPP 自己找 NAL 边界这对 RTSP 拉流场景几乎必须开。关掉它你就得自己在 Python 层拼帧、找起始码多一层逻辑就多一层 bug而且用 Python 做字节查找几 MB/s 的流量就能让 CPU 明显上升。EOS 也不能忘。流结束时最后一个包要打上 EOS 标记否则重排序缓冲区里剩下的那几帧永远吐不出来表现为视频最后少了几帧。这个现象在做离线转码测试时特别容易被当成解码器丢帧其实丢的是你自己。3.3 从 MppFrame 里取出 NV12stride 是个隐形陷阱拿到MppFrame之后你会想当然地用width * height * 1.5去切 NV12 数据然后发现画面是斜的。原因是 MPP 输出的 YUV 平面带 stride 对齐水平方向通常对齐到 16 或 64 字节垂直方向对齐到 16 行。真正的缓冲区大小是hor_stride * ver_stride * 1.5而你只应该按width去逐行读中间跳过填充字节。这件事在 1920 宽的时候可能恰好对齐1920 能被 16 和 64 整除所以很多人测试的时候完全没发现问题一换成 1280×720 或者摄像头直出的 1280×960 就开始花屏。我的建议是从第一天就按 stride 处理不要赌分辨率碰巧对齐。取数据有两条路。一条是mpp_buffer_get_ptr拿虚拟地址直接memcpy或包成 numpy 数组另一条是mpp_buffer_get_fd拿 fd把 fd 交给下游的 RGA/EGL/推理框架全程不碰 CPU。前者简单但多一次拷贝后者快但要求下游支持 dmabuf。注意某些 DRM/DMA-HEAP 配置下mpp_buffer_get_ptr可能返回空指针这时候要么自己在 Python 里对 fd 做 mmap要么干脆走 fd 那条路。3.4 用六十行 C 薄封装绕开 ctypes 结构体对齐这个坑理论上可以用 ctypes 直接把MppApi结构体映射出来然后取里面的函数指针调用。但MppApi是个成员顺序会随版本变化的大结构体字段偏移对不上就会出现调用成功但行为诡异甚至段错误而且这类错误极难定位。我的做法是写一个 60 行的 C 文件把这些函数指针的调用收在 C 里面Python 侧只用 ctypes 处理int和指针永远不会碰结构体布局。这个封装还能顺手把 info-change 帧、errinfo 这些处理逻辑收进去Python 侧代码干净很多。/* mppx.c —— 极简 MPP Python 封装 */ #include stdlib.h #include stdint.h #include rockchip/rk_mpi.h #include rockchip/mpp_buffer.h #include rockchip/mpp_frame.h #include rockchip/mpp_packet.h #include rockchip/mpp_dec_cfg.h typedef struct { MppCtx ctx; MppApi *mpi; MppBufferGroup grp; } MppDec; MppDec *mppx_new(void) { return (MppDec *)calloc(1, sizeof(MppDec)); } int mppx_open(MppDec *d, int coding, int split_parser) { if (mpp_create(d-ctx, d-mpi) ! MPP_OK) return -2; if (mpp_init(d-ctx, MPP_CTX_DEC, (MppCodingType)coding) ! MPP_OK) return -3; MppDecCfg cfg NULL; mpp_dec_cfg_init(cfg); mpp_dec_cfg_set_u32(cfg, base:split_parser, split_parser ? 1 : 0); d-mpi-control(d-ctx, MPP_DEC_SET_CFG, cfg); mpp_dec_cfg_deinit(cfg); if (mpp_buffer_group_get_internal(d-grp, MPP_BUFFER_TYPE_DRM) ! MPP_OK) return -4; mpp_buffer_group_limit_config(d-grp, 0, 16); d-mpi-control(d-ctx, MPP_DEC_SET_EXT_BUF_GROUP, d-grp); RK_U32 block 1; d-mpi-control(d-ctx, MPP_SET_INPUT_BLOCK, block); d-mpi-control(d-ctx, MPP_SET_OUTPUT_BLOCK, block); return 0; } /* 返回 0喂进去了, 1输入满, 0错误 */ int mppx_put(MppDec *d, const void *data, int size, int eos, int64_t pts) { MppPacket pkt NULL; if (mpp_packet_init(pkt, (void *)data, size) ! MPP_OK) return -1; mpp_packet_set_pts(pkt, pts); if (eos) mpp_packet_set_eos(pkt); MPP_RET ret d-mpi-decode_put_packet(d-ctx, pkt); mpp_packet_deinit(pkt); if (ret MPP_OK) return 0; return (ret MPP_ERR_BUFFER_FULL) ? 1 : -2; } /* 返回 0拿到帧, 1暂无帧, 2分辨率变化已处理, 0错误 */ int mppx_get(MppDec *d, int64_t *handle, int *fd, int *w, int *h, int *hs, int *vs, int *fmt, int64_t *pts) { MppFrame f NULL; if (d-mpi-decode_get_frame(d-ctx, f) ! MPP_OK) return -1; if (!f) return 1; if (mpp_frame_get_info_change(f)) { d-mpi-control(d-ctx, MPP_DEC_SET_INFO_CHANGE_READY, NULL); mpp_frame_deinit(f); return 2; } MppBuffer buf mpp_frame_get_buffer(f); *fd buf ? mpp_buffer_get_fd(buf) : -1; *w mpp_frame_get_width(f); *h mpp_frame_get_height(f); *hs mpp_frame_get_hor_stride(f); *vs mpp_frame_get_ver_stride(f); *fmt mpp_frame_get_fmt(f); *pts mpp_frame_get_pts(f); *handle (int64_t)(intptr_t)f; return 0; } void mppx_release(int64_t handle) { MppFrame f (MppFrame)(intptr_t)handle; if (f) mpp_frame_deinit(f); } void mppx_close(MppDec *d) { if (!d) return; if (d-mpi) d-mpi-reset(d-ctx); if (d-ctx) mpp_destroy(d-ctx); if (d-grp) mpp_buffer_group_put(d-grp); free(d); }编译一行搞定注意链接librockchip_mpp和libdrm。Python 侧加载时把argtypes和restype全部声明清楚尤其是指针类型不声明的话在 64 位系统上会遇到指针被截断成 32 位的经典问题。gcc -O2 -fPIC -shared -o libmppx.so mppx.c -lrockchip_mpp -ldrmimport ctypes lib ctypes.CDLL(./libmppx.so) lib.mppx_new.restype ctypes.c_void_p lib.mppx_open.argtypes [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] lib.mppx_put.argtypes [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int, ctypes.c_int64] lib.mppx_get.argtypes [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int64), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int64)] lib.mppx_release.argtypes [ctypes.c_int64]4. 从一路扩到八路线程模型、GIL 和缓冲池规划单路跑通之后八路不是简单地for i in range(8)。有几个决策点必须先定下来否则跑到一半就会出现第七路开始掉帧跑十分钟内存爆掉这类问题。4.1 每个 MppCtx 绑一个线程别共享别复用MppCtx不是线程安全的也不能跨流复用。八路就是八个上下文、八个线程一一绑定从创建到销毁都在同一个线程里完成。别想着搞个线程池动态调度因为上下文和流的状态是强绑定的动态调度只会引入锁竞争和难以复现的偶发问题。线程数量上八路用八个解码线程另外加一个管理线程负责重连、统计、配置更新。管理线程只做控制面的事不要去碰解码上下文两者通过队列和标志位通信。这样即使某一路断流重连也不会影响其他七路。4.2 ctypes 调用期间 GIL 是放开的所以线程就够用这是很多人不知道的一点ctypes 调用CDLL里的函数时会自动释放 GILPyDLL不会别用错。也就是说八个线程在 C 函数内部是真正并行执行的VPU 提交、等待、取帧这些动作不会互相阻塞。真正会抢 GIL 的是 Python 层的代码——所以我的原则是每个帧周期里 Python 侧只做三件事变量赋值、入队、计数绝不做字符串处理和字节查找。如果你确实需要在 Python 侧对帧做点运算那就切到多进程每个进程负责两路进程间只传元数据和 fdfd 可以通过 Unix socket 用SCM_RIGHTS传递。但这会带来 fd 生命周期管理的复杂度除非必要我不推荐。4.3 输入队列必须有界直播流宁可丢老包也不要堆八路线程各自从网络或文件读数据读线程和解码线程之间一定要有队列而且队列长度必须有上限。我一般设 8~16 个包。队列满了怎么办丢掉最老的包塞进最新的。这个策略对离线文件处理是错的但对实时流是对的实时视频补帧没有任何意义你花时间解一个两秒前的包画面只会更慢。丢掉老包能让延迟稳定在一个可控区间。这条经验值很多钱我在一个项目上因为队列没设上限跑了两小时以后延迟涨到四十多秒排查半天才反应过来是队列在无界增长。4.4 八路实测数据与瓶颈定位的方法我在这台板子上RK3588 8G、LPDDR4x、Ubuntu 22.04、厂商 5.10 内核实测的参考口径是八路 1080p25 H.264只做解码不做搬运整机 CPU 占用在 25%~40% 之间单路端到端延迟 60~90ms 之间波动跑满 200fps 稳定不掉。一旦在 Python 层对每帧做一次拷贝并做色彩转换CPU 立刻翻倍到 70% 以上而且延迟抖动明显变大。这个对比说明问题的根因非常清楚瓶颈是搬运不是解码。定位瓶颈的手法很简单三步走。第一步把下游处理全部注释掉只保留喂包收帧看帧率能不能到 200fps能到说明解码链路没问题。第二步逐步加回拷贝、色彩转换、推理每加一步看一次 CPU 和帧率哪一步掉得多瓶颈就在那。第三步同时用cat /proc/interrupts | grep -i vdec观察中断增长速率如果中断增长远低于帧率 × 2编解码一般都有多次中断说明 VPU 已经跑满那就只能减路数或者降分辨率了。5. 零拷贝这条线dmabuf、RGA 和下游怎么接前面反复提到少拷贝一次这一节说清楚具体怎么做。核心思想是解码出来的帧从头到尾都待在 DMA 缓冲里只在最后必须给 CPU 看的时候才落地一次。5.1 mpp_buffer_get_fd 拿到的那个 fd 到底能干什么这个 fd 是一个 dma_buf 的文件描述符。它的好处是可以被同一个进程里的多个组件共享RGA 可以直接导入它做缩放和色彩转换DRM/KMS 可以直接把它当显示图层很多推理框架的输入接口也支持直接喂 fd。整个过程不需要 CPU 参与数据搬运也不需要复制。使用上有一个纪律必须遵守fd 的生命周期跟着帧走。帧被mpp_frame_deinit释放之后那个 fd 随时可能被解码器回收并复用。所以如果下游是异步处理比如推理线程还在跑要么在交给下游之前对 fd 做一次dup要么保证下游在自己这一帧处理完之前不返回。我在这上面翻过车现象是偶尔出现画面内容串帧——两路视频的画面互相插入很难查。后来统一改成在移交时dup下游处理完再close问题就消失了。5.2 用 RGA 做 NV12 到 RGB 的转换与缩放RGA 是 Rockchip 的 2D 硬件加速单元色彩空间转换、缩放、旋转、叠加这些操作都能做而且支持直接以 fd 作为输入输出。典型链路是解码输出 NV12 的 fdRGA 转成 RGB 或者 RGB888输出到另一个 dma_buf然后这个 dma_buf 要么给显示要么给推理框架要么再交给编码器推流。调用方式上librga 提供了 im2d 那套 C 接口可以用 ctypes 包一层。关键是把源和目标都用wrapbuffer_fd包起来配置好宽度、高度、格式、stride然后调imcvtcolor或imresize。这里 stride 又是关键参数源和目标都要填真实的 stride填错就会出现斜切或者色彩错乱——解码这边踩过的 stride 坑在 RGA 这边会再踩一遍。如果你的下游是 OpenCV 这类只认 CPU 内存的库那最后一步还是逃不掉一次拷贝但这时候你可以选择拷贝成 BGR 而不是 RGB 再手工翻转通道省掉一次 Python 层的通道交换。别小看这一步8 路 200fps 下省掉一次全帧操作CPU 能差十几个百分点。5.3 拷贝一次和零拷贝的实测差距我做过一个对照实验同一批八路 1080p25 流两种模式跑十分钟取平均。结论如下表量级参考即可具体数字跟你的流内容、分辨率、下游处理都有关系。处理模式平均帧率整机 CPU单路延迟十分钟后 RSS 增长每帧 memcpy Python 侧转换148 fps71%85~160ms明显增长fd 直传 RGA 转换200 fps33%60~95ms基本平稳差距的来源有两块一块是拷贝本身的带宽和 CPU 周期另一块是 Python 层持有大对象导致 GC 压力和内存碎片。第二块经常被忽略但长时间运行时它的影响反而更大——RSS 缓慢爬升往往就是这种碎片造成的。6. 跑长稳之前这几个故障要先排掉能跑起来只是开始能连续跑七十二小时不掉帧、不涨内存才是真的交付。这一节按我实际遇到的故障频率排序。6.1 内存只涨不落buffer 与 frame 的释放顺序最常见的泄漏点有两个。第一个是忘记mpp_frame_deinit每拿一帧就漏一个缓冲区八路 25fps 下每秒泄漏 200 个几分钟就能耗尽 buffer group之后decode_get_frame会一直返回空程序看起来卡死但没有任何报错。第二个是 info-change 帧没有正确释放每次分辨率变化都会漏一次这种泄漏量小但很难发现通常表现为稳定运行几天后内存多出几十兆。排查方法是往封装里加一个使用量打印每处理 500 帧输出一次当前 buffer group 的占用正常情况应该是锯齿状波动、有涨有落。如果只涨不落基本可以确定是释放漏了。另外在 Python 侧也建议用/proc/pid/status里的VmRSS做长跑监控两个指标一起看能快速区分是 C 层漏还是 Python 层漏。经验decode_put_packet拿到包之后建议不要立刻释放包体所在的内存等你下一次复用这块缓冲区之前再覆盖它。这个做法比较保守但能避开一些版本上对外部内存生命周期的处理差异出问题的概率低很多。6.2 花屏、绿屏、马赛克从 info-change 帧和参数集查起画面异常分三类。整屏绿色或者灰色通常是拿到了还没解码完成的帧或者 info-change 帧被当成正常帧处理了间歇性马赛克并伴随错误计数上升多半是码流丢包H.264 的 IDR 帧丢了之后后面的 P 帧会持续花屏直到下一个 IDR 到来这是正常的但如果频繁出现就得查网络或读流的队列策略。还有一种情况是只有前几帧正常、后面全乱这通常发生在流式喂包关闭了 split_parser 的情况下你手工拼的帧边界错了一个字节解码器从某个位置开始就完全跑偏了。我的一般做法是先用mpi_dec_test这个官方 demo 喂同一个文件或同一段流如果它也花屏说明是流本身的问题如果它正常而你花屏那就是喂包逻辑的问题从 NAL 边界和 EOS 标记查起。6.3 帧率上不去分清是 VPU 堵了还是 Python 堵了区分方法我在第 4 节提过这里再给一个更量化的判据。看单路解码线程的时间分布如果大部分时间花在decode_get_frame返回空内层循环空转说明 VPU 供不上帧,那就是算力问题如果时间花在 Python 层的代码上那就是自己写的逻辑太重。给内层循环加一个空转上限也很重要非阻塞模式下如果一直空转会白白烧掉一个核加个time.sleep(0.001)让出时间片整体帧率反而更稳。还有一个很隐蔽的坑喂包的数据来源如果是 Python 的bytes对象每次切片都会产生新对象200fps × 8 路下这部分的分配压力不小。我一般会预先分配一个固定大小的缓冲区用memoryview或者直接把 C 层的读函数结果传进去避免在热路径上做 Python 对象的创建销毁。6.4 温度与降频连续跑八路时别忽略散热八路硬解加上 RGA 转换SoC 的负载其实不低。板子如果没加散热片在密闭机箱里连续跑一两个小时很容易触发降频表现是帧率从稳定 200fps 慢慢掉到 160fps 甚至更低而且降温以后也不会自动恢复到原来的水平因为热设计已经进入反馈循环。监控方式是定时读取几个温度区域cat /sys/class/thermal/thermal_zone*/temp单位是毫摄氏度。同时观察 CPU 频率是否被限制在低频档。如果发现降频先加散热再说优化因为热问题引起的不稳定是没法靠改代码解决的。我一般会给板子配一个带风扇的金属外壳八路 24 小时运行的温度能压在 60 度出头帧率曲线非常平。最后分享一个我在真机上总结出来的小习惯部署之前先在目标板子上跑一遍 24 小时压力测试记录帧率、CPU、内存、温度四条曲线作为基线存档。以后线上出问题的时候只要对比这四条曲线的偏移就能在几分钟内判断是环境问题、负载变化还是代码回归比现场调试高效得多。这套 MPP 的调用封装我前后改了三个版本最初那版用 ctypes 直接映射结构体在换了 MPP 版本之后整整花了一天定位段错误现在这版薄封装的形态已经跟着我在好几块板子上跑过长期任务稳定性没什么问题。