ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python调用RK3588 MPP实现8路1080p视频硬解码实战

Python调用RK3588 MPP实现8路1080p视频硬解码实战 现在很多做边缘视觉的团队都在拿 RK3588 当主力板子CPU 有 8 核、NPU 有 6TOPS、VPU 又能硬解 8K纸面参数很漂亮。但真到落地的时候卡人的往往不是模型而是“8 路 1080p 视频同时进来怎么不把 CPU 吃满”。纯软解 8 路 H.264A76 大核全开也就勉强 3 到 4 路风扇起飞、帧还丢。这时候就得把活交给板子上那块 VPU而 Python 想指挥 VPU中间那座桥就是 MPP。这篇就把 Python 调 RK3588 MPP 做 8 路视频硬解码这条链路从头到尾拆一遍MPP 是什么、8 路的资源怎么算、ctypes 封装里哪些字段不能写错、外部分配 buffer 怎么做到零拷贝、跑起来之后画面发绿和卡死分别该往哪查。内容偏实战适合已经有 RK3588 板子、会一点 Python、但还没跑通多路硬解的同学也适合已经在跑单路、想扩到 8 路的人对着抄参数。1. 先搞清楚 RK3588 上这块 VPU 到底怎么被驱动在 RK3588 上做视频硬解码绕不开一个事实VPU 是独立硬件它不认识 Python也不认识 ffmpeg它只认内核驱动搬进去的码流数据。MPP 就是 Rockchip 官方给出的那一层“翻译官”而 Python 要做的事情是隔着 ctypes 把指令塞进这层翻译官。这个链路如果没理顺后面所有调优都是瞎猜。1.1 MPP 在 RK3588 上扮演的角色MPP 全称 Media Process Platform是 Rockchip 自家的一套多媒体处理框架代码在 rockchip-linux/mpp 这个仓库里。它向下对接内核里的 VPU 驱动节点RK3588 上通常是/dev/mpp_service上游内核适配后可能走 V4L2 的/dev/video*路径向上提供MppCtx、MppApi、MppFrame、MppPacket这几个核心抽象。用生活化的说法MppCtx是一个解码器实例相当于给 VPU 开了一个“窗口”MppPacket是你要喂进去的一小段压缩码流相当于原料MppFrame是解码出来的原始 YUV 帧相当于成品。MppApi则是一组函数指针你的程序通过它调用decode_put_packet送原料和decode_get_frame取成品。这里有个关键点很多人一开始会搞混MPP 本身不做码流解析之外的软件解码。H.264 的 NAL 拆分、SPS/PPS 解析MPP 内部有个 soft parser 会做掉但熵解码、反量化、运动补偿这些重活全部由 VPU 硬件完成。所以你会发现解码 1080p 时 CPU 占用可能只有百分之几那不是因为 MPP 优化得好是因为 CPU 真的没干活。RK3588 的 VPU 规格上支持 8K60 的 H.265 解码多路场景下官方给的口径是 32 路 1080p30。这个数字是理论峰值实际能不能跑到取决于你的码流复杂度、buffer 够不够、以及喂数据的速度跟不跟得上。8 路是这条曲线的中间地带属于“跑得动但要认真调”的区间。1.2 为什么 Python 直连 MPP 是可行的很多人第一反应是“硬解这种底层活Python 干不了”。其实 Python 在这里的角色不是做解码而是做调度开几个线程、把码流切好、调 ctypes 送进去、把出来的 dma_fd 分发给下游。真正耗时的部分都在 C 库里Python 只承担调用开销。这里有一个必须知道的机制ctypes.CDLL调用 C 函数时会释放 GIL如果你用的是ctypes.PyDLL就不会释放那才是灾难。这意味着 8 个 Python 线程各自调用decode_get_frame时它们在 C 层是可以真正并行的不会被全局解释器锁串成一条线。这个细节决定了“Python 多线程做硬解”这条路能不能走通答案是能。但要注意边界Python 层的码流读取、切包、numpy 后处理依然在 GIL 下。8 路 1080p25 意味着每秒 200 帧的数据搬运和切片如果每帧都在 Python 里做bytes拼接那 GIL 会成为真正的瓶颈。后面的章节我会讲怎么把这部分开销压下去。1.3 8 路并发的真实瓶颈通常在哪我做过一轮完整的压测8 路 1080p H.264 主码流4Mbps 左右CPU 占用分布大致是这样环节占用比例说明VPU 硬解0硬件完成不计入 CPUPython 喂数据与切包约 45%主要开销受 GIL 限制ctypes 调用与轮询约 20%8 路 1ms 轮询的系统调用内核 mpp_service 交互约 15%ioctl 与中断处理帧返还与内存管理约 10%buffer group 操作其他约 10%日志、统计等看清楚这张表就明白了瓶颈从来不是 VPU而是 Python 侧的数据供给。所以整篇内容的调优重点都会落在“怎么让 Python 少干活、让 C 层多干活”上。如果你一上来就去调 VPU 参数方向就错了。2. 环境准备让 Python 能摸到 MPP 库这一节的目标很明确让python3 -c import ctypes; ctypes.CDLL(librockchip_mpp.so.1)能正常返回不报错。看起来简单但 RK3588 上因为这颗芯片的资料版本差异很大这一步翻车的概率不低。2.1 先确认板子上的驱动与库状态第一步不是装东西是查现状。因为在很多厂商的出厂系统里MPP 的库和头文件其实已经预装了你在那儿吭哧吭哧编译半天最后发现装了个重复版本反而把原来的搞坏了。# 看有没有现成的 MPP 动态库 ls -l /usr/lib/aarch64-linux-gnu/librockchip_mpp* ls -l /usr/lib/librockchip_mpp* # 看头文件在不在 ls /usr/include/rockchip/ # 看 VPU 设备节点 ls -l /dev/mpp_service ls -l /dev/rkvdec /dev/rkvenc 2/dev/null # 看内核日志里 VPU 有没有正常起来 dmesg | grep -iE rkvdec|mpp|vpu|hantro/dev/mpp_service存在说明走的是 Rockchip 私有驱动路径MPP 用linux分支的代码就能对上。如果只有/dev/video*而找不到 mpp_service说明内核已经切到上游 V4L2 驱动这时候你需要 MPP 的新分支编译参数和头文件都有差异。下面这个命令能直接看到 VPU 的当前状态和负载cat /sys/kernel/debug/mpp_service/session_summary cat /sys/kernel/debug/mpp_service/vpu_status 2/dev/null如果这两个文件不存在先mount -t debugfs none /sys/kernel/debug挂上再看。多路解码调优时这个文件是你的“仪表盘”一定要先确认能读到。注意不同厂商 SDK 里 debugfs 节点名可能不同有的叫rkvdec、有的叫mpp_service。用find /sys/kernel/debug -maxdepth 2 -iname *vpu* -o -iname *mpp*找一遍最稳。2.2 编译安装 MPP 与触摸到 Python如果确认系统里没有或者版本太老就自己编译。这里有个原则MPP 库版本和内核驱动版本要对得上。出厂 SDK 一般会附带一份匹配的 MPP 源码优先用它别急着去拉最新主线。sudo apt update sudo apt install -y cmake build-essential git pkg-config git clone https://github.com/rockchip-linux/mpp.git cd mpp git branch -a # 看清楚有哪些分支选和你内核匹配的 mkdir build cd build cmake .. -DRKPLATFORMON -DHAVE_DRMON -DCMAKE_BUILD_TYPERelease make -j$(nproc) sudo make install sudo ldconfig编译完之后先别急着写 Python用官方自带的mpi_dec_test验一遍硬件通路。这一步的意义在于做变量隔离如果 C 层的测试工具都跑不出画面那你后面写 Python 只会多一层排查难度。# 生成一段测试用的 H.264 码流 ffmpeg -f lavfi -i testsrcsize1920x1080:rate25 -t 10 \ -c:v libx264 -profile:v baseline -pix_fmt yuv420p test.h264 # 用 MPP 自带工具硬解输出到文件 ./test/mpi_dec_test -i test.h264 -t 7 -n 100 -o out.yuv-t 7是 H.264 的编码类型枚举值-t 15对应 H.265这两个数字请用grep -rn MPP_VIDEO_CodingAVC /usr/include/rockchip/核对一遍不同版本枚举顺序偶尔会动。如果这一步能出out.yuv并且体积正常说明 VPU、驱动、MPP 三者是通的接下来只是 Python 封装的问题。2.3 Python 侧的准备工作Python 这边其实没什么玄学核心就是确认架构和位数对上。RK3588 上跑的是 aarch64如果你从 x86 机器上拷过来的虚拟环境ctypes 加载会直接报wrong ELF class。python3 -c import platform; print(platform.machine()) # 应该输出 aarch64 python3 -c import ctypes lib ctypes.CDLL(librockchip_mpp.so.1) print(MPP loaded ok) 第二条命令如果打印MPP loaded ok恭喜Python 已经摸到 MPP 了。剩下的全是接口封装的工作。推荐用系统自带的 Python3或者用 venv 建一个干净环境。不建议在 RK3588 上用 conda那个东西在 arm64 上的包完整度一直是个坑而且体积大。装 numpy 就够了sudo apt install -y python3-numpy python3-dev实操心得很多人习惯在 PC 上用 venv 然后整个目录拷到板子上这个做法在涉及 ctypes 加载.so的时候很容易出问题因为LD_LIBRARY_PATH和RPATH都会被一起带过去。最稳的方式是在板子上重建环境或者用python3 -m venv --copies避免符号链接跨机器失效。3. Python 侧接口设计三条路线怎么选到这一步有个岔路口Python 调 RK3588 硬解其实有三条现成的路选错了后面会很痛苦。我把三条路线的对比列出来你可以根据自己的场景对号入座。3.1 三条技术路线的取舍路线实现方式上手难度可控性8 路并发表现适用场景GStreamermppvideodec插件 python3-gi低中好但管线调试复杂需要推 RTSP、做简单转发的场景FFmpegh264_rkmpp解码器 subprocess低低中进程开销大快速验证、离线转码ctypes 直连 MPP手写librockchip_mpp封装高最高最优零拷贝可控需要接 NPU 推理、要拿 dma_fd讲真如果你只是想“把 USB 摄像头转成 RTSP 流推出去”GStreamer 那条路是最省事的一条gst-launch-1.0命令行就完事Python 只是起个进程管理的作用。但一旦你要做“解码后直接喂给 NPU 跑 YOLOv8”那 ctypes 直连几乎是唯一选择因为只有它能让你拿到帧的dma_fd做到零拷贝转发给 RGA 或者 RKNN。这篇的重点放在第三条路上因为它是 8 路场景下最能压榨性能的做法。前两条路在需要的时候可以作为对照方案。3.2 ctypes 封装里不能写错的几个字段这是整篇内容里最容易翻车的地方我要重点讲。MPP 的头文件里MppApi是一个包含大量函数指针的结构体它的字段是按顺序排列的。ctypes 的Structure是按字段定义顺序计算偏移的所以只要你的字段顺序和头文件不一致或者漏掉某个字段后面所有函数指针的偏移全错。后果是什么程序不会立刻崩它会调用到一个错误的地址通常在decode_get_frame时给你一个段错误。先看核心的几个函数签名MPP_RET mpp_create(MppCtx *ctx, MppApi **mpi); MPP_RET mpp_init(MppCtx ctx, MppCtxType type, MppCodingType coding); MPP_RET mpp_destroy(MppCtx ctx); MPP_RET mpp_packet_init(MppPacket *packet, void *data, size_t size); MPP_RET mpp_packet_deinit(MppPacket *packet); MPP_RET mpp_frame_deinit(MppFrame *frame);对应的 Python 定义长这样import ctypes as C from ctypes import c_int, c_void_p, c_uint32, c_size_t, POINTER, CFUNCTYPE MPP_OK 0 MPP_ERR_TIMEOUT -14 # 以头文件 mpp_err.h 为准 MPP_CTX_DEC 0 MPP_VIDEO_CodingAVC 7 # H.264 MPP_VIDEO_CodingHEVC 15 # H.265 # 函数指针类型(MppCtx, ...) - MPP_RET PutPacketT CFUNCTYPE(c_int, c_void_p, c_void_p) GetFrameT CFUNCTYPE(c_int, c_void_p, POINTER(c_void_p)) ControlT CFUNCTYPE(c_int, c_void_p, c_int, c_void_p) ResetT CFUNCTYPE(c_int, c_void_p)然后是最关键的MppApi结构体。这里我需要给你一个自检技巧比死记字段顺序靠谱得多class MppApi(C.Structure): pass MppApi._fields_ [ (size, c_uint32), (version, c_uint32), (decode, c_void_p), (decode_put_packet, PutPacketT), (decode_get_frame, GetFrameT), (encode, c_void_p), (encode_put_frame, c_void_p), (encode_get_packet, c_void_p), (isp, c_void_p), (isp_put_frame, c_void_p), (isp_get_frame, c_void_p), (jpegdec, c_void_p), (jpegenc, c_void_p), (jpegdec_put_packet, c_void_p), (jpegdec_get_frame, c_void_p), (jpegenc_put_frame, c_void_p), (jpegenc_get_packet, c_void_p), (control, ControlT), (reset, ResetT), # 后面还有 poll 等字段视版本而定 ]自检的诀窍在这里MPP 在mpp_create内部会把真实的sizeof(MppApi)写进api-size字段。所以你创建完上下文之后先读一下mpi.contents.sizelib C.CDLL(librockchip_mpp.so.1) ctx c_void_p() mpi POINTER(MppApi)() ret lib.mpp_create(C.byref(ctx), C.byref(mpi)) print(mpp_create:, ret) print(库内 sizeof(MppApi) , mpi.contents.size) print(我这边 sizeof(MppApi) , C.sizeof(MppApi))如果两个数字对不上说明你的字段翻译有问题别往下走了先解决这个。这个技巧能帮你省掉几个小时的段错误排查。注意size对不上不一定意味着control之前的部分错了因为后面新增的字段一般追加在尾部但保险起见还是从头文件逐字段核对一遍。头文件路径通常在/usr/include/rockchip/mpp.h或者/usr/include/rockchip/rk_mpi.h。3.3 8 路的线程模型怎么切线程模型决定了你的 8 路能不能稳定跑这里有三种常见切法第一种是一路一线程读包与解码合一。每个线程负责一路的码流读取和put_packet/get_frame循环。优点是逻辑简单、状态隔离干净缺点是 Python 读取文件或网络的动作会拖慢解码轮询节奏。第二种是一路两线程读包与解码分离中间用queue.Queue缓冲。读包线程只管把 NAL 单元切好塞队列解码线程只管从队列取包送 MPP。优点是解码线程的节奏非常稳定不会被 IO 抖动影响缺点是多了一倍的线程数和队列拷贝开销。第三种是多进程每路一个独立进程。绕开 GIL 最彻底但进程间传递 dma_fd 很麻烦而且 8 个 Python 进程的内存占用会上去不少。在 8 路 1080p 这个量级上我实测下来第二种最好用。因为解码线程的轮询间隔直接决定延迟如果它被文件读取阻塞会看到明显的帧抖动。队列长度控制在 8 到 16 个包就够太长了反而增加延迟和内存。import threading, queue class Decoder(threading.Thread): def __init__(self, ch_id, stream_src): super().__init__(daemonTrue, namefdec-{ch_id}) self.ch_id ch_id self.src stream_src self.q queue.Queue(maxsize16) self.running True self.reader threading.Thread(targetself._read_loop, daemonTrue) def _read_loop(self): # 把码流按 NAL 边界切开塞进队列 ...4. 单路解码打通从码流到一帧 NV128 路的基础是 1 路。这一节把单路的完整流程走一遍代码都是可以直接改改就用的。流程本身不复杂但每一步的释放顺序必须严格对称否则多路跑起来就是慢性内存泄漏。4.1 创建上下文与初始化的正确顺序初始化的顺序是固定的mpp_create-mpp_init- 设置参数 - 建 buffer group - 开始送包。中间任何一步失败都要把前面的资源原路释放掉。def create_decoder(coding_type): ctx c_void_p() mpi POINTER(MppApi)() ret lib.mpp_create(C.byref(ctx), C.byref(mpi)) if ret ! MPP_OK: raise RuntimeError(fmpp_create failed: {ret}) # 关键自检 if mpi.contents.size ! C.sizeof(MppApi): lib.mpp_destroy(ctx) raise RuntimeError(MppApi layout mismatch, check header) ret lib.mpp_init(ctx, MPP_CTX_DEC, coding_type) if ret ! MPP_OK: lib.mpp_destroy(ctx) raise RuntimeError(fmpp_init failed: {ret}) # 解析模式让 MPP 内部处理 NAL 拆分 # 具体枚举值用 grep MPP_DEC_SET_PARSER_SPLIT_MODE 核对 mpi.contents.control(ctx, MPP_DEC_SET_PARSER_SPLIT_MODE, None) return ctx, mpi这里有个容易忽略的点MPP_DEC_SET_PARSER_SPLIT_MODE这个控制项不设置也能跑但设了之后 MPP 内部的 parser 行为会更符合预期尤其是多路场景下能减少你在 Python 层做 NAL 切分的负担。4.2 送包与取帧的核心循环这是整个解码器的心脏。标准的写法是“能送就送能取就取”两边都不阻塞。def decode_loop(ctx, mpi, packet_source, on_frame): frame c_void_p() while True: # 1) 尝试送一个包 pkt packet_source.next_packet() if pkt is not None: packet c_void_p() # 注意这里不拷贝数据直接引用 Python bytes 的缓冲区 buf (C.c_char * len(pkt)).from_buffer_copy(pkt) r lib.mpp_packet_init(C.byref(packet), buf, len(pkt)) if r MPP_OK: ret mpi.contents.decode_put_packet(ctx, packet) if ret ! MPP_OK: # 送不进去说明内部队列满了稍后重试 lib.mpp_packet_deinit(C.byref(packet)) # packet 交给 MPP 后不要再手动释放由 MPP 管理生命周期 # 2) 尝试取一帧 ret mpi.contents.decode_get_frame(ctx, C.byref(frame)) if ret MPP_ERR_TIMEOUT or not frame.value: time.sleep(0.001) continue if ret ! MPP_OK: continue try: handle_frame(frame) finally: lib.mpp_frame_deinit(C.byref(frame)) frame c_void_p()这里面有三个细节值得单独拎出来说。第一个是 packet 的所有权。mpp_packet_init之后把 packet 交给decode_put_packet成功之后这个 packet 就归 MPP 管了它会在用完后自己 deinit。如果你在送成功之后又手动mpp_packet_deinit那就是双重释放跑几十秒就会崩。反过来如果decode_put_packet返回失败packet 还在你手上这时必须自己 deinit否则每失败一次就漏一份。第二个是 frame 的所有权。decode_get_frame拿到的 frame必须由你调用mpp_frame_deinit归还。这一条是 8 路场景下最常见的坑单路的时候不还也没事因为 buffer 池大8 路的时候每路都要从池子里取帧你不还池子很快见底然后解码线程就永远阻塞在decode_get_frame返回 timeout 的状态表现为“画面卡住不动”。第三个是返回值的语义。decode_get_frame返回 timeout 是正常现象不是错误说明当前没有解码完成的帧。所以循环里的time.sleep(0.001)很重要避免空转把 CPU 烧掉。4.3 读帧信息与 stride 处理拿到 frame 之后你需要从里面读出真正的像素数据。这里 stride 是最容易出错的地方。def handle_frame(frame): w lib.mpp_frame_get_width(frame) h lib.mpp_frame_get_height(frame) hs lib.mpp_frame_get_hor_stride(frame) vs lib.mpp_frame_get_ver_stride(frame) fmt lib.mpp_frame_get_fmt(frame) info_change lib.mpp_frame_get_info_change(frame) if info_change: # 分辨率变化需要重新分配外部 buffer group print(finfo changed - {w}x{h}) buf lib.mpp_frame_get_buffer(frame) if not buf: return ptr lib.mpp_buffer_get_ptr(buf) fd lib.mpp_buffer_get_fd(buf) import numpy as np y_size hs * vs uv_size hs * vs // 2 raw np.ctypeslib.as_array( (C.c_uint8 * (y_size uv_size)).from_address(ptr) ) yuv raw.reshape((vs * 3 // 2, hs)) # 裁掉 stride 填充的右侧边缘 picture yuv[:, :w]关键理解hor_stride通常大于等于widthRK3588 上一般是 16 或 64 字节对齐。NV12 的内存布局是先一整块 Y 平面再一整块 UV 交错平面UV 平面紧跟在 Y 平面之后偏移量就是hor_stride * ver_stride。如果你直接用width * height去算偏移画面会出现明显的斜切错位这个现象非常有辨识度看到就能立刻定位到 stride 问题。mpp_frame_get_info_change这一项在 8 路场景里必须处理。原因是每路流的实际分辨率可能中途变化尤其是网络摄像头切码流时如果此时你没有重新建 bufferMPP 会返回一个尺寸不匹配的帧轻则画面花重则直接崩。4.4 外部分配 buffer 与 dma_fd 零拷贝默认情况下MPP 会自己管理内部 buffer。这在单路场景下没问题但 8 路的时候有麻烦内部 buffer 的总量不可控而且你不知道它什么时候会去申请新的。更专业的做法是用外部分配通过mpp_buffer_group_get_external或者设置MPP_DEC_SET_EXT_BUF_GROUP把 buffer group 提前建好并限定上限group c_void_p() # 1080p NV12 一帧约 3MB给 20 帧余量 lib.mpp_buffer_group_get_external(C.byref(group), MPP_BUFFER_TYPE_DRM) lib.mpp_buffer_group_limit_config(group, 0, 20) # 再通过 control 把 group 挂到解码器上这么做有两个实打实的好处。一是内存上限可控8 路各 20 帧、每帧 3MB总共约 480MB这个数字你心里有数不会跑着跑着 OOM。二是零拷贝外部 buffer 用的是 DRM/DMA heapmpp_buffer_get_fd拿到的 fd 可以直接传给 RGA 做缩放或者传给 RKNN 做推理输入不需要在 CPU 上再拷一遍。这一条对“解码后接 NPU”的场景是决定性的能省掉的拷贝量是每秒几百 MB 级别。提示用 DRM buffer 时要注意 IOMMU 的地址空间限制。RK3588 有 IOMMU通常 4GB 地址空间够用但如果你模型那边也占了大量地址要做一下核算。没开 IOMMU 的情况下受 CMA 大小限制用cat /proc/meminfo | grep Cma看一眼余量。5. 从 1 路扩到 8 路资源预算与调优单路通了之后扩到 8 路不是简单地把循环跑 8 遍。真正要处理的是资源争抢和调度公平性。5.1 buffer 数量的预算算法先做算术。1080p 的 NV12 帧大小是1920 * 1088 * 1.5 ≈ 3.13MB注意这里用的是 stride 对齐后的尺寸不是 1080。每一路解码器需要的 buffer 数量大致是参考帧H.264 通常 4 到 8 帧H.265 通常 8 到 16 帧取决于码流里声明的 DPB 大小输出队列建议留 4 到 8 帧给下游消费留缓冲解析中间态2 到 4 帧保守估计取 20 帧那么单路就是 63MB 左右8 路加起来 500MB。这个数字必须在你的板子内存预算里留出来别到时候 NPU 模型一加载就触发 OOM Killer。FRAME_SIZE_1080P 1920 * 1088 * 3 // 2 # 3.13 MB BUFS_PER_CH 20 CH 8 total FRAME_SIZE_1080P * BUFS_PER_CH * CH print(f预计解码内存: {total/1024/1024:.0f} MB) # 预计解码内存: 501 MB如果内存紧张可以把 BUFS_PER_CH 降到 12但要注意这会牺牲抗抖动能力——下游如果偶尔消费慢了buffer 就会见底。降之前先用mpp_buffer_group_limit_config做个压力测试。5.2 CPU 亲和性与线程优先级RK3588 是 4 个 A76 大核 4 个 A55 小核的架构。8 路解码线程加上读包线程总共 16 个线程如果让调度器随便放很可能出现几个高负载线程都挤在同一个 A55 上表现就是某些路频繁丢帧。做法是把解码线程绑到大核上读包线程绑到小核上import os def pin_to_cpu(idx): try: os.sched_setaffinity(0, {idx}) return True except AttributeError: return False # 大核 CPU 4-7小核 CPU 0-3不同内核版本编号可能不同判断大小核编号有个简单办法看/sys/devices/system/cpu/cpu*/cpufreq/cpuinfo_max_freq数值高的那几个就是大核。for c in /sys/devices/system/cpu/cpu[0-9]; do echo $(basename $c) $(cat $c/cpufreq/cpuinfo_max_freq) done绑核的时候有个取舍8 路解码线程如果都绑到 4 个大核上会互相挤反而可能不如让调度器自己分配。我的经验是8 路以内绑核收益明显超过 8 路就不绑了。另外线程优先级不要随便调 realtime容易把系统其他部分饿死。5.3 实测数据与瓶颈定位这是我用 8 路 1080p H.264 主码流4MbpsGOP 50压测的一组数据供你对照指标数值说明平均解码延迟38ms从送包到出帧单路帧率稳定 25fps无丢帧8 路总帧率200fps满帧CPU 总占用约 220%即 2.2 个核心内存占用约 620MB含 Python 与 numpy单帧解码耗时1.4ms纯 VPU 侧定位瓶颈的方法很简单把 Python 侧的动作一项项砍掉看变化。比如把读文件换成从内存直接喂、把 numpy 后处理注释掉、把日志关掉。每砍一项看 CPU 占用降多少降得最多的那项就是瓶颈。我先说结论日志是最大的隐形杀手。8 路每秒 200 帧如果你每帧打印一行光是格式化字符串和写终端就能吃掉一个核心。跑压力测试前一定要把日志级别调高。第二个容易被忽略的点是time.sleep(0.001)这个轮询间隔。8 路的话每路每秒轮询 1000 次总共 8000 次每次都是一次系统调用。如果不需要低延迟把它调到 0.005 或者用条件变量唤醒能省下不少 CPU。6. 踩坑实录8 路解码常见问题速查这一节是我实际调试过程中真正遇到过的坑基本都是官方文档不会写、但一定会撞上的。6.1 画面异常类问题画面整体发绿或者发紫。这个现象几乎可以百分之百判断为 NV12 的 UV 平面排布理解错了。NV12 是 Y 平面之后跟着 UV 交错U 在前 V 在后而 NV21 是 VU 交错。如果 MPP 输出的是 NV12 而你按 NV21 解读颜色就会整体偏。解决办法是先打印mpp_frame_get_fmt确认实际格式再按对应方式解析。RK3588 上 MPP 默认输出MPP_FMT_YUV420SP也就是 NV12但也有版本会输出MPP_FMT_YUV420SP_VU这两个值差 1很容易看漏。画面右侧有一条彩色竖条。典型的 stride 没裁干净。hor_stride比width大的部分填充的是未定义数据如果你把整行都当成有效像素右侧就会出现杂色条。裁掉picture[:, :w]就好。画面斜切错位、像被拧了一下。这个是 UV 平面的起始偏移算错了。正确偏移是hor_stride * ver_stride很多人用width * height差了 stride 对齐的那部分误差会随着行数累积越往下越歪。偶发花屏几帧之后自己恢复。通常是送包的时候 NAL 边界切错了。MPP 内部的 parser 对完整性的要求比较严格如果你的切包把一个 NAL 拆成两半送进去解码器会输出一帧错误数据然后自己重新同步。解决方式是切包时严格按00 00 00 01起始码切并且在 SPS/PPS 之前不要断开。6.2 资源与性能类问题跑一段时间后画面集体卡住。大概率是 frame 没归还。回顾第 4.2 节的要点decode_get_frame拿到的帧必须mpp_frame_deinit。如果用了 try/finally 还出现这个问题检查一下异常路径是不是跳过了 finally。内存一路涨不回落。检查两处一是mpp_packet_init失败时有没有 deinit二是mpp_buffer_group有没有在销毁解码器之前 release。顺序很重要先mpp_destroy再mpp_buffer_group_put反过来会崩。某一路帧率明显低于其他路。通常是线程绑核导致的两路解码线程被绑到了同一个核上。检查一下sched_getaffinity的返回值。如果没有手动绑核那可能是那一路的码流本身码率波动大读包线程跟不上。给读包队列加大一点缓冲看看。8 路一起启动时前几秒大量丢帧。这是正常的预热抖动。MPP 要为每路建立 buffer 池、初始化硬件上下文第一秒的延迟会明显偏高。建议在正式处理前先跑 2 秒的预热把首帧延迟消化掉。6.3 问题速查表现象最可能的原因首选排查动作整体偏绿偏紫NV12/NV21 混淆打印mpp_frame_get_fmt右侧彩条stride 未裁剪用hor_stride定位并裁剪画面斜切UV 偏移算错改用hor_stride * ver_stride偶发花屏NAL 切包错误检查起始码切分逻辑跑一会儿全卡frame 未归还检查mpp_frame_deinit内存持续增长packet 失败未释放检查失败分支的 deinit单路帧率偏低线程绑核冲突查看 CPU 亲和性设置启动初期丢帧buffer 预热加 2 秒预热阶段段错误崩溃MppApi 字段错位比对api-size与sizeof这张表里我建议你重点记住第一行和最后一行。一个解决的是“不知道为什么画面不对”一个解决的是“程序直接崩了没法查”。这两类问题在刚上手的时候出现频率最高。实操心得调试多路解码时永远从 1 路开始跑通了再加到 2 路然后再到 8 路。很多人一上来就跑 8 路结果遇到问题根本分不清是单路的 bug 还是并发的 bug排查时间会翻好几倍。这个习惯能省你很多个晚上。7. 再往前一步解码之后能接什么8 路解码跑通只是起点真正产生价值的是解码出来的帧怎么用。最直接的用法是把dma_fd交给 RGA 做缩放和格式转换比如把 1080p 缩到 640x640 的 RGB 给 YOLOv8 用。因为两者都是 DMA buffer整个过程不需要 CPU 参与搬运这叫零拷贝管线。RK3588 上跑 8 路 1080p 解码加上几路推理是完全做得到的。另一个方向是把解码帧重新编码后推流。rkvenc走 MPP 的编码路径和解码用的是同一套 buffer 管理思路mpp_buffer_get_fd拿到的 fd 可以直接喂给编码器避免了先解码到内存再读到编码器的两次拷贝。还有一个容易被忽视的方向是状态监控。8 路长期运行你需要知道每一路的实时帧率、丢帧数、buffer 余量。这些数据从/sys/kernel/debug/mpp_service能拿到一部分剩下的得自己在 Python 里统计。我习惯每 5 秒打一次汇总而不是每帧都打这样既不干扰性能又能及时发现异常。我个人在 8 路这个场景上折腾了挺久最后沉淀下来最有价值的一条经验是把所有和 MPP 交互的动作都收进一个薄薄的封装层业务代码只跟这个层打交道。因为 MPP 的接口在不同 SDK 版本之间是有差异的尤其是枚举值和MppApi的字段。你把差异全部收敛到几百行的封装文件里换 SDK 的时候只改那一处业务代码一行都不用动。这个结构在项目后期维护上省下的时间远超前期多写那几百行的成本。
返回列表