使用Nvidia Video Codec(三) NvDecoder

Nvidia GPU中集成了编码和解码模块,它们都是独立的硬件,独立于cuda核心。对应的API,分别叫做NVENC API和NVDEC API,属于cuda driver api。对应的库分别为libnvidia-encode.sonvencodeapi.lib(windows下貌似只有静态库)和libnvcuvid.sonvcuvid.lib(windows下貌似只有静态库)。

Video Codec SDK 下载,SDK的目录中包含了头文件,例子(samples),sdk 库文件,sdk文档。

使用编解码的sdk,建议直接使用samples中封装好的NvEncoderCudaNvDecoder类。

这篇文章主要是结合sdk文档,分析NvDecoder的实现,了解解码的流程及细节。

NvDecoder

NvDecoder是samples中对NVDEC API的封装。使用它的流程是:创建NvDeocer对象---> 调用Decode方法,传入nalu数据--> 调用GetFrame方法获取解码后的YUV数据。

构造函数
cpp 复制代码
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,表示使用显存,解码后处理的图片数据直接通过显存传输,可以提高性能。如果直接存为文件,就没必要使用显存了,设置位false
  • bDeviceFramePitched指定是否通过cuMemAllocPitch来分配内存,上面已经提了cuMemAllocPitch的特点。
  • pCropRect指定对图像的剪裁区域,这个剪裁操作在解码时就做了,并不需要额外的图像剪裁后处理。
  • pResizeDim指定缩放,这个缩放操作也是在解码时就做了,并不需要额外的图像缩放后处理。

在构造函数中调用如下NVDEC API:

  • cuvidCreateVideoParser

这个API是对视频流进行分析,比如判断视频流中sps,pps,idr等一些关键帧,还判断当前视频流数据是否可以解码,需要一个CUVIDPARSERPARAMS参数,如下调用方式:

cpp 复制代码
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 = HandleOperatingPoin

NVDEC_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方法

ParserDecoder创建后,就需要靠数据驱动了,通过Decode方法传入数据。

cpp 复制代码
int NvDecoder::Decode(const uint8_t *pData, int nSize, int nFlags, int64_t nTimestamp)

将nalu数据传入到Decode方法,最终传到了ParsercuvidParseVideoData方法,整个解码流程就被驱动起来,如下:

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)**的个数。

ulNumDecodeSurfacesulNumOutputSurfaces

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 内部的工作流程

  1. 分配缓冲区: 在初始化时,CUVID 驱动程序会根据你和编码流的信息,分配一个由 ulNumDecodeSurfaces 数量决定的物理缓冲区池。
  2. 解码操作: 当一帧数据被解码时,解码器会使用这个池中的一个缓冲区作为目标。
  3. 完成通知: 当一帧解码完成,CUVID 通过回调函数通知应用程序。这个通知中包含的 nPicIdx,实际上就是这个解码缓冲区在物理池中的索引
  4. 映射操作: 当你调用 cuvidMapVideoFrame 并传入这个 nPicIdx 时,驱动程序会直接将对应索引的那个物理解码缓冲区映射给你的应用程序。
  • **ulNumDecodeSurfaces**:它的物理意义 是决定了物理缓冲区池的总大小 。它的逻辑意义 是确保解码器有足够的缓冲区来处理帧间依赖。它通常是只读的,由驱动程序根据编码流自动计算。
  • **ulNumOutputSurfaces**:它不代表一个独立的物理缓冲区池。它的逻辑意义 是告诉驱动程序,应用程序需要同时"持有"多少个已完成的帧 。例如,如果 ulNumOutputSurfaces 设置为 3,那么驱动程序在任何时候都会确保应用程序最多只持有 3 个来自解码池的索引。当应用程序试图获取第 4 个帧时,如果前 3 个帧还没有被 cuvidUnmapVideoFrame 释放,驱动程序就会阻塞,直到有空闲的缓冲区。

我又追问了一个问题:设置 ulNumDecodeSurfaces 为10,设置 ulNumOutputSurfaces为2的意义?

  1. **ulNumDecodeSurfaces = 10**
    • 这定义了CUVID 驱动程序内部的物理缓冲区池大小为 10。所有解码操作都会在这个由 10 个缓冲区组成的池中进行。
    • 这个值由驱动程序根据视频流的编码特性(如参考帧数量)来决定,确保解码过程可以顺利进行。
  2. **ulNumOutputSurfaces = 2**
    • 这定义了你的应用程序可以同时"持有"或访问的已解码帧的最大数量 。这个"持有"是通过 cuvidMapVideoFrame() 函数实现的。
    • 你的应用程序可以请求映射一帧数据,当它完成处理后,必须调用 cuvidUnmapVideoFrame() 来释放这个"持有权"。
    • 如果你已经映射了 2 个帧,并且还没有解除任何一个的映射,那么当你试图再次调用 cuvidMapVideoFrame() 时,这个调用会阻塞,直到其中一个缓冲区被你释放。

