Linux操作系统学习笔记(二十八)深入理解CPU

一. 前言

  在前面一些文章中多多少少有提到一些CPU的结构以及对应的寄存器等,但是总觉得不够透彻,所以单开一文详细叙述CPU的各种知识,从而加深对操作系统和性能的理解。本文从最基本的加法器和乘法器切入,随后介绍CPU的基本架构和实现原理,接着介绍各个核心模块的实现细节,最后介绍CPU的一些高级黑科技。

二. 加法器和乘法器

2.1 门电路和半加器

  如果从二极管如何形成高低电平说起,那怕是要说完大部分《模拟电路设计》和《数字电路设计》的知识了,有兴趣的同学可以回顾一下自行研究,我们就从各种基本门电路开始谈起。如下所示为常见的门电路。

img

  与、或、非是最基本的逻辑运算,而或非、异或、与非的出现让多位算数便于实现。如异或门就是一个最简单的整数加法,所需要使用的基本门电路:两个0或者两个1相加仍然是0,只有0和1相加才会是1。但是这还不够,我们还需要考虑进位问题,所以还需要一个与门作为标记位,判断该次计算是否需要进位。与门和异或门的组合,就形成了半加器。

img

2.2 半加器到全加器

  半加器之所以叫做半加,是因为半加器可以解决个位的加法问题,但是如果放到二位上来说,就不够用了。解决方法也很简单如下

  • 第一位加法采用半加器,其进位信息输入到第二位
  • 第二位加法采用两个半加器
    • 第一个半加器输入第二位的两个数,输出进位信息和相加结果
    • 相加结果和第一位输入的进位信息输入到第二个半加器,输出第二位的真实值和进位信息
    • 前两步得到的进位信息进行或运算,即为实际进位到第三位的进位信息

  下图所示为一个全加器,图中加数和被加数指的是该位的数字,而进位信息来自于上一级,最终得到的和为该位的结果,进位信号会输入到下一位的全加器进位信号。通过这种方式,我们就可以实现多位数加法运算。

img

  多个全加器串联,就构成了最基本的加法器。对于这个全加器,在个位,我们只需要用一个半加器,或者让全加器的进位输入始终是 0。因为个位没有来自更右侧的进位。而最左侧的一位输出的进位信号,表示的并不是再进一位,而是表示我们的加法是否溢出了。该位电信号实际会被计算机接收到并做判断是否接受溢出操作,如果不接受则会出现异常中断。

img

2.3 基本乘法器

  二进制的乘法实际上是多次加法的移位运算,乘数是0则不加,是1则左移被乘数一位并相加,最终结构如下所示。这里的控制测试,其实就是通过一个时钟信号,来控制左移、右移以及重新计算乘法和加法的时机。

img

2.3 并行乘法器

  在这个乘法器的实现过程里,我们其实就是把乘法展开,变成了“加法 + 位移”来实现。因为下一组的加法要依赖上一组的加法后的计算结果,下一组的位移也要依赖上一组的位移的结果。这样,整个算法是“顺序”的,每一组加法或者位移的运算都需要O(N)的时间。为了减小时间复杂度到O(N),CPU采取了类似于分治的思想,将时间复杂度减小至O(logN),如下图所示。

img

  除此之外,还有个更厉害的方法:不用让计算机模拟人脑的思考方式来连结线路,把进位部分的电路完全展开就好了。我们的半加器到全加器,再到加法器,都是用最基础的门电路组合而成的。门电路的计算逻辑,可以像我们做数学里面的多项式乘法一样完全展开。在展开之后呢,我们可以把原来需要较少的,但是有较多层前后计算依赖关系的门电路,展开成需要较多的,但是依赖关系更少的门电路。如果我们完全展开电路,高位的进位和计算结果,可以和低位的计算结果同时获得。这个的核心原因是电路是天然并行的,一个输入信号,可以同时传播到所有接通的线路当中。

img

  有了加法器、乘法器,也就有了CPU的最基本组成单位之一:ALU(Arithmetic-Logic Unit )

三. CPU基本结构

  CPU的核心是指令和运算。指令即我们撰写的代码,是怎么变成一条条的机器能够理解的指令的,以及是按照什么样的顺序运行的。而运算,即上面介绍的ALU。只有把“指令”和“计算”这两部分功能连通起来,我们才能构成一个真正完整的 CPU。

