处理器
指令周期
计算机每执行一条指令的过程,可以分解成这样几个步骤,然后一直重复
- Fetch(取得指令):也就是从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增,好在未来执行下一条指令。
- Decode(指令译码):也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
- Execute(执行指令):也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。
这样一个周期就是指令周期。从拿到,到执行结束。其中指令周期中不同部分由计算机不同部分执行。
- 存储器:取指令的阶段,指令是放在存储器里的。
- 控制器:通过 PC 寄存器和指令寄存器取出指令的过,是由控制器操作的。指令的解码过程,也是由控制器进行的。
- 运算器:一旦到了执行指令阶段,无论是进行算术操作、逻辑操作的 R 型指令,还是进行数据传输、条件分支的 I 型型指令,都是由算术逻辑单元(ALU)操作的。如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成,不需要用到运算器。
CPU 周期:一般把从内存里面读取一条指令的最短时间称为 CPU 周期。
一个 CPU 周期,通常会由几个时钟周期累积起来。一个 CPU 周期的时间,就是这几个 Clock Cycle 的总和。 对于一个指令周期来说,取出一条指令,然后执行它,至少需要两个 CPU 周期。取出指令至少需要一个 CPU 周期,执行至少也需要一个 CPU 周期,复杂的指令则需要更多的 CPU 周期。
指令周期,CPU 周期,时钟周期的关系:
处理器由两类原件组成:
- 操作原件:ALU
- 存储原件:寄存器
所有 CPU 支持的指令,都会在控制器里面,被解析成不同的输出信号。现在的 Intel CPU 支持 2000 个以上的指令。则控制器输出的控制信号对应的至少也有 2000 种不同的组合。
运算器里的 ALU 和各种组合逻辑电路,可以认为是一个固定功能的电路。控制器“翻译”出来的,就是不同的控制信号。这些控制信号,告诉 ALU 去做不同的计算。可以说正是控制器的存在,让我们可以“编程”来实现功能。
指令译码器将输入的机器码,解析成不同的操作码和操作数,然后传输给 ALU 进行计算。
CPU 所需要的硬件电路
- ALU:加法器,乘法器等。
- 寄存器:锁存器,D 触发器
- 能引起 PC 寄存器一直自增的计数器电路。
- 编码及寻址的译码器电路:无论是对于指令进行 decode,还是对于拿到的内存地址去获取对应的数据或者指令,我们都需要通过一个电路找到对应的数据。
这样 CPU 好像一个永不停歇的机器,一直在不停地读取下一条指令运行。那为什么CPU 还会有满载运行和 Idle 闲置的状态呢?
- CPU在空闲状态就会停止执行,具体来说就是切断时钟信号,CPU的主频就会瞬间降低为0,功耗也会瞬间降低为0。由于这个空闲状态是十分短暂的,所以你在任务管理器里面也只会看到CPU频率下降,不会看到降低为0。当CPU从空闲状态中恢复时,就会接通时钟信号,这样CPU频率就会上升。所以你会在任务管理器里面看到CPU的频率起伏变化。
- 操作系统内核有 idle 进程,优先级最低,仅当其他进程都阻塞时被调度器选中。idle 进程循环执行 HLT 指令,关闭 CPU 大部分功能以降低功耗,收到中断信号时 CPU 恢复正常状态。
时序逻辑电路
时序逻辑电路可以解决的问题:
- 自动运行:时序电路接通之后可以不停地开启和关闭开关,进入一个自动运行的状态。-> PC 寄存器
- 存储:通过时序电路实现的触发器,能把计算结果存储在特定的电路里面,而不是像组合逻辑电路那样,固定的输入输出。一旦输入有任何改变,对应的输出也会改变。
CPU 的主频是由一个类似晶体振荡器的电路来实现的,而这个晶体振荡器生成的电路信号,就是时钟信号。
实现这样一个电路只需如图这样一个电路即可:
开关 A 闭合(也就是相当于接通电路之后),开关 B 就会不就会不停地在开和关之间切换,生成对应的时钟信号。这个不断切换的过程,对于下游电路来说,就是不断地产生新的 0 和 1 这样的信号。这个按照固定的周期不断在 0 和 1 之间切换的信号,就是时钟信号。
有了时钟信号,利用这样一个电路,就可以构造出有记忆功能的电路,如寄存器和存储器。如图RS-触发器电路:
接通开关 R,输出变为 1,即使断开开关,输出还是 1 不变。接通开关 S,输出变为 0,即使断开开关,输出也还是 0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。 其中当两个开关都为 0 的时候,对应的输出不是 1 或者 0,而是和 Q 的上一个状态一致。
通过引入了时序电路,数据就可以“存储”下来了:通过反馈电路,创建了时钟信号,然后再利用这个时钟信号和门电路组合,实现了“状态记忆”的功能。 电路的输出信号不单单取决于当前的输入信号,还要取决于输出信号之前的状态。最常见的这个电路就是 D 触发器,它也是实际在 CPU 内实现存储功能的寄存器的实现方式。
单指令周期处理器
有了时钟信号和D触发器,就有了一个每过一个时钟周期,就会固定自增 1 的程序计数器即PC寄存器。
加法器的两个输入,一个始终设置成 1,另外一个来自于一个 D 型触发器 A。我们把加法器的输出结果,写到这个 D 型触发器 A 里面。于是,D 型触发器里面的数据就会在固定的时钟信号为 1 的时候更新一次。如图:
加法计数、内存取值,乃至后面的命令执行,最终其实都是由时钟信号,来控制执行时间点和先后顺序的。
在最简单的情况下,我们需要让每一条指令,从程序计数,到获取指令、执行指令,都在一个时钟周期内完成。 因为如果 PC 寄存器自增地太快,程序就会出错。因为前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取寄存器里面的数据来做下一次计算了。这个时候,如果我们的指令使用同样的寄存器,前一条指令的计算就会没有效果,计算结果就错了。
在这种设计下,就需要在一个时钟周期里,确保执行完一条最复杂的 CPU 指令,也就是耗时最长的一条 CPU指令。这样的 CPU 设计就是单指令周期处理器。 这样的设计有点儿浪费。因为即便只调用一条非常简单的指令,整个处理器也需要等待整个时钟周期的时间走完,才能执行下一条指令。-> 通过流水线技术进行性能优化,可以减少需要等待的时间
其实译码器的本质,就是从输入的多个位的信号中,根据一定的开关和电路组合,选择出自己想要的信号。除了能够进行“寻址”之外,还可以把对应的需要运行的指令码,同样通过译码器,找出我们期望执行的指令,也就是在之前的 opcode,以及后面对应的操作数或者寄存器地址。
- 首先,有一个自动计数器。这个自动计数器会随着时钟主频不断地自增,来作为 PC 寄存器。
- 在这个自动计数器的后面,连上一个译码器。译码器还要同时连着我们通过大量的 D 触发器组成的内存。
- 自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的 CPU 指令。
- 读取出来的 CPU 指令会通过我们的 CPU 时钟的控制,写入到一个由 D 触发器组成的寄存器,也就是指令寄存器当中。
- 在指令寄存器后面,我们可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成 opcode 和对应的操作数。
- 当我们拿到对应的 opcode 和操作数,对应的输出线路就要连接 ALU,开始进行各种算术和逻辑运算。对应的计算结果,则会再写回到 D 触发器组成的寄存器或者内存当中。
从上述指令的执行流程可以看出,我们可以在一个时钟周期里面,去自增 PC 寄存器的值,也就是指令对应的内存地址。然后,我们要根据这个地址从 D 触发器里面读取指令,这个还是可以在刚才那个时钟周期内。但是对应的指令写入到指令寄存器,我们可以放在一个新的时钟周期里面。指令译码给到 ALU 之后的计算结果,要写回到寄存器,又可以放到另一个新的时钟周期。所以,执行一条计算机指令,其实可以拆分到很多个时钟周期,而不是必须使用单指令周期处理器的设计。
因为从内存里面读取指令时间很长,所以如果使用单指令周期处理器,就意味着我们的指令都要去等待一些慢速的操作。这些不同指令执行速度的差异,也正是计算机指令有指令周期、CPU 周期和时钟周期之分的原因。因此,现代我们优化 CPU 的性能时,用的 CPU 都不是单指令周期处理器,而是通过流水线、分支预测等技术,来实现在一个周期里同时执行多个指令。
面向流水线的指令设计
不同指令的执行时间不同,随着门电路层数的增加,由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长。但是我们需要让所有指令都在一个时钟周期内完成,如单指令周期处理器那样,那就只好把时钟周期和执行时间最长的那个指令设成一样。也就意味着快速执行完成的指令,需要等待满一个时钟周期,才能执行下一条指令,如图:
由此因为复杂指令执行时间的制约,处理器的时钟频率就没法太高。因为太高的话,有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来,开始执行下一条指令的时候,前一条指令的执行结果可能还没有写入到寄存器里面。那下一条指令读取的数据就是不准确的,就会出现错误。 如图,超时写入即无效,这次改动相当于丢失了。
因为 CPU 的指令执行过程,其实也是由各个电路模块组成的。这些都是一个一个独立的组合逻辑电路,完全可以把他们进行拆分。如下:
这样一来,就不用把时钟周期设置成整条指令执行的时间,而是拆分成完成这样的一个一个小步骤需要的时间。同时,每一个阶段的电路在完成对应的任务之后,也不需要等待整个指令执行完成,而是可以直接执行下一条指令的对应阶段。
如果我们把一个指令拆分成“取指令 - 指令译码 - 执行指令”这样三个部分,那这就是一个三级的流水线。如果我们进一步把“执行指令”拆分成“ALU 计算(指令执行)- 内存访问 - 数据写回”,那么它就会变成一个五级的流水线。
五级的流水线,就表示我们在同一个时钟周期里面,同时运行五条指令的不同阶段。这个时候,虽然执行一条指令的时钟周期变成了 5,但是我们可以把 CPU 的主频提得更高了。我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的==流水线级==的操作,在一个时钟周期内完成就好了。
如果某一个操作步骤的时间太长,我们就可以考虑把这个步骤,拆分成更多的步骤,让所有步骤需要执行的时间尽量都差不多长。 这样,在通过同时在执行多条指令的不同阶段,我们提升了 CPU 的“吞吐率”。在外部看来,CPU 好像是“一心多用”,在同一时间,同时执行 5 条不同指令的不同阶段。在 CPU 内部,其实它就像生产线一样,不同分工的组件不断处理上游传递下来的内容,而不需要等待单件商品生产完成之后,再启动下一件商品的生产过程。
需要注意的是:
增加流水线深度,在同主频下,其实是降低了 CPU 的性能。因为一个 Pipeline Stage,就需要一个时钟周期。那么我们把任务拆分成 31 个阶段,就需要 31 个时钟周期才能完成一个任务;而把任务拆分成 11 个阶段,就只需要 11 个时钟周期就能完成任务。在这种情况下,31 个 Stage 的 3GHz 主频的 CPU,其实和 11 个 Stage 的 1GHz 主频的 CPU,性能是差不多的。事实上,因为每个 Stage 都需要有对应的 Pipeline 寄存器的开销,这个时候,更深的流水线性能可能还会更差一些。
多级流水线技术的挑战:
- 功耗问题:由于提升了流水线深度,所以为了保持和原来相同的性能,CPU主频也要同步提升到对应的频率。同时,由于流水线深度的增加,我们需要的电路数量变多了,也就是我们所使用的晶体管也就变多了。主频的提升和晶体管数量的增加都使得我们 CPU 的功耗变大了。
- 指令依赖问题:流水线技术带来的性能提升,是一个理想情况。在实际的程序执行中,并不一定能够做得到。 因为会有计算机组成里所说的“冒险”问题。比如数据冒险,结构冒险、控制冒险等其他的依赖问题。如下代码段,三条指令间相互依赖,流水线越长,这个冒险的问题就越难一解决。因为,同一时间同时在运行的指令太多了。如果只有 3 级流水线,我们可以把后面没有依赖关系的指令放到前面来执行。但如果我们有 20 级流水线,意味着我们要确保这 20 条指令之间没有依赖关系。这个挑战一下子就变大了很多。毕竟我们平时撰写程序,通常前后的代码都是有一定的依赖关系的,几十条没有依赖关系的,几十条没有依赖关系的指令可不好找。
数据冒险:
int a = 10 + 5; // 指令 1
int b = a * 2; // 指令 2
float c = b * 1.0f; // 指令 3
冒险和预测
CPU 流水线设计需要解决的三大冒险:结构冒险,数据冒险,控制冒险。
结构冒险
结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。CPU 在同一个时钟周期,同时在运行两条计算机指令的不同阶段,可能会用到同样的硬件电路。
如图:在第 1 条指令执行到访存(MEM)阶段的时候,流水线里的第 4 条指令,在执行取指令(Fetch)的操作。访存和取指令,都要进行内存数据的读取。我们的内存,只有一个地址译码器的作为地址输入,那就只能在一个时钟周期里面读取一条数据,没办法同时执行第 1 条指令的读取内存数据和第 4 条指令的读取指令代码。
解决办法:本质上就是增加资源,对于访问内存数据和取指令的冲突,一个直观的解决方案就是把我们的内存分成两部分,让它们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。 这样把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构。
但如果如上面这样拆的话对程序指令和数据需要的内存空间,我们就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题,但是也失去了灵活性。现代 CPU 架构,借鉴了哈佛架构,在高速缓存层面拆分成指令缓存和数据缓存。
内存的访问速度远比 CPU 的速度要慢,所以现代的 CPU 并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中,这样后续的访问都是访问高速缓存。 而指令缓存和数据缓存的拆分,使得我们的 CPU 在进行数据访问和取指令的时候,不会再发生资源冲突的问题了。
数据冒险
数据冒险:它是程序逻辑层面的事情,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,可以分成三大类,分别是先写后读,先读后写,和写后再写。
先写后读案例:
int main() {
int a = 1;
int b = 2;
a = a + 2;
b = a + 3;
}
===============================================================
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + 2;
12: 83 45 fc 02 add DWORD PTR [rbp-0x4],0x2 把 0x2 添加到 rbp-0x4 对应的内存地址里面。
b = a + 3;
16: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 从 rbp-0x4 这个内存地址里面,把数据写入到 eax 这个寄存器里面。
19: 83 c0 03 add eax,0x3 把 0x3 添加到 eax 对应的寄存器上。
1c: 89 45 f8 mov DWORD PTR [rbp-0x8],eax 从 eax 寄存器中,把数据写入到 [rbp-0x8] 这个内存地址中。
}
1f: 5d pop rbp
20: c3 ret
所以,我们需要保证,在内存地址为 16 的指令读取 rbp-0x4 里面的值之前,内存地址 12 的指令写入到 rbp-0x4 的操作必须完成。这就是先写后读所面临的数据依赖。
先读后写案例:
int main() {
int a = 1;
int b = 2;
a = b + a;
b = a + b;
}
===============================================================
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = b + a;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8] 把内存地址为 [rbp-0x8] 的数据写入到寄存器 eax 中。
15: 01 45 fc add DWORD PTR [rbp-0x4],eax 把 eax 寄存器里面的值读出来,再加到 rbp-0x4 的内存地址里。[rbp-0x4] 地址的数据值变了
b = a + b;
18: 8b 45 fc mov eax,DWORD PTR [rbp-0x4] 把内存地址为 [rbp-0x4] 的数据写入更新到 eax 这个寄存器中。
1b: 01 45 f8 add DWORD PTR [rbp-0x8],eax 把寄存器 eax 中的值读出来,加到内存地址为 [rbp-0x8] 的地址处。
}
1e: 5d pop rbp
1f: c3 ret
如果我们在内存地址 18 的 eax 的写入先完成了,在内存地址为 15 的代码里面取出 eax 才发生,我们的程序计算就会出错。第 15 行执行的逻辑便不是 b+a
而变成了 a+a
。
写后再写案例:
int main() {
int a = 1;
a = 2;
}
===============================================================
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
a = 2;
b: c7 45 fc 02 00 00 00 mov DWORD PTR [rbp-0x4],0x2
}
如果内存地址 b 的指令在内存地址 4 的指令之后写入。那么这些指令完成之后,rbp-0x4 里的数据就是错误的。
流水线停顿(流水线冒泡)
如果我们发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“再等等”。我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。这样就能够判断出来,这个指令是否会触发数据冒险。 如果会触发数据冒险,由于时钟信号会不停地在 0 和 1 之间自动切换,我们无法让流水线真的停下来等等。在执行后面的操作步骤前面,插入一个 NOP 操作,
也就是执行一个其实什么都不干的操作。可见,这也是一个牺牲CPU性能的方法,最差的情况下,流水线架构的 CPU,又会退化成单指令周期的 CPU 了。
在实践当中,各个指令不需要的阶段,并不会直接跳过,而是会运行一次 NOP 操作。通过插入一个 NOP 操作,我们可以使后一条指令的每一个 Stage,一定不和前一条指令的同 Stage 在一个时钟周期执行。这样,就不会发生先后两个指令,在同一时钟周期竞争相同的资源,产生结构冒险了。
操作数前推
通过 NOP 操作进行对齐,我们在流水线里,就不会遇到资源竞争产生的结构冒险问题了。-> 指令,数据译码可以分析出,然后对应产生资源竞争的后置位的流水线周期中添加 NOP 操作。
插入过多的 NOP 操作,意味着我们的 CPU 总是在空转,所以应该尽量减少一些 NOP 操作的插入。
案例:
add $t0, $s2,$s1
add $s2, $s1,$t0
- 第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到t0 这个寄存器里面。
- 第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到s2 这个寄存器里面。
这样一段代码可以分析出,在执行阶段会遇到数据依赖类型的冒险。即后一条指令的执行依赖前一条指令的数据写会阶段的执行结果。 根据流水线停顿方案的处理,我们需要在第二条指令的译码阶段之后,插入对应的 NOP 指令,直到前一条指令的数据写回完成之后,才能继续执行。这样的弊端是浪费了两个时钟周期 相当于第二条指令多执行了两个时钟周期来运行两次空转的 NOP 操作。
操作数前推:第一条指令的执行结果,能够直接传输给第二条指令的执行阶段作为输入,那第二条指令,就不用再从寄存器里面,把数据再单独读出来一次,才来执行代码。即我们在第一条指令的执行阶段完成之后,直接将结果数据传输给到下一条指令的 ALU。然后,下一条指令不需要再插入两个 NOP 阶段,就可以继续正常走到执行阶段。
需要注意的是:有的时候,虽然我们可以把操作数转发到下一条指令,但是下一条指令的执行阶段,需要在该条指令访存阶段完成之后,才能进行。 如下,LOAD 指令在访存阶段才能把数据读取出来,所以下一条指令的执行阶段,需要在访存阶段完成之后,才能进行。
乱序执行
因为如上图,即便操作数前推也会遇到不得不停下整个流水线等待前置指令仿存结束。还是需要整个流水线执行一次 NOP 操作。但问题在于,后续没有数据依赖的指令,也跟着前面的指令一起无谓的等待了。可见无论流水线停顿还是操作数前推,归根结底还是只要前置的指令特定的流水线阶段还没有执行完成,后面的指令就会被“阻塞”住。
乱序执行:在流水线里,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行。
案例:
a = b + c
d = a * e
x = y * z
对于流水线停顿和操作数前推,计算里面的 x ,却要等待 a 和 d 都计算完成,实在没啥必要。所以我们完全可以在 d 的计算等待 a 的计算的过程中,先把 x 的结果计算出来,不要让 CPU 空转。
乱序执行的方法,在第二条指令等待第一条指令的访存阶段的时候,第三条指令已经执行完成。
引入乱序执行后的 CPU 流水线:
- 在取指令和指令译码的时候,乱序执行的 CPU 和其他使用流水线架构的 CPU 是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
- 在指令译码完成之后,就不一样了。CPU 不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站 的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车站的指令,就像是一列列的火车。
- 这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
- 一旦指令依赖的数据来齐了,指令及数据就可以打包并交到后面的功能单元,其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。
- 指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区的地方。
- 在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。 只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
- 实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区里面,最终才会写入到高速缓存和内存里。
乱序执行,只有 CPU 内部指令的执行层面,可能是“乱序”的。关键在于指令的译码阶段正确地分析出指令之间的数据依赖关系,来保证乱序执行在互相没有数据依赖影响的指令之间发生。
所以案例代码的执行顺序应该是:x -> a -> d。或者 a,x -> d。在整个计算过程中,负责乘法的功能单元都没有闲置。执行完 x 之后等来了 d,接着运行 d。
整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
乱序执行,极大地提高了 CPU 的运行效率。核心原因是,现代 CPU 的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU 不得不加入 NOP 操作进行空转。而现代 CPU 的流水线级数也已经相对比较深了,到达了 14 级。这也意味着,同一个时钟周期内并行执行的指令数是很多的。
而乱序执行,以及高速缓存,弥补了 CPU 和内存之间的性能差异。同样,也充分利用了较深的流水行带来的并发性,使得我们可以充分利用 CPU 的性能。
为什么要保障内存访问的顺序呢?在前后执行的指令没有相关数据依赖的情况下,为什么我们仍然要求这个顺序呢?
数据从cpu --> 寄存器 --> 内存,数据从CPU到内存中间有个寄存器,寄存器和内存数据交换应该也是整页交换,如果不顺序写回寄存器的话,很有可能在寄存器页边界的时候,到内存发生时间差,导致后面寄存器再重新取内存的时候发生数据错误,取数据的时间差内,内存段中的数据被更改了。之前数据不依赖,不保证后面数据不依赖。所以还是顺序写回比较安全。
控制冒险
在结构冒险和数据冒险的演示案例中,所有的流水线停顿操作都要从指令执行阶段开始。流水线的前两个阶段,也就是取指令(IF)和指令译码(ID)的阶段,是不需要停顿的。但取指令和指令译码不会需要遇到任何停顿,这是基于一个假设。所有的指令代码都是顺序加载执行的。不过这个假设,在执行的代码代码中,一旦遇到 if…else 这样的条件分支,或者 for/while 循环,就会不成立。
在 jmp 指令发生的时候,CPU 可能会跳转去执行其他指令。jmp 后的那一条指令是否应该顺序加载执行,在流水线里面进行取指令的时候无法得知,要等 jmp 指令执行完成,去更新了 PC 寄存器之后,才能知道是否执行下一条指令,还是跳转到另外一个内存地址,去取别的指令。
这种为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是控制冒险。
缩短分支延迟
条件跳转指令的电路操作:根据指令的 opcode 去条件码寄存器比较,然后将跳转的地址写入到 PC 寄存器。
但无论是 opcode,还是对应的条件码寄存器,还是我们跳转的地址,都是在指令译码(ID)的阶段就能获得的。 而对应的条件码比较的电路,只要是简单的逻辑门电路就可以了,并不需要一个完整而复杂的 ALU。所以,我们可以将条件判断、地址跳转,都提前到指令译码阶段进行,而不需要放在指令执行阶段。对应的,我们也要在 CPU 里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。
这种方式,本质上和前面数据冒险的操作数前推的解决方案类似,就是在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。不过只是改造硬件,并不能彻底解决问题。跳转指令的比较结果,仍然要在指令执行的时候才能知道。 在流水线里,第一条指令进行指令译码的时钟周期里,我们其实就要去取下一条指令了。这个时候,我们其实还没有开始指令执行阶段,自然也就不知道比较的结果。
分支预测
简单的分支预测方式是假装分支不发生。即指令不受判断的影响,一直顺序向下运行。条件一定不发生,这是一种静态预测。
如果这种预测方式失败了,那我们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。CPU 不仅要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,我们还需要做对应的清除操作。比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。这样CPU 需要提供对应的丢弃指令的功能,通过控制信号清除掉已经在流水线中执行的指令。只要对应的清除开销不要太大,我们就是划得来。
动态分支预测
这个方法,其实就是用一个比特,去记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。这种方法叫一级分支预测,对应的,还有用两个比特来记录对应的状态,需要连续两次相同的结果才会预测下一次分支的比较情况。
案例:
public class BranchPrediction {
public static void main(String args[]) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
for (int j = 0; j <1000; j ++) {
for (int k = 0; k < 10000; k++) {
}
}
}
long end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
for (int j = 0; j <1000; j ++) {
for (int k = 0; k < 100; k++) {
}
}
}
end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start) + "ms");
}
}
==========================================
Time spent in first loop is 5ms
Time spent in second loop is 15ms
以上两个循环体执行时间的差异正式因为分支预测,循环其实也是利用 cmp 和 jle 这样先比较后跳转的指令来实现的。而分支预测策略最简单的一个方式,自然是“假定分支不发生”。对应到上面的代码,第一段代码的最内层每执行10000次才会发生一次错误,而第二段代码执行100次就会发生一次。这样的循环体累积下来,预测错误的数量也是很客观的。需要丢弃更多的运行到一半的指令,并再去重新加载新的指令执行。
超长指令字设计
可是即便坐了如此多流水线层民的优化,以及乱序执行,最佳情况下,IPC 也只能到 1。因为CPU 仍然只能在一个时钟周期里面,取一条指令。
CPI:时钟周期数,它的倒数 IPC,即一个时钟周期里面能够执行的指令数,代表了 CPU 的吞吐率。
需要知道,整数和浮点数计算的电路,在 CPU 层面是分开的。并不是所有计算功能都在一个 ALU 里面,真实的情况是,处理器会有多个 ALU。
乱序执行的时候执行阶段是可以并行执行的,但是取指令及指令译码阶段却不行。如果我们可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。这样,我们在一个时钟周期里,能够完成的指令就不只一条了。IPC 也就能做到大于 1 了。
这种 CPU 设计,即是多发射和超标量的CPU设计。
- 多发射:如图同一个时间,可能会同时把多条指令发射到不同的译码器或者后续处理的流水线中去。
- 超标量:如图有很多条并行的流水线,而不是只有一条流水线。本来我们在一个时钟周期里面,只能执行一个标量的运算。在多发射的情况下,我们就能够超越这个限制,同时进行多次计算。即能够让取指令以及指令译码也并行进行。
需要知道的是,不同的功能单元的流水线长度不一样。我们平时所说的14级流水线,指的通常是进行整数计算指令的流水线长度。复杂的计算,流水线长度会更长。
无论乱序执行还是超标量技术都需要解决前述的冒险问题。CPU 在指令执行之前,去判断指令之间是否有依赖关系。如果有对应的依赖关系,指令就不能分发到执行阶段。可是对于依赖关系的检测,会使硬件电路更复杂。
程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time
超长指令字设计: 后续 Intel 提出了把分析和解决指令依赖关系的事情放到了软件层面。叫作超长指令字设计即不仅想让编译器来优化指令数,还想直接通过编译器,来优化 CPI。搞定指令先后的依赖关系,使得一次可以取一个指令包。
在乱序执行和超标量的 CPU 架构里,指令的前后依赖关系,是由 CPU 内部的硬件电路来检测的。而到了超长指令字 的架构里面,这个工作交给了编译器这个软件。
于是,我们可以让编译器把没有依赖关系的代码位置进行交换。然后,再把多条连续的指令打包成一个指令包。CPU 在运行的时候,不再是取一条指令,而是取出一个指令包。解析出 3 条指令直接并行运行。
使用超长指令字架构的 CPU,同样是采用流水线架构的。也就是说,一组指令,仍然要经历多个时钟周期。同样的,下一组指令并不是等上一组指令执行完成之后再执行,而是在上一组指令的指令译码阶段,就开始取指令了 。值得注意的一点是,流水线停顿这件事情在超长指令字里面,很多时候也是由编译器来做的。除了停下整个处理器流水线,超长指令字的 CPU 不能在某个时钟周期停顿一下,等待前面依赖的操作执行完成。编译器需要在适当的位置插入 NOP 操作,直接在编译出来的机器码里面,就把流水线停顿这个事情在软件层面就安排妥当。
在超长指令字架构的 CPU 下,操作数前推、乱序执行,分支预测能用在这样的体系架构下么?
VLIW架构下处理器乱序执行应该不需要了,因为编译器已经将可以并行执行的指令打包成了指令包;操作数前推和分支预测应该可以用。
超线程
所有上面提到的提升CPU性能的方案,流水线架构,分支预测,乱序执行以及超标量和超长指令字节本质上都是一种指令级并行,想要通过同一时间执行两条指令,来提升 CPU 的吞吐率。
但这些方法都会受流水线级数太深而起不到效果。 更深的流水线就意味着同时在流水线里面的指令就越多,相互的依赖关系就越多。所以很多时候,不得不把流水线停顿下来,插入很多 NOP 操作,来解决这些依赖带来的冒险问题。
超线程技术: 既然 CPU 同时运行那些在代码层面有前后依赖关系的指令,会遇到各种冒险问题,不如去找一些和这些指令完全独立,没有依赖关系的指令来运行好了。 -> 这样的指令来自同时运行的另外一个程序里
超线程的 CPU,其实是把一个物理层面 CPU 核心,“伪装”成两个逻辑层面的 CPU 核心。这个 CPU,会在硬件层面增加很多电路,使得我们可以在一个 CPU 核心内部,维护两个不同线程的指令的状态信息。
如图,在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。这样,这个 CPU 核心就可以维护两条并行的指令的状态。 在外面看起来,似乎有两个逻辑层面的 CPU 在同时运行,但在 CPU 的其他功能组件上,Intel 还是只提供了一份,如指令译码器和 ALU,一个 CPU 核心仍然只有一份。因为超线程并不是真的去支持同时运行==任意==两个指令,那就真的变成物理多核了,在超线程基础上并行的指令,需要两个线程同时不冲突的使用CPU的资源
超线程的目的:是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。 因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。
这样,CPU 通过很小的代价,就能实现“同时”运行多个线程的效果。通常只要在CPU核心的添加10%左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。由于并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。一般是在那些各个线程“等待”时间比较长的应用场景下。比如,需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。后置指令需要依赖前置指令的内存访问才能执行,这个等待阶段就可以换其他数据不相关线程执行。
单指令多数据流 SIMD
SIMD性能差异案例,如下两段代码的执行时间,使用SIMD技术的性能是未使用的30多倍。
python
>>> 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,即单指令单数据。后者即是单指令多数据的 SIMD,而一个多核 CPU,它同时处理多个指令的方式可以叫作MIMD,也就是多指令多数据。
SIMD 在获取数据和执行指令的时候,都做到了并行。在从内存里面读取数据的时候,SIMD 是一次性读取多个数据。 ==Intel 在引入 SSE 指令集的时候==,在 CPU 里面添上了 8 个 128 Bits 的寄存器。128 Bits 也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取 4 次对应的数据,时间就省下来了。
在数据读取到了之后,在指令的执行层面,SIMD 也是可以并行进行的。 4 个整数各自加 1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要 CPU 里有足够多的功能单元,能够同时进行这些计算,这个加法就是 4 路同时并行的,自然也省下了时间。
总结
指令级并行是一种隐式并行,也就是写程序的人不需要关注,通过流水线和超标量,使得一个程序的指令序列中有多条同时乱序运行,顺序提交。这依赖寄存器重命名,多个执行单元,重排序缓冲和指令预测技术。
线程级并行时一种显式并行,也就是程序员要写多线程程序。线程级并行主要指同时多线程(SMT)/超线程(HT)以及多核和多处理器。SMT是在指令级并行的基础上的扩展,可以在一个核上运行多个线程,多个线程共享执行单元,以便提高部件的利用率,提高吞吐量。SMT需要为每个线程单独保持状态,如程序计数器(PC),寄存器堆,重排序缓冲等。
数据级并行是一种显式并行,主要指单指令多数据(SIMD),比如a,b和c都是相同大小的数组,要进行的计算是a的每一个元素与b的响应元素进行运算,结果放入c的对应元素中。如果没有SIMD,就需要写一个循环执行多遍来完成,而SIMD中 一条指令就可以并行地执行运算。
异常和中断
常见异常
- 硬件层面:当加法器进行两个数相加的时候,会遇到算术溢出;在玩游戏的时候,按下键盘发送了一个信号给到 CPU,CPU 要去执行一个现有流程之外的指令,这也是一个“异常”。
- 软件层面:程序进行系统调用,发起一个读文件的请求。这样应用程序向系统调用发起请求的情况,一样是通过“异常”来实现的。
异常,是一个硬件和软件组合到一起的处理过程。异常的发生和捕捉是在硬件层面完成的。但是异常的处理,是由软件来完成的。
计算机会为每一种可能会发生的异常,分配一个异常代码(中断向量)。异常发生的时候,通常是 CPU 检测到了一个特殊的信号。 比如,你按下键盘上的按键,输入设备就会给 CPU 发一个信号。或者,正在执行的指令发生了加法溢出,同样,会有一个进位溢出的信号。这些信号,在组成原理里面,一般叫作发生了一个事件。CPU在检测到事件的时候,其实也就拿到了对应的异常代码。
这些异常代码里,I/O 发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由 CPU 预先分配好的,也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。
拿到异常代码之后,CPU 就会触发异常处理的流程。计算机在内存里,会保留一个异常表。也叫中断向量表,和上面的中断向量对应起来。这个异常表类似GOT表,存放的是不同的异常代码对应的异常处理程序所在的地址。
CPU 在拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。
异常的分类:中断、陷阱、故障和中止。
- 中断:程序在执行到一半的时候,被打断了。这个打断执行的信号,来自于 CPU 外部的 I/O 设备。在键盘上按下一个按键,就会对应触发一个相应的信号到达 CPU 里面。CPU 里面某个开关的值发生了变化,也就触发了一个中断类型的异常。
- 陷阱:是程序员“故意“主动触发的异常。 如在程序里面打了一个断点,这个断点就是设下的一个"陷阱"。当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中。然后,对应的异常处理程序就会来处理这个"陷阱"当中的猎物。case:应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,我们用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行。
- 故障:它和陷阱的区别在于,陷阱是我们开发程序的时候刻意触发的异常,而故障通常不是。比如,我们在程序执行的过程中,进行加法计算发生了溢出,其实就是故障类型的异常。这个异常不是我们在开发的时候计划内的,也一样需要有对应的异常处理程序去处理。
- 终止:可以视作故障的一种特殊情况。当 CPU 遇到了故障,但是恢复不过来的时候,程序就不得不中止了。
故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以称之为“同步“类型的异常。
在实际的异常处理程序执行之前,CPU 需要去做一次“保存现场”的操作,即把当前正在执行的函数指令去压栈。特别的还要特别处理
- 中断、故障 发生的时候,需要把 CPU 内当前运行程序用到的所有寄存器的数据,都放到栈里面,最典型的就是条件码寄存器里面的内容。
- 像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
- 像故障这样的异常,在异常处理程序执行完成之后。从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。因为当前指令因为故障没有正常执行成功,必须重新去执行一次。
所以,对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在 CPU 层面的切换,所以这个过程我们称之为上下文切换。
CISC RISC
过去为了少用内存,指令的长度也是可变的。常用的指令要短一些,不常用的指令可以长一些。那个时候的计算机,想要用尽可能少的内存空间,存储尽量多的指令。
但后来计算机性能蓬勃发展,存储空间也更大了。并且当时发现实际在 CPU 运行的程序里,80% 的时间都是在使用 20% 的简单指令。后来就提出了 RISC 的理念。
RISC:CPU 选择把指令“精简”到 20% 的简单指令。而原先的复杂指令,则通过用简单指令组合起来来实现,让软件来实现硬件的功能。这样,CPU 的整个硬件设计就会变得更简单了,在硬件层面提升性能也会变得更容易了。
过去因为指令数量多,软硬件两方面都受到了很多挑战。
- 硬件层面,要想支持更多的复杂指令,CPU里面的电路就要更复杂,设计起来也就更困难。更复杂的电路,在散热和功耗层面,也会带来更大的挑战。
- 软件层面,支持更多的复杂指令,编译器的优化就变得更困难。毕竟,面向 2000 个指令来优化编译器和面向 500 个指令来优化编译器的困难是完全不同的。
RISC 的 CPU 里完成指令的电路变得简单了,腾出了更多的空间被拿来放通用寄存器。因为RISC 完成同样的功能,执行的指令数量要比 CISC 多 ,所以,如果需要反复从内存里面读取指令或者数据到寄存器里来,那么很多时间就会花在访问内存上。于是,RISC 架构的 CPU 往往就有更多的通用寄存器。除了寄存器这样的存储空间,RISC 的 CPU 也可以把更多的晶体管,用来实现更好的分支预测等相关功能,进一步去提升 CPU 实际的执行效率。
程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time
对于这个公式,CISC 优化的是指令数,来减少CPU的执行时间。而RISC优化的是CPI,因为指令比较简单,时钟周期数也就比较少。
后来 Intel 在开发安腾处理器的同时,也在不断借鉴其他 RISC 处理器的设计思想。由于必须向前兼容,所以兼容的 x86 指令集无法更改,为了让CISC风格的指令集用RISC的形势在CPU中运行,引入了微指令架构。
在微指令架构的 CPU 里面,编译器编译出来的机器码和汇编代码并没有发生什么变化。但在指令译码的阶段,指令译码器“翻译”出来的,不再是某一条 CPU 指令。译码器会把一条机器码,==“翻译”成好几条“微指令”==。这里的一条条微指令,就不再是 CISC 风格的了,而是变成了固定长度的 RISC 风格的了。
这些 RISC 风格的微指令,会被放到一个微指令缓冲区里面,然后再从缓冲区里面,分发给到后面的超标量,并且是乱序执行的流水线架构里面。不过这个流水线架构里面接受的,就不是复杂的指令,而是精简的指令了。在这个架构里,我们的指令译码器相当于变成了设计模式里的一个“适配器”。这个适配器,填平了 CISC 和 RISC 之间的指令差异。但是这样的译码器,意味着需要更复杂的电路和更长的译码时间,通过 RISC 提升的性能又被译码器花费了。
后来,由于运算指令的集中性20%。Intel 就在 CPU 里面加了一层 L0 Cache。这个 Cache 保存的就是指令译码器把 CISC 的指令“翻译”成 RISC 的微指令的结果。 于是,在大部分情况下,CPU 都可以从 Cache 里面拿到译码结果,而==不需要让译码器去进行实际的译码操作==。这样不仅优化了性能,因为译码器的晶体管开关动作变少了,还减少了功耗。
因为“微指令”架构的存在,从 Pentium Pro 开始,Intel 处理器已经不是一个纯粹的 CISC 处理器了。它同样融合了大量 RISC 类型的处理器设计。不过,由于 Intel 本身在 CPU 层面做的大量优化,比如乱序执行、分支预测等相关工作,x86 的 CPU 始终在功耗上还是要远远超过 RISC 架构的 ARM,所以最终在智能手机崛起替代 PC 的时代,落在了 ARM 后面。
现在,CISC 和 RISC 架构的分界已经没有那么明显了。Intel 和 AMD 的 CPU 也都是采用译码成 RISC 风格的微指令来运行。而 ARM 的芯片,一条指令同样需要多个时钟周期,有乱序执行和多发射。
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。
总结
RISC 的指令是固定长度的,CISC 的指令是可变长度的。RISC 的指令集里的指令数少,而且单个指令只完成简单的功能,所以被称为“精简”。CISC 里的指令数多,为了节约内存,直接在硬件层面能够完成复杂的功能,所以被称为“复杂”。RISC 的通过减少 CPI 来提升性能,而 CISC 通过减少需要的指令数来提升性能。
Intel 的 x86 CPU 的“微指令”的设计思路。“微指令”使得我们在机器码层面保留了 CISC 风格的 x86 架构的指令集。但是,通过指令译码器和 L0 缓存的组合,使得这些指令可以快速翻译成 RISC 风格的微指令,使得实际执行指令的流水线可以用 RISC 的架构来搭建。使用“微指令”设计思路的 CPU,不能再称之为 CISC 了,而更像一个 RISC 和 CISC 融合的产物。
GPU
现在我们电脑里面显示出来的 3D 的画面,其实是通过多边形组合出来的。而实际这些人物在画面里面的移动、动作,都是通过计算机根据图形学的各种计算,实时渲染出来的。
图形渲染的流程
图形渲染流程:
- 顶点处理
- 图元处理
- 栅格化
- 片段处理
- 像素操作
顶点处理:构成多边形建模的每一个多边形,都有多个顶点。这些顶点都有一个在三维空间里的坐标。但是我们的屏幕是二维的,所以在确定当前视角的时候,我们需要把这些顶点在三维空间里面的位置,转化到屏幕这个二维空间里面。这个转换的操作,就被叫作顶点处理。而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的。
图元处理:图元处理,其实就是要把顶点处理完成之后的各个顶点连起来,变成多边形。其实转化后的顶点,仍然是在一个三维空间里,只是第三维的 Z 轴,是正对屏幕的“深度”。所以我们针对这些多边形,需要做一个操作,叫剔除和裁剪,也就是把不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。
栅格化:我们的屏幕分辨率是有限的。它一般是通过一个个“像素”来显示出内容的。所以,这个操作就是把多边形转换成屏幕里面的一个个像素点。这个操作呢,就叫作栅格化。这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。
片段处理:在栅格化变成了像素点之后,、图还是“黑白”的。我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。
像素操作:最后一步呢,我们就要把不同的多边形的像素点“混合”到一起。可能前面的多边形可能是半透明的,那么前后的颜色就要混合在一起变成一个新的颜色;或者前面的多边形遮挡住了后面的多边形,那么我们只要显示前面多边形的颜色就好了。最终,输出到显示设备。
这样的5 个步骤的渲染流程呢,一般也被称之为图形流水线。
由于图形渲染的流程是固定的,所以直接用硬件来处理这部分过程,显然比使用 CPU 来计算好很多。因为整个计算流程是完全固定的,不需要流水线停顿、乱序执行等等的各类导致 CPU 计算变得复杂的问题。我们也不需要有什么可编程能力,不用像 CPU 那样考虑计算和处理能力的通用性,只要让硬件按照写好的逻辑进行运算就好了。
可编程图形处理器
因为整个图形渲染过程都是在硬件里面固定的管线来完成的。程序员们过去在加速卡上能做的事情呢,只有改配置来实现不同的图形渲染效果。如果通过改配置做不到,就没有什么办法了。
GPU的这个编程能力不是像 CPU 那样,有非常通用的指令,可以进行任何你希望的操作,而是在整个的渲染管线的一些特别步骤,能够自己去定义处理数据的算法或者操作。
早期的可编程管线的 GPU,仅提供了单独的顶点处理和片段处理(像素处理)的着色器。后来由于这样设计的电路有一半时间是闲着的,于是后来就有了统一着色器架构。这些可以编程的接口,我们称之为Shader中文名称就是着色器。
因为用的指令集是一样的,所以就在 GPU 里面放很多个一样的 Shader 硬件电路,然后通过统一调度,把顶点处理、图元处理、片段处理这些任务,都交给这些 Shader 去处理,让整个 GPU 尽可能地忙起来。这样的设计,就是我们现代 GPU 的设计,就是统一着色器架构。这个时候的“着色器”的作用,其实已经和它的名字关系不大了,而是变成了一个通用的抽象计算模块的名字。
现代 GPU 的三个核心创意
- 芯片瘦身:现代 CPU 里的晶体管变得越来越多,越来越复杂,其实已经不是用来实现“计算”这个核心功能,而是拿来实现处理乱序执行、进行分支预测,以及我们之后要在存储器讲的高速缓存部分。
而在 GPU 里,这些电路就显得有点多余了,GPU 的整个处理过程是一个流式处理的过程,因为没有那么多分支条件,或者复杂的依赖关系,我们可以把 GPU 里这些对应的电路都可以去掉,做一次小小的瘦身,只留下取指令、指令译码、ALU 以及执行这些计算需要的寄存器和缓存就好了。一般来说,我们会把这些电路抽象成三个部分,就是下面图里的取指令和指令译码、ALU 和执行上下文。
- 多核并行和 SIMT:这样一来,GPU 电路就比 CPU 简单很多了。于是,我们就可以在一个 GPU 里面,塞很多个这样并行的 GPU 电路来实现计算,就好像 CPU 里面的多核 CPU 一样。和 CPU 不同的是,我们不需要单独去实现什么多线程的计算。因为 GPU 的运算是天然并行的,上面有说。
SIMD:这个技术是说,在做向量计算的时候,我们要执行的指令是一样的,只是同一个指令的数据有所不同而已。在 GPU 的渲染管线里,这个技术可就大有用处了。
GPU 就借鉴了 CPU 里面的 SIMD,用了一种叫作SIMT的技术,SIMT 比 SIMD 更加灵活。在 SIMD 里面,CPU 一次性取出了固定长度的多个数据,放到寄存器里面,用一个指令去执行。而 SIMT,可以把多条数据,交给不同的线程去处理。
由于各个线程里面执行的指令流程是一样的,但是可能根据数据的不同,走到不同的条件分支。这样,相同的代码和相同的流程,可能执行不同的具体的指令。于是,GPU 设计就可以进一步进化,也就是在取指令和指令译码的阶段,取出的指令可以给到后面多个不同的 ALU 并行进行运算。这样,我们的一个 GPU 的核里,就可以放下更多的 ALU,同时进行更多的并行运算了。 并行上并行,超级并行- -
GPU中超线程
当GPU中遇到类似CPU中的“流水线停顿”问题。GPU 上可以和CPU一样调度一些别的计算任务给当前的 ALU。
和超线程一样,既然要调度一个不同的任务过来,我们就需要针对这个任务,提供更多的执行上下文。所以,一个 Core 里面的执行上下文的数量,需要比 ALU 多。
在通过芯片瘦身、SIMT 以及超线程技术此时的GPU就是一个更擅长并行进行暴力运算的 GPU。
NVidia 2080 显卡的技术规格
2080 一共有 46 个 SM(流式处理器),这个 SM 相当于 GPU 里面的 GPU Core,所以你可以认为这是一个 46 核的 GPU,有 46 个取指令指令译码的渲染管线。每个 SM 里面有 64 个 Cuda Core。你可以认为,这里的 Cuda Core 就是我们上面说的 ALU 的数量或者 Pixel Shader 的数量,46x64 呢一共就有 2944 个 Shader。然后,还有 184 个 TMU,TMU也就是用来做纹理映射的计算单元,它也可以认为是另一种类型的 Shader。
总结
GPU 一开始是没有“可编程”能力的,程序员们只能够通过配置来设计需要用到的图形渲染效果。随着“可编程管线”的出现,程序员们可以在顶点处理和片段处理去实现自己的算法。为了进一步去提升 GPU 硬件里面的芯片利用率,微软在 XBox 360 里面,第一次引入了“统一着色器架构”,使得 GPU 变成了一个有“通用计算”能力的架构。
接着,我们从一个 CPU 的硬件电路出发,去掉了对 GPU 没有什么用的分支预测和乱序执行电路,来进行瘦身。之后,基于渲染管线里面顶点处理和片段处理就是天然可以并行的了。我们在 GPU 里面可以加上很多个核。
又因为我们的渲染管线里面,整个指令流程是相同的,我们又引入了和 CPU 里的 SIMD 类似的 SIMT 架构。这个改动,进一步增加了 GPU 里面的 ALU 的数量。最后,为了能够让 GPU 不要遭遇流水线停顿,我们又在同一个 GPU 的计算核里面,加上了更多的执行上下文,让 GPU 始终保持繁忙。
GPU 里面的多核、多 ALU,加上多 Context,使得它的并行能力极强。同样架构的 GPU,如果光是做数值计算的话,算力在同样价格的 CPU 的十倍以上。而这个强大计算能力,以及“统一着色器架构”,使得 GPU 非常适合进行深度学习的计算模式,也就是海量计算,容易并行,并且没有太多的控制分支逻辑。
使用 GPU 进行深度学习,往往能够把深度学习算法的训练时间,缩短一个,乃至两个数量级。而 GPU 现在也越来越多地用在各种科学计算和机器学习上,而不仅仅是用在图形渲染上了。
FPGA
由于在设计更简单一点儿的专用于特定功能的芯片,少不了要几个月。而设计一个 CPU,往往要以“年”来计。在这个过程中,硬件工程师们要设计、验证各种各样的技术方案,可能会遇到各种各样的 Bug。如果我们每次验证一个方案,都要单独设计生产一块芯片,那这个代价也太高了。所以后来就设计出FPGA(现场可编程门阵列)这样一个芯片,可以通过不同的程序代码,来操作这个硬件之前的电路连线,不再需要单独制造一块专门的芯片来验证硬件设计。
它可以像软件一样对硬件编程,可以反复烧录,还有海量的门电路,可以组合实现复杂的芯片功能。
FPGA实现编程式硬件电路三步:
- 用存储换功能实现组合逻辑。在实现 CPU 的功能的时候,我们需要完成各种各样的电路逻辑。在 FPGA 里,这些基本的电路逻辑,不是采用布线连接的方式进行的,而是预先根据我们在软件里面设计的逻辑电路,算出对应的真值表,然后直接存到一个叫作 LUT(查找表)的电路里面。这个 LUT 呢,其实就是一块存储空间,里面存储了“特定的输入信号下,对应输出 0 还是 1”。
- 对于需要实现的时序逻辑电路,我们可以在 FPGA 里面直接放上 D 触发器,作为寄存器。这个和 CPU 里的触发器没有什么本质不同。不过,我们会把很多个 LUT 的电路和寄存器组合在一起,变成一个叫作逻辑簇的东西。在 FPGA 里,这样组合了多个 LUT 和寄存器的设备,也被叫做 CLB(可配置逻辑块)。
- FPGA 是通过可编程逻辑布线,来连接各个不同的 CLB,最终实现我们想要实现的芯片功能。
我们通过配置 CLB 实现的功能有点儿像我们前面讲过的全加器。它已经在最基础的门电路上做了组合,能够提供更复杂一点的功能。更复杂的芯片功能,我们不用再从门电路搭起,可以通过 CLB 组合搭建出来。
于是,通过 LUT 和寄存器,我们能够组合出很多 CLB,而通过连接不同的 CLB,最终有了我们想要的芯片功能。最关键的是,这个组合过程是可以“编程”控制的。而且这个编程出来的软件,还可以后续改写,重新写入到硬件里。让同一个硬件实现不同的芯片功能。
ASIC
ASIC:专用集成电路,它是很对特定的场景来专门设计的芯片,它的电路更精简,单片的制造成本也比 CPU 更低。而且,因为电路精简,所以通常能耗要比用来做通用计算的 CPU 更低。之前说的早期的图形加速卡,其实就可以看作是一种 ASIC。FPGA 的芯片通过“编程”的手段也可以编程ASIC,只是这么做有点儿硬件上了浪费。
FPGA相比ASIC的优缺点:
- 缺点:每一个 LUT 电路,其实都是一个小小的“浪费”。一个 LUT 电路设计出来之后,既可以实现与门,又可以实现或门,自然用到的晶体管数量,比单纯连死的与门或者或门的要多得多。同时,因为用的晶体管多,它的能耗也比单纯连死的电路要大,单片 FPGA 的生产制造的成本也比 ASIC 要高不少。
- 优点:它没有硬件研发成本。ASIC 的电路设计,需要仿真、验证,还需要经过流片,变成一个印刷的电路版,最终变成芯片。这整个从研发到上市的过程,最低花费也要几万美元,高的话,会在几千万乃至数亿美元。更何况,整个设计还有失败的可能。所以,如果我们设计的专用芯片,只是要制造几千片,那买几千片现成的 FPGA,可能远比花上几百万美元,来设计、制造 ASIC 要经济得多。
单个 ASIC 的生产制造成本比 FPGA 低,ASIC 的能耗也比能实现同样功能的 FPGA 要低。能耗低,意味着长时间运行这些芯片,所用的电力成本也更低。但是,ASIC 有一笔很高的 NRE(一次性工程费用)成本。这个成本,就是 ASIC 实际“研发”的成本。只有需要大量生产 ASIC 芯片的时候,我们才能摊薄这份研发成本。
虚拟机
分时操作系统:是使一台计算机采用时间片轮转的方式同时为几个、几十个甚至几百个用户服务的一种操作系统。
把计算机与许多终端用户连接起来,分时操作系统将系统处理机时间与内存空间按一定的时间间隔,轮流地切换给各终端用户的程序使用。由于时间间隔很短,每个用户的感觉就像他独占计算机一样。分时操作系统的特点是可有效增加资源的使用率。例如UNIX系统就采用剥夺式动态优先的CPU调度,有力地支持分时操作。
虚拟机技术:就是指在现有硬件的操作系统上,能够模拟一个计算机系统的技术。而模拟一个计算机系统,最简单的办法,其实不能算是虚拟机技术,而是一个模拟器。
解释型虚拟机
要模拟一个计算机系统,最简单的办法,就是兼容这个计算机系统的指令集。模拟器是跑在我们的操作系统上的一个应用程序。它可以识别我们想要模拟的、计算机系统的程序格式和指令,然后一条条去解释执行。
这个方式,其实和运行 Java 程序的 Java 虚拟机很像。只不过,Java 虚拟机运行的是 Java 自己定义发明的中间代码(字节码),而不是一个特定的计算机系统的指令。字节码->机器码
这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件。 不过这个方式也有两个明显的缺陷。
- 第一,做不到精确的“模拟”。很多的老旧的硬件的程序运行,要依赖特定的电路乃至电路特有的时钟频率,想要通过软件达到 100% 模拟是很难做到的。
- 第二个缺陷就更麻烦了,那就是这种解释执行的方式,性能实在太差了。因为我们并不是直接把指令交给 CPU 去执行的,而是要经过各种解释和翻译工作。
为了解决性能问题,也有类似于 Java 当中的 JIT 这样的“编译优化”的办法,把本来解释执行的指令,编译成 Host 可以直接运行的指令。但是,这个性能还是不能让人满意。因为他无法保证所有
虚拟机监视器
为了摒弃上述两个缺点,以及需要一个“全虚拟化”的技术。也就是说,我们可以在现有的物理服务器的硬件和操作系统上,去跑一个完整的、不需要做任何修改的客户机操作系统。这样的实现方式就是加一个中间层,虚拟机监视器。
如果说我们宿主机的 OS 是房东的话,这个虚拟机监视器呢,就好像一个二房东。我们运行的虚拟机,都不是直接和房东打交道,而是要和这个二房东打交道。我们跑在上面的虚拟机呢,会把整个的硬件特征都映射到虚拟机环境里,这包括整个完整的 CPU 指令集、I/O 操作、中断等等。
Type-2 型虚拟机
既然指令都是通过虚拟机监视器来执行的,那具体指令是怎么落到硬件上去实际执行的就引申出两种实现方式,分别是 Type-1型虚拟机和Type-2型虚拟机。
在 Type-2 虚拟机里,我们上面说的虚拟机监视器好像一个运行在操作系统上的软件。你的客户机的操作系统呢,把最终到硬件的所有指令,都发送给虚拟机监视器。而虚拟机监视器,又会把这些指令再交给宿主机的操作系统去执行。 - 类似上述模拟器的执行模式,这样类型的虚拟机更多是用在我们日常的个人电脑里,而不是用在数据中心里。
Type-1 型虚拟机
在数据中心里面用的虚拟机,我们通常叫作 Type-1 型的虚拟机。这个时候,客户机的指令交给虚拟机监视器之后呢,不再需要通过宿主机的操作系统,才能调用硬件,而是可以直接由虚拟机监视器去调用硬件。 这样 Type-1 型的虚拟机也就无法再在 Intel x86 上面去跑一个 ARM 的程序了。但带来了指令执行效率的提升。
所以,在 Type-1 型的虚拟机里,我们的虚拟机监视器其实并不是一个操作系统之上的应用层程序,而是一个嵌入在操作系统内核里面的一部分。无论是 KVM、XEN 还是微软自家的 Hyper-V,其实都是系统级的程序。
因为虚拟机监视器需要直接和硬件打交道,所以它也需要包含能够直接操作硬件的驱动程序。所以 Type-1 的虚拟机监视器更大一些,同时兼容性也不能像 Type-2 型那么好。不过,因为它一般都是部署在我们的数据中心里面,硬件完全是统一可控的,这倒不是一个问题了。
Docker
虽然,Type-1 型的虚拟机看起来已经没有什么硬件损耗。但是,这里面还是有一个浪费的资源。在我们实际的物理机上,我们可能同时运行了多个的虚拟机,而这每一个虚拟机,都运行了一个属于自己的单独的操作系统。 多运行一个操作系统,意味着我们要多消耗一些资源在 CPU、内存乃至磁盘空间上。
我们很多时候想要租用的不是“独立服务器”,而是独立的计算资源,或者只要一个能够进行资源和环境隔离的“独立空间”就好了。
通过 Docker,我们不再需要在操作系统上再跑一个操作系统,而只需要通过容器编排工具,比如 Kubernetes 或者 Docker Swarm,能够进行各个应用之间的环境和资源隔离就好了。
这种隔离资源的方式呢,也有人称之为“操作系统级虚拟机”,好和上面的全虚拟化虚拟机对应起来。不过严格来说,Docker 并不能算是一种虚拟机技术,而只能算是一种资源隔离的技术而已。
总结
我们现在的云服务平台上,你能够租到的服务器其实都是虚拟机,而不是物理机。而正是虚拟机技术的出现,使得整个云服务生态得以出现。
虚拟机是模拟一个计算机系统的技术,而其中最简单的办法叫模拟器。我们日常在 PC 上进行 Android 开发,其实就是在使用这样的模拟器技术。不过模拟器技术在性能上实在不行,所以我们才有了虚拟化这样的技术。
在宿主机的操作系统上,运行一个虚拟机监视器,然后再在虚拟机监视器上运行客户机的操作系统,这就是现代的虚拟化技术。这里的虚拟化技术可以分成 Type-1 和 Type-2 这两种类型。
Type-1 类型的虚拟化机,实际的指令不需要再通过宿主机的操作系统,而可以直接通过虚拟机监视器访问硬件,所以性能比 Type-2 要好。而 Type-2 类型的虚拟机,所有的指令需要经历客户机操作系统、虚拟机监视器、宿主机操作系统,所以性能上要慢上不少。不过因为经历了宿主机操作系统的一次“翻译”过程,它的硬件兼容性往往会更好一些。
今天,即使是 Type-1 型的虚拟机技术,我们也会觉得有一些性能浪费。我们常常在同一个物理机上,跑上 8 个、10 个的虚拟机。而且这些虚拟机的操作系统,其实都是同一个 Linux Kernel 的版本。于是,轻量级的 Docker 技术就进入了我们的视野。Docker 也被很多人称之为“操作系统级”的虚拟机技术。不过 Docker 并没有再单独运行一个客户机的操作系统,而是直接运行在宿主机操作系统的内核之上。所以,Docker 也是现在流行的微服务架构底层的基础设施。