硬件结构
# 硬件结构
# CPU 是如何执行程序的?
问题:
- 代码写了那么多,你知道
a = 1 + 2
这条代码是怎么被 CPU 执行的吗? - 软件用了那么多,你知道软件的 32 位和 64 位之间的区别吗?32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
- CPU 看了那么多,我们都知道 CPU 通常分为 32 位和 64 位,你知道 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
# 图灵机的工作方式
图灵的基本思想是用机器来模拟人们用纸笔进行数学运算的过程,而且还定义了计算机由哪些部分组成,程序又是如何执行的。
图灵机的基本组成如下:
有一条「纸带」,纸带由一个个连续的格子组成,每个格子可以写入字符,纸带就好比内存,而纸带上的格子的字符就好比内存中的数据或程序;
有一个「读写头」,读写头可以读取纸带上任意格子的字符,也可以把字符写入到纸带的格子;
读写头上有一些部件,比如存储单元、控制单元以及运算单元:
1、存储单元用于存放数据;
2、控制单元用于识别字符是数据还是指令,以及控制程序的流程等;
3、运算单元用于执行运算指令;
图灵机主要功能就是:
- 读取纸带格子中的内容,然后交给控制单元识别字符是数字还是运算符指令,(读取 -> 识别)
- 如果是数字则存入到图灵机状态中,
- 如果是运算符,则通知运算符单元读取状态中的数值进行计算,计算结果最终返回给读写头,
- 读写头把结果写入到纸带的格子中。
# 冯诺依曼模型
定义计算机基本结构为 5 个部分,分别是运算器、控制器、存储器、输入设备、输出设备,这 5 个部分也被称为冯诺依曼模型。
运算器、控制器是在中央处理器(CPU)里的,存储器就我们常见的内存,输入输出设备则是计算机外接的设备,比如键盘就是输入设备,显示器就是输出设备。
存储单元和输入输出设备要与中央处理器打交道的话,离不开总线。所以,它们之间的关系如下图:
# 内存
我们的程序和数据都是存储在内存,存储的区域是线性的。
在计算机数据存储中,存储数据的基本单位是字节(byte),1 字节等于 8 位(8 bit)。每一个字节都对应一个内存地址。
内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
# 中央处理器
中央处理器也就是我们常说的 CPU。
32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:
- 32 位 CPU 一次可以计算 4 个字节;(32/8,一字节 8 位)
- 64 位 CPU 一次可以计算 8 个字节;(64/8)
这里的 32 位和 64 位,通常称为 CPU 的位宽,代表的是 CPU 一次可以计算(运算)的数据量。
CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。
- 控制单元负责控制 CPU 工作,
- 逻辑运算单元负责计算,
- 而寄存器可以分为多种类,每种寄存器的功能又不尽相同。主要作用是存储计算时的数据。
为什么有了内存还需要寄存器?
因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。
常见的寄存器种类:
- 通用寄存器,用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
- 程序计数器,用来存储 CPU 下一条要执行指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令「的地址」。
- 指令寄存器,用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里
# 总线
总线是用来通信的,在 CPU 和内存以及其他设备之间,总线可分为 3 种:
- 地址总线,用于指定 CPU 将要操作的内存地址;
- 数据总线,用于读写内存的数据;
- 控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线;
当 CPU 要读写内存数据的时候,是如何通过总线的?
- 首先要通过「地址总线」来指定内存的地址;
- 然后通过「控制总线」控制是读或写命令;
- 最后通过「数据总线」来传输数据;
地址总线 -> 控制总线 -> 数据总线
# 输入、输出设备
输入设备向计算机输入数据,计算机经过计算后,(运算器)把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。
# 线路位宽与 CPU 位宽
线路位宽
数据是如何通过线路传输的呢?
其实是通过操作电压,低电压表示 0,高压电压则表示 1。
如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。
- 这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输。
- 当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。
为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。
CPU 想要操作「内存地址」就需要「地址总线」:
- 如果地址总线只有 1 条,那每次只能表示 「0 或 1」这两种地址,所以 CPU 能操作的内存地址最大数量为 2(2^1)个(注意,不要理解成同时能操作 2 个内存地址);
- 如果地址总线有 2 条,那么能表示 00、01、10、11 这四种地址,所以 CPU 能操作的内存地址最大数量为 4(2^2)个。
那么,想要 CPU 操作 4G 大的内存,那么就需要 32 条地址总线,因为 2 ^ 32 = 4G
。
CPU 位宽
CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。
为什么 64 位 CPU 性能不一定就比 32 位 CPU 高很多?
因为很少应用需要算超过 32 位的数字,
所以如果计算的数额不超过 32 位数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来。
# 程序执行的基本过程
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。(寄存器)
那 CPU 执行程序的过程如下:
- 第一步,
- CPU 读取「程序计数器」的值,这个值是指令的内存地址,
- 然后 CPU 的【控制单元】操作「地址总线」指定需要访问的内存地址,
- 接着通知【内存设备】准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,
- CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
- 第二步,
- 「程序计数器」的值自增,表示指向下一条指令。
- 这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;
- 第三步,
- CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,
- 如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;
- 如果是存储类型的指令,则交由「控制单元」执行;
简单总结一下就是
一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环(执行程序)的过程被称为 CPU 的指令周期。
# a = 1 + 2 执行具体过程
CPU 是不认识 a = 1 + 2
这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。
针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是 CPU 能够真正认识的东西。
下面来看看 a = 1 + 2
在 32 位 CPU 的执行过程。
程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:
- 数据 1 被存放到 0x200 位置;
- 数据 2 被存放到 0x204 位置;
编译器会把 a = 1 + 2
翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x100 ~ 0x10c 的区域中:
- 0x100 的内容是
load
指令将 0x200 地址中的数据 1 装入到(指令)寄存器R0
; - 0x104 的内容是
load
指令将 0x204 地址中的数据 2 装入到寄存器R1
; - 0x108 的内容是
add
指令将寄存器R0
和R1
的数据相加,并把结果存放到寄存器R2
; - 0x10c 的内容是
store
指令将寄存器R2
中的数据存回数据段中的 0x208 地址中,这个地址也就是变量a
内存中的地址;
简单总结就是:
add
指令将寄存器R0
和R1
的数据相加,并把结果放入到R2
,从而翻译成机器码
编译完成后,具体执行程序的时候,【程序计数器】会被设置为 0x100 地址,然后依次执行这 4 条指令。(编译 -> 执行)
不难发现上面的例子中,地址之间都是相隔 4 个字节
- 指令间隔
这是因为上面的例子是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。
- 数据间隔
而数据的大小是根据你在程序中指定的变量类型,比如 int
类型的数据则占 4 个字节,char
类型的数据则占 1 个字节
string
类型的话,在 UTF-8 编码下,一个英文字母通常占用 1 个字节,一个汉字通常占用 3 个字节。
# 指令
指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。
不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码。
最简单的 MIPS 指集
MIPS 的指令是一个 32 位的整数。
- 高 6 位代表着操作码,表示这条指令是一条什么样的指令,
- 剩下的 26 位不同指令类型所表示的内容也就不相同,
- 主要有三种类型 R、I 和 J。
三种类型的含义:
- R 指令,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。如果是逻辑位移操作,后面还有位移操作的「位移量」,而最后的「功能码」则是再前面的操作码不够的时候,扩展操作码来表示对应的具体指令的;
- I 指令,用在数据传输、条件分支等。这个类型的指令,就没有了位移量和功能码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或一个常数;
- J 指令,用在跳转,高 6 位之外的 26 位都是一个跳转后的地址;
编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。
CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。
执行指令的方式
大多数 CPU 都使用来【流水线】的方式来执行指令,所谓的流水线就是【把一个任务拆分成多个小任务】,于是一条指令通常分为 4 个阶段,称为 4 级流水线。
四个阶段的具体含义:
- CPU 通过程序计数器读取对应内存地址的指令,这个部分称为 Fetch(取得指令);
- CPU 对指令进行解码,这个部分称为 Decode(指令译码);
- CPU 执行指令,这个部分称为 Execution(执行指令);
- CPU 将计算结果存回寄存器或者将寄存器的值存入内存,这个部分称为 Store(数据回写);
上面这 4 个阶段,我们称为指令周期(Instrution Cycle),CPU 的工作就是一个周期接着一个周期,周而复始。
# 指令的类型
指令从功能角度划分,可以分为 5 大类:
- 数据传输类型的指令,比如
store/load
是寄存器与内存间数据传输的指令,mov
是将一个内存地址的数据移动到另一个内存地址的指令; - 运算类型的指令,比如加减乘除、位运算、比较大小等等,它们最多只能处理两个寄存器中的数据;
- 跳转类型的指令,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的
if-else
、switch-case
、函数调用等。 - 信号类型的指令,比如发生中断的指令
trap
; - 闲置类型的指令,比如指令
nop
,执行后 CPU 会空转一个周期
传输、运算、跳转、信号、闲置
# 指令的执行速度
CPU 的硬件参数都会有 GHz
这个参数(主频),比如一个 1 GHz 的 CPU,指的是时钟频率是 1 G,代表着 1 秒会产生 1G 次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。
对于 CPU 来说,在一个时钟周期内,CPU 仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。(v=f/T)
一个时钟周期一定能执行完一条指令吗?
答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。
如何让程序跑的更快?
程序的 CPU 执行时间 越少 程序就跑得 越快。
主频越高说明 CPU 的工作速度就越快,比如我手头上的电脑的 CPU 是 2.4 GHz 四核 Intel Core i5,这里的 2.4 GHz 就是电脑的主频,时钟周期时间就是 1/2.4G。
CPU 时钟周期数 = 指令数 x 每条指令的平均时钟周期数(Cycles Per Instruction,简称 CPI
)
因此,要想程序跑的更快,优化这三者即可:
- 指令数,表示执行程序所需要多少条指令,以及哪些指令。需优化编译器。
- 每条指令的平均时钟周期数 CPI,表示一条指令需要多少个时钟周期数,依赖于流水线方式;
- 时钟周期时间,表示计算机主频,取决于计算机硬件。
# 总结
冯诺依曼模型
你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的:
- 如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令;
- 操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上。
总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽
64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
64 位相比 32 位 CPU 的优势主要体现在两个方面:
- 64 位 CPU 可以一次计算超过 32 位的数字,而 32 位 CPU 如果要计算超过 32 位的数字,要分多步骤进行计算,效率就没那么高,但是大部分应用程序很少会计算那么大的数字,所以只有运算大数字的时候,64 位 CPU 的优势才能体现出来,否则和 32 位 CPU 的计算性能相差不大。
- 通常来说 64 位 CPU 的地址总线是 48 位,而 32 位 CPU 的地址总线是 32 位,所以 64 位 CPU 可以寻址更大的物理内存空间。如果一个 32 位 CPU 的地址总线是 32 位,那么该 CPU 最大寻址能力是 4G,即使你加了 8G 大小的物理内存,也还是只能寻址到 4G 大小的地址,而如果一个 64 位 CPU 的地址总线是 48 位,那么该 CPU 最大寻址能力是
2^48
,远超于 32 位 CPU 最大寻址能力。
为什么通常说 64 位 CPU 的地址总线是 48 位的?
这涉及到物理寻址和虚拟内存的设计。实际上,64 位 CPU 的地址总线并不是固定为 48 位,而是有一定的范围。
# 磁盘比内存慢几万倍?
如果大家自己想组装电脑的话,肯定需要购买一个 CPU 和存储器方面的设备。
相信大家都知道内存和硬盘都属于计算机的存储设备,断电后内存的数据是会丢失的,而硬盘则不会,因为硬盘是持久化存储设备,同时也是一个 I/O 设备。
但其实 CPU 内部也有存储数据的组件,比如寄存器、CPU L1/L2/L3 Cache 也都是属于存储设备,只不过它们能存储的数据非常小。
那机械硬盘、固态硬盘、内存这三个存储器,到底和 CPU L1 Cache 相比速度差多少倍呢?
# 存储器的层次结构
寄存器,处理速度是最快的,但是能存储的数据也是最少的。
CPU Cache,中文称为 CPU 高速缓存,处理速度相比寄存器慢了一点,但是能存储的数据也稍微多了一些。
L1 Cache 通常分成「数据缓存」和「指令缓存」,L1 是距离 CPU 最近的,因此它比 L2、L3 的读写速度都快、存储空间都小。
对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。
CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的
# 寄存器
寄存器的数量通常在几十到几百之间,每个寄存器可以用来存储一定的字节(byte)的数据。比如:
- 32 位 CPU 中大多数寄存器可以存储
4
个字节; - 64 位 CPU 中大多数寄存器可以存储
8
个字节。
如果寄存器的速度太慢,则会拉长指令的处理周期,从而给用户的感觉,就是电脑「很慢」。
# CPU Cache
CPU Cache 用的是一种叫 **SRAM(Static Random-Access Memory,静态随机存储器)**的芯片。
SRAM 之所以叫「静态」存储器,是因为只要有电,数据就可以保持存在,而一旦断电,数据就会丢失了。
# 内存
内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 **DRAM(Dynamic Random Access Memory,动态随机存取存储器)**的芯片。
相比 SRAM,DRAM 的密度更高,功耗更低,有更大的容量,而且造价比 SRAM 芯片便宜很多。
因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因
# SSD/HDD 硬盘
SSD(Solid-state disk)就是我们常说的固态硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比 SSD 大概快 10~1000
倍。
当然,还有一款传统的硬盘,也就是机械硬盘(Hard Disk Drive, HDD),它是通过物理读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢 10W
倍左右。
由于 SSD 的价格快接近机械硬盘了,因此机械硬盘已经逐渐被 SSD 替代了。
# 存储器的层次关系
CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。
比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。
另外,当 CPU 需要访问内存中某个数据的时候,
- 如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,
- 如果寄存器没有这个数据,CPU 就会查询 L1 高速缓存,
- 如果 L1 没有,则查询 L2 高速缓存,
- L2 还是没有的话就查询 L3 高速缓存,
- L3 依然没有的话,才去内存中取数据。
各存储器之间的区别:
# 如何写出让 CPU 跑得更快的代码?
# CPU Cache 有多快?
CPU 从 L1 Cache 读取数据的速度,相比从内存读取的速度,会快 100
多倍
# CPU Cache 的数据结构和读取过程是什么样的?
数据结构
- CPU Cache 是由很多个 Cache Line 组成的,
- Cache Line 是 CPU 从内存读取数据的基本单位,
- 而 Cache Line 是由各种标志(Tag)+ 数据块(Data Block)组成
CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)。
读取过程
CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。
内存地址映射问题
内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。
# 如何写出 CPU 缓存命中率高的代码?
在前面我也提到,L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会分别处理数据和指令,比如 1+1=2
这个运算,+
就是指令,会被放在「指令缓存」中,而输入数字 1
则会被放在「数据缓存」里。
因此,我们要分开来看「数据缓存」和「指令缓存」的缓存命中率。
# 如何提升数据缓存的命中率?
举一个遍历二维数组的例子。
同样的输出结果,顺序访问会比跳跃式的访问速度更快。
因为不连续性、跳跃式访问数据元素的方式,可能不能充分利用到了 CPU Cache 的特性,从而代码的性能不高。
那访问 array[0][0]
元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?
这跟 CPU Cache Line 有关,它表示 CPU Cache 一次性能加载数据的大小,可以在 Linux 里通过 coherency_line_size
配置查看 它的大小,通常是 64 个字节。
也就是说,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 array[0][0]
时,由于该元素不足 64 字节,于是就会往后顺序读取 array[0][0]~array[0][15]
到 CPU Cache 中。顺序访问的 array[i][j]
因为利用了这一特点,所以就会比跳跃式访问的 array[j][i]
要快。
因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升。
# 如何提升指令缓存的命中率?
我们以一个例子来看看,现有一个元素为 0 到 100 之间随机数字组成的一维数组。
int array[N];
for (i = 0; i < N; i++) {
array[i] = rand() % 100;
}
2
3
4
接下来,对这个数组做两个操作:
第一个操作,循环遍历数组,把小于 50 的数组元素置为 0;
// 操作一:数组遍历 for(i = 0; i < N; i++) { if (array[i] < 50) { array[i] = 0; } }
1
2
3
4
5
6第二个操作,将数组排序;
// 操作二:排序 sort(array, array + N);
1
2
那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢?
在回答这个问题之前,我们先了解 CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 和 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。
当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50
的次数会比较多,于是分支预测就会缓存 if
里的 array[i] = 0
指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。
# 如何提升多核 CPU 的缓存命中率?
现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问内存的频率。
# 总结
要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:
- 对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升;
- 对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率;
另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。
# CPU 缓存一致性
# 介绍
在多核处理器系统中,每个核心都有自己的本地缓存(CPU Cache),用于存储最近访问的内存数据。
- 为了提高性能,处理器会从主内存复制数据到本地缓存中进行读写操作。
- 然而,这种缓存的存在可能导致缓存不一致性的问题,即不同核心上的缓存中的数据可能不同步。
# 缓存一致性问题
- 写操作的原子性: 当一个核心执行写操作时,数据首先被写入其本地缓存,而不是直接写回主内存。这导致其他核心无法立即看到这个更新,因为它们可能仍然使用本地缓存中的旧数据。
- 多核心之间的通信: 当一个核心修改了某个内存位置的数据,其他核心如何知道这个变化?如果不进行同步,其他核心可能无法获取最新的数据,导致读取到脏数据。
# CPU Cache 的数据写入
- 写回法(Write Back): 处理器先将数据写入本地缓存,而不是立即写回主内存。这提高了写入速度,但也可能导致其他核心无法立即看到更新。
- 写直达法(Write Through): 处理器在写入本地缓存的同时立即写回主内存。这确保了主内存的一致性,但增加了写操作的延迟。
# 缓解缓存一致性问题的方法
- 缓存一致性协议: 处理器使用一些协议(如 MESI 协议:Modified、Exclusive、Shared、Invalid)来维护缓存的一致性。这些协议定义了缓存状态和在不同状态下的处理器之间的通信规则。
- 屏障指令(Memory Barrier): 程序员可以使用屏障指令来强制处理器刷新本地缓存或者阻止某些重排序操作,以确保内存操作的有序性。
- 原子操作: 使用原子操作可以确保某些操作的原子性,减少了并发写入时的一致性问题。
- 采用同步机制: 使用锁或其他同步机制来保证多个线程对共享数据的访问是有序的,从而避免缓存一致性问题。
# 结论
CPU 缓存一致性是多核处理器中需要处理的复杂问题,处理器通过采用缓存一致性协议和其他同步机制来解决这些问题。程序员也需要在编写并发程序时注意缓存一致性,以避免出现意料之外的行为。
# 虚拟地址与物理地址
- 虚拟地址(Virtual Address):虚拟地址是由 CPU 生成的用于访问内存的地址空间,它是在程序运行时由操作系统提供给每个进程或线程的抽象地址。虚拟地址空间是每个进程独立的,使得每个进程可以认为自己独占整个内存空间,不受其他进程的影响。虚拟地址空间可以比实际的物理内存大小大得多,这样就允许每个进程使用的内存超过实际可用的物理内存。
- 物理地址(Physical Address):物理地址是内存中实际存储数据的地址,是 CPU 通过地址总线直接访问内存单元的地址。它对应着计算机中实际的硬件内存空间。每个物理地址都与内存模块上的特定存储单元(如 RAM)相关联。
在计算机系统中,虚拟地址与物理地址之间的映射是由操作系统的内存管理单元(MMU,Memory Management Unit)来实现的。MMU 将虚拟地址映射到物理地址,使得程序访问虚拟地址时,可以被正确地映射到对应的物理地址,从而实现正确的内存访问。
- 虚拟地址和物理地址的使用有助于提高计算机系统的性能和安全性,使得每个进程可以独立使用自己的地址空间,且不受其他进程的影响。
- 同时,操作系统可以使用虚拟地址空间来提供内存隔离和保护机制,从而增强计算机系统的安全性和稳定性。
# 学习参考
2.1 CPU 是如何执行程序的? | 小林coding (xiaolincoding.com) (opens new window)