3.1 指令周期、机器周期和时钟周期

  CPU的运行包括以下几部分:取指令(Fetch)、译码(Decode)、执行(Execute),三个步骤的重复循环进行称之为指令周期(Instruction Cycle)。

  • Fetch(取得指令),也就是从 PC 寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把 PC 寄存器自增,好在未来执行下一条指令。

  • Decode(指令译码),也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是 R、I、J 中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。

  • Execute(执行指令),也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。

  我们一般把从内存里面读取一条指令的最短时间,称为 CPU 周期(又称Machine Cycle,机器周期)。对于一个指令周期来说,我们取出一条指令,然后执行它,至少需要两个 CPU 周期:取出指令至少需要一个 CPU 周期,执行至少也需要一个 CPU 周期,复杂的指令则需要更多的 CPU 周期。而时钟周期(Clock Cycle),也就是我们机器的主频。一个 CPU 周期,通常会由几个时钟周期累积起来。一个 CPU 周期的时间,就是这几个 Clock Cycle 的总和。三者关系可用下图表示。

img

3.2 基本结构

  要想完成CPU的指令周期,我们需要以下部分:

  • 存储部分,保存程序员编写的众多指令,即存储元件,也有叫状态元件(State Element),比如我们在计算过程中需要用到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件。
  • 控制部分,控制指令周期的执行,即控制单元(Control Unit)。
  • 计算部分,完成实际的指令计算操作,即计算单元(ALU),又称操作元件,也叫组合逻辑元件(Combinational Element),它们的功能就是在特定的输入下,根据组合电路的逻辑,生成特定的输出。
img

  三者结合工作,即可完成CPU的功能。

3.3 基本硬件电路

  为了完成上述CPU的基本功能,我们至少需要以下四种电路:

  • ALU ,实际就是一个没有状态的根据输入计算输出结果的第一个电路。
  • 锁存器(Latch),以及 D 触发器(Data/Delay Flip-flop)。这两种电路可以存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用,但是可以在需要的时候拿出来用,从而达到状态存储的效果。
  • 随时间自动变换的程序计数器。我们需要有一个“自动”的电路,按照固定的周期,不停地实现 PC 寄存器自增,自动地去执行“Fetch - Decode - Execute“的步骤。我们看似写了各种复杂的高级程序进行各种函数调用、条件跳转。其实只是修改 PC 寄存器里面的地址。PC 寄存器里面的地址一修改,计算机就可以加载一条指令新指令,往下运行。
  • 译码器。无论是对于指令进行 decode,还是对于拿到的内存地址去获取对应的数据或者指令,我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。

img

  把这四类电路通过各种方式组合在一起,就能最终组成功能强大的 CPU 了。但是,要实现这四种电路中的中间两种,我们还需要时钟电路的配合,这就是时序逻辑电路。时序电路解决了自动运行、存储和时序协调的问题,是CPU电路核心所在。

3.4 D触发器

  首先,我们需要时钟。CPU 的主频是由一个晶体振荡器来实现的,而这个晶体振荡器生成的电路信号,就是我们的时钟信号。时钟信号的原理无外乎利用电磁感应或者反相器实现一个开启后会关闭、关闭后又会开启的效果,如下图所示

img

  这种不断开开关关的过程就会形成如下所示的高低电平的交错,这就是我们需要的周期性时钟信号。

img

  有了时钟信号,RS触发器就可以构造出一个存储结构,也即CPU 中用来存储计算结果的寄存器。RS触发器接通开关 R,输出变为 1,即使断开开关,输出还是 1 不变。接通开关 S,输出变为 0,即使断开开关,输出也还是 0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。

img

  RS寄存器还是一个比较简陋的模型,需要控制RS两个开关,我们通过反相器将两个开关合并,同时通过与门接入了时钟信号的控制,也就构成了D触发器。一个 D 型触发器,只能控制 1 个比特的读写,但是如果我们同时拿出多个 D 型触发器并列在一起,并且把用同一个 CLK 信号控制作为所有 D 型触发器的开关,这就变成了一个 N 位的 D 型触发器,也就可以同时控制 N 位的读写。

img

3.5 程序计数器

  程序计数器和译码器均由D触发器构造而来。程序计数器,又叫PC寄存器,由D触发器和加法器构成。通过时钟信号的定时输入,我们可以每个时钟周期调用一次加法器自增,接着保存在D触发器里。每次自增之后,我们可以去对应的 D 型触发器里面取值,这也是我们下一条需要运行指令的地址。由此我们实现了顺序访问指令地址的功能。为保证D触发器不会出现时钟周期结束时指令操作未结束从而数据错乱的情况,需要在一个时钟周期里,确保执行完一条最复杂的 CPU 指令,也就是耗时最长的一条 CPU 指令。这样的 CPU 设计,我们称之为单指令周期处理器(Single Cycle Processor)。

img

