伪共享问题初探:根源与检测

文摘   2024-09-05 12:01   陕西  

cache

在我们的程序访问内存的时候,如果每一次都去从主存里面获取数据的话,程序就会经常需要去等待访存,这会导致程序的运行比较缓慢:

内存访问

为了加快程序的执行,出于一种简单的考虑:如果访问了A地址,那么下次极有可能去访问A或者A附近的地址,也就是所谓的局部性原理,我们设计了缓存(cache)。缓存简单来说就是主存的一个子集,映射到主存上,每次基于规则去判断是否命中,如果命中的话就直接从缓存中取数据来加速程序的执行,一般我们将一个cache line作为一个最小区域

cache示意图

走缓存能够极大的加速内存的访问,我们用几张图来展示单CPU缓存(不考虑多级缓存)命中与不命中的流程:

命中缓存

hit

缓存未命中

cache miss
直接访问

多线程与一致性协议

随着单核性能到达瓶颈,业界开始往多核性能方向发展。当我们编写单进程/多进程程序的时候,由于进程的隔离性,所以不太需要去考虑一致性问题;但是当涉及到多线程程序的时候,由于多个CPU可能会去访问同一个数据,就需要我们保证一致性。如果没有缓存的存在,每次我们都直接从内存中进行读取,那么每一次变化都是可以直接被另一个CPU感知到的:

没有cache直接修改内存
没有cache直接访问内存

但是当有了cache以后,由于加了一个中间层,如果一个线程在CPU0修改了变量A,而另一个线程在CPU1想基于A的值做进一步更新,如果还是使用原来的值,就会出现错误:

cache不一致

为了解决这样的问题,我们在cache这里加上了一致性协议,比如MSIMESI等,简单来说就是可以让不同CPU上的cache能够感知到别的CPU针对同一个cache line的修改

一致性协议

false sharing

由于我们将cache line作为一个最小区域,所以只要这个区域中有一个变量出现了变化,一致性协议就会要求其他的CPU进行同步;而如果这个区域中有多个变量在不同CPU持续更新,就会导致频繁的cache miss,从而降低性能:看起来像是使用了cache,但是实际上cache经常失效,我们一般称之为伪共享(false sharing)

false sharing

如果我们将同步区域变小是不是可以解决这个问题呢?并不能解决,因为只要存在失效就存在着同步,同步4字节也是一次同步。

测试程序

在实际的编程中,我们也会写出存在伪共享的代码,下面将通过一段代码的优化来介绍一些针对伪共享问题的处理。

测试代码:

测试代码

构建与运行:

构建

USE method

作为分析的第一步,笔者习惯先简单的了解系统上发生了什么(例如USE method),例如我们可以尝试从资源的角度去看。笔者习惯先用topfree来看一下系统大致的一些情况。

USE method可以参见USE方法:系统性能分析第一步

执行top

top

可以看到,我们开了四个现场,占了四个CPU,整体的执行时间是:

初始耗时

至此,我们可以简单的认为这里运行的时间都是在CPU上,或者说是一个计算密集型任务。

top里看起来内存占用并不多,我们可以尝试用perf采集一些基础内存指标看看:

perf stat查看访问相关
访存相关指标

可以看到,程序的ipc偏低,一个时钟周期只能执行0.31条指令,存在明显的性能问题;同时我们还可以看到存在比较多的cache miss现象。我们需要关注访存上可能存在的缓存失效现象。

这里也可以使用TopDown方法来查看性能瓶颈。TopDown参考Top-Down性能分析方法(原理篇):揭秘代码运行瓶颈

perf record

针对这种CPU比较密集的程序,我们可以用perf recordperf report进行Profiling性能剖析来查看CPU上具体热点,了解进程在CPU上在干嘛:

perf record

收集以后用perf report先简单看一下

perf report

发现主要的热点都在四个线程上。我们需要聚焦到四个线程的代码上。

perf c2c

从前面我们可以知道问题大概率是在四个子线程上,并且存在比较多的cache miss。因此我们可以尝试用perf c2c来查看是否存在伪共享情况:

perf c2c

开始收集:

perf c2c record

查看结果:

perf c2c report

这里我们重点看HITM(LLC Misses to Remote cache),表示的是由cache的修改导致远端的cache失效的占比,由Load Remote HITM/Load LLC Misses得到,值越大说明伪共享的问题越严重。判断可能存在伪共享问题

再往下看,我们可以看到缓存行里面具体的数据:

cache line具体情况

我们去查看针对的代码地址:

相关源代码

这里汇编的逻辑就是先从数组中把数据取到寄存器,然后操作以后再写回到对应的内存地址中去(data[index].value++)。这样我们就定位到了伪共享发生的位置,就是我们申明的这个结构体数组:

伪共享地址

原理解析与修复

知道了是哪里形成了伪共享以后,我们用一张图来直观的展示当前伪共享的过程:

false sharing图示

如果想减少伪共享,最简单的就是将这几个数据放到不同的cache line中:

分开cache line

为了放到不同的cache line中,我们需要先查看系统上缓存行的大小:

查看cache line大小
cache line = 64bytes

接着我们可以进行如下的尝试:

按照64字节对齐

简单来说,就是让数据按照64字节进行对齐,这样每一个进程都可以独享一个缓存行,从而减少伪共享导致的cache miss。在完成修改后,我们重新运行尝试:

修改后重新运行

查看cache miss情况:

ipc明显提升

可以看到整体的ipc升到了2.32,时间变成了原来的1/6,说明我们的优化还是有一定效果的。

总结

本文主要简单介绍了伪共享产生的背景和原因,以及通过一个简单的例子展示了如何使用perf等工具解决伪共享问题。后续我们将继续深入的介绍perf c2c等工具的实现原理和使用细节。

本文所使用的测试代码均已上传到https://github.com/AshinZ/perf-workshop。

参考资料

  • perf c2c(https://blog.csdn.net/aolitianya/article/details/138312017)

Linux内核之旅
Linux内核之旅
 最新文章