文章总结: 本文解析了多线程中伪共享导致性能下降的原因,结合MESI协议阐述了缓存一致性机制。通过C++示例与VTune分析,定位了多核修改同一缓存行引发的乒乓效应。采用缓存行对齐优化后,性能提升近一倍。文章强调了并发编程中合理设计内存布局及使用性能分析工具的重要性。 综合评分: 88 文章分类: 其他
性能杀手之False Sharing
原创
孤舟蓑笠翁
MyStackTrace
2025年4月29日 22:26 北京
“伪共享”之所以叫伪共享是因为多个core不是真的共享了某个变量,而是多个core读写的变量刚好处于同一个cacheline上的不同位置,它们共享了同一个cacheline,而这样的共享其实是可以通过改变数据结构的布局来避免的。我们都知道每个core都有自己的本地缓存,比如L1 data cache。如果多个core最近都访问过同一个cacheline,即使它们访问的位置不同,那么它们的本地缓存上极有可能都有一份这个cacheline的副本,因为缓存系统是以cacheline为单位管理缓存的,即使每个core都只需要这个cacheline上的一小部分数据,它们也必须将整个cacheline保存在各自的本地缓存中。如果接下来有的core修改了这个cacheline上面的某个位置上的数据,那么它就需要通知其他core将这个cacheline在它们本地的副本标记为无效,那这些core后面再访问到这个cacheline的时候就会产生cache miss,如果多个core对这个cacheline的读写很频繁,就会导致这种invalidate和cache miss频繁发生,从而严重影响性能。
在详细分析False Sharing之前,我们需要了解一下多核CPU是如何维持缓存一致性(Cache Coherence)的。多核CPU是通过MESI协议(或者其他扩展变种协议)维持缓存一致性的,其名称来源于四个关键的cacheline状态:Modified(已修改)、Exclusive(独占)、Shared(共享)和Invalid(无效)。
Modified (M):cacheline数据已被当前core修改,与主存不一致,为唯一有效副本,当该cacheline数据被写回到内存,可以将其转移为Shared状态。
Exclusive (E):cacheline数据与主存一致,且仅当前core持有该数据,当前core可自由修改该cacheline,修改后状态变为Modified,无需通知其他core。
Shared (S):cacheline数据与主存一致,且可能被多个core共享,某个core需通过总线事务(如Invalidate)使其他副本失效后,才能修改数据。
Invalid (I):cacheline数据无效,不可使用。访问的时候需重新从主存或其他缓存获取。
MESI协议的状态转移情况如下:
Read Cache Hit:当前core可以直接从本地缓存读取数据,cacheline状态不变。
Read Cache Miss:当前core发起Bus Read事务,然后根据下面的不同情况做不同的状态转移。若其他core缓存中有这个cacheline,状态为Shared,则其他core把该cacheline数据发送给当前core,当前core将该cacheline转为Shared状态;若其他core缓存有这个cacheline,状态为Exclusive,则其他core将cacheline数据发送给当前core,其他core和当前core把各自本地缓存中的这个cacheline状态转为Shared状态;若其他core缓存有该cacheline,状态为Modified,其他core将cacheline数据发送给当前core,当前core将cacheline转为Shared状态,其他core写回主存并将它自己的cacheline也转为Shared状态;若所有的core都没有该cacheline数据,则这笔Bus Read事务被主存处理,当前core从主存获取到该cacheline的数据之后,将其转为Exclusive状态。
Write Cache Hit:如果该cacheline在当前core的状态为Modified/Exclusive,则直接写入,状态维持或变为Modified;如果该cacheline在当前core的状态为Shared,则发起Bus Invalidate事务,让其他core将该cacheline转为Invalid状态,当前core写入数据后将该cacheline状态转为Modified。
Write Cache Miss:当前core发起Bus Write事务,然后根据下面的不同情况做不同的状态转移。如果其他core的缓存中有该cacheline数据,状态为Shared/Exclusive,则其他core将该cacheline中的数据发送给当前core,然后其他所有core将自己的这个cacheline转为Invalid状态,当前core将该cacheline转为Modified状态;如果其他core的缓存中有该cacheline,状态为Modified,则其他core将该cacheline中的数据发送给当前core,然后其他core将自己的这个cacheline转为Invalid状态,当前core将该cacheline转为Modified状态。如果其他都没有该cacheline,则当前core从主存获取该cacheline的数据,然后将其状态转为Modified。
了解了多核CPU是如何维护缓存一致性之后,我们来看下false sharing的例子代码。这段代码创建了两个线程,每个线程会从counter array中拿一个counter对其进行累加,线程0对counter0进行累加,线程1对counter1进行累加,两个线程分别对其自己的counter累加MAX_LOOP次才退出,最后main函数统计整个测试所花的时间。由于struct COUNTER是按照1字节对齐的,所以struct COUNTER的大小是8字节,又因为g_CounterArray的起始地址是按照cacheline大小对齐的,因此counter0和counter1一定处于同一个cacheline中。那么这两个线程对counter0和counter1的访问就会受到false sharing的影响。
#include <iostream>#include <thread>#include <intrin.h>using namespace std;#define CACHELINE_SIZE 64typedef struct __declspec(align(1)) COUNTER{ uint64_t Counter;} COUNTER;#define NOF_THREADS 2typedef struct COUNTER_ARRAY{ COUNTER Counters[NOF_THREADS];} COUNTER_ARRAY;__declspec(align(CACHELINE_SIZE)) COUNTER_ARRAY g_CounterArray{};#define MAX_LOOP 2000000000static void ThreadFunc(COUNTER *Counter){ int loop = 0; while (loop < MAX_LOOP) { Counter->Counter += 1; loop++; }}thread *g_Threads[NOF_THREADS];int main(int argc, char *argv[]){ uint64_t start = __rdtsc(); for (int i = 0; i < NOF_THREADS; i++) { g_Threads[i] = new thread(ThreadFunc, &g_CounterArray.Counters[i]); } for (int i = 0; i < NOF_THREADS; i++) { g_Threads[i]->join(); delete g_Threads[i]; g_Threads[i] = nullptr; } uint64_t stop = __rdtsc(); cout << "test takes: " << stop - start << " CPU cycles"; return 0;}
我们知道这个程序会有潜在的性能问题,所以我们尝试用vtune对这个程序进行profiling,vtune给出的分析结果如下图。可以看到这个程序是Memory Bound(访存密集型)的,而且是Store Bound的,也就是说这个程序在写内存方面有性能瓶颈,程序的时间大部分都花在了写内存上面了。
在vtune中查看程序的热点,可以发现就是对counter自加的那条语句是热点,大部的clock ticks都花在了这条语句上。这也就是一条简单的自加语句为什么会这么耗时呢?而且它下面还有一条自加语句loop++,为什么这条语句不耗时?
为了说明这两条自加运算为啥在性能上表现出这么大的差异,我们需要去看下汇编代码。下图是ThreadFunc的汇编代码,我们可以看出,语句Counter += 1被编译成了一条在内存中自加的指令,所以这条指令是需要访存的,而语句loop++被编译成了一条在寄存器中运算的指令,是不需要访存的。编译器之所以这么做是因为Counter是外部传进来的参数,编译器无法判断这个值在其他地方是否会被访问,所以每次自加之后要将其写回内存,这样其他地方如果要用这个值就能通过内存地址访问到这个值,但是loop变量是一个局部变量,只属于这个函数,其他地方是无法访问到这个局部变量的,所以编译器可以放心的去做优化,把loop的值放到寄存器中去进行运算。通过汇编代码分析,我们基本可以得出结论语句Counter += 1是热点主要是因为它编译后的指令需要访存,语句loop++不是热点,是因为它编译出来的指令是操作寄存器,不用访存。
下面我们分析一下语句Counter += 1为什么有访存瓶颈。为了便于说明问题,我们简化了CPU的缓存系统层级结构,假设每个core都只有一级local cache,core和它的本地缓存以及主存都被直接连在一条共享的bus上面。下图中cacheline大小为64字节,所以每个小方格大小是8个字节,主存中的第一个小方格表示counter0,第二个小方格表示counter1。线程0跑在core0上,线程1跑在core1上。
假设这两个线程执行有如下的流程,其实也不是假设了,实际上是肯定能够跑出这样的时序的。当core0读counter0时,会把counter0所在的cacheline,比如叫cacheline A,加载到其local cache中,然后core1也开始读自己的counter1,也会把cacheline A加载到其local cache中,根据MESI协议,此时cacheline A在两个core的local cache中都处于Shared状态,core0和core1的local cache中都有cacheline A的内容。接下来core0对counter0加1,并把结果写入其local cache中,这个时候core0就需要把它自己local cache中的cacheline A标记为Modified状态,并且还要发送Invalidate请求给core1,让core1把自己local cache中的cacheline A的副本给标记为Invalid状态,因为这个时候core0已经修改了cacheline A的内容,其他core拿的副本就无效了。紧接着 core1也对自己的counter1加1,当其要把结果写回其local cache的时候,发现它自己的cacheline A的副本已经是Invalid状态,所以触发了一次write cache miss,然后core1就需要通过总线发起内存请求,当core0监听到这个内存请求之后,会把它这时拿着cacheline A的有效副本发给core1,并且core0需要把自己的cacheline A副本给标记为Invalid状态,因为cacheline A的所有权已经转移给了core1,core1把自己刚刚获取到的cacheline A转为Modified状态,然后core1把刚才计算出来的counter1的值写回到它的cacheline A的副本上。然后core0又开始对其counter0进行操作了,这又会像上面描述的那样引起core1上cacheline A的副本的失效。上面这样的流程会被core0和core1 频繁地重复,cacheline A就像乒乓球一样在core0和core1之间来回跑,bus上充斥着core0和core1维护cache coherence的事务,这对性能影响非常大。
那么如何消除false sharing呢?如果我们把上面的程序中的struct COUNTER改成按照cacheline大小(64字节)对齐的话,那么struct COUNTER的大小会变成64个字节,除了前面8个字节表示counter之外,其他的56个字节都是padding的字节,这样counter0和counter1就会被分配在两个cacheline上面(如下图所示),core0和core1访问各自的counter时不再共享同一个cacheline了,上面所说的同一个cacheline在两个core之间来回“乒乓”的问题就没有了,这个false sharing引起的性能问题应该就没有了。
改完程序我们重新用vtune对其进行profiling,分析结果如下图。从图上可以看到原本的Memory Bound消失了,程序变成了Core Bound,也就说程序变成了计算密集型的程序了,这是因为原本的访存热点被消除了,把计算热点给凸显出来了。
我们分别将这两版程序运行5次,结果如下图。可以看到将struct COUNTER按照64字节对齐之后,测试的执行时间缩短了大概57%,性能提升了大概1倍,优化效果还是非常明显的,原因就是我们消除了falsesharing。
总结一下:false sharing对程序的性能影响还是很大的,我们在多线程编程的过程中需要尽量避免这种情况的发生,需要注意多个线程共享数据结构的设计是否合理,尤其是多个线程对共享数据结构访问很频繁的情况需要格外注意,必要的时候可以使用vtune或者perf这样的性能分析工具进行profiling,以方便我们定位程序的热点位置,帮助我们尽快找到有性能问题的代码。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:MyStackTrace 孤舟蓑笠翁《性能杀手之False Sharing》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论