3.6 多指令模型

  单指令周期处理器虽然简单,但是显然浪费了大量的时间,所以我们对CPU的操作进行拆分,按照“取指令、译码、执行”来分解,其中执行部分可以再细分为 “ 从寄存器或者内存中读取数据,通过 ALU 进行运算,把结果写回到寄存器或者内存中” 三个部分。这样拆分之后,就形成了经典的五级流水线模型。我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。

img

  同样的思想,我们还可以继续拆分为更多层级,如现在常见的14级流水线模型。但是拆分多级也会带来另一个问题:每一级流水线对应的输出,都要放到流水线寄存器(Pipeline Register)里面,然后在下一个时钟周期,交给下一个流水线级去处理。所以,每增加一级的流水线,就要多一级写入到流水线寄存器的操作。过多层级会带来更大的消耗,反而无法提升性能。

四. 寄存器

  寄存器是CPU内部用来存放数据的一些小型存储区域,包括参与运算的数据和运算结果以及一些CPU运行需要的信息。通常寄存器可以分为以下几类

  • 通用寄存器
  • 标志寄存器
  • 指令寄存器
  • 段寄存器
  • 控制寄存器
  • 调试寄存器
  • 描述符寄存器
  • 任务寄存器
  • MSR寄存器
  • 存储寄存器
  • 累积寄存器

  通用寄存器、段寄存器、标志寄存器、指令寄存器,这四组寄存器共同构成了一个基本的指令执行环境,一个线程的上下文也基本上就是这些寄存器,在执行线程切换的时候,就是修改它们的内容。

4.1 通用寄存器(General Purpose Register)

  通用寄存器给应用程序存放各种临时数据,32位系统中约定俗成的用法如下所示

  • eax: 用来执行加法及存放函数调用的返回值
  • ebx: 数据存取,被调用者保存
  • ecx: 传参第四个参数,或用做计数器,比如for循环
  • edx: 传参第三个参数,或读写I/O端口时用于存放端口号
  • esp: 栈顶指针,指向栈的顶部
  • ebp: 栈底指针,指向栈的底部,通常用ebp+偏移量的形式来定位函数存放在栈中的局部变量
  • esi: 传参第二个参数,或字符串操作时,用于存放数据源的地址
  • edi: 传参第一个参数,或字符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作

  在64位中,这些寄存器开头字母e改为r,实际用法不变,同时增加了r8r15这8个新的寄存器。寄存器富裕最大的好处在于可以使用寄存器存储更多的传参,从而加快了函数处理速度。值得一提的是其中r8r9用来保存第五个和第六个传参。

4.2 标志寄存器(Flags Register)

  标志寄存器记录了CPU执行指令过程中的一系列状态,即当前运行线程的一系列关键信息,这些标志大都由CPU自动设置和修改

img
1
2
3
4
5
6
7
8
CF 进位标志
PF 奇偶标志
ZF 零标志
SF 符号标志
OF 补码溢出标志
TF 跟踪标志
IF 中断标志
······

4.3 指令寄存器和程序计数器(Instrunction Pointer Register & Program Counter Register)

  32位指令寄存器为eip,而64位为rip寄存器。指令寄存器可以说是CPU中最最重要的寄存器了,它保存当前指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令。当执行一条指令时,先把它从内存取到数据寄存器(DR,Data Register)中,然后再传送至IR。指令划分为操作码和地址码字段,由二进制数字组成。为了执行任何给定的指令,必须对操作码进行测试,以便识别所要求的操作。指令译码器就是做这项工作的。指令寄存器中操作码字段的输出就是指令译码器的输入。操作码一经译码后,即可向操作控制器发出具体操作的特定信号。

  程序计数器存放的是下一条指令的地址,不断自增,在3.5节已有详细介绍。

4.4 段寄存器(Segment Register)

  段寄存器用于CPU内存寻址功能,主要包括以下几种

  • cs: 代码段
  • ds: 数据段
  • ss: 栈段
  • es: 扩展段
  • fs: 数据段
  • gs: 数据段

  段寄存器里面存储的内容与CPU当前工作的内存寻址模式紧密相关。

  • 当CPU处于16位实地址模式下时,段寄存器存储段的基地址,寻址时,将段寄存器内容左移4位(乘以16)得到段基地址+段内偏移得到最终的地址。

  • 当CPU工作于保护模式下,段寄存器存储的内容不再是段基址了,此时的段寄存器中存放的是段选择子,用来指示当前这个段寄存器“指向”的是哪个分段。

  16个bit长度的段寄存器内容划分了三个字段:

  • PRL: 特权请求级,就是我们常说的ring0-ring3四个特权级。
  • TI: 0表示用的是全局描述符表GDT,1表示使用的是局部描述符表LDT。
  • Index: 这是一个表格中表项的索引值,这个表格叫内存描述符表,它的每一个表项都描述了一个内存分段。

