当我们尝试那些开箱即用的GPU服务时,很快意识到在经济高效地利用GPU运行推荐模型服务之前需要对其优化。我们首先使用分析工具来分析在模型推理过程中发生了什么,在仔细观察分析结果时,我们注意到时间线上有大量的小CUDA Kernel在执行。
这是推荐系统模型的预期行为,其中数百个特征在模型后期阶段进行特征拼接之前需要单独处理。然而,对于大量的小算子,启动CUDA Kernel带来的开销比计算开销还要昂贵。与训练时的batchsize相比,模型服务时的batchsize相对更小,进而加剧了这一问题。
上图分别是模型优化前和优化后的分析结果。CUDA Kernel时间线(用红框突出显示)表明,Kernel的启动开销(蓝色块之间的间隙)显著减少,因此GPU得到了更好得利用,它将大部分时间花费在Kernel执行中。
我们采取的第一个方法是找出减少小op数量的机会。我们寻找常被使用的模型组件,并尽可能对其优化。其中一个例子是Embedding的查表模块,它包含两个计算步骤:原始ID到索引的查找,索引到Embedding的查找。由于我们有大量特征,这两步操作会被重复数百次。通过使用cuCollections (https://github.com/NVIDIA/cuCollections) 以支持GPU上原始ID的哈希表,并实现了自定义的Embedding查找模块以合并多个查找操作,我们显著地减少了op的数量。在执行一些优化后,马上看到了更优的性能表现。
GPU 架构的性能随着每一代的更新而不断提高。 现代 GPU 每个操作(如kernel运行或内存复制)所花费的时间现在以微秒为单位。 但是,将每个操作提交给 GPU 也会产生一些开销——也是微秒级的。实际的应用程序中经常要执行大量的 GPU 操作:典型模式涉及许多迭代(或时间步),每个步骤中有多个操作。 如果这些操作中的每一个都单独提交到 GPU 启动并独立计算,那么提交启动开销汇总在一起可能导致明显的整体性能下降。CUDA Graphs 将整个计算流程定义为一个图而不是单个操作的列表。 最后通过提供一种由单个 CPU 操作来启动图上的多个 GPU 操作的方式减少kernel提交启动开销,进而解决上述问题。
CUDA Graph允许我们将模型推理过程捕捉为静态图,而不是单独调度。它可以让整个计算作为一个单元进行执行,而不产生任何Kernel启动开销。我们支持将CUDA Graph作为模型服务的一个新后端。一开始加载模型时,模型服务执行一次模型推理以构建图实例,该实例可以针对实时流量重复执行。
CUDA Graph自身的一些限制给我们的模型服务带来了额外的复杂性。其中最大的限制是CUDA Graph要求所有Tensor都具有静态形状和布局,这对动态大小批次和不规则的变长Tensor带来了挑战。然而,我们相信为了获得更好性能进行的权衡是值得的,并且我们可以将输入Tensor补齐到精心挑选的静态形状。
深度学习算子优化的核心就是进行kernel fusion,通过减少kernel的数目来减少kernel launch的开销,同时使得中间计算结果尽量的复用GPU Cache,降低GPU内存带宽的压力。
在视频模型里面,Conv3D+BN+MaxPool3D是一个很重要的算子组合,其性能直接影响整个模型的性能。由于Conv3D为计算密集型算子,因此我们尝试使用TensorCore来加速,但是实际发现性能并没有明显的提升。经过分析,问题出现在图片通道转换上。PyTorch的Conv3D和BN算子仅支持Channels First格式输入,而TencorCore的Conv3D必须是Channels Last格式,这就是造成了如果使用TensorCore加速Conv3D,就会自动插入Channels First到Channels Last通道转算算子,如下图所示:
中间的kernel就是Conv3D,在其前后分别插入两个通道转换算子,使得TensorCore加速的收益被通道转换抵消掉了。为了兼容TensorCore的Channels Last格式,我们需要把整个模型都改成该格式,才能保证模型从头至尾不需要通道转换。因此我们修改了PyTorch的Conv3D和BN算子,使其支持Channels Last格式,但是PyTorch自带的MaxPool3D算子在Channels Last格式下性能很低,直接使用的话会抵消掉前面的优化。经过调研,发现Cudnn的MaxPool3D算子性能满足要求,但是功能不满足,该算子只能输出最大值,却不输出最大值的Index信息。而Index信息在接下来的MaxUnpool3D使用。经过反复分析,我们发现MaxUnpool的过程,其实和Pooling的backward过程是相同的,因此我们采用了CudnnPoolingBackward方法来代替MaxUnpool3D,同时给backward方法传入Pooling前后的值,以使其推算Index信息,替换后的效果如下:
分析视频的第一步是读取视频,但是在分析视频时,并不需要每一帧都分析,而是按照一定频率采样,比如1/5。如果使用OpenCV提供的read接口读入每一帧,会造成80%的读取是无意义的,浪费宝贵的内存并造成延时增大。
经过分析OpenCV的文档,发现read方法其实是分两步来实现的,1.移动指针至下一帧位置;2.对视频内容进行解析。如下图:
而步骤1指针移动的速度非常快,耗时几乎可以忽略,而步骤2视频解析才是大头。因此,我们可以使用OpenCV的一对组合函数, grab和retrieve来加速视频读取,对于未采样的帧只grap,对于采样的帧才进行retrieve解码。如下图所示:
经过测试,在1/5采样率的情况下,grab + retrieve方法读取一个视频的时间是read的 1/5。
除了上述方面,对于超大时长的视频,还可以使用多线程同时读取。每个线程设置各自读取的起始帧,就可以达到把1个视频拆分成N个视频同时读取的效果。如下图所示:
https://github.com/hpcaitech/ColossalAI
仅需1% Embedding参数,硬件成本降低十倍,开源方案单GPU训练超大推荐模型