1

问题起源

问题出自再看bilibili视频时候,(地址https://www.bilibili.com/video/BV12p4y1W7Dz),发现一道关于CPU问题从未考虑过,所以就产生了兴趣,具体问题是这样的:

下面两段代码fnc1和fnc2(这里我给简化了,效果都一样),哪段的执行速度更快:

const N = 2000
var arr2D [N][N]int

func fnc1()  {
  for i := 0; i < N; i++ {
    for j := 0; j < N; j++ {
      arr2D[i][j] = 0
    }
  }
}

func fnc2()  {
  for i := 0; i < N; i++ {
    for j := 0; j < N; j++ {
      arr2D[j][i] = 0
    }
  }
}

最终答案是fnc1要比fnc2快,这里我把他俩合并用一段代码调试下:

const N = 2000

func main() {
  //为了防止公用一个二维数组造成影响,这里用两个二维数组,每个方法分别用一个
  var arr2D1 [N][N]int
  var arr2D2 [N][N]int
  //模拟fnc1
  start1 := time.Now().UnixNano()
  for i := 0; i < N; i++ {
    for j := 0; j < N; j++ {
      arr2D1[i][j] = 0
    }
  }
  ct1 := time.Now().UnixNano() - start1
  //模拟fnc2
  start2 := time.Now().UnixNano()
  for i := 0; i < N; i++ {
    for j := 0; j < N; j++ {
      arr2D2[j][i] = 0
    }
  }
  ct2 := time.Now().UnixNano() - start2
  //输出时间
  fmt.Println("fnc1 time :",ct1)
  fmt.Println("fnc2 time :",ct2)
}

输出结果绝大多数fnc1的时间要小于fnc2的时间,(偶尔偶尔会出现fnc1大于fnc2,猜测原因是当时CPU忙于其他的进程)

$ go run main.go
fnc1 time : 3499675
fnc2 time : 10823762
$ go run main.go
fnc1 time : 3475027
fnc2 time : 307537210
......

当时疑惑这种现象是怎么造成的,毕竟两个方法的时间复杂度都是相同的。这就引出了今天的主题,CPU缓存

CPU架构介绍

我们在一般买电脑时候都是会先了解下内存(RAM)多大等信息,我们还知道Redis为什么快原因之一是基于内存的数据库。仿佛CPU就是在直接操作内存,内存就是最快的东西。但是对于比内存,还有比他快得多的存储介质,CPU高速缓存

首先让我们看下图计算机存储器的层次结构,图片取自《深入理解计算机系统》。

所有的现代计算机都使用了这种存储结构,越靠近上面,造价越高,容量越小。现代计算机中,最快的就是寄存器存取了,寄存器距离CPU核心最近,但是可存储数据最少,往往用来放一些函数参数;接下来分别是L1-L3三级缓存,也是今天的重点,他们作为CPU的缓存充当了CPU与主存之间的垫片;然后就是最熟悉的主存,就是我们常说的内存;再次就是磁盘和远程文件存储系统了。

接下来把视角拉近,主要看下CPU的存储架构,下面是一个通用的双核CPU架构图:

简要介绍下各个CPU部件:

控制单元(CU):是CPU的指挥中心,他根据用户编译好的程序,从存储器中取出各种指令,用来指挥CPU工作

计算单元(ALU):用于执行算术运算与逻辑运算,全部操作听从由控制单元指挥

寄存器:紧挨着控制单元与计算单元,速度是最快的,但是容量也是最小,32位CPU大多数寄存器可以存储4个字节,64位大多数为8字节。

L1-Cache:每个CPU都有一个L1-Cache,实际上L1-Cache又分为两个,一个指令缓存(i-cache),一个数据缓存(d-cache),这是由于两个缓存的更新策略不同,所以分开,通常L1-Cache大小在几十Kb到几百Kb之间,Linux下可以通过下面命令获取,一次访问需要需要 2~4 个时钟周期。

# 数据缓存 d-cache
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
# 指令缓存 i-cache
$ cat /sys/devices/system/cpu/cpu0/cache/index1/size

L2-Cache:每个CPU也有一个L2-Cache,大小要大于L1-Cache,由于位置比L1-Cache距离CPU核心更远,所以速度稍慢于L1-Cache,需要10~20 个时钟周期。

$ cat /sys/devices/system/cpu/cpu0/cache/index2/size

L3-Cache:L3-Cache是被所有CPU共享的,大小也更大,访问速度也稍慢与L2-cache,需要20~60 个时钟周期。

$ cat /sys/devices/system/cpu/cpu0/cache/index3/size

CPU取数据过程

当CPU需要读取某些数据时候,如果寄存器中有,就直接用寄存器数据;如果没有,就去L1-cache中读取,L1-cache中没有,就去L2-cache,L2没有查询L3,L3没有去主存获取,获取到后依次填充三级缓存。

CPU缓存上的数据就是主存上的数据,读取数据的规则并不是按照单个元素来读取,它是一块一块读取数据的,比如当我取出一个很小的数据时,他会把所在的那块内存块加载到缓存上。这样,当我们访问这个数据相邻的数据时候,也就可以直接通过CPU Cache来获取了,这样大小的内存块被称为Cache Line。在Linux上可以通过下面命令看到:

# cpu0的L1-d-cache的cache Line,我的机器是64字节
$ cat /sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size
64

案例结合

我们回到上面的案例,我们首先定义了二维数组,在内存中的存储的连续方式是这样的:

var arr [2][2]int

在内存中是连续分布的,顺序是 0_0, 0_1, 1_0, 1_1,每个元素占用8个字节

我们可以通过取地址得出这个结论

func main() {
  var arr [2][2]int
  fmt.Println(uintptr(unsafe.Pointer(&arr[0][0])))
  fmt.Println(uintptr(unsafe.Pointer(&arr[0][1])))
  fmt.Println(uintptr(unsafe.Pointer(&arr[1][0])))
  fmt.Println(uintptr(unsafe.Pointer(&arr[1][1])))
}
-----------
//可以看到内存地址依次加8
824634298096
824634298104
824634298112
824634298120

所以,对应到我们的案例,当我们使用i_j方式赋值时候,取出第一个元素0_0的时候,就会从内存中取出一块64字节的Cache Line,也就是取出了0_0到0_7放到了CPU缓存中,当下次迭代到0_1时候,就会直接使用CPU缓存中的数据,这充分利用了CPU缓存。

const N = 2000
var arr2D [N][N]int

func fnc1() {
  for i := 0; i < N; i++ {
    for j := 0; j < N; j++ {
      arr2D[i][j] = 0
    }
  }
}

但是当使用j_i方式赋值时候,第二次迭代要取用1_0的数据,这时候CPU要重新去主存加载数据到缓存,造成了CPU缓存穿透,自然也就影响了性能。所以造成了fnc2的时间长于fnc1的时间。

延伸阅读

  • 《如何写出让CPU执行更快的代码》链接
  • 《开发内功修炼》链接
  • 《深入理解计算机系统》第六章:存储器层次结构

郭朝
24 声望7 粉丝