cache
在我们的程序访问内存的时候,如果每一次都去从主存里面获取数据的话,程序就会经常需要去等待访存,这会导致程序的运行比较缓慢:
为了加快程序的执行,出于一种简单的考虑:如果访问了A地址,那么下次极有可能去访问A或者A附近的地址,也就是所谓的局部性原理,我们设计了缓存(cache
)。缓存简单来说就是主存的一个子集,映射到主存上,每次基于规则去判断是否命中,如果命中的话就直接从缓存中取数据来加速程序的执行,一般我们将一个cache line作为一个最小区域:
走缓存能够极大的加速内存的访问,我们用几张图来展示单CPU
缓存(不考虑多级缓存)命中与不命中的流程:
命中缓存
缓存未命中
多线程与一致性协议
随着单核性能到达瓶颈,业界开始往多核性能方向发展。当我们编写单进程/多进程程序的时候,由于进程的隔离性,所以不太需要去考虑一致性问题;但是当涉及到多线程程序的时候,由于多个CPU
可能会去访问同一个数据,就需要我们保证一致性。如果没有缓存的存在,每次我们都直接从内存中进行读取,那么每一次变化都是可以直接被另一个CPU
感知到的:
但是当有了cache
以后,由于加了一个中间层,如果一个线程在CPU0
修改了变量A,而另一个线程在CPU1
想基于A的值做进一步更新,如果还是使用原来的值,就会出现错误:
为了解决这样的问题,我们在cache
这里加上了一致性协议,比如MSI
、MESI
等,简单来说就是可以让不同CPU上的cache能够感知到别的CPU针对同一个cache line的修改:
false sharing
由于我们将cache line
作为一个最小区域,所以只要这个区域中有一个变量出现了变化,一致性协议就会要求其他的CPU
进行同步;而如果这个区域中有多个变量在不同CPU
持续更新,就会导致频繁的cache miss
,从而降低性能:看起来像是使用了cache,但是实际上cache经常失效,我们一般称之为伪共享(false sharing)
:
如果我们将同步区域变小是不是可以解决这个问题呢?并不能解决,因为只要存在失效就存在着同步,同步4字节也是一次同步。
测试程序
在实际的编程中,我们也会写出存在伪共享的代码,下面将通过一段代码的优化来介绍一些针对伪共享问题的处理。
测试代码:
构建与运行:
USE method
作为分析的第一步,笔者习惯先简单的了解系统上发生了什么(例如USE method
),例如我们可以尝试从资源的角度去看。笔者习惯先用top
、free
来看一下系统大致的一些情况。
USE method可以参见USE方法:系统性能分析第一步
执行top
:
可以看到,我们开了四个现场,占了四个CPU,整体的执行时间是:
至此,我们可以简单的认为这里运行的时间都是在CPU
上,或者说是一个计算密集型任务。
从top
里看起来内存占用并不多,我们可以尝试用perf
采集一些基础内存指标看看:
可以看到,程序的ipc
偏低,一个时钟周期只能执行0.31
条指令,存在明显的性能问题;同时我们还可以看到存在比较多的cache miss
现象。我们需要关注访存上可能存在的缓存失效现象。
这里也可以使用TopDown方法来查看性能瓶颈。TopDown参考Top-Down性能分析方法(原理篇):揭秘代码运行瓶颈
perf record
针对这种CPU
比较密集的程序,我们可以用perf record
和perf report
进行Profiling
性能剖析来查看CPU
上具体热点,了解进程在CPU
上在干嘛:
收集以后用perf report
先简单看一下
发现主要的热点都在四个线程上。我们需要聚焦到四个线程的代码上。
perf c2c
从前面我们可以知道问题大概率是在四个子线程上,并且存在比较多的cache miss
。因此我们可以尝试用perf c2c
来查看是否存在伪共享情况:
开始收集:
查看结果:
这里我们重点看HITM
(LLC Misses to Remote cache
),表示的是由cache
的修改导致远端的cache
失效的占比,由Load Remote HITM/Load LLC Misses
得到,值越大说明伪共享的问题越严重。判断可能存在伪共享问题。
再往下看,我们可以看到缓存行里面具体的数据:
我们去查看针对的代码地址:
这里汇编的逻辑就是先从数组中把数据取到寄存器,然后操作以后再写回到对应的内存地址中去(data[index].value++
)。这样我们就定位到了伪共享发生的位置,就是我们申明的这个结构体数组:
原理解析与修复
知道了是哪里形成了伪共享以后,我们用一张图来直观的展示当前伪共享的过程:
如果想减少伪共享,最简单的就是将这几个数据放到不同的cache line
中:
为了放到不同的cache line
中,我们需要先查看系统上缓存行的大小:
接着我们可以进行如下的尝试:
简单来说,就是让数据按照64字节进行对齐,这样每一个进程都可以独享一个缓存行,从而减少伪共享导致的cache miss
。在完成修改后,我们重新运行尝试:
查看cache miss
情况:
可以看到整体的ipc
升到了2.32,时间变成了原来的1/6,说明我们的优化还是有一定效果的。
总结
本文主要简单介绍了伪共享产生的背景和原因,以及通过一个简单的例子展示了如何使用perf
等工具解决伪共享问题。后续我们将继续深入的介绍perf c2c
等工具的实现原理和使用细节。
本文所使用的测试代码均已上传到https://github.com/AshinZ/perf-workshop。
参考资料
perf c2c(https://blog.csdn.net/aolitianya/article/details/138312017)