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 标称能扛 8K@60 的 H.265 解码,换算成像素吞吐是每秒接近 2G 像素。也就是说,8 路 1080p25 只吃掉了它大约 20% 的解码能力,理论上还有四倍余量。
| 指标 | 单路 1080p25 | 8 路合计 |
|---|---|---|
| 像素吞吐 | 51.8 M pixel/s | 414 M pixel/s |
| 解码输出写带宽(NV12) | 约 78 MB/s | 约 0.62 GB/s |
| 叠加参考帧读取后的近似总线压力 | 150~300 MB/s | 1.2~2.4 GB/s |
| 占 8K@60 理论解码能力的比例 | 约 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),那就自己编。关键参数只有两个:-DRKPLATFORM=ON打开 Rockchip 平台路径,-DHAVE_DRM=ON打开 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 -DRKPLATFORM=ON -DHAVE_DRM=ON -DCMAKE_BUILD_TYPE=Release .. 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 | 中 | 最低 | 最高 | 能(拿 fd) | 8 路以上、要接 RGA/算法/推流 |
| 官方源码里的 Python 绑定 | 低 | 最低 | 中 | 部分版本支持 | 快速验证思路 |
| PyAV(ffmpeg 的 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要传一个MppCodingType,H.264 是 7,H.265 是0x01000004,VP9 是 10,AV1 排在更后面。这些值不要凭记忆写死,用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,什么时候该 get
MPP 解码的循环节奏是固定的:喂一个包,然后把帧收干净,再喂下一个。不是"喂一个收一个",因为解码器有流水线延迟,也因为有 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 里取出 NV12:stride 是个隐形陷阱
拿到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里的函数时,会自动释放 GIL(PyDLL不会,别用错)。也就是说八个线程在 C 函数内部是真正并行执行的,VPU 提交、等待、取帧这些动作不会互相阻塞。真正会抢 GIL 的是 Python 层的代码——所以我的原则是每个帧周期里 Python 侧只做三件事:变量赋值、入队、计数,绝不做字符串处理和字节查找。
如果你确实需要在 Python 侧对帧做点运算,那就切到多进程,每个进程负责两路,进程间只传元数据和 fd(fd 可以通过 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 的 fd,RGA 转成 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 fps | 71% | 85~160ms | 明显增长 |
| fd 直传 RGA 转换 | 200 fps | 33% | 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 版本之后整整花了一天定位段错误,现在这版薄封装的形态已经跟着我在好几块板子上跑过长期任务,稳定性没什么问题。