记一次计算密集型服务的性能优化
性能优化是一个庞杂的话题,不同的场景,不同的视角都会得到不同的理解。
比如:有的为了提高吞吐,有的为了降低延迟,有的为了节省资源。
他们并不是同一个问题,有时可以牺牲一定的延迟来换取更高的吞吐,比如批量处理;
有时可以用更多的存储空间来换取更低的延迟,比如哈希表……
本文讲述的是对计算密集型服务提高吞吐和降低延迟的优化经验和理解。
一、背景
这是微服务框架下的一个末端计算节点,使用C++编写,只有一个上游调用方,没有下游。
不使用数据库等外部存储,所有数据都在内存中(约占用45GB内存)。
使用Thrift ThreadPool框架,接收到请求后,根据内存中的数据做“简单”计算后打成ProtoBuffer包返回给调用方。
在48核物理机上,优化前极限QPS是3.5k左右,优化后的极限QPS超过14k,性能提升4倍。
二、方法论
性能优化也可以从不同层面去考虑,比如业务流程、架构设计、硬件配置亦或是代码编写等。
本文主要从代码优化的角度来优化性能。
使用perf等工具可以分析出热点代码。
根据二八法则,优化20%的代码可以提升80%的性能。
所以要先找出瓶颈,然后优化这个瓶颈,然后再找下一个瓶颈……
如上图,我归纳了一些常见的性能瓶颈。
一般极限QPS下如果CPU利用率不高,就要考虑是否有阻塞或者竞争(锁);
如果CPU利用率达到或者接近100%,极限性能还是不高,可以考虑优化流程,算法,去除不必要的内存拷贝,减少堆内存的申请、释放和系统调用等。
当然,有些系统看上去没有明显的瓶颈(没有明显的热点代码),这时候就比较考验工程师对性能优化的理解了。
一般可以从精简流程或者使用高效的“方法”替代低效的“方法”入手。
比如减少临时对象,使用哈希表替代红黑树,提高缓存利用率等方法。
做好性能优化的关键是:了解计算机是如何运转的。
- 了解硬件的工作原理,知道哪些操作快哪些些操作慢,比如:相比于内存,磁盘IO的速度比较慢;相比于cache,内存的读写比较慢等等。
- 在硬件之上要了解操作系统的工作原理,比如:堆内存和栈内存的区别,系统调用和函数调用的区别,锁的实现原理等等。
- 在操作系统之上要了解编译器和标准库都做了什么。执行一行看似简单的代码计算机要做哪些操作?
- 再之上就是对算法和业务的理解了。很多时候低效的算法和冗余的业务逻辑才是拖累系统性能的关键。
性能优化是一个系统工程,不能一味的追求性能,除了如何优化还要考虑其他问题:
- 是否需要优化?过早的优化是万恶之源。
- 投入产出比
- 稳定性,因改造引入的风险
- 鲁棒性
- 代码的可读性
- 维护成本
- 可扩展性
三、第一轮优化——去除IO阻塞
找到瓶颈,依据数据而不是凭空猜测。
优化前的极限QPS是3.5k,简单分析一下发现总的CPU利用率只有50%左右,推断系统内部有严重的阻塞(条件等待)。
常见阻塞原因有IO阻塞和互斥锁,使用iostat进一步观察系统状态发现await和iowait都很高,这说明IO阻塞比较严重。
因为系统运行时只有写日志会触发IO,所以怀疑是写同步日志造成的IO阻塞。
关闭日志后压测发现极限QPS可以达到8k+,这基本可以断定写同步日志对系统性能影响比较大。
将同步日志改为异步日志很容易,可是也会带来一些弊端。
最严重的就是有丢日志的风险,进程异常退出时可能导致缓存中的日志不能刷到磁盘上。
考虑到日志本身价值不大,只有在系统异常退出时(比如OOM),需要日志辅助定位问题。
综合考虑后,打算把错误日志记同步日志,其他的记异步日志。
这次优化的收益很明显,只修改了日志配置,极限QPS从3.5k提升到7k+。
后续打算把访问日志分离出来,也记同步日志,降低丢日志的影响。
四、第二轮优化——代码优化
二八法则,优先解决头部问题。
火焰图
从火焰图上看到除了malloc和free没有明显的热点代码。
压到极限QPS时,总的CPU利用率也只有70%。
分析一下malloc的过程,不难发现其内部是有线程锁的。
IO阻塞解决后,malloc的阻塞问题就凸显出来了。
TCMalloc做了线程级的缓存,理论上可以缓解锁竞争的问题。
可是换用TCMalloc后发现性能并没有提升(后来证明是因为未正确编译导致的,这里走了一些弯路),然后就尝试了其他思路,从代码的层面入手。
要了解计算机是如何运行的,知道每行代码都做了什么。
4.1 去除冗余逻辑
由于历史原因,代码中有一些中间数据结构,数据结构间的转换带来了额外的性能开销。
还有一些为记录调试日志的额外操作,比如吧PB序列化成文本或者字符串拼接等。
再有就是去除不必要的拷贝和临时变量。
4.2 标准模板(STL)的性能优化
在代码中没有搜索到太多的malloc/free和new/delete,这和火焰图显示的结果不符。
进一步翻阅代码发现有大量的STL容器(vector、string等)的使用,虽然这些都是在栈上创建的对象,但是他们都会申请堆上的内存。这些容器在增长的时候都可能会重新申请内存,比如vector的push_back()就会因为剩余容量不足导致重新申请内存,而且还可能要触发拷贝。
字符串(string)拼接的时候也有类似的问题。
这种问题可以同reserve来解决,前提是能预估最大容量。
另外vector的push_back()会多触发一次拷贝,所以用emplace_back()性能会更好一些。
针对调用频繁,size不大的且可以确定的vector,用完全在栈上的对象(我用STL风格的API封装的“数组”)代替。
栈内存的申请几乎不消耗时间,只需要移动栈顶指针即可,内存连续,cache利用率也高;
而堆内存的申请就比较复杂,可能涉及到线程间的竞争,即使有线程级缓存,也难免会带来额外的计算开销。
iostream用起来很方便,但是性能很糟糕,调用频繁的地方可以用snprintf()代替。
如果可以的话,用unordered_map代替map,用数组代替unordered_map。
五、第三轮优化——减少竞争
做完第二轮优化,性能提升50%,极限QPS从7k提升到10k+。
这时候malloc/free带来的开销就更加突出,而且极限情况下CPU利用率也只有70%。
所以锁推断是因为malloc/free中有线程锁,加上STL频繁的申请释放内存导致的。
因为TCMalloc有线程级的缓存,可以缓解这一现象,这和第二轮优化中的测试结果不符(因为编译问题导致TCMalloc未能生效)。
仔细检查并修正后,性能大幅提升,极限QPS可以超过14k。CPU利用率达到95%左右。
优化后的火焰图
六、还可以做哪些优化
前三轮优化使性能提升4倍,总体改动量不大,风险也可控,所以产出投入比还是很高的。
还有一些可以优化的点,因为改造成本、改造风险、运维成本和收益不够等原因暂时没有实施。
6.1 优化数据结构
服务会用到大量的静态数据,单份数据超过20GB。这些数据的结构还有优化空间。
- 有些数据用二叉树做索引,如果换成哈希表性能会更好。
- 数据不够紧凑,还有压缩空间,一般数据越紧凑对cache的利用率也会越高。
6.2 优化ProtoBuffer
ProtoBuffer也会因为频繁的申请/释放小对象导致性能不佳,可以用Arena Allocation优化。
Memory allocation and deallocation constitutes a significant fraction of CPU time spent in protocol buffers code. By default, protocol buffers performs heap allocations for each message object, each of its subobjects, and several field types, such as strings. These allocations occur in bulk when parsing a message and when building new messages in memory, and associated deallocations happen when messages and their subobject trees are freed.
6.3 使用Huge Page
Huge Page,是指的大页内存管理方式。与传统的4KB的普通页管理方式相比,Huge Page管理大内存(8GB以上)更为高效。
我们的服务占用40GB以上的内存,所以理论上使用Huge Page会有一定性能提升。
不过使用Huge Page也会带来额外的运维成本,也需要综合考虑风险和稳定性等原因。
Huge Page的示意图
七、总结
7.1 是否需要优化?
性能优化是一个复杂的问题,除了如何提高性能还需要从收益、成本、风险等维度综合考虑。
尤其要考虑清楚为什么要优化?是否需要优化?产出投入比是否够高?是否有更好的解决方案?
7.2 “上兵伐谋”
不能上来就扎到代码里,“宏观”上的优化可能会有奇效。
- 流程:业务流程是否可以优化?有时删掉一个冗余的逻辑会有大幅的收益。
- 算法:在规模较大时,时间复杂度上的差异往往是致命的。
- 机制:同步OR异步,通知OR轮询,阻塞OR非阻塞等等。机制上的差异是很难弥补的。
- 取舍:时间和空间互换,吞吐和延迟互换,做出合适的取舍,可以事半功倍。
7.3 “其下攻城”
代码是给人看的,程序还要靠计算机来运行。要知道计算机是如何运行的,知道每行代码背后计算机都做了什么,知道哪些操作快、哪些操作慢,才能做好性能优化。
有太多的点不能一一列举,总结一下就是:理论指导实践,实践检验理论。