Fork me on GitHub

标签 gpu 下的文章

GPU调度优化项目回顾

项目效果

重要的放在前面,先给出结论,感兴趣的可以继续往下阅读。直接上图直观感受一下(红线是新方法,上图越靠上越好,下图越平稳越靠下越好):

D436F44F-7235-4F24-83C2-0FA022029472.png

060D3EB0-5904-4B3B-B2ED-82387709AA62.png

在高峰期时:

  1. CPU IDLE 由平均 33.908 提升至 40.63,提升幅度为19.8%
  2. 核心服务单位时间总耗时由 8703.655 降至 7554.532,降幅为13.2%
  3. 在标准测试集上压测结果表明,容量提升在8%~15%左右(根据配置情况)

遇到了什么问题?

OCR后端服务从15年春开始搭建,从最初仅有CNN单字识别服务,GPU机器单手可数的境况,发展到现在,增加了中文RNN,英文RNN,Misc API Server(包括语言模型,分类服务(中英文,方向,黄图),中英文纠错)等,机器规模也扩张了十倍。

在单字服务上线前的压测中,就发现了GPU在多任务并行方面的奇怪表现:单个Device同时执行多任务时,单任务耗时会被显著拉长,同时执行的任务越多,损耗越显著。这个问题带来的后果是:

  • 耗时不稳定:底层服务耗时不稳定,容易形成连锁反应,造成系统逐层崩溃
  • 系统吞吐不足:面对峰谷差异异常显著的场景,高峰期有雪崩的隐患

简单的说,就是可用性存在隐患

怎么说也是经历过一些场面的程序员,基本的应对手段还是有的。在祭出了线程池+无锁队列+异步的招数后,单机吞吐从不到最开始的不到300,逐渐提升到了330,最后到了390。在仅有CNN服务的情况下,也就够用了。

平静的日子过了一年多,16年下半学期那一波流量猛涨,加上同机混布的服务越来越多,机器资源越来越紧张,让我们的系统容量,耗时和稳定性又受到了挑战。于是单机性能优化这个topic又重新拿了出来,并且被反复讨论了很多遍,方向主要集中在调度和GPU代码优化两方面。

对于后者没什么可说的,但对于前者,我的内心是很矛盾的,矛盾来源于:

  • 一方面,之前的招数让单卡同时执行的任务已经是可控并且有限的,通过调度来提升,着力点在哪里,空间又能有多大,都不确定;
  • 另一方面:GPU在多任务并行时的奇怪表现以及GPU难以达到高负载,始终缺乏一个合理的解释,应该是有事情可以做的
    带着这样矛盾的心情,这个项目开始上路了。

如何解决?

经过了数轮的争论,决定从重试的角度入手,具体是利用nginx支持四层反向代理的机制去做一些事情,希望能在集群范围内充分利用每个Device,尽管最后不是这么做的。在摸索的过程中,我们发现这个问题的核心还是在多个GPU之间均衡分配任务,尽量避免重叠。一句转折,省略了很多中间的探索过程,跨度有点大,这部分的工作虽然没有直接使用,但明确了方向。

如果抛开中间过程,单看结论,会感觉仿佛回到了出发点,只是这个时候的感觉是不一样的,之前虽然知道要面对的是这个问题,但不知道着力点在那里,怎么去做,折腾了一番之后,虽然还是面对这个问题,但理解上更进了一步,隐约感觉可以搞一些事情了。

1. 对于重叠

之前知道有这个问题,但没有量化,那就先做个实验看一下,重叠带来的损耗究竟如何。实验结果如下:

05CC32AA-D2FD-44CF-B7A4-54F42E5A342C.png

以两个任务并行为例:最差为同时执行,两个任务都受到了影响,原本各自50ms总共100ms的任务,变成了各自100ms总共200ms,浪费了100ms;其它情况重叠程度不同,浪费的情况不同。

这个实验只是个验证实验,本身对最后结果并没有直接帮助,不过,从这个实验中总结出了两个个重要的指标,就是有效GPU时间浪费时间。理论上,最好的调度方法,有效GPU时间应该和全串行情况相同,浪费时间则尽可能的少

