算是读书笔记吧
最简单的 CPU
1. 首先,我们有一个自动计数器
这个自动计数器会随着时钟主频不断地自增,来作为我们的 PC 寄存器。
2. 在这个自动计数器的后面,我们连上一个译码器
译码器还要同时连着我们通过大量的 D 触发器组成的内存。
3. 自动计数器会随着时钟主频不断自增
从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的 CPU 指令。读取出来的 CPU 指令会通过我们的 CPU 时钟的控制,写入到一个由 D 触发器组成的寄存器,也就是指令寄存器当中。
4. 在指令寄存器后面,我们可以再跟一个译码器
这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成 opcode 和对应的操作数。
5. 执行具体指令
当我们拿到对应的 opcode 和操作数,对应的输出线路就要连接 ALU,开始进行各种算术和逻辑运算。
对应的计算结果,则会再写回到 D 触发器组成的寄存器或者内存当中。
指令周期(Instruction Cycle)
计算机每执行一条指令所需要的一次循环,就叫指令周期
计算机每执行一条指令的过程,可以分解成这样几个步骤:
1.Fetch(取得指令)
由控制器(Control Unit)操作
也就是从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增,好在未来执行下一条指令。
2.Decode(指令译码)
由控制器(Control Unit)操作
也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
3.Execute(执行指令)
由算术逻辑单元(ALU),也就是运算器进行操作。
也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。
不过,如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成,不需要用到运算器。
指令寄存器
存放当前正在执行的指令,包括指令的操作码,地址码,地址信息
PC 寄存器
存放着下一条指令的地址
也叫程序计数器,是用来计数的,指示指令在存储器的存放位置,也就是个地址信息。
各种函数调用、条件跳转。其实只是修改 PC 寄存器里面的地址。
PC 寄存器里面的地址一修改,计算机就可以加载一条指令新指令,往下运行。
译码器
无论是对于指令进行 decode
根据内存地址去获取对应的数据或者指令
运算器
可以完成数据的存储、处理和传输的单元。通常由两部分组成:
操作元件
ALU,也叫组合逻辑元件(Combinational Element)
在特定的输入下,根据不同组合电路的逻辑,生成特定的输出。
存储元件
也叫状态元件
比如我们在计算过程中需要用到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件。
控制器
机械地重复“Fetch - Decode - Execute“循环中的前两个步骤,然后把最后一个步骤,通过控制器产生的控制信号,交给 ALU 去处理
时序逻辑电路
任意时刻的输出仅仅取决于该时刻的输入,与电路原来的状态无关。
类似加减乘除法器
时序逻辑电路
任意时刻的输出不仅取决于当时的输入信号,而且还取决于电路原来的状态,或者说,还与以前的输入有关。
也就是我们说的记忆功能。
最常见的这个电路就是我们的 D 触发器,它也是我们实际在 CPU 内实现存储功能的寄存器的实现方式。
这也是现代计算机体系结构中的“冯·诺伊曼”机的一个关键,就是程序需要可以“存储”,而不是靠固定的线路连接或者手工拨动开关,来实现计算机的可存储和可编程的功能。
流水线设计
通过将一个CPU指令的执行步骤,拆分成多个小步骤的方式。达到缩短单个时钟周期的目的。
单指令周期处理器
在一个时钟周期里,确保执行完一条最复杂的 CPU 指令,也就是耗时最长的一条 CPU 指令
如果 PC 寄存器自增地太快,程序就会出错。
因为前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取里面的数据来做下一次计算了。
这个时候,如果我们的指令使用同样的寄存器,前一条指令的计算就会没有效果,计算结果就错了。
这样做的弊端是,无论是执行一条用不到 ALU 的无条件跳转指令,还是一条计算起来电路特别复杂的浮点数乘法运算,我们都等要等满一个时钟周期。
指令流水线
每个步骤独立运作,不需要等待整条指令执行完毕的这种协作方式,将原本串行起来的几个步骤并行执行。叫做指令流水线。
CPU 本身的设计,是由一个个独立的组合逻辑电路串接起来形成的,天然能够适合这样采用流水线“专业分工”的工作方式。
流水线级(Pipeline Stage)
每一个独立的步骤,我们就称之为流水线阶段或者流水线级(Pipeline Stage)。
如果我们把一个指令拆分成“取指令 - 指令译码 - 执行指令”这样三个部分,那这就是一个三级的流水线。
如果我们进一步把“执行指令”拆分成“ALU 计算(指令执行)- 内存访问 - 数据写回”,那么它就会变成一个五级的流水线。
这样,我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。
如果某一个操作步骤的时间太长,我们就可以考虑把这个步骤,拆分成更多的步骤,让所有步骤需要执行的时间尽量都差不多长。这样,也就可以解决我们在单指令周期处理器中遇到的,性能瓶颈来自于最复杂的指令的问题。像我们现代的 ARM 或者 Intel 的 CPU,流水线级数都已经到了 14 级。
超长流水线
Pentium 4与Athlon的CPU大战
流水线设计带来的性能提升也引出了现代桌面 CPU 的最后一场大战,也就是 Intel 的 Pentium 4 和 AMD 的 Athlon 之间的竞争
在技术上,这场大战 Intel 可以说输得非常彻底,Pentium 4 系列以及后续 Pentium D 系列所使用的 NetBurst 架构被完全抛弃,退出了历史舞台。但是在商业层面,Intel 却通过远超过 AMD 的财力、原本就更大的市场份额、无所不用的竞争手段,以及最终壮士断腕般放弃整个 NetBurst 架构,最终依靠新的酷睿品牌战胜了 AMD。
在此之后,整个 CPU 领域竞争的焦点,不再是 Intel 和 AMD 之间的桌面 CPU 之战。在 ARM 架构通过智能手机的快速普及,后来居上,超越 Intel 之后,移动时代的 CPU 之战,变成了高通、华为麒麟和三星之间的“三国演义”。
那 2000 年发布的 Pentium 4 的流水线深度是多少呢?答案是 20 级,比 Pentium III 差不多多了一倍,而到了代号为 Prescott 的 90 纳米工艺处理器 Pentium 4,Intel 更是把流水线深度增加到了 31 级。
超长流水线的弊端
功耗问题
需要的寄存器变多。主频的提升和晶体管数量的增加都使得我们 CPU 的功耗变大了。
依赖问题很难解决
过长的流水线使得任务之间的依赖处理复杂度成指数级的增长。
三大冒险
任何一本讲解 CPU 的流水线设计的教科书,都会提到流水线设计需要解决的三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)
结构冒险
结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。
CPU 在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样的硬件电路。
最典型的例子就是内存的数据访问,访存(MEM)和取指令(Fetch)都要进行内存数据的读取:
访存(MEM)和取指令(Fetch)都要进行内存数据的读取。而我们的内存,只有一个地址译码器的作为地址输入,无法同时进行这两件事。
普林斯顿架构(Princeton Architecture)也就是冯·诺依曼体系结构的内存结构如图所示
这其实和薄膜键盘的“锁键”问题一样。
常用的最廉价的薄膜键盘,并不是每一个按键的背后都有一根独立的线路,而是多个键共用一个线路。如果我们在同一时间,按下两个共用一个线路的按键,这两个按键的信号就没办法都传输出去。
这也是为什么,重度键盘用户,都要买贵一点儿的机械键盘或者电容键盘。因为这些键盘的每个按键都有独立的传输线路,可以做到“全键无冲”。
哈佛架构(Harvard Architecture)
把我们的内存分成两部分,让它们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。
现代CPU的混合架构
现代的 CPU 虽然没有在内存层面进行对应的拆分,却在 CPU 内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
我们的内存虽然没有按照功能拆分,但是在高速缓存层面进行了拆分,也就是拆分成指令缓存和数据缓存这样的方式,从硬件层面,使得同一个时钟下对于相同资源的竞争不再发生。
数据冒险
然而还有很多冒险问题,是程序逻辑层面的事儿。其中,最常见的就是数据冒险。
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类:
先写后读(Read After Write,RAW),也叫数据依赖。
先读后写(Write After Read,WAR),也叫反依赖。
写后再写(Write After Write,WAW),也较输出依赖。
流水线停顿(Pipeline Stall)
也叫流水线冒泡(Pipeline Bubbling),在存在依赖的情况下,让流水线“再等等“。
这个存在依赖的情况,确切来讲是:
我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。所以,在这个时候,我们能够判断出来,这个指令是否会触发数据冒险。
不过,我们并不是让流水线停下来,而是在执行后面的操作步骤前面,插入一个 NOP 操作,也就是执行一个其实什么都不干的操作。
需要注意的是我们不仅要在当前指令里面,插入 NOP 操作,所有后续指令也要插入对应的 NOP 操作。
就像一路纵队,前边有人停下系鞋带,后边所有人都得原地踏步踏,不然就得踩着脑袋过去了。
不过,流水线停顿这样的解决方案,是以牺牲 CPU 性能为代价的。因为,实际上在最差的情况下,我们的流水线架构的 CPU,又会退化成单指令周期的 CPU 了。
操作数前推(Operand Forwarding)
也较操作数旁路,更合适的名字应该叫操作数转发。当后一个操作S2的执行依赖前一个操作S1的执行结果,此时直接将S1的结果数据传输给S2的ALU进行处理。
当只用流水线冒泡进行处理时:
我们的第 2 条指令,其实多花了 2 个时钟周期,运行了两次空转的 NOP 操作。
操作数前推进行处理时:
它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了 2 个时钟周期。
转发(Forwarding),其实是这个技术的逻辑含义。
旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”,我们在 CPU 的硬件里面,需要再单独拉一根信号传输的线路出来,使得 ALU 的计算结果,能够重新回到 ALU 的输入里来。这样的一条线路,就是我们的“旁路”。
操作数前推+流水线冒泡进行处理时:
有的时候,虽然我们可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。
有些时候,我们的操作数前推并不能减少所有“冒泡”,只能去掉其中的一部分。我们仍然需要通过插入一些“气泡”来解决冒险问题。
乱序执行
不以编译的代码顺序为指令执行阶段的顺序,通过一个类似线程池的保留站,在保证不破坏数据依赖的前提下让系统自己去动态调度先执行哪些指令。弥补了 CPU 和内存之间的性能差异,可以充分利用 CPU 的性能。
以下这个例子:
a = b + c
d = a * e
x = y * z
第三步计算其实完全不依赖前两步,但是却要在流水线中等待前两步执行完成。
其实我们完全可以将第三步提前执行。所以在第二条指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了。
内乱外序
指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
在乱序执行的情况下,只有 CPU 内部指令的执行层面,可能是“乱序”的。只要我们能在指令的译码阶段正确地分析出指令之间的数据依赖关系,这个“乱序”就只会在互相没有影响的指令之间发生。
即便指令的执行过程中是乱序的,我们在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。
控制冒险
对于if…else 这样的条件分支,或者 for/while 循环,指令无法顺序执行。为了确保能取到正确的指令,之后的取指令和指令译码就会被打断,等待之前的判断逻辑来决定后面要执行的指令。
缩短分支延迟
本质上和前面数据冒险的操作数前推的解决方案类似,就是在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。
我们可以将条件判断、地址跳转,都提前到指令译码阶段进行,而不需要放在指令执行阶段。
对应的,我们也要在 CPU 里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。
不过,他仍然不够快:
在流水线里,第一条指令进行指令译码的时钟周期里,我们其实就要去取下一条指令了。这个时候,我们其实还没有开始指令执行阶段,自然也就不知道比较的结果。
静态分支预测
最简单的分支预测技术,叫作“假装分支不发生”。
顾名思义,自然就是仍然按照顺序,把指令往下执行。
如果分支预测是正确的
我们节省下来本来需要停顿下来等待的时间
如果分支预测失败了
那我们就把后面已经取出指令已经执行的部分,给丢弃掉。
这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。
CPU 不仅要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,我们还需要做对应的清除操作。
比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。
所以,CPU 需要提供对应的丢弃指令的功能,通过控制信号清除掉已经在流水线中执行的指令。
只要对应的清除开销不要太大,我们就是划得来的。
动态分支预测
根据之前条件跳转的比较结果来预测下一次比较结果。
一级分支预测(One Level Branch Prediction)
也叫 1 比特饱和计数(1-bit saturating counter)。
用一个比特,记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。
如果前一天下雨,那么今天就会下雨:
这个表格里一共有 31 天,那我们就可以预测 30 次。你可以数一数,按照这种预测方式,我们可以预测正确 23 次,正确率是 76.7%,比随机预测的 50% 要好上不少。
双模态预测器(Bimodal Predictor)
也叫2 比特饱和计数。
在一级分支预测的基础上,用多个前置状态,预测当前的分支。可以通过引入一个状态机来达到目的。
连续发生两次相同状态,才改变状态机的状态:
预测的结果的正确率会是 22 次,也就是 73.3% 的正确率。
这个方法虽然简单,但是却非常有效。在 SPEC 89 版本的测试当中,使用这样的饱和计数方法,预测的准确率能够高达 93.5%。
Intel 的 CPU,一直到 Pentium 时代,在还没有使用 MMX 指令集的时候,用的就是这种分支预测方式。
for循环中的预测冒险
Time spent in first loop is 5ms
Time spent in second loop is 15ms
其他提升CPU性能的技术
超标量
正常的流水线情况下,我们只能让CPU在指令执行阶段可以并行处理。但是一个时钟周期内,也只能读取一条指令。
超标量技术,通过添加硬件一次读取多条指令的方式,让CPU的IPC(一个时钟周期里面能够执行的指令数,代表CPU的吞吐率)超过了1。
多发射
我们同一个时间,可能会同时把多条指令发射(Issue)到不同的译码器或者后续处理的流水线中去。
超标量
本来我们在一个时钟周期里面,只能执行一个标量(Scalar)的运算。在多发射的情况下,我们就能够超越这个限制,同时进行多次计算。
超线程
是一个“线程级并行”的解决方案。它通过让一个物理 CPU 核心,“装作”两个逻辑层面的 CPU 核心,使得 CPU 可以同时运行两个不同线程的指令
超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术。
在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。
我们并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。
一般是在那些各个线程“等待”时间比较长的应用场景下。
比如,我们需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。
SIMD(Single Instruction Multiple Data)单指令多数据流
一种“指令级并行”的加速方案,或者我们可以说,它是一种“数据并行”的加速方案。
对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用 SIMD 是一个很划算的办法。在这个大量的“数据并行”,其实通常就是实践当中的向量运算或者矩阵运算。
在实际的程序开发过程中,过去通常是在进行图片、视频、音频的处理。最近几年则通常是在进行各种机器学习算法的计算。
正是 SIMD 技术的出现,使得我们在 Pentium 时代的个人 PC,开始有了多媒体运算的能力。可以说,Intel 的 MMX、SSE 指令集,和微软的 Windows 95 这样的图形界面操作系统,推动了 PC 快速进入家庭的历史进程。
并行读取
Intel 在引入 SSE 指令集的时候,在 CPU 里面添上了 8 个 128 Bits 的寄存器。128 Bits 也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。
并行计算
4 个整数各自加 1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要 CPU 里有足够多的功能单元,能够同时进行这些计算,这个加法就是 4 路同时并行的,自然也省下了时间。
手机CPU霸主--ARM
CISC--复杂指令集(Complex Instruction Set Computing)
使用类似赫夫曼编码(Huffman coding)的方式,将机器指令进编码。常用的指令要短一些,不常用的指令可以长一些。
虽然冯·诺依曼高屋建瓴地提出了存储程序型计算机的基础架构,但是实际的计算机设计和制造还是严格受硬件层面的限制。当时的计算机很慢,存储空间也很小。《人月神话》这本软件工程界的名著,讲的是花了好几年设计 IBM 360 这台计算机的经验。IBM 360 的最低配置,每秒只能运行 34500 条指令,只有 8K 的内存。为了让计算机能够做尽量多的工作,每一个字节乃至每一个比特都特别重要。
所以,CPU 指令集的设计,需要仔细考虑硬件限制。为了性能考虑,很多功能都直接通过硬件电路来完成。为了少用内存,指令的长度也是可变的。就像算法和数据结构里的赫夫曼编码(Huffman coding)一样,常用的指令要短一些,不常用的指令可以长一些。那个时候的计算机,想要用尽可能少的内存空间,存储尽量多的指令。
RISC--精简指令集(Reduced Instruction Set Computing)
把指令“精简”到 20% 的简单定长指令,让软件来实现复杂de硬件的功能。
CPU 的整个硬件设计就会变得更简单了,在硬件层面提升性能也会变得更容易了:
更多的通用寄存器
因为 RISC 完成同样的功能,执行的指令数量要比 CISC 多,所以,如果需要反复从内存里面读取指令或者数据到寄存器里来,那么很多时间就会花在访问内存上。
更好的分支预测
RISC 的 CPU 也可以把更多的晶体管,用来实现更好的分支预测等相关功能
RISC 降低了 CPU 硬件的设计和开发难度
从 80 年代开始,大部分新的 CPU 都开始采用 RISC 架构。从 IBM 的 PowerPC,到 SUN 的 SPARC,都是 RISC 架构
微指令架构
从 Pentium Pro 时代开始,Intel 就开始在处理器里引入了微指令(Micro-Instructions/Micro-Ops)架构。
让 CISC 风格的指令集,在译码是翻译成RISC风格,用 RISC 的形式在RISC架构的CPU 里面运行
将CISC指令,转化成多条RISC的微指令执行
在微指令架构里,我们的指令译码器相当于变成了设计模式里的一个“适配器”(Adaptor)。这个适配器,填平了 CISC 和 RISC 之间的指令差异。
用缓存,替代译码器。提升性能
Intel 就在 CPU 里面加了一层 L0 Cache。这个 Cache 保存的就是指令译码器把 CISC 的指令“翻译”成 RISC 的微指令的结果。
于是,在大部分情况下,CPU 都可以从 Cache 里面拿到译码结果,而不需要让译码器去进行实际的译码操作。这样不仅优化了性能,因为译码器的晶体管开关动作变少了,还减少了功耗。
使用“微指令”设计思路的 CPU,不能再称之为 CISC 了,而更像一个 RISC 和 CISC 融合的产物。
ARM
ARM 这个名字现在的含义,是“Advanced RISC Machines”。
你从名字就能够看出来,ARM 的芯片是基于 RISC 架构的
手机端ARM能够战胜Intel的原因
1. 功耗优先
一个 4 核的 Intel i7 的 CPU,设计的时候功率就是 130W。
而一块 ARM A8 的单个核心的 CPU,设计功率只有 2W。两者之间差出了 100 倍。
在移动设备上,功耗是一个远比性能更重要的指标,毕竟我们不能随时在身上带个发电机。
ARM 的 CPU,主频更低,晶体管更少,高速缓存更小,乱序执行的能力更弱。
所有这些,都是为了功耗所做的妥协。
2. 低价
ARM 并没有自己垄断 CPU 的生产和制造,只是进行 CPU 设计,然后把对应的知识产权授权出去,让其他的厂商来生产 ARM 架构的 CPU。
它甚至还允许这些厂商可以基于 ARM 的架构和指令集,设计属于自己的 CPU。像苹果、三星、华为,它们都是拿到了基于 ARM 体系架构设计和制造 CPU 的授权。ARM 自己只是收取对应的专利授权费用。多个厂商之间的竞争,使得 ARM 的芯片在市场上价格很便宜。
所以,尽管 ARM 的芯片的出货量远大于 Intel,但是收入和利润却比不上 Intel。
意在打造“CPU 届的 Linux”的开源ARM架构