开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情
写在前面
本文是MIT6.172课程的第四课的下半部分,主要介绍了浮点运算模块、向量模块和一些计算机架构特性。
浮点运算单元
现代x86-64架构支持通过一系列不同的指令集来实现标量浮点运算,包括SSE、AVX和x87指令。
SSe和AVX指令支持单精度(float)和双精度(double)的浮点标量运算,而x87指令支持单精度(float)、双精度(double)和拓展精度(long double)的浮点标量运算。此外,SSE和AVX指令还包括向量指令。编译器更倾向于使用SSE指令而不是x87指令来实现浮点运算,因为SSE指令更容易编译和进行优化。
在SSE指令中,也通过后缀的方式来表现位宽:
向量硬件
在计算机体系结构中,我们用SIMD来表示单指令流多数据流的模式,落实到实际架构上,就是向量单元。
一种最简单的理解向量运算的方式,就是矩阵的加法。假设用两个一维数组来模拟两个矩阵来做加法:没有向量单元之前,我们往往会使用遍历的方式从头遍历到尾进行加法运算并保存结果,但是在向量单元中,可以一次性的进行多次加法,这样就减少了循环的次数。
向量元素的操作是对应的,比如第一个矩阵的第三个数和第二个矩阵的第三个数进行加法运算;并且每个向量模块的行为都是一致的,这取决于指令是什么。
现代的x86-64架构支持多种向量指令集:
- SSE指令集:支持整数、单精度和双精度数据的向量操作;
- AVX指令集:支持单精度和双精度的浮点数向量操作;
- AVX2指令集:支持整数向量操作;
- AVX-512(AVX3)指令集:将寄存器的长度提高到了512bit,提供了新的向量操作。
这其中,AVX和AVX2指令集拓展了SSE指令集:SSE指令集使用128位的XMM向量寄存器并且一次最多使用两个操作数;AVX指令集则可以使用256位的YMM寄存器并且一次可以操作三个操作数,其中两个源操作数和一个结果操作数。
计算机架构速览
在本章当中,不会大篇幅的进行计算机组成原理的讲解,我们假设读者已经了解相关知识,主要介绍一些新的特性。
五级流水线是计算机组成原理中经典的架构图:
其中IF阶段进行指令的读取,ID阶段进行指令的解码并将指令内容进行解析,控制整个CPU的运行;EX阶段则负责进行ALU的相关运算;MA阶段负责进行数据的读取和写入;WB阶段进行数据的写回。原来的单指令方式虽然简单易懂,但是每个时间段都有模块空闲,所以考虑进行周期的切割,通过流水线来加速指令的执行。但是这样的加速并不是没有代价的,指令的执行过程中会出现阻塞(stall):
产生阻塞的原因有多种,主要是因为冒险(hazard)的产生。冒险主要分为以下几类:
- Structural hazard:结构冒险,两条指令在同一时间去使用相同的硬件单元,这是不可实现的,所以会有一条指令等待另一条指令的进行;
- Data hazard:数据冒险,指令的运算依赖于前一条指令的运行结果,所以要等待前一条指令产生结果;
- Control hazard:控制冒险,指的是IF和ID阶段因为要跳转产生的阻塞;
这其中数据冒险的原因主要来自于以下几种情况:
第一种就是说后一条指令使用了前一条指令的结果;第二种则是前一条指令读取了后一条指令写入的地址;第三种则是两条指令写入了同一个地方。
在理想情况下,流水线可以一直不停的执行,使得每个模块的使用率都接近100%。但是在现实中,有各种原因会导致阻塞的产生。为了减少阻塞,提高指令的执行速度,我们可以从并行性和局部性两个角度进行考虑。并行性的话我们可以使用向量运算、多核结构等方式实现;局部性则是通过减少数据的访存来提高速度,比如cache。
旁路
前文说道,流水线中的阻塞很大一部分是来自于数据的读写不一致,或者说更新不及时。那么我们是不是可以让他们尽可能快的获取到数据呢?通过旁路(Bypassing)结构,我们可以减少一部分的阻塞。举例来说,我们假设有i和j两条指令,j指令需要读取i指令的计算结果来进行运算,那么j指令需要等待i指令计算出结果并写回到寄存器后再进行运算,这样就阻塞了3个周期;但是通过旁路技术,我们可以让j指令的计算(EX阶段)的时候直接读取i指令在内存访问(MA)阶段的数据,这样就不会产生任何的阻塞。那么是不是对于所有的阻塞都可以通过这种方式来减少阻塞呢?并不是这样的,如果一个旁路过于复杂,其也会影响到整体电路的运行速度。
超标量
在指令的实现上,由于其复杂程度的不同,所以每条指令的实现周期数也不尽相同:
如果浮点运算和普通的算逻运算在一个模块,那么可能为了等待一个结果而影响整个流水线的效率。为了能够提高并行度,我们不妨把这些复杂的单元独立出来作为单独的处理模块:
在这样的情况下,这些独立的函数单元也并不会时刻满载,我们需要更进一步的挖掘其性能,所以我们通过超标量的方式来实现:将一条指令译码成多个微指令,再将这些指令放入到进行了更细致划分的流水线中进行运行,这样就使得指令的指令更加紧密,也就更进一步的使用了硬件能力:
例如在Haswell架构中:
取指和译码单元每次能够产生四条微指令传入到后续的流水线中,这样就使得后续的流水线尽可能的减少空转。
乱序执行
我们不妨先简化一下超标量架构:
我们这里把Issue模块看成是管理指令和调度指令的单元。
我们假设有四条指令i、j、k、h,其中i和j有相互的数据依赖关系,k和h有相互的数据依赖关系,他们会产生冒险导致阻塞;但是i和k并没有任何关系,那么Issue模块可以考虑让i和k指令先进行运算,j和h后进行运算。就等于在i和j中间插入了一条k指令,k和h之间插入了一条j指令,执行顺序为i、k、j、h,这样即使会产生阻塞,也可以利用阻塞的一个周期来运行一条指令,从而进行加速。这就是乱序执行的优点。
乱序执行需要考虑指令数据的依赖关系,我们可以通过重命名寄存器的方式来减少依赖。也即如果目标寄存器的地址可以被修改,不妨修改这个目标地址,来减少前后指令的数据依赖。再之后进行指令的乱序执行来提高指令并行度。
Issue模块为了能够加速乱序执行,通过reorder buffer(ROB)感知到数据的依赖关系,从而进行指令的重排和执行。
分支预测
还有一种会造成流水线阻塞的原因是分支跳转,一旦产生了分支跳转,由于延迟槽的存在,指令也需要进行阻塞来冲刷掉预取不会执行的预取指令。为了减少这种情况的发生,我们通过分支预测的方式来进行加速。
在取指前,就进行分支预测,判断这一次条件跳转是否会成功。预测成功率越高,加速效果就越明显。
一种简单的分支预测模型是通过维护一个表映射来记录跳转地址和预测结果的关系来实现的。每个跳转地址对应一个2-bit的数据:
基于每一次的跳转结果,我们会进行这个数据的修改,从而提高预测的成功率。现代的分支预测模型则会根据过去k次的跳转结果来进行预测。