Fork me on GitHub

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

使用 RawSocket 做端口探活(2)

问题背景

在上一篇文章《使用 RawSocket 做 TCP 端口探活》中,介绍了一种使用 raw socket 来实现服务健康检查的方法。据此开发的集群健康检查工具 nurse 在我们的系统上兢兢业业工作半年多以后,突然变得不稳定了。起初以为是网络不稳定,一直没管,但问题一直持续,看来并非是网络问题,抽空追查了一下,因为网络编程相关知识不够熟悉,一直以为是 raw socket 会丢包(我并不是唯一遇到这个问题的人),花了一下午,绕了一个大圈,最终才确认是 SO_RCVBUF 的问题,现将追查过程记录在此,避免遗忘 。

单独提一下 nurse 的大致工作情况,方便了解接下来的问题:

  1. 加载探测目标的文件,初始化发送探测包的线程池,初始化接收回包的 raw socket,初始化 epoll event loop
  2. 开始探测循环:

    1. 初始化探测结果集合
    2. 遍历探测目标,使用线程池并行发送 tcp syn 包进行半连接探测
    3. event loop 收到 epollin 的事件后,使用 recvfrom 从 raw socket 读取回包,记录探测结果
    4. 遍历完之后汇总结果,使用通知线程发送消息(如果有的话),继续下一次探测循环

追查过程

起初探测目标一直在两三百个左右,工作正常没有问题。一次系统扩容后,探测目标增加到了480个,由于探测配置和扩容是自动同步的,刚扩完就发现健康检查报告不稳定,断断续续的报一批服务失活和恢复,问题持续了一段时间,觉得不太正常便开始追查。

一开始怀疑是网络问题,因为判断失活就是因为没有收到 ack 回包,于是使用 tcpdump 抓包看了一下,tcpdump 的条件和生成 lsf code的条件相同tcpdump -i eth0 'tcp and tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and tcp[8:4] = 888889',因为seq 序列号是自己指定的,通过这个能准确抓到回包,结果如下图,回包确实有480个,而且很稳定,直接排除了网络问题。

A627F297-49CD-40D8-B050-B35E7791C365.png

期间也重新回顾了 tcp 三次握手以及半连接相关的概念,其中阿里中间件团队博客上的《关于 TCP 半连接和全连接队列》这篇文章介绍的比较详细,感兴趣可以去看一下。

既然回包没有问题,那问题应该还是在处理的代码上了,加上了一些调试日志,显示虽然发送了480个探测包出去,但是 epoll 只响应了310多个回包的事件,还有100多个响应丢了。这就奇怪了,按理这个数量也不算多大的压力,为何 raw socket会丢包?

回顾相关的代码,整理了一下工作逻辑:tcpdump 命令确认了收包480个,排除了探测发包部分,问题是出在 epoll 事件循环里面;而事件循环相对逻辑简单,就是响应 epollin 事件,recv 收包,然后拆包获取源地址和端口,判断存活状态。tcpdump 结果看回包没问题,拆包有问题的可能性也很小,要不也不会这时候出错。于是排查的点集中在了 epoll 事件循环和 recv 这个部分。

首先看 recv 这部分的代码:

std::string capture(int recv_fd, const struct sockaddr_in &lo) {
    char recv_buf[RECV_BUF_SIZE];
    memset(recv_buf, 0, RECV_BUF_SIZE);

    struct sockaddr saddr;
    socklen_t saddr_size = sizeof(saddr);
    if (recvfrom(recv_fd, recv_buf, RECV_BUF_SIZE, 0, &saddr, &saddr_size) < 0) {
        fprintf(stderr, "WARNING: Revf from socket %d failed\n", recv_fd);
        return "";
    }

    // 忽略以下拆包的代码
    // ...
}

只有 recvfrom 这个操作了,难道是这儿的问题?前文中也提到了,因为 raw socket 的性质,回包都是通过同一个 raw socket 接收的,会不会是同时有多个 packet 到达,recv 会合并读取这些个包,而解析时候只当作一个解析,所以丢失结果了?仔细阅读了 recvfrom 的文档,google 了很多相关资料,排除了这种可能性。Stackoverflow 上的这个问题和我的疑虑类似,回答中也明确说了,不会合并,不同的数据包同时到达时仍然是不同的数据包,需要多次调用 recvfrom 来获取数据。

