目标检测速度从9 FPS到650 FPS

2020-10-11 16:20:33

让代码在GPU上快速运行需要一种非常不同的方法来使代码在CPU上快速运行,因为硬件体系结构从根本上是不同的。如果您有在CPU上进行高效编码的背景,那么您必须调整一些关于哪些模式是最佳模式的假设。

所有类型的机器学习工程师都应该关心从他们的模型和硬件中挤压性能-不仅是出于生产目的,也是为了研究和培训。在研究和开发中,快速迭代循环会带来更快的改进。

本文是对使特定深度学习模型(NVIDIA的SSD300)在功能强大的GPU服务器上快速运行的实际深入探讨,但一般原则适用于所有GPU编程。SSD300是在COCO上训练的对象检测模型,因此输出将是具有81类对象概率的边界框。

本文的部分目的是了解在不影响Python的灵活性或熟悉创建模型的库(Pytorch)的情况下,我们可以获得多大的吞吐量。我们不会深入到自定义CUDA内核或使用标准的“服务”框架,因为我们会在高级别上发现许多大型优化可用。我们将从一个简单实现的简单视频推理管道开始,接着是我的Pytorch Video Pipeline介绍性文章。

根据PyTorch Hub页面,代码的基线版本将使用SSD300 repo中的后处理函数。此模型的实现者不会假装此示例代码已准备好投入生产,我们将找到许多方法来改进它。事实上,发布的此模型的基准测试结果根本不运行后处理代码。

Def on_frame_Probe(PAD,INFO):全局START_TIME,FRAMES_PROCESSED START_TIME=开始时间或时间。Time()with nvtx_range(';ON_FRAME_PROBE';):buf=info。Get_buffer()print(f';[{buf.pts/Gst.SECOND:6.2f}]';)image_tensor=buffer_to_image_tensor(buf,pad.。Get_current_caps())image_Batch=预处理(image_tensor。解压缩(0))Frame_Processing+=image_Batch。带手电筒的大小(0)。No_grad():with nvtx_range(';推理';):locs,labels=检测器(Image_Batch)后处理(locs,labels)返回GST。PadProbeReturn。好的。

预处理会将0到255的整数RGB像素值转换为缩放后的-1.0到+1.0浮点值。

检测器(IMAGE_BATCH)运行SSD300模型,注意模型和输入张量此时位于CUDA设备(GPU)上,这一点很重要。

我们稍后将深入研究这些函数,现在让我们检查一下基准性能。

Night Systems是一款非常棒的工具,可以帮助您进行高级别的GPU调优。它显示CPU/GPU资源利用率,并能够跟踪OS系统调用、CUDA、CuDNN、CuBLAS、NVTX,甚至可以跟踪一些我们并不关心的技术。

NVTX是一个重要的API,我们将使用它来检测代码中的区域和事件-这将允许我们将跟踪的使用模式映射到代码中的逻辑。

在启用跟踪的情况下运行基线视频管道后,打开Night Systems显示约40秒的活动解码和处理视频文件(单击查看完整分辨率):

许多工具包的调用持续时间,包括操作系统调用和我在代码中放入的自定义NVTX范围。当我们放大时,这些将变得清晰。

我已经为一些问题添加了注释,这些问题即使在非常高的级别上也是很清楚的。CPU使用率高,GPU使用率低,主机(系统内存)和设备(GPU内存)之间有大量内存传输。

让我们深入查看几个帧的处理过程。请注意对应于代码逻辑部分的灰色NTVX范围:

后处理占用90%的运行时间是一场灾难-从高层次来看,这是首先要解决的问题,但是是什么导致了它呢?

在后处理过程中,我们可以看到CPU使用率非常高,GPU使用率非常低(但不是0%),并且有从设备到主机的持续内存传输。最有可能的情况是,后处理主要在CPU上完成,但它会不断地从GPU中提取处理所需的小块数据。

查看CUDA API时间线,我们可以看到大量内存传输以及它们周围的绿色同步。CUDA同步调用进一步证明,后处理部分在CPU上完成,部分在GPU上完成,并且以非常细粒度的方式进行同步。让我们把它修好。

Def postprocess(locs,labels):with nvtx_range(';postprocess';):Results_Batch=SSD_utils。DECODE_RESULTS((locs,labels))RESULTS_BATCH=[SSD_UTILS。Pick_BEST(Results,Detect_Threshold)表示Results_Batch中的结果]对于bbox、类、Results_Batch中的分数:如果分数。Shape[0]>;0:打印(bbox,类,分数)。

在突出显示的行处,参数locs和标签是GPU上的张量(它们直接从SSD300推理返回)。DECODE_RESULTS代码以元素方式访问张量并在CPU上执行工作,导致重复的细粒度数据请求从GPU发送到系统内存。