请记住这个结论,这是接下来各项实验的评价基础。

2. 对于均衡

关于均衡,使用生产-消费者方式的线程池其实已经能很好的做load balance了,每个worker都根据自己的能力去处理任务,一切都井然有序。但注意,这里有一个前提,那就是所有任务都是通过一个线程池实例来分配,而且每个device对应一个worker线程。而线上实际情况是,三个核心服务(CNN,中文RNN,英文RNN)各自拥有各自的线程池,其中CNN单个device上有两个worker线程(至于为什么是这样在这里不重要,只需了解这是当时最符合实际的实现即可)

结合之前追bug时候的发现:同时发出的两个CNN请求,有一定比例都落到同一个device上。想了想,这里可能还真的不那么均衡。为了验证,也做了个实验把线上GPU处理任务的过程可视化,于是有了下面这个结果,暂且称呼它为任务密度图:

C3D13DD3-FC39-4B73-9684-3E65994384CD.png

我们在服务中注入了一些采集代码,收集了一段时间内各个device上执行任务的情况,并将其以堆叠图的形式展现出来,结果验证了我们的猜测,总体均衡,但在局部时刻,各个卡的任务可能不均衡。另外发现由于执行需要时间,同时并行的任务比预期的多,控制并行任务总数,也是可以做的事情之一。

通过上面两个验证实验,着力点相对清晰了:
我们需要想一些办法:1. 减少同时执行的任务数;2. 让多个device在局部时刻也做到任务均衡。而评判的方法,就是有效GPU时间和浪费的时间。
虽然这时候还是不知道能有多大作用,但至少可以发现,接下来还是有点事情可以搞,聊胜于无。

实验-猜测-再实验

这部分耗时较长,挑关键路径说,但要说明的是,实际过程比这个要曲折的多,每一步都是在反复的实验测试之后改良总结而成。

上文提到的着力点,拆解一下:

1. 对于单个线程池,减少worker线程数
2. 对于多个线程池之间,通过各种IPC方式让彼此之间协调任务分配
3. 准备一个标准测试集,设计指标去评判修改前后的效果变化

最先进行的方案1很容易也很快尝到了甜头,将原有CNN线程池单个device两个线程改成一个,很快发现了耗时上的收益(红线是修改后,越低越好):CNN耗时从30多毫秒将至20多毫秒,降了接近10毫秒!(至于为什么之前采用2个,因为当时没有其它服务,CNN任务内部夹杂着CPU计算的部分,实验得出一个device两个worker线程吞吐最高,混布之后并没有想到这部分会受到影响)

ACD7E9F8-1241-468E-AC96-D02ACBDC1DA5.png

再来看方案2,经过1的处理后,单个device上同时执行的任务至多是3个了(CNN,中文RNN,英文RNN),最理想的情况是串行,也就是1个。那么,是不是可以看一下至多2个,至多1.5个的情况?

不过在此之前,我们先要把标准测试集以及自动测试的环境搭建起来,方便对比各种并发度下的表现。经过长时间的手工实验和不断总结,形成的压测工具如下图,一键执行指定级别(并发1 ~ 10台机器,每台机器可模拟1 ~ 10组克隆的线上流量)的压测,每个场景下的测试重复5遍,自动采集各项指标(服务端和客户端)并汇总求均值。该工具将之前需要三四天的手工测试工作量缩减到一到两小时级别。

38B95943-B179-4B9D-8013-1708A1748D3E.png

CC5522D2-01DA-44D5-90E8-BF70C8D7CFF0.png

接下来处理方案二,IPC本身不难,调用各种API而已,But the devil is in the details,麻烦之处在于处理各种细节各种分支避免各种问题上,处理不好的诸如PHP的eacc和apc扩展,使用不当容易出现假死问题。在实验版本上就不追求细节了,使用最简单粗暴的信号量进行模拟,暴力减除边界条件,辅以外部程序进行调整,很快搞出了实验版本来采集各项数据,接下来的实验过程是漫长的,压测工具的各项指标也是在这个过程中一步步完善起来。

最初重点关注的是GPU时间和均衡程度,如下图:

