1. X86 CPU缓存架构
如图1:所示CPU缓存架构,其中包括ALU、寄存器、MMU、TLB以及三级Cache组成。其中MMU负责的是虚拟地址与物理地址的转换. 提供硬件机制的内存访问授权;TLB:Translation lookaside buffer,即旁路转换缓冲,或称为页表缓冲;里面存放的是一些页表文件(虚拟地址到物理地址的转换表)。三级缓存(包括L1一级缓存、L2二级缓存、L3三级缓存)都是集成在CPU内的缓存,它们的作用都是作为CPU与主内存之间的高速数据缓冲区,L1最靠近CPU核心;L2其次;L3再次。运行速度方面:L1最快、L2次快、L3最慢;容量大小方面:L1最小、L2较大、L3最大。CPU会先在最快的L1中寻找需要的数据,找不到再去找次快的L2,还找不到再去找L3,L3都没有那就只能去内存找了。其中L1和L2缓存CPU独占,L3缓存则多个CPU间共享。另外X86采用NUMA架构,NUMA(Non-Uniform Memory Access,非统一内存访问)架构是一种针对多处理器系统的内存组织方式。 在这种架构中,处理器被分配到不同的节点,每个节点拥有自己的本地内存。 处理器可以访问本地内存和其他节点的内存,但访问本地内存的速度要快于访问其他节点的内存。上述的NUMA架构,三级缓存以及TLB缓存等,其决定了其CPU架构的局部性原理,决定了我们性能优化的方法和方向。
图1: CPU环境架构
2.性能瓶颈
进行任何性能优化前,有个很重要的前提是先找到性能瓶颈点,然后才能针对性优化,这就要求我们学会用性能分析工具:perf 性能检测,帮助我们发现性能瓶颈问题位置和原因。其中perf stat 采集程序运行事件,用于分析指定程序的性能概况:其中重点关注branches
:这段时间内发生分支预测的次数。现代的CPU都有分支预测方面的优化。。branches-misses
:这段时间内分支预测失败的次数,这个值越小越好。L1-dcache-loads
:一级数据缓存读取次数。L1-dcache-load-missed
:一级数据缓存读取失败次数。LLC-loads
:last level cache
读取次数。LLC-load-misses
:last level cache
读取失败次数。perf top 对程序性能进行实时分析:可观察到程序中函数使用CPU占比;perf top -p $(pidof dataplane); 可查看函数中最消耗CPU的指令逻辑;可观察到程序中函数cache-misses占比;perf top -e cache-misses $(pidof dataplane)可查看函数中cache-misses最高的指令逻辑。
3. 常见的性能优化方法
局部性原理 :局部性有两种,即时间局部性和空间局部性。时间局部性是指当一个数据被访问后,它很有可能会在不久的将来被再次访问,比如循环代码中的数据或指令本身;空间局部性是指当程序访问地址为xxxx的数据时,很有可能会紧接着访问xxxx周围的数据,比如遍历数组或指令的顺序执行;由于这两种局部性存在于大多数的程序中,硬件系统可以很好地预测哪些数据可以放入缓存,从而运行得很好。
缓存优化-缓存亲和性。缓存访问是设计多处理器调度时遇到的关键问题,是所谓的缓存亲和度(cache affinity),即一个进程在某个CPU上运行时 会在该CPU缓存中维护许多状态信息;下次进程在相同CPU上运行时,由于缓存中的数据而执行得更快。相反,当进程在不同的CPU上运行时,由于需要重新加载数据而变得更慢(好在硬件保证的缓存一致性可以保证正确执行)。因此 性能优化时应该考虑到这种缓存亲和性,尽可能将进程保持在同一个CPU上。
NUMA优化 比起访问remote memory,local memory 访问不仅延迟低(100ns),而且也减少了对公共总线(interconnect)的竞争。合理地放置数据(比如直接调用NUMA api) , 软件调优化基本上还是围绕在尽量访问本地内存这一思路上。如果本地内存已用完,那么尽量访问本CPU下相临节点的内存,避免访问跨CPU访问最远端的内存,通常可以提高20-30%性能。
CPU资源优化。CPU独占:独占CPU资源,减少调度影响,提高系统性能;CPU绑定:减少CPU上下文切换,提高系统性能;中断亲和 : 中断负载均衡,减轻其他CPU负担,提高系统性能;进程亲和:减少CPU上下文切换,提高系统性能;中断隔离:减少中断对CPU调度影响,提高系统性能。
内存优化:采用更大容量的内存,减少内存不足对性能影响,实现用空间换时间的性能优化;采用大页内存,减少TLB misses,从而提高访存效率,如启用2M大页内存,甚至1G大页内存;使用更新内存技术,比如DDR5,更好的内存硬件可以减少内存延迟,提高内存访问速度,从而提高系统性能。
时钟优化:时钟芯片,采用更高精度时钟芯片可以获得更精确的时间,可以让系统控制粒度更细;时钟频率:时钟频率调整,调高->可以达到更细的计时精度,提高任务调度的效率;调低→可以降低时钟中断的打扰和降低功耗。
锁和无锁设计优化。如何正确有效的保护共享数据是编写并行程序必须面临的一个难题,通常的手段就是同步。同步可分为阻塞型同步(Blocking Synchronization)和非阻塞型同步( Non-blocking Synchronization),多线程里面难免需要访问"共享内存",如果不加锁很容易导致结果异常,程序首先要保证正确,即使影响性能低也需要加锁来防止错误,此时该怎么提高CPU执行性能呢? 一个比较重要的优化工作是锁需要精心设计。阻塞锁通过改变了线程的运行状态。让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的线程,通过竞争,进入运行状态;mutex 主要用于线程间互斥访问资源场景;semaphore 主要用于多个线程同步场景;读写锁针主要用于读多写少场景;非阻塞锁,非阻塞锁不会改变线程状态,使用时不会产生调度,通过CPU忙等待或者基于CAS(Compare - And - Swap)原子操作指令实现非阻塞访问资源;自旋锁底层通过控制原子变量的值,让其他CPU忙等待,cache亲和性高和控制好锁粒度,可以提高多线程访问资源效率,主要用于加锁时间极短且无阻塞点场景;RCU锁(Read-Copy Update)--非常重要一种无锁设计,对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它(因此不会导致锁竞争,不会导致锁竞争,内存延迟以及流水线停滞,读效率极高),但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作,RCU实际上是一种改进的读写锁,更能提高读多写少场景的系统性能;原子操作可以保证指令以原子的方式执行(锁总线或者锁CPU缓存)——执行过程不被打断,主要用于全局统计、引用计数,无锁设计等场景;CAS操作(Compare And Set或是 Compare And Swap),现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构,主要用于各种追求极限高性能场景,比如内存数据库,内存消息队列,DPDK的内存池mempool,java 的Disruptor等;真正无锁-没有资源冲突,每个线程只使用local数据,最高级别的无锁设计,适合分而治之算法场景。
网络IO优化:零拷贝,减少驱动到协议栈之间内存拷贝,减少用户空间到内核空间内存拷贝,提升IO性能;kernelbypass:绕过内核协议栈(路径长,多核性能差),提高IO吞吐量;PMD用户态驱动,使用无中断方式直接操作网卡的接收和发送队列;充分挖掘网卡的潜能:借助网卡支持的分流(RSS、FDIR)和 卸载(TSO、CSUM)等特性。
网络数据包处理优化:批量合并,批量收包 批量发包 借助合并与批量收发包往往能提升吞吐量,从而大幅度提高性能。预处理策略就是提前做好一些准备工作,这样可以提高后续处理性能;如使用CPU预取指令对数据包进行预取,提前将所需要的数据取出来,可以提高流水线效率和缓存效率;Per-CPU是基于空间换时间的方法, 让每个CPU都有自己的私有数据段(放在L1中),并将一些变量私有化到 每个CPU的私有数据段中. 单个CPU在访问自己的私有数据段时, 不需要考虑其他CPU之间的竞争问题,也不存在同步的问题. 注意只有在该变量在各个CPU上逻辑独立时才可使用。
代码层面的优化,主要包括以下几个方面:
a.循环优化:适当展开循环,可让指令并行执行,提供搞性能;
b.条件判断:减少条件判断语句,减少分支预测失败概率,提升CPU流水线效率,从而提升性能;
c.表达式优化: 优化布尔逻辑可以减少不必要计算;使++i 而不使用 i++可以减少中间临时变量;
d.采用位运算:如果没有越界风险,使用位运算符合计算机计算模型,效率更高;
e.内存&cache对齐/读写分离:数据结构最好是cache-line对齐的整数倍,同时数据结构的成员字段按读和写分开到不同的cache-line,高频访问的成员字段放到最前面,可提高cache命中效率,减少Cache miss;
f.指针优化:尽量减少指针使用,指针跳转会导致Cache miss;
g.向量化:合适使用SIMD高级指令可以优化代码;
h.inline优化:高频调用的处理逻辑尽可能 做到inline;
i.cache预取优化: 使用CPU预取指令对数据进行预取,提前将所需要的数据取出来,可以提高流水线效率和缓存效率;
j.插入其他语言:插入汇编,优化高频函数;
k:递归优化:尽量把递归修改为循环,减少递归调用代价;
l.惰性处理策略就是尽量将操作(比如计算),推迟到必需执行的时刻,这样很可能避免多余的操作。
m.中断后半部分优化,把可延迟函数放到延后处理,从而提高中断处理整体效率;
n.缺页中断处理,不需要进程把所有内存页载入内存,只有需要的时候再加载,这样可以减少大量无效内存操作,提高整体性能
o.数据结构优化:hash结构 > 树型结构 > 线性结构;
编译优化:编译器优化:O0 -->> O1 -->> O2 -->> O3,来额外的性能提升;编译器API:使用内联函数,使用内存对齐API,使用cache对齐API等 ,可以更好让编译器优化代码,减少调用指令,提高性能;JIT编译器优化:使用Jit技术,可以把中间代码生成本地指令,提升代码执行效率。
乱序执行优化:处理器为了提高运算速度而做出违背代码原有顺序的优化。在单核单线程中,是不会对结果造成影响的。但是在多核或者多线程情况下就有可能会出现问题。在多核情况下,同时会有多个核执行指令,每个指令都有可能被乱序,另外,处理器还引入了L1、L2等缓存机制,每个核都有自己的缓存,这就导致了逻辑次序上,后写入内存的数据未必真的最后写入,最后会带来一个问题,如果我们不做任何防护措施,处理器最后得出的结果会和我们逻辑得出的结果大不相同。
4. 总结
性能优化思想需要结合实际的应用场景,合理采用方法和设计更优框架来提升整体的高性能的处理和鲁棒性,同时伴随整个软件生命周期。