其实看到这个问题和里面的回答,如果对网络编程熟悉一些,应该可以找到问题了,但正式因为不熟悉,所以没有意识到里面提到的 buffer 是指的排队的 buferr,错误理解成代码中的 recv_buf,错失了找到问题的一次机会。

recv 看起来没有问题,难道是 epoll 的事件循环有问题?毕竟打日志调试的时候发现,确实少了100多个事件。相关的主要代码如下:

    // 在限定时间范围内接收返回结果,对于收到结果的 target,判断是否恢复
    while (true) {
        int event_cnt = epoll_wait(epoll_fd, &event, 1, 150);
        if (event_cnt < 0) {
            fprintf(stderr, "ERROR: Epoll failed with errno: %d\n", errno);
            exit(3);
        }
        for (int i = 0; i < event_cnt; ++i) {
            std::string str_host = capture(event.data.fd, local);
            if (str_host.empty()) continue; 

            if (health_states[str_host].st_change_on_success()) {
                std::string content = std::string("服务: ") + detect_flag[str_host] + "  地址: " + str_host + "\n";
                recover_hosts.emplace_back(content);
            }

            detect_flag.erase(str_host);
        }
        if (get_cur_ms() - start_ms >= 900) break;
    }

这一块儿,调试的时候也有一些疑问,就是每次 epoll 都只返回1个事件,然后几百次就要几百个循环去做系统调用,肯定会慢一些。慢的话,是否有可能处理的晚的包就超时被丢掉了?

先看 epoll 每次都返回1个事件的问题。epoll_wait的第2、3个参数返回的事件结构体数组以及最多返回事件的数量,由于之前理解不透彻,分别填的是单个 event 结构体的地址和1,改成数组和10之后测试,返回的时间仍然是1,疑惑半天,StackOverflow 上的这个问题给出了回答:epoll_wait返回的事件数量指的是有 I/O 活动的文件描述符的数量,而由于我们只监听了一个 raw socket,所以不管达到多少个事件,每次返回数量总是1。既然如此,那我们就改写一下代码,对于每次 epollin 的事件,我们就循环读,直到没有数据为止,这样也可以减少 epoll_wait的系统调用,改动如下:

    for (int i = 0; i < event_cnt; ++i) {
        while (true) {
            // capture中的 recv_from,需要加上 MSG_DONTWAIT 选项变为非阻塞
            // 读不到数据的时候就会立即退出,返回空
            // 然后这里遇到空则退出循环
            std::string str_host = capture(event.data.fd, local);
            if (str_host.empty()) break;  

            if (health_states[str_host].st_change_on_success()) {
                std::string content = std::string("服务: ") + detect_flag[str_host] + "  地址: " + str_host + "\n";
                recover_hosts.emplace_back(content);
            }

            detect_flag.erase(str_host);
        }
    }

当然也看了不少 ET 和 LT 的对比,在这里没有明显区别,LT 处理相对简单,就不拓展问题了。改完之后,发现处理的事件仍然还是少,问题依旧存在。

不过在这个过程中,阅读的大量资料,让我对这个模型的轮廓逐渐熟悉,大体猜测到,问题可能真的是因为回包被丢掉了,而丢掉的原因,很有可能是因为处理的慢,而排队长度又有限,导致有一部分包直接被丢弃了。正如 tcp dump结果里写的那样,*** packets droped by kernel。解决方法,要么是处理的足够快,要么就是加大队列长度(最简单的做法,by redhat documentation - SOCKET QUEUE)。而这里的队列,就是SO_RCVBUF指定的了,所以,修改这个值,问题应该就能解决,创建 raw socket 的代码修改如下:

    // Because using lsf we need to create an ETH_PACKET capture socket
    int recv_socket = 0;
    if ((recv_socket = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) < 0) {
        fprintf(stderr, "ERROR: Create recv socket failed\n");
        return -1;
    }

    // Set large SO_RCVBUF, need to change /proc/sys/net/core/rmem_max
    unsigned int optVal = 624640;
    unsigned int optLen = sizeof(optVal);
    if (setsockopt(recv_socket, SOL_SOCKET, SO_RCVBUF, &optVal, optLen) < 0) {
        fprintf(stderr, "ERROR: Cant't set recv buf for recv socket\n");
        return -1;
    }
    optVal = 0;
    getsockopt(recv_socket, SOL_SOCKET, SO_RCVBUF, &optVal, &optLen);
    fprintf(stderr, "DEBUG: initial socket receive buf %d\n", optVal);