简单来说: ulNumOutputSurfaces = 2 允许解码器为你准备 2 个可以随时访问的帧。但是,cuvidMapVideoFrame 是一个"取货"操作,就像在仓库门口排队取货一样,你还是需要串行地去拿取。你可以拿完一个,处理它,然后再拿下一个。这个 2 的值确保了当你去拿第二个时,它可能已经准备好了,不需要等待解码器。

总结

ulNumDecodeSurfaces 决定了**底层有多少个"物理工位"**供解码器使用。

ulNumOutputSurfaces 决定了你的应用程序可以从这些工位中同时"取走"多少个"成品"

因此,设置 ulNumOutputSurfaces = 2ulNumDecodeSurfaces = 10 的意思是:

  • 解码器内部有 10 个可用的缓冲区,以确保解码过程不会因为缺少参考帧而卡顿。
  • 应用程序最多可以同时访问其中 2 个缓冲区。这确保了应用程序不会因为获取太多数据而导致内存管理困难,同时也平滑了与解码器之间的速度差异。

总结下:解码缓存和解码输出缓存是同一个缓存,通过 **ulNumDecodeSurfaces** **ulNumOutputSurfaces**是同一个缓存有两种不同的用处。


既然是ulNumOutputSurfaces是指同时可以访问的缓存的个数,那么是否可以用多个线程去同时取这些值呢?我又问了下DeepSeek

** 设置ulNumOutputSurfaces为2,是否意味着可以同时用两个CPU线程调用 cuvidMapVideoFrame ?**

不能,将 ulNumOutputSurfaces 设置为 2 **并不意味着你可以同时在两个不同的 CPU 线程上调用 ****cuvidMapVideoFrame** 来获取不同的帧。

为什么不能?

cuvidMapVideoFrame 是一个同步操作。虽然解码线程和你的应用程序线程是异步的,但对 cuvidMapVideoFrame 的调用通常应遵循一个单线程模型 ,或者至少是串行访问

这是因为:

  1. 线程安全: NVDEC CUVID API 的设计通常不是为了在多线程环境下同时调用同一个对象的函数而设计的。cuvidMapVideoFrame 可能会访问内部共享状态或数据结构,如果多个线程同时调用它,可能会导致竞态条件(race condition)未定义的行为
  2. 函数作用: 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).

关于cuvidMapVideoFramecuvidDecodePicture间的关系,我也问了下DeepSeek,它梳理了它们的具体操作,可以加深理解。

cuvidMapVideoFrame具体是做了什么工作?为什么说官方文档上是说明它等待解码完成,而会阻塞cuvidDecodePicture,那cuvidDecodePicture具体又是做了什么?

cuvidMapVideoFrame 的具体工作

cuvidMapVideoFrame 的核心工作是内存映射和同步,而不是数据复制。

  1. 等待和同步:
    • 当你调用 cuvidMapVideoFrame 时,它首先会检查你指定的帧索引 (nPicIdx) 是否已完成解码 并且已完成所有后处理(如果配置了的话)。
    • 如果该帧尚未完全准备好(例如,解码器还在忙着处理它),cuvidMapVideoFrame阻塞当前的应用程序线程,直到 GPU 上的解码和后处理任务完成。
    • 它等待的正是 GPU 上的工作完成。
  2. 内存映射 (Zero-Copy):
    • 一旦帧准备就绪,它会将存储该帧数据的显存区域直接映射到你的应用程序的地址空间(或者一个 CUDA 可访问的指针)。
    • 这样做实现了零拷贝(Zero-Copy),应用程序可以直接通过返回的指针访问显存中的数据,避免了从显存到系统内存或另一块显存的昂贵数据复制操作。
  3. 获取持有权:
    • 成功映射后,应用程序就获得了该输出缓冲区的**"持有权"**。这意味着该缓冲区在被 **cuvidUnmapVideoFrame** 释放之前,不能被解码器重新用于存储新的解码输出。

为什么 cuvidMapVideoFrame 会阻塞 cuvidDecodePicture

你看到的"cuvidMapVideoFrame 阻塞 cuvidDecodePicture"的说法,其实是描述了两种线程之间资源竞争的后果 ,而不是直接的函数调用阻塞。这里的关键是 **ulNumOutputSurfaces** 的限制。