img

4.5 控制寄存器(Control Register)

  控制寄存器保存了CPU运行过程中自身的一些关键信息。32位CPU总共有cr0-cr4共5个控制寄存器,64位增加了cr8。他们各自有不同的功能,如下所示

  • cr0: 存储了CPU控制标记和工作状态
  • cr1: 保留未使用
  • cr2: 页错误出现时保存导致出错的地址
  • cr3: 存储了当前进程的虚拟地址空间的重要信息——页目录地址,在进程空间切换的时候,CR3也将同步切换。
  • cr4: 也存储了CPU工作相关以及当前任务的一些信息
  • cr8: 64位新增扩展使用

img

其中CR0的各标记位表示

  • PG: 是否启用内存分页
  • AM: 是否启用内存对齐自动检查
  • WP: 是否开启内存写保护,若开启,对只读页面尝试写入时将触发异常,这一机制常常被用来实现写时复制功能
  • PE: 是否开启保护模式

4.6 调试寄存器

  调试寄存器是实现程序断点调试的必备工具。对于通常的断点,也就是程序执行到某个位置下就停下来,这种断点实现的方式,在x86/x64上是利用了一条软中断指令:int 3来进行实现的。注意,这里的int表示interrupt,即中断的意思,是一条汇编指令,int 3则表示中断向量号为3的中断。其实现流程如下所示:

  • 在我们使用调试器下断点时,调试器将会把对应位置的原来的指令替换为一个int 3指令,机器码为0xCC。这个动作对我们是透明的,我们在调试器中看到的依然是原来的指令,但实际上内存中已经不是原来的指令了。

  • CPU在执行这条int 3指令时,将自动触发中断处理流程。CPU将取出IDTR寄存器指向的中断描述符表IDT的第3项,执行里面的中断处理函数,该部分详情参见前文

  • 操作系统会把触发这一事件的进程冻结起来,随后将这一事件发送到调试器,调试器拿到之后就知道目标进程触发断点了。这个时候,就能通过调试器的UI交互界面或者命令行调试接口来调试目标进程,查看堆栈、查看内存、变量。如果我们要继续运行,调试器将会把之前修改的int 3指令给恢复回去,然后告知操作系统,继续执行后续指令。

  但是另一种调试,即指定内存位置断点,则需要调试寄存器来发挥作用了,这种称之为硬件断点。在x86架构CPU内部,提供了8个调试寄存器DR0~DR7。

  • DR0~DR3:这是四个用于存储地址的寄存器

  • DR4~DR5:这两个有点特殊,受前面提到的CR4寄存器中的标志位DE位控制,如果CR4的DE位是1,则DR4、DR5是不可访问的,访问将触发异常。如果CR4的DE位是0,则DR4和DR5将会变成DR6和DR7的别名,相当于做了一个软链接。这样做是为了将DR4、DR5保留,以便将来扩展调试功能时使用。

  • DR6:这个寄存器中存储了硬件断点触发后的一些状态信息

  • DR7:调试控制寄存器,这里面记录了对DR0-DR3这四个寄存器中存储地址的中断方式(是对地址的读,还是写,还是执行)、数据长度(1/2/4个字节)以及作用范围等信息

img

4.7 描述符寄存器(Descriptor Register)

  在x86/x64系列CPU中,有三个非常重要的描述符寄存器,它们分别存储了三个地址,指向了三个非常重要的描述符表,分别是全局描述符表、局部描述符表和中断描述符表。

  • GDTR寄存器:全局描述符表寄存器,CPU现在使用的是段+分页结合的内存管理方式,系统的分段存储在一个叫全局描述符表(GDT)的表格中,并用GDTR寄存器指向这个表。这个表中的每一项都描述了一个内存段的信息。
  • LDTR寄存器:局部描述符表寄存器,这个寄存器和上面的GDTR寄存器一样,同样指向的是一个段描述符表(LDT)。不同的是,GDT是全局唯一,LDT是局部使用的,可以创建多个,随着任务段切换而切换(下文介绍任务寄存器会提到)。
  • IDTR寄存器:中断描述符表寄存器,指向了中断描述符表IDT,这个表的每一项都是一个中断处理描述符,当CPU执行过程中发生了硬中断、异常、软中断时,将自动从这个表中定位对应的表项,里面记录了发生中断、异常时该去哪里执行处理函数。

img