但是,需要注意的是,SO_RCVBUF的取值还受到系统上限的限制,在 linux 手册中有介绍,最大值上限由/proc/sys/net/core/rmem_max决定,最终取值是max(代码中设定的值,2*rmem_max取值),也就是最大也只能是上限的两倍,如果仍然不够,就需要修改/proc/sys/net/core/rmem_max的取值

7C7A7150-EB3F-4A7D-A0D9-42146A7D3EBA.png

修改完buffer 限制,一切恢复正常,问题解决。果然只看书是没用的,需要在实践中去理解消化。

记 CentOS7一处内存cache导致的性能问题

问题背景

线上业务集群迁移到新机器,在运行一段时间后,发现新机器不是很稳定,各个机器的 cpu idle 和耗时会出现一些尖峰式的震荡,idle 最低的时候会压到0,同时也会出现不少超时请求,看起来,机器在那一瞬间像被卡住了一样,但又能较快恢复。如下图所示

6F205DED-DBB5-412F-AB47-BBC944D7F36A.png

与 NUMA 和 NUMA balancing 的问题不同,之前出问题的机器会比较稳定的耗时长,idle 低,性能差,而这些机器则是不稳定出现,而且出问题的时间很短,很难抓住现场。而且这些机器所在机房本身又有超电风险,又加开了睿频,一时间,我们也很难确定问题是来自于外部还是来自于机器本身。

问题解决

问题的结局也很偶然,是有同学在测试其它业务的时候发现系统 buff/cache 过高,而他们的业务需要申请大量内存,当 buff/cache 填满之后,系统性能明显下降,手动清理之后恢复。看表象和我们的情况不太一致,我们的业务也不像他们那样大量使用内存,抱着试一试的态度,也看了一眼系统 buff/cache 的情况,还真发现两者存在正相关的关系(当然不是所有 buff/cache 高的机器都有抖动的情况,而是抖动的机器绝大多数都是 buff/cache 高),如下图所示:

A8F5039A-EDDC-4CED-805A-B3F751B64793.png

手动释放 cache 也比较简单,直接执行echo 3 > /proc/sys/vm/drop_caches命令即可清除,注意在如上面这样 buff/cache 巨大的情况下,清理会执行较长时间,也会占用一个 cpu,不建议在高峰期操作,对正常机器,这个操作也是流量有损的。另:官方文档里强调这个操作可能会导致严重性能问题,不建议在生产环境未经实验就开始使用。

至于为何会有问题,Tuning kernel memory for performance这篇文章里会有介绍,在此不班门弄斧。问题大致原因是,虽然 buff/cache 被认为是可用的内存,但使用时,也需要甄别那些块是可以释放使用的,因此当所有内存都被 buff/cache 占用后,在某些情况下,清理释放这些内存块也需要大量占用 cpu 资源导致系统响应变慢。

应对这个问题的简单方法是写个定时任务定时清理内存,但这样治标不治本,另外一种较持久的方法是设置保留内存的大小,在上面文章的 solution 里也有介绍,设置/proc/sys/vm/min_free_kbytes的大小即可,一般设置内存大小的10%为保留内存,当系统发现 free 的内存小于该值的时候,会开始寻找可以释放的 buff/cache,使得 free 的值在保留值之上。不过注意这样的方式机器重启之后就会失效,一劳永逸的方法是修改/etc/sysctl.conf,修改vm.min_free_kbytes的值即可。另外文中提到,对于耗时敏感的服务,任何程度的 swap 都会导致性能受损,可以将vm.swappiness设置为0,不使用 swap。

再次强调,生产环境直接修改这些值可能会导致严重后果,请先在灰度环境或者流量低峰测试之后再进行操作。建议详细阅读Tuning kernel memory for performance这篇文章,正确理解相关参数的含义。