最初的修复非常简单-我们将在单个操作中将这些完整的张量发送到系统内存。

Def postprocess(locs,labels):with nvtx_range(';postprocess';):Results_Batch=SSD_utils。DECODE_RESULTS((loc.。Cpu(),标签。Cpu())RESULTS_BATCH=[SSD_UTILS。Pick_BEST(Results,Detect_Threshold)表示Results_Batch中的结果]对于bbox、类、Results_Batch中的分数:如果分数。Shape[0]>;0:打印(bbox,类,分数)。

在没有启用跟踪的情况下运行基线代码可以获得9FPS的吞吐量,并且这个改进的代码以略低于16FPS的速度运行。78%的改进对于只输入两次.cpu()来说并不是坏事。这为GPU编程带来了一个很好的经验法则:

更新的NSight Systems视图显示了两个帧的处理(如下所示),具有明显的差异。我们现在看到的不是从设备到主机的持续的小传输流,而是后处理阶段开始时的一个大传输。

后处理仍然占用80%以上的帧处理时间,并且该过程仍然存在严重的CPU瓶颈。如果我们可以用GPU做后处理呢?

对于某些型号来说,将预处理和后处理转换为高度矢量化的GPU代码可能很棘手,但这是您可以做出的影响最大的性能改进之一。

我已经为SSD模型添加了大约100行代码来实现这一点-以下是新的顶级后处理代码:

Def postprocess(locs,labels):with nvtx_range(';postprocess';):locs,pros=xywh_to_xyxy(locs,label)#展平批次和类别Batch_dim,box_dim,class_dim=pros。Size()Flat_locs=locs。重塑(-1,4)。REPEAT_INTERLEVE(CLASS_DIM,DIM=0)FLAT_PRORS=PROBS。VIEW(-1)CLASS_INDEX=火炬。Arange(CLASS_DIM,DEVICE=DEVICE)。重复(BATCH_DIM*BOX_DIM)IMAGE_INDEX=(手电筒。1(box_dim*class_dim,device=device)*手电筒。Arange(1,BATCH_DIM+1,DEVICE=设备)。解压(-1))。View(-1。行动组。盒子。BATCHED_NMS(FLAT_LOCS,FLAT_PROSS,CLASS_INDEX*IMAGE_INDEX,IOU_THRESHOLD=0.7)bbox=Flat_locs[nms_ask]。CPU()PROBS=FLAT_PRORS[nms_掩码]。CPU()CLASS_INDEX=CLASS_INDEX[nms_ask]。如果是bbox,则为cpu()。Size(0)>;0:打印(bbox,class_index,pros)。

如果上面的代码很难理解,那就太棒了,因为我花了相当大的努力来学习如何做到这一点。

详细介绍代码超出了本文的范围,但一般流程是:

对所有检测执行批处理非最大抑制(NMS),使用索引确保NMS不跨类或映像应用。重要的是,这一切都发生在GPU上的张量和操作上。

现在,让我们看看新的Night Systems输出缩放到帧级别:

单个帧的后处理已经从CPU上的54ms左右下降到GPU上的3ms以下。在没有跟踪开销的情况下测量吞吐量,我们现在的速度约为80 FPS,而在CPU上进行后处理时为16 FPS。

这比帧处理吞吐量提高了400%,显示了现代GPU的荒谬能力。另一条经验法则是:

查看上面的跟踪输出,最诱人的观察是在推断阶段GPU利用率相当低。此推论是主机(CPU)提交给设备(GPU)的一系列CUDA内核-GPU正在执行所有实际工作,但主机仍然跟不上。

增加发送到GPU的工作量的一种非常简单的方法是通过一次批处理通过神经网络发送多个帧。

在这个简单的实现中,只有顶层的每帧代码才会更改。帧张量被累积到一个列表中,当列表达到Batch_Size时,我们一次对整个批次进行预处理、推断和后处理。我还更改了NVTX范围以便我们可以跟踪批次的累积。

Def on_frame_Probe(PAD,INFO):全局START_TIME,FRAMES_PROCESSED START_TIME=开始时间或时间。Time()全局IMAGE_BATCH,如果不是IMAGE_BATCH:Torch。库达。Nvtx。RANGE_PUSH(';批次';)手电筒。库达。Nvtx。RANGE_PUSH(';CREATE_BATCH';)buf=INFO。Get_buffer()print(f';[{buf.pts/Gst.SECOND:6.2f}]';)image_tensor=buffer_to_image_tensor(buf,pad.。Get_current_caps())image_Batch。Append(Image_Tensor)if len(Image_Batch)<;Batch_Size:返回GST。PadProbeReturn。好的,手电筒。库达。Nvtx。RANGE_POP()#CREATE_BATCH IMAGE_BATCH=预处理(手电筒。STACK(IMAGE_BATCH))Frames_Processing+=image_Batch。带手电筒的大小(0)。No_grad():with nvtx_range(';推理';):locs,labels=检测器(Image_Batch)image_Batch=[]后处理(locs,label)手电筒。库达。Nvtx。RANGE_POP()#批量返回GST。PadProbeReturn。好的。

通过这个简单的更改(使用批大小为4),吞吐量从80FPS跃升到125FPS左右。

推断时间大约翻了一番,这是一笔不错的交易,因为我们要处理4倍以上的帧。这给出了一个经验法则,它是早先的“发送大量工作”想法的特例,但它值得强调:

现在,GPU在推理(最终)过程中得到了充分利用,但是在我们将帧累积到批处理中时,GPU完全处于空闲状态。我们很快就会解决这个问题,但首先,我们将使用Volta、图灵和NVIDIA硬件中的张量内核启用另一个快速半精度推理。

我选择NVIDIA的SSD300型号作为本文的原因之一,是因为NVIDIA提供了Float32和半精度Float16预训练版本。在Pytorch1.6之前,训练混合精度模型的最好方法是使用NVIDIA的Apex库,该库可以轻松地存储和训练具有float16精度的模型权重,同时以float32张量累积梯度。在Pytorch>;=1.6中,此支持是内置的。

对于我们的目的,唯一需要的代码更改是1)从Torch Hub加载不同版本的模型,以及2)确保发送用于推理的张量是浮动的16而不是浮动的32。我还在这次迭代中将批处理大小从4个增加到8个,以便在推理过程中保持>;95%的GPU利用率。