img

  IDT中的表项称为Gate,中文意思为,因为这是应用程序进入内核的主要入口。虽然表的名字叫中断描述符表,但表中存储的不全是中断描述符,IDT中的表项存在三种类型,对应三种类型的门:

  • 任务门
  • 陷阱门
  • 中断门

  三种描述符中都存储了处理这个中断/异常/任务时该去哪里处理的地址。三种门用途不一,其中中断门是真正意义上的中断,而像前面提到的调试指令int 3以及老式的系统调用指令int 2e/int 80都属于陷阱门。任务门则用的较少,要了解任务门,先了解下任务寄存器。

img

4.8 任务寄存器(Task Register)

  x86CPU的构想是每一个任务对应一个TSS,然后由TR寄存器指向当前的任务,执行任务切换时,修改TR寄存器的指向即可,这是硬件层面的多任务切换机制。这个构想其实还是很不错的,然而现实却打了脸,包括Linux和Windows在内的主流操作系统都没有使用这个机制来进行线程切换,而是自己使用软件来实现多线程切换。所以,绝大多数情况下,TR寄存器都是指向固定的,即便线程切换了,TR寄存器仍然不会变化。

4.9 模型特定寄存器(Module Specific Register)

  模型特定寄存器,即MSR寄存器,这些寄存器不像上面列出的寄存器是固定的,这些寄存器可能随着不同的版本有所变化。这些寄存器主要用来支持一些新的功能。随着x86CPU不断更新换代,MSR寄存器变的越来越多,但与此同时,有一部分MSR寄存器随着版本迭代,慢慢固化下来,成为了变化中那部分不变的,这部分MSR寄存器,Intel将其称为Architected MSR,这部分MSR寄存器,在命名上,统一加上了IA32的前缀。

  如IA32_SYSENTER_CS,IA32_SYSENTER_ESP和IA32_SYSENTER_EIP,这三个MSR寄存器是用来实现快速系统调用。在早期的x86架构CPU上,系统调用依赖于软中断实现,类似于前面调试用到的int 3指令,在Windows上,系统调用用到的是int 2e,在Linux上,用的是int 80。软中断毕竟还是比较慢的,因为执行软中断就需要内存查表,通过IDTR定位到IDT,再取出函数进行执行。系统调用是一个频繁触发的动作,如此这般势必对性能有所影响。在进入奔腾时代后,就加上了上面的三个MSR寄存器,分别存储了执行系统调用后,内核系统调用入口函数所需要的段寄存器、堆栈栈顶、函数地址,不再需要内存查表。快速系统调用还提供了专门的CPU指令sysenter/sysexit用来发起系统调用和退出系统调用。在64位上,这一对指令升级为syscall/sysret

4.10 存储寄存器(Memory Register)

  存储寄存器包括地址存储和数据存储,即MAR和MDR寄存器。这两种寄存器会和RAM及缓存打交道,用于数据和地址的存取工作,是最常用的寄存器之一。

4.11 累积寄存器(Accumulator Register)

  累积寄存器保存ALU累加结果并用于后续计算,也是常用寄存器。

五. 冒险和预测

  了解了CPU的基本结构和底层实现的寄存器后,我们聚焦于多指令CPU流水线模型。其中最关键的,则是流水线设计需要解决的三大冒险:结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。

5.1 结构冒险

  结构冒险源于流水线操作重,取指令和访问存储器可能会发生读取冲突。为此,我们常见的解法是将指令和数据分开存放,即指令缓存和数据缓存,结构如下所示。之所以采用混合架构而非彻底的分开指令和数据,是因为如果强制拆分,则无法动态分配二者内存,失去了灵活性。内存的访问速度远比 CPU 的速度要慢,所以现代的 CPU 并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分,使得我们的 CPU 在进行数据访问和取指令的时候,不会再发生资源冲突的问题了。

img

5.2 数据冒险

  数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW)。

  • 先写后读:同一个寄存器存在先写入、后读出的顺序关系,即数据依赖
  • 先读后写:同一个寄存器存在先读出、后写入的顺序关系,即反依赖
  • 写后再写:同一个寄存器需要先后写入两次,即输出依赖

5.2.1 流水线冒泡

  解决数据冒险最经典的方法是流水线停顿(Pipeline Stall),又称流水线冒泡(Pipeline Bubbling)。该方法简言之就是对需要等待的地方插入一个空指令NOP,从而延缓后续的流水线操作。

img

5.2.2 操作数前推

  流水线冒泡毫无疑问可以解决问题,但是也带来了性能的下降,为此CPU后续提出了更精妙的解决方案:操作数前推(Operand Forwarding),或称操作数旁路(Operand Bypassing)。如下所示为先后执行两个ADD指令的情况,其中第二个ADD依赖于第一个ADD的结果。常规做法是先做流水线对齐、插入NOP,然后规避风险停顿一个阶段。而实际上,我们可以将第一个指令执行后的结果直接发送给第二个指令执行,即不从写回的寄存器读取,这就是前推/转发的真谛。

  转发,其实是这个技术的逻辑含义,也就是在第 1 条指令的执行结果,直接“转发”给了第 2 条指令的 ALU 作为输入。另外一个名字,旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”,我们在 CPU 的硬件里面,需要再单独拉一根信号传输的线路出来,使得 ALU 的计算结果,能够重新回到 ALU 的输入里来。这样的一条线路,就是我们的“旁路”。它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了 2 个时钟周期。

