CTR模型在互联网的搜索、推荐、广告等场景有着广泛的应用。近年来,随着深度神经网络的引入,CTR模型的推理对硬件算力的要求逐渐增加。本文介绍了美团在CTR模型优化的实践。通过分析模型结构特点,结合GPU硬件架构,我们设计了一系列流程对模型进行定制优化,达到了降低延迟、提高吞吐、节省成本的目标。
CTR(Click-Through-Rate)即点击通过率,是指网络广告的点击到达率,即该广告的实际点击次数除以广告的展现量。为CTR指标服务的打分模型,一般称为CTR模型。我们可以将此概念进一步扩展到互联网应用中各种预估转化率的模型。CTR模型在推荐、搜索、广告等场景被广泛应用。相对于CV(计算机视觉)、NLP(自然语音处理)场景的模型,CTR模型的历史结构比较简单,计算量较小。美团的CTR模型一直沿用CPU推理的方式。随着近几年深度神经网络的引入,CTR模型结构逐渐趋于复杂,计算量也越来越大,CPU开始不能满足模型对于算力的需求
而GPU拥有几千个计算核心,可以在单机内提供密集的并行计算能力,在CV、NLP等领域展示了强大的能力。通过CUDA[1]及相关API,英伟达建立了完整的GPU生态。基于此,美团基础研发平台通过一套方案将CTR模型部署到GPU上。单从模型预测阶段看,我们提供的基于英伟达T4的GPU深度优化方案,在相同成本约束下,对比CPU,提升了10倍的吞吐能力。同时,在典型的搜索精排场景中,从端到端的维度来看,整体吞吐能力提升了一倍以上。
除了提高吞吐、降低成本外,GPU方案还为CTR模型的应用带来了额外的可能。例如,在某搜索框自动补全的场景,由于天然的交互属性,时延要求非常苛刻,一般来说无法使用复杂的模型。而在GPU能力的加持下,某复杂模型的平均响应时间从15毫秒降低至6~7毫秒,已经达到了上线要求。
接下来,本文将与大家探讨美团机器学习平台提供的新一代CTR预测服务的GPU优化思路、效果、优势与不足,希望对从事相关工作的同学有所帮助或者启发。
第一,TensorFlow的算子粒度划分较细,导致一个模型通常由几千个算子构成,这些算子在GPU上的执行转变为对应的GPU kernel的执行。kernel是GPU上并行执行的函数。
GPU kernel大体上可以划分为传输数据、kernel启动、kernel计算等几个阶段,其中每个kernel的启动需要约10左右。大量的小算子导致每个kernel的执行时间很短,kernel启动的耗时占了大部分。相邻的kernel之间需要通过读写显存进行数据的传输,产生大量的访存开销。而GPU的访存吞吐远远低于计算吞吐,导致性能低下,GPU利用率并不高。
第二,GPU卡上包含多个计算单元,理论上,不同计算单元是可以跑不同kernel的,但实际上为了编程简单,CUDA默认假设在同一时刻一个Stream里跑同一个kernel。虽然可以通过多Stream的方式跑,但是多Steam之间又缺少细粒度的协同机制。
在经过充分调研与讨论后,我们决定第一期重点关注TensorFlow框架下如何解决常见CTR模型结构在英伟达GPU上执行效率不高的问题,我们先将问题收敛为以下两个子问题:
为了解决上面的问题,我们对业界深度学习加速器进行了一些调研。业界比较成熟的推理优化方案主要是TensorRT/XLA/TVM。TensorRT采用手工优化,对一些定制的模型结构进行算子融合,并对计算密集型算子(如卷积)进行了高效调优。XLA是TensorFlow内置的编译优化工具,主要针对访存密集型结构,通过编译手段,实现算子的融合。TVM[2]具备较全面的优化能力,使用编译手段进行算子的融合,同时可以通过机器学习的方式实现计算密集型算子的自动调优。
经过广泛的调研和对比,我们最终选择了TVM作为优化工具。TVM通过编译手段,可以较好地应对多变的模型结构,解决了手工优化通用性差的问题。但TVM应用在业务模型也存在一系列问题:支持的算子数较少,而且目前对动态Shape的支持还不够好。针对这两个问题,我们将TVM和TensorFlow结合起来,结合CTR模型的结构特点与GPU的硬件特性,开发一系列流程,实现了对CTR模型的优化。
通过将多个小算子融合为一个语义等价的大算子,可以有效减少GPU上的kernel数量。一方面,kernel数量减少直接降低了kernel发射的开销;另一方面,融合后的大kernel执行的计算量增加,避免了多个kernel间数据传输导致的频繁访存,提高了计算的访存比。
可以看到,上图中的左右等价结构,左侧的21个算子执行的运算,可以在1个等价算子中完成。反映到GPU的活动上,左侧至少有21个GPU kernel以及21次显存的读写,而右侧只需要执行1个kernel以及1次显存读写。对于每个融合后的算子,需要有对应的kernel实现。然而,模型的算子组合是无穷的,对每种融合后算子手工实现kernel是不现实的。TVM通过编译手段,可以自动进行算子的融合以及设备代码生成,避免了逐一手写kernel的负担。
3.1.1 TF-TVM自动切图优化
TensorFlow模型中,如果包含TVM不支持的算子,会导致无法执行TVM转换。我们的思路是将可以用TVM优化的部分切出来,转为TVM的engine,其他部分依然使用TensorFlow的算子。在XLA和TRT转换的时候也有类似问题,我们分析了TF-XLA和TF-TRT二者的实现:
在最终方案实现上,我们参考了TF-TRT的设计。这个设计对比XLA的优势在于XLA切图方案与TensorFlow源码紧耦合,直接将XLA的三个Pass嵌入到了启动Session的主流程中。而切图策略,优化策略后续会有非常频繁的迭代,我们不希望与TensorFlow的源码太过耦合。我们扩展了TF-TVM的方案,在实际使用中我们把这个切图过程为一个独立流程。在模型部署或更新时,自动触发。
在推理阶段,优化过的子图使用TVM执行,其余的计算图使用TensorFlow原生实现执行,将两者结合共同完成模型的推理。由于TVM和TensorFlow的Runtime各自使用独立的内存管理,数据在不同框架间传输会导致额外的性能开销。为了降低这部分开销,我们打通了两个框架的底层数据结构,尽可能避免额外的数据拷贝。
3.1.2 计算图等价替换
TensorFlow模型中过多的不被TVM支持的算子会导致TF-TVM切图零碎,影响最终的优化效果。为了让TF-TVM切图尽量大且完整,以及让TVM优化过程中的融合力度更大,我们对模型中的一些复杂结构进行检测,替换为执行更高效或更易于融合的等价结构。
例如,TensorFlow原生EmbeddingLookup结构,为了支持分布式训练,会对Embedding表进行切分,产生DynamicPartition和ParallelDynamicStitch等动态算子。这些动态算子不被TVM支持,导致TF-TVM图切分过于细碎。为了让TF-TVM切图更完整,我们通过图替换,对这种结构进行修改,通过将Embedding分表提前合并,得到简化的EmbeddingLookup结构。
TVM优化后的子图被替换为一个节点,该节点在GPU上执行,通常有几十甚至几百个输入,该节点的前置输入(如Placeholder)通常是在CPU上执行,会涉及多次的CPU-GPU传输。频繁的小数据量传输,无法充分利用带宽。为了解决这个问题,我们对模型结构进行修改,在计算图中添加合并与拆分节点,控制切图的位置,减少数据传输的次数。
一种可能的合并方式是,对这些输入按相同的Shape和Dtype进行合并,后续进行拆分,将拆分节点切入TVM的子图一起优化。这种方式会导致一些问题,如部分子图的算子融合效果不佳;另一方面,GPU kernel函数的参数传递内存限制在4KB,对于TVM节点输入非常多的情况(如超过512个),会遇到生成代码不合法的情况。
对于TVM无法支持的子图,我们对业务中高频使用的结构进行抽象,采用手写自定义算子的方式,进行了高效GPU实现。
例如,模型中有部分时序特征使用String类型输入,将输入的字符串转为补齐的数字Tensor,将int类型的Tensor作为下标进行Embedding操作。这部分子图的语义如图,以下简称SE结构(StringEmbedding):
这一部分结构,TensorFlow的原生实现只有基于CPU的版本,在数据量较大且并行度较高的情景下,性能下降严重,成为整个模型的瓶颈。为了优化这部分结构的性能,我们在GPU上实现了高效的等价操作。
如图所示,PadString算子在CPU端将多个字符串按最大长度进行补齐,拼接成一个内存连续的uint8类型Tensor,以便一次性传输到GPU。StringEmbedding接收到补齐后的字符串后,利用GPU并行计算的特性,协同大量线程完成字符串的切分与查表操作。在涉及规约求和、求前缀和等关键过程中,使用了GPU上的Reduce/Scan算法,编码过程使用warp_shuffle
指令,不同线程通过寄存器交换数据,避免了频繁访存的开销,获得了很好的性能。
GPU Scan算法示意,一个8个元素的前缀和操作,只需要3个迭代周期。在一个有几十路类似操作的模型中,手工优化前后的GPU timeline对比如下图,可以看到H2D + StringEmbedding这部分结构的耗时有很大的缩减,从42毫秒缩减到1.83毫秒。
除了StringEmbedding结构,我们对StringSplit + ToNumber + SparseSegmentSqrt、多路并行StringEmbedding等结构都进行了高效融合实现,在优化流程中通过结构匹配进行相应的替换。
实际线上的RPC请求,每个请求内的样本数(下文称Batch)是在[1,MaxValue]范围内变化的,MaxValue受上游业务系统,其他基础系统能力等多方面因素制约,相对固定。如上图所示,以某个搜索服务为例,我们统计了线上的Batch数值分布,Batch=MaxValue的请求占比约45%,Batch=45占比7.4%,Batch=1占比2.3%。其余的Batch占比从0.5%到1%不等。对于GPU来说,提高单个请求的Batch能更好地利用硬件资源,发挥GPU的并行计算能力,表现出相对CPU更优的延迟和吞吐;当Batch较小时,GPU相对CPU的优势就不明显了(下图是我们测试同样的模型在固定压力下,CPU/GPU上延迟的变化)。
大部分请求都由GPU在做了,CPU资源有较多空余,我们将一些小Batch的碎请求放在CPU运行,这样可以让整个Worker的资源利用更加均衡,提高系统整体的性能。我们根据测试设定了一个Batch阈值,以及计算图在异构硬件上区别执行的判断逻辑:对于小Batch的情况,直接在CPU上执行计算图,只有Batch超过阈值的请求才会在GPU上推理。从线上的统计数据来看,整体流量的77%跑在GPU上,23%跑在CPU上。
在GPU的一系列优化策略和动作中,Batch大小是很重要的信息,不同Batch下优化出的kernel实现可能是不同的,以达到对应workload下最优的计算性能;由于线上的流量特点,发送到GPU的请求Batch分布比较细碎,如果我们针对每个Batch都优化一个模型的kernel实现显然是不够经济和通用的。因此,我们设计了一个Batch分桶策略,生成N个固定Batch的优化模型,在实际请求到来时找到Batch距离最近的一个Bucket,将请求向上Padding到对应的Batch计算,从而提高了GPU的利用效率。
我们选取一个模型进行线上性能压测分析。
下图对比了在不同的QPS下(x轴),GPU模型在各BatchSize下的推理时延(y轴)。GPU模型在BatchSize=128以下,推理耗时差异不明显,较大的BatchSize更有利于吞吐;对比BatchSize=256的GPU模型与BatchSize为25的CPU模型,在QPS低于64的情况下,二者推理耗时基本持平;QPS超过64的情况下,GPU的推理时延低于CPU。GPU的吞吐相比CPU提升了10倍。
同时,我们可以看到不同曲线的陡峭程度,CPU在QPS高出64后,时延会迅速上升,GPU则依然保持平稳,直到QPS超过128才会有明显上升,但仍旧比CPU更平稳。
针对CTR模型的结构特点,我们抽象出了一套平台化的通用优化流程。通过对模型结构的分析,自动应用合适的优化策略,通过性能评估和一致性校验,保证模型的优化效果。
在易用性层面,目前的方案形式是提供了一套在线优化脚本,用户提交模型后,自动优化部署。由于涉及对计算图结构的分析、编辑以及TVM的编译等过程,目前的模型优化耗时较长,大部分模型优化耗时在20分钟左右。后续需要考虑加速TVM编译的效率。
在通用性层面,从我们的实际应用情况来看,TVM编译优化和高性能手写算子是最主要的收益来源。手工优化很考验开发同学对业务模型的理解和GPU编程的能力。编写一个高性能的融合算子已经不太容易,要做到有一定的迁移能力和扩展性则更有难度。
总的来说,CTR模型推理在GPU上未来需要考虑的问题还有很多。除了要基于业务理解提供更好的性能外,还要考虑模型规模巨大后无法完整放入显存的问题以及支持在线模型更新的问题。