性能问题无处不在,但是很多同学都可能知道一句话:“过早优化乃万恶之源”,在日常工作中并不太关注性能,然而在机器学习应用里面,性能却关乎应用的生死。这一波人工智能热潮的源起之一就是深度学习的计算性能获重大突破,从而使问题做到工程可解,达到实用的程度。
在实际工作中,我们发现算法人员的知识领域往往偏应用,更熟悉诸如推荐、物体识别、自然语音处理等业务相关的算法与模型的设计上,而对计算环境,尤其是异构计算环境的庞杂细节知识不够了解,从而导致这些算法应用的性能不高,资源利用效率偏低,有的时候还会因为无法满足响应时间的要求而无法上线。同时计算平台的工程师对机器学习的业务流程、应用软件栈构成、模型、性能相关的可调参数和工具、方法等方面了解不多,感觉无处下手。
这篇文章希望能大致梳理一下机器学习应用调优的原则、方法及工具,给相关人员做个参考。我们将自顶而下介绍机器学习性能调优遇到的技术和工具,希望读者能有一个全局的认识。调优既需要全局纵览,又需要深入毫末,限于篇幅,很多细节点到即止,没有展开,但是读者可以根据本文提到的信息,利用搜索引擎和文末的参考资料部分深入学习下去。
原则
性能问题是系统性工程问题,不应该使用兵来将挡、水来土掩的救火式的方法去解决问题,而要统筹安排、遵循一定的方法论系统性地揭示和解决。
发现与解决性能问题,如同侦探办案,需要大胆假设、小心论证。既要有全局观,能找到主要矛盾,又要能够细致入微,深入技术细节,找到问题的根本原因与解决方案。
在调查性能问题的过程中,需要随时记录调查过程,方便随时复盘,调整调查方向和手段。
在调查过程中,如果有多个影响因素,应该一次改变一个因素,观察结果,方便区分不同影响,而不应该一次改变多个因素。
方法论
调优循环
性能调优是过程是一个持续改进过程,是一个动态革新的过程,可以抽象成一个优化循环。这个循环从开发性能基准测试开始,性能基准测试集需要具有典型性,也就是能反映业务的典型场景,要么从最重要的业务简化出来,要么按照典型业务的性能特征构造出来。而且这个性能基准要具有稳定性,也就是在同样条件下可以重复得出统计意义上一致的性能结果。性能基准测试最好是做成完全自动化的,即配置、运行与输入均不须人工干预。一旦确定好性能基准测试集,就可以运行测试,获取和记录性能基准数据。重复运行测试,使用性能探测、分析工具收集、分析应用的性能瓶颈,然后针对瓶颈,对系统配置、应用配置或者代码作出相应改动,经过正确性验证后,再次运行性能基准测试,得到新的性能数据,比较性能,如有增强,则确认这次改动,使用版本管理系统记录改动,这样就完成了一次性能调优迭代,循此方法,直至性能达到要求或者接近理论预估值。
基础坚实
在调优开始前,需要确认测试工作环境功能、性能正常,确保工作在正确的基础上进行。这一步非常重要,但通常都被省略掉。不在正常的环境里工作均为浪费,南辕北辙。
对于单机环境,主要是 CPU、内存、磁盘、网络、GPU 等部件的基础性能。这会涉及到诸如 BIOS 里性能偏好的设置、C-State 的设置、NUMA 设置,Hyper-Threading 的设置,内存条在插槽的分布方式、GPGPU 的 PCIe 插槽的分布方式以及操作系统内核相关调优参数的设置等。运行相关单项基础性能测试,对照各物理部件的理论性能,如果偏差太大,需要找到相应原因解决。对于多机的集群环境,在单机性能验证完以后,主要考虑互联的吞吐量和延迟性能是否达标,这会涉及到界面卡相关的物理和操作系统配置的修改,RDMA 的配置以及交换机的配置等等。
自上而下
在大规模分布式应用,比如分布式训练中,首先要优化的是加速比,也就是每增加一台计算设备(服务器或者 GPGPU 卡)的性能增长与单机性能的比值,比如单机性能为 100,而加入集群后,集群性能增加了 60,则加速比为 60%。提高加速比可以从并行算法设计、实现架构以及互联设备层面上着手,减少计算节点间的数据依赖,减少数据交换延迟,提高并行度。加速比是提高大作业吞吐量性能的关键。
单计算设备或者称为单机的性能,一般来讲改变算法和数据结构收益最大,在机器学习应用里对应的就是选择合适的模型。而在实践中,调优阶段很难做这么大改动,通常从数据输入优化、框架配置等方面入手。然后才是运行库调优,操作系统相关性能参数调整,诸如 IO 调度策略,大页内存,CPU 核绑定等等。一步一步挖掘,根据发现的性能瓶颈逐渐深入优化。
自上而下的优化顺序,有助于首先解决主要问题,获取最大收益,快速实现调优目标。
性能指标
对于应用性能的衡量,通常有两个指标 - 吞吐量和延迟。吞吐量指的是单位时间内完成的处理事务总数。延迟指的是一个请求发出到完成的耗时。为了达到这两个目标的技术实现方式是不一样的,调优方法也有所区别。提高吞吐量一般是通过提高并行度、使用并行流水线、增大缓存、异步等方法充分发挥资源使用率的方式。减低延迟通常是缩短关键路径,减少同步等待,提高缓存命中率,空间换时间等方式实现。提高计算资源的使用率通常对这两种性能均有好处,但是提高 IO 资源的使用率则未必。
应用性能特征
应用的性能特征指的是应用在资源使用上的偏好和模式,比如常见的计算密集型应用,IO 密集型应用等说法就是一种粗略的性能特征描述。比较精确的描述是首先确定资源维度,也就是哪些资源类型,可以包含硬件资源与软件资源,比如 CPU、内存、磁盘、网络、GPU、数据库服务等等,然后针对这些资源维度,给出量化的使用率指标,比如 CPU 利用率,内存使用量,IO 利用率,IOPS,网络流量, QPS 等,使用性能监控,在一个典型业务场景下,收集各指标,按资源维度消耗就可以描述出这个应用的性能特征。
应用性能特征可以用来指导性能优化的大方向,根据性能木桶理论,通常来讲主要的优化方向就是利用率最高的那个维度。
机器学习性能相关技术构成
如上文所说,调优是一个逐层深入的过程,必须对整个技术构成有比较深入的了解才能知道调查的方向和每层需要关注的问题。我们先由远而近地了解一下机器学习应用性能的相关技术构成。在现阶段,互联网应用的机器学习主要有两个应用:训练与推理。训练是一个大规模数据处理的过程,通常比较关注吞吐量,推理则又分为在线推理业务和离线推理业务,对于在线推理,通常延迟是最主要的性能目标,而离线推理则比较关注吞吐量。主要的应用领域以图形图像的处理、语音处理、自然语言处理、推荐等,对应的 CNN,RNN,BERT,Wide & Deep 模型与算法。
机器学习的目的是得到一个模型,可以把模型看成一个函数,训练就是程序读入大量数据,经过不断迭代计算,得出模型的参数,从而使这个函数比较好的模拟真实的规律,在读入新数据时,得出与事实相近的推断结果,这称为推理。
下面我们以比较典型的深度学习的卷积神经网络的训练过程为例,一层层地深入了解它的技术构成与性能调优相关技术。
IO
训练数据输入可以有多种来源 - 本地磁盘,网络文件系统,分布式文件系统服务等,训练数据的格式与存储方式也各异,性能关心的是数据输入的吞吐量带宽,IOPS (每秒 IO 操作量),IO 队列深度等。一般来说,大量小文件输入的情况下,IOPS 对性能的影响更大。除了更换更强大的硬件以外,软件层面上能做的是使用缓存、预取以及将数据的 ETL(Extract, Transform, Load)各部分和训练部分组成 pipeline 以掩盖操作延迟、增加资源使用率,整合数据以减少 IO 操作等,如果使用网络存储或者 SSD 等能支持更高 IOPS 或并发访问的资源上,开辟更多 IO 线程也会带来好处。在 TensorFlow 框架上,使用 tf.data API 尤其是 TFRecord 是投入产出比最好的方式。
在实际的代码中经常见到单线程的顺序式的数据读取、解码、预处理、洗牌、组织 batch 的代码,这个过程涉及大量 IO 操作和 CPU 计算操作、内存复制操作,如果使用 GPU 还会有 PCIe 的数据传输操作。这些操作简单的顺序执行,过程缓慢而冗长,导致 CPU 和 GPU 强大的计算能力基本闲置等待的状态。这个使用 htop 命令和 NVIDIA-smi dmon 命令可以清楚的看到 CPU 与 GPU 的忙闲程度。
还有一个小技巧可以判断是不是 IO 导致训练性能低下的元凶。将被训练的模型计算换成最简单的计算,然后再测试训练性能,如果变化不大,做说明了计算速度对性能影响不大,基本可以定位 IO 是性能瓶颈。
计算
有了合适的数据输入后,就正式进入模型训练阶段了。对于 TensorFlow 1.x 版本框架下构建的程序而言,通常会构建 tf.Graph 计算图,计算图描述了数据的计算流程,图的节点是运算操作(Ops),运算操作代表了特定的抽象运算,会根据具体硬件设备(device)有具体的运算核(kernel)实现,运算核会利用不同的运算库,比如 Eigen,NVIDIA CuDNN,Intel的 MKL DNN 等,不同的库通常都会有相应的性能调节方式,可以在需要的时候使用。
在计算图节点间流动的是数据,称为张量(Tensor),如下图所示。计算图可以通过 TensorBoard 查看。
双击 namespase 方框,可以展开和收拢细节。直至用椭圆形表示的 Ops,单击 Ops 图标,可以看到 Ops 的 Operation,属性、输入、输出等细节。这些细节信息会在后面的精细化调优的时候用到。
TensorFlow 程序的客户端,会通过 Session 接口与 Master 进行交互,Master 负责管理一个或多个 worker, worker 与一个或多个硬件设备(device)相连。Session 的 run() 方法会启动 worker 运行计算图,按照事先的实现进行计算。如下图所示。你会发现 worker 之间(worker 到 parameter server,worker 到 worker)会有数据传输,这些数据传输会跨越不同设备,了解传输的路径有助于发现瓶颈和优化。
在模型训练时,尤其是神经网络训练,如果能够使用预先训练好的相关模型,固化靠前的部分层(所谓通用概念层),实现增量式训练,将大幅降低训练的计算量,提高训练效率。
在实际工作中,我们发现精细调优过的机器学习代码的性能远好于简单直接运行,有的甚至达到10倍以上。下面让我们来看一下,如何充分发挥计算能力,优化计算性能。
在模型训练中,batch size 对性能的影响较大,所谓 batch size 就是处理完这些数目的样本后,才去更新模型的参数,这样可以大幅度减少模型参数更新的计算数量和相关的数据交换开销。同时在一定范围内改变 batch size 的值,并不会对模型的精确度有影响,所以理论上来讲,在可能范围内 batch size 越大则性能越好,但是受限于计算设备的内存大小。通常来讲 CPU 上训练的 batch size 上限大于 GPU 的。此外还有 learning rate 等参数,详情请参考相关文档。
另外一个对性能有显著影响的就是计算用的浮点数表示格式,浮点数表示方式有双精度/DP64(64位)、单精度/FP32(32位),半精度/FP16(16位)等, 通常来讲机器学习用的是 FP32 格式,但是有证据表明在神经网络中降低计算数的精度,对最终的模型精度影响可以很小。使用 FP16,由于下面会说明的向量化数据并行运算,性能比 FP32 理论上高了 1 倍,但是在不调整模型的情况下精度损失在 3% 以下甚至更小,因此可以代价很小的使用 FP16 代替 FP32,但是需要监控模型的精度。更近一步还可以使用 int8 甚至更小的数据类型,但是更小意味着更容易溢出和更多精度损失,在实际运行中计算梯度变化值的时候还是使用FP32,但在每层更新参数时转换为 int8,因此会有大量隐式数据转换过程,所以使用 XLA、nGraph 或者其他计算图编译的融合优化,能减少这种数据转换和复制操作,从而带来比较大的改进。
有一种重要的优化可以提高推理的性能和缩小模型的大小,就是模型量化(Quantization),所谓模型量化就是将模型的权值限制为有限个取值,类似于量子学的电子能级,这样就可以用很少的 bit 数表示了,极端的情况包括二值化和三值化。模型量化可以在训练好的模型上进行优化,也可以在训练的时候使用量化感知训练方式,将相关信息保留下来供模型量化使用,确保精度,起到更好效果。感兴趣的同学可以参考相关论文。
还有一些优化需要对底层的硬件平台了解透彻。我们以一个典型的机器学习服务器为例,一般来讲是一个 2U 或者 4U 的 X86 服务器,有两块 CPU,PCIe 插槽上插着 4 块或者 8 块 NVIDIA 的 GPU,比如 V100。
我们先来看看 CPU,服务器上的这两块 CPU 放在两个插槽(socket)里,中间有 QPI/UPI 连接。QPI/UPI 的数目根据 CPU 的档次不同有 1 根到 3 根,带宽也有不同,这个会影响两个 CPU 之间的数据交换的性能。每块 CPU 都有自己的内存控制器,内存控制器有不同的通道数目,每个通道又支持一定数目和频率的内存条,通道数目、内存条的频率以及内存条的插法会影响内存的带宽性能。使用 dmidecode 命令可以查看内存的频率、型号、插槽位置等信息。CPU 访问自己内存控制器下的内存和访问另外一个 CPU 下的内存速度显然是不一样的,这个称之为非一致性内存访问,简称为 NUMA 架构,在 BIOS 里可以设置为 NUMA 方式或者交叉混合(interleave)方式,现代的 Linux 操作系统都是所谓 NUMA aware 的,在进程调度以及内存管理上,会按距离区别对待不同区域的内存,因此性能表现会更好,所以建议打开 NUMA 设置。同时 CPU 功耗偏好也可以设置为性能模式,而不是省电模式。
每块 CPU 又有多个物理核(core),每个核根据 CPU 型号(有的型号不支持超线程)和 BIOS 设置,又有可能有 2 个超线程(Hyper-Threading),称为逻辑核。这些信息可以通过 lscpu 命令看到。NUMA 信息可以通过 numactl -H 命令查看。
在每个核里面又有多个算术逻辑运算单元,其中对机器学习最重要的为乘加器(FMA),根据 CPU 型号,有 1 到 2 个乘加器,乘加器支持单指令多数据运算(SIMD/AVX),也就是执行一条指令就可以完成多个数据的相同计算操作,比如一条指令完成8 个浮点型数和另外 8 个浮点型数的分别相乘计算。这样的数据并行可以数倍提高计算性能,但是需要编译器和代码中做称为向量化的支持,好消息就是 Intel 的 MKL 数学库已经实现了常用的计算,比如矩阵乘,使用的时候直接调用即可。对于 TensorFlow 而言,缺省下载的官方执行程序或者 docker image 没有针对 AVX 编译,不能充分发挥 CPU 的性能,建议使用 Intel 发行的 docker image 或者 conda channel 获取,还有自己从 TensorFlow 源代码自行编译,提速效果非常明显。
使用 MKL enabled Tensorflow,会有一些环境变量影响性能,比如 KMP_AFFINITY, OMP_NUM_THREADS,KMP_BLOCKTIME,以及 TensorFlow 的两个运行参数intra_op_parallelism_threads,inter_op_parallelism_threads。详细请参见 TensorFlow Performance Overview,这里就不展开了。更多 Session 运行参数参见: tf.ConfigProto,根据不同的运行环境和模型情况,里面有相当多选项对性能有显著影响。
因为服务器有两个物理 CPU,在通常开了 Hyper-Threading 的情况下每个物理核展现为两个逻辑核,这样就事实将一台服务器的全部逻辑核分成了 4 个不同的组,因此在 TensorFlow 里面将这些核当成 4 个不同的计算设备在实践中也会部分提高性能,具体可以使用 numactl
命令将 4 个TensorFlowworker 与特定核组绑定,同时相应调整 intra_op_parallelism_threads,inter_op_parallelism_threads 参数与实际一致。
让我们来看看 GPU。在大规模或者复杂模型的训练中,通常会使用多个卡。如前面的图所示,运算设备上的 worker 会跟 parameter server (PS)有大量数据交换,PS 可以放在CPU 上,也可以放在 GPU 上,在所有 GPU 都插在同一个 PCIe Root Complex 或者使用 NVLink 的情况下,NVIDIA 的 NCCL 会利用硬件的P2P 协议,大幅度减少数据通讯的延迟。如果这些条件都不满足,则 PS 就应该指定放在 CPU 上运行,以获取最佳性能。
在性能调优中,经常需要揭开性能的黑盒子,常用工具就是各种性能剖析工具。常见的TensorFlow 程序开发语言是 Python,因此可以使用 Python profile、cProfile 模块进行整体性的剖析。
深入到 TensorFlow 运行层面,就可以使用 TFProf 或者 Timeline 深入了解各 Ops 的时间占比信息,以方便进一步优化主要的性能拖慢操作。TFProf 详情请见文末的参考资料。
Timeline 用法如下:
1.在代码中增加 trace 数据输出部分
sess.run(...
options=tf.RunOptions(trace_level=tf.RunOptions.FULL_TRACE),
run_metadata=run_metadata)
trace = timeline.Timeline(step_stats=run_metadata.step_stats)
with open('timeline.ctf.json', 'w') as trace_file:
trace_file.write(trace.generate_chrome_trace_format())
2.运行下后就会产生一个名为 timeline.ctf.json 的文件。
3.然后在 Chrome 浏览器地址栏中输入 chrome://tracing/,然后点击 Load 按钮,加载刚才的 timeline.ctf.json 的文件,就可以看到运行时间线,可以放大、缩小查看,也可以点击相关 Ops 查看详情。
在这个时间追踪的剖析图上,很容易发现最耗时的操作,或是并行度高低的情况,XLA 可以用来帮助优化。XLA 是一个编译器,使用 JIT 即时编译技术分析计算图,找出可以融合的操作,将多个操作合并,并生成对应计算设备的原生代码。也可以使用编译器的优化算法进行数据流、控制流分析,减少计算。英特尔有类似的工具称为 nGraph,也可以试一试。
如果上述工具都没有能帮你达到性能目标,你还可以自己实现 Ops,然后加入 TensorFlow 中,并调用它,这在许多需要低延迟的算法中有较多应用,但难度最大,所以请确认别无他法再用。具体可以参见 TensorFlow 的 Adding a New Op 文档。
参考资料