不务正业系列6:计算机组成原理笔记
Posted IE06
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了不务正业系列6:计算机组成原理笔记相关的知识,希望对你有一定的参考价值。
目录
原文地址:https://blog.csdn.net/qyf__123/article/list/1
1. 基本结构
1.1 传统结构
三大件:CPU(分为CU和PU,用于计算)、内存(用于存储)和主板(包含chipset和bus,用于通信)
其他部件:输入输出、CPU辅助(显卡等)
以上部件+可编程+可存储 = 现代计算机
1.2 虚拟机
Type-1 类型的虚拟化机,实际的指令不需要再通过宿主机的操作系统,而可以直接通过虚拟机监视器访问硬件,所以性能比 Type-2 要好。而 Type-2 类型的虚拟机,所有的指令需要经历客户机操作系统、虚拟机监视器、宿主机操作系统,所以性能上要慢上不少。不过因为经历了宿主机操作系统的一次 “翻译” 过程,它的硬件兼容性往往会更好一些。
今天,即使是 Type-1 型的虚拟机技术,我们也会觉得有一些性能浪费。我们常常在同一个物理机上,跑上 8 个、10 个的虚拟机。而且这些虚拟机的操作系统,其实都是同一个 Linux Kernel 的版本。于是,轻量级的 Docker 技术就进入了我们的视野。Docker 也被很多人称之为 “操作系统级” 的虚拟机技术。不过 Docker 并没有再单独运行一个客户机的操作系统,而是直接运行在宿主机操作系统的内核之上。所以,Docker 也是现在流行的微服务架构底层的基础设施。
2. CPU概念
2.1 主频
你在买电脑的时候,一定关注过 CPU 的主频。比如我手头的这台电脑就是 Intel Core-i7-7700HQ 2.8GHz,这里的 2.8GHz 就是电脑的主频(Frequency/Clock Rate)。这个 2.8GHz,我们可以先粗浅地认为,CPU 在 1 秒时间内,可以执行的简单指令的数量是 2.8G 条。
目前的主频在这个数量级上不去了,主要原因是功耗。功耗 ~= 1/2 ×负载电容×电压的平方×开关频率×晶体管数量。目前的CPU,功耗在10-100瓦的数量级之间,再高的功耗就撑不住了。
计算机每执行一条指令的过程,可以分解成这样几个步骤:
1.Fetch(取得指令),也就是从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增,好在未来执行下一条指令。
2.Decode(指令译码),也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
3.Execute(执行指令),也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。
CPU 内部的操作速度很快,但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来,所以我们一般把从内存里面读取一条指令的最短时间,称为 CPU 周期。
2.2 计算机指令
举个C的例子:
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
首先需要用编译器(GCC等)进行compile,变成汇编语言(ASM language),然后再Assemble成机器码(Machine code)
使用gcc -g -c test.c进行编译,再使用objdump -d -S test.o查看编码:
test.o: file format Mach-O 64-bit x86-64
Disassembly of section __TEXT,__text:
_main:
; {
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 31 c0 xorl %eax, %eax
; int a = 1;
6: c7 45 fc 01 00 00 00 movl $1, -4(%rbp)
; int b = 1;
d: c7 45 f8 01 00 00 00 movl $1, -8(%rbp)
; a = a + b;
14: 8b 4d fc movl -4(%rbp), %ecx
17: 03 4d f8 addl -8(%rbp), %ecx
1a: 89 4d fc movl %ecx, -4(%rbp)
; }
1d: 5d popq %rbp
1e: c3 retq
左边16进制数字是机器码,右边的pushq、movq是汇编代码。常见的指令如下:
不同的机器的指令集不一样,下图是MIPS指令集:
机器码最后要在CPU上执行,还要通过指令译码器变成一堆二进制信号。
2.3 寄存器:CPU的缓存
逻辑上,我们可以认为CPU是由一堆寄存器构成的。一个 CPU 里面会有很多种不同功能的寄存器。这里介绍三种比较特殊的。
- PC寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。
- 指令寄存器(Instruction Register),用来存放当前正在执行的指令。
- 条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。
一个程序执行的时候,CPU会根据PC寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。而有些特殊指令,比如上一讲我们讲到 J 类指令,也就是跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。
寄存器可以理解为Flip-Flop(触发器)和Latches(锁存器)构成的简单电路。N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。我们一般使用的Intel i7 CPU有16个64位寄存器。
再介绍两个特殊的寄存器,用于内存栈:
RSP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶,是实际进行计算的地方。
RBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
内存栈用于函数调用。函数特殊的地方在于,执行完之后还要返回到原地址,因此需要多用一个寄存器记录返回地址,当函数调用层级比较多的时候,寄存器的个数就会不够用了。因此在内存中单独开辟了一个stack用于记录函数调用的入口地址,每次函数执行完了之后将地址出栈即可。此外,寄存器放不下的函数参数也会放在stack中,合称stack frame,如下图:
每个函数开始前有一个push rbp和mov rbp, rsp代码,就是将待执行的语句(rbp)入栈,把控制权交给函数(rsp)。函数执行完之后,调用pop rbp和ret将控制权又交给了原代码。所谓的stack overflow,就是调用的层级或参数实在太多,内存里面也放不下了。
如果函数只执行一次,那么可以将函数内的代码直接嵌入到原代码中,称为内联。内联带来的优化是,CPU 需要执行的指令数变少了,根据地址跳转的过程不需要了,压栈和出栈的过程也不用了。
3. CU:程序编译、加载和执行过程
3.1 ELF和静态链接库
汇编结束后生成的.o文件并不能直接执行,而是要用链接器将各个分散的目标文件串联起来最终生成一个可执行文件才行。目标文件和可执行文件都是ELF格式,中文名字叫可执行与可链接文件格式,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。
这种合并代码段的方法,就是静态链接(Static Link)。
我们这里讲的是 Linux 下的 ELF 文件格式,而 Windows 的可执行文件格式是一种叫作PE(Portable Executable Format)的文件格式。Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。
如果我们有一个可以能够解析 PE 格式的装载器,我们就有可能在 Linux 下运行 Windows 程序了。这样的程序真的存在吗?没错,Linux 下著名的开源项目 Wine,就是通过兼容 PE 格式的装载器,使得我们能直接在 Linux 下运行 Windows 程序的。而现在微软的 Windows 里面也提供了 WSL,也就是 Windows Subsystem for Linux,可以解析和加载 ELF 格式的文件。
3.2 如何加载到内存中
第一个问题,如何同时将多个程序同时加载到内存中?方法就是使用虚拟内存,对CPU来说起始地址都是一样的。
第二个问题,内存不断加载释放造成的碎片怎么办?方法就是内存交换(Memory Swapping),将程序内存写到硬盘上,然后再从硬盘上读回来到内存里面。
为了减少内存交换的次数,现代计算机使用了内存分页(paging)技术,把整个物理内存空间切成一段段固定尺寸的大小,程序所需要的虚拟内存空间,也同样切成一段段固定尺寸的大小。从虚拟内存到物理内存的映页的尺寸一般远远小于整个程序的大小。你可以通过getconf PAGE_SIZE命令看看你手头的 Linux 系统设置的页的大小。
分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。
3.3 动态链接库
有一些常用的程序,可以供大家共同使用,叫作动态链接(Dynamic Link)。在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。
在动态链接对应的共享库,我们在共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。**虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。**所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。
4. PU:数据处理方法
4.1 加法器
首先,数据在CPU中使用二进制方式存储的。
通过一个异或门计算出个位,通过一个与门计算出是否进位,我们就通过电路算出了一个一位数的加法。于是,我们把两个门电路打包,给它取一个名字,就叫作半加器(Half Adder)。我们用两个半加器和一个或门,就能组合成一个全加器。
有了全加器,我们要进行对应的两个 8 bit 数的加法就很容易了。我们只要把 8 个全加器串联起来就好了。个位的全加器的进位信号作为二位全加器的输入信号,二位全加器的进位信号再作为四位的全加器的进位信号。这样一层层串接八层,我们就得到了一个支持 8 位数加法的算术单元。如果要扩展到 16 位、32 位,乃至 64 位,都只需要多串联几个输入位和全加器就好了。
出于性能考虑,实际 CPU 里面使用的加法器,比起我们今天讲解的电路还有些差别,会更复杂一些。真实的加法器,使用的是一种叫作超前进位加法器的东西。
4.2 乘法器
使用加法器、左移和右移电路就能实现乘法器
这种按照顺序计算的方式符合人脑单线程的方式:
对于计算机来说,可以火力全开,所有位数同时计算:
还可以继续减少延时,也就是增加电路,将一些计算提前做好,能减少树的层数,和超前进位加法器类似。
4.3 数据表示方法
定点数:我们用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表示 8 个这样的整数。然后我们把最右边的 2 个 0~9 的整数,当成小数部分;把左边 6 个 0~9 的整数,当成整数部分。这样,我们就可以用 32 个比特,来表示从 0 到 999999.99 这样 1 亿个实数了。这种用二进制来表示十进制的编码方式,叫作BCD 编码(Binary-Coded Decimal)。其实它的运用非常广泛,最常用的是在超市、银行这样需要用小数记录金额的情况里。在超市里面,我们的小数最多也就到分。这样的表示方式,比较直观清楚,也满足了小数部分的计算。
浮点数:浮点数的科学计数法的表示,有一个IEEE的标准,它定义了两个基本的格式。一个是用 32 比特表示单精度的浮点数,也就是我们常常说的 float 或者 float32 类型。另外一个是用 64 比特表示双精度的浮点数,也就是我们平时说的 double 或者 float64 类型。
首先看float32:
第一部分是一个符号位,用来表示是正数还是负数。
接下来是一个 8 个比特组成的指数位。用 1~254 映射到 -126~127 这 254 个有正有负的数上。
最后,是一个 23 个比特组成的有效数位。
在python中,我们执行0.3+0.6,得到0.8999999
为什么我们用 0.3 + 0.6 不能得到 0.9 呢?这是因为,浮点数没有办法精确表示 0.3、0.6 和 0.9。事实上,我们拿出 0.1~0.9 这 9 个数,其中只有 0.5 能够被精确地表示成二进制的浮点数,也就是 s = 0、e = -1、f = 0 这样的情况。
浮点数的加法遵循先对齐,再相加的原则。
5 CPU电路结构
CPU 所需要的硬件电路
那么,要想搭建出来整个 CPU,我们需要在数字电路层面,实现这样一些功能。
第一,进行计算的ALU ,是一个没有状态的,根据输入计算输出结果的第一个电路。
第二,我们需要有一个能够进行状态读写的电路元件,也就是我们的寄存器。我们需要有一个电路,能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用,但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路,就有锁存器(Latch),以及我们后面要讲的 D 触发器(Data/Delay Flip-flop)的电路。
第三,我们需要有一个“自动”的电路,按照固定的周期,不停地实现 PC 寄存器自增,自动地去执行“Fetch - Decode - Execute“的步骤。我们的程序执行,并不是靠人去拨动开关来执行指令的。我们希望有一个“自动”的电路,不停地去一条条执行指令。
我们看似写了各种复杂的高级程序进行各种函数调用、条件跳转。其实只是修改 PC 寄存器里面的地址。PC 寄存器里面的地址一修改,计算机就可以加载一条指令新指令,往下运行。实际上,PC 寄存器还有一个名字,就叫作程序计数器。顾名思义,就是随着时间变化,不断去数数。数的数字变大了,就去执行一条新指令。所以,我们需要的就是一个自动数数的电路。
第四,我们需要有一个“译码”的电路。无论是对于指令进行 decode,还是对于拿到的内存地址去获取对应的数据或者指令,我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。
5.1 时钟信号
要实现这四种电路中的中间两种,我们还需要时钟电路的配合,称为时序逻辑电路。下图是实现时钟电路的晶体振荡器:
这种电路,其实就相当于把电路的输出信号作为输入信号,再回到当前电路。这样的电路构造方式呢,我们叫作反馈电路(Feedback Circuit)。
5.2 寄存器
一般寄存器是指由基本的RS触发器结构衍生出来的D触发。
首先看RS 触发器电路,这个电路由两个或非门电路组成。这样一个电路,我们称之为触发器(Flip-Flop)。接通开关 R,输出变为 1,即使断开开关,输出还是 1 不变。接通开关 S,输出变为 0,即使断开开关,输出也还是 0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。
上面的电路,输入都断开后,结果不停的再电路里面循环,也就是所谓的存储功能。我们看看下面这个电路,这个在我们的上面的 R-S 触发器基础之上,在 R 和 S 开关之后,加入了两个与门,同时给这两个与门加入了一个时钟信号 CLK作为电路输入。
这样,当时钟信号 CLK 在低电平的时候,与门的输入里有一个 0,两个实际的 R 和 S 后的与门的输出必然是 0。也就是说,无论我们怎么按 R 和 S 的开关,根据 R-S 触发器的真值表,对应的 Q 的输出都不会发生变化。
只有当时钟信号 CLK 在高电平的时候,与门的一个输入是 1,输出结果完全取决于 R 和 S 的开关。我们可以在这个时候,通过开关 R 和 S,来决定对应 Q 的输出。
如果这个时候,我们让 R 和 S 的开关,也用一个反相器连起来,也就是通过同一个开关控制 R 和 S。只要 CLK 信号是 1,R 和 S 就可以设置输出 Q。而当 CLK 信号是 0 的时候,无论 R 和 S 怎么设置,输出信号 Q 是不变的。这样,这个电路就成了我们最常用的 D 型触发器。用来控制 R 和 S 这两个开关的信号呢,我们视作一个输入的数据信号 D,也就是 Data,这就是 D 型触发器的由来。
把 R 和 S 两个信号通过一个反相器合并,我们可以通过一个数据信号 D 进行 Q 的写入操作
一个 D 型触发器,只能控制 1 个比特的读写,但是如果我们同时拿出多个 D 型触发器并列在一起,并且把用同一个 CLK 信号控制作为所有 D 型触发器的开关,这就变成了一个 N 位的 D 型触发器,也就可以同时控制 N 位的读写。
CPU 里面的寄存器可以直接通过 D 型触发器来构造。我们可以在 D 型触发器的基础上,加上更多的开关,来实现清 0 或者全部置为 1 这样的快捷操作。
5.3 计数器
有了时钟信号,我们可以提供定时的输入;有了 D 型触发器,我们可以在时钟信号控制的时间点写入数据。我们把这两个功能组合起来,就可以实现一个自动的计数器了。加法器的两个输入,一个始终设置成 1,另外一个来自于一个 D 型触发器 A。我们把加法器的输出结果,写到这个 D 型触发器 A 里面。于是,D 型触发器里面的数据就会在固定的时钟信号为 1 的时候更新一次。
5.4 译码器
我们来看一看简单的地址译码器。把“寻址”这件事情退化到最简单的情况,就是在两个地址中,去选择一个地址:
我们通过一个反相器、两个与门和一个或门,就可以实现一个选择器。通过控制反相器的输入是 0 还是 1,能够决定对应的输出信号,是和地址 A,还是地址 B 的输入信号一致。64位系统能够选择的地址是 2 6 4 2^64 264个,输出指定位置的信号。
当然,除了寻址译码器,还有其他的译码器,原理类似,结构更复杂。
5.5 合起来
首先,我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增,来作为我们的 PC 寄存器。
在这个自动计数器的后面,我们连上一个译码器。译码器还要同时连着我们通过大量的 D 触发器组成的内存。
自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的 CPU 指令。
读取出来的 CPU 指令会通过我们的 CPU 时钟的控制,写入到一个由 D 触发器组成的寄存器,也就是指令寄存器当中。
在指令寄存器后面,我们可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成 opcode 和对应的操作数。
当我们拿到对应的 opcode 和操作数,对应的输出线路就要连接 ALU,开始进行各种算术和逻辑运算。对应的计算结果,则会再写回到 D 触发器组成的寄存器或者内存当中。
6 现代CPU技术
6.1 流水线技术
一言以蔽之:使用线平衡技术,让流水线的周期时间和节拍时间变短。
流水线之间有缓存时间消耗,因此拆解的工位也不能太多。Pentium 4 之前的 Pentium III CPU,流水线的深度是 11 级,也就是一条指令最多会拆分成 11 个更小的步骤来操作,而 CPU 同时也最多会执行 11 条指令的不同 Stage。随着技术发展到今天,你日常用的手机 ARM 的 CPU 或者 Intel i7 服务器的 CPU,流水线的深度是 14 级。可以看到,差不多 20 年过去了,通过技术进步,现代 CPU 还是增加了一些流水线深度的。那 2000 年发布的 Pentium 4 的流水线深度是多少呢?答案是 20 级,比 Pentium III 差不多多了一倍,而到了代号为 Prescott 的 90 纳米工艺处理器 Pentium 4,Intel 更是把流水线深度增加到了 31 级。事实上,31 个 Stage 的 3GHz 主频的 CPU,其实和 11 个 Stage 的 1GHz 主频的 CPU,性能是差不多的。
6.2 任务调度
将计算任务拆解到计算机的流水线上是一个复杂的排班调度问题(而且常常是一次性的任务),如果优化的不好,流水线工位的空闲时间可能会非常长,这种风险叫做Hazard。下面介绍一下流水线设计需要解决的三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。
-
结构冒险
把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture),来自哈佛大学设计Mark I 型计算机时候的设计。对应的,我们之前说的冯·诺依曼体系结构,又叫作普林斯顿架构(Princeton Architecture)。从这些名字里,我们可以看到,早年的计算机体系结构的设计,其实产生于美国各个高校之间的竞争中。
我们今天使用的电脑在内存上还是冯·诺依曼体系结构的。不过借鉴了哈佛结构的思路,CPU内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
-
数据冒险:三种不同的依赖关系
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW)。
解决这些数据冒险的办法最简单的一个办法就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。NOP指令的插入,就好像一个水管(Pipeline)里面,进了一个空的气泡。在水流经过的时候,没有传送水到下一个步骤,而是给了一个什么都没有的空气泡。这也是为什么,我们的流水线停顿,又被叫作流水线冒泡(Pipeline Bubble)的原因。
一个精巧的解决方案是操作数转发(Operand Forwarding),或者操作数旁路(Operand Bypassing)。转发,其实是这个技术的逻辑含义,也就是在第 1 条指令的执行结果,直接“转发”给了第 2 条指令的 ALU 作为输入。另外一个名字,旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”,我们在 CPU 的硬件里面,需要再单独拉一根信号传输的线路出来,使得 ALU 的计算结果,能够重新回到 ALU 的输入里来。这样的一条线路,就是我们的“旁路”。它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了 2 个时钟周期。
还有一种方法乱序执行(https://en.wikipedia.org/wiki/Tomasulo_algorithm)指令,也就是引入一个线程池,和多线程的思想类似。
- 控制冒险
在条件跳转的情况下,为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是控制冒险(Control Harzard)。这也是流水线设计里最后一种冒险。
第一个办法,叫作缩短分支延迟。在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。
第二种解决方案,叫作分支预测(Branch Prediction)技术,也就是说,让我们的 CPU 来猜一猜,条件跳转后执行的指令,应该是哪一条。如果分支预测失败了,我们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。
第三种动态方法。根据之前条件跳转的比较结果来预测,叫做一级分支预测(One Level Branch Prediction),或者叫1 比特饱和计数(1-bit saturating counter)。这个方法,其实就是用一个比特,去记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。我们还可以用更多的信息,而不只是一次的分支信息来进行预测。于是,我们可以引入一个状态机(State Machine)来做这个事情。2 个比特来记录对应的状态叫作2 比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。
根据这个机制,我们在循环嵌套的时候,如果没有依赖关系,可以考虑把最复杂的循环放在最里面:
这个方法虽然简单,但是却非常有效。在 SPEC 89 版本的测试当中,使用这样的饱和计数方法,预测的准确率能够高达 93.5%。Intel 的 CPU,一直到 Pentium 时代,在还没有使用 MMX 指令集的时候,用的就是这种分支预测方式。
6.3 并行流水线:多发射与超标量
在指令乱序执行的过程中,我们的取指令(IF)和指令译码(ID)部分并不是并行进行的。其实只要我们把取指令和指令译码,也一样通过增加硬件的方式,并行进行就好了。我们可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。这样,我们在一个时钟周期里,能够完成的指令就不只一条了。这种 CPU 设计,我们叫作多发射(Mulitple Issue)和超标量(Superscalar)。
除了从CPU电路层面去做依赖关系的优化,能不能从编译器的角度去做优化呢?著名的 IA-64 架构的安腾(Itanium)处理器就是这样做的,超长指令字设计(Very Long Instruction Word,VLIW)不仅想让编译器来优化指令数,还想直接通过编译器,来优化 CPI。编译器在这个过程中,其实也能够知道前后数据的依赖。于是,我们可以让编译器把没有依赖关系的代码位置进行交换。然后,再把多条连续的指令打包成一个指令包。
然而,安腾处理器和Pentium 4一样,在市场上是一个失败的产品。在经历了12年之久的设计研发之后,安腾一代只卖出了几千套。而安腾二代,在从2002年开始反复挣扎了16年之后,最终在2018年被Intel宣告放弃,退出了市场。下面是一个对比图:
安腾处理器的问题有两个:一方面,安腾处理器的指令集和 x86 是不同的。这就意味着,原来 x86 上的所有程序是没有办法在安腾上运行的,而需要通过编译器重新编译才行;另一方面,安腾处理器的 VLIW 架构决定了,如果安腾需要提升并行度,就需要增加一个指令包里包含的指令数量。一旦这么做了,虽然同样是 VLIW 架构,同样指令集的安腾 CPU,程序也需要重新编译,甚至我们需要重新来写编译器,才能让程序在新的 CPU 上跑起来。总的来说,安腾处理器的前后兼容性都很差。
6.4 超线程
超线程的 CPU,其实是把一个物理层面 CPU 核心,“伪装”成两个逻辑层面的 CPU 核心。这个 CPU,会在硬件层面增加很多电路,使得我们可以在一个 CPU 核心内部,维护两个不同线程的指令的状态信息。
比如,在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。这样,这个 CPU 核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的 CPU 在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术。
不过,在 CPU 的其他功能组件上,Intel 可不会提供双份。无论是指令译码器还是 ALU,一个 CPU 核心仍然只有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。
通常我们只要在 CPU 核心的添加 10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
我们并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。一般是在那些各个线程“等待”时间比较长的应用场景下。比如,我们需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。这个时候,让 CPU 里的各个功能单元,去处理另外一个数据库连接的查询请求就是一个很好的应用案例。
如图,CPU的Cores,被标明了是4,而Threads,则是8。说明这个CPU,只有4个物理的CPU核心,也就是所谓的4核CPU。但是在逻辑层面,它“装作”有8个CPU核心,可以利用超线程技术,来同时运行8条指令。
6.5 SIMD:加速矩阵乘法
我们来看下面这段代码
>>> import numpy as np
>>> import timeit
>>> a = list(range(1000))
>>> b = np.array(range(1000))
>>> timeit.timeit("[i + 1 for i in a]", setup="from __main__ import a", number=1000000)
32.82800309999993
>>> timeit.timeit("np.add(1, b)", setup="from __main__ import np, b", number=1000000)
0.9787889999997788
前面使用循环来一步一步计算的算法呢,一般被称为SISD,也就是单指令单数据(Single Instruction Single Data)的处理方式。如果你手头的是一个多核 CPU 呢,那么它同时处理多个指令的方式可以叫作MIMD,也就是多指令多数据(Multiple Instruction Multiple Data)。NumPy 直接用到了 SIMD 指令,能够并行进行向量的操作。SIMD,中文叫作单指令多数据流(Single Instruction Multiple Data)数据读取和计算可以并行来做。
就以我们上面的程序为例,数组里面的每一项都是一个 integer,也就是需要 4 Bytes 的内存空间。Intel 在引入 SSE 指令集的时候,在 CPU 里面添上了 8 个 128 Bits 的寄存器。128 Bits 也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取 4 次对应的数据,时间就省下来了。
在数据读取到了之后,在指令的执行层面,SIMD 也是可以并行进行的。4 个整数各自加 1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要 CPU 里有足够多的功能单元,能够同时进行这些计算,这个加法就是 4 路同时并行的,自然也省下了时间。
所以,对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用 SIMD 是一个很划算的办法。在这个大量的“数据并行”,其实通常就是实践当中的向量运算或者矩阵运算。在实际的程序开发过程中,过去通常是在进行图片、视频、音频的处理。最近几年则通常是在进行各种机器学习算法的计算。
基于 SIMD 的向量计算指令,也正是在 Intel 发布 Pentium 处理器的时候,被引入的指令集。当时的指令集叫作MMX,也就是 Matrix Math eXtensions 的缩写,中文名字就是矩阵数学扩展。而 Pentium 处理器,也是 CPU 第一次有能力进行多媒体处理。这也正是拜 SIMD 和 MMX 所赐。
从 Pentium 时代开始,我们能在电脑上听 MP3、看 VCD 了,而不用专门去买一块“声霸卡”或者“显霸卡”了。没错,在那之前,在电脑上看 VCD,是需要专门买能够解码 VCD 的硬件插到电脑上去的。而到了今天,通过 GPU 快速发展起来的深度学习技术,也一样受益于 SIMD 这样的指令级并行方案。
7 异常处理
计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number)。有些教科书会把异常代码叫作中断向量(Interrupt Vector)。异常发生的时候,通常是 CPU 检测到了一个特殊的信号。比如,你按下键盘上的按键,输入设备就会给 CPU 发一个信号。或者,正在执行的指令发生了加法溢出,同样,我们可以有一个进位溢出的信号。这些信号呢,在组成原理里面,我们一般叫作发生了一个事件(Event)。CPU 在检测到事件的时候,其实也就拿到了对应的异常代码。
这些异常代码里,I/O 发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由 CPU 预先分配好的,也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。
拿到异常代码之后,CPU 就会触发异常处理的流程。计算机在内存里,会保留一个异常表(Exception Table)。也有地方,把这个表叫作中断向量表(Interrupt Vector Table),好和上面的中断向量对应起来。这个异常表有点儿像我们在第 10 讲里讲的 GOT 表,存放的是不同的异常代码对应的异常处理程序(Exception Handler)所在的地址。
我们的 CPU 在拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。
故障的分类:
在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以我们称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以我们称之为“同步“类型的异常。
在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,我们都是采用同一套处理流程,也就是上面所说的,“保存现场、异常代码查询、异常处理程序调用“。而中止类型的异常,其实是在故障类型异常的一种特殊情况。当故障发生,但是我们发现没有异常处理程序能够处理这种异常的情况下,程序就不得不进入中止状态,也就是最终会退出当前的程序执行。对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在 CPU 层面的切换,所以这个过程我们称之为上下文切换(Context Switch)。
8 ARM架构CPU
8.1 X86
x86是一个intel通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集合,X与处理器没有任何关系,它是一个对所有*86系统的简单的通配符定义,例如:i386, 586,奔腾(pentium)。由于早期intel的CPU编号都是如8086,80286来编号,由于这整个系列的CPU都是指令兼容的,所以都用X86来标识所使用的指令集合如今的奔腾,P2,P4,赛扬系列都是支持X86指令系统的,所以都属于X86家族 。
X86指令集是美国Intel公司为其第一块16位CPU(i8086)专门开发的,美国IBM公司1981年推出的世界第一台PC机中的CPU–i8088(i8086简化版)使用的也是X86指令,同时电脑中为提高浮点数据处理能力而增加的X87芯片系列数学协处理器则另外使用X87指令,以后就将X86指令集和X87指令集统称为X86指令集。虽然随着CPU技术的不断发展,Intel陆续研制出更新型的i80386、i80486直到今天的Pentium 4(以下简为P4)系列,但为了保证电脑能继续运行以往开发的各类应用程序以保护和继承丰富的软件资源,所以Intel公司所生产的所有CPU仍然继续使用X86指令集,所以它的CPU仍属于X86系列。
另外除Intel公司之外,AMD和Cyrix等厂家也相继生产出能使用X86指令集的CPU,由于这些CPU能运行所有的为Intel CPU所开发的各种软件,所以电脑业内人士就将这些CPU列为Intel的CPU兼容产品。由于Intel X86系列及其兼容CPU都使用X86指令集,所以就形成了今天庞大的X86系列及兼容CPU阵容。当然在台式(便携式)电脑中并不都是使用X86系列CPU,部分服务器和苹果(Macintosh)机中还使用美国DIGITAL(数字)公司的Alpha 61164和PowerPC 604e系列CPU。
Intel从8086开始,286、386、486、586、P1、P2、P3、P4都用的同一种CPU架构,统称X86。
8.2 CISC和RISC
RISC 架构的 CPU 的想法其实非常直观。既然我们 80% 的时间都在用 20% 的简单指令,那我们能不能只要那 20% 的简单指令就好了呢?答案当然是可以的。因为指令数量多,计算机科学家们在软硬件两方面都受到了很多挑战。
在硬件层面,我们要想支持更多的复杂指令,CPU 里面的电路就要更复杂,设计起来也就更困难。更复杂的电路,在散热和功耗层面,也会带来更大的挑战。在软件层面,支持更多的复杂指令,编译器的优化就变得更困难。毕竟,面向 2000 个指令来优化编译器和面向 500 个指令来优化编译器的困难是完全不同的。
于是,在 RISC 架构里面,CPU 选择把指令“精简”到 20% 的简单指令。而原先的复杂指令,则通过用简单指令组合起来来实现,让软件来实现硬件的功能。这样,CPU 的整个硬件设计就会变得更简单了,在硬件层面提升性能也会变得更容易了。
RISC 的 CPU 里完成指令的电路变得简单了,于是也就腾出了更多的空间。这个空间,常常被拿来放通用寄存器。因为 RISC 完成同样的功能,执行的指令数量要比 CISC 多,所以,如果需要反复从内存里面读取指令或者数据到寄存器里来,那么很多时间就会花在访问内存上。于是,RISC 架构的 CPU 往往就有更多的通用寄存器。
除了寄存器这样的存储空间,RISC 的 CPU 也可以把更多的晶体管,用来实现更好的分支预测等相关功能,进一步去提升 CPU 实际的执行效率。程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time
CISC 的架构,其实就是通过优化指令数,来减少 CPU 的执行时间。而 RISC 的架构,其实是在优化 CPI。因为指令比较简单,需要的时钟周期就比较少。
Intel基于32位系统x86指令集的CPU非常成功,导致后面想要修改CPU架构非常困难。AMD趁着Intel研发安腾的时候,推出了兼容32 位x86指令集的64位架构,也就是AMD64。为了和AMD展开竞争,Intel也在2004年推出了自己的64位版x86,也就是EM64T。
8.3 微指令架构
从 Pentium Pro 时代开始,Intel 就开始在处理器里引入了微指令(Micro-Instructions/Micro-Ops)架构。而微指令架构的引入,也让 CISC 和 RISC 的分界变得模糊了。在微指令架构的 CPU 里面,编译器编译出来的机器码和汇编代码并没有发生什么变化。但在指令译码的阶段,指令译码器“翻译”出来的,不再是某一条 CPU 指令。译码器会把一条机器码,“翻译”成好几条“微指令”。这里的一条条微指令,就不再是 CISC 风格的了,而是变成了固定长度的 RISC 风格的了。
这些 RISC 风格的微指令,会被放到一个微指令缓冲区里面,然后再从缓冲区里面,分发给到后面的超标量,并且是乱序执行的流水线架构里面。不过这个流水线架构里面接受的,就不是复杂的指令,而是精简的指令了。在这个架构里,我们的指令译码器相当于变成了设计模式里的一个“适配器”(Adaptor)。这个适配器,填平了 CISC 和 RISC 之间的指令差异。
Cache里面保存的是指令译码器把CISC的指令“翻译”成RISC的微指令的结果。于是,在大部分情况下,CPU 都可以从 Cache 里面拿到译码结果,而不需要让译码器去进行实际的译码操作。这样不仅优化了性能,因为译码器的晶体管开关动作变少了,还减少了功耗。不过x86的CPU始终在功耗上还是远远落后于RISC架构的ARM,所以最终在智能手机崛起替代PC的时代,落在了ARM后面。Intel 除了 x86 和安腾之外,还推出过 Atom 这个面向移动设备的低功耗 CPU。与Centrino迅驰一样,Intel也给Atom处理器取了一个好听的中国名字,叫“凌动”。而与之搭配的Menlow平台则被改称为“迅驰凌动(Centrino Atom)”。 atom也是一波三折,在2016年时因为ARM发展太过迅速,宣布将停止开发低端的ATOM处理器, 后来貌似又起死复活,打算用在物联网等地方。
8.4 ARM
ARM 这个名字现在的含义,是“Advanced RISC Machines”。你从名字就能够看出来,ARM 的芯片是基于 RISC 架构的。不过,ARM 能够在移动端战胜 Intel,并不是因为 RISC 架构。ARM 真正能够战胜 Intel,我觉得主要是因为下面这两点原因。
第一点是功耗优先的设计。一个 4 核的 Intel i7 的 CPU,设计的时候功率就是 130W。而一块 ARM A8 的单个核心的 CPU,设计功率只有 2W。两者之间差出了 100 倍。在移动设备上,功耗是一个远比性能更重要的指标,毕竟我们不能随时在身上带个发电机。ARM 的 CPU,主频更低,晶体管更少,高速缓存更小,乱序执行的能力更弱。所有这些,都是为了功耗所做的妥协。
第二点则是低价。ARM 并没有自己垄断 CPU 的生产和制造,只是进行 CPU 设计,然后把对应的知识产权授权出去,让其他的厂商来生产 ARM 架构的 CPU。它甚至还允许这些厂商可以基于 ARM 的架构和指令集,设计属于自己的 CPU。像苹果、三星、华为,它们都是拿到了基于 ARM 体系架构设计和制造 CPU 的授权。ARM 自己只是收取对应的专利授权费用。多个厂商之间的竞争,使得 ARM 的芯片在市场上价格很便宜。所以,尽管 ARM 的芯片的出货量远大于 Intel,但是收入和利润却比不上 Intel。
不过,ARM 并不是开源的。所以,在 ARM 架构逐渐垄断移动端芯片市场的时候,“开源硬件”也慢慢发展起来了。一方面,MIPS 在 2019 年宣布开源;另一方面,从 UC Berkeley 发起的RISC-V项目也越来越受到大家的关注。而 RISC 概念的发明人,图灵奖的得主大卫·帕特森教授从伯克利退休之后,成了 RISC-V 国际开源实验室的负责人,开始推动 RISC-V 这个“CPU 届的 Linux”的开发。可以想见,未来的开源 CPU,也多半会像 Linux 一样,逐渐成为一个业界的主流选择。如果想要“打造一个属于自己 CPU”,不可不关注这个项目。
9 辅助处理器:GPU、FPGA和ASIC
9.1 GPU处理图形的原理
3D游戏的图形是用多边形建模创建出来的,物体的移动、动作,乃至根据光线发生的变化,都是通过计算机根据图形学的各种计算,实时渲染出来的。
这个对于图像进行实时渲染的过程,可以被分解成下面这样 5 个步骤:
1 顶点处理(Vertex Processing)
把物体顶点在三维空间里面的位置,转化到屏幕这个二维空间里面。这个转换的操作,就被叫作顶点处理。这样的转化都是通过线性代数的计算来进行的。可以想见,我们的建模越精细,需要转换的顶点数量就越多,计算量就越大。而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的。
2 图元处理(Primitive Processing)
在顶点处理完成之后呢,我们需要开始进行第二步,也就是图元处理。图元处理,其实就是要把顶点处理完成之后的各个顶点连起来,变成多边形。其实转化后的顶点,仍然是在一个三维空间里,只是第三维的 Z 轴,是正对屏幕的“深度”。所以我们针对这些多边形,需要做一个操作,叫剔除和裁剪(Cull and Clip),也就是把不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。
3 栅格化(Rasterization)
在图元处理完成之后呢,渲染还远远没有完成。我们的屏幕分辨率是有限的。它一般是通过一个个“像素(Pixel)”来显示出内容的。所以,对于做完图元处理的多边形,我们要开始进行第三步操作。这个操作就是把它们转换成屏幕里面的一个个像素点。这个操作呢,就叫作栅格化。这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。
4 片段处理(Fragment Processing)
在栅格化变成了像素点之后,我们的图还是“黑白”的。我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。
5 像素操作(Pixel Operations)
最后一步呢,我们就要把不同的多边形的像素点“混合(Blending)”到一起。可能前面的多边形可能是半透明的,那么前后的颜色就要混合在一起变成一个新的颜色;或者前面的多边形遮挡住了后面的多边形,那么我们只要显示前面多边形的颜色就好了。最终,输出到显示设备。
在上世纪 90 年代的时候,屏幕的分辨率还没有现在那么高。一般的 CRT 显示器也就是 640×480 的分辨率。这意味着屏幕上有 30 万个像素需要渲染。为了让我们的眼睛看到画面不晕眩,我们希望画面能有 60 帧。于是,每秒我
以上是关于不务正业系列6:计算机组成原理笔记的主要内容,如果未能解决你的问题,请参考以下文章