在不跟踪开销的情况下运行时,吞吐量从125 FPS提高到185 FPS。多亏了张量磁芯,吞吐量几乎毫不费力地提高了~50%。

这个NSight Systems Per-Batch视图看起来与前一个视图非常相似。批处理时间已从约30毫秒缩短到约40毫秒,但我们现在每批处理8帧,而不是4帧。

更仔细地观察,我们可以看到,推断和后处理时间几乎没有变化,而批处理的大小增加了一倍。批处理创建阶段现在非常明显地限制了我们的吞吐量-GPU基本上是空闲的,我们有一些大量的主机/设备内存传输。是时候解决这个问题了。

这些大量的主机/设备内存传输是由于GStreamer无法直接向我们提供位于GPU上的Pytorch张量,以便进行处理。如果仔细观察GStreamer管道,您会发现nvv4l2decder元素正在向下游传递video/x-raw(内存:NVMM)缓冲区-这告诉我们视频帧正在使用GPU进行解码。然后,管道使用nvVideoConvert元素将此GPU内存显式传输到主机(请注意突出显示的行上缺少(MEMORY:NVMM)):

管道=商品及服务税。Parse_Launch(f';';';filesrc location=media/in.mp4 num-Buffers=256!解码器!NvVideoConvert!Video/x-raw,format={frame_format}!假墨水名称=s';';';)

这允许GStreamer将解码的帧内容放入流水线中的常规(主机)缓冲区,然后在预处理期间将缓冲区传输回GPU。这是巨大的时间浪费,如果帧可以端到端地留在GPU上,速度会快得多。

如果我们在整个流水线中只保留GPU上的内存,会怎么样?它看起来如下所示:

管道=商品及服务税。Parse_Launch(f';';';filesrc location=media/in.mp4 num-Buffers=256!解码器!NvVideoConvert!Video/x-raw(内存:NVMM),FORMAT={FRAME_FORMAT}!假墨水名称=s';';';)。

我们的帧探测器已经添加到最后一个(Fakeink)元素的缓冲区接收器中,因此现在将使用表示GPU上内存的缓冲区来调用它。但是,这样的缓冲区是什么样子的呢?嗯,缓冲区包含的不是包含解码帧的像素的缓冲区,而是一个C结构-NvBufSurface。

NvBufSurface可以用来找出解码缓冲区的GPU内存地址,以及像大小和像素格式这样的帧特征。这些细节允许我们将此GPU内存直接复制到Pytorch张量中。这是设备到设备的内存传输,速度极快。