6F1D91CD-7502-4E58-AFD9-C53086503516.png

6F1D91CD-7502-4E58-AFD9-C53086503516.png

仅针对GPU时间,随着单个device并发度限制降低,同样的计算量所占用的GPU时间也有显著降低,看到这个结果我们一度欢欣鼓舞,但事情并没有这么简单,随着实验的逐步完善,我们发现并且验证了,单个device并发度的降低也会显著降低单机的吞吐量,客户端耗时也有明显延长。

通过限制并发度做任务均衡的方法没有如预期那样有效果,说明我们分析时使用的模型可能并不恰当。反复讨论之后我们将注意力集中到了之前所说的『浪费时间』上,猜测这个时间并不是浪费在GPU的执行上,执行本身没有变慢,慢是慢在了互相等待上,称为浪费时间也不太合适,改为等待时间比较好。于是将GPU时间采集的代码细化到了循环内部,实验证实了之前的猜测。

虽然方案2并没有取得成功,但从『浪费时间』到『等待时间』的转变,突然让我们对调度模型有了新的认识,突然一下子,感觉面对的这个模型,不正好和进程调度的模型非常类似吗?在处理器数量小于作业数的场景下,最短作业优先不是吞吐最高的调度算法吗?再想办法处理一下长作业『饿死』的情况,是不是就可以解决这上面的等待问题?

最短作业优先的例子在生活中也不少见,以行车为例,大车通常速度较慢,不允许驶入快车道,快车道仅允许小汽车快速通过。在这种情况下,小车和大车都能以各自速度顺利通行,此时道路通行能力最佳;即便因为各种大车速度不一,慢车道通行稍微缓慢一点,整体通行能力仍然较高;如果大车不受约束,驶入快车道,那么小车通行必然受阻,被迫以大车速度通行,此时道路通行能力大大下降,拥堵也就发生了。

36763D46-4B2C-4C99-BFE5-C7D0E4B2551B.png

道路的通行能力可以看成是系统的吞吐量,小汽车可以看做是执行较快的任务,大车则是执行较慢的任务,在线上FCFS的情况下,就犹如下图,快任务受慢任务拖累,如果快任务量大的情况下,这个拖累更为可观,系统吞吐也就呈曲线加速降低。如果我们加以限制,保证慢任务在慢通道上执行,快任务则不受限制,那么吞吐量也必然会有提升。

马上模拟验证了一下,当看到实验版本的总耗时小于线上版本的时候,立马感觉到这事儿靠谱了。接下来就是不断的实验,采集数据,证明该方法有效,最终实验对比图如下:

E2020C0E-4FED-46E9-9B1E-2A6A12734A90.png

曲线代表响应时间,条形图代表压力。可以看到,实验版本承压能力明显更强,响应时间在高压力时仍能保持平稳,测得的吞吐量也更高;线上版本响应时间随压力变化明显,存在拐点;并发度受限版本在之前实验中虽然在纯GPU时间上有明显收益,但吞吐和响应时间上的表现较差。

至此,方案已经明确了,剩下需要做的就是开发线上版本,使用共享内存和互斥量替代信号量,对并行环境下的初始化和异常退出着重处理,都是一些技巧性的问题,在此不再详述。

小结

这个项目要解决的问题属于比较uniq的,并没有查到有效资料可以参考。关键在于主机端和cuda sdk衔接的地方,在CPU的环境下,较少有机会主动关注任务调度的情况,即便关注,加权和均衡是更多考虑的地方,较少会采取最短作业优先这种比较偏激的做法。

回顾这个项目,个人感觉有几个关键的地方:

  • 有意义的讨论
    在方向不明确的时候,有意义的讨论能将问题范围缩小,避免无用功
  • 大胆假设,小心求证:
    根据已掌握的信息,大胆做出假设,建立模型,定义问题,然后设法验证,要么证真,要么证伪。前者说明假设正确,可以在此基础上进一步深入;后者则需要探究原因,重设模型,重新定义问题,再次进入求证的循环。在我们实验-验证-再实验的环节,这个方法体现的比较明显
  • 基础比较重要:
    个人感觉最大的转折点在于看到了等待时间的验证结果后,立马就想起了套用进程调度模型,感谢上学时候在操作系统那门课程上拿了满分
  • 可视化和自动化工具:
    需要大量实验的项目,很多结论和前进的方向,都要从数据中得出,可视化能有效的发现问题或者趋势。自动化工具也能极大节约重复实验的精力,且能够更大程度保证实验的一致性和有效性,人总会出错的,机器更高效可靠。

