携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情
1.1 CPU是如何执行程序的?
有这么几个问题:
- a = 1 + 2 这条指令是怎么被CPU运行起来的?
- 软件32位和64位的区别?32位操作系统可以运行在64位系统上吗?64位可以运行在32位上吗?原因又是什么?
- CPU分为32位和64位,64位优势在哪呢,64位性能一定比32位好吗?
本章大纲
- 图灵机的工作方式
- 冯诺依曼模型
- 线路位宽与CPU位宽
- 程序执行的基本过程
- a = 1 + 2 执行具体过程
图灵机的工作方式
当我们不太清楚一个事物为什么是这样子的时候,我们应该去看看它的历史。它在时间潮流中的变化决定它现在出现在你面前的模样。
图灵机是很早就提出的抽象计算模型,下面是它的大概模样
图灵机的基本组成如下:
- 有一条纸带,由一个一个的格子组成,纸带好比内存,格子中的数据就像内存中的数据。
- 有一个读写头,读写头可以读取纸带上的任意格子的字符
- 头上有一些部件,比如存储单元、控制单元、以及运算单元。分别用于存储数据,识别字符以及控制程序流程,执行运算指令
冯诺依曼模型
冯诺依曼遵循图灵机的设计提出了在现在依旧符合的冯诺依曼模型
定义了计算机基本机构:运 算器、控制器、存储器、输入、输出设备。这五个部分被称为冯诺依曼模型
运算器、控制器是在中央处理器里的,存储器就是我们常见的内存,输入输出设备就是计算机外接的设备。
存储器和输入输出设备要是与中央处理器打交道的话,离不开总线。
下面介绍 内存、中央处理器、总线、输入输出设备
内存
内存这一个存储区域是线性的。存储数据的基本单位是字节(byte),1字节==8位(bit)。每一个字节对应一个内存地址。
内存地址是从0开始的,自增,最后一个地址为总大小 - 1,和数组差不多性质。所以内存读写任何一个数据的速度是一样的
中央处理器
即CPU,有32位和64位,区别在于一次能计算多少字节数据。
- 32位 -- 4个字节
- 64位 -- 8个字节
这么设计是为了计算更大的数值。
CPU内部还有常见的如寄存器、控制单元、和逻辑运算单元。
为什么已经有存储器内存存储了,还需要寄存器来存储?
因为内存离CPU远,计算速度比较慢
常见的寄存器种类:
- 通用寄存器:用来存放需要进行运算的数据
- 程序计数器:用来存放CPU要执行的下一条指令的内存地址
- 指令寄存器:用来存放程序计数器存放的指令,指令本身
总线
CPU和内存以及其他设备的通信
分类:
- 地址总线:用于CPU要操作的内存地址
- 数据总线:用于读写内存的数据
- 控制总线:用于发送和接受信号,比如中断、设备复位等信号
读写内存时
- 地址总线指定内存地址
- 控制总线控制读写命令
- 数据总线传输数据
输出输出设备
外界与CPU交互,用到了控制总线
线路位宽与CPU位宽
数据通过线路传输是通过操作电压完成,低电压是0,高电压是1。
一位一位的传输就是串行方式。想一次多传输就应该增加线路。
为了避免低效率的串行传输方式,线路的位宽最好一次就能访问到所有的内存地址。
CPU要操作内存地址就需要地址总线:
- 如果地址总线只有一根,能够表示的只有0 和 1所以能够操作的内存地址最大为2,不是同时操作的地址。
- 两条就是 00 01 10 11 四个地址,
即2 ^ 根数
那么要操作4g内存,就需要32条总线,2 ^ 32 = 4 G
上面的是线路位宽,下面是CPU位宽
CPU的位宽不应该小于线路位宽,因为32位的CPU一次只能操作32位的地址总线和数据总线
32位如何计算64位大小的数字?
拆分成高低位,然后先算地位,再算进位,再算高位。
而64位CPU就能一次性计算。
所以如果32位和64位一起计算32位可能没什么差别,但是当计算64位时64位才会有优势。
另外,32位CPU最大只能操作4GB内存条,装了8g也没有用。64位则是2^64
程序执行的基本过程
- 第一步,CPU根据程序计数器获取到存储指令的内存地址,然后控制单元操作地址总线访问内存地址,通知内存准备数据,准备好数据后,通过数据总线将指令内容传给CPU,CPU收到后存入到指令寄存器
- CPU分析指令寄存器中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给逻辑运算单元运算,如果是存储类型的指令,就交给控制单元执行。
- 第三步 ,CPU执行完指令后,程序计数器自增,表示指向下一条指令,大小由CPU位宽决定,比如32位CPU,一条指令大小就是4字节,需要4个地址存放,程序计数器就自增4
这个往复过程就是CPU的指令周期
a = 1 + 2执行具体过程
首先CPU是不认识 a = 1 + 2的,所以要想跑起程序就需要把整个程序翻译成汇编语言,整个过程就叫汇编。
针对汇编代码,需要汇编器翻译成机器码,由01组成,这个就是计算机指令,CPU能够认识。
a = 1 + 2 在 32位CPU的执行过程:
程序编译过程中,编译器通过分析代码,能够发现1 和 2 是数据,内存会有个叫数据段的区域来存放这些数据。
如图
- 数据 1 被存放到 0x100 位置;
- 数据 2 被存放到 0x104 位置;
存放指令的地方叫正文段
存放数据的地方叫数据段
编译器会把a = 1 + 2 翻译成4条指令存放到正文段。
- 0x200 load 将数据1 装入 R0寄存器
- 0x204 load 将数据2 装入 R1寄存器
- 0x208 add 将R0 和 R1 相机,并把结果放到寄存器R2
- 0x20c store 将寄存器R2的数据存回0x108地址就是变量a的地址
再提一句就是,32位CPU一条指令4个字节,也就是4个内存地址
指令
指令其实指的是机器码,但是为了方便理解,这里说的是汇编代码。CPU通过解析机器码来知道指令的内容。
不同的CPU有不同的指令集,也对应不同的机器码,下面是最简单的MIPS指令集,来了解机器码是如何生成的。
MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型R、I 和 J。
- R指令,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。如果是逻辑位移后面还有唯一操作的位移量,最后的功能码则是前面的操作码不够的时候,扩展操作码来表示对应的具体指令的。
- I指令,用在数据传输、条件分支等。没有位移量、功能码、和第三个寄存器。三部分合并成一个地址值或一个常数
- J指令用于跳转,后面26位全表示跳转后的地址
下面举例:add指令将寄存器R0和R1的数据相加,并把结果放入到R2,翻译成机器码
加和运算add指令属于R指令类型
编译器在编译程序的时候会构造指令,CPU执行时会解析指令。指令的编码与解码。
在此基础上,现代大多是CPU都是使用流水线的方式来执行指令。
四个阶段的具体含义?
- 通过程序计数器读取响应内存地址的指令 Fetch 取得指令
- 对指令进行解码 Decode 指令译码
- 执行指令 Execution 执行指令
- 将计算结果放入寄存器或者内存,Store 数据回写
上面4个步骤叫做指令周期。
不同阶段由不同计算机组件完成:
- 指令都是存储在存储器中,通过程序计数器和指令寄存器取指令的过程都是由控制器操作的。
- 译码--控制器
- 执行时,算术由算术逻辑单元负责,跳转啥的由控制器负责
指令的类型
从功能出发,和上面不要混肴,上面有一个是指令集,是具体的体现:
- 数据传输类型 store/load 寄存器与内存 mov 内存与内存
- 运算类型 加减乘除
- 跳转类型 if-else swit-case 函数调用
- 信号类型 trap 中断
- 闲置类型 nop CPU空转
指令的执行速度
CPU的硬件参数GHZ ,1GHZ表示1秒能够产生1G次脉冲信
号,每一次脉冲信号高低电平的转换就是一个时钟周期。
CPU在一个时钟周期内,只能完成一次最基本的动作,时钟频率越高时钟周期越短,工作速度越快。
一个时钟周期能够执行一条指令吗?
不能。大多数指令都不能,而且不同的指令需要的时钟周期也不一样。乘法比加法多。
如何让程序跑的更快?
消耗的CPU时间少,说明程序是快的,对于程序的CPU执行时间可以拆解成CPU时钟周期数和时钟周期时间的乘积
时钟周期时间即主频。主频越高说明运行速度越快。比如2.4GHZCPU,时钟周期时间就是 1/2.4G
但是主频一般来说是相对固定的,所以我们应该去减少CPU时钟周期数。
CPU时钟周期数 = 指令数 * 每天指令的平均时钟周期数CPI
那对于如何让程序跑的更快这个问题的答案就出来了?
- 指令数,可以靠编译器,同样的代码在不同的编译器中编译出来的指令不一样
- 每条指令的平均时钟周期数CPI:大多数CPU已使用流水线技术,让一条指令所需要的CPU时钟周期数尽可能的少。
- 时钟周期时间:超频技术,打开超频意味着把CPU内部时钟调快了。代价是热量和崩溃。
很多厂商为了跑分而跑分,基本都是在这三个方面入手的哦,特别是超频这一块
总结
1.2 磁盘比内存缓慢几万倍?
\
存储器的层次结构是怎么样的呢?
理解存储器的层次结构的过程可以当作在图书馆看书。我们的大脑好比CPU,我们的运算速度很快,但是我们每次能处理的信息很少,就好像寄存器,容量很小但是很快。L1,L2,L3缓存就好像是短期记忆和长期记忆,访问时没有那么快,需要一点时间回忆一下。如果我们需要的东西,我们根本不记得,我们就需要去翻书,桌上触手可及的书就好像内存,每次翻阅都是在重新记到脑子里去,而图书馆书架上的书就好像硬盘,我们每次都需要先找到,然后开始看书,然后记到脑子里。
对于存储器,运行速度越快,成本越高,所以为啥寄存器的容量比较小,磁盘的内存比较大。另外,一个比较好的CPU的价格大于内存条+磁盘的价格。
接下来详细的了解一下
各个存储器的细节:
寄存器
- 32 位 CPU 中大多数寄存器可以存储 4 个字节;
- 64 位 CPU 中大多数寄存器可以存储 8 个字节。
寄存器的访问速度非常快,一般是半个CPU时钟周期,CPU时钟周期和CPU主频息息相关。
2 GHz 主频的 CPU,那么它的时钟周期就是 1/2G,也就是 0.5ns(纳秒)。
CPU cahce
CPU缓存使用的是SRAM(Static Random-Access Memory静态随机存储芯片)芯片
SRAM之所以叫做静态存储器的,是因为只要有电,数据就可以一直保持,断电就会丢失。
在 SRAM 里面,一个 bit 的数据,通常需要 6 个晶体管,所以 SRAM 的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度非常快。
L1cache
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size 查看
index0 , 1 , 2 , 3 分别是 数据缓存,指令缓存,L2, L3
内存
DRAM随机存储芯片。动态是因为数据被存储在电容中,电容会不断漏电,需要定时刷新才能保证数据不被丢失。
硬盘
SSD固态硬盘 HDD机械硬盘
断电后不会丢失
总结
可以发现,不同的存储器之间性能差距很大,构造存储器分级很有意义,分级的目的是要构造缓存体系
CPU怎么知道要访问的内存数据是否在CPU cache中
1.3如何写出让CPU跑得更快的程序
在上面的基础上知道了层级结构之后,我们还需要了解一下CPU cache 的数据结构和读取过程是怎么样的,才能根据此得出如何让程序在CPU层面跑的更快。
L1cache分为数据缓存和指令缓存,L1cache 和 L2cache都是每个CPU核心独有的,L3是多个CPU核心共享的。
CPU是怎么读数据的?
CPU cache的结构
由多个cache line组成,同时cache line也是CPU读取内存的基本单位。
cache line 由各种标志tag + 数据块Data Block组成
\
CPU读取内存数据时,不是一个元素一个元素的读,而是一块一块,每一块就是cache line 缓存块
查看linux缓存块大小的指令 如下
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
举个例子,一个数组有100个元素,每个元素4个字节,CPU会顺序读取16个元素到cpu cache,下次就直接读cache就好了,能够提高性能。
在CPU中,不管数据存不存在缓存中,都会先访问cache,只有当cache找不到数据,才会去读取内存。
那CPU怎么知道访问的内存数据在不在缓存中呢,如果在又怎么找呢?
从直接映射Cache ( Direct Mapped Cache ) 说起,
前面提到,CPU访问内存的时候读取的是一块块的cache line大小的数据,这在内存中叫做内存块(Block)。直接映射Cache就是把内存块的地址始终映射在一个CPU cache line地址。实现方式就是“取模运算”。
举个例子,内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Cache Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Cache Line 中的话,则是一定映射在 7 号 CPU Cache Line 中,因为 15 % 8 的值是 7。
这个时候就会产生多个内存块同时映射在一个内存块上。
因此为了区别不同的内存块,在对应的CPU cache line中,会存储一个tag(组标记) ,存储记录当前cacheline对应的内存块,以此来区别不同的内存块
除了组标记tag以外,还有两个信息:
- 数据块(从内存中来的)
- 有效位,有效位是0则CPU直接访问内存重新加载数据
CPU读取cpu cache line 的时候并不是读取整个数据块,而是读取所需要的一个数据片段, 这样的数据叫做字(word),怎么在cache line中找到这个字呢?通过偏移量(offset)
因此一个内存的访问地址是 组标记 + cache line 索引 + 偏移量
cpu通过这个能够在CPU cache里面找到缓存的数据。
所以CPU cache中的数据结构就是 索引 + 有效位 + 组标记 + 数据块
如果内存中的数据已经在CPU cache中了,那么CPU访问内存地址会经历以下4个步骤:
- 根据内存访问地址的索引,取模计算计算CPU cache的索引
- 拿到索引,根据有效位判断是否有效,无效则访问内存,有效则继续
- 对比内存地址中的组标记和cache line 中的组标记,如果不是就直接访问内存。如果是的话就往下执行
- 根据偏移量获取到相应的字
提高缓存命中率就能提高代码运行速度
我们知道L1cache分为 数据缓存和指令缓存
提高数据缓存命中,需要知道数据在缓存中的存储方式。比如,数组,二维数组等等。
提高指令缓存命中,CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。
比如先遍历再排序快,还是先排序再遍历呢?
当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,于是分支预测就会缓存 if 里的 array[i] = 0 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。
如果你肯定代码中的 if 中的表达式判断为 true 的概率比较高,我们可以使用显示分支预测工具,
如何提升多核 CPU 的缓存命中率?
在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。
而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。
当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。
在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。
\