作者:亚劼 英亮 陈龙等
导语
随着美团外卖业务不断发展,外卖广告引擎团队在多个领域进行了工程上的探索和实践,也取得了一些成果。我们将以连载的方式进行分享,内容主要包括:① 业务平台化的实践;② 大规模深度学习模型工程实践;③ 近线计算的探索与实践;④ 大规模索引构建与在线检索服务实践;⑤ 机制工程平台化实践。不久前,我们已发表过业务平台化的实践(详情请参阅《美团外卖广告平台化的探索与实践》一文)。本文为连载文章的第二篇,我们将重点针对大规模深度模型在全链路层面带来的挑战,从在线时延、离线效率两个方面进行展开,阐述广告在大规模深度模型上的工程实践,希望能为大家带来一些帮助或者启发。
1 背景
在搜索、推荐、广告(下简称搜推广)等互联网核心业务场景下,进行数据挖掘及兴趣建模,为用户提供优质的服务,已经成为改善用户体验的关键要素。近几年,针对搜推广业务,深度学习模型凭借数据红利和硬件技术红利,在业界得以广泛落地,同时在CTR场景,业界逐步从简单DNN小模型过渡到数万亿参数的Embedding大模型甚至超大模型。外卖广告业务线主要经历了“LR浅层模型(树模型)” -> “深度学习模型” -> “大规模深度学习模型”的演化过程。整个演化趋势从以人工特征为主的简单模型,逐步向以数据为核心的复杂深度学习模型进行过渡。而大模型的使用,大幅提高了模型的表达能力,更精准地实现了供需侧的匹配,为后续业务发展提供了更多的可能性。但随着模型、数据规模的不断变大,我们发现效率跟它们存在如下的关系:根据上图所示,在数据规模、模型规模增长的情况下,所对应的“时长”变得会越来越长。这个“时长”对应到离线层面,体现在效率上;对应到在线层面,就体现在Latency上。而我们的工作就是围绕这个“时长”的优化来开展。
2 分析
相比普通小模型,大模型的核心问题在于:随着数据量、模型规模增加数十倍甚至百倍,整体链路上的存储、通信、计算等都将面临新的挑战,进而影响算法离线的迭代效率。如何突破在线时延约束等一系列问题?我们先从全链路进行分析,如下所示:
“时长”变长,主要会体现在以下几个方面:
- 在线时延:特征层面,在线请求不变的情况下,特征量的增加,带来的IO、特征计算耗时增加等问题尤为突出,需要在特征算子解析编译、特征抽取内部任务调度、网络I/O传等方面重塑。在模型层面,模型历经百M/G到几百G的变化,在存储上带来了2个数量级的上升。此外,单模型的计算量也出现了数量级的上涨(FLOPs从百万到现在千万),单纯的靠CPU,解决不了巨大算力的需求,建设CPU+GPU+Hierarchical Cache推理架构来支撑大规模深度学习推理势在必行。
- 离线效率:随着样本、特征的数倍增加,样本构建,模型训练的时间会被大大拉长,甚至会变得不可接受。如何在有限的资源下,解决海量样本构建、模型训练是系统的首要问题。在数据层面,业界一般从两个层面去解决,一方面不断优化批处理过程中掣肘的点,另一方面把数据“化批为流”,由集中式转到分摊式,极大提升数据的就绪时间。在训练层面,通过硬件GPU并结合架构层面的优化,来达到加速目的。其次,算法创新往往都是通过人来驱动,新数据如何快速匹配模型,新模型如何快速被其他业务应用,如果说将N个人放在N条业务线上独立地做同一个优化,演变成一个人在一个业务线的优化,同时广播适配到N个业务线,将会有N-1个人力释放出来做新的创新,这将会极大地缩短创新的周期,尤其是在整个模型规模变大后,不可避免地会增加人工迭代的成本,实现从“人找特征/模型” 到“特征/模型找人”的深度转换,减少“重复创新”,从而达到模型、数据智能化的匹配。
- Pipeline其他问题:机器学习Pipeline并不是在大规模深度学习模型链路里才有,但随着大模型的铺开,将会有新的挑战,比如:① 系统流程如何支持全量、增量上线部署;② 模型的回滚时长,把事情做正确的时长,以及事情做错后的恢复时长。简而言之,会在开发、测试、部署、监测、回滚等方面产生新的诉求。
本文重点从在线时延(模型推理、特征服务)、离线效率(样本构建、数据准备)等两个方面来展开,逐步阐述广告在大规模深度模型上的工程实践。如何去优化“时长”等相关问题,我们会在后续篇章介进行分享,敬请期待。
3 模型推理
在模型推理层面,外卖广告历经了三个版本,从1.0时代,支持小众规模的DNN模型为代表,到2.0时代,高效、低代码支持多业务迭代,再到如今的3.0时代,逐步面向深度学习DNN算力以及大规模存储的需求。主要演进趋势如下图所示:
面向大模型推理场景,3.0架构解决的两个核心问题是:“存储问题”和“性能问题”。当然,面向N个百G+模型如何迭代,运算量数十倍增加时在线稳定性如何保障,Pipeline如何加固等等,也是工程面临的挑战。下面我们将重点介绍模型推理3.0架构是如何通过“分布式”来解决大模型存储问题,以及如何通过CPU/GPU加速来解决性能、吞吐问题。
3.1 分布式
大模型的参数主要分为两部分:Sparse参数和Dense参数。
- Sparse参数:参数量级很大,一般在亿级别,甚至十亿/百亿级别,这会导致存储空间占用较大,通常在百G级别,甚至T级别。其特点:①单机加载困难:在单机模式下,Sparse参数需全部加载到机器内存中,导致内存严重吃紧,影响稳定性和迭代效率;②读取稀疏:每次推理计算,只需读取部分参数,比如User全量参数在2亿级别,但每次推理请求只需读取1个User参数。
- Dense参数:参数规模不大,模型全连接一般在2~3层,参数量级在百万/千万级别。特点:① 单机可加载:Dense参数占用在几十兆左右,单机内存可正常加载,比如:输入层为2000,全连接层为[1024, 512, 256],总参数为:2000 * 1024 + 1024 * 512 + 512 * 256 + 256 = 2703616,共270万个参数,内存占用在百兆内;② 全量读取:每次推理计算,需要读取全量参数。
因此,解决大模型参数规模增长的关键是将Sparse参数由单机存储改造为分布式存储,改造的方式包括两部分:① 模型网络结构转换;② Sparse参数导出。
3.1.1 模型网络结构转换
业界对于分布式参数的获取方式大致分为两种:外部服务提前获取参数并传给预估服务;预估服务内部通过改造TF(TensorFlow)算子来从分布式存储获取参数。为了减少架构改造成本和降低对现有模型结构的侵入性,我们选择通过改造TF算子的方式来获取分布式参数。
正常情况下,TF模型会使用原生算子进行Sparse参数的读取,其中核心算子是GatherV2算子,算子的输入主要有两部分:① 需要查询的ID列表;② 存储Sparse参数的Embedding表。
算子的作用是从Embedding表中读取ID列表索引对应的Embedding数据并返回,本质上是一个Hash查询的过程。其中,Embedding表存储的Sparse参数,其在单机模型中全部存储在单机内存中。
改造TF算子本质上是对模型网络结构的改造,改造的核心点主要包括两部分:① 网络图重构;② 自定义分布式算子。
1. 网络图重构:改造模型网络结构,将原生TF算子替换为自定义分布式算子,同时进行原生Embedding表的固化。
- 分布式算子替换:遍历模型网络,将需要替换的GatherV2算子替换为自定义分布式算子MtGatherV2,同时修改上下游节点的Input/Output。
- 原生Embedding表固化:原生Embedding表固化为占位符,既能保留模型网络结构完整,又能避免Sparse参数对单机内存的占用。
2. 自定义分布式算子:改造根据ID列表查询Embedding流程,从本地Embedding表中查询,改造为从分布式KV中查询。
- 请求查询:将输入ID进行去重以降低查询量,并通过分片的方式并发查询二级缓存(本地Cache + 远端KV)获取Embedding向量。
- 模型管理:维护对模型Embedding Meta注册、卸载流程,以及对Cache的创建、销毁功能。
- 模型部署:触发模型资源信息的加载,以及对Embedding数据并行导入KV的流程。
3.1.2 Sparse参数导出
- 分片并行导出:解析模型的Checkpoint文件,获取Embedding表对应的Part信息,并根据Part进行划分,将每个Part文件通过多个Worker节点并行导出到HDFS上。
- 导入KV:提前预分配多个Bucket,Bucket会存储模型版本等信息,便于在线路由查询。同时模型的Embedding数据也会存储到Bucket中,按分片并行方式导入到KV中。
整体流程如下图所示,我们通过离线分布式模型结构转换、近线数据一致性保证、在线热点数据缓存等手段,保障了百G大模型的正常迭代需求。
可以看到,分布式借助的存储是外部KV能力,后续会替换为更加高效、灵活、易管理的Embedding Service。
3.2 CPU加速
抛开模型本身的优化手段外,常见的CPU加速手段主要有两种:① 指令集优化,比如使用AVX2、AVX512指令集;② 使用加速库(TVM、OpenVINO)。
- 指令集优化:如果使用TensorFlow模型,在编译TensorFlow框架代码时,直接在编译选项里加入指令集优化项即可。实践证明引入AVX2、AVX512指令集优化效果明显,在线推理服务吞吐提升30%+。
- 加速库优化:加速库通过对网络模型结构进行优化融合,以达到推理加速效果。业界常用的加速库有TVM、OpenVINO等,其中TVM支持跨平台,通用性较好。OpenVINO面向Intel厂商硬件进行针对性优化,通用性一般,但加速效果较好。
下面,将会重点介绍我们使用OpenVINO进行CPU加速的一些实践经验。OpenVINO是Intel推出的一套基于深度学习的计算加速优化框架,支持机器学习模型的压缩优化、加速计算等功能。OpenVINO的加速原理简单概括为两部分:线性算子融合和数据精度校准。
- 线性算子融合:OpenVINO通过模型优化器,将模型网络中的多层算子进行统一线性融合,以降低算子调度开销和算子间的数据访存开销,比如将Conv+BN+Relu三个算子合并成一个CBR结构算子。
- 数据精度校准:模型经过离线训练后,由于在推理的过程中不需要反向传播,完全可以适当降低数据精度,比如降为FP16或INT8的精度,从而使得内存占用更小,推理延迟更低。
CPU加速通常是针对固定Batch的候选队列进行加速推理,但在搜推广场景中,候选队列往往都是动态的。这就意味着在模型推理之前,需要增加Batch匹配的操作,即将请求的动态Batch候选队列映射到一个离它最近的Batch模型上,但这需构建N个匹配模型,导致N倍的内存占用。而当前模型体积已达百G规模,内存严重吃紧。因此,选取合理的网络结构用于加速是需要考虑的重点问题。下图是整体的运行架构:
- 网络分布:CTR模型网络结构整体抽象为三部分:Embedding层、Attention层和MLP层,其中Embedding层用于数据获取,Attention层包含较多逻辑运算和轻量级的网络计算,MLP层则为密集网络计算。
- 加速网络选择:OpenVINO针对纯网络计算的加速效果较好,可以很好地应用于MLP层。另外,模型大部分数据存储在Embedding层中,MLP层占内存只有几十兆左右。如果针对MLP层网络划分出多个Batch,模型内存占用在优化前(Embedding+Attention+MLP)≈ 优化后(Embedding+Attention+MLP×Batch个数),对于内存占用的影响较小。因此,我们最终选取MLP层网络作为模型加速网络。
目前,基于OpenVINO的CPU加速方案已经在生产环境取得不错效果:CPU与基线持平时,服务吞吐提升40%,平均时延下降15%。如果大家想在CPU层面做些加速的话,OpenVINO是个不错的选择。
3.3 GPU加速
一方面,随着业务的发展,业务形态越来越丰富,流量越来越高,模型变宽变深,算力的消耗急剧增加;另一方面,广告场景主要使用DNN模型,涉及大量稀疏特征Embedding和神经网络浮点运算。作为访存和计算密集型的线上服务,在保证可用性的前提下,还要满足低延迟、高吞吐的要求,对单机算力也是一种挑战。这些算力资源需求和空间的矛盾,如果解决不好,会极大限制业务的发展:在模型加宽加深前,纯CPU 推理服务能够提供可观的吞吐,但是在模型加宽加深后,计算复杂度上升,为了保证高可用性,需要消耗大量机器资源,导致大模型无法大规模应用于线上。目前,业界比较通用的解决办法是利用GPU来解决这个问题,GPU本身比较适用于计算密集型任务。使用GPU需要解决如下挑战:如何在保证可用性、低延迟的前提下,尽可能做到高吞吐,同时还需要考虑易用性和通用性。为此,我们也在GPU上做了大量实践工作,比如TensorFlow-GPU、TensorFlow-TensorRT、TensorRT等,为了兼顾TF的灵活性以及TensorRT的加速效果,我们采用TensorFlow+TensorRT独立两阶段的架构设计。
3.3.1 加速分析
- 异构计算:我们的思路跟CPU加速比较一致,200G的深度学习CTR模型不能直接全放入到GPU里,访存密集型算子适用(比如Embedding相关操作)CPU,计算密集型算子(比如MLP)适用GPU。
- GPU使用需要关注的几个点:① 内存与显存的频繁交互;② 时延与吞吐;③ 扩展性与性能优化的Trade Off;④ GPU Utilization 。
- 推理引擎的选择:业界常用推理加速引擎有TensorRT、TVM、XLA、ONNXRuntime等,由于TensorRT在算子优化相比其他引擎更加深入,同时可以通过自定义plugin的方式实现任意算子,具有很强的扩展性。而且TensorRT支持常见学习平台(Caffe、PyTorch、TensorFlow等)的模型,其周边越来越完善(模型转换工具onnx-tensorrt、性能分析工具nsys等),因此在GPU侧的加速引擎使用TensorRT。
- 模型分析:CTR模型网络结构整体抽象为三部分:Embedding层、Attention层和MLP层,其中Embedding层用于数据获取,适合CPU;Attention层包含较多逻辑运算和轻量级的网络计算,MLP层则重网络计算,而这些计算可以并行进行,适合GPU,可以充分利用GPU Core(Cuda Core、Tensor Core),提高并行度。
3.3.2 优化目标
深度学习推理阶段对算力和时延具有很高的要求,如果将训练好的神经网络直接部署到推理端,很有可能出现算力不足无法运行或者推理时间较长等问题。因此,我们需要对训练好的神经网络进行一定的优化。业界神经网络模型优化的一般思路,可以从模型压缩、不同网络层合并、稀疏化、采用低精度数据类型等不同方面进行优化,甚至还需要根据硬件特性进行针对性优化。为此,我们主要围绕以下两个目标进行优化:
- 延时和资源约束下的吞吐:当register、Cache等共享资源不需要竞争时,提高并发可有效提高资源利用率(CPU、GPU等利用率),但随之可能带来请求延时的上涨。由于在线系统的延时限制非常苛刻,所以不能只通过资源利用率这一指标简单换算在线系统的吞吐上限,需要在延时约束下结合资源上限进行综合评估。当系统延时较低,资源(Memory/CPU/GPU等)利用率是制约因素时,可通过模型优化降低资源利用率;当系统资源利用率均较低,延时是制约因素时,可通过融合优化和引擎优化来降低延时。通过结合以上各种优化手段可有效提升系统服务的综合能力,进而达到提升系统吞吐的目的。
- 计算量约束下的计算密度:CPU/GPU异构系统下,模型推理性能主要受数据拷贝效率和计算效率影响,它们分别由访存密集型算子和计算密集型算子决定,而数据拷贝效率受PCIe数据传输、CPU/GPU内存读写等效率的影响,计算效率受各种计算单元CPU Core、CUDA Core、Tensor Core等计算效率的影响。随着GPU等硬件的快速发展,计算密集型算子的处理能力同步快速提高,导致访存密集型算子阻碍系统服务能力提升的现象越来越突出,因此减少访存密集型算子,提升计算密度对系统服务能力也变得越来越重要,即在模型计算量变化不大的情况下,减少数据拷贝和kernel launch等。比如通过模型优化和融合优化来减少算子变换(比如Cast/Unsqueeze/Concat等算子)的使用,使用CUDA Graph减少kernel launch等。
下面将围绕以上两个目标,具体介绍我们在模型优化、融合优化和引擎优化所做的一些工作。
3.3.3 模型优化
1. 计算与传输去重:推理时同一Batch只包含一个用户信息,因此在进行inference之前可以将用户信息从Batch Size降为1,真正需要inference时再进行展开,降低数据的传输拷贝以及重复计算开销。如下图,inference前可以只查询一次User类特征信息,并在只有用户相关的子网络中进行裁剪,待需要计算关联时再展开。
- 自动化过程:找到重复计算的结点(红色结点),如果该结点的所有叶子结点都是重复计算结点,则该结点也是重复计算结点,由叶子结点逐层向上查找所有重复结点,直到结点遍历查找完,找到所有红白结点的连接线,插入User特征扩展结点,对User特征进行展开。
2. 数据精度优化:由于模型训练时需要反向传播更新梯度,对数据精度要求较高;而模型推理时,只进行前向推理不需要更新梯度,所以在保证效果的前提下,使用FP16或混合精度进行优化,节省内存空间,减少传输开销,提升推理性能和吞吐。
3. 计算下推:CTR模型结构主要由Embedding、Attention和MLP三层构成,Embedding层偏数据获取,Attention有部分偏逻辑,部分偏计算,为了充分压榨GPU的潜力,将CTR模型结构中Attention和MLP大部分计算逻辑由CPU下沉到GPU进行计算,整体吞吐得到大幅提升。
3.3.4 融合优化
在线模型inference时,每一层的运算操作都是由GPU完成,实际上是CPU通过启动不同的CUDA kernel来完成计算,CUDA kernel计算张量的速度非常快,但是往往大量的时间是浪费在CUDA kernel的启动和对每一层输入/输出张量的读写操作上,这造成了内存带宽的瓶颈和GPU资源的浪费。这里我们将主要介绍TensorRT部分自动优化以及手工优化两块工作。1. 自动优化:TensorRT是一个高性能的深度学习inference优化器,可以为深度学习应用提供低延迟、高吞吐的推理部署。TensorRT可用于对超大规模模型、嵌入式平台或自动驾驶平台进行推理加速。TensorRT现已能支持TensorFlow、Caffe、MXNet、PyTorch等几乎所有的深度学习框架,将TensorRT和NVIDIA的GPU结合起来,能在几乎所有的框架中进行快速和高效的部署推理。而且有些优化不需要用户过多参与,比如部分Layer Fusion、Kernel Auto-Tuning等。
- Layer Fusion:TensorRT通过对层间的横向或纵向合并,使网络层的数量大大减少,简单说就是通过融合一些计算op或者去掉一些多余op,来减少数据流通次数、显存的频繁使用以及调度的开销。比如常见网络结构Convolution And ElementWise Operation融合、CBR融合等,下图是整个网络结构中的部分子图融合前后结构图,FusedNewOP在融合过程中可能会涉及多种Tactic,比如CudnnMLPFC、CudnnMLPMM、CudaMLP等,最终会根据时长选择一个最优的Tactic作为融合后的结构。通过融合操作,使得网络层数减少、数据通道变短;相同结构合并,使数据通道变宽;达到更加高效利用GPU资源的目的。
- Kernel Auto-Tuning:网络模型在inference时,是调用GPU的CUDA kernel进行计算。TensorRT可以针对不同的网络模型、显卡结构、SM数量、内核频率等进行CUDA kernel调整,选择不同的优化策略和计算方式,寻找适合当前的最优计算方式,以保证当前模型在特定平台上获得最优的性能。上图是优化主要思想,每一个op会有多种kernel优化策略(cuDNN、cuBLAS等),根据当前架构从所有优化策略中过滤低效kernel,同时选择最优kernel,最终形成新的Network。
2. 手工优化:众所周知,GPU适合计算密集型的算子,对于其他类型算子(轻量级计算算子,逻辑运算算子等)不太友好。使用GPU计算时,每次运算一般要经过几个流程:CPU在GPU上分配显存 -> CPU把数据发送给GPU -> CPU启动CUDA kernel -> CPU把数据取回 -> CPU释放GPU显存。为了减少调度、kernel launch以及访存等开销,需要进行网络融合。由于CTR大模型结构灵活多变,网络融合手段很难统一,只能具体问题具体分析。比如在垂直方向,Cast、Unsqueeze和Less融合,TensorRT内部Conv、BN和Relu融合;在水平方向,同维度的输入算子进行融合。为此,我们基于线上实际业务场景,使用NVIDIA相关性能分析工具(NVIDIA Nsight Systems、NVIDIA Nsight Compute等)进行具体问题的分析。把这些性能分析工具集成到线上inference环境中,获得inference过程中的GPU Profing文件。通过Profing文件,我们可以清晰的看到inference过程,我们发现整个inference中部分算子kernel launch bound现象严重,而且部分算子之间gap间隙较大,存在优化空间,如下图所示:
为此,基于性能分析工具和转换后的模型对整个Network分析,找出TensorRT已经优化的部分,然后对Network中其他可以优化的子结构进行网络融合,同时还要保证这样的子结构在整个Network占有一定的比例,保证融合后计算密度能够有一定程度的上升。至于采用什么样的网络融合手段,根据具体的场景进行灵活运用即可,如下图是我们融合前后的子结构图对比:
3.3.5 引擎优化
- 多模型:由于外卖广告中用户请求规模不确定,广告时多时少,为此加载多个模型,每个模型对应不同输入的Batch,将输入规模分桶归类划分,并将其padding到多个固定Batch,同时对应到相应的模型进行inference。
- Multi-contexts和Multi-streams:对每一个Batch的模型,使用多context和多stream,不仅可以避免模型等待同一context的开销,而且可以充分利用多stream的并发性,实现stream间的overlap,同时为了更好的解决资源竞争的问题,引入CAS。如下图所示,单stream变成多stream:
- Dynamic Shape:为了应对输入Batch不定场景下,不必要的数据padding,同时减少模型数量降低显存等资源的浪费,引入Dynamic Shape,模型根据实际输入数据进行inference,减少数据padding和不必要的计算资源浪费,最终达到性能优化和吞吐提升的目的。
- CUDA Graph:现代GPU每个operation(kernel运行等)所花费的时间至少是微秒级别,而且,将每个operation提交给GPU也会产生一些开销(微秒级别)。实际inference时,经常需要执行大量的kernel operation,这些operation每一个都单独提交到GPU并独立计算,如果可以把所有提交启动的开销汇总到一起,应该会带来性能的整体提升。CUDA Graph可以完成这样的功能,它将整个计算流程定义为一个图而不是单个操作的列表,然后通过提供一种由单个CPU操作来启动图上的多个GPU操作的方法减少kernel提交启动的开销。CUDA Graph核心思想是减少kernel launch的次数,通过在推理前后capture graph,根据推理的需要进行update graph,后续推理时不再需要一次一次的kernel launch,只需要graph launch,最终达到减少kernel launch次数的目的。如下图所示,一次inference执行4次kernel相关操作,通过使用CUDA Graph可以清晰看到优化效果。
- 多级PS:为了进一步挖掘GPU加速引擎性能,对Embedding数据的查询操作可通过多级PS的方式进行:GPU显存Cache->CPU内存Cache->本地SSD/分布式KV。其中,热点数据可缓存在GPU显存中,并通过数据热点的迁移、晋升和淘汰等机制对缓存数据进行动态更新,充分挖掘GPU的并行算力和访存能力进行高效查询。经离线测试,GPU Cache查询性能相比CPU Cache提升10倍+;对于GPU Cache未命中数据,可通过访问CPU Cache进行查询,两级Cache可满足90%+的数据访问;对于长尾请求,则需要通过访问分布式KV进行数据获取。具体结构如下:
3.3.6 Pipeline
模型从离线训练到最终在线加载,整个流程繁琐易出错,而且模型在不同GPU卡、不同TensorRT和CUDA版本上无法通用,这给模型转换带来了更多出错的可能性。因此,为了提升模型迭代的整体效率,我们在Pipeline方面进行了相关能力建设,如下图所示:
Pipeline建设包括两部分:离线侧模型拆分转换流程,以及在线侧模型部署流程:
- 离线侧:只需提供模型拆分节点,平台会自动将原始TF模型拆分成Embedding子模型和计算图子模型,其中Embedding子模型通过分布式转换器进行分布式算子替换和Embedding导入工作;计算图子模型则根据选择的硬件环境(GPU型号、TensorRT版本、CUDA版本)进行TensorRT模型的转换和编译优化工作,最终将两个子模型的转换结果存储到S3中,用于后续的模型部署上线。整个流程都是平台自动完成,无需使用方感知执行细节。
- 在线测:只需选择模型部署硬件环境(与模型转换的环境保持一致),平台会根据环境配置,进行模型的自适应推送加载,一键完成模型的部署上线。
Pipeline通过配置化、一键化能力的建设,极大提升了模型迭代效率,帮助算法和工程同学能够更加专注的做好本职工作。下图是在GPU实践中相比纯CPU推理取得的整体收益:
4 特征服务CodeGen优化
特征抽取是模型计算的前置阶段,无论是传统的LR模型还是日趋流行的深度学习模型,都需要通过特征抽取来得到输入。在之前的博客美团外卖特征平台的建设与实践中,描述了我们基于模型特征自描述MFDL,将特征计算流程配置化,尽量保证了在线预估和离线训练时样本的一致性。随着业务快速迭代,模型特征数量不断增加,特别是大模型引入了大量的离散特征,导致计算量有了成倍的增长。为此,我们对特征抽取层做了一些优化,在吞吐和耗时上都取得了显著的收益。
4.1 全流程CodeGen优化
DSL是对特征处理逻辑的描述。在早期的特征计算实现中,每个模型配置的DSL都会被解释执行。解释执行的优点是实现简单,通过良好的设计便能获得较好的实现,比如常用的迭代器模式;缺点是执行性能较低,在实现层面为了通用性避免不了添加很多的分支跳转和类型转换等。实际上,对于一个固定版本的模型配置来说,它所有的模型特征转换规则都是固定的,不会随请求而变化。极端情况下,基于这些已知的信息,可以对每个模型特征各自进行Hard Code,从而达到最极致的性能。显然,模型特征配置千变万化,不可能针对每个模型去人工编码。于是便有了CodeGen的想法,在编译期为每一个配置自动生成一套专有的代码。CodeGen并不是一项具体的技术或框架,而是一种思想,完成从抽象描述语言到具体执行语言的转换过程。其实在业界,计算密集型场景下使用CodeGen来加速计算已是常用做法。如Apache Spark通过CodeGen来优化SparkSql执行性能,从1.x的ExpressionCodeGen加速表达式运算到2.x引入的WholeStageCodeGen进行全阶段的加速,都取得了非常明显的性能收益。在机器学习领域,一些TF模型加速框架,如TensorFlow XLA和TVM,也是基于CodeGen思想,将Tensor节点编译成统一的中间层IR,基于IR结合本地环境进行调度优化,从而达到运行时模型计算加速的目的。
借鉴了Spark的WholeStageCodeGen,我们的目标是将整个特征计算DSL编译形成一个可执行方法,从而减少代码运行时的性能损耗。整个编译过程可以分为:前端(FrontEnd),优化器(Optimizer)和后端(BackEnd)。前端主要负责解析目标DSL,将源码转化为AST或IR;优化器则是在前端的基础上,对得到的中间代码进行优化,使代码更加高效;后端则是将已经优化的中间代码转化为针对各自平台的本地代码。具体实现如下:
- 前端:每个模型对应一张节点DAG图,逐个解析每个特征计算DSL,生成AST,并将AST节点添加到图中。
- 优化器:针对DAG节点进行优化,比如公共算子提取、常量折叠等。
- 后端:将经过优化后的图编译成字节码。
经过优化之后,对节点DAG图的翻译,即后端代码实现,决定了最终的性能。这其中的一个难点,同时也是不能直接使用已有开源表达式引擎的原因:特征计算DSL并非是一个纯计算型表达式。它可以通过读取算子和转换算子的组合来描述特征的获取和处理过程:
- 读取算子:从存储系统获取特征的过程,是个IO型任务。比如查询远程KV系统。
- 转换算子:特征获取到本地之后对特征进行转换,是个计算密集型任务。比如对特征值做Hash。
所以在实际实现中,需要考虑不同类型任务的调度,尽可能提高机器资源利用率,优化流程整体耗时。结合对业界的调研以及自身实践,进行了以下三种实现:
- 基于任务类型划分Stage:将整个流程划分成获取和计算两种Stage,Stage内部分片并行处理,上一个Stage完成后再执行下一个Stage。这是我们早期使用的方案,实现简单,可以基于不同的任务类型选择不同的分片大小,比如IO型任务可以使用更大的分片。但缺点也很明显,会造成不同Stage的长尾叠加,每个Stage的长尾都会影响整个流程的耗时。
- 基于流水线划分Stage:为了减少不同Stage的长尾叠加,可以先将数据分片,为每个特征读取分片添加回调,在IO任务完成后回调计算任务,使整个流程像流水线一样平滑。分片调度可以让上一个Stage就绪更早的分片提前进入下一个Stage,减少等待时间,从而减少整体请求耗时长尾。但缺点就是统一的分片大小不能充分提高每个Stage的利用率,较小的分片会给IO型任务带来更多的网络消耗,较大的分片会加剧计算型任务的耗时。
- 基于SEDA(Staged Event-Driven Architecture)方式:阶段式事件驱动方式使用队列来隔离获取Stage和计算Stage,每个Stage分配有独立的线程池和批处理处理队列,每次消费N(batching factor)个元素。这样既能够实现每个Stage单独选择分片大小,同时事件驱动模型也可以让流程保持平滑。这是我们目前正在探索的方式。
CodeGen方案也并非完美,动态生成的代码降低了代码可读性,增加了调试成本,但以CodeGen作为适配层,也为更深入的优化打开了空间。基于CodeGen和异步非阻塞的实现,在线上取到了不错的收益,一方面减少了特征计算的耗时,另一方面也明显的降低了CPU负载,提高了系统吞吐。未来我们会继续发挥CodeGen的优势,在后端编译过程中进行针对性的优化,如探索结合硬件指令(如SIMD)或异构计算(如GPU)来做更深层次的优化。
4.2 传输优化
在线预估服务整体上是双层架构,特征抽取层负责模型路由和特征计算,模型计算层负责模型计算。原有的系统流程是将特征计算后的结果拼接成M(预测的Batch Size) × N(样本宽度)的矩阵,再经过序列化传输到计算层。之所以这么做,一方面出于历史原因,早期很多非DNN的简单模型的输入格式是个矩阵,经过路由层拼接后,计算层可以直接使用,无需转换;另一方面,数组格式比较紧凑,可以节省网络传输耗时。然而随着模型迭代发展,DNN模型逐渐成为主流,基于矩阵传输的弊端也非常明显:
- 扩展性差:数据格式统一,不兼容非数值类型的特征值。
- 传输性能损耗:基于矩阵格式,需要对特征做对齐,比如Query/User维度需要被拷贝对齐到每个Item上,增大了请求计算层的网络传输数据量。
为了解决以上问题,优化后的流程在传输层之上加入一层转换层,用来根据MDFL的配置将计算的模型特征转换成需要的格式,比如Tensor、矩阵或离线使用的CSV格式等。实际线上大多数模型都是TF模型,为了进一步节省传输消耗,平台设计了Tensor Sequence格式来存储每个Tensor矩阵:其中,r_flag用来标记是否是item类特征,length表示item特征的长度,值为M(Item个数)×NF(特征长度),data用来存储实际的特征值,对于Item特征将M个特征值扁平化存储,对于请求类特征则直接填充。基于紧凑型Tensor Sequence格式使数据结构更加紧凑,减少网络传输数据量。优化后的传输格式在线上也取得不错的效果,路由层调用计算层的请求大小下降了50%+,网络传输耗时明显下降。
4.3 高维ID特征编码
离散特征和序列特征可以统一为Sparse特征,特征处理阶段会把原始特征经过Hash处理,变为ID类特征。在面对千亿级别维度的特征,基于字符串拼接再Hash的过程,在表达空间和性能上,都无法满足要求。基于对业界的调研,我们设计和应用了基于Slot编码的方式特征编码格式:
其中,feature_hash为原始特征值经过Hash后的值。整型特征可以直接填充,非整型特征或交叉特征先经过Hash后再填充,超过44位则截断。基于Slot编码方案上线后,不仅提升了在线特征计算的性能,同时也为模型效果的带来了明显提升。
5 样本构建
5.1 流式样本
业界为了解决线上线下一致性的问题,一般都会在线dump实时打分使用的特征数据,称为特征快照;而不是通过简单离线Label拼接,特征回填的方式来构建样本,因为这种方式会带来较大的数据不一致。架构原始的方式如下图所示:
这种方案随着特征规模越来越大、迭代场景越来越复杂,突出的问题就是在线特征抽取服务压力大,其次是整个数据流收集成本太高。此样本收集方案存在以下问题:
- 就绪时间长:在现有资源限制下,跑那么大数据几乎要在T+2才能将样本数据就绪,影响算法模型迭代。
- 资源耗费大:现有样本收集方式是将所有请求计算特征后与曝光、点击进行拼接,由于对未曝光Item进行了特征计算、数据落表,导致存储的数据量较大,耗费大量资源。
5.1.1 常见的方案
为了解决上面的问题,业界常见有两个方案:①Flink实时流处理;②KV缓存二次处理。具体流程如下图所示:
- 流式拼接方案:借助流式处理框架(Flink、Storm等)低延迟的流处理能力,直接读取曝光/点击实时流,与特征快照流数据在内存中进行关联(Join)处理;先生成流式训练样本,再转存为模型离线训练样本。其中流式样本和离线样本分别存储在不同的存储引擎中,支持不同类型的模型训练方式。此方案的问题:在数据流动环节的数据量依然很大,占用较多的消息流资源(比如Kafka);Flink资源消耗过大,如果每秒百G的数据量,做窗口Join则需要30分钟×60×100G的内存资源。
- KV缓存方案:把特征抽取的所有特征快照写入KV存储(如Redis)缓存N分钟,业务系统通过消息机制,把候选队列中的Item传入到实时计算系统(Flink或者消费应用),此时的Item的量会比之前请求的Item量少很多,这样再将这些Item特征从特征快照缓存中取出,数据通过消息流输出,支持流式训练。这种方法借助了外存,不管随着特征还是流量增加,Flink资源可控,而且运行更加稳定。但突出的问题还是需要较大的内存来缓存大批量数据。
5.1.2 改进优化
从减少无效计算的角度出发,请求的数据并不会都曝光。而策略对曝光后的数据有更强的需求,因此将天级处理前置到流处理,可以极大提升数据就绪时间。其次,从数据内容出发,特征包含请求级变更的数据与天级变更的数据,链路灵活分离两者处理,可以极大提升资源的利用,下图是具体的方案:
1. 数据拆分:解决数据传输量大问题(特征快照流大问题),预测的Label与实时数据一一Match,离线数据可以通过回流的时候二次访问,这样可以极大降低链路数据流的大小。
- 样本流中只有上下文+实时特征,增加读取数据流稳定性,同时由于只需要存储实时特征,Kafka硬盘存储下降10+倍。
2. 延时消费Join方式:解决占用内存大问题。
- 曝光流作为主流,写入到HBase中,同时为了后续能让其他流在HBase中Join上曝光,将RowKey写入Redis;后续流通过RowKey写入HBase,曝光与点击、特征的拼接借助外存完成,保证数据量增大后系统能稳定运行。
- 样本流延时消费,后台服务的样本流往往会比曝光流先到,为了能Join上99%+的曝光数据,样本流等待窗口统计至少要N分钟以上;实现方式是将窗口期的数据全部压在Kafka的磁盘上,利用磁盘的顺序读性能,省略掉了窗口期内需要缓存数据量的大量内存。
3. 特征补录拼样本:通过Label的Join,此处补录的特征请求量不到在线的20%;样本延迟读取,与曝光做拼接后过滤出有曝光模型服务请求(Context+实时特征),再补录全部离线特征,拼成完整样本数据,写入HBase。
5.2 结构化存储
随着业务迭代,特征快照中的特征数量越来越大,使得整体特征快照在单业务场景下达到几十TB级别/天;从存储上看,多天单业务的特征快照就已经PB级别,快到达广告算法存储阈值,存储压力大;从计算角度上看,使用原有的计算流程,由于计算引擎(Spark)的资源限制(使用到了shuffle,shuffle write阶段数据会落盘,如果分配内存不足,会出现多次落盘和外排序),需要与自身数据等大小的内存和较多的计算CU才能有效的完成计算,占用内存高。样本构建流程核心流程如下图所示:
在补录特征时,存在以下问题:
- 数据冗余:补录特征的离线表一般为全量数据,条数在亿级别,样本构建用到的条数约为当日DAU的数量即千万级别,因此补录的特征表数据在参与计算时存在冗余数据。
- Join顺序:补录特征的计算过程即维度特征补全,存在多次Join计算,因此Join计算的性能和Join的表的顺序有很大关系,如上图所示,如果左表为几十TB级别的大表,那么之后的shuffle计算过程都会产生大量的网络IO、磁盘IO。
为了解决样本构建效率慢的问题,短期先从数据结构化治理,详细过程如下图所示:
- 结构化拆分。数据拆分成Context数据和结构化存储的维度数据代替混合存储。解决Label样本拼接新特征过程中携带大量冗余数据问题;并且做结构化存储后,针对离线特征,得到了很大的存储压缩。
- 高效过滤前置。数据过滤提前到Join前,减少参与特征计算的数据量,可以有效降低网络IO。在拼接过程中,补录特征的Hive表一般来说是全量表,数据条数一般为月活量,而实际拼接过程中使用的数据条数约为日活量,因此存在较大的数据冗余,无效的数据会带来额外的IO和计算。优化方式为预计算使用的维度Key,并生成相应的布隆过滤器,在数据读取的时候使用布隆过滤器进行过滤,可以极大降低补录过程中冗余数据传输和冗余计算。
- 高性能Join。使用高效的策略去编排Join顺序,提升特征补录环节的效率和资源占用。在特征拼接过程中,会存在多张表的Join操作,Join的先后顺序也会极大影响拼接性能。如上图所示,如果拼接的左表数据量较大时,那么整体性能就会差。可以使用哈夫曼算法的思想,把每个表看作一个节点,对应的数据量量看成是他的权重,表之间的Join计算量可以简单类比两个节点的权重相加。因此,可以将此问题抽象成构造哈夫曼树,哈夫曼树的构造过程即为最优的Join顺序。
数据离线存储资源节省达80%+,样本构建效率提升200%+,当前整个样本数据也正在进行基于数据湖的实践,进一步提升数据效率。
6 数据准备
平台积累了大量的特征、样本和模型等有价值的内容,希望通过对这些数据资产进行复用,帮助策略人员更好的进行业务迭代,取得更好的业务收益。特征优化占了算法人员提升模型效果的所有方法中40%的时间,但传统的特征挖掘的工作方式存在着花费时间长、挖掘效率低、特征重复挖掘等问题,所以平台希望在特征维度赋能业务。如果有自动化的实验流程去验证任意特征的效果,并将最终效果指标推荐给用户,无疑会帮助策略同学节省大量的时间。当整个链路建设完成,后续只需要输入不同的特征候选集,即可输出相应效果指标。为此平台建设了特征、样本的“加”、“减”、“乘”、“除”智能机制。
6.1 做“加法”
特征推荐基于模型测试的方法,将特征复用到其他业务线现有模型,构造出新的样本和模型;对比新模型和Base模型的离线效果,获取新特征的收益,自动推送给相关的业务负责人。具体特征推荐流程如下图所示:
- 特征感知:通过上线墙或业务间存量方式触发特征推荐,这些特征已经过一定验证,可以保证特征推荐的成功率。
- 样本生产:样本生产时通过配置文件抽取特征,流程自动将新增特征加到配置文件中,然后进行新样本数据的生产。获取到新特征后,解析这些特征依赖的原始特征、维度、和UDF算子等,将新特征配置和依赖的原始数据融合到基线模型的原有配置文件中,构造出新的特征配置文件。自动进行新样本构建,样本构建时通过特征名称在特征仓库中抽取相关特征,并调用配置好的UDF进行特征计算,样本构建的时间段可配置。
- 模型训练:自动对模型结构和样本格式配置进行改造,然后进行模型训练,使用TensorFlow作为模型训练框架,使用tfrecord格式作为样本输入,将新特征按照数值类和ID类分别放到A和B两个组中,ID类特征进行查表操作,然后统一追加到现有特征后面,不需要修改模型结构便可接收新的样本进行模型训练。
- 自动配置新模型训练参数:包括训练日期、样本路径、模型超参等,划分出训练集和测试集,自动进行新模型的训练。
- 模型评测:调用评估接口得到离线指标,对比新老模型评测结果,并预留单特征评估结果,打散某些特征后,给出单特征贡献度。将评估结果统一发送给用户。
6.2 做“减法”
特征推荐在广告内部落地并取得了一定收益后,我们在特征赋能层面做一些新的探索。随着模型的不断优化,特征膨胀的速度非常快,模型服务消耗资源急剧上升,剔除冗余特征,为模型“瘦身”势在必行。因此,平台建设了一套端到端的特征筛选工具。
- 特征打分:通过WOE(Weight Of Evidence, 证据权重)等多种评估算法给出模型的所有特征评分,打分较高特征的质量较高,评估准确率高。
- 效果验证:训练好模型后,按打分排序,分批次对特征进行剔除。具体通过采用特征打散的方法,对比原模型和打散后模型评估结果,相差较大低于阈值后结束评估, 给出可以剔除的特征。
- 端到端方案:用户配置好实验参数和指标阈值后,无需人为干涉,即可给出可删除的特征以及删除特征后模型的离线评估结果。
最终,在内部模型下线40%的特征后,业务指标下降仍然控制在合理的阈值内。
6.3 做“乘法”
为了得到更好的模型效果,广告内部已经开始做一些新的探索,包括大模型、实时化、特征库等。这些探索背后都有一个关键目标:需要更多、更好的数据让模型更智能、更高效。从广告现状出发,提出样本库(Data Bank)建设,实现把外部更多种类、更大规模的数据拿进来,应用于现有业务。具体如下图所示:
我们建立了一套通用的样本共享平台,在这个平台上,可以借用其他业务线来产生增量样本。并且也搭建通用的Embedding共享架构,实现业务的以大带小。下面以广告业务线复用非广告样本为例,具体做法如下:
- 扩样本:基于Flink流式处理框架,建设了高扩展样本库DataBank,业务A很方便复用业务B、业务C的曝光、点击等Label数据去做实验。尤其是为小业务线,扩充了大量的价值数据,这种做法相比离线补录Join,一致性会更强,特征平台提供了在线、离线一致性保障。
- 做共享:在样本就绪后,一个很典型的应用场景就是迁移学习。另外,也搭建Embedding共享的数据通路(不强依赖“扩样本”流程),所有业务线可以基于大的Embedding训练,每个业务方也可以update这个Embedding,在线通过建立Embedding版本机制,供多个业务线使用。
举例来说,通过将非广告样本复用到广告内一个业务,使样本数量增加了几倍,结合迁移学习算法,离线AUC提升千分之四,上线后CPM提升百分之一。此外,我们也在建设广告样本主题库,将各业务生成的样本数据进行统一管理(统一元数据),面向用户透出统一样本主题分类,快速注册、查找、复用,面向底层统一存储,节约存储、计算资源,减少数据Join,提高时效性。
6.4 做“除法”
通过特征“减法”可以剔除一些无正向作用的特征,但通过观察发现模型中还存在很多价值很小的特征。所以更进一步我们可以通过价值、成本两方面综合考虑,在全链路基于成本的约束下价值最大,筛选出那些投入产出比较低特征,降低资源消耗。这个在成本约束下去求解的过程定义为做“除法”,整体流程如下图所示。
在离线维度,我们建立了一套特征价值评估系统,给出特征的成本和价值,在线推理时可以通过特征价值信息进行流量降级、特征弹性计算等操作,做“除法”关键步骤如下:
- 问题抽象:如果我们能得到每个特征的价值得分,又可以拿到特征的成本(存储、通信、计算加工),那么问题就转换成了在已知模型结构、固定资源成本下,如何让特征的价值最大化。
- 成本约束下的价值评估:基于模型的特征集,平台首先进行成本和价值的统计汇总;成本包括了离线成本和在线成本,基于训练好的评判模型,得出特征的综合排序。
- 分场景建模:可以根据不同的资源情况,选择不同的特征集,进行建模。在有限的资源下,选择价值最大的模型在线Work。另外,可以针对比较大的特征集建模,在流量低峰启用,提升资源利用率的同时给业务带来更大收益。还有一种应用场景是流量降级,推理服务监控在线资源的消耗,一旦资源计算达到瓶颈,切换到降级模型。
7 总结与展望
以上是我们在大规模深度学习工程上的反“增”实践,去助力业务降本提效。未来我们还会持续在以下方面进行探索、实践:
- 全链路GPU化:在推理层面,通过GPU的切换,支撑更复杂业务迭代的同时,整体成本也极大的降低,后面会在样本构建、特征服务上进行GPU化改造,并协同推进离线训练层面的升级。
- 样本数据湖:通过数据湖的Schema Evolution、Patch Update等特性构建更大规模的样本仓库,对业务方进行低成本、高价值的数据透出。
- Pipeline:算法全生命周期迭代过程中,很多环节的调试,链路信息都不够“串联”,以及离线、在线、效果指标的视角都比较割裂,基于全链路的标准化、可观测大势所趋,并且这是后续链路智能化弹性调配的基础。现在业界比较火的MLOps、云原生都有较多的借鉴思路。
- 数据、模型智能匹配:上文提到在模型结构固定前提下,自动为模型加、减特征,同理在模型层面,固定一定特征输入前提下,去自动嵌入一些新的模型结构。以及在未来,我们也将基于业务领域,通过平台的特征、模型体系,自动化地完成数据、模型的匹配。
8 本文作者
亚劼、英亮、陈龙、成杰、登峰、东奎、仝晔、思敏、乐彬等,均来自美团外卖技术团队。