img img

5.2.3 乱序执行

  但是哪怕是操作前推+流水线冒泡,我们还是会存在一些NOP。对此我们还有另一招:乱序执行。如果第三条指令并不依赖于前两条指令的计算结果,则可以在第二条等待的时候,让第三条抢先执行。从今天软件开发的维度来思考,乱序执行好像是在指令的执行阶段,引入了一个“线程池”。

img
  1. 在取指令和指令译码的时候,乱序执行的 CPU 和其他使用流水线架构的 CPU 是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
  2. 在指令译码完成之后,就不一样了。CPU 不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站(Reservation Stations)的地方。
  3. 这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。
  4. 一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function Unit,FU),其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。
  5. 指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方。
  6. 在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
  7. 实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer )里面,最终才会写入到高速缓存和内存里。

  整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。乱序执行,极大地提高了 CPU 的运行效率。

5.3 控制冒险

  在程序中,我们存在很多的判断和循环语句,为了确保能取到正确的指令,而不得不进行等待延迟的情况就是控制冒险。

5.3.1 缩短分支延迟

  最简单的做法是缩短分支延迟:我们可以将条件判断、地址跳转,都提前到指令译码阶段进行,而不需要放在指令执行阶段。对应的,我们也要在 CPU 里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。这种方式,本质上和前面数据冒险的操作数前推的解决方案类似,就是在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。

  不过只是改造硬件,并不能彻底解决问题。跳转指令的比较结果,仍然要在指令执行的时候才能知道。在流水线里,第一条指令进行指令译码的时钟周期里,我们其实就要去取下一条指令了。这个时候,我们其实还没有开始指令执行阶段,自然也就不知道比较的结果。

5.3.2 分支预测

  最简单的分支预测技术,叫作“假装分支不发生”。顾名思义,自然就是仍然按照顺序,把指令往下执行。其实就是 CPU 预测,条件跳转一定不发生。这样的预测方法,其实也是一种静态预测技术。如果分支预测是正确的,我们自然赚到了。这个意味着,我们节省下来本来需要停顿下来等待的时间。如果分支预测失败了呢?那我们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。CPU 不仅要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,我们还需要做对应的清除操作。比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。所以,CPU 需要提供对应的丢弃指令的功能,通过控制信号清除掉已经在流水线中执行的指令。只要对应的清除开销不要太大,我们就是划得来的。

5.3.3 动态分支预测

  静态分支预测的概率不够理想,由此出现了动态分支预测。动态分支预测会根据之前的状态进行一个推算,从而在一定程度上提高预测的成功率。该部分有着众多算法,并且在不断演进,是现代CPU的核心技术之一。

六. 高级架构

6.1 多发射和超标量

  在前面我们提到了乱序执行功能,而实际上取指令和译码也没必要挨个进行,这就是多发射(Mulitple Issue)和超标量(Superscalar)。只要我们把取指令和指令译码,也一样通过增加硬件的方式,并行进行就好了。我们可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。这样,我们在一个时钟周期里,能够完成的指令就不只一条了。IPC (Instruction Per Clock)也就能做到大于 1 了。

img

  多发射指的是同一个时间,可能会同时把多条指令发射(Issue)到不同的译码器或者后续处理的流水线中去。而超标量表示的是在一个时钟周期里面,原本只能执行一个标量(Scalar)的运算。在多发射的情况下,我们就能够超越这个限制,同时进行多次计算。

  在英特尔的历史上,安腾的超长指令字设计就源于该思想:我们可以让编译器把没有依赖关系的代码位置进行交换。然后,再把多条连续的指令打包成一个指令包。这种技术称为超长指令字(VLIW)技术。安腾的 CPU 就是把 3 条指令变成一个指令包。但是该架构由于和X86系统不兼容,无法做到向前兼容,同时也不易向后兼容,因此注定了无法占领市场,最终被淘汰。