阻塞机制的本质:资源争夺

  1. 资源: 解码器和应用程序共享的资源是 ulNumDecodeSurfaces 定义的物理缓冲区池
  2. **ulNumOutputSurfaces = 2**** 的含义:** 这告诉驱动程序,应用程序最多只能同时"持有"池中的 2 个缓冲区。
  3. 场景模拟:
    • 应用程序已经通过 cuvidMapVideoFrame 持有了缓冲区 A 和 B。
    • 解码器(通过 GPU)完成了第 3 帧的解码,需要找一个空闲的缓冲区(例如 C)来存储结果。
    • 当应用程序正在处理 A 和 B 时 ,解码器可以继续解码下一帧。但是,当解码器需要存储新的 解码输出时,它必须使用一个未被应用程序持有的缓冲区。
    • 如果应用程序持有的 A 和 B 碰巧是目前唯一空闲 的两个缓冲区(或者说,解码器内部需要的参考帧也已填满),并且应用程序还没有调用 cuvidUnmapVideoFrame 释放它们,那么解码器线程在调用 **cuvidDecodePicture** 尝试获取一个新的空闲解码表面时,就会发现所有表面都被占用了(要么被应用程序持有,要么被解码器作为参考帧占用)。
  4. 结果: cuvidDecodePicture 就会阻塞 ,直到应用程序释放一个它正在持有的输出表面(即调用 cuvidUnmapVideoFrame)。

所以,cuvidMapVideoFrame 本身是等待 GPU 完成工作,而它导致的持有 行为,会间接导致解码线程(调用 **cuvidDecodePicture** 的线程)在找不到空闲缓冲区时发生阻塞

cuvidDecodePicture 具体做了什么工作?

cuvidDecodePicture 是将压缩的比特流数据提交给 NVIDIA 硬件解码器(NVDEC 单元)进行解码的函数。

它的主要工作流程是:

  1. 提交比特流: 应用程序将一帧完整的压缩数据(如 H.264 NALU 或 HEVC AU)传递给 cuvidDecodePicture
  2. 选择目标: 驱动程序在 ulNumDecodeSurfaces 缓冲区池中选择一个当前空闲且可用的表面,作为当前帧的解码输出目标。
  3. 设置参数: 驱动程序将解码所需的全部参数(如参考帧列表、量化参数、Slice Header 等)发送给 NVDEC 硬件。
  4. 命令入队: 将解码任务放入 GPU 的命令队列中,供 NVDEC 单元执行。
  5. 异步返回: cuvidDecodePicture 函数通常会立即返回,而不会等待 GPU 解码完成。解码任务在 GPU 上是异步进行的。

总结: cuvidDecodePicture 只是一个提交任务 的 API;真正的解码工作是在 GPU 硬件上异步完成的。

在文档中还说明了,在多个视频流同时解码的情况,这种生产者-消费者的模式并不能提升效率,因为cuvidDecodePicture会成为瓶颈,就是数据太多,硬解码器处理不过来,可能解码比map还慢,这个比较好理解,在生产者/消费者模式中,如果生产者很慢,那么这种多线程没什么意义。在samples中的解码,并没有采用生产者-消费者模式。

ulNumDecodeSurfacesulNumOutputSurfaces值的设置

理论上ulNumDecodeSurfaces的值越大越好,但是越大,显存占用越大,但解码的效率并不一定提升,解码效率是取决与硬件,这个值只是设置解码缓存,并不是解码效率的核心因素。这个值的设置在文档中有说明,应该设置为parser返回的ulMaxNumDecodeSurfaces值。

CUVIDDECODECREATEINFO::ulNumDecodeSurfaces = CUVIDPARSERPARAMS::ulMaxNumDecodeSurfaces

ulNumOutputSurfaces推荐的值是2或者3,在samples中这个值被设置为了2。

相关推荐
骄傲的心别枯萎6 天前
项目1:FFMPEG推流器讲解(五):FFMPEG时间戳、时间基、时间转换的讲解
ffmpeg·音视频·视频编解码·时间戳·rv1126
派阿喵搞电子7 天前
基于ffmpeg库,在AGX上编译jetsonFFmpeg库带有硬件加速的h264_nvmpi视频编解码器
ffmpeg·视频编解码
AI视觉网奇8 天前
ffmpeg 播放视频 暂停
视频编解码
骄傲的心别枯萎14 天前
项目1:FFMPEG推流器讲解(一):FFMPEG重要结构体讲解
linux·ffmpeg·音视频·视频编解码·rv1126
骄傲的心别枯萎15 天前
项目1:FFMPEG推流器讲解(二):FFMPEG输出模块初始化
linux·ffmpeg·音视频·视频编解码·rv1126
DogDaoDao17 天前
DCT与DST变换原理及其在音视频编码中的应用解析
音视频·实时音视频·视频编解码·dct变换·变换编码·dst变换
Everbrilliant8919 天前
音视频编解码全流程之用Extractor后Decodec
ffmpeg·视频编解码·mediacodec·音视频解码·ffmpeg编解码·decodec·ndkmediacodec
Everbrilliant8921 天前
音视频编解码全流程之用Extractor后Muxer生成MP4
视频编解码·amediamuxer·ffmpeg数据包提取·amediaextractor·ffmpeg数据包的写入·提取器extractor·复用器muxer