当前位置:首页 > 技术学院 > 技术前线
[导读]很多开发者写了多年代码,始终把性能优化的注意力放在算法复杂度、锁优化层面,完全忽略了CPU Cache这个最核心的性能影响因素。你写的每一行内存访问代码,在现代多核CPU上的执行速度,90%的时间开销都不取决于内存本身的读写速度,而是取决于数据能不能命中CPU的高速缓存。同样的逻辑、同样的算法复杂度,仅仅因为数据的内存排布顺序不同,实际运行性能就能差出几十上百倍。彻底理清CPU Cache的底层工作逻辑,你才能写出真正贴合硬件特性的极致高性能代码。

很多开发者写了多年代码,始终把性能优化的注意力放在算法复杂度、锁优化层面,完全忽略了CPU Cache这个最核心的性能影响因素。你写的每一行内存访问代码,在现代多核CPU上的执行速度,90%的时间开销都不取决于内存本身的读写速度,而是取决于数据能不能命中CPU的高速缓存。同样的逻辑、同样的算法复杂度,仅仅因为数据的内存排布顺序不同,实际运行性能就能差出几十上百倍。彻底理清CPU Cache的底层工作逻辑,你才能写出真正贴合硬件特性的极致高性能代码。

一、CPU Cache的诞生背景:填补CPU与内存之间的速度鸿沟

现代CPU的运算性能,在过去几十年里提升了几百上千倍,但是内存的访问速度提升的幅度却非常有限,两者之间的性能差距已经拉大到了几十倍。如果CPU每一次读写数据都直接去访问主内存,CPU的绝大多数算力都会处于空闲等待状态,哪怕是最简单的运算逻辑,运行速度也会被内存拖得极慢。CPU Cache这套分层缓存架构,就是为了填补两者之间的巨大速度鸿沟而设计的。

现代CPU普遍采用多级Cache的分层设计,最靠近CPU核心的是L1 Cache,它的容量最小,通常只有几十KB,但是访问速度极快,和CPU寄存器的速度几乎没有差别。紧接着是L2 Cache,容量通常几百KB,访问速度比L1稍慢,但依然远高于主内存。再往上是所有核心共享的L3 Cache,容量可以达到几MB甚至几十MB,访问速度进一步降低,但依然比直接访问主内存快很多。最后才是系统的主内存,容量最大,但是访问延迟最高。

CPU访问内存数据的时候,会先沿着L1、L2、L3的顺序逐级查找,如果在Cache里找到了需要的数据,就直接从Cache里读取,不需要访问主内存,这就是缓存命中。如果一直查找到L3都没有找到数据,才会向主内存发起请求,把数据从主内存加载到Cache里再返回给CPU。一次L1缓存命中的访问延迟大概是几个CPU时钟周期,而一次主内存访问的延迟高达几百个时钟周期,两者之间的性能差距超过一百倍。程序运行过程中缓存命中率的高低,直接决定了整个程序的实际运行速度。

Cache的设计遵循了两个非常核心的程序局部性原理:时间局部性是指如果一个数据刚刚被访问过,那么它在短时间内大概率还会被再次访问;空间局部性是指如果一个内存地址刚刚被访问过,那么它附近相邻的内存地址,接下来也有很大概率会被访问。CPU Cache正是利用这两个特性,把最近频繁访问的数据和它附近的相邻数据缓存起来,最大化减少直接访问主内存的次数。

二、Cache Line的核心机制:90%的性能陷阱都来自这里

CPU Cache不是以单个字节为单位来缓存数据的,它的最小缓存单位是Cache Line,在绝大多数现代CPU上,一个Cache Line的大小是64字节。也就是说,当CPU从主内存加载一个字节的数据到Cache里的时候,会自动把这个字节所在的连续64字节的内存块,完整加载到同一个Cache Line里。这个设计正是利用了空间局部性原理,一次加载相邻的64字节数据,后续访问附近的内存地址几乎都能直接命中缓存。

很多开发者踩过的伪共享性能坑,本质上就是Cache Line机制带来的典型问题。当多个不同的CPU核心,同时频繁修改位于同一个Cache Line里的不同变量的时候,虽然这些变量本身是互相独立的,没有任何数据竞争,但是因为它们共享同一个Cache Line,任意一个核心修改了其中一个变量,都会导致其他核心上的整个Cache Line全部失效。其他核心要修改自己的变量的时候,就必须重新从主内存同步这个Cache Line,大量不必要的Cache一致性同步开销,会导致程序的性能断崖式下跌,甚至比加互斥锁的实现还要慢。

很多新手写的多线程计数器代码,每个线程操作自己独立的计数变量,没有任何共享竞争,但是实际运行起来性能极差,几乎全部都是踩中了伪共享的坑。解决伪共享的方法也非常直接,只要给每个独立的频繁修改变量的前后,填充足够多的冗余字节,保证每个变量单独占满一整个Cache Line,不和其他线程的变量共享同一个Cache Line,就能彻底避免伪共享带来的性能损耗。

除此之外,数组遍历的顺序也会对缓存命中率产生天差地别的影响。比如遍历一个二维数组,按照行优先的顺序依次访问,相邻的内存地址是连续的,一次Cache Line加载进来之后,后续连续64次访问都能直接命中缓存,缓存命中率几乎是100%。如果反过来按照列优先的顺序跳跃访问,每一次访问的内存地址都间隔很远,几乎每一次访问都不命中Cache,必须直接访问主内存,同样的遍历逻辑,后者的运行速度可能比前者慢几十倍。这也是为什么很多新手写的矩阵运算代码,明明算法复杂度完全正确,实际运行速度却慢得离谱,本质上就是完全忽略了空间局部性,缓存命中率极低。

三、面向Cache的代码优化:从细节榨干硬件的全部性能

理解了CPU Cache的运行机制之后,你就可以用很多非常简单的优化技巧,在不修改核心业务逻辑的前提下,让代码的运行性能提升数倍甚至数十倍。

最基础的优化就是尽可能让你的数据排布紧凑,减少不必要的内存填充和冗余字段,让更多的数据可以同时被放到有限的Cache空间里。比如之前提到的结构体对齐优化,把小字段集中排列,减少结构体的总大小,就能让同样的Cache空间里放下更多的结构体对象,大幅提升缓存命中率。同时尽可能避免使用链表这类随机内存访问的数据结构,链表的每个节点分散在内存的不同位置,遍历的时候几乎每一次访问都要跳转到完全不相关的内存地址,缓存命中率极低。优先选用数组这类连续内存存储的数据结构,尽可能保证访问的内存地址是连续的,最大化利用空间局部性。

对于循环逻辑的优化,要尽可能把循环的顺序调整成最符合空间局部性的模式,同时把多个小循环合并成一个大循环,避免重复遍历多遍数组,减少不必要的内存访问次数。对于计算密集型的逻辑,尽可能把所有需要用到的数据提前一次性加载到Cache里,后续所有运算全部基于缓存里的数据完成,运算结束之后再把结果写回主内存,全程尽量减少直接访问主内存的次数。

现在工业界所有的极致高性能程序,比如Redis、Nginx、高性能数据库引擎,全部都做了大量面向CPU Cache的针对性优化。很多时候你不需要升级更高级的CPU,仅仅调整一下数据结构的内存排布、修改一下遍历的顺序,就能让程序的性能获得数倍的提升。深入理解CPU Cache的工作原理,是每一个追求极致性能的开发者必须掌握的核心底层能力。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
关闭