6.2 超线程技术

  超线程技术的思路和多发射类似,就是采取一些方式提高并发量,让不会产生相关性的指令并发执行,从而增加CPU的处理性能。对于传统CPU结构来说,在同一时间点上,一个物理的 CPU 核心只会运行一个线程的指令,所以其实我们并没有真正地做到指令的并行运行。超线程的 CPU,其实是把一个物理层面 CPU 核心,“伪装”成两个逻辑层面的 CPU 核心。这个 CPU,会在硬件层面增加很多电路,使得我们可以在一个 CPU 核心内部,维护两个不同线程的指令的状态信息。比如,在一个物理 CPU 核心内部,会有双份的 PC 寄存器、指令寄存器乃至条件码寄存器。这样,这个 CPU 核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的 CPU 在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术。

img

  需要注意的是,超线程技术和多核的区别在于,在 CPU 的其他功能组件上,Intel 可不会提供双份。无论是指令译码器还是 ALU,一个 CPU 核心仍然只有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。超线程的目的,是在一个线程 A 的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU 的译码器和 ALU 就空出来了,那么另外一个线程 B,就可以拿来干自己需要的事情。这个线程 B 可没有对于线程 A 里面指令的关联和依赖。

6.3 SIMD

  SIMD,即单指令多数据流(Single Instruction Multiple Data) 。SIMD 在获取数据和执行指令的时候,都做到了并行。一方面,在从内存里面读取数据的时候,SIMD 是一次性读取多个数据。另一方面,多个数据只要不互相依赖,则可以并行单独计算,从而实现了执行的并行。对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用 SIMD 是一个很划算的办法。在这个大量的“数据并行”,其实通常就是实践当中的向量运算或者矩阵运算。在实际的程序开发过程中,过去通常是在进行图片、视频、音频的处理。最近几年则通常是在进行各种机器学习算法的计算。

6.4 GPU

  GPU的出现和图像学的进步密不可分,而实际还是要感谢游戏的出现和进步。对于图像进行实时渲染的过程,可以被分解成下面这样 5 个步骤:顶点处理(Vertex Processing),图元处理(Primitive Processing),栅格化(Rasterization),片段处理(Fragment Processing),像素操作(Pixel Operations)。

  • 顶点处理:图形渲染的第一步是顶点处理。构成多边形建模的每一个多边形,都有多个顶点(Vertex)。这些顶点都有一个在三维空间里的坐标。但是我们的屏幕是二维的,所以在确定当前视角的时候,我们需要把这些顶点在三维空间里面的位置,转化到屏幕这个二维空间里面。这个转换的操作,就被叫作顶点处理。可以想见,我们的建模越精细,需要转换的顶点数量就越多,计算量就越大。而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的。
  • 图元处理:图元处理,其实就是要把顶点处理完成之后的各个顶点连起来,变成多边形。我们针对这些多边形,需要做一个操作,叫剔除和裁剪(Cull and Clip),也就是把不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。
  • 栅格化:在图元处理完成之后呢,渲染还远远没有完成。我们的屏幕分辨率是有限的。它一般是通过一个个“像素(Pixel)”来显示出内容的。所以,对于做完图元处理的多边形,需要把它们转换成屏幕里面的一个个像素点。这个操作就叫作栅格化。这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。
  • 片段处理:在栅格化变成了像素点之后,我们的图还是“黑白”的。我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。
  • 像素操作:把不同的多边形的像素点“混合(Blending)”到一起。可能前面的多边形可能是半透明的,那么前后的颜色就要混合在一起变成一个新的颜色;或者前面的多边形遮挡住了后面的多边形,那么我们只要显示前面多边形的颜色就好了。最终,输出到显示设备。

  由上可见,图形处理的最大特点在于,计算量大、计算方法单一、计算之间没有依赖关系。而这完美规避了CPU复杂运算中可能遇到的冒险,由此我们完全可以做成“简化专用”版CPU,这就是GPU。早期的GPU没有顶点处理步骤,而是依然在CPU中执行。而后续GPU的发展,不仅将整个过程移到了GPU中,并且提供了一定的编程能力供以程序员使用,这就是可编程管线(Programable Function Pipeline)

img

  上古时代,可编程管线仅限于顶点处理(Vertex Processing)和片段处理(Fragment Processing)部分。这些可以编程的接口,我们称之为 Shader,即着色器。大家很快发现,虽然我们在顶点处理和片段处理上的具体逻辑不太一样,但是里面用到的指令集可以用同一套。而且,虽然把 Vertex Shader 和 Fragment Shader 分开,可以减少硬件设计的复杂程度,但是也带来了一种浪费,有一半 Shader 始终没有被使用。在整个渲染管线里,Vertext Shader 运行的时候,Fragment Shader 停在那里什么也没干。Fragment Shader 在运行的时候,Vertext Shader 也停在那里发呆。于是,统一着色器架构(Unified Shader Architecture)就应运而生了,也称为CUDA(Compute Unified Device Architecture)。正是因为 Shader 变成一个“通用”的模块,才有了把 GPU 拿来做各种通用计算的用法,也就是 GPGPU(General-Purpose Computing on Graphics Processing Units,通用图形处理器)。而正是因为 GPU 可以拿来做各种通用的计算,才有了过去 10 年深度学习的火热。