Def buffer_to_image_tensor(buf,caps):with nvtx_range(';buffer_to_image_tensor';):caps_structure=caps。Get_Structure(0)Height,Width=Caps_Structure。GET_VALUE(';Height';),CAPs_Structure。Get_value(';width';)is_map,map_info=buf。地图(GST。贴图标志。读取)IF IS_MAPPED:TRY:IMAGE_ARRAY=NP。Ndarray((高度,宽度,像素字节),dtype=np。Uint8,Buffer=MAP_INFO。数据)返回火炬。From_numpy(image_array[:,:,:3]。Copy()#rgba->;rgb,并将生命周期延长到后续取消映射之后)最后:buf。取消映射(MAP_INFO)。

我们需要将此更改为将GStreamer缓冲区解释为NvBufSurface C结构。NVIDIA提供了一个名为NVD的Python库来处理这些结构,但它有两个主要问题:

它没有提供一种方法来避免GPU到主机的缓冲区复制-对我们的目的毫无用处。

我已经创建了一个最小的基于ctype的模块,它与libnvbufface.so交互,并执行我们想要的操作:ghetto_nvds.py。

更新的高级buffer_to_image_tensor代码只需几个步骤就可以完成一件事-将NvBufSurface复制到匹配的NvBufSurface,其中目标数据指针指向预先分配的Pytorch张量。

Def buffer_to_image_tensor(buf,caps):with nvtx_range(';buffer_to_image_tensor';):caps_structure=caps。Get_Structure(0)Height,Width=Caps_Structure。GET_VALUE(';Height';),CAPs_Structure。Get_value(';width';)is_map,map_info=buf。地图(GST。贴图标志。读取)If is_map:try:source_Surface=ghetto_nvds。NvBufSurface(Map_Info)Torch_Surface=ghetto_NVDS。NvBufSurface(Map_Info)DEST_TENSOR=手电筒。零((火炬_表面。Surface eList[0]。高度、火炬表面。Surface eList[0]。宽度,4),dtype=手电筒。Uint8,device=device)Torch_Surface。Struct_copy_from(Source_Surface)断言(source_Surface。NumFill==1)Assert(SOURCE_SERFACE。Surface eList[0]。Color Format==19)#rgba#将Torch_Surface映射到DEST_TENSOR内存Torch_Surface。Surface eList[0]。DataPtr=DEST_TENSOR。Data_ptr()#将解码后的GPU缓冲区(Source_Surface)复制到Pytorch张量(torch_Surface->;desttensor)torch_Surface中。MEM_COPY_FROM(SOURCE_SERFACE)最后:buf。取消映射(Map_Info)返回DEST_TENSOR[:,:,:3]。

以前,批处理创建和预处理大约占端到端批处理时间的30%,随着这一变化,它们下降到10%左右。吞吐量现在从185FPS上升到235FPS。

Night Systems在批量创建过程中仍然显示了一些令人费解的设备到主机的内存传输,但深入研究发现这些是不需要同步的单字节传输-目前不是问题。

到目前为止,我们的流水线在批组件级别是高度串行的-后处理遵循推理,推理遵循预处理,预处理遵循批创建。它看起来像这样:

为了在保持串行的同时达到充分利用,每个组件都需要单独达到充分利用。这将是一项繁重的工作。

一种快捷方式是引入一些并发性,这样利用率不佳的多个执行线程加起来将在系统级别获得更高的利用率。在这里,我将在单个更改中引入多个级别的并发性,但是为了避免疯狂,这些步骤中的每个步骤都经过了增量测试。

结果相当不错。在添加这几个并发级别之前,我们的速度是235 FPS。现在,对于单个GPU,我们可以看到在两个GPU上分别运行350 FPS和650 FPS(禁用了性能跟踪)。

这一次,Night Systems没有立即指出前进的方向,尽管它表明我们的利用率没有达到最大化:

在寻找新的瓶颈时,我发现视频解码和张量创建阶段可以运行在3000 FPS以上,所以这是很好的。

凭直觉,我决定测量一下全局解释器锁(GIL)的负载。如果您正在阅读代码,您会知道我们在Python中几乎不做任何计算(因此在Python中花费尽可能少的时间来保存GIL),但是我们确实有多个并发的Python线程向GPU提交操作流。

使用名为gil_load的GIL采样工具,我测量出GIL处于活动状态的时间超过45%,进程等待获取它的时间超过30%。这是个问题。

为了研究这种GIL争用对吞吐量的影响,我添加了不同数量的虚假GIL消耗线程(它们只是重复执行1+1)。即使添加单个线程来消耗大约10%的GIL时间,吞吐量也会立即降低(大约50FPS)。

考虑到最初的目标是将基于Python的管道推向极限,我们已经走得足够远了。Python的GIL瓶颈的解决方案不是什么花招,而是停止使用Python编写数据路径代码。

我们已经大大改进了流水线-从9FPS到650FPS-但Night Systems的最新视图仍然显示出硬件上还有很大的净空空间。深入研究还显示了大量需要修复的问题--无法解释的小内存传输、不必要的同步等。

我会挖的。

.