使用Nvidia Video Codec(三) NvDecoder
Nvidia GPU中集成了编码和解码模块,它们都是独立的硬件,独立于cuda核心。对应的API,分别叫做NVENC API和NVDEC API,属于cuda driver api。对应的库分别为libnvidia-encode.so
,nvencodeapi.lib
(windows下貌似只有静态库)和libnvcuvid.so
,nvcuvid.lib
(windows下貌似只有静态库)。
Video Codec SDK 下载,SDK的目录中包含了头文件,例子(samples),sdk 库文件,sdk文档。
使用编解码的sdk,建议直接使用samples中封装好的NvEncoderCuda
和NvDecoder
类。
这篇文章主要是结合sdk文档,分析NvDecoder
的实现,了解解码的流程及细节。
NvDecoder
NvDecoder
是samples中对NVDEC API的封装。使用它的流程是:创建NvDeocer
对象—> 调用Decode
方法,传入nalu数据–> 调用GetFrame
方法获取解码后的YUV数据。
构造函数
NvDecoder(CUcontext cuContext, bool bUseDeviceFrame, cudaVideoCodec eCodec, bool bLowLatency = false,bool bDeviceFramePitched = false, const Rect *pCropRect = NULL, const Dim *pResizeDim = NULL,int maxWidth = 0, int maxHeight = 0, unsigned int clkRate = 1000)
cuContext
需要绑定的CUcontext
的对象,NvDecoder封装的API是属于cuda driver层的API,所以它需要一个CUcontext
对象,要绑定到这个Context。bUseDeviceFrame
指定是否使用显存存储解码后的YUV数据(显存就是device memory),如果图像后处理需要使用cuda做颜色空间的转换或者需要再编码,那么将它置为true,表示使用显存,解码后处理的图片数据直接通过显存传输,可以提高性能。如果直接存为文件,就没必要使用显存了,设置位falsebDeviceFramePitched
指定是否通过cuMemAllocPitch
来分配内存,上面已经提了cuMemAllocPitch
的特点。pCropRect
指定对图像的剪裁区域,这个剪裁操作在解码时就做了,并不需要额外的图像剪裁后处理。pResizeDim
指定缩放,这个缩放操作也是在解码时就做了,并不需要额外的图像缩放后处理。
在构造函数中调用如下NVDEC API:
cuvidCreateVideoParser
这个API是对视频流进行分析,比如判断视频流中sps,pps,idr等一些关键帧,还判断当前视频流数据是否可以解码,需要一个CUVIDPARSERPARAMS
参数,如下调用方式:
CUVIDPARSERPARAMS videoParserParameters = {};
videoParserParameters.CodecType = eCodec;
videoParserParameters.ulMaxNumDecodeSurfaces = 1;
videoParserParameters.ulClockRate = clkRate;
videoParserParameters.ulMaxDisplayDelay = bLowLatency ? 0 : 1;
videoParserParameters.pUserData = this;
videoParserParameters.pfnSequenceCallback = HandleVideoSequenceProc;
videoParserParameters.pfnDecodePicture = HandlePictureDecodeProc;
videoParserParameters.pfnDisplayPicture = HandlePictureDisplayProc;
videoParserParameters.pfnGetOperatingPoint = HandleOperatingPoinNVDEC_API_CALL(cuvidCreateVideoParser(&m_hParser, &videoParserParameters));
pfnSequenceCallback
在initial sequence header 或者 video format 变化时,会触发这个回调,其中会调用cuvidCreateDecoder
创建解码器。
这个initial sequeue header是什么意思?我开始的理解是sps,pps这些信息,就是在GOP的头几帧,但是这个callback只会触发一次,如果是sps,pps这些信息,应该是触发多次(也许NvDecoder内部作了判断)。
video format变化指的是分辨率变化。
pfnDecodePicture
在这个回调中Parser
将可解码的一帧数据给到解码器解码,其中会调用cuvidDecodePicture
解码。
pfnDisplayPicture
在这个回调中获取解码后的数据,其中会调用cuvidMapVideoFrame
取出解码器中解码后的数据。
创建解码器,解码,取解码后的数据,由这三个回调串联起来。
Decode
方法
Parser
和Decoder
创建后,就需要靠数据驱动了,通过Decode
方法传入数据。
int NvDecoder::Decode(const uint8_t *pData, int nSize, int nFlags, int64_t nTimestamp)
将nalu数据传入到Decode
方法,最终传到了Parser
的cuvidParseVideoData
方法,整个解码流程就被驱动起来,如下:
NvDecoder::Decode
—>cuvidParseVideoData
—>pfnDecodePicture
中调用cuvidCreateDecoder
创建解码器(只会第一次触发或分辨率变化后触发)—>pfnDecodePicture
中调用cuvidDecodePicture
—>pfnDisplayPicture
中调用cuvidMapVideoFrame
取出解码后的数据。
生产者和消费者
在文档中提到了,将解码和从解码器中取数据的操作,可以分到不同线程:一个解码线程(包含parser和decode操作),一个叫map(取数据)线程(指的是cuvidMapVideoFrame
操作) ,中间通过一个消息队列进行交互,消息队列中存放的是解码好的图片picture_index
,这个成员变量定义在CUVIDPARSERDISPINFO
(pfnDisplayPicture
回调函数的形参)中。
解码缓存和解码输出缓存
在解码器的实现中,解码是生产者,cuvidMapVideoFrame
是消费者,它们中间有一个缓存(解码器内部的缓存),叫做DecodeSurface,在pfnDisplayPicture
的回调中,该回调的形参CUVIDPARSERDISPINFO
中的变量picture_index
,指示的就是哪个解码后的数据在哪个**解码缓存(DecodeSurface)**中。在随后通过cuvidMapVideoFrame
将指定的picture_index
图像数据映射出来,就可以拷贝了。**解码缓存(DecodeSurface)**它的个数由ulNumDecodeSurfaces
指定,该变量定义在CUVIDDECODECREATEINFO
(cuvidCreateDecoder
的形参)。
但是CUVIDDECODECREATEINFO
(cuvidCreateDecoder
的形参)中还有一个ulNumOutputSurfaces
值,它指定了应用程序可以使用的**解码输出缓存(OutputSurfaces)**的个数。
ulNumDecodeSurfaces
和ulNumOutputSurfaces
ulNumDecodeSurfaces
是指解码器内部使用的缓存数量,ulNumOutputSurfaces
解码器输出的缓存数量,那两个缓存有什么区别呢?
还有一个问题,在pfnDisplayPicture
回调中的picture_index
范围却由ulNumDecodeSurfaces
确定,这有点让人迷惑,既然ulNumOutputSurfaces
是输出缓存的数量,picture_index
是应该由它指定。
ulNumOutputSurfaces
在官方文档中的解释
This is the maximum number of output surfaces that the client will simultaneously map to decode surfaces for further processing using cuvidMapVideoFrame()
只是说可以同时最大使用的OutputSurfaces的个数,没有交代这个OutputSurfaces具体是什么,也没有说picture_index
与它的关系。
问了下DeepSeek,下面是它给出的说明:
CUVID 没有为解码和输出分别维护两套完全独立的物理缓冲区池。相反,它采用了一种更高效的方式,将解码缓冲区(由
ulNumDecodeSurfaces
定义)作为主缓冲区池,并直接将解码结果的索引暴露给应用程序。CUVID 内部的工作流程
- 分配缓冲区: 在初始化时,CUVID 驱动程序会根据你和编码流的信息,分配一个由
ulNumDecodeSurfaces
数量决定的物理缓冲区池。- 解码操作: 当一帧数据被解码时,解码器会使用这个池中的一个缓冲区作为目标。
- 完成通知: 当一帧解码完成,CUVID 通过回调函数通知应用程序。这个通知中包含的
nPicIdx
,实际上就是这个解码缓冲区在物理池中的索引。- 映射操作: 当你调用
cuvidMapVideoFrame
并传入这个nPicIdx
时,驱动程序会直接将对应索引的那个物理解码缓冲区映射给你的应用程序。
**ulNumDecodeSurfaces**
:它的物理意义是决定了物理缓冲区池的总大小。它的逻辑意义是确保解码器有足够的缓冲区来处理帧间依赖。它通常是只读的,由驱动程序根据编码流自动计算。**ulNumOutputSurfaces**
:它不代表一个独立的物理缓冲区池。它的逻辑意义是告诉驱动程序,应用程序需要同时“持有”多少个已完成的帧。例如,如果ulNumOutputSurfaces
设置为 3,那么驱动程序在任何时候都会确保应用程序最多只持有 3 个来自解码池的索引。当应用程序试图获取第 4 个帧时,如果前 3 个帧还没有被cuvidUnmapVideoFrame
释放,驱动程序就会阻塞,直到有空闲的缓冲区。
我又追问了一个问题:设置 ulNumDecodeSurfaces
为10,设置 ulNumOutputSurfaces
为2的意义?
**ulNumDecodeSurfaces = 10**
:
- 这定义了CUVID 驱动程序内部的物理缓冲区池大小为 10。所有解码操作都会在这个由 10 个缓冲区组成的池中进行。
- 这个值由驱动程序根据视频流的编码特性(如参考帧数量)来决定,确保解码过程可以顺利进行。
**ulNumOutputSurfaces = 2**
:
- 这定义了你的应用程序可以同时“持有”或访问的已解码帧的最大数量。这个“持有”是通过
cuvidMapVideoFrame()
函数实现的。- 你的应用程序可以请求映射一帧数据,当它完成处理后,必须调用
cuvidUnmapVideoFrame()
来释放这个“持有权”。- 如果你已经映射了 2 个帧,并且还没有解除任何一个的映射,那么当你试图再次调用
cuvidMapVideoFrame()
时,这个调用会阻塞,直到其中一个缓冲区被你释放。简单来说:
ulNumOutputSurfaces = 2
允许解码器为你准备 2 个可以随时访问的帧。但是,cuvidMapVideoFrame
是一个“取货”操作,就像在仓库门口排队取货一样,你还是需要串行地去拿取。你可以拿完一个,处理它,然后再拿下一个。这个 2 的值确保了当你去拿第二个时,它可能已经准备好了,不需要等待解码器。总结
ulNumDecodeSurfaces
决定了**底层有多少个“物理工位”**供解码器使用。
ulNumOutputSurfaces
决定了你的应用程序可以从这些工位中同时“取走”多少个“成品”。因此,设置
ulNumOutputSurfaces = 2
和ulNumDecodeSurfaces = 10
的意思是:
- 解码器内部有 10 个可用的缓冲区,以确保解码过程不会因为缺少参考帧而卡顿。
- 应用程序最多可以同时访问其中 2 个缓冲区。这确保了应用程序不会因为获取太多数据而导致内存管理困难,同时也平滑了与解码器之间的速度差异。
总结下:解码缓存和解码输出缓存是同一个缓存,通过**ulNumDecodeSurfaces**
和**ulNumOutputSurfaces**
是同一个缓存有两种不同的用处。
既然是ulNumOutputSurfaces
是指同时可以访问的缓存的个数,那么是否可以用多个线程去同时取这些值呢?我又问了下DeepSeek
** 设置ulNumOutputSurfaces为2,是否意味着可以同时用两个CPU线程调用 cuvidMapVideoFrame ?**
不能,将
ulNumOutputSurfaces
设置为 2 **并不意味着你可以同时在两个不同的 CPU 线程上调用 ****cuvidMapVideoFrame**
来获取不同的帧。为什么不能?
cuvidMapVideoFrame
是一个同步操作。虽然解码线程和你的应用程序线程是异步的,但对cuvidMapVideoFrame
的调用通常应遵循一个单线程模型,或者至少是串行访问。这是因为:
- 线程安全: NVDEC CUVID API 的设计通常不是为了在多线程环境下同时调用同一个对象的函数而设计的。
cuvidMapVideoFrame
可能会访问内部共享状态或数据结构,如果多个线程同时调用它,可能会导致竞态条件(race condition)或未定义的行为。- 函数作用:
cuvidMapVideoFrame
的作用是获取一个已准备好的帧,而不是获取所有已准备好的帧。你的应用程序通常在收到解码完成的回调(pfnDecodePicture
)后,在同一个线程中调用它来获取该帧。推荐的工作流
标准且安全的做法是:
- 单一线程处理: 你通常会有一个主处理线程(或称“映射线程”),它负责从解码器获取已解码的帧。这个线程会循环等待解码完成的通知,然后调用
cuvidMapVideoFrame
来获取帧数据,进行后处理,最后调用cuvidUnmapVideoFrame
释放资源。- 多线程后处理: 如果你的应用程序需要多线程来处理数据,你应该在
cuvidMapVideoFrame
调用之后,将获取到的帧数据指针(或帧句柄)传递给另一个后处理线程。这样,多个线程可以并行处理不同的帧,而不会在 CUVID API 层面上产生冲突。简单来说:
ulNumOutputSurfaces = 2
允许解码器为你准备 2 个可以随时访问的帧。但是,cuvidMapVideoFrame
是一个“取货”操作,就像在仓库门口排队取货一样,你还是需要串行地去拿取。你可以拿完一个,处理它,然后再拿下一个。这个 2 的值确保了当你去拿第二个时,它可能已经准备好了,不需要等待解码器。
cuvidMapVideoFrame
在官方文档中解释了cuvidMapVideoFrame
的作用
cuvidMapVideoFrame() API takes decode surface index (nPicIdx) as input and maps it to one
of available output surfaces, post-processes the decoded frame and copy to output surface and
returns CUDA device pointer and associated pitch of the output surfaces.
将DecodeSurfaces中的数据map到OutputSurfaces,直白的理解是从一个缓存区到另外一个缓存区,但是经过上面对解码缓存区的解释。并不是两个缓冲区,而是只有DecodeSurfaces(解码缓存),OutputSurfaces(解码输出缓存)只是对它的复用。
ulNumOutputSurfaces
实际是限制cuvidMapVideoFrame
使用的DecoderSurfaces(解码缓存)的个数,操作后,需要cuvidUnmapVideoFrame
告诉驱动对应的缓存可用,否则达到可使用的输出缓存个数后,会阻塞解码操作。
那么将解码和map分为生产者和消费者的理由,在文档中说明如下:
cuvidMapVideoFrame() is a blocking call as it waits for decoding to complete. If
cuvidMapVideoFrame() is called on same CPU thread as cuvidDecodePicture(), it will
block cuvidDecodePicture() as well.In this case, the application will not be able to submit
decode packets to NVDEC until mapping is complete. It can be avoided by performing the
mapping operation on a CPU thread (referred as mapping thread) different from the one calling
cuvidDecodePicture() (referred as decoding thread).
关于cuvidMapVideoFrame
和cuvidDecodePicture
间的关系,我也问了下DeepSeek,它梳理了它们的具体操作,可以加深理解。
cuvidMapVideoFrame具体是做了什么工作?为什么说官方文档上是说明它等待解码完成,而会阻塞cuvidDecodePicture,那cuvidDecodePicture具体又是做了什么?
cuvidMapVideoFrame
的具体工作
cuvidMapVideoFrame
的核心工作是内存映射和同步,而不是数据复制。
- 等待和同步:
- 当你调用
cuvidMapVideoFrame
时,它首先会检查你指定的帧索引 (nPicIdx
) 是否已完成解码并且已完成所有后处理(如果配置了的话)。- 如果该帧尚未完全准备好(例如,解码器还在忙着处理它),
cuvidMapVideoFrame
会阻塞当前的应用程序线程,直到 GPU 上的解码和后处理任务完成。- 它等待的正是 GPU 上的工作完成。
- 内存映射 (Zero-Copy):
- 一旦帧准备就绪,它会将存储该帧数据的显存区域直接映射到你的应用程序的地址空间(或者一个 CUDA 可访问的指针)。
- 这样做实现了零拷贝(Zero-Copy),应用程序可以直接通过返回的指针访问显存中的数据,避免了从显存到系统内存或另一块显存的昂贵数据复制操作。
- 获取持有权:
- 成功映射后,应用程序就获得了该输出缓冲区的**“持有权”**。这意味着该缓冲区在被
**cuvidUnmapVideoFrame**
释放之前,不能被解码器重新用于存储新的解码输出。为什么
cuvidMapVideoFrame
会阻塞cuvidDecodePicture
?你看到的“
cuvidMapVideoFrame
阻塞cuvidDecodePicture
”的说法,其实是描述了两种线程之间资源竞争的后果,而不是直接的函数调用阻塞。这里的关键是**ulNumOutputSurfaces**
的限制。阻塞机制的本质:资源争夺
- 资源: 解码器和应用程序共享的资源是
ulNumDecodeSurfaces
定义的物理缓冲区池。**ulNumOutputSurfaces = 2**
** 的含义:** 这告诉驱动程序,应用程序最多只能同时“持有”池中的 2 个缓冲区。- 场景模拟:
- 应用程序已经通过
cuvidMapVideoFrame
持有了缓冲区 A 和 B。- 解码器(通过 GPU)完成了第 3 帧的解码,需要找一个空闲的缓冲区(例如 C)来存储结果。
- 当应用程序正在处理 A 和 B 时,解码器可以继续解码下一帧。但是,当解码器需要存储新的解码输出时,它必须使用一个未被应用程序持有的缓冲区。
- 如果应用程序持有的 A 和 B 碰巧是目前唯一空闲的两个缓冲区(或者说,解码器内部需要的参考帧也已填满),并且应用程序还没有调用
cuvidUnmapVideoFrame
释放它们,那么解码器线程在调用**cuvidDecodePicture**
尝试获取一个新的空闲解码表面时,就会发现所有表面都被占用了(要么被应用程序持有,要么被解码器作为参考帧占用)。- 结果:
cuvidDecodePicture
就会阻塞,直到应用程序释放一个它正在持有的输出表面(即调用cuvidUnmapVideoFrame
)。所以,
cuvidMapVideoFrame
本身是等待 GPU 完成工作,而它导致的持有行为,会间接导致解码线程(调用**cuvidDecodePicture**
的线程)在找不到空闲缓冲区时发生阻塞。
cuvidDecodePicture
具体做了什么工作?
cuvidDecodePicture
是将压缩的比特流数据提交给 NVIDIA 硬件解码器(NVDEC 单元)进行解码的函数。它的主要工作流程是:
- 提交比特流: 应用程序将一帧完整的压缩数据(如 H.264 NALU 或 HEVC AU)传递给
cuvidDecodePicture
。- 选择目标: 驱动程序在
ulNumDecodeSurfaces
缓冲区池中选择一个当前空闲且可用的表面,作为当前帧的解码输出目标。- 设置参数: 驱动程序将解码所需的全部参数(如参考帧列表、量化参数、Slice Header 等)发送给 NVDEC 硬件。
- 命令入队: 将解码任务放入 GPU 的命令队列中,供 NVDEC 单元执行。
- 异步返回:
cuvidDecodePicture
函数通常会立即返回,而不会等待 GPU 解码完成。解码任务在 GPU 上是异步进行的。总结:
cuvidDecodePicture
只是一个提交任务的 API;真正的解码工作是在 GPU 硬件上异步完成的。
在文档中还说明了,在多个视频流同时解码的情况,这种生产者-消费者的模式并不能提升效率,因为cuvidDecodePicture
会成为瓶颈,就是数据太多,硬解码器处理不过来,可能解码比map还慢,这个比较好理解,在生产者/消费者模式中,如果生产者很慢,那么这种多线程没什么意义。在samples中的解码,并没有采用生产者-消费者模式。
ulNumDecodeSurfaces
和ulNumOutputSurfaces
值的设置
理论上ulNumDecodeSurfaces
的值越大越好,但是越大,显存占用越大,但解码的效率并不一定提升,解码效率是取决与硬件,这个值只是设置解码缓存,并不是解码效率的核心因素。这个值的设置在文档中有说明,应该设置为parser返回的ulMaxNumDecodeSurfaces
值。
CUVIDDECODECREATEINFO::ulNumDecodeSurfaces = CUVIDPARSERPARAMS::ulMaxNumDecodeSurfaces
ulNumOutputSurfaces
推荐的值是2或者3,在samples中这个值被设置为了2。