TF-Serving使用Batch提升性能

之前的文章中介绍了TF-Serving的CPU和GPU版本的编译方法,现在介绍一下在实际使用中,遇到的各种各样的问题,重点是GPU版本使用batching方面遇到的问题。

CPU版本TF-Serving的问题

CPU版本的TF-Serving有两种形式,一种是python环境;另一种是源码编译的CPU版本(或者直接使用docker)。

CPU版本的主要问题在于性能开销,如果不是online inference,或者online但请求量不大,CPU版本也够用。但如果请求量大,CPU版本的性能就捉襟见肘了,例如我们的线上服务,使用python环境,几千qps就需要几十台物理机,成本太高。同时,Batching在CPU版本上,实测效果并不明显(可能各个模型情况不尽相同),batching主要还是作用在加速设备上,比如GPU(In particular, batching is necessary to unlock the high throughput promised by hardware accelerators such as GPUs

Python版本还有一个问题就是捉摸不定的接口格式,例如”Batched output tensor has 0 dimensions“,开启batch之后,请求必须是多图,关闭batch之后,单图多图都可以,详情可见这个issue。在实际使用中, 按batch大小在客户端做chunk的时候,不可避免的会有剩余单图的情况,这样就比较尴尬,这应该是tf-serving的实现问题。

GPU版本TF-Serving的问题

解决GPU版本的编译问题之后,最主要的问题就是batching的使用和调优上了,虽然TF-Serving官方提供了一个文档说明,但实际使用中,问题依然很多,最主要的问题就是:(1)batching不起作用(issue 1312);(2)Multi GPU环境下的使用问题(issue 311)。下面简单说明一下我们在这两个问题上做的测试以及最终处理方法。

batching没有效果

Batching主要是将多个请求中的数据合并成一个batch同时执行以达到加速的效果,官方文档里提到了四个参数:

  • max_batch_size:batch的大小,影响吞吐/延迟
  • batch_timeout_micros:形成一个batch的最大等待时间,影响最大延迟
  • num_batch_threads:并行batch的数量,控制并行程度
  • max_enqueued_batches:排队的数量,太短影响吞吐,太长影响耗时

从我们实验的结果来看,对于online inference,这几个参数的重要程度依次是batch_timeout_micros max_batch_sizenum_batch_threadsmax_enqueued_batches

以图像识别场景为例,一个请求一张图片,最开始没有开启batch,主要参数都是默认的情况下,P4单卡吞吐 <100 qps。开启batch,但batch_timeout_micros设置的比较大时,耗时很长,效果很差。官方文档中说的“Temporarily set batch_timeout_micros to infinity while you tune max_batch_size to achieve the desired balance between throughput and average latency. Consider values in the hundreds or thousands.”有一定的误导,对于在线预测,由于需要将多个请求合并成一个batch,这个等待的时间并不能很长,几毫秒就够了,也就是接下来所说的,”For online serving, tune batch_timeout_micros to rein in tail latency. The idea is that batches normally get filled to max_batch_size, but occasionally when there is a lapse in incoming requests, to avoid introducing a latency spike it makes sense to process whatever’s in the queue even if it represents an underfull batch. The best value for batch_timeout_micros is typically a few milliseconds”,一开始没有理解这一点,按照文档步骤去做参数调优,测试结果很是让人困扰。直到将batch_timeout_micros改成几毫秒的时候,才看到batch的收益,结合其它几个参数调优,P4单卡吞吐陆续上到了350qps,最终优化到了480qps(合理设置batch size,以及server自身的参数)。

另外文档中还说的”Zero is a value to consider; it works well for some workloads”,也产生了一些误导,主要是没有说明是哪些workloads适用0的情况。对于一次请求一张图片,将batch_timeout_micros设置为0意味着不使用batching,因为每个请求都不等待后续请求,自然也就不能形成batch。但是,在使用客户端batch的时候,可以设置为0测试一下,因为在客户端拼好batch大小的数据以后,server端是可以不用等待直接执行的。接下来解决Multi GPU使用率的问题,就用到了客户端batch这一点。

Multi GPU使用率的问题

开启batching并单卡调优之后,是不是直接扩展到多卡就可以了呢?本以为是的,但现实给了一记暴击。TF-Serving对多GPU的支持是依赖于模型的定义,模型训练时使用多卡,那么inference的时候也要求使用同等数量的卡。如果模型训练用的单卡,虽然从显存占用上看是多卡,但实际只会用第一张卡。所以,要用多张卡,需要使用NVIDIA_VISIBLE_DEVICES环境变量去设置可见的GPU设备,然后启动一个TF-Serving实例,有8张卡,就需要启动8个实例。

为了Load Balance方便,我在这8个实例前加了一个Nginx做反向代理,然后一个请求一张图,加大压力去压,期望能获得480*8的qps,但实际并没有,调整timeout参数也至多只能压到500左右的qps,推测还是因为做了balance,所以在单张卡上难以形成batch。多方寻找原因未果,只能尝试客户端batch的形式,所谓客户端batch,也很简单,就是一个请求中包含多张图片,请求格式参见RESTful API

使用客户端batch之后,吞吐量是上去了,但还会遇到压力结束,TF-Serving进程意外终止的情况,而直接压8个实例则没有这个问题,这个问题没有详细追究,如果有人知晓原因还请告知。最终是在客户端做了Load Balance和batch,随机请求这8个实例,8个GPU的使用率都可以压到80以上,总体的吞吐也能到4000张图/每秒

当然,对于客户端来说,也有一些优化的手段,比如,http请求的并行化,这里推荐使用事件机制,例如evhttpclient,压缩客户端等待时间。

其实还有一些例如tail latency的问题没有解决,不过至此,最主要的吞吐问题已经差不多了,剩余问题解决了再补充。

最后附上一些相关的资料,方便以后查询:

  1. How we improved TensorFlow Serving performance by over 70%
  2. 基于TensorFlow Serving的深度学习在线预估
  3. Optimizing TensorFlow Serving performance with NVIDIA TensorRT
  4. Optimizing TensorFlow Models for Serving

Tensorflow Serving CentOS 7源码编译(CPU + GPU版)

CPU 版本编译

1.需要联网环境,如果是生产环境,最好配置能够连接外网的代理,否则需要 copy 相关依赖安装,费时费力。代理配置方法:

# http://URI:PORT为代理地址,注意需要加上协议名称,如 http://,否则相关编译脚本下载依赖时会报错
export http_proxy=http://URI:PORT
export https_proxy=http://URI:PORT

2.安装相关依赖,依赖列表可以从 tensorflow-serving 的 docker file 中找到,主要需要安装的依赖如下,因 github 上的项目更新较快,请以 docker file 中的为准。:

# 一般会缺失的依赖
yum -y install java-1.8.0-openjdk-devel automake autoconf libtool libicu patch

# python 相关,连接境外源较慢,建议更换成国内源,比如 douban 等
pip install --upgrade pip
pip install numpy grpcio Keras-Applications Keras-Preprocessing h5py requests enum --trusted-host pypi.doubanio.com

3.安装 bazel:在 https://copr.fedorainfracloud.org/coprs/vbatts/bazel/ 中下载对应版本的repo文件,并拷贝到/etc/yum.repos.d/中,使用命令 yum install –downloadonly bazel 就可以下载 bazel 的 rpm 包,缓存在 /var/cache/yum 子目录下,比如/var/cache/yum/x86_64/7/vbatts-bazel/packages,如果有别的机器没有网络,可以将bazel-0.12.0-1.el7.centos.x86_64.rpm上传服务器对应目录下,使用命令 yum install bazel-0.12.0-1.el7.centos.x86_64.rpm 即可安装。如果 yum 下载最后提示 check sum 失败,可以查看 log 找到下载地址,wget 下载,再将 rpm 包复制到上述目录安装

4.下载 tf-serving 代码开始编译,时间较长,主要的错误就是依赖缺失,根据日志提示查找对应包安装即可

git clone --recurse-submoduleshttps://github.com/tensorflow/serving && cd serving
bazel build -c opt tensorflow_serving/...

GPU 版本编译

GPU 版本需要在 cuda 9 环境下编译,cuda 8下编译始终失败,git 上已经有说明。所以以下步骤,请在 Centos 7 + cuda 9 + cudnn 7 for cuda 9的环境下操作。

1.对于联网环境,相关依赖还有 bazel 的安装, 同上文 CPU环境

2.GPU 版本需要安装 TensorRT,在官网下载与 cuda 版本对应的 tar 包(如果 cuda 不是通过 rpm 安装,这里请不要选择 rpm 包,装不上),例如我们的环境是CentOs 7 + cuda 9。下载之后解压即可,将其移到合适位置,后续需要用到 TensorRT 的安装路径。

3.下载 tf-serving 的代码,参照 gpu docker 文件里的编译步骤,整理编译需要的环境变量和编译命令如下:

#!/bin/bash
export PATH=/usr/local/sbin:/usr/local/bin:/root/bin  #依照自有环境修改
export LD_LIBRARY_PATH=/usr/local/cuda/lib64/         #依照自有环境修改
export LD_LIBRARY_PATH=/usr/local/cuda/lib64/stubs:/usr/local/cuda/extras/CUPTI/lib64:$LD_LIBRARY_PATH               #依照自有环境修改,主要是增加 cuda 的依赖
export BAZEL_VERSION=0.23.1                #指定 bazel 的版本
export CUDNN_VERSION=7.5.0                 #指定 cudnn 的版本
export TF_TENSORRT_VERSION=5.0.2           #指定 TensorRT 的版本
export TF_NEED_CUDA=1                      #编译 GPU 版本必须指定的参数
export TF_NEED_TENSORRT=1
export TENSORRT_INSTALL_PATH=/usr/local/tensorRT/lib #依照自有环境修改,指向tensorRT 的 lib 目录
export TF_CUDA_COMPUTE_CAPABILITIES=3.0,3.5,5.2,6.0,6.1  #依照自有环境上的 GPU 修改
export TF_CUDA_VERSION=9.0                 #cuda 版本号
export TF_CUDNN_VERSION=7                  #cudnn 版本号
export TF_NCCL_VERSION=                    #兼容老版本,1.13版本以后可以移除
export TMP="/home/tcheng/serving/tmp"      #编译所需 tmp 目录,请保证磁盘空间足够

cd serving && bazel build \
    --color=yes \
    --curses=yes \
    --config=cuda \
    --copt="-fPIC" \ 
    --verbose_failures \
    --output_filter=DONT_MATCH_ANYTHING \
    --config=nativeopt \
    --incompatible_disallow_data_transition=false \
    tensorflow_serving/model_servers:tensorflow_model_server

我所使用的 bazel 版本是0.23.1,tf-serving 版本为1.13,如上编译应该没有什么问题。编译成功之后的 modelserver 在 bazel-bin/tensorflow_serving/model_servers/tensorflow_model_server目录下。ldd 可以查看编译结果的依赖,如果需要在别出使用,可以用 chrpath 工具修改 RUNPATH 到指定目录,将相关 so 拷贝到该目录即可使用。

4.记录编译过程中出过的一些问题:

* git clone 代码的时候需要recurse-submodules下载全部组件,否则在 bazel build 的时候会下载,慢不说还经常下载失败,提示`Premature EOF`
* bazel 版本更新可能会带来一些不兼容的情况,比如上面命令中的--incompatible_disallow_data_transition=false选项,在 docker file 中并未提到,可以根据编译出错的提示Using cfg = "data" on an attribute is a noop and no longer supported. Please remove it. You can use --incompatible_disallow_data_transition=false to temporarily disable this check,稍加搜索即可解决。