使用 wrk 定制请求进行压力测试

背景

最近手里有个活需要做压力测试,压力测试已经做过很多了,由于是自己做,也不需要发什么正规的测试报告,简单起见会用shell 脚本结合管道和 curl 自行构建请求发压力,这样可以利用线上数据快速构建大量接近真实的请求,也可以自定义并发度和请求数,方便快捷,参见之前的“并行执行任务的脚本”一文。

而这次是由 QA 来做,在压测工具上遇到了些问题,主要是请求构造方面,我们的场景下是将图片 base64 encode 成文本然后发送 POST 请求,请求的 body 比较大,QA 所使用的 JMeter 似乎在 body size 上会有问题导致卡死,总之花了一天时间问题没有解决。于是考虑帮他们处理一下压测工具的问题。

之前用过 ab,wrk,ab 过于简单且存在压力压不上去的问题,不合适;wrk 性能强大,可以定制请求,可以一试;另外看看到了 siege,看介绍说是生成一个 urls.txt就可以满足需求,准备尝试一下(但最终实践证明,siege也不是那么好使,至少按要求生成了 url POST body格式的文件(注意我们的 body 很大),siege 并没有正常执行,试了几次直接放弃了)。最终决定还是用 wrk,之前项目中 wrk 模拟百万提交的能力令人印象深刻,加之有脚本定制功能,满足需求肯定是没问题的。

wrk 相关

