七叶笔记 » golang编程 » 利用 CPU cache 特性优化 Go 程序

利用 CPU cache 特性优化 Go 程序

如下 Go 语言伪代码,开启两个协程,分别对一个结构体变量中的两个相邻的数据成员进行 n 次原子自增操作,当打开_ [56]byte这个看似多余的代码后,程序运行速度加快了一倍!你知道是为什么吗?

 ...type Foo struct {    a uint64    // _ [56]byte    b uint64    // _ [56]byte}... Go  func() {    for i := 0; i < 1000 * 1000; i++ {        atomic.AddUint64(&foo.a, 1)    }}()go func() {    for i := 0; i < 1000 * 1000; i++ {        atomic.AddUint64(&foo.b, 1)    }}()// 等待两个协程执行完毕,记录总执行时间...  

完整可运行代码见:

CPU cache

大家都知道,内存的速度远快于磁盘的速度,但事实上,跟 CPU 的处理速度相比,内存还是太慢了。CPU 不愿意老是等内存,于是就有了 CPU cache 。CPU cache 的速度比内存快数十倍。

很多资料上都有关于不同存储硬件速度和容量的对比,但是有的数据随着硬件的发展已经过期了,想对此有个大致了解的可以看看 《Latency Numbers Every Programmer Should Know》[1] ,这个网页的数据是按年更新,且历史数据都能看到。

CPU cache 位于 CPU 和内存之间,CPU 读取数据时,并不是直接从内存读取,而是先从 CPU cache 中读,读不到再从内存读。

显然,CPU cache 命中率越高,程序执行速度就越快。但是受限于制造工艺以及成本,CPU cache 的容量远小于内存。所以必须要有一种缓存策略,用于决定哪些数据缓存在 CPU cache 中。

CPU cache 的缓存策略是基于局部性原理设计的。局部性分两点:时间局部性,即最近刚被访问的数据大概率会被再次访问;空间局部性,即最近刚被访问的数据,相邻的数据大概率会被访问。

CPU cache line

根据以上原理,CPU cache 在缓存数据时,并不是以单个字节为单位缓存的,而是以 CPU cache line 大小为单位缓存,CPU cache line 在一般的 x86 环境下为 64 字节。也就是说,即使从内存读取 1 个字节的数据,也会将邻近的 64 个字节一并缓存至 CPU cache 中。

linux 下,可以通过getconf -a | grep cache 命令获取 cache line 大小。

这也是访问数组通常比链表快的原因之一。

false sharing

但是在多核多线程环境下,cache line 的优化方式会带来一个问题。

这里需要先对 CPU cache 的知识做些扩充。事实上,CPU cache 也是分多级的,常见的有三级,分别是 L1,L2,L3,这三级缓存也遵循存储器的金字塔原理,即从 L1 到 L3,速度递减,容量递增。一般来说,L1 和 L2 是每个 CPU 核都有的,L3 则是所有 CPU 核共有。

 # macos 可以通过如下命令得到各级缓存大小:$sysctl -a | grep cachesize# 输出如下:hw.l1icachesize: 32768  #表示L1指令cache为32KBhw.l1dcachesize: 32768  #表示L1数据cache为32KBhw.l2cachesize: 262144  #表示L2 cache为256KBhw.l3cachesize: 3145728 #表示L3 cache为3MB  

由于一个 CPU 核在读取一个变量时,以 cache line 的方式将后续的变量也读取进来,缓存在自己这个核的 cache 中,而后续的变量也可能被其他 CPU 核并行缓存。当前面的 CPU 对前面的变量进行写入时,该变量同样是以 cache line 为单位写回内存。此时,在其他核上,尽管缓存的是该变量之后的变量,但是由于没法区分自身变量是否被修改,所以它只能认为自己的缓存失效,重新从内存中读取。这种现象叫做 false sharing

cache line padding

在高性能系统编程场景下,一般解决 false sharing 的方法是,在变量间添加无意义的填充数据( cache line padding )。使得我们真正需要高频并发读写的不同变量,不出现在一个 cache line 中。

Go 标准库

我们来看看 Go 标准库中使用 cache line padding 的两个例子。

第一个例子来自 Go 标准库中的 Timer 定时器。和 cache line padding 相关的代码如下:

 // src/ runtime /time.goconst timersLen = 64var timers [timersLen] struct  {  timersBucket  pad [cpu.CacheLinePadSize - unsafe.Sizeof(timersBucket{})%cpu.CacheLinePadSize]byte}  

timers这个数组变量是 全局变量 ,数组大小固定为 64。关于 Timer 的具体实现原理不在本文描述范围内,感兴趣的可以看看我之前写的文章 《golang 源码阅读之定时器以及避坑指南》[2] 。这里你只需要知道,Go 中创建的 Timer 会被哈希到一个timersBucket上,数组中的timersBucket对象会被并行访问。

数组元素类型是一个匿名结构体,结构体包含了两个数据成员:真正与业务逻辑功能相关的timersBucket,这里是将timersBucket类型作为一个匿名数据成员嵌入到匿名结构体中,另一个数据成员是pad,做 cache line padding 优化用。

如果去掉 cache line padding 的优化,上面的匿名结构体数组等价于var timers [timersLen]timersBucket。

匿名结构体对timersBucket的封装,相当于在原本一个接一个的timersBucket数组元素之间,插入了pad。从而保证不同的 timersBucket 对象不会出现在同一个 cache line 中。

再来分析 pad 数据成员。

其中的cpu.CacheLinePadSize变量定义在src/internal/cpu下,它通过 构建标签的方式[3] 在不同环境下定义了不同的值,比如在cpu_x86.go文件下被定义为64。unsafe.Sizeof(timersBucket{})用于计算一个timersBucket变量的大小。取余CacheLinePadSize是为了处理结构体大小超过 64 的情况,只取尾部不够 64 的部分。最后再用 64 减去刚才的值,得到需要填充多大空间才能填满这个 cache line,填充时使用[]byte类型。

再看一个 Go 标准库中的例子,来自 内存管理 模块,代码如下:

 // src/runtime/mheap.gotype mheap struct {  // ...   central  [numSpanClasses]struct {    mcentral mcentral    pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte  }  // ...}  

同样的,本文也不介绍内存管理的具体实现,我们只需要知道mheap类型只会定义一个全局变量,central数组大小固定,其中的mcentral会被并行访问。可以看到,几乎和上面定时器的例子如出一辙,原来的配方,熟悉的味道。

最后,说回本文开头的 demo,由于我已经知道我的 macos 的 cache line 是 64,而一个uint64占 8 个字节,所以直接使用[56]byte作为 padding。

总结

cache line padding 适用于多个相邻的变量频繁被并发读写的场景。事实上,刚才所举的两个 Go 标准库中的例子,数组中的元素timersBucket和mcentral的第一个数据成员都是mutex类型的变量,而mutex作为互斥锁,其内部实现就需要频繁读写自身状态。

但 cache line padding 也不是万能的,首先,内容无实际意义的 padding 增加了内存使用量开销。

更重要的是,在某些场景下增加 padding,意味着你放弃了 CPU cache 提供给你的空间局部性相关的预读取的奖励。

本文为了简洁(其实是个人能力有限),省略了很多计算机系统方面的细节,比如内存对齐,数据写入缓存后何时写入内存,多个 CPU 核如何保证缓存一致性,MESI 协议,CPU 如何知道原本想访问的内存地址存放在 cache 的什么位置等。希望以后有机会再写些相关的文章。

参考资料:

  • 深入浅出计算机组成原理[4]
  • cacheline 对 Go 程序的影响[5]
  • 理解 CPU Cache[6]
  • What is the size of the cache memory on Mac?[7]

参考资料

[1]

《Latency Numbers Every Programmer Should Know》: ~rcs/research/interactive_latency.html

[2]

《golang源码阅读之定时器以及避坑指南》:

[3]

构建标签的方式:

[4]

深入浅出计算机组成原理:

[5]

cacheline 对 Go 程序的影响:

[6]

理解 CPU Cache:

[7]

What is the size of the cache memory on Mac?:

相关文章