img

  现代GPU在此基础上继续优化,并为机器学习和图形渲染提供了充实的硬件基础。首先,我们将CPU中复杂的高速缓存、乱序执行、分支预测等通通去掉,尽可能简化了硬件架构,因为图形处理本身是流式处理,并无太多分支和依赖。

img

  上节提到了SIMD技术,而这种技术在 GPU 的渲染管线里大放异彩。无论是顶点去进行线性变换,还是屏幕上临近像素点的光照和上色,都是在用相同的指令流程进行计算。所以,GPU 就借鉴了 CPU 里面的 SIMD,用了一种叫作SIMT(Single Instruction,Multiple Threads)的技术。SIMT 比 SIMD 更加灵活。在 SIMD 里面,CPU 一次性取出了固定长度的多个数据,放到寄存器里面,用一个指令去执行。而 SIMT,可以把多条数据,交给不同的线程去处理。各个线程里面执行的指令流程是一样的,但是可能根据数据的不同,走到不同的条件分支。这样,相同的代码和相同的流程,可能执行不同的具体的指令。这个线程走到的是 if 的条件分支,另外一个线程走到的就是 else 的条件分支了。

img

  随着CUDA的普及,GPU也难免会出现逻辑判断(尽可能避免之后),所以超线程技术依然可以用于此。

img

  于此,现代GPU就成型了。在通过芯片瘦身、SIMT 以及更多的执行上下文,我们就有了一个更擅长并行进行暴力运算的 GPU。这样的芯片,也正适合我们今天的深度学习的使用场景。一方面,GPU 是一个可以进行“通用计算”的框架,我们可以通过编程,在 GPU 上实现不同的算法。另一方面,现在的深度学习计算,都是超大的向量和矩阵,海量的训练样本的计算。整个计算过程中,没有复杂的逻辑和分支,非常适合 GPU 这样并行、计算能力强的架构。

6.5 TPU

  TPU是谷歌为深度学习量身定制的计算处理器。深度学习除了大量的矩阵计算以外,还需要进行推断。所谓推断部分,是指我们在完成深度学习训练之后,把训练完成的模型存储下来。这个存储下来的模型,是许许多多个向量组成的参数。然后,我们根据这些参数,去计算输入的数据,最终得到一个计算结果。这个推断过程,可能是在互联网广告领域,去推测某一个用户是否会点击特定的广告;也可能是我们在经过高铁站的时候,扫一下身份证进行一次人脸识别,判断一下是不是你本人。

  相对于模型训练,模型的推断主要有以下特点:

  • 深度学习的推断工作更简单,对灵活性的要求也就更低。模型推断的过程,我们只需要去计算一些矩阵的乘法、加法,调用一些 Sigmoid 或者 RELU 这样的激活函数。这样的过程可能需要反复进行很多层,但是也只是这些计算过程的简单组合。
  • 深度学习的推断的性能,首先要保障响应时间的指标。推断过程,像互联网广告的点击预测,我们往往希望能在几十毫秒乃至几毫秒之内就完成,而人脸识别也不希望会超过几秒钟。很显然,模型训练和推断对于性能的要求是截然不同的。
  • 深度学习的推断工作,希望在功耗上尽可能少一些。深度学习的训练,对功耗没有那么敏感,只是希望训练速度能够尽可能快,多费点电就多费点儿了。这是因为,深度学习的推断,要 7×24h 地跑在数据中心里面。而且,对应的芯片,要大规模地部署在数据中心。一块芯片减少 5% 的功耗,就能节省大量的电费。

  由于以上差别,GPU显然不能很好适应深度学习的推断功能,因此才有了TPU的问世。关于TPU的具体架构,在此不做详细展开了,有兴趣可以自行研究。

总结

  本文较为详细的介绍了CPU的发展过程和各种技术,由此加深我们对CPU的理解,从而写出性能更好的代码。

参考文献

[1] Linux-insides

[2] 深入理解Linux内核

[3] Linux内核设计的艺术

[4] 深入理解计算机系统

[5] 深入理解Linux网络技术内幕

[6] 计算机组成原理

[7] 极客时间 深入浅出计算机组成原理

[8] 计算机组成与设计:硬件/软件接口

[9] 编码:隐匿在计算机软硬件背后的语言

[10] In-Datacenter Performance Analysis of a Tensor Processing Unit

坚持原创,坚持分享,谢谢鼓励和支持