wrk(https://github.com/wg/wrk.git)介绍很多,这里不重复了,特点就是性能强,能用较少的资源就可以发出强大的压力,支持定制请求。CentOs 下安装比较容易:

yum install openssl-devel git -y
git clone https://github.com/wg/wrk.git wrk
cd wrk && make
# 把生成的 wrk 拷贝的PATH目录,例如
cp wrk /usr/local/bin

wrk 的帮助信息:

Usage: wrk <options> <url>
  Options:
    -c, --connections <N>  Connections to keep open
    -d, --duration    <T>  Duration of test
    -t, --threads     <N>  Number of threads to use   
    -s, --script      <S>  Load Lua script file
    -H, --header      <H>  Add header to request
        --latency          Print latency statistics
        --timeout     <T>  Socket/request timeout
    -v, --version          Print version details      
  Numeric arguments may include a SI unit (1k, 1M, 1G)
  Time arguments may include a time unit (2s, 2m, 2h)

选项比较简单,-t 可以指定并发线程数,-d 可以指定压力持续时间,然后就是我们需要关注的定制部分了,-s,使用 lua 脚本定制发送的请求。

wrk 压测脚本有3个生命周期:

  1. 启动阶段:在脚本文件中实现 setup 方法,wrk 就会在测试线程初始化后但未启动时调用该方法,wrk 会为每一个线程调用一次 setup 方法,并传入代表测试线程的对象 thread 作为参数,在方法中可以操作 thread获取/存储信息,甚至关闭该线程
function setup(thread)
    # thread 提供了1个属性,3个方法
    #   thread.addr             设置请求的ip
    #   thread.get(name)        获取线程全局变量
    #   thread.set(name, value) 设置线程全局变量
    #   thread.stop()           终止线程
end
  1. 运行阶段:如下,该阶段包括四种方法
function init(args)
    # 每个线程在进入运行阶段后仅调用一次,args 用于获取命令行中传入的参数
    # 可以在这个阶段加载一些数据等等
end

function delay()
    # 每次请求调用1次,发送下一个请求之前的延迟,单位为 ms
    # 例如需要随机间隔请求时,可以使用该函数
end

function request()
    # 每次调用请求1次,返回具体的 http 请求,可以使用 wrk.format 函数生成请求,见下文介绍
    # 我们的场景下需要定制每个 post 请求的 body,就需要利用这个函数
    # 注意不要在这个请求中做耗时的操作,数据的加载可以放到 init 里去做,在这里直接读结果
end

function response(status, headers, body)
    # 每次调用请求1次,返回 http 响应
    # 为提升性能,如果没有定义该方法,wrk 不会解析响应的 headers 和 body
end
  1. 结束阶段:整个测试过程中只调用一次,获取压测结果,生成定制化的报告,报告中的参数如下
function done(summary, latency, requests)
    # latency.min              -- minimum value seen
    # latency.max              -- maximum value seen
    # latency.mean             -- average value seen
    # latency.stdev            -- standard deviation
    # latency:percentile(99.0) -- 99th percentile value
    # latency(i)               -- raw value and count

    # summary = {
    #     duration = N,  -- run duration in microseconds
    #     requests = N,  -- total completed requests
    #     bytes    = N,  -- total bytes received
    #     errors   = {
    #         connect = N, -- total socket connection errors
    #         read    = N, -- total socket read errors
    #         write   = N, -- total socket write errors
    #         status  = N, -- total HTTP status codes > 399
    #         timeout = N  -- total request timeouts
    #     }
    # }
end

整个生命周期总结可以见下图:

3C8FBB2B-AD66-44A1-A6AB-FB41AA1E8BC5.png

如下是在 wrk 线程各阶段可以使用的变量,例如,对于我们的场景,每个请求POST 的数据都是不一样的,所以需要在运行阶段中,利用 wrk.format 来生成每一次的具体请求。

# 定义请求的主要字段,例如可以用 wrk.method="POST" 将所有请求改为 POST 请求
wrk = {
    scheme  = "http",
    host    = "localhost",
    port    = nil,
    method  = "GET",
    path    = "/",
    headers = {},
    body    = nil,
    thread  = <userdata>,
}

# method: http方法, 如GET/POST/DELETE 等
# path:   url的路径, 如 /index, /index?a=b&c=d
# headers: 一个header的table
# body:    一个http body, 字符串类型
function wrk.format(method, path, headers, body)

# 获取域名的IP和端口,返回table,例如:返回 `{127.0.0.1:80}`
# host:一个主机名或者地址串(IPv4的点分十进制串或者IPv6的16进制串)
# service:服务名可以是十进制的端口号,也可以是已定义的服务名称,如ftp、http等
function wrk.lookup(host, service)

# 判断addr是否能连接,例如:`127.0.0.1:80`,返回 true 或 false
function wrk.connect(addr)

问题解决

熟悉了 wrk 的工作情况,我们的问题就好处理了。实际需求也很简单,就是有一批图片,需要发压力的时候随机挑选图片,然后按指定压力 post 出去即可。发压力可以使用命令wrk -t N -d M --latency --timeout=5s "http://ip:port/path",具体定制请求,就需要使用到-s 参数,编写 lua 脚本。

我们的POST 参数格式是id=***&content=***,其中 content 就是图片的 base64编码。因此,我们可以先将图片按该格式预处理好,存成文本文件,然后lua 脚本直接读文件构造请求即可。假设我们的图片已经按照该格式存储在./data目录下,一个图片对应一个文本,文件名就是从1到 N 的数字

那么自定义脚本 post. lua 如下:

# 指定请求方法都为 POST,MIME 类型为简单的 url-encode
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/x-www-form-urlencoded"

# 定义读取 POST body 内容的函数,按上文所述,POST 的 body 已预处理好并且分别存在单独的文件中
function get_body(path)
    local file, errorMessage = io.open(path, "r")
    if not file then
        error("Could not read file: "..errorMessage.."\n")
    end
    local content = file:read "*all"
    file:close()
    return content
end

# wrk 运行阶段初期,先加载所有的 body 内容到一个 table 中,运行时直接随机读取
init = function (args)
    body_table={}
    for i = 1, 1322 do
        body_table[i]=get_body("./data/"..i);
    end
    math.randomseed(tostring(os.time()):reverse():sub(1, 7))
end

# wrk 运行阶段,随机选取 POST body,构造具体发送的请求
request = function ()
    local n = math.random(1322)
    return wrk.format(nil, nil, nil, body_table[n])
end

这样,我们就可以使用命令wrk -t N -d M -s post.lua --latency --timeout=5s "http://ip:port/path"发送随机图片的压力了,其中 N 是并发线程数,M 是持续的时间,比如30s。

输出报告如下:

BDC0F8ED-D52A-48A6-807E-C1BA4A69572D.png