程序员必须了解的CPU知识 - 科普篇

本文字数共3545字,预计阅读需20分钟

欢迎关注我的微信公众号以获得更好的阅读体验:猿闻见

1. 导读

对于一名程序员来说,无论你使用的是什么语言,代码最终都会交给CPU来执行。所以了解CPU相关的知识一方面属于程序员的内功,另一方面也可以帮助你在日常编写代码时写出更加高效的代码

本文不打算对CPU进行深入探究,相反是以简单的语言来帮助大家了解CPU的工作原理以及不得不提到的CPU缓存相关知识,其中晦涩的内容我会通过配图来帮助大家理解,最后会以几个例子来帮助大家更直观的感受到CPU缓存带来的性能影响

2. CPU基础知识

CPU即Central Processing Unit(*处理器),是我们的代码打交道最多的硬件之一,要想让一个CPU工作,就必须给它提供指令数据,而这里的指令和数据一般就放在我们的内存当中。其中指令就是由我们平常编写的代码翻译而来,数据也是我们代码中需要用到的数据(例如一个int值、一串字符串等等)

以C语言为例,从我们开始编写到运行的生命周期可以粗略的用下图表示:

程序员必须了解的CPU知识 - 科普篇

大致分为以下几个步骤

  1. 我们日常中使用编辑器或者IDE敲入代码
  2. 代码编写完成后使用编译和链接工具生成可以被执行的程序,也就是机器语言(指令的集合)
  3. 当程序被运行时,整个程序(包括指令和数据)会被完整的载入到内存当中
  4. CPU不停的向内存读取该程序的指令执行直到程序结束

通过上述第4步我们知道,CPU自身是没有保存我们的程序的,需要不停的向内存读取

那么有个问题是CPU是如何向内存读取的呢?

这里其实存在一个“总线”的概念,即CPU会通过地址总线、控制总线、数据总线来与我们的内存进行交互。其中地址总线的作用是寻址,即CPU告诉内存需要哪一个内存地址上的数据;控制总线的作用是对外部组件的控制,例如CPU希望从内存读取数据则会在控制总线上发一个“读信号”,如果希望往内存中写一个数据则会发一个“写信号”;而数据总线的作用顾名思义就是用来传输数据本身的了

例如CPU需要希望从内存中读一条数据,那么整个过程为:
程序员必须了解的CPU知识 - 科普篇

到这里我们已经知道了CPU在执行我们程序的过程中会不断的与内存交互,读取需要的指令和数据或者写入相关的数据。这个过程是非常非常快的,一般CPU与内存交互一次需要200个时钟周期左右,而现代的处理器单个时钟周期一般都短于1纳秒(1秒 = 十亿纳秒)

但我们的前辈们仍然对这个速度不满足,所以又对CPU设计了一套缓存系统来加速对内存中数据的读取

3. CPU缓存

现代CPU通常设计三级缓存(L1、L2、L3),其中L1、L2缓存是每个CPU核心独享的,L3缓存是所有CPU核心共享的,而L1缓存又分为数据缓存和指令缓存

程序员必须了解的CPU知识 - 科普篇

我们的数据就从内存先到L3缓存中,再到L2缓存中,再到L1缓存中,最后再到CPU寄存器中

按照大小来看,通常L1 < L2 < L3 < 内存 < 磁盘,如果你手边有一台Linux机器的话,可以通过下面的命令查看CPU各级缓存的大小

程序员必须了解的CPU知识 - 科普篇

以我手上这台服务器为例,L1指令缓存大小为32K、数据缓存大小为32K,L2缓存大小为1MB,L3缓存大小为35.75MB

程序员必须了解的CPU知识 - 科普篇

按照速度来看,通常L1 > L2 > L3 > 内存 > 磁盘,以时钟周期为计量单位

  • L1缓存:约 4 个CPU时钟周期
  • L2缓存:约 10 个CPU时钟周期
  • L3缓存:约 40 个CPU时钟周期
  • 内存:约 200 个CPU时钟周期

也就意味着如果能命中缓存,我们程序的执行速度至少提升5倍左右,如果能命中L1缓存则提升50倍左右,这已经属于相当大的性能提升了

有了缓存系统后,CPU就不必要每条指令或数据都读一次了,可以一次性读取若干条指令或数据然后放到缓存里供以后查询,因为根据局部性原理,CPU访问内存时,无论是读取指令还是数据,所访问的内存单元都趋于聚集在一个较小的连续区域中,所以一次性读取一块连续的内存有利于后续的缓存命中

