1. 背景
视频已成为内容和广告的主要媒介形式,但目前的视频内容理解或审核等AI能力,主流依然是先抽帧,再基于图像帧做特征提取和预测。抽帧由于步骤多、计算重,在视频AI推理场景很容易成为性能瓶颈。因此,有必要使用硬件加速等手段,来对视频抽帧做极致的性能优化。
在腾讯广告的流量中也是如此,视频所占比例逐年快速提升,视频抽帧这里如果出现时耗或吞吐瓶颈(特别是针对高FPS抽帧的情况),很容易影响到后续的特征提取以及模型预测性能,以及整体的GPU利用率。在当前的广告视频AI推理服务中,抽帧往往占据了其中大部分时耗,因此,视频抽帧的性能对于视频内容理解服务的时耗和整体资源开销,有着举足轻重的地位。
视频抽帧的几个步骤,计算量非常大,传统的CPU方式抽帧往往受限于CPU整体的计算吞吐,很难满足低时延高性能要求。使用硬件来做硬解码以及并行计算加速是一个比较理想的替代方案,NVIDIA的GPU从2014年发布的Maxwell架构开始,即增加了单独的硬件编解码计算单元,并且GPU上为数众多的CUDA core也特别适用于图像数据并行处理加速。目前云上广泛使用的推理芯片Tesla T4,解码器已经发展到第四代,包含两个独立于CUDA core的解码单元,且支持大部分主流的视频格式。
<center>▲ NVIDIA GPU NVDEC Architecture</center>
2. 目标
视频抽帧流程大体上包括以下几个步骤:视频解码、帧色彩空间转换、落盘方式的JPEG编码,如果非落盘,则对解码出来的视频帧做预处理,然后交给模型进行特征提取或预测。
其中帧色彩空间转换、JPEG编码都涉及像素级别计算,非常适合使用GPU CUDA kernel来做并行计算加速。此外,视频解码后得到的帧都是未经压缩的原始数据,数据量很大,如果解码是在CPU上进行,或者GPU解码后自动传回了CPU,则需要频繁做 device(显存)与 host(主存)之间的原始帧数据来回拷贝,IO时耗长且数据带宽拥塞,导致时延明显增加。
因此,该方案的主要目标是尽可能减少host与device间的数据IO交换,做到抽帧过程全流程GPU异构计算,充分利用腾讯云NVIDIA GPU自带的硬件解码单元NVDEC,最大限度减少视频解码对于CPU以及GPU CUDA core占用的同时,尽可能低延时、高吞吐地处理视频抽帧以及后续的模型推理。
<center>▲ NVIDIA 官方给出的T4卡NVDEC解码性能</center>
具体来说,本方案主要从计算和IO两个方面着手,解码部分充分利用了GPU通常闲置的NVDEC解码器,其他步骤以像素或像素块计算为主因此使用CUDA kernel做并行加速。IO方面,由于中间过程是原始帧,GPU数据带宽有限,该方案实现了全流程CPU和GPU无帧数据交换,最大程度提升性能和吞吐,确保云上视频AI推理服务的GPU利用率。
3. 具体方案
3.1 计算优化
3.1.1 NVDEC硬解码
当前线网主力的GPU推理卡T4、P40,以及后续即将升级的A系列,主流的视频编码格式基本都已支持,各卡型支持的具体格式如下:
调用GPU硬解码主要有两种方式,一种是直接使用NVIDIA官方提供的Video Codec SDK,另一种方式是使用FFmpeg,其已经封装了对GPU硬解码的支持。考虑到目前T4卡对视频格式的支持还不够完善,因此本文使用的是FFmpeg方式,如果遇到GPU不支持的视频格式,只需修改解码器类型即可快速降级到CPU解码方案,CPU和GPU两种模式抽帧的代码逻辑也较为统一。
以下分别以FFmpeg CPU 4、8、16线程,以及GPU硬解码方式,抽取线网100个广告视频做离线测试,平均时耗对比如下:
<center>(注:视频平均大小约15M,平均时长26s,大部分为720P视频;FFmpeg建议最大解码线程数16)</center>
分配给GPU模型推理服务的CPU核数一般不会太多,因此以FFmpeg 8线程、2worker(在本文中是指单进程多实例的方式)做性能压测,1000个广告视频测试数据如下:
由此可见,在GPU线上推理环境,如果充分利用T4卡2 x NVDEC硬件解码模块,可在几乎不影响线上服务CPU、CUDA原有workloads计算的情况下,额外增加一倍解码算力,抽帧QPS可在原有基础上翻倍。此处应注意,不同架构GPU所附带的NVDEC硬解模块数不同,并且NVDEC不支持外部再用多线程操作解码,应当根据NVDEC模块数选择正确的多实例多worker进行解码。例如T4卡有2个NVDEC硬解码模块,如果只用单实例,则硬解模块利用率将不会超过50%。(如果服务对吞吐的要求高于时延,则此处GPU硬解码的worker数可以设为n 1,充分压榨硬件解码模块)
3.1.2 CUDA 色彩空间转换
视频解码后得到的帧为YUV格式,而通常模型预测或其他后续处理一般需要RGB/BGR像素格式,因此需要做一次色彩空间转换,将YUV帧转换为模型需要的RGB格式。传统方式是调用FFmpeg的swscale模块来实现,但是该方式只支持在CPU进行计算,需要做一次device到host的数据IO,并且非常消耗CPU资源,计算并行度也不高。用Perf采集火焰图分析发现,swscale计算耗时占比接近40%:
YUV 到 RGB 格式的转换是 3x3 的常量矩阵与 YUV 三维向量相乘,即逐像素地将明度 Y、色度 U、浓度 V 三个分量按公式线性变换为 R、G、B 三色值(这里的常量矩阵的值取决于视频所采用的颜色标准,比如 BT.601/BT.709/BT.2020,可参见 Video Codec SDK 里面的示例)。
以BT.601为例,YUV到RGB格式的转换公式如下:
R = Y 1.402 (Cr-128) G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) B = Y 1.772 (Cb-128)
由公式可见,可以很方便地将计算过程改为一维或二维的Block线程块CUDA kernel调用,充分利用GPU数以千计的 CUDA 核心并行计算来做提速。
性能:对线网100个广告视频做性能对比评测,CUDA kernel调用相对于CPU的swscale方式平均提速在20倍以上,并且视频清晰度越高,优势越明显。
3.1.3 CUDA JPEG编码
如果是在视频预处理等场景,则需要对抽帧结果做JPEG编码后再落盘保存。JPEG编码具体流程如下:
虽然不同于色彩空间转换的逐像素操作,但也是将整张图片划分为8x8像素的小分块分别进行离散余弦变换、量化、Huffman编码等处理,同样非常适合用GPU CUDA core计算单元来做并行加速。NVIDIA从CUDA Toolkit 10开始也已经封装了nvJPEG模块提供JPEG编码能力。
需要说明的是,使用GPU做JPEG编码,与CPU JPEG编码存在一定比例的像素差异。确保JPEG文件头中各项参数一致的情况下(压缩质量、量化表、Huffman表均相同),实测像素差异比在0.5%左右。由于JPEG编码为有损压缩,因此解码后依然存在像素差异,有可能导致模型给出的预测结果存在偏差。例如OCR的目标检测模块,分别使用CPU和GPU编码的JPEG图像作为输入,预测得到的检测框坐标值在部分case上存在一定偏差,从而有概率导致文字识别结果出现不一致。NV工程师给出的答复是GPU的浮点计算单元截取的位数或精度可能与CPU存在一定差异,暂时无法解决。一种可行的解决方案,是模型训练也使用GPU JPEG编码的图片作为输入,保证模型训练和推理的输入一致性,从而确保模型推理效果。
性能:实测线网1000个广告视频,CUDA方式JPEG编码约有15~20倍性能提升,同样清晰度越高性能优势越大:
3.2 IO优化
3.2.1 显存缓存视频帧
FFmpeg使用GPU硬解码后,得到的视频帧格式为AV_PIX_FMT_NV12,通过NV提供的cudaPointerGetAttributes API做指针类型检查,为Host端内存指针。也就是说调用NVDEC模块解码后,默认对视频帧做了一次device到host的传输。
由于这里的视频帧均为未压缩的原始像素帧,且原始视频的所有FPS帧都会做该处理,会占用大量GPU与host端内存的数据带宽。以1080P视频为例,解码后单帧大小约5M,30M视频解码后约700帧,总大小可达到3G 。以T4卡为例,与host间数据传输通道为 x16 PCIe Gen3,数据带宽有限,理论传输速度约16GB/s,解码 传输回GPU做色彩转换来回耗时约180ms x 2,不但增加了时延,而且大量占据了原本就不太宽裕的PCIe带宽。在多worker并行情况下更是容易造成数据带宽拥堵,对线上推理服务整体吞吐有较大影响。
下图为使用nvprof采集到的抽帧过程profiling数据,也验证了存在DtoH & HtoD的两次额外帧数据传输。可见device与host间的数据IO受PCIe带宽影响,耗时较长,并且导致CUDA kernel计算时间片连续性差。
如果有办法做到GPU硬解后的视频帧,不默认传回到host端,而是直接缓存在显存等待后续计算,则可以无缝对接后续的模型推理或JPEG落盘,省去device与host端的来回两次数据交换时耗,且大幅减轻GPU与CPU间的数据IO吞吐压力。答案是可行的,查阅相关资料后,发现FFmpeg已经封装了对于GPU硬件缓冲区方式的支持:
代码语言:C 复制 if (hw_device_ctx == nullptr) {
(*dec_ctx)->get_format = hw_get_format;
// 创建硬件加速器的缓冲区
if (av_hwdevice_ctx_create(&hw_device_ctx,device_type,NULL,NULL,0) < 0) {
SoError("av_hwdevice_ctx_create fail!");
return;
}
/** 如果使用软解码则默认有一个软解码的缓冲区(获取AVFrame的),而硬解码则可以额外创建硬件解码的缓冲区
* 这个缓冲区变量为hw_frames_ctx,不手动创建,则在调用avcodec_send_packet()函数内部自动创建一个
* 但是必须手动赋值硬件解码缓冲区引用hw_device_ctx(是一个AVBufferRef变量)
* 即hw_device_ctx有值,则使用硬件缓存方式解码
*/
SoDebug("av_hwdevice_ctx_create end, ctx: %p.", hw_device_ctx);
}
(*dec_ctx)->hw_device_ctx = av_buffer_ref(hw_device_ctx);
// 配置获取硬件加速器像素格式的函数;该函数实际上就是将AVCodec中AVHWCodecConfig中的pix_fmt返回
enum AVPixelFormat hw_device_pixel;
enum AVPixelFormat hw_get_format(AVCodecContext *ctx,const enum AVPixelFormat *fmts)
{
const enum AVPixelFormat *p;
for (p = fmts; *p != AV_PIX_FMT_NONE; p ) {
if (*p == hw_device_pixel) {
SoDebug("get hw_get_format res: %d", *p);
return *p;
}
}
SoWarn("Failed to get HW surface format!");
return AV_PIX_FMT_NONE;
}
但是使用硬件缓冲区方式后,得到的视频帧格式变为AV_PIX_FMT_CUDA,且Y和UV plane的data linesize也由1088变为1280,需要做相应转换后才能得到常见的NV12或YUV420P格式。这里相关资料非常少,笔者在尝试过程中也踩了不少坑,后续会将相关代码开源出来。完成这里的转换之后,使用cudaPointerGetAttributes检查frame data指针类型,已经是device端指针,由此打通了全流程异构抽帧的关键一环。
通过nvprof抓取到的性能数据可见,cudaMemcpy由之前的DtoH & HtoD来回传输变为一次显存内部的DtoD,时耗由173ms x 2变为25ms,吞吐也有不少提升。此外,CUDA kernel计算时间片的连续性也得到不少改善。
性能:实测线网1000个广告视频,整体性能相较于非硬件缓冲区方式有25%左右的提升,GPU硬解码器NVDEC资源利用率提升约30%:
3.3 工程优化
本文以介绍GPU全流程抽帧方案为主,过程中为了把性能做到极致也涉及到一些工程优化,由于篇幅原因这里只做简单介绍,部分细节会在后续文章中详细展开。
- 通过显存预分配 复用、AVHWDeviceContext缓冲区 & JPEG编码器复用等手段,单次抽帧时耗可再优化百ms级别。
- 将NVDEC硬解码、色彩空间转换、JPEG编码、模型推理等步骤,利用CUDA多流,并对每个环节做Pipeline overlap并行化处理,可充分释放每个步骤的最大计算性能,进一步提升计算吞吐和资源利用率。
- 目前有不少算法服务是基于Python进行开发&部署,本方案为保障高性能,使用C 开发。通过pybind11基于C 封装Python抽帧API,保障算法开发部署的灵活性与效率的同时,确保高性能的抽帧能力。跨语言交互细节可参考我之前整理的文章:《给Python算法插上性能的翅膀——pybind11落地实践》
- 不落盘方式,对接模型推理之前一般需要先做预处理操作,如果要做到全流程GPU,需要将预处理改写为CUDA kernel调用。这里可以将常用的CV类预处理操作封装为CUDA基础函数库,也可以使用NVIDIA已经封装好的NPP模块、DALI预处理加速框架等方案。
4. 整体效果
4.1 全流程时耗对比
- 相较于CPU 8线程,全流程在latency上有一倍左右的速度优势。吞吐方面,由于几乎不占用PCIe数据带宽,对模型推理等device&host间数据IO基本无阻塞,亦有不少提升。
- 相较于Python算法常用的ffmpeg-python方式,有数倍性能提升。
4.2 环境相关
FFmpeg
编译配置:
./configure --enable-gpl --enable-shared --enable-pthreads --enable-cuda --enable-cuvid --enable-nvenc --enable-nonfree --enable-libnpp --enable-libx264 --enable-libfdk_aac --extra-cflags=-I/usr/local/cuda/include --extra-ldflags=-L/usr/local/cuda/lib64
运行后有可能报错:
ERROR: cuda requested, but not all dependencies are satisfied: ffnvcodec
解决方案:4.x的新版,需要单独安装nvcodec:https://git.videolan.org/git/ffmpeg/nv-codec-headers.git
测试环境:
机型:GPU机型GN7 CPU:20核 ROM:80G GPU:NVIDIA Tesla T4 x 1 GPU Driver:430.50 Cuda:10.1 FFmpeg:4.3.2 OpenCV:3.4
5. 通用解决方案
不同的视频AI算法,对于抽帧有不同的需求,并且抽帧能力对于算法同学来说并非主要研究方向。因此,如果能沉淀出一套较为通用的抽帧解决方案,对于算法同学来说有很大的帮助。目前该方案仍在迭代中,当前具备的特点和优势如下:
- 高性能:硬解 CUDA并行计算加速,较CPU方案快近一倍,较Python版快数倍
- 全异构:整个pipeline中间过程无CPU&GPU间帧数据交换,避免PCIe带宽成为瓶颈
- 算力利用:充分利用通常闲置的NVDEC解码芯片,结合工程优化提升资源利用率,降低视频AI部署成本
- 灵活性:1. 不同的算法部署环境,可灵活配置GPU/CPU worker数,且支持两种模式间无干扰并行工作;2. 同时支持落盘和非落盘两种场景,且一次解码过程可对接多种抽帧参数
- 兼容性:对于GPU硬解暂不支持的部分格式,支持快速降级到CPU模式抽帧
- 便捷性:同时支持C 和Python两种调用方式,针对不同部署环境,可通过配置快速修改部署参数
目前我们团队正在参与腾讯太极机器学习平台共建,主要承担的是公共基础模块建设。该解决方案也会作为太极平台的基础抽帧能力组件,与太极的推理加速组件进行整合。
6. 结语
本方案从GPU硬件加速的角度出发,分别针对抽帧各步骤做性能分析&计算优化,解决了中间过程大数据量的原始视频帧host与device端数据IO交换问题,避免GPU与CPU间的PCI-E数据带宽瓶颈,真正做到全流程GPU异构抽帧。基于此,可在GPU无缝对接后续的模型推理(不落盘)以及JPEG编码(落盘)两种主流的抽帧使用场景,是实现全流程GPU视频AI推理能力的先决条件。同时,充分利用了GPU推理环境通常闲置的NVDEC解码芯片,对于整体服务时耗、吞吐,以及硬件资源利用率均有不错的提升,降低了云上视频AI推理服务GPU/CPU算力成本,在算力紧缺的AI2.0时代有着非常重要的意义。
目前该方案已在腾讯广告多媒体AI的视频人脸服务落地,解决了最主要的抽帧性能瓶颈,满足广告流水对于服务的性能要求。更多视频AI算法特别是高FPS抽帧场景也正在接入优化中。
7. 展望
视频抽帧优化是视频AI推理优化中的重要一环,后续的预处理,以及模型推理、后处理等环节如何优化,并且更好地结合到一起从而实现整体上的性能最优,是一个非常大的课题以及值得探索的点,笔者后续会继续分享这方面的经验心得。
(该方案已得到NVIDIA官方大力认可,并作为优秀案例进行推广:《腾讯广告视频抽帧的全流程GPU加速》)
最后,给个人公众号打个小广告,主要分享AI工程领域的加速、性能优化实践经验总结。干货以及个人思考为主,欢迎大家关注&技术交流:
8. 参考资料
- https://developer.NVIDIA.com/NVIDIA-video-codec-sdk
- https://docs.NVIDIA.com/video-technologies/video-codec-sdk/ffmpeg-with-NVIDIA-gpu/
- https://github.com/FFmpeg/FFmpeg/blob/n4.2.5/doc/examples/hw_decode.c
- https://docs.NVIDIA.com/cuda/nvjpeg/index.html
- https://docs.NVIDIA.com/deeplearning/dali/user-guide/docs/index.html