现实中,CPU通常情况下每次的读取内存时都会一次性读取内存中连续的64个字节,这个连续的64字节术语就叫做Cache Line(缓存行),所以每一级CPU缓存就像下面这样

程序员必须了解的CPU知识 - 科普篇

如果你手边有一台Linux机器的话,可以通过下面的命令查看你的机器使用的CPU的Cache Line大小是多少

程序员必须了解的CPU知识 - 科普篇

对于我的服务器来说,L1缓存就有 32KB / 64B = 512 个Cache Line

到这里,我们已经知道了CPU缓存的工作原理和加载方式,这里实际上还遗留了两个话题没有讲,一个是如何组织每一级的 Cache Line(例如 L1 的 512 个Cache Line)来提升访问的命中率;另一个更加复杂一点,在现代CPU都是多核的场景下如何保证数据的一致性,因为每个核都有自己的L1和L2缓存,那么如果核心1修改的时候只修改了缓存的数据而没有修改内存中的数据,其他核心读到的就是旧数据了,如何解决这一问题?

由于本篇文章只是期望对CPU知识进行一个科普,不希望对于小白来说一次性接触大量的新内容,所以这两个问题我准备在后面的另外两篇再进行更细致的讨论

4. 性能对比

下面以几个实际的例子来加深大家对Cache Line如何影响程序性能的理解

4.1 示例一

我们假设有一个5000万长度的int数组,接着把这个数组的其中一些元素乘以2,考虑下面这两份代码

程序员必须了解的CPU知识 - 科普篇

直觉上代码一比代码二少循环了4倍,并且也少乘2了4倍,理论上代码一比代码二快4倍左右才合理

但在我的服务器上运行的结果是代码一平均花费90毫秒,代码二平均花费93毫秒,性能几乎是差不多的,读者可以自行思考一下原因,再点击下方空白处查看解析

这里最主要的原因还是Cache Line,虽然代码一需要执行的指令确实比代码二要少4倍,但由于CPU一次会把连续的64个字节都读入缓存,而读写缓存的速度又特别快(还记得吗?L1的读取速度只有约4个时钟周期,是内存的50倍),以至于我们很难察觉到这4倍指令的差距

4.2 示例二

假设我们需要遍历一个二维数组,考虑下面这两种遍历方法:

程序员必须了解的CPU知识 - 科普篇

由于数组长度是一模一样的,直觉上我们期望的是两份代码运行时间相差无几。但在我的服务器上代码一运行需要23毫秒,代码二运行需要51毫秒,读者可以自行思考一下原因,再查看解析

这里最主要的原因依然是Cache Line,由于C语言中二维数组的内存是连续的,所以我们按行访问的时候访问的一直都是连续的内存,而Cache Line也是连续的64个字节,所以按行访问对Cache Line更友好,更容易命中缓存
而按列访问的话每次访问的内存不是连续的,每次的跨度都是256*sizeof(int)也就是1KB,更容易出现缓存Miss

4.3 示例三

假设我们有一个数组,我们希望计算所有大于100的元素的和,考虑下面两份代码

程序员必须了解的CPU知识 - 科普篇

其中代码一是随机生成了个长度为1000W的数组,然后统计大于100的所有数字的和;代码二也是随机生成了个长度为1000W的数组,但是是先排完序,再统计大于100的所有数字的和。并且可以看到,两份代码都是只计算了统计sum的那段代码的消耗时间,所以两份代码都不考虑随机生成数组和排序花费的时间

理论上来讲两份代码花费时间应当是相差无几的,但实际上在我的机器上跑出来第一份代码输出的是46毫秒,第二份代码输出的是23毫秒

读者可以自行思考一下原因,再查看解析,提示:第二份代码中在统计sum之前数组是有序的

这里依然和CPU缓存有关,但这次的原因不再是数据缓存了,而是指令缓存
对于代码二而言,最关键的一点是数组是有序的,而现代CPU一般都有一种叫分支预测的能力,即CPU会根据分支预测算法计算出对于当前的 if 条件语句,下一步最有可能走到 if 里的指令还是 else 里的指令。显然,排好序的数组非常有利于CPU做分支预测

5. 关注

欢迎关注我的微信公众号以获得更好的阅读体验:猿闻见
程序员必须了解的CPU知识 - 科普篇