19.4 ISA
19.4.1 ISA定义
就像任何语言都有有限的单词一样,处理器可以支持的基本指令/基本命令的数量也必须是有限的,这组指令通常称为指令集(instruction set),基本指令的一些示例是加法、减法、乘法、逻辑或和逻辑非。请注意,每条指令需要处理一组变量和常量,最后将结果保存在变量中,这些变量不是程序员定义的变量,是计算机内的内部位置。我们将指令集架构定义为:
指令集架构(instruction set architecture,ISA)是处理器支持的所有指令的语义,包括指令本身及其操作数的语义,以及与外围设备的接口。
指令集架构是软件感知硬件的方式,我们可以将其视为硬件输出到外部世界的基本功能列表。Intel和AMD CPU使用x86指令集,IBM处理器使用PowerPC R指令集,HP处理器使用PA-RISC指令集,ARM处理器使用ARMR指令集(或其变体,如Thumb-1和Thumb-2)。因此,不可能在基于ARM的系统上运行为Intel系统编译的二进制文件,因为指令集不兼容,但在大多数情况下,可以重用C/C++程序。要在特定架构上运行C/C++程序,我们需要为该特定架构购买一个编译器,然后适当地编译C/C++程序。
19.4.2 基础指令
基本计算机具有16位指令寄存器 (IR),可以表示内存引用或寄存器引用或输入输出指令。一种简单的指令格式可以是如下形式:
基础指令可分为以下几类:
- 内存引用
这些指令将内存地址称为操作数,另一个操作数总是累加器。下图为直接和间接寻址指定12位地址、3位操作码(111除外)和1位寻址模式。
示例:IR寄存器内容是0001XXXXXXXXXXXX,即ADD指令取指译码后发现是ADD操作的内存引用指令,因此:
DR ← M[AR]
AC ← AC + DR, SC ← 0
- 寄存器引用
这些指令对寄存器而不是内存地址执行操作。下图的IR(14 – 12) 为 111(将其与内存引用区分开),IR(15) 为 0(将其与输入/输出指令区分开),其余12位指定寄存器操作。
示例:IR寄存器内容是0111001000000000,即CMA在取指和解码周期后发现它是补码累加器的寄存器引用指令,因此:
AC ← ~AC
- 输入/输出
这些指令用于计算机和外部环境之间的通信。下图的IR(14 – 12) 为 111(将其与内存引用区分开来),IR(15) 为 1(将其与寄存器引用指令区分开),其余 12 位指定 I/O 操作。
示例:IR寄存器内容是1111100000000000,即INP经过取指和解码循环后发现它是用于输入字符的输入/输出指令。因此,来自外围设备的INPUT字符。
包含在16位IR寄存器中的指令集是:
- 算术、逻辑和移位指令(与、加、补、左循环、右循环等)。
- 将信息移入和移出内存(存储累加器,加载累加器)。
- 带有状态条件的程序控制指令(分支、跳过)。
- 输入输出指令(输入字符、输出字符)。
指令具体的描述如下表:
19.4.3 指令集设计准则
现在让我们开始为处理器设计指令集的艰难过程,可以将指令集视为软件和硬件之间的法律合同,双方都需要履行各自的合同。软件部分需要确保用户编写的所有程序都能成功有效地转译成基本指令,同样,硬件需要确保指令集中的所有指令都是有效实现的。双方都需要做出合理的假设,ISA需要具有一些必要的特性和一些有效性所需的特性。
- 完整。ISA应能够实现所有用户程序,是绝对必要的要求,我们希望ISA能够代表用户为其编写的所有程序。例如,如果我们有一个ISA,只有一条ADD指令,那么我们将无法减去两个数字。为了实现循环,ISA应该有一些方法来一遍遍地重新执行同一段代码。如果没有这种对和while循环的支持,C程序中的循环将无法工作。
请注意,对于通用处理器,我们正在查看所有可能的程序。然而,许多用于嵌入式设备的处理器功能有限,例如执行字符串处理的简单处理器不需要支持模拟点数(带小数点的数字)。我们需要注意的是,不同的处理器被设计用于做不同的事情,因此它们的ISA可能不同。然而,底线是任何ISA都应该是完整的,因为它应该能够用机器代码表达用户打算为其编写的所有程序。
- 简明。指令集的有限大小,最好不要有太多的指示。实现一条指令需要相当多的硬件,执行大量指令将不必要地增加处理器中晶体管的数量并增加其复杂性。因此,大多数指令集都有64到1000条指令。例如,MIPS指令集包含64条指令,而截至2012年,Intel x86指令集大约有1000条指令。请注意,对于ISA中的指令数量,1000条被认为是相当大的数字。
- 通用。指令应捕获通用案例,程序中的大多数常见指令都是简单的算术指令,如加法、减法、乘法、除法。最常见的逻辑指令是逻辑和、或、异或、和非。因此,为这些常见操作中的每一个指定一条指令是有意义的。
很少使用的计算的指令不是一个好主意。例如,实现计算$\sin^{-1}(x)$的指令可能没有意义,可以提供使用现有的数学技术(如泰勒级数展开)实现的专用库函数来计算$\sin^{-1}(x)$。由于大多数程序很少使用此函数,因此如果此函数执行时间相对较长,它们不会受到不利影响。
- 简单。指令应该尽量简单。假设有很多添加数字序列的程序,为了设计专门针对此类程序定制的处理器,我们有几个关于add指令的选项。我们可以实现一条将两个数字相加的指令,也可以实现一个可以获取操作数列表并生成列表和的指令。这里的复杂性显然存在差异,不能说哪种实现更快。前一种方法要求编译器生成更多指令,但是,每个添加操作都执行得很快。后一种方法生成的指令数量更少,但是,每条指令执行的时间更长。前一种类型的ISA称为精简指令集(Reduced Instruction Set),后一种ISA称为复杂指令集(Complex Instruction Set)。
精简指令集计算机(reduced instruction set computer,RISC)实现具有简单规则结构的简单指令,指令的数量通常很小(64到128)。示例:ARM、IBM PowerPC、HP PA-RISC。
复杂指令集计算机(complex instruction set computer,CISC)实现高度不规则的复杂指令,采用多个操作数,并实现复杂功能。其次,指令的数量很大(通常为500+)。示例:Intel x86、VAX。
直到90年代末,RISC与CISC的争论一直是一个非常有争议的问题。然而,从那时起,设计师、程序员和处理器供应商一直倾向于RISC设计风格,共识似乎是采用少量相对简单的、具有规则结构和格式的指令。值得注意的是,这一点仍有争议,因为CISC指令有时更适合某些类型的应用。现代处理器通常使用混合方法,其中既有简单的指令,也有一些复杂的指令。然而,在底层,CISC指令被转译成RISC指令。因此,我们认为行业稍微偏向RISC指令,认为有简单的指示是一种可取的特性。
ISA需要完整、简洁、通用和简单,且必须完整,而其余属性是可取的(但附有争议)。
19.4.4 图灵机和指令完整性
如何验证ISA的完整性?这是一个非常有趣、困难且理论上深刻的问题。确定给定ISA对于给定程序集是否完整的问题是一个相当困难的问题,一般情况要有趣得多。我们需要回答这个问题:给定ISA,它能代表所有可能的程序吗?
假设有一个ISA,其中包含基本的加法和乘法指令,我们能用这个ISA运行所有可能的程序吗?答案是否定的,因为我们不能用现有的基本指令减去两个数字。如果我们将减法指令添加到指令库中,我们可以计算一个数的平方根吗?即使我们可以,是否可以保证我们可以进行所有类型的计算?要回答这些令人烦恼的问题,我们需要首先设计一台通用机器。
通用机器(universal machine)是可以执行任何程序的机器。
它是一台可以执行所有程序的机器,可以把这台机器的每一个基本动作都当作一条指令。通用机器的一组动作就是它的ISA,而这个ISA是完整的。当说ISA是完整的时,相当于说可以专门基于给定的ISA构建通用机器,可以通过解决通用机器的设计问题来解决ISA的完整性问题。它们是双重问题,就通用机器而言,推理更容易。
20世纪初,计算机科学家开始思考通用机器的设计,他们想知道什么是可计算的,什么不是,以及不同类别机器的能力。其次,能够计算所有可能程序结果的理论机器的形式是什么?计算机科学的这些基本结果构成了当今现代计算机体系结构的基础。
阿兰·图灵(Alan Turing)是第一个提出一种极其简单和强大的通用机器的人,这台机器恰如其分地以他的名字命名,被称为图灵机器(Turing machine)。这只是一个理论实体,通常用作数学推理工具,可以创建图灵机的硬件实现,然而极为困难,并且需要不成比例的资源。尽管如此,图灵机构成了当今计算机的基础,而现代ISA是从图灵机的基本动作中派生出来的。因此,非常有必要研究它的设计。
下图显示了图灵机的一般结构,它包含一个内部磁带,磁带是一个单元阵列,每个单元格可以包含有限字母表中的符号,有一个特殊符号$用作特殊标记,一个专用的磁带头指向磁带中的一个单元。在一组状态中,有一小块存储器可以保存当前状态,该存储元件称为状态寄存器。
上面描述的图灵机不是通用机器,因为它包含一个动作表,该动作表特定于机器正在计算的函数。一个真正的通用机器将具有相同的动作表、符号以及每个功能的相同状态集。如果我们能设计一个能模拟另一个图灵机的图灵机,我们就能制造一个通用图灵机——通用且不会特定于正在计算的函数。
让被模拟的图灵机被称为M,通用图灵机则被称为U。让我们首先为M的动作表创建一个通用格式,并将其保存在U磁带上的指定位置,每个动作都需要5个参数——旧状态、旧符号、方向(左或右)、新状态、新符号。我们可以使用一组常见的基本符号,可以是10位十进制数字(0-9),如果一个函数需要更多的符号,那么我们可以考虑将一个符号包含在一组由特殊分隔符划分的连续单元中。让这样的符号称为模拟符号。同样,模拟动作表中的状态也可以编码为十进制数。对于方向,我们可以使用0表示左侧,1表示右侧。因此,单个动作表条目可能看起来像(@1334@34@0@1335@10@),其中“@”是分隔符,该条目表示,如果遇到符号34,我们将从状态1334移动到1335。我们向左移动(0),并写一个值10。因此,我们找到了一种对用于计算某个函数的图灵机的动作表、符号集和状态进行编码的方法。
类似地,我们可以指定磁带的一个区域来包含M的状态寄存器,称之为模拟状态寄存器。让M的磁带在U的磁带中有一个专用的空间,我们把这个空间称为工作区(work area)。这种组织如下图所示。
通用图灵机的布局。
磁带因此分为三部分,第一部分包含模拟动作表,第二部分包含模拟状态寄存器,最后一部分包含包含一组模拟符号的工作区。通用图灵机(U)有一个非常简单的动作表和一组状态,其思想是在模拟动作表中查找与模拟状态寄存器中的值和磁带头下的模拟符号相匹配的正确条目。然后,通用图灵机需要通过移动到新的模拟状态来执行相应的动作,并在需要时覆盖工作区中的模拟符号。为了做每一个基本动作,U需要做几十次磁带头运动。然而,结论是我们可以构造一个通用的图灵机。
可以构造一个通用的图灵机,它可以模拟任何其他的图灵机器。
自20世纪50年代以来,研究人员设计了更多类型的具有自己的状态和规则集的假想机器,这些机器中的每一台都已被证明至多与图灵机一样强大。所有机器和计算系统都有一个通用名称,它们都像图灵机一样具有表达力和功能。这种系统可以说是图灵完整的(Turing complete)。因此,任何通用机器和ISA都是图灵完整的。
任何等同于图灵机的计算系统都被称为图灵机。
因此,如果ISA是图灵完整的,我们需要证明ISA是完整的或通用的。
现在考虑一个更适合实际实现的通用图灵机的变体(下图),让它具有以下特性。请注意,这样的机器已经被证明是图灵完整的。
一种改进的通用图灵机
- 磁带为半无限(semi-infinite,仅在一个方向上延伸至无限)。
- 模拟状态是指向模拟动作表中的条目的指针。
- 每个状态的模拟动作表中有一个唯一的条目。在查找模拟动作表时,我们不关心磁带头下的符号。
- 一个动作指示磁带头访问工作区中的一组位置,并根据它们的值使用简单的算术函数计算一个新值。它将此新值写入工作区中的新位置。
- 默认的下一个状态是动作表中的后续状态。
- 如果磁带上某个位置的符号小于某个值,动作也可以任意改变状态,意味着模拟磁带头将开始从模拟动作表中的新区域提取动作。
这台图灵机建议采用以下形式的机器组织。有大量指令(动作表),这个指令数组通常被称为程序。有一个状态寄存器,用于维护指向数组中当前指令的指针,称为程序计数器,可以更改程序计数器以指向新指令。有一个大的工作区,可以存储、检索和修改符号,此工作区也称为数据区。指令表(程序)和工作区(数据)保存在我们改进的图灵机的磁带上。在实际的机器中,有限磁带可被看作内存。存储器是一个大的存储单元阵列,其中存储单元包含一个基本符号。内存的一部分包含程序,另一部分包含数据。
此外,每条指令都可以读取内存中的一组位置,计算它们上的一个小算术函数,并将结果写回内存,还可以根据内存中的值跳转到任何其他指令。有一个专用单元来计算这些算术函数,写入内存,并跳转到其他指令,被称为CPU(中央处理单元)。下图显示了该机器的概念组织。
基本指令处理器。
上面我们已经捕获了图灵机的所有方面:状态转换、磁带头的移动、重写符号以及基于磁带头下符号的决策。这种机器与冯·诺依曼机器非常相似,后者构成了当今计算机的基础。
现在,让我们尝试为改进的图灵机设计一个ISA,有可能有一个只包含一条指令的完整ISA,考虑一个与改进的图灵机兼容并且已经被证明是图灵完备的指令。
sbn a, b, c
sbn表示减法,如果为负数则分支,此指令从a中减去b(a和b是存储器位置),将结果保存在a中。如果a<0,则跳转到指令表中位置c处的指令,否则,控制转移到下一条指令。例如,我们可以使用此指令将保存在位置a和b中的两个数字相加。请注意,退出是程序末尾的一个特殊位置。
1: sbn temp, b, 2
2: sbn a, temp, exit
这里假设内存位置temp已经包含值0。第一条指令将$-b$保存在temp中,不管结果的值如何,它都跳到下一条指令。请注意,标识符(数字:)是指令的序列号。在第二条指令中,计算$a = a + b = a - (-b)$。因此,成功地相加了两个数字,现在可以使用这段基本代码将数字从1加到10。我们假设变量计数器初始化为9,索引初始化为10,一初始化为1,和初始化为0。
1: sbn temp, temp, 2 // temp = 0
2: sbn temp, index, 3 // temp = -1 * index
3: sbn sum, temp, 4 // sum += index
4: sbn index, one, 5 // index -= 1
5: sbn counter, one, exit // loop is finished, exit
6: sbn temp, temp, 7 // temp = 0
7: sbn temp, one, 1 // (0 - 1 < 0), hence goto 1
我们观察到,这个小的操作序列运行for循环。退出条件在第5行,循环返回发生在第7行。在每一次迭代中,它都计算$-sum += index$。
有许多类似的单指令ISA已经被证明是完整的,例如,如果小于等于,则进行减法和分支,如果借用(borrow),则进行反向减法和跳过,以及具有通用内存移动操作的计算机。
用一条指令编写一个程序是非常困难的,而且程序往往很长。没有理由吝啬指令的数量,通过考虑大量的指令,可以使复杂程序的实现变得更加轻松。让我们尝试将基本的sbn指令分解为几个指令:
- 算术指令。可以有一组算术指令,如加法、减法、乘法和除法。
- 移动指令。可以有移动指令,在不同的内存位置移动值,允许将常量值加载到内存位置。
- 分支指令。需要根据计算结果或存储在内存中的值来改变程序计数器以指向新指令的分支指令。
记住这些基本原则,我们可以设计许多不同类型的完整ISA。需要注意的是,我们只需要三种类型的指令:算术(数据处理)、移动(数据传输)和分支(控制)。
在任何指令集中,至少需要三种类型的指令:
1、需要算术指令来执行加法、减法、乘法和除法等运算。大多数指令集也有这类专门的指令来执行逻辑运算,如逻辑OR和NOT。
2、需要数据传输指令,可以在内存位置之间传输值,并可以将常量加载到内存位置。
3、需要能够根据指令操作数的值在程序中的不同点开始执行指令的分支指令。
寄存器机(register machine)是指包含无限数量的命名存储位置,这些存储位置称为寄存器。寄存器可以随机访问,所有指令都使用寄存器名作为操作数。CPU访问寄存器,获取操作数,然后处理它们。还存在混合机器,它们可以增加存储空间带有寄存器的标准Von Neumann机器。寄存器是可以保存符号的存储位置。
存储器通常是非常大的结构,在现代处理器中,整个内存可以包含数十亿个存储位置,这种大小的内存的任何实际实现在实践中都相当缓慢。硬件中有一个一般的经验法则,大则慢,小则快。因此,为了实现快速操作,每个处理器都有一组可以快速访问的寄存器,寄存器的数量通常在8到64之间。算术和分支操作中的大多数操作数都存在于这些寄存器中,由于程序倾向于在任何时间点重复使用一小组变量,因此使用寄存器可以节省许多内存访问。然而,有时需要将内存位置引入寄存器或将寄存器中的值写回内存位置。在这些情况下,我们使用专用的加载和存储指令,在内存和寄存器之间传输值。大多数程序都有大多数纯寄存器指令,加载和存储指令的数量通常约为已执行指令总数的三分之一。
假设我们要将数字的3次方加到存储位置b和c中,并将结果保存在存储位置a中。带有寄存器的机器需要以下指令,假设r1、r2和r3是寄存器的名称,没有使用任何特定的(通用的、概念性的)ISA。
- 1: r1 = mem[b] // load b
- 2: r2 = mem[c] // load c
- 3: r3 = r1 * r1 // compute b^2
- 4: r4 = r1 * r3 // compute b^3
- 5: r5 = r2 * r2 // compute c^2
- 6: r6 = r2 * r5 // compute c^3
- 7: r7 = r4 + r6 // compute b^3 + c^3
- 4: mem[a] = r7 // save the result
mem是表示内存的数组,需要首先将值加载到寄存器中,然后执行算术计算,然后将结果保存回内存。上面的代码通过使用寄存器来节省内存访问,如果增加计算的复杂性,将节省更多的内存访问,因此,使用寄存器的执行速度会更快。最终的处理器组织如下图所示。
很明显,安排计算在堆栈上工作是不可取的,将有许多冗余负载和存储。尽管如此,对于打算计算长数学表达式的机器,以及程序大小是一个问题的机器,通常会选择堆栈。很少有基于堆栈的机器的实际实现,如Burroughs Large Systems、UCSD Pascal和HP 3000(经典)。Java语言在编译过程中假设一台基于堆栈的机器,由于基于堆栈的机器很简单,Java程序实际上可以在任何硬件平台上运行。当我们运行编译后的Java程序时,Java虚拟机(JVM)会动态地将Java程序转换为另一个可以在带有寄存器的机器上运行的程序。
基于累加器的机器使用一个寄存器,称为累加器(accumulator)。每条指令都将单个内存位置作为输入操作数,例如,加法运算将累加器中的值与存储器地址中的值相加,然后将结果存储回累加器。早期无法容纳寄存器的机器曾经有累加器,累加器能够减少内存访问的次数并加速程序。
累加器的某些方面已经渗透到英特尔x86处理器组中,这些处理器是2012年台式机和笔记本电脑最常用的处理器。对于大数的乘法和除法,这些处理器使用寄存器eax作为累加器。对于其他通用指令,任何寄存器都可以指定为累加器。
19.4.5 常见ISA
目前市面上流行的指令集包含ARM指令集和x86指令集。ARM是高级RISC机器(Advanced RISC Machines),是一家总部位于英国剑桥的标志性公司,截至2012年,包括苹果iPhone和iPad在内的大约90%的移动设备都运行在基于ARM的处理器上。同样,截至2012年超过90%的台式机和笔记本电脑运行在基于Intel或AMD的x86处理器上。ARM是RISC指令集,x86是CISC指令集。
还有许多其他为各种处理器量身定制的指令集,移动计算机的另一个流行指令集是MIPS指令集,基于MIPS的处理器也用于汽车和工业电子中的各种处理器。
对于大型服务器,通常使用IBM(PowerPC)、Sun(如今的Oracle,UltraSparc)或HP(PA-RISC)处理器。每个处理器系列都有自己的指令集,这些指令集通常是RISC指令集,大多数ISA共享简单的指令,如加法、减法、乘法、移位和加载/存储指令。除了这个简单的集合,他们使用了大量更专业的指令。在ISA中选择正确的指令集取决于处理器的目标市场、工作负载的性质以及许多设计时间限制,下表显示了流行的指令集列表。
19.4.6 指令实现机制
有一小组基本逻辑组件,可以以各种方式组合起来存储二进制数据,并对该数据执行算术和逻辑运算。如果要执行特定的计算,则可以构造专门为该计算设计的逻辑组件的配置。我们可以将以所需配置连接各种组件的过程视为编程的一种形式。生成的“程序”是硬件形式的,称为硬连线程序(hardwired program)。
现在考虑这个替代方案。假设我们构造了算术和逻辑函数的通用配置,这组硬件将根据施加到硬件的控制信号对数据执行各种功能。在定制硬件的原始情况下,系统接受数据并产生结果(下图a)。使用通用硬件,系统接受数据和控制信号并产生结果,因此程序员只需要提供一组新的控制信号,而不是为每个新程序重新布线硬件。
硬件和软件方法。
如何提供控制信号?答案很简单,但很微妙。整个程序实际上是一系列步骤,在每个步骤中,对一些数据执行一些算术或逻辑运算。对于每个步骤,都需要一组新的控制信号。让我们为每一组可能的控制信号提供一个唯一的代码,并在通用硬件中添加一个可以接受代码并生成控制信号的段(上图b)。
编程现在更容易了。我们需要做的是提供一个新的代码序列,而不是为每个新程序重新布线硬件。实际上,每个代码都是一条指令,部分硬件解释每个指令并生成控制信号。为了区分这种新的编程方法,一系列代码或指令被称为软件。
19.4.6.1 存储指令
让我们考虑加载指令:ld r1, 12[r2]
,此处将内存地址计算为r2和数字12的内容之和。ld指令访问此内存地址,获取存储的整数并将其存储在r1中。假设计算的内存地址指向整数的第一个存储字节(即小端表示),所以内存地址包含LSB。详情如下图(a)所示。存储操作则相反,将r1的值存储到存储器地址(r2+12)中,如下图(b)所示。
19.4.6.2 函数
回顾一下实现一个简单函数的基本要求。假设地址为A的指令调用函数foo,在执行函数foo之后,需要立即返回A处指令之后的指令,该指令的地址为A+4(如果我们假设A处的指令长度为4字节)。这个过程被称为从函数返回,地址(a+4)被称为返回地址。
返回地址(Return address)是进程在执行函数后需要分支到的指令的地址。
因此,实现函数有两个基本方面:1、调用或调用函数的过程;2、涉及从函数返回。
函数本质上是一块汇编代码,调用一个函数本质上是让PC指向这段代码的开头。我们可以将标签与每个函数相关联,标签应该与函数中的第一条指令相关联,调用函数就像分支到函数开头的标签一样简单。然而,这只是故事的一部分,我们还需要实现返回功能。因此,我们不能使用无条件分支指令来实现函数调用。
因此,让我们提出一个专用的函数调用指令,它分支到函数的开头,同时保存函数需要返回的地址(称为返回地址)。让我们考虑下面的C代码,并假设每个C语句对应于一行汇编代码。
a = foo(); /* Line 1 */
c = a + b; /* Line 2 */
在这个小代码片段中,我们使用函数调用指令来调用foo函数,返回地址是第2行中指令的地址。调用指令必须将返回地址保存在专用存储位置,以便以后可以检索。大多数RISC指令集都有一个专用寄存器,称为返回地址寄存器(不妨称为ra),用于保存返回地址,返回地址寄存器由函数调用指令自动填充。当我们需要从函数返回时,我们需要分支返回地址寄存器中包含的地址。
如果foo调用另一个函数会发生什么?在这种情况下,ra中的值将被覆盖。我们稍后将讨论这个问题。现在让我们考虑将参数传递给函数并返回返回值的问题。
假设函数foo调用函数foobar。foo被称为调用者(caller),foobar被称为被调用者(callee)。请注意,调用方与被调用方的关系是不固定的。foo可以调用foobar,foobar也可以在同一个程序中调用foo。根据哪个函数调用另一个函数来决定单个函数调用的调用者和被调用者。
调用者和被调用者都看到相同的寄存器视图。因此,我们可以通过寄存器传递参数,同样也可以通过寄存器来传递返回值。然而,正如我们在下面列举的,在这个简单的想法中有几个问题(假设我们有16个寄存器)。
- 一个函数可以接受16个以上的参数,比我们现有的通用寄存器数量还要多,因此需要添加额外的空间来保存参数。
- 函数可以返回大量数据,例如C中的大型结构。这段数据可能不可能在寄存器中存储。
- 被调用者可能会覆盖调用者将来可能需要的寄存器。
因此,我们观察到,通过寄存器传递参数和返回值只适用于简单的情况,不是一个非常灵活和通用的解决方案。尽管如此,我们的讨论提出了两个要求:
- 空间问题。我们需要额外的空间来发送和返回更多的参数。
- 覆盖问题。我们需要确保被调用方不会覆盖调用方的寄存器。
为了解决这两个问题,需要更深入地了解函数是如何工作的。可以将函数foo想象成一个黑匣子,它接受一系列参数并返回一组值。要执行它的工作,foo可以花费一纳秒、一周甚至一年的时间。foo可以调用其他函数来完成它的工作、将数据发送到I/O设备以及访问内存位置。下图是函数foo的可视化。
总而言之,通用函数处理参数,根据需要从内存和I/O设备读取和写入值,然后返回结果。关于内存和I/O设备,目前我们并不特别关心,有大量可用内存,空间不是主要限制,读写I/O设备通常也与空间限制无关。主要问题是寄存器,因为它们供不应求。
让我们先解决空间问题,可以通过寄存器和内存传输值。为了简单起见,如果我们需要传输少量数据,我们可以使用寄存器,否则我们可以通过内存传输它们。类似地,对于返回值,我们可以通过内存传输值。如果我们使用内存传输数据,那么我们不受空间限制。然而,这种方法缺乏灵活性,因为调用者和被调用者之间必须就要使用的内存位置达成严格的协议。请注意,我们不能使用一组固定的内存位置,因为被调用方可以递归调用自己。
void foobar()
{
...
foobar();
...
}
精明的读者可能会认为,被调用方可以从内存中读取参数并将其转移到内存中的其他临时区域,然后调用其他函数。然而,这种方法既不优雅,也不十分有效。稍后将研究更优雅的解决方案。
因此可以得出结论,我们已经部分解决了空间问题。如果需要在调用者和被调用者之间传输一些值,或者反之亦然,可以使用寄存器。但是,如果参数/返回值不在可用寄存器集中,那么需要通过内存传输它们。对于通过内存传输数据,我们需要一个优雅的解决方案,它不需要调用者和被调用者之间就用于传输数据的内存位置达成严格的协议。
将寄存器保存在内存中并随后恢复的概念称为寄存器溢出(register spilling)。
要解决覆盖问题,有两种解决方案:1、调用者可以将所需的寄存器集保存在内存中的专用位置,可以在被调用方完成后检索其寄存器集,并将控制权返回给调用方。2、让被调用方保存和恢复它需要的寄存器。这两种方法都如下图所示。这种将寄存器值保存在内存中,然后再检索的方法称为溢出。
调用方保存和被调用方保存的寄存器。
我们又遇到了同样的问题,即调用者和被调用者都需要就需要使用的内存位置达成严格的协议。现在让我们一起努力解决这两个问题。
我们简化了向函数传递参数和从函数传递参数,以及使用内存中的专用位置保存/恢复寄存器的过程。然而,该解决方案被发现是灵活的,对于大型现实世界程序来说,实现起来可能相当复杂。为了简化这个想法,让我们在函数调用中定义一个模式。
典型的C或Java程序从主函数开始。然后,该函数调用其他函数,这些函数可能反过来调用其他函数。最后,当主函数退出时,执行终止。每个函数定义一组局部变量,并对这些变量和函数参数执行计算,它还可以调用其他函数。最后,函数返回一个值,很少返回一组值(C中的结构)。请注意,函数终止后,不再需要局部变量和参数。因此,如果其中一些变量或参数保存在内存中,我们需要回收空间。其次,如果函数溢出了寄存器,那么这些内存位置也需要在它退出后释放。最后,如果被调用方调用另一个函数,则需要将返回地址寄存器的值保存在内存中,还需要在函数退出后释放此位置。
最好将所有这些信息连续保存在一个内存区域中,被称为函数的激活块(activation block),下图显示了激活块的内存映射。
激活块包含参数、返回地址、寄存器溢出区(对于调用方保存和被调用方保存的方案)和局部变量。一旦函数终止,就可以完全摆脱激活块。一个函数如果想要返回一些值,那么可以使用寄存器这样做,但是它如果想要返回一个大的结构,那么就可以将其写入调用方的激活块中,调用方可以在其激活块中提供一个可以写入该数据的位置。后面有可能更优雅地做到这一点,在解释如何做到这一点之前,需要了解如何在内存中安排激活块。
我们可以有一个存储区域,其中所有的激活块都存储在相邻的区域中。考虑一个例子,假设函数foo调用函数foobar,foobar又调用foobarbar。下图显示了4个内存状态:(a)在调用foobar之前,(b)在调用foobarbar之前,(c)在调用foobarbar之后,(d)在foobarar返回之后。
在这个内存区域中有一个后进先出的行为,最后调用的函数是要完成的第一个函数,这种后进先出的结构传统上被称为计算机科学中的堆栈(stack),因此专用于保存激活块的存储区域称为堆栈。传统上,堆栈被认为是向下增长的(向更小的内存地址增长),意味着主功能的激活块从非常高的位置开始,新的激活块被添加到现有激活块的正下方(朝向较低的地址)。堆栈的顶部实际上是堆栈中最小的地址,而堆栈的底部是最大的地址。堆栈的顶部表示当前正在执行的函数的激活块,堆栈的底部表示初始主函数。
堆栈(stack)是保存程序中所有激活块的内存区域,一般情况是向下增长的。在调用函数之前,我们需要将其激活块推送到堆栈中,当函数完成执行时,需要将其激活块弹出到堆栈中。
堆栈指针寄存器(stack pointer register)保存指向堆栈顶部的指针。
大多数架构将指向堆栈顶部的指针保存在一个称为堆栈指针的专用寄存器中,常被称为sp。请注意,对于许多架构,堆栈是纯软件结构。对于他们来说,硬件不知道堆栈。但对于某些架构(如x86),硬件知道堆栈并使用它来推送返回地址或其他寄存器的值。即使在这种情况下,硬件也不知道每个激活块的内容,结构由程序集程序员或编译器决定。在所有情况下,编译器都需要显式添加汇编指令来管理堆栈。
为被调用方创建新的激活块涉及以下步骤。
- 将堆栈指针减小激活块的大小。
- 复制参数的值。
- 如果需要,通过写入相应的内存位置来初始化任何局部变量。
- 如果需要,溢出任何寄存器(存储到激活块)。
从函数返回时,必须销毁激活块,可以通过将激活块的大小添加到堆栈指针来完成。
通过使用堆栈,我们解决了所有问题。调用方和被调用方不能覆盖彼此的局部变量,局部变量保存在激活块中,两个激活块不重叠。除了变量之外,还可以通过在激活块中显式插入保存寄存器的指令来阻止被调用方重写调用方的寄存器。实现这一点有两种方法:调用者保存的方案和被调用方保存的方案。其次,无需就将用于传递参数的内存区域达成明确协议,堆栈可以用于此目的,调用者可以简单地将参数推送到堆栈上,这些参数将被推送到被调用方的激活块中,被调用方可以轻松使用它们。同样,当从函数,被调用方可以通过堆栈传递返回值,需要先通过减少堆栈指针来销毁其激活块,然后才能将返回值推送到堆栈上。调用方将知道被调用方的语义,因此在被调用方返回后,可以假定其激活块已被被调用方有效地放大,返回值占用了额外的空间。
ARM使用B/BL/BX/BLX等语句调用函数和返回函数,而x86使用call等指令调用函数,此外,x86和ARM都可使用ret指令返回地址。下面是ARM的函数调用示例代码:
.globl main
.extern abs
.extern printf
.text
output_str:
.ascii "The answer is %d\n\0"
@ returns abs(z)+x+y
@ r0 = x, r1 = y, r2 = z
.align 4
do_something:
push {r4, lr}
add r4, r0, r1
mov r0, r2
bl abs ; 调用abs
add r0, r4, r0
pop {r4, pc}
main:
push {ip, lr}
mov r0, #1
mov r1, #3
mov r2, #-4
bl do_something ; 调用do_something
mov r1, r0
ldr r0, =output_str
bl printf
mov r0, #0
pop {ip, pc}
有趣的指令是pushpop和bl,只需获取提供的寄存器列表并将其推到堆栈上,或者将其弹出并放入提供的寄存器中。bl只不过是带链接的分支,分支后的下一条指令的地址被加载到链接寄存器lr中。
一旦我们正在调用的例程被执行,lr就可以被复制回pc,将使CPU能够在bl指令之后从代码中继续。在do_someting中,我们将链接寄存器推送到堆栈,这样就可以再次将其弹出返回,即使对abs的调用将覆盖链接寄存器的原始内容。程序存储r4,因为Arm过程调用标准规定在函数调用之间必须保留r4-r11(下图),并且被调用的函数负责该保留,意味着do_someting需要将r0+r1的结果保存在一个不会被abs破坏的寄存器中,并且我们还必须保存用于保存该结果的任何寄存器的内容。当然,在这种特殊情况下,我们可以只使用r3,但是需要考虑的。我们推送并弹出寄存器,尽管我们不必保留它,因为过程调用标准要求堆栈64位对齐。这在使用堆栈操作时提供了性能优势,因为它们可以利用CPU内的64位数据路径。
我们可以直接压入高地址的值,毕竟如果abs需要注册,那么这就是它保存值的方式。推送r4而不是我们知道需要的值有一个小的性能问题,但最有力的论点可能是,在函数的开始和结束时只推送/弹出所需的任何寄存器,就可以减少错误发生的可能性,提高代码的可读性。此外,“main”函数还压入和弹出lr的内容,因为虽然主代码可能是我的代码中要执行的第一件事,但它不是加载程序时要执行的第二件事。编译器将在调用main之前插入对一些基本设置函数的调用,并在退出时进行一些最终清理调用。
现在让我们尝试将每条指令编码为32位值。假设有0、1、2和3地址格式的指令,其次,有些指令采用即时值,因此需要将32位划分为多个字段。假设有21条指令,则需要5位来编码指令类型,常规指令中的每个指令的代码如下表所示。我们可以使用32位字段中最重要的位来指定指令类型,指令的代码也称为操作码(opcode)。
现在,让我们尝试从0地址指令开始对每种类型的指令进行编码。
我们拥有的两条0地址指令是ret和nop。操作码由五个最重要的位指定,在这种情况下,ret等于10100,b等于10010(参见上表)。它们的编码如下图所示,我们只需要在MSB位置指定5位操作码,其余27位不需要。
编码ret指令。
我们拥有的1地址指令是call、b、beq和bgt,它们将标签作为参数。在编码指令时,我们需要指定标签的地址作为参数,标签的地址与它所指向的指令的地址相同。如果标签后的行为空,那么我们需要考虑下一条包含指令的汇编语句。
这四条指令的操作码需要5位,剩余的27位可用于地址。请注意,内存地址是32位长,不能用27位覆盖地址空间,但可以进行两个关键的优化。首先,可以假设PC相对寻址,可以假设27位指定了相对于当前PC的偏移量(正负)。现代程序中的分支语句是因为for/while循环或if语句而生成的,对于这些构造,分支目标通常在几百条指令的范围内。如果有27位来指定偏移量,并且假设它是2的补码,那么任何方向(正或负)的最大偏移量都是226,对于几乎所有的程序来说已足够。
还有另一个重要的观察。一条指令需要4个字节。如果假设所有指令都与4字节边界对齐,那么指令的所有起始内存地址都将是4的倍数,因此地址的至少两个有符号二进制数字将是00,没有理由在试图指定它们时浪费比特,可以假设27位指定包含指令的内存字(以4字节内存字为单位)地址的偏移量。通过这种优化,从PC的字节偏移量变为29位,即使是最大的程序,这个数字也应该足够。以防万一有极端的例子,其中分支目标距离超过228个字节,那么汇编程序需要将分支链接起来,这样一个分支将调用另一个分支,以此类推。这些指令的编码如下图所示。
1地址指令的编码(分支格式)。
请注意,1地址指令格式禁止使用0地址格式中未使用的位,可以将ret指令的0地址格式视为1地址格式的特例,1地址格式称为分支格式。以这种格式命名字段,将格式的操作码部分称为op,将偏移量称为offset。操作字段包含位置28-32的位,偏移字段包含位置1-27的位。
接下来考虑3地址指令:add、sub、mul、div、mod和或、lsl、lsr和asr。
考虑一个通用的3地址指令,它有一个目标寄存器、一个输入源寄存器和一个可以是寄存器或立即数的第二个源操作数。如果第二个源操作数是寄存器或立即数,需要将一位输入和输出。将其称为I位,并在指令中的操作码之后指定它。如果I=1,则第二个源操作数是立即数,如果I=0,则第二个源操作数是寄存器。
现在考虑将第二个源操作数作为寄存器(I=0)的3地址寄存器的情况。因为有16个寄存器,所以需要4位来唯一地指定每个寄存器。寄存器ri可以编码为i的无符号4位二进制等价物。因此,要指定目标寄存器和两个输入源寄存器,需要12位。结构如下图所示,此指令格式称为寄存器格式。像分支格式一样,不妨命名不同的字段:op(操作码,位:28-32)、I(立即数,位:27)、rd(目的寄存器,位:23-26)、rs1(源寄存器1,位:19-22)和rs2(源寄存器2,位:15-18)。
假设第二个源操作数是立即数,那么需要将I设置为1,接下来计算指定立即数所剩的位数。现在已经为操作码投入了5位,为I位投入了1位,为目标寄存器投入了4位,为第一个源寄存器投入了四位,总共花费了14位。因此,在32位中,剩下18位,可以使用它们来指定立即数。
建议将18位分为两部分:2位(修改器)+16位(立即数的常数部分),两个修改位可以取三个值:00(默认值)、01(“u”)和10(“h”)。当使用默认修改器时,剩余的16位用于指定16位2的补码数。对于u和h修改器,假设立即字段中的16位常量是无符号数。假设立即字段为18位长,具有修改部分和常量部分,处理器根据修改器将立即数内部扩展为32位值。
此编码如下图所示,可将此指令格式称为立即数格式。像分支格式一样,不妨命名不同的字段:op(操作码,位:28-32)、I(立即数,位:27)、rd(目标寄存器,位:23-26)、rs1(源寄存器1,位:19-22)和imm(立即数:1-18)。
用类似的方式,可以用下图所示的方式编码cmp、not和mov指令:
而加载指令的实现如下图:
19.4.7 ARM指令编码
ARM有四种类型的指令:数据处理(加/减/乘/比较)、加载/存储、分支和其他,需要2位来表示这些信息,这些位决定了指令的类型。下图显示了ARM中指令的通用格式。
对于数据处理指令,类型字段等于00,其余26位需要包含指令类型、特殊条件和寄存器。下图显示了数据处理指令的格式。
第26位称为I(立即数)位,类似于前面所述的I位。如果将其设置为1,则第二个操作数是立即数,否则是寄存器。由于ARM有16条数据处理指令,需要4位来表示它们,该信息保存在第22-25位。第21位保存S位,如果打开,则指令将设置CPSR。
其余20位保存输入和输出操作数。由于ARM有16个寄存器,需要4位来编码一个寄存器。第17-20位保存第一个输入操作数(rs)的标识符,要求是一个寄存器。第13-16位保存目标寄存器(rd)的标识符。
位1-12用于保存立即数或移位器操作数,下面看看如何最好地利用这12位。
ARM支持32位立即数,然而实际上只有12位来编码它们。不可能对所有$2^{32}$个可能的值进行编码,需要从中选择一个有意义的子集,想法是使用12位对32位值的子集进行编码,硬件预计将解码这12位,并在处理指令时将其扩展到32位。
现在,12位是一个相当不灵活的值,既不是1字节,也不是2字节。有必要想出一个非常巧妙的解决方案,想法是将12位分为两部分:4位常量(rot)和8位有效载荷(payload),参见下图。
此外,加载、存储指令格式如下:
而分支指令如下:
ARM Endian支持使用E-Bit加载/存储字:
19.4.8 x86指令编码
x86是真正的CISC指令集,其编码过程更为规律,几乎所有的指令都遵循标准格式。其次,x86中的操作码通常有多种模式和前缀。先看看编码机器指令的广泛结构,下图显示了二进制编码指令的结构。
x86二进制指令格式。
x86指令格式细节。
第一组1-4字节用于编码指令的前缀,rep前缀就是其中一个例子,还有许多其他类型的前缀可以在第一组1-4字节中编码。
接下来的1-3个字节用于对操作码进行编码,整个x86 ISA有数百条指令,操作码还编码操作数的格式。例如,加法指令可以将其第一个操作数作为内存操作数,也可以将其第二个操作数用作内存操作数。此信息也是操作码的一部分。
接下来的两个字节是可选的。第一个字节被称为ModR/M字节,用于指定源寄存器和目标寄存器的地址,第二个字节被称作SIB(标度索引基)字节,该字节记录基本缩放索引和基本缩放索引偏移寻址模式的参数,存储器地址可以可选地具有32位的位移(在本书中也称为偏移量)。因此,我们可以选择在一条指令中多4个字节来记录位移值。最后,一些x86指令接受立即数作为操作数,立即数也可以大到32位,因此,最后一个字段(也是可选的)用于指定立即数操作数。
ModR/M字节有三个字段,如下图所示:
SIB字节的结构如下图所示:
x86数字数据格式如下:
x86 EFLAGS寄存器:
x86控制寄存器:
MMX寄存器到浮点寄存器的映射:
19.5 处理器
19.5.1 指令处理概览
我们可以将处理器的操作大致分为五个阶段,如下图所示。
指令处理的五个阶段:指令获取、操作数获取、执行、内存访问、寄存器写入。
指令的多时钟周期管线图。图中时间从左到右在页面上前进,指令从页面的顶部到底部前进。管线阶段的表示沿指令轴放置在每个部分,占据适当的时钟周期。图中显示了每个阶段之间的管线寄存器,数据路径以图形方式表示管线的五个阶段,但命名每个管线阶段的矩形也同样有效。
第1步是从内存中获取指令。机器的底层组织并不重要,该机器可以是冯·诺依曼机器(共享指令和数据存储器),也可以是哈佛机器(专用指令存储器)。提取阶段有逻辑元件来计算下一条指令的地址,如果当前指令不是分支,那么需要将当前指令的大小(4字节)添加到存储在PC中的地址。但如果当前指令是分支,那么下一条指令的地址取决于分支的结果和目标。此信息从处理器中的其他单元获得。
第2步是解码“指令并从寄存器中取出操作数。不同指令类型所需的处理非常不同,例如加载存储指令使用专用的存储单元,而算术指令则不使用。为了解码指令,处理器有专用的逻辑电路,根据指令中的字段生成信号,这些信号随后被其他模块用来正确处理指令。像Intel处理器这样的商用处理器有非常复杂的解码单元,解码x86指令集非常复杂。不管解码的复杂程度如何,解码过程通常包括以下步骤:
- 提取操作数的值,计算嵌入的立即数并将其扩展到32或64位,以及生成关于指令处理的附加信息。
- 生成关于指令的更多信息的过程包括生成处理器专用信号。例如,可以为加载/存储指令生成“启用内存单元”形式的信号,对于存储指令,可以生成一个禁用寄存器写入功能的信号。
第3步是执行算术和逻辑运算。它包含一个能够执行所有算术和逻辑运算的算术和逻辑单元(ALU),ALU还需要计算加载存储操作的有效地址,通常情况下处理器的这一部分也计算分支的结果。
ALU(算术逻辑单元)包含用于对数据值执行算术和逻辑计算的元素,通常包含加法器、乘法器、除法器,并具有计算逻辑位运算的单元。
第4步包含用于处理加载存储指令的存储单元。该单元与存储器系统接口,并协调从存储器加载和存储值的过程。典型处理器中的内存系统相当复杂,其中一些复杂性是在处理器的这一部分中实现的。
第5步是将ALU计算的值或从存储器单元获得的加载值写入寄存器文件。
单个指令所需的处理称为指令周期。使用前面给出的简化的两步描述,指令周期如下图所示,这两个步骤被称为获取周期和执行周期。只有在机器关闭、发生某种不可恢复的错误或遇到使计算机停止的程序指令时,程序执行才会停止。
基本指令周期。
考虑一个使用假设机器的简单示例,该机器包括下图中列出的特性。处理器包含一个称为累加器(AC)的数据寄存器,指令和数据都是16位长,因此使用16位字组织内存是方便的。指令格式为操作码提供4位,因此可以有多达$2^4=16$个不同的操作码,并且可以直接寻址多达$2^{12}=4096$(4K)个字的内存。
假想机器的特性。
下图说明了部分程序执行,显示了内存和处理器寄存器的相关部分。所示的程序片段将地址940处的存储字的内容添加到地址941处的内存字的内容,并将结果存储在后一位置。需要三条指令,可以描述为三个获取和三个执行周期:
- PC包含300,即第一条指令的地址。该指令(十六进制值1940)被加载到指令寄存器IR中,PC递增。注意,此过程涉及使用内存地址寄存器和内存缓冲寄存器。为了简单起见,这些中间寄存器被忽略。
- IR中的前4位(第一个十六进制数字)表示要加载AC,剩余的12位(三个十六进制数字)指定要加载数据的地址(940)。
- 从位置301获取下一条指令(5941),并且PC递增。
- 添加AC的旧内容和位置941的内容,并将结果存储在AC中。
- 从位置302取出下一条指令(2941),并且PC递增。
- AC的内容存储在位置941中。
程序执行示例(内存和寄存器的内容为十六进制)。
特定指令的执行周期可能涉及对内存的不止一次引用。此外,指令可以指定I/O操作,而不是内存引用。考虑到这些额外的考虑因素,图下图提供了基本指令周期的更详细的视图,该图采用状态图的形式。对于任何给定的指令周期,某些状态可能为空,而其他状态可能被访问多次。
指令周期状态图。
状态描述如下:
- 指令地址计算(iac):确定要执行的下一条指令的地址,通常需要在前一条指令的地址上添加一个固定的数字,例如如果每条指令的长度为16位,并且内存被组织为16位字,则在前一个地址上加1。相反,如果内存被组织为可单独寻址的8位字节,则在前一个地址上加2。
- 指令获取(if):将指令从其内存位置读入处理器。
- 指令操作解码(iod):分析指令以确定要执行的操作类型和要使用的操作数。
- 操作数地址计算(oac):如果操作涉及对内存中的操作数的引用或通过I/O可用的操作数,则确定操作数的地址。
- 操作数获取(of):从内存中获取操作数或从I/O中读取操作数。
- 数据操作(do):执行指令中指示的操作。
- 操作数存储(os):将结果写入内存或输出到I/O。
下图显示了包括中断周期处理的修订指令周期状态图:
下图左是非直接时钟周期,右是中断时钟周期:
下图通过指出每种模块类型的主要输入和输出形式,说明了所需的交换类型:
微处理器寄存器组织示例:
19.5.2 处理器结构
19.5.2.1 处理器单元
处理器内部包含了诸多单元,诸如获取单元、数据路径和控制单元、操作数获取单元、执行单元(分支单元、ALU)、内存访问单元、寄存器回写单元等等。
下图显示了获取单元电路的实现。在一个周期中需要执行两个基本操作:1、下一个PC(程序计数器)的计算;2、获取指令。
电路中有两种元件:
- 第一类元件是寄存器、存储器、算术和逻辑电路,用于处理数据值。
- 第二类元素是决定数据流方向的控制单元。处理器中的控制单元通常生成信号以控制所有多路复用器(multiplexer),被称为控制信号(control signal),因为其作用是控制信息流。
因此,我们可以从概念上认为处理器由两个不同的子系统组成:
- 数据路径(data path)。它包含存储和处理信息的所有元素。例如,数据存储器、指令存储器、寄存器文件和ALU(算术逻辑单元)是数据路径的一部分。内存和寄存器存储信息,而ALU处理信息,例如,它将两个数字相加,并产生和作为结果,或者它可以计算两个数字的逻辑函数。
- 控制路径(control path)。它通过生成信号来引导信息的正确流动,生成一个信号,指示多路复用器在分支目标和默认下一个PC之间进行选择。在这种情况下,多路复用器由信号
isBranchTaken
控制。
我们可以将控制路径和数据路径视为电路的两个不同元件,就像城市的交通网络一样。道路和红绿灯类似于数据路径,控制交通灯的电路构成了控制路径,控制路径决定灯光转换的时间。在现代智慧城市中,控制城市中所有交通灯的过程通常是集成的。如果有可能智能控制交通,使汽车绕过交通堵塞和事故现场。类似地,处理器的控制单元相当智能,它的工作是尽可能快地执行指令。现代处理器的控制单元已经非常复杂。
数据路径(data path):数据路径由处理器中专用于存储、检索和处理数据的所有元素组成,如寄存器、内存和ALU。
控制路径(control path):控制路径主要包含控制单元,其作用是生成适当的信号来控制数据路径中指令和数据的移动。
数据路径和控制路径之间的关系。
现在看看执行指令。首先将指令分为两种类型:分支和非分支。分支指令由计算分支结果和最终目标的专用分支单元处理,非分支指令由ALU(算术逻辑单元)处理。分支单元的电路如下图所示:
使用多路复用器在返回地址(op1)的值和指令中嵌入的branchT目标之间进行选择。isRet信号控制多路复用器,如果它等于1就选择op1,否则选择分支目标。多路复用器branchPC的输出被发送到提取单元。
下图显示了包含ALU的执行单元部分。ALU的第一个操作数(A)始终为op1(从操作数获取单元获得),但第二个操作数(B)可以是寄存器或符号扩展立即数,由控制单元生成的isImmediate信号决定,isImmediate信号等于指令中立即数位的值,如果是1,则图中的多路复用器选择immx作为操作数,如果为0,则选择op2作为操作数。ALU将一组信号作为输入,统称为aluSignals,aluSignals由控制单元生成,并指定ALU操作的类型。ALU的结果称为aluResult。
下图显示了ALU的一种设计。ALU包含一组模块,每个模块计算单独的算术或逻辑函数,如加法或除法。其次,每个模块都有一个启用或禁用它的专用信号,例如,当我们想执行简单的加法时,没有理由启用除法器。有几种方法可以启用或禁用单元,最简单的方法是为每个输入位使用一个传输门(transmission gate,见下下图),如果信号(S)打开,则输出反映输入值。否则,它将保持其以前的值。因此,如果启用信号关闭,则模块不会看到新的输入。因此,它不会耗散功率,并被有效禁用。
ALU。
传输门。
总之,下图展示了执行单元(分支单元和ALU)的完整设计。要设置输出(aluResult),需要一个多路复用器,可以从ALU中的所有模块中选择正确的输出,没有在图中显示此多路复用器。
执行单元(分支和ALU单元)。
下图显示了内存访问单元。它有两个输入{数据和地址,地址由ALU计算,它等于ALU的结果(aluResult),加载和存储指令都使用这个地址,地址保存在传统上称为MAR(内存地址寄存器)的寄存器中。
内存单元。
通过连接所有部分来形成整体。到目前为止,已经将处理器分为五个基本单元:指令获取单元(IF)、操作数获取单元(OF)、执行单元(EX)、内存访问单元(MA)和寄存器写回单元(RW)。是时候把所有的部分结合起来,看看统一的图片了(下图,省略了详细的电路,只关注数据和控制信号的流动)。
一个基础处理器。
19.5.2.2 控制单元
一个简单处理器的硬接线控制单元可以被认为是一个黑盒子,它以6位作为输入(5个操作码位和1个立即数位),并产生22个控制信号作为输出。如下图所示。
硬接线控制单元的抽象。
控制单元的结构图。
硬接线控制单元快速高效,这就是今天大多数商用处理器使用硬接线控制单元的原因,但硬接线控制单元并不十分灵活,例如在处理器出厂后,不可能更改指令的行为,甚至不可能引入新指令。有时如果功能单元中存在错误,需要更改指令的执行方式,例如如果乘法器存在设计缺陷,那么理论上可以使用加法器和移位单元运行布斯乘法算法。然而,我们需要一个非常复杂的控制单元来动态地重新配置指令的执行方式。
支持灵活的控制单元还有其他更实际的原因。某些指令集(如x86)具有重复指令给定次数的rep指令,它们还具有复杂的字符串指令,可以处理大量数据,支持此类指令需要非常复杂的数据路径。原则上,我们可以通过精心设计的控制单元来执行这些指令,而这些控制单元又有简单的处理器来处理这些指令,这些子处理器可以生成用于实现复杂CISC指令的控制信号。
数据路径和控制信号。
带内部总线的CPU。
19.5.2.3 基于微程序的处理器
前面已经研究了带有硬接线控制单元的处理器,设计了一个包含处理和执行指令所需的所有元素的数据路径。在输入操作数之间有选择的地方,添加了一个多路复用器,它由来自控制单元的信号控制。控制单元将指令的内容作为输入,并生成所有控制信号。现代高性能处理器通常采用这种设计风格。请注意,效率是有代价的,成本是灵活性。我们可能需要添加更多的多路复用器,并为每个新指令生成更多的控制信号。其次,在处理器交付给客户后,不可能向处理器添加新指令。有时候,我们渴望这样的灵活性。
通过引入将ISA中的指令转换为一组简单微指令的转换表,可以引入这种额外的灵活性。每个微指令都可以访问处理器的所有锁存器和内部状态元素。通过执行一组与指令关联的微指令,我们可以实现该指令的功能,这些微指令或微代码保存在微代码表中。通常可以通过软件修改该表的内容,从而改变硬件执行指令的方式。有几个原因需要这种灵活性,允许我们添加新指令或修改现有指令的行为。其中一些原因如下:
- 处理器在执行某些指令时有时会出现错误。因为设计师在设计过程中犯下的错误,或者是由于制造缺陷,其中一个著名的例子是英特尔奔腾处理器中的除法错误,英特尔不得不召回它卖给客户的所有奔腾处理器。如果可以动态地更改除法指令的实现,那么就不必调用所有处理器。因此可以得出结论,处理器的某种程度的可重构性有助于解决在设计和制造过程的各个阶段可能引入的缺陷。
- 英特尔奔腾4等处理器,以及英特尔酷睿i3和英特尔酷睿i7等更高版本的处理器,通过执行存储在内存中的一组微指令来实现一些复杂的指令。通常使用微码来实现数据串或指令的复杂操作,这些操作会导致一系列重复计算,意味着英特尔处理器在内部将复杂指令替换为包含简单指令的代码段,使得处理器更容易实现复杂的指令。我们不需要对数据路径进行不必要的更改,添加额外的状态、多路复用器和控制信号来实现复杂的指令。
- 当今的处理器只是芯片的一部分,有许多其他元件,被称为片上系统(system-on-chip,SOC)。例如,手机中的芯片可能包含处理器、视频控制器、相机接口、声音和网络控制器。处理器供应商通常会硬连线一组简单的指令,而与视频和音频控制器等外围设备接口的许多其他指令都是用微码编写的。根据应用程序域和外围组件集,可以定制微码。
- 有时使用一组专用微指令编写自定义诊断例程。这些例行程序在芯片运行期间测试芯片的不同部分,报告故障并采取纠正措施。这些内置自检(BIST)例程通常是可定制的,并以微码编写。例如,如果我们希望高可靠性,那么我们可以修改在CPU上执行可靠性检查的指令的行为,以检查所有组件。但是,为了节省时间,可以压缩这些例程以检查更少的组件。
因此,我们观察到,有一些令人信服的理由能够以编程方式改变处理器中指令的行为,以实现可靠性、实现附加功能并提高可移植性。因此,现代计算系统,尤其是手机和平板电脑等小型设备使用的芯片依赖于微码。这种微码序列通常被称为固件。
现代计算系统,尤其是手机、调制解调器、打印机和平板电脑等小型设备,使用的芯片依赖于微码。这种微码序列通常被称为固件(firmware)。
因此,让我们设计一个基于微程序的处理器,即使在处理器被制造并发送给客户之后,它也能为我们提供更大的灵活性来定制指令集。需要注意,常规硬接线处理器和微编程处理器之间存在着基本的权衡。权衡是效率与灵活性,不能指望有一个非常灵活的处理器,它既快速又省电。
19.5.2.4 微编程数据路径
让我们为微程序处理器设计数据路径,修改处理器的数据路径。处理器有一些主要单元,如提取单元、寄存器文件、ALU、分支单元和内存单元。这些单元是用导线连接的,只要有可能有多个源操作数,我们就在数据路径中添加一个多路复用器。控制单元的作用是为多路复用器生成所有控制信号。
问题是多路复用器的连接是硬接线的,不能建立任意连接,例如,不能将存储单元的输出发送到执行单元的输入。因此我们希望有一个组件之间没有固定互连的设计,理论上任何单位都可以向任何其他单位发送数据。
最灵活的互连是基于总线的结构。总线是一组连接所有单元的普通铜线,支持一个写入,在任何时间点支持多个读者。例如,单元A可以在某个时间点写入总线,所有其他单元都可以获得单元A写入的值。如果需要,可以将数据从一个单元发送到另一个单元,或从一个装置发送到一组其他单元。控制单元需要确保在任何时间点,只有一个单元写入总线,需要处理正在写入的值的单元从总线读取值。
现在让我们继续设计我们为硬连线处理器引入的所有单元的简化版本,这些简化版本可以适当地用于我们的微程序处理器的数据路径。
让我们从解释微程序处理器的设计原理开始。我们为每个单元添加寄存器,这些寄存器存储特定单元的输入数据,专用输出寄存器存储单元生成的结果,这两组寄存器都连接到公共总线。与硬连线处理器不同的是,在不同的单元之间存在大量的耦合,微程序处理器中的单元是相互独立的。他们的工作是执行一组操作,并将结果返回总线。每个单元就像编程语言中的一个函数,它有一个由一组寄存器组成的接口,用于读取数据,通常需要1个周期来计算其输出,然后该单元将输出值写入输出寄存器。
根据上述原理,下图中展示了提取单元的设计,它有两个寄存器:pc(程序计数器)和ir(指令寄存器)。pc寄存器可以从总线读取其值,也可以将其值写入总线,没有将ir连接到总线,因为没有其他单位对指令的确切内容感兴趣,其他单位只对指令的不同字段感兴趣。因此,有必要解码指令并将其分解为一组不同的字段,由解码单元完成。
微程序处理器中的提取单元。
解码单元在功能上类似于操作数获取单元,但我们不在该单元中包含寄存器文件,而将其视为微程序处理器中的一个独立单元。下图显示了操作数获取单元的设计。
微程序处理器中的解码单元。
我们将解码单元和寄存器文件组合成一个单元,称为硬连线处理器的操作数获取单元,但更期望在微程序处理器中保持寄存器文件独立,因为在硬连线处理器中,它在解码指令后立即被访问。然而,微程序处理器可能不是这样——在指令执行期间,可能需要多次访问它。
微程序处理器中的寄存器文件。
ALU的结构如下图所示,有两个输入寄存器,A和B。ALU对寄存器A和B中包含的值执行操作,操作的性质由args值指定。例如,如果指定了加法运算,则ALU将寄存器A和B中包含的值相加。如果指定了减法运算,那么将从A中包含的数值减去B中的值,对于cmp指令,ALU更新标志。使用两个标志来指定相等和大于条件,分别保存在寄存器标志flags.E和标志flags.GT中,然后ALU运算的结果保存在寄存器aluResult中。此处还假设ALU在总线上指定args值后需要1个周期才能执行。
微程序处理器中的ALU。
内存单元如下图所示。与硬连线处理器一样,它有两个源寄存器:mar(内存地址寄存器)和mdr(内存数据寄存器),mar缓冲内存地址,mdr缓冲需要存储的值。还需要一组参数来指定内存操作的性质:加载或存储,加载操作完成后,ldResult寄存器中的数据可用。
微程序处理器中的存储单元。
综上,微程序处理器中的数据路径总览如下:
19.5.3 管线处理器
19.5.3.1 管线概述
假设前面介绍的硬连线处理器需要一个周期来获取、执行和将指令的结果写入寄存器文件或内存。在电气层面上,是通过从提取单元经由其他单元流到寄存器写回单元的信号来实现的,而电信号从一个单元传播到另一个单元需要时间。
例如,从指令存储器中获取指令需要一些时间。然后需要时间从寄存器文件读取值,并用ALU计算结果。内存访问和将结果写回寄存器文件也是相当耗时的操作。需要等待所有这些单独的子操作完成,然后才能开始处理下一条指令,意味着电路中有大量的空闲,当操作数获取单元执行其工作时,所有其他单元都处于空闲状态。同样,当ALU处于活动状态时,所有其他单元都处于非活动状态。如果我们假设五个阶段(IF、OF、EX、MA、RW)中的每一个都需要相同的时间,那么在任何时刻,大约80%的电路都是空闲的!这代表了计算能力的浪费,空闲资源绝对不是一个好主意。
如果能找到一种方法让芯片的所有单元保持忙碌,那么就能提高执行指令的速度。
管线流程
不妨类比一下前面讨论的简单单周期处理器中的空闲问题。当一条指令在EX阶段时,下一条指令可以在OF阶段,而后续指令可以在IF阶段。事实上,如果在处理器中有5个阶段,简单地假设每个阶段花费的时间大致相同,可以假设同时处理5条指令,每条指令在处理器的不同单元中进行处理。类似于流水线中的汽车,指令在处理器中从一个阶段移动到另一个阶段。此策略确保处理器中没有任何空闲单元,因为处理器中的所有不同单元在任何时间点都很忙。
在此方案中,指令的生命周期如下。它在周期n中进入IF阶段,在周期n+1中进入OF阶段,周期n+2中进入EX阶段,循环n+3中进入MA阶段,最后在周期n+4中完成RW阶段的执行。这种策略被称为流水线(pipelining,又名管线、管道),实现流水线的处理器被称为流水处理器(pipelined processor)。五个阶段(IF、OF、EX、MA、RW)的顺序在概念上一个接一个地布置,称为流水线(pipeline,类似于汽车装配线)。下图显示了流水线数据路径的组织。
流水线数据路径。
上图中,数据路径分为五个阶段,每个阶段处理一条单独的指令。在下一个周期中,每条指令都会传递到下一个阶段,如图所示。
性能优势
现在,让我们考虑流水线处理器的情况,假设阶段是平衡的,意味着执行每个阶段需要相同的时间,大多数时候,处理器设计人员都会尽可能最大程度地实现这个目标。因此可以将r除以5,得出执行每个阶段需要r/5纳秒的结论,可以将循环时间设置为r/5。循环结束后,流水线每个阶段中的指令进入下一阶段,RW阶段的指令移出流水线并完成执行,同时,新指令进入IF阶段。如下图所示。
流水线中的指令。
如果我们可以用5阶段流水线获得5倍的优势,那么按照同样的逻辑,应该可以用100阶段流水线得到100倍的优势。事实上,可以不断增加阶段的数量,直到一个阶段只包含一个晶体管。但情况并非如此,流水线处理器的性能存在根本性的限制,不可能通过增加流水线阶段的数量来任意提高处理器的性能。在一定程度上,增加更多的阶段会适得其反。
下图a描述了不使用流水线的指令序列的时序,显然是一个浪费的过程,即使是非常简单的流水线也可以大大提高性能。下图b显示了两阶段流水线方案,其中两个不同指令的I级和E级同时执行。流水线的两个阶段是指令获取阶段和执行指令的执行/内存阶段,包括寄存器到内存和内存到寄存器的操作,因此我们看到第二条指令的指令提取阶段可以与执行/存储阶段的第一部分并行执行。然而,第二条指令的执行/存储阶段必须延迟,直到第一条指令清除流水线的第二阶段。该方案的执行率可以达到串行方案的两倍,两个问题阻碍了实现最大加速。首先,我们假设使用单端口内存,并且每个阶段只能访问一个内存,需要在某些指令中插入等待状态。第二,分支指令中断顺序执行流,为了以最小的电路来适应这种情况,编译器或汇编器可以将NOOP指令插入到指令流中。
通过允许每个阶段进行两次内存访问,可以进一步改进流水线,产生了下图c所示的序列。现在最多可以重叠三条指令,其改进程度为3倍,同样,分支指令会导致加速比达不到可能的最大值,请注意数据依赖性也会产生影响。如果一条指令需要被前一条指令更改的操作数,则需要延迟,同样可以通过NOOP实现。
由于RISC指令集的简单性和规则性,分为三个或四个阶段的设计很容易完成。下图d显示了4阶段管线的结果,一次最多可以执行四条指令,最大可能的加速是4倍。再次注意,使用NOOP来解释数据和分支延迟。
19.5.3.2 管线阶段
流水线处理器使用的电子构造有所不同,下面是不同阶段的一种设计:
流水线处理器中的IF阶段。
流水线处理器中的OF阶段。
流水线处理器中的EX阶段。
流水线处理器中的MA阶段。
流水线处理器中的RW阶段。
现在通过下图显示的带有流水线寄存器的数据路径来总结关于简单流水线的讨论。注意,我们的处理器设计已经变得相当复杂,图表大小已经达到了一页,不想引入更复杂的图表。
流水线数据路径。
下图显示了流水线数据路径的抽象。该图主要包含不同单元的框图,并显示了四个流水线寄存器。我们将使用该图作为讨论先进流水线的基线。回想一下,rst寄存器操作数可以是指令的rs1字段,也可以是ret指令的返回地址寄存器。在ra和rs1之间选择多路复用器是基线流水线设计的一部分,为了简单起见,没有在图中显示它,假设它是寄存器文件单元的一部分。类似地,选择第二寄存器操作数(在rd和rs2之间)的多路复用器也被假定为寄存器文件单元的一部分,因此图中未示出,只显示选择第二个操作数(寄存器或立即数)的多路复用器。
下图显示了三条指令通过管道时的流水线图,每一行对应于每个流水线阶段,列对应于时钟周期。在示例代码中,有三条相互之间没有任何依赖关系的指令,将这些指令分别命名为:[1]、[2]和[3]。最早的指令[1]在第一个周期进入流水线的IF阶段,在第五个周期离开流水线。类似地,第二条指令[2]在第二个周期中进入流水线的IF阶段,在第六个周期中离开流水线。这些指令中的每一条都会在每个循环中前进到流水线的后续阶段,流水线图中每条指令的轨迹都是一条朝向右下角的对角线。请注意,在考虑指令之间的依赖性之后,这个场景将变得相当复杂。
流水线示意图。
下面是构建流水线图的规则:
- 构建一个单元网格,该网格有5行和N列,其中N是希望考虑的时钟周期总数,每5行对应于一个流水线阶段。
- 如果指令([k])在周期m中进入流水线,那么在第一行的第m列中添加一个与[k]对应的条目。
- 在第(m+1)个周期中,指令可以停留在同一阶段(因为流水线可能会停顿,稍后将描述),也可以移动到下一行(OF阶段)。在网格单元中添加相应的条目。
- 以类似的方式,指令按顺序从IF级移动到RW级,它从不后退,但可以在连续的周期中保持在同一阶段。
- 一个单元格中不能有两个条目。
- 在指令离开RW阶段后,最终将其从流水线图中删除。
根据以上规则举个简单的例子,假设有以下代码:
add r1, r2, r3
sub r4, r2, r5
mul r5, r8, r9
为上述代码段构建的流水线图如下(假设第一条指令在周期1中进入流水线):
条件分支对指令流水线操作的影响:
6阶段的CPU指令管线:
一个备选的管线描述:
下图a将加速因子绘制为在没有分支的情况下执行的指令数的函数。正如可能预期的,在极限(n趋近正无穷),有k倍的加速。图b显示了作为指令管道中级数函数的加速因子。在这种情况下,加速因子接近可以在没有分支的情况下馈送到管道中的指令数。因此,管线阶段数越大,加速的可能性越大。然而,作为一个实际问题,额外管线阶段的潜在收益会因成本增加、阶段之间的延迟以及遇到需要刷新管线的分支而抵消。
19.5.3.3 管线冲突
让我们考虑下面的代码片段:
add r1, r2, r3
sub r3, r1, r4
此处的加法指令生成寄存器r1的值,子指令将其用作源操作数,这些指令构建一个流水线图如下所示。
显示RAW(写入后读取)危险的流水线图。
显示了有一个问题。指令1在第f个周期中写入r1的值,指令2需要在第3个周期中读取其值。这显然是不可能的,我们在两条指令的相关流水线阶段之间添加了一个箭头,以指示存在依赖关系。由于箭头向左(时间倒退),我们无法在管道中执行此代码序列,被称为数据冲突(亦称数据危险,data hazard),冲突被定义为流水线中错误执行指令的可能性,这种特殊情况被归类为数据冲突,除非采取适当措施,否则指令2可能会得到错误的数据。
冲突(hazard)被定义为流水线中错误执行指令的可能性,表示由于无法获得正确的数据而导致错误执行的可能性。
这种特定类型的数据危险被称为RAW(写入后读取)冲突。上面语句的减法指令试图读取r1,需要由加法指令写入。在这种情况下,读取会在写入之后。
请注意,这不是唯一一种数据冲突,另外两种类型的数据危害是WAW(写入后写入)和WAR(读取后写入)冲突,这些冲突在我们的流水线中不是问题,因为我们从不改变指令的顺序,前一条指令总是在后一条指令之前。相比之下,现代处理器具有以不同顺序执行指令的无序(out-of-order)流水线。
在有序流水线(如我们的流水线)中,前一条指令总是在流水线中的后一条指令之前。现代处理器使用无序(out-of-order)流水线来打破这一规则,并且可以让后面的指令在前面的指令之前执行。
让我们看看下面的汇编代码段:
add r1, r2, r3
sub r1, r4, r3
指令1和指令2正在写入寄存器r1。按照顺序,流水线r1将以正确的顺序写入,因此不存在WAW危险。然而,在无序流水线中,我们有在指令1之前完成指令2的风险,因此r1可能会以错误的值结束。这便是WAW冲突的一个例子。读者应该注意,现代处理器通过使用一种称为寄存器重命名(register renaming)的技术确保r1不会得到错误的值。
让我们举一个潜在WAR冲突的例子:
add r1, r2, r3
add r2, r5, r6
指令2试图写入r2,而指令1将r2作为源操作数。如果指令2先被执行,那么指令1可能会得到错误的r2值。实际上,由于寄存器重命名等方案,这在现代处理器中不会发生。我们需要理解,冲突是发生错误的理论风险,但不是真正的风险,因为采取了足够的措施来确保程序不会被错误地执行。
本文将主要关注RAW危害,因为WAW和WAR危害仅与现代无序处理器相关。让我们概述一下解决方案的性质,为了避免RAW危险,有必要确保流水线知道它包含一对指令,其中一条指令写入寄存器,另一条指令按程序顺序稍后从同一寄存器读取。它需要确保使用者指令正确地从生产者指令接收操作数(在本例中为寄存器)的值,我们将研究硬件和软件方面的解决方案。
现在看看当我们在流水线中有分支指令时会出现的另一种危险,假设有下面的代码片段:
[1]: beq .foo
[2]: mov r1, 4
[3]: add r2, r4, r3
...
...
.foo:
[100]: add r4, r1, r2
下图展示了前三条指令的流水线图:
此处,分支的结果在循环3中被确定,并被传送到提取单元,提取单元从周期4开始提取正确的指令。如果执行了分支,则不应执行指令2和3。可悲的是,在周期2和周期3中,无法知道分支的结果。因此,这些指令将被提取,并将成为流水线的一部分。如果执行分支,则指令2和3可能会破坏程序的状态,从而导致错误,指令2和指令3被称为错误路径中的指令。这种情况称为控制冲突(control hazard)。如果分支的结果与其实际结果不同,则会执行的指令被认为是错误的。例如,如果执行分支,则程序中分支指令之后的指令路径错误。
控制冲突(control hazard)表示流水线中错误执行的可能性,因为分支错误路径中的指令可能会被执行并将结果保存在内存或寄存器文件中。
为了避免控制冲突,有必要识别错误路径中的指令,并确保其结果不会提交到寄存器文件和内存。应该有一种方法使这些指令无效,或者完全避免它们。
当不同的指令试图访问同一个资源,而该资源不能允许所有指令在同一周期内访问它时,就会出现结构冲突。让我们举个例子。假设我们有一条加法指令,可以从内存中读取一个操作数,它可以具有以下形式:
add r1, r2, 10[r3]
结构冲突(structural hazard)是指由于资源限制,指令可能无法执行。例如,当多个指令试图在同一周期内访问一个功能单元时,可能会出现这种情况,并且由于容量限制,该单元无法允许所有感兴趣的指令继续执行。在这种情况下,冲突中的一些指令需要暂停执行。
此处,有一个寄存器源操作数r2和一个内存源操作数10[r3],进一步假设流水线在OF阶段读取内存操作数的值。现在让我们来看一个潜在的冲突情形:
[1]: st r4, 20[r5]
[2]: sub r8, r9, r10
[3]: add r1, r2, 10[r3]
请注意,这里没有控制和数据冲突,尽管如此,让我们考虑流水线图中存储指令处于MA阶段时的一点。此时,指令2处于EX阶段,指令3处于OF阶段。请注意,在此循环中,指令1和3都需要访问存储单元。但如果我们假设内存单元每个周期只能服务一个请求,那么显然存在冲突情况,其中一条指令需要暂停执行。这种情况是结构冲突的一个例子。
由于具有典范性,后面我们把重点放在努力消除RAW和控制冲突上。
19.5.3.4 管线冲突解决
软件方案
现在,让我们找出一种避免RAW冲突的方法,假设有以下代码:
[1]: add r1, r2, r3
[2]: sub r3, r1, r4
指令2要求OF级中的r1值。然而,此时,指令1处于EX阶段,它不会将r1的值写回寄存器文件,因此不能允许指令2在流水线中继续。一个简单的软件解决方案是聪明的编译器可以分析代码序列并意识到存在RAW冲突,它可以在这些指令之间引入nop指令,以消除任何RAW冲突。考虑以下代码序列:
[1]: add r1, r2, r3
[2]: nop
[3]: nop
[4]: nop
[5]: sub r3, r1, r4
当子指令到达OF阶段时,加法指令将写入其值并离开流水线,因此子指令将获得正确的值。请注意,添加nop指令是一个成本高昂的解决方案,因为我们实际上是在浪费计算能力。在这个例子中,添加nop指令基本上浪费了3个周期。然而,如果考虑更长的代码序列,那么编译器可能会重新排序指令,这样就可以最小化nop指令的数量。任何编译器干预的基本目标都必须是在生产者和消费者指令之间至少有3条指令。
举个具体的例子,重新排序以下代码段,并添加足够数量的nop指令,以使其在流水线上正确执行:
add r1, r2, r3 ; 1
add r4, r1, 3 ; 2
add r8, r5, r6 ; 3
add r9, r8, r5 ; 4
add r10, r11, r12 ; 5
add r13, r10, 2 ; 6
答案是:
add r1, r2, r3 ; 1
add r8, r5, r6 ; 3
add r10, r11, r12 ; 5
nop
add r4, r1, 3 ; 2
add r9, r8, r5 ; 4
add r13, r10, 2 ; 6
我们需要理解这里的两个重要点:第一个是nop指令的能力,第二个是编译器的能力,编译器是确保程序正确性和提高性能的重要工具。在这种情况下,我们希望以这样一种方式重新排序代码,即引入最小数量的nop指令。
接下尝试使用相同的技术解决控制冲突。
如果再次查看流水线图,就会发现分支指令和分支目标处的指令之间至少需要两条指令,这是因为在EX阶段结束时得到分支结果和分支目标。此时,流水线中还有两条指令。当分支指令分别处于OF和EX阶段时,这些指令已被提取,它们可能执行错了路径。在EX阶段确定分支目标和结果之后,我们可以继续在IF阶段获取正确的指令。
现在考虑当不确定分支结果时提取的这两条指令。如果分支的PC等于p1,则它们的地址分别为p1+4和p1+8。如果不采取行动,它们不会执行错误路径。但是,如果执行分支,则需要从管道中丢弃这些指令,因为它们位于错误的路径上。
让我们看看一个简单的软件解决方案,其中硬件假设分支指令之后的两条指令没有在错误的路径上,这两条指令的位置称为延迟时隙(delay slot)。通常可以通过在分支后插入两条nop指令来确保延迟间隙中的指令不会引入错误,但这样做不会获得任何额外的性能,可以取而代之地对在分支指令之前执行的两条指令进行绑定,并将它们移动到分支之后的两个延迟时隙中。
请注意,我们不能随意将指令移动到延迟时隙,不能违反任何数据依赖约束,还需要避免RAW冲突,另外,我们不能将任何比较指令移动到延迟时隙中。如果没有适当的指令可用,那么我们总是可以返回到普通的解决方案并插入nop指令。也有可能我们只需要找到一条可以重新排序的指令,然后只需要在分支指令之后插入一条nop指令。延迟分支方法是一种非常有效的方法,可以减少需要添加以避免控制冲突的nop指令的数量。
在简单流水线数据路径中,分支后获取的两条指令的PC分别等于p1+4和p1+8(p1是分支指令的PC)。由于编译器确保这些指令始终在正确的路径上,而不管分支的结果如何,因此我们不会通过获取它们来提交错误。在确定分支的结果之后,如果不执行分支,则获取的下一条指令的PC等于p1+12,或者如果执行分支,PC等于分支目标。因此,在这两种情况下,在确定分支的结果后都会获取正确的指令,可以得出结论,软件解决方案在流水线版本的处理器上正确执行程序。
总之,软件技术的关键是延迟时隙的概念。在分支之后需要两个延迟时隙,因为不确定后续的两条指令,它们可能在错误的路径上。然而,使用智能编译器,可以设法将执行的指令移动到延迟时隙,而不管分支的结果如何。因此可以避免在延迟时隙中放置nop指令,从而提高性能。这种分支指令被称为延迟分支指令(delayed branch instruction)。
如果处理器假定在其结果确定之前获取的所有后续指令都在正确的路径上,则分支指令称为延迟分支(delayed branch)。如果处理器在提取分支指令的时间与确定其结果之间提取n条指令,那么我们就说我们有n个延迟时隙。编译器需要确保正确路径上的指令占用延迟时隙,并且不会引入额外的控制或RAW冲突。编译器还可以在延迟时隙中引入nop指令。
现在举个例子。重新排序下面的汇编代码,以便在具有延迟分支的流水线处理器上正确运行,假设每个分支指令有两个延迟时隙。
add r1, r2, r3 ; 1
add r4, r5, r6 ; 2
b .foo ; 3
add r8, r9, r10 ; 4
答案:
b .foo ; 3
add r1, r2, r3 ; 1
add r4, r5, r6 ; 2
add r8, r9, r10 ; 4
硬件方案
上面研究了消除RAW和控制冲突的软件解决方案,但编译器方法不是很通用,原因是:
- 首先,程序员总是可以手动编写汇编代码,并尝试在处理器上运行它。在这种情况下,错误的可能性很高,因为程序员可能没有正确地重新排序代码以消除冲突。
- 其次,还有可移植性问题。为一条流水线编写的一段汇编代码可能无法在遵循相同ISA的另一条流水线上运行,因为它可能具有不同数量的延迟时隙或不同数量的阶段。如果我们的汇编程序不能在使用相同ISA的不同机器之间移植,那么引入汇编程序的主要目标之一就失败了。
让我们尝试在硬件层面设计解决方案,硬件应确保无论汇编程序如何,都能正确运行,输出应始终与单周期处理器产生的输出相匹配。为了设计这样的处理器,需要确保指令永远不会接收错误的数据,并且不会执行错误的路径指令。可以通过确保以下条件成立来实现:
- 数据锁定(Data-Lock)。我们不能允许指令离开OF阶段,除非它从寄存器文件接收到正确的数据,意味着需要有效地暂停IF和OF阶段,并让其余阶段执行,直到OF阶段中的指令可以安全地读取其操作数。在此期间,从OF阶段传递到EX阶段的指令需要是nop指令。
- 分支锁定(Branch-Lock)。我们从不在错误的路径上执行指令,要么暂停处理器直到结果已知,要么使用技术确保错误路径上的指令不能将其更改提交到内存或寄存器。
在流水线的纯硬件实现中,有时需要阻止新指令进入流水线阶段,直到某个条件停止保持。停止流水线阶段接受和处理新数据的概念称为流水线暂停(pipeline stall)或流水线互锁(pipeline interlock)。其主要目的是确保程序执行的正确性。
如果我们确保数据锁定和分支锁定条件都成立,那么流水线将正确执行指令。请注意,这两种情况都要求管道的某些阶段可能需要暂停一段时间,这些暂停也称为流水线互锁。换言之,通过保持流水线空闲一段时间,可以避免执行可能导致错误执行的指令。下表是纯软件和硬件方案实现流水线的整个逻辑的利弊。请注意,在软件解决方案中,我们尝试重新排序代码,然后插入最小数量的nop指令,以消除冲突的影响。相比之下,在硬件解决方案中,我们动态地暂停部分流水线,以避免在错误的路径中执行指令,或使用错误的操作数值执行指令。暂停流水线相当于让某些阶段保持空闲,并在其他阶段插入nop指令。
我们观察到,软件解决方案的效率高度依赖于程序的性质,可以对某些程序中的指令重新排序,以完全隐藏RAW冲突和分支的有害影响。然而,在某些程序中,我们可能没有找到足够的可以重新排序的指令,因此被迫插入大量nop指令,会降低性能。相比之下,一个遵守数据锁和分支锁条件的纯硬件方案,只要检测到可能错误执行的指令,就会暂停流水线。这是一种通用方法,比纯软件解决方案慢。
现在可以将硬件和软件解决方案结合起来,重新排序代码,使其尽可能对流水线友好,然后在带有互锁的流水线上运行它。注意,在这种方法中,编译器不保证正确性,只是将生产者指令和消费者指令尽可能分开,并在支持它们的情况下利用延迟分支。这减少了我们需要暂停流水线的次数,并确保了两全其美。在设计带互锁的流水线前,下面借助流水线图来研究互锁的性质。
现在绘制带有互锁的流水线图,考虑下面的代码片段。
add r1, r2, r3
sub r4, r1, r2
带气泡的流水线图。
指令[1]写入寄存器r1,指令[2]从r1读取,显然存在RAW依赖关系。为了确保数据锁定条件,我们需要确保指令[2]仅在读取了指令[1]写入的r1值时才离开OF阶段,仅在循环6中可行(上图),然而指令[2]在周期3中到达OF阶段。如果没有冲突,则理想情况下它会在周期4中进入EX阶段。由于我们有互锁,指令[2]也需要在周期4、5和6中保持在OF阶段中。问题是,当EX阶段在周期4、5和6中没有处理有效指令时,它会做什么?类似地,MA阶段在周期5、6和7中不处理任何有效的指令。我们需要有一种方法来禁用流水线阶段,这样我们就不会执行多余的工作,标准方法是将nop指令插入阶段。
再次参考上图。在循环3结束时,知道需要引入互锁,因此在周期4中,指令[2]保留在OF阶段,将nop指令插入EX阶段,该nop指令在周期5中移动到MA级,在周期6中移动到RW级。该nop命令称为流水线气泡。气泡是由互锁硬件动态插入的nop指令,在类似于正常指令的流水线阶段中移动。同样,在循环5和6中,我们需要插入管线气泡。最后,在周期7中,指令[2]可以自由地进入EX和后续阶段。气泡不起任何作用,因此当阶段遇到气泡时,没有任何控制信号打开。要注意的另一个微妙的点是,不能在同一个周期内对同一个寄存器进行读写,需要优先选择写入,因为它是较早的指令,而读取需要暂停一个周期。
实现气泡有两种方法:
- 可以在指令包中有一个单独的气泡位。每当位为1时,该指令将被解释为一个气泡。
- 可以将指令的操作码更改为nop的操作码,并将其所有控制信号替换为0。这种方法更具侵入性,但可以完全消除电路中的冗余工作。在前一种方法中,控制信号将打开,由其激活的单元将保持运行。硬件需要确保气泡不能对寄存器或内存进行更改。
流水线气泡(pipeline bubble)是由互锁硬件动态插入流水线寄存器中的nop指令,气泡以与正常指令相同的方式在流水线中传播。
总之,通过在流水线中动态插入气泡,可以避免数据冲突。
接下来阐述div和mod指令等慢指令的问题。在大多数流水线中,这些指令很可能需要n(n>1)个周期才能在EX阶段执行。在n个周期的每个周期中,ALU完成div或mod指令的部分处理。每个这样的循环被称为T状态(T State),通常一个阶段具有1T状态,但慢指令的EX阶段有许多T状态。因此,为了正确实现慢指令,需要暂停IF和OF阶段(n-1)个周期,直到操作完成。
为了简单起见,我们将不再讨论这个问题,相反,继续进行简单的假设,即所有流水线阶段都是平衡的,并且需要1个周期来完成它们的操作。现在看看控制冲突,首先考虑以下代码片段。
[1]: beq .foo
[2]: add r1, r2, r3
[3]: sub r4, r5, r6
....
....
.foo:
[4]: add r8, r9, r10
如果分支被执行,可以在流水线中插入气泡,而不是使用延迟分支,否则不需要做任何事情。假设分支被去掉,这种情况下的流水线图如下所示。
在这种情况下,指令[1]的分支条件的结果在循环3中决定。此时,指令[2]和[3]已经在流水线中(分别在IF和of阶段)。由于分支条件求值为take,我们需要取消指令[2]和[3],否则它们将被错误执行。因此将它们转换为气泡,如上图所示,指令[2]和[3]在循环4中转换为气泡。其次,在循环4从正确的分支目标(.foo)中提取,因此指令[4]进入流水线。两个气泡都经过所有流水线阶段,最后分别在循环6和7中离开流水线。
因此可以通过在流水线中动态引入气泡来确保这两个条件(数据锁定和分支锁定)。下面更详细地看看这些方法。
为了确保数据锁定条件,需要确保OF阶段中的指令与后续阶段中的任何指令之间没有冲突,冲突被定义为可能导致RAW冲突的情况。换句话说,如果后续阶段的指令写入由OF阶段的指令读取的寄存器,则存在冲突。因此需要两个硬件来实现数据锁定条件,第一步是检查是否存在冲突,第二步是确保流水线停止。
首先看看冲突检测硬件。冲突检测硬件需要将OF阶段中的指令的内容与其他三个阶段(即EX、MA和RW)中的每个指令的内容进行比较,如果与这些指令中的任何一条发生冲突,可以声明有冲突。让我们关注检测冲突的逻辑,简要介绍一下冲突检测电路的伪代码,设OF阶段中的指令为[A],后续阶段中的一条指令为[B]。检测冲突的算法伪代码如下所示:
Data: Instructions: [A] and [B]
Result: Conflict exists (true), no conflict (false)
1 if [A].opcode 2 (nop,b,beq,bgt,call) then
/* Does not read from any register */
2 return false
3 end
4 if [B].opcode 2 (nop, cmp, st, b, beq, bgt, ret) then
/* Does not write to any register */
5 return false
6 end
/* Set the sources */
7 src1 [A]:rs1
8 src2 [A]:rs2
9 if [A].opcode = st then
10 src2 [A]:rd
11 end
12 if [A].opcode = ret then
13 src1 ra
14 end
/* Set the destination */
15 dest [B]:rd
16 if [B].opcode = call then
17 dest ra
18 end
/* Check if the first operand exists */
19 hasSrc1 true
20 if [A].opcode 2 (not,mov) then
21 hasSrc1 false
22 end
/* Check the second operand to see if it is a register */
23 hasSrc2 true
24 if [A].opcode =2 (st) then
25 if [A]:I = 1 then
26 hasSrc2 false
27 end
28 end
/* Detect conflicts */
29 if (hasSrc1 = true) and (src1 = dest) then
30 return true
31 end
32 else if (hasSrc2 = true) and (src2 = dest) then
33 return true
34 end
35 return false
用硬件实现上述算法很简单,只需要一组逻辑门和多路复用器,大多数硬件设计者通常用硬件描述语言(如Verilog或VHDL)编写类似于上述算法的电路描述,并依靠智能编译器将描述转换为实际电路。
我们需要三个冲突检测器($\mathrm{OF} \leftrightarrow \mathrm{EX}, \mathrm{OF} \leftrightarrow \mathrm{MA}, \mathrm{OF} \leftrightarrow \mathrm{RW}$)。如果没有冲突,则指令可以自由地进入EX阶段,但如果至少有一个冲突,则需要暂停IF和OF阶段。一旦指令通过OF阶段,它就保证拥有所有的源操作数。
现在来看看流水线的暂停。我们基本上需要确保在发生冲突之前,没有新的指令进入IF和OF阶段,这可以通过禁用PC和IF-OF流水线寄存器的写入功能来简单地确保。因此,它们不能接受时钟边缘(clock edge)上的新数据,将继续保持它们以前的值。
其次,还需要在流水线中插入气泡,例如从OF传递到EX阶段的指令需要是无效指令或气泡,可以通过传递nop指令来确保。因此确保数据锁定条件的电路是直接的,需要一个连接到PC的冲突检测器和IF-OF寄存器。在发生冲突之前,这两个寄存器将被禁用,无法接受新数据。我们强制OF-EX寄存器中的指令包含nop,流水线的增强电路图如下图所示。
带互锁的流水线的数据路径(实现数据锁定条件)。
接下来阐述分支锁定条件。
假设流水线中有一条分支指令(b、beq、bgt、call、ret)。如果有延迟时隙,那么数据路径与上图所示的相同,不需要做任何更改,因为执行的整个复杂性已经加载到了软件中。然而,将流水线暴露于软件有其利弊,如果在管线中添加更多阶段,那么现有的可执行文件可能会停止工作。为了避免这种情况,让我们设计一个不向软件暴露延迟时隙的流水线。
有两个设计选项:
- 第一种:可以假设在确定结果之前不采取分支。我们可以继续在分支之后获取这两条指令并进行处理,一旦在EX阶段决定了分支的结果,就可以根据结果采取适当的行动。如果未执行分支,则在分支指令之后获取的指令位于正确的路径上,无需再执行任何操作。但是,如果执行了分支,则必须取消这两条指令,并用流水线气泡(nop指令)替换它们。
- 第二种:暂停流水线,直到分支的结果被确定,而不管结果如何。
显然,第二种设计的性能低于假设不采用分支的第一种替代方案,例如,如果一个分支30%的时间没有被占用,那么对于第一个设计,30%的时间都在做有用的工作。然而,对于第二个选项,我们在获取分支指令后的2个周期中从未做过任何有用的工作。
因此,让我们从性能的角度考虑第一个设计,只有在分支被占用时,才取消分支后的两个指令,这种方法为预测不采用(predict not taken),因为实际上是在预测不采取的分支。稍后,如果发现此预测错误,则可以取消错误路径中的指令。
如果分支指令的PC等于p,那么选择在接下来的两个周期中在p+4和p+8处获取指令。如果分支没有被执行,那么将继续执行。但是,如果分支被执行,那么将取消这两条指令,并将它们转换为流水线气泡。
我们不需要对数据路径进行任何重大更改,需要一个小型分支冲突单元,从EX阶段接收输入。如果执行分支,则在下一个周期中,它将If-OF和OF-EX阶段中的指令转换为流水线气泡。带有分支互锁单元的扩展数据路径如下图所示。
带互锁的流水线的数据路径(实现数据锁定和分支锁定条件)。
接下来阐述带转发(Forwarding)的流水线。
上面已经实现了一个带有互锁的流水线。互锁确保流水线正确执行,而不管指令之间的依赖性如何。对于数据锁定条件,我们建议在流水线中添加互锁,在寄存器文件中有正确的值之前,不允许指令离开操作数获取阶段。然而,下面将看到,不必总是添加互锁。事实上,在很多情况下,正确的数据已经存在于流水线寄存器中,尽管不存在于寄存器文件中。可以设计一种方法,将数据从内部流水线寄存器正确地传递到适当的功能单元。考虑以下代码:
add r1, r2, r3
sub r4, r1, r2
下图仅包含这两条指令的流水线图,(a)显示了带互锁的流水线图,(b)显示了无互锁和气泡的流水线图。现在尝试论证不需要在指令之间插入气泡。
(a)带互锁的流水线图,(b)无互锁和气泡的流水线图。
让我们深入查看上图(b)。指令1在EX阶段结束时产生其结果,或者在周期3结束时产生结果,并在周期5中写入寄存器文件。指令2在周期3开始时需要寄存器le中的r1值,显然是不可能的,因此建议添加流水线互锁来解决此问题。
让我们尝试另一种解决方案,允许指令执行,然后在循环3中,[2]将获得错误的值,允许它在周期4中进入EX阶段。此时,指令[1]处于MA阶段,其指令包包含正确的r1值。r1值是在前一个周期中计算的,存在于指令包的aluResult字段中,[1] 的指令包在周期4中位于EX-MA寄存器中。现如果在EX-MA的aluResult字段和ALU的输入之间添加一个连接,那么可以成功地将r1的正确值传输到ALU。我们的计算不会出错,因为ALU的操作数是正确的,因此ALU运算的结果也将被正确计算。
下图显示了我们在流水线图中的操作结果,将指令[1]的MA阶段添加到指令[2]的EX阶段。由于箭头不会在时间上倒退,因此可以将数据(r1的值)从一个阶段转发(forward)到另一个阶段。
在流水线中转发的示例。
转发(Forwarding)是一种通过阶段之间的直接连接在不同流水线阶段中的指令之间传输操作数值的方法,不使用寄存器文件跨指令传输操作数的值,从而避免昂贵的流水线互锁。
我们刚刚研究了一种非常强大的技术,可以避免管线中的停顿,称为转发。本质上,我们允许操作数的值在指令之间流动,方法是跨阶段直接传递它们,不使用寄存器文件跨指令传输值。转发的概念允许我们背靠背地(以连续的周期)执行指令[1]和[2],不需要添加任何暂停周期。因此,不需要重新排序代码或插入nop。
为了在指令[1]和[2]之间转发r1的值,我们在MA级和EX级之间添加了一个连接,上图9中通过在指令[1]和[2]的相应阶段之间画一个箭头来显示这种联系。这个箭头的方向是垂直向上的,由于它没有在时间上倒退,有可能转发该值,否则是不可能的。
现在让我们尝试回答一个一般性问题——可以在所有指令对之间转发值吗?注意,不需要是连续的指令,即使生产者和消费者ALU指令之间有一条指令,我们仍然需要转发值。现在尝试考虑管线中各阶段之间的所有可能的转发路径。
广泛遵循的转发基本原则如下:
- 在后期和早期之间添加了转发路径。
- 在管线中尽可能迟地转发一个值。例如,如果给定阶段不需要给定值,并且可以在稍后阶段从生产者指令中获取该值,那么我们等待在稍后阶段获取转发的值。
请注意,这两个基本原则都不影响程序的正确性,它们只允许消除冗余的转发路径。现在,系统地看看管道中需要的所有转发路径:
- RW --> MA:MA阶段需要来自RW阶段的转发路径,考虑下图所示的代码片段,指令[2]需要MA阶段(周期5)中的r1值,而指令[1]在周期4结束时从内存中获取r1值。因此,它可以在周期5中将其值转发给指令[2]。
- RW --> EX:下图所示的代码段显示了一条加载指令,它在周期4结束时获取寄存器r1的值,以及一条后续的ALU指令,它需要周期5中的r1值。因为不会在时间上倒退,所以可以转发该值。
- MA --> EX:下图所示的代码段显示了一条ALU指令,该指令在周期3结束时计算寄存器r1的值,以及一条连续的ALU指令在周期4中需要r1的数值。在这种情况下,还可以通过在MA和EX级之间添加互连(转发路径)来转发数据。
- RW --> OF:通常OF阶段不需要转发路径,因为它没有任何功能单元,不需要立即使用值,可以稍后根据原则2转发价值。然而,唯一的例外是从RW阶段转发,无法稍后转发该值,因为指令将不在管线中。因此有必要添加从RW到OF级的转发路径,需要RW --> OF转发的代码段示例如下图所示。指令[1]通过在周期4结束时从内存中读取r1的值来生成r1值,然后它在周期5中将r1值写入寄存器文件。同时,指令[4]尝试在周期5的OF阶段读取r1值,不幸的是,这里存在冲突。因此,我们建议通过在RW和OF阶段之间添加转发路径来解决冲突。因此,禁止指令[4]读取r1值的寄存器文件。相反,指令[4]]使用RW --> OF转发路径从指令[1]获取r1值。
不需要添加以下转发路径:MA-->OF和EX-->OF,因为我们可以使用以下转发路径(RW-->EX)和(MA-->EX)。根据原则2,需要避免冗余转发路径,因此不添加从MA和EX级到OF级的转发路径。我们不向IF阶段添加转发路径,因为在这个阶段,还没有解码指令,不知道其操作数。
现在又衍生了一个问题:转发是否完全消除了数据冲突?
现在回答这个问题。先考量ALU指令,它们在EX阶段产生结果,并准备在MA阶段前进,任何后续的使用者指令都需要前一条ALU指令在EX阶段最早生成的操作数的值。此时可以实现成功的转发,因为操作数的值在MA阶段已经可用。如果生产者指令已离开流水线,则任何后续指令都可以使用任何可用的转发路径或从寄存器文件获取值。如果生产者指令是ALU指令,那么总是可以将ALU运算的结果转发给消费者指令。为了证明这一事实,需要考虑所有可能的指令组合,并判断是否可以将输入操作数转发给使用者指令。
唯一显式生成寄存器值的其他指令是加载指令。请记住,存储指令不会写入任何寄存器。让我们看看加载指令,加载指令在MA阶段结束时产生其值,因此它准备在RW阶段转发其值。考虑下图中的代码片段及其流水线图。
加载-使用冲突。
指令[1]是写入寄存器r1的加载指令,指令[2]是使用寄存器r1作为源操作数的ALU指令,加载指令在周期5开始时准备好转发。不幸的是,ALU指令在周期4开始时需要r1的值,故而需要在流水线图中绘制一个箭头,该箭头在时间上向后流动。因此,在这种情况下,转发是不可能的。
加载-使用冲突(Load-Use Hazard)是指加载指令将加载的值提供给在EX阶段需要该值的紧随其后的指令的情况。即使有转发,管线也需要在加载指令之后插入一个暂停周期。
这是需要在管线中引入暂停循环的唯一情况,这种情况被称为加载-使用冲突,加载指令将加载的值提供给在EX阶段需要该值的紧随其后的指令。消除加载-使用冲突的标准方法是允许管线插入气泡,或者使用编译器重新排序指令或插入nop指令。
总之,具有转发的管线确实可能需要互锁,唯一的特殊情况是加载-使用冲突。
请注意,如果在存储加载值的加载指令之后有一个存储指令,那么我们不需要插入暂停循环,因为存储指令需要MA阶段的值。此时,加载指令处于RW阶段,可以转发该值。
如果要实现管线的转发,需要根据不同的管线阶段来实现。
支持转发的OF阶段如下图所示,基线管道中没有转发的多路复用器用较浅的颜色着色,而为实现转发而添加的附加多路复用器被着色为较深的颜色。
下图显示了修改后的EX阶段。EX级从OF级获得的三个输入是A(第一个ALU操作数)、B(第二个ALU运算数)和op2(第二寄存器操作数)。对于A和B,我们添加了两个复用器M3和M4,以在OF级中计算的值和分别从MA和RW级转发的值之间进行选择。对于可能包含存储值的op2字段,我们不需要MA --> EX转发,因为在MA阶段需要存储值,因此我们可以使用RW --> MA转发,从而减少一条转发路径。因此,多路复用器M5具有两个输入(默认值和从RW级转发的值)。
下图显示了具有额外转发支持的MA阶段。内存地址在EX阶段计算,并保存在指令包的aluResult字段中,内存单元直接使用该值作为地址。然而,在存储的情况下,需要存储的值(op2)可以从RW阶段转发,因此添加了多路复用器M6,它在指令包中的op2字段和从RW级转发的值之间进行选择。电路的其余部分保持不变。
下图显示了RW阶段。因为是最后一个阶段,所以它不使用任何转发值。但是,它将写入寄存器le的值分别发送到MA、EX和OF阶段。
下图将所有部分放在一起,并显示了支持转发的管线。总之,我们需要添加6个多路复用器,并在单元之间进行一些额外的互连,以传递转发的值。我们设想一个专用的转发单元,它为多路复用器(图中未示出)计算控制信号。除了这些小的更改,不需要对数据路径进行其他重大更改。
带转发的流水线数据路径(简图)。
我们在讨论转发时使用了一个简图(上图)。需要注意的是,实际电路现在变得相当复杂。除了对数据路径的扩展,还需要添加一个专用转发单元来为多路复用器生成控制信号。详细图片如下图所示。
现在将互锁逻辑添加到管线中,需要数据锁定和分支锁定条件的互锁逻辑。请注意,现在已经成功处理了除加载-使用冲突以外的所有RAW冲突。在加载-使用冲突的情况下,只需要停止一个周期,大大简化了数据锁定电路。如果EX阶段有加载指令,就需要检查加载指令和OF阶段的指令之间是否存在RAW数据依赖关系,不需要考虑的唯一RAW冲突是加载-存储依赖性,即加载写入包含存储值的寄存器,我们不需要暂停,因为可以将要存储的值从RW转发到MA阶段。对于所有其他数据依赖性,需要通过引入气泡将管线暂停1个周期,此举可以解决加载-使用冲突,确保分支锁定条件的电路保持不变。还需要检查EX阶段中的指令,如果它是一个执行的分支,需要使if和OF阶段的指令无效。最后应注意,互锁始终优先于转发。
19.5.3.5 性能标准和测量
本节讨论流水线处理器的性能。
需要首先在处理器的上下文中定义性能的含义。大多数时候,当我们查找笔记本电脑或智能手机的规格时,会被大量的术语淹没,比如时钟频率、RAM和硬盘大小,遗憾的是,这些术语都不能直接表示处理器的性能。计算机标签上从未明确提及性能的原因是“性能”一词相当模糊,处理器的性能一词总是指给定的程序或汇编,因为处理器对不同程序的性能不同。
给定一个程序P,让我们尝试量化给定处理器的性能。如果P在A上执行P的时间比在B上执行P所需的时间短,那么处理器A比处理器B性能更好。因此,量化给定程序的性能非常简单,测量运行程序所需的时间,然后计算其倒数,这个数字可以解释为与处理器相对于程序的性能成正比。
首先计算运行程序P所需的时间: $$ \begin{aligned} \tau &=# \text { seconds } \ \ &=\frac{# \text { seconds }}{# \text { cycles }} \times \frac{# \text { cycles }}{# \text { instructions }} \times(# \text { instructions }) \ \ &=\underbrace{\frac{# \text { seconds }}{# \text { cycles }}}{1 / f} \times \underbrace{\frac{# \text { cycles }}{# \text { instructions }}}{C P I} \times(# \text { instructions }) \ \ &=\frac{ \text { CPI } \times(# \text { instructions })}{f} \
\end{aligned} $$
- 每秒的周期数是处理器的时钟频率(f)。
- 每个指令的平均周期数称为CPI(Cycles per instruction),其逆数(每个周期的指令数)称为IPC(Instructions per cycle)。
- 最后一项是指令数(缩写为#insts)。注意,是动态指令的数量,或者处理器实际执行的指令数量,不是程序可执行文件中的指令数。
静态指令是程序的二进制或可执行文件包含指令列表里的每条指令。
动态指令是静态指令的实例,当指令进入流水线时由处理器创建。
; -----示例1-----
; 在不违反程序正确性的情况下重新排序以下代码,以减少暂停。
add r1, r2, r3
ld r4, 10[r5]
sub r1, r4, r2
;答案
ld r4, 10[r5]
add r1, r2, r3
sub r1, r4, r2
; 没有加载-使用冲突,程序的逻辑保持不变。
; -----示例2-----
; 在不违反程序正确性的情况下重新排序以下代码,以减少暂停。假设有2个延迟时隙的延迟分支.
add r1, r2, r3
ld r4, 10[r5]
sub r1, r4, r2
add r8, r9, r10
b .foo
; 答案
add r1, r2, r3
ld r4, 10[r5]
b .foo
sub r1, r4, r2
add r8, r9, r10
; 消除了加载-使用风险,并最佳地使用了延迟时隙。
架构
我们使用流水线设计了一个高级架构。请注意,流水线本身并不能提高性能,由于暂停,与单周期处理器相比,流水线减少了程序的IPC。流水线的主要好处是它允许我们以更高的频率运行处理器,最小周期时间从单循环流水线的$t_{max}$减少到k级流水线机器的$t_{max}/k+l$。由于每个周期都完成一条新指令的执行,除非出现暂停,所以可以在流水线机器上更快地执行一组指令,指令执行吞吐量要高得多。
流水线的主要好处是以更高的频率运行处理器,可以确保更高的指令吞吐量(更多的指令每秒完成执行)。与单周期处理器相比,流水线本身减少了程序的IPC,也增加了处理任何单个指令所需的时间。
延迟分支和转发等技术有助于提高流水线机器的IPC,我们需要专注于通过各种技术提高复杂管线的性能。需要注意的重要一点是,架构技术影响频率(通过流水线阶段的数量)和IPC(通过转发和延迟分支等优化)。
制造工艺
制造工艺影响晶体管的速度,进而影响组合逻辑块和锁存器的速度,晶体管越小,速度越快。因此总算法工作量($t_{max}$)和锁存延迟(l)也在稳步减少,可以在更高的频率下运行处理器,从而提高性能。制造技术只影响我们运行处理器的频率,对IPC或指令数量没有任何影响。
总之,可以用下图总结这一段的讨论。
性能、编译器、架构和技术之间的关系。
请注意,总体情况并不像本节描述的那么简单,还需要考虑功率和复杂性问题。通常,由于复杂性的增加,实现超过20个阶段的流水线非常困难。其次,大多数现代处理器都有严重的功率和温度限制,这个问题也称为功率墙(power wall,下图)。通常不可能提高频率,因为我们无法承受功耗的增加,根据经验法则,功率随频率的立方而增加,将频率增加10%会使功耗增加30%以上,非常之大。设计者越来越避免以非常高的频率运行的深度流水线设计。
英特尔x86微处理器的时钟频率和功耗超过八代30年。奔腾4在时钟频率和功率上有了戏剧性的飞跃,但在性能上没有那么出色。Prescott的热问题导致了奔腾4系列的报废。Core 2系列恢复为更简单的流水线,具有更低的时钟速率和每个芯片多个处理器。Core i5管道紧随其后。
关于性能,最后要进一步讨论的是功率和温度的问题。
功率和温度问题在这些年变得越来越重要。高性能处理器芯片通常在正常操作期间消耗60-120W的功率。,如果在一台服务器级计算机中有四个芯片,那么将大致消耗400W的功率。一般来说,计算机中的其他组件,如主存储器、硬盘、外围设备和风扇,也会消耗类似的电量,总功耗约为800W。如果增加额外的开销,例如电源、显示硬件的非理想效率,则功率需求将达到约1KW。一个拥有100台服务器的典型服务器场将需要100千瓦的电力来运行计算机。此外还需要冷却装置(如空调),通常为了去除1W的热量,需要0.5W的冷却功率,因此服务器农场的总功耗约为150千瓦。相比之下,一个典型的家庭的额定功率为6-8千瓦,意味着一个服务器农场消耗的电力相当于20-25个家庭使用的电力,非常显而易见。请注意,包含100台机器的服务器场是一个相对较小的设置,实际上,有更大的服务器农场,包含数千台机器,需要兆瓦的电力,足以满足一个小镇的需求。
现在考虑真正的小型设备,比如手机处理器,由于电池寿命有限,功耗也是一个重要问题。所有人都会喜欢电池续航很长的设备,尤其是功能丰富的智能手机。现在考虑更小的设备,例如嵌入身体内部的小型处理器,用于医疗应用,通常在起搏器等设备中使用小型微芯片。在这种情况下,不想强迫患者携带重型电池,或经常给电池充电,从而给患者带来不便。为了延长电池寿命,重要的是尽可能减少耗电。
此外,温度是一个非常密切相关的概念。结合下图中芯片的典型封装图,通常有一个200-400平方毫米的硅管芯(silicon die),管芯是指包含芯片电路的矩形硅块。由于这一小块硅耗散60-100W的功率(相当于6-10个CFL灯泡),除非采取额外措施冷却硅管芯,否则其温度可能会升至200摄氏度。首先在硅管芯上添加一块5cm x 5cm的镀镍铜板,就是所谓的扩散器,扩散器通过传播热量,从而消除热点,有助于在模具上形成均匀的温度分布。需要一个扩散器,因为芯片的所有部分都不会散发相同的热量,例如ALU通常耗散大量热量,而存储元件相对较冷。其次,散热取决于程序的性质,对于整数基准测试,浮点ALU是空闲的,会更冷。为了确保热量正确地从硅管芯流到扩散器,通常添加一种导热凝胶,称为热界面材料(TIM)。
大多数芯片都有一种结构,即散热器顶部的散热器。它是一种铜基结构,具有一系列鳍片,如上图所示。添加了一系列鳍片以增加其表面积,确保处理器产生的大部分热量可以散发到周围的空气中。在台式机、笔记本电脑和服务器中使用的芯片中,有一个风扇安装在散热器上,或者安装在计算机机箱中,将空气吹过散热器,确保热空气被驱散,而来自外部的冷空气流过散热器。散热器、散热器和风扇的组合有助于散热处理器产生的大部分热量。
尽管采用了先进的冷却技术,处理器仍能达到60-100摄氏度。在玩高度互动的电脑游戏时,或者在运行天气模拟等大量数据处理应用程序时,芯片上的温度最高可达120摄氏度,足以烧开水、煮蔬菜,甚至在冬天温暖一个小房间,我们不需要买加热器,只需要运行一台计算机!请注意,温度有很多有害影响:
- 片上铜线和晶体管的可靠性随着温度的升高呈指数下降。由于一种被称为NBTI(负偏置温度不稳定性)的效应,芯片往往会随着时间老化,老化有效地减缓了晶体管的速度。因此,有必要随着时间的推移降低处理器的频率,以确保正确的操作。
- 一些功耗机制(如泄漏功率)取决于温度,意味着随着温度的升高,总功率的泄漏分量也随之升高,进一步提高了温度。
总之,为了降低电费、降低冷却成本、延长电池寿命、提高可靠性和减缓老化,降低芯片上的功率和温度非常重要。现在让我们快速回顾一下主要的功耗机制。主要关注两种机制,即动态和泄漏功率(leakage power),泄漏功率也称为静态功率。
先阐述动态功率。
可以把芯片的封装看作一个封闭的黑盒子,有电能流入,热量流出。在足够长的时间段内,流入芯片的电能的量完全等于根据能量守恒定律作为热量耗散的能量的量。此处忽略了沿I/O链路发送电信号所损失的能量,但与整个芯片的功耗相比,该能量可以忽略不计。
任何由晶体管和铜线组成的电路都可以被建模为具有电阻器、电容器和电感器的等效电路。电容器和电感器不散热,但电阻器将流经电阻器的一部分电能转换为热量,这是电能在等效电路中转化为热能的唯一机制。
现在考虑一个小电路,它有一个电阻器和一个电容器,如下图所示,电阻器代表电路中导线的电阻,电容器表示电路中晶体管的等效电容。需要注意的是,电路的不同部分,例如晶体管的栅极,在给定的时间点具有一定的电势,意味着晶体管的栅极起着电容器的作用,从而存储电荷。类似地,晶体管的漏极和源极具有等效的漏极电容和源极电容。通常不会在简单的分析中考虑等效电感,因为大多数导线通常很短,并且它们不起电感器的作用。
具有电阻和电容的电路。
19.5.4 高级技术
本节将简要介绍实现处理器的高级技术。请注意,本节绝不是独立的,其主要目的是为读者提供额外学习的指导。本节将介绍几个大幅度提高性能的广泛范例,这些技术被最先进的处理器采用。
现代处理器通常使用非常深的流水线(12-20阶段)在同一周期内执行多条指令,并采用先进技术消除流水线中的冲突。让我们看看一些常见的方法。
19.5.4.1 分支预测
让我们从IF阶段开始,看看如何做得更好。如果在管线中有一个执行的分支,那么IF阶段尤其需要在管线中暂停2个周期,然后需要开始从分支目标中提取。随着我们添加更多的线线阶段,分支惩罚(branch penalty)从2个周期增加到20多个周期,使得分支指令非常昂贵,会严重限制性能。因此,有必要避免管线暂停,即使对于已采取的分支也是如此。
如果可以预测分支的方向,也可以预测分支目标,那会怎么样?在这种情况下,提取单元可以立即从预测的分支目标开始提取。如果在稍后的时间点发现预测错误,则需要取消预测错误的分支指令之后的所有指令,并将其从管线中丢弃,这种指令也称为推测指令(speculative instruction)。
现代处理器通常根据预测执行大量指令集,例如预测分支的方向,并相应地从预测的分支目标开始提取指令,稍后执行分支指令时验证预测。如果发现预测错误,则从管线中丢弃所有错误获取或执行的指令,这些指令称为推测指令(speculative instruction)。相反,正确获取并执行的指令,或其预测已验证的指令称为非推测指令。
请注意,禁止推测性指令更改寄存器文件或写入内存系统是极其重要的,因此需要等待指令变得非推测性,然后才允许它们进行永久性的更改。第二,不允许它们在非推测之前离开管线,但如果需要丢弃推测指令,那么现代管线采用更简单的机制,通常会删除在预测失败的分支指令之后获取的所有指令,而不是选择性地将推测指令转换为管线气泡。这个简单的机制在实践中非常有效,被称为管线刷新(pipeline flush)。
现代处理器通常采用一种简单的方法,即丢弃管线中的所有推测指令。它们完全完成所有指令的执行,直到出现预测失误的指令,然后清理整个管线,有效地删除在预测失误指令之后获取的所有指令。这种机制称为管线刷新(pipeline flush)。
现在概述分支预测中的主要挑战:
- 如果一条指令是一个分支,需要在提取阶段首先读取,如果是分支,需要读取分支目标的地址。
- 接下来,需要预测分支的预期方向。
- 有必要监测预测指令的结果。如果存在预测失误,那么需要在稍后的时间点执行管线刷新,以便能够有效地删除所有推测指令。
在分支的情况下检测预测失误是相当直接的,将预测添加到指令包中,并用实际结果验证预测。如果它们不同,那么安排管线刷新。主要的挑战是预测分支指令的目标及其结果。
现代处理器使用称为分支目标缓冲器(BTB)的简单硬件结构,它是一个简单的内存阵列,保存最后N(从128到8192不等)条分支指令的程序计数器及其目标。找到匹配的可能性很高,因为程序通常表现出一定程度的局部性,意味着它们倾向于在一段时间内重复执行同一段代码,例如循环,因此BTB中的条目往往会在很短的时间内被重复使用。如果存在匹配,那么也可以自动推断该指令是分支。
要有效地预测分支的方向要困难得多,但可以利用后面阐述的模式。程序中的大多数分支通常位于循环或if语句中,其中两个方向的可能性不大,事实上,一个方向的可能性远大于另一个方向,例如循环中的分支占用了大部分时间。有时if语句仅在某个异常条件为真时才求值,大多数情况下,与这些if语句关联的分支都不会被执行。类似地,对于大多数程序,设计者观察到几乎所有的分支指令都遵循特定的模式,它们要么对一个方向有强烈的偏倚,要么可以根据过去的历史进行预测,要么可以基于其它分支的行为进行预测。当然,这种说法没有理论依据,只是处理器设计者的观察结果,因此他们设计了预测器来利用程序中的这种模式。
本节讨论一个简单的2位分支预测器。假设有一个分支预测表,该表为表中的每个分支分配一个2位值,如下图所示。
如果该值为00或01,则预测该分支不会被执行。如果它等于10或11,那么预测分支被执行。此外,每次执行分支时,将相关计数器递增1,每次不执行分支时将计数器递减1。为了避免溢出,不将11递增1以产生00,也不将00递减以产生11。我们遵循饱和算术的规则,即(二进制):$11+1=11$和$00-1=00$。这个2位值被称为2位饱和计数器,其状态图如下图所示。
预测分支有两种基本操作:预测和训练。为了预测分支,我们在分支预测表中查找其程序计数器的值,在特定情况下,使用pc地址的最后n位来访问2n个条目的分支预测表。读取2位饱和计数器的值,并根据其值预测分支,当得到分支的实际结果时,我们训练通过使用饱和算法递增或递减计数器的值。
现在看看这个预测器的工作原理,考虑一段简单的C代码及其等效的汇编代码:
void main()
{
foo();
...
foo();
}
int foo()
{
int i, sum = 0
for(i=0; i < 10; i++)
{
sum = sum + i;
}
return sum;
}
.main:
call .foo
...
call .foo
.foo:
mov r0, 0 /* sum = 0 */
mov r1, 0 /* i = 0 */
.loop:
add r0, r0, r1 /* sum = sum + i */
add r1, r1, 1 /* i = i + 1 */
cmp r1, 10 /* compare i with 10 */
bgt .loop /* if(r1 > 10) jump to .loop */
ret
让我们看看循环中的分支语句bgt .loop
,对于除最后一次之外的所有迭代,都会执行分支。如果在状态10下启动预测器,那么第一次,分支预测正确(采取),计数器递增并等于11,对于后续的每一次迭代,都会正确地预测分支。然而,在最后一次迭代中,需要将其预测为未采取,这里有一个错误的预测,因此,2位计数器递减,并设置为10。现在考虑一下再次调用函数foo时的情况,2位计数器的值为10,并且分支bgt .loop
被正确预测为采用。
因此,2位计数器方案在预测方案中增加了一点延迟(或过去的历史)。如果分支历史上一直在一个方向,那么一个异常不会改变预测。这个模式对于循环非常有用,正如在这个简单的例子中看到的那样,循环最后一次迭代中分支指令的方向总是不同的,但下一次进入循环时,分支的预测是正确的,正如本例所示。请注意,这只是其中一种模式,现代分支预测程序可以利用更多类型的模式。
程序优化提示:
- 为编译器提供尽可能多的有关正在执行的操作的信息。
- 尽可能使用常量和局部变量。如果语言允许,请定义原型并声明静态函数。
- 尽可能使用数组而不是指针。
- 避免不必要的类型转换,并尽量减少浮点到整数的转换。
- 避免溢出和下溢。
- 使用适当的数据类型(如float、double、int)。
- 考虑用乘法代替除法。
- 消除所有不必要的分支。
- 尽可能使用迭代而不是递归。
- 首先使用最可能的情况构建条件语句(例如if、switch、case)。
- 在结构中按尺寸顺序声明变量,首先声明尺寸最大的变量。
- 当程序出现性能问题时,请在开始优化程序之前对程序进行概要分析。(评测是将代码分成小块,并对每一小块进行计时,以确定哪一块花费的时间最多的过程。)
- 切勿仅基于原始性能放弃算法。只有当所有算法都完全优化时,才能进行公平的比较。
- 过程内联(procedure inlining),用函数体替换对函数的调用,用调用者的参数替换过程的参数。
- 循环转换(loop transformation),可以减少循环开销,改善内存访问,并更有效地利用硬件。
- 循环展开(loop-unrolling)。在执行多次迭代的循环中,例如那些传统上由For语句控制的循环,循环展开loop-unrolling的优化通常有用。循环展开包括进行循环,多次复制身体,并减少执行转换后的循环的次数。循环展开减少了循环开销,并为许多其他优化提供了机会。
- 复杂的循环转换,如交换嵌套循环和阻塞循环以获得更好的内存行为。
- 局部和全局优化。在专用于局部和全局优化的过程中,执行以下优化:
- 局部优化在单个基本块内工作。局部优化过程通常作为全局优化的先导和后续运行,以在全局优化前后“清理”代码。
- 全局优化跨多个基本块工作。
- 全局寄存器分配为代码区域的寄存器分配变量。寄存器分配对于在现代处理器中获得良好性能至关重要。
- 更具体地,有子表达式消除(Common subexpression elimination)、削减强度(Strength reduction)、常量传播(Constant propagation)、拷贝传播(Copy propagation)、死存储消除(Dead store elimination)等操作。
如果使用两个位,则可以使用它们来记录相关指令执行的最后两个实例的结果,或者以其他方式记录状态。下图显示了一种典型的方法,假设算法从流程图的左上角开始。只要执行遇到的每个后续条件分支指令,决策过程就预测将执行下一个分支,如果单个预测错误,则算法继续预测下一个分支被执行。只有在没有采取两个连续分支的情况下,算法才会转移到流程图的右侧,随后该算法将预测在一行中的两个分支被取下之前不会取下分支,因此该算法需要两个连续的错误预测来改变预测决策。
分支预测流程图。
预测过程可以用有限状态机更紧凑地表示,如下图所示,许多文献通常使用有限状态机表示。
分支预测状态图。
下图将该方案与从未采取的预测策略进行了对比。使用前一种策略,指令获取阶段总是获取下一个顺序地址。如果执行了分支,处理器中的某些逻辑会检测到这一点,并指示从目标地址提取下一条指令(除了刷新管道之外)。分支历史表被视为缓存,每个预取都会触发分支历史表中的查找。如果未找到匹配项,则使用下一个顺序地址进行提取,如果找到匹配,则根据指令的状态进行预测:下一个顺序地址或分支目标地址被馈送到选择逻辑。
为了弥补依赖性,已经开发了代码重组技术,首先考虑分支指令。延迟分支(Delayed branch)是一种提高流水线效率的方法,它使用的分支在执行以下指令后才生效(因此称为延迟),紧接在分支之后的指令位置被称为延迟槽(delay slot)。这个奇怪的过程如下表所示。在标记为“正常分支”的列中,有一个正常的符号指令机器语言程序。执行102之后,下一条要执行的指令是105。为了规范流水线,在这个分支之后插入一个NOOP。但是,如果在101和102处的指令互换,则可以实现提高的性能。
下图显示了结果。图a显示了传统管线方法。JUMP指令在时间4被获取,在时间5,JUMP指令与指令103(ADD指令)被获取的同时被执行。因为发生了JUMP,它更新了程序计数器,所以必须清除流水线中的指令103,在时间6,作为JUMP的目标的指令105被加载。
图b显示了典型RISC组织处理的相同管线,时间是一样的,但由于插入了NOOP指令,不需要特殊的电路来清除管线,NOOP简单地执行而没有效果。
图c显示了延迟分支的使用。JUMP指令在ADD指令之前的时间2获取,ADD指令在时间3获取。但请注意,ADD指令是在执行JUMP指令有机会改变程序计数器之前获取的。因此,在时间4期间,在获取指令105的同时执行ADD指令,保留了程序的原始语义,但执行需要两个更少的时钟周期。
19.5.4.2 延迟加载
类似延迟分支的策略称为延迟加载(delayed load),可以用于加载指令。在LOAD指令中,将成为加载目标的寄存器被处理器锁定。然后,处理器继续执行指令流,直到它到达需要该寄存器的指令为止,此时它将空闲,直到加载完成。如果编译器可以重新排列指令,以便在加载过程中完成有用的工作,那么效率就会提高。
19.5.4.3 循环展开
另一种提高指令并行性的编译器技术是循环展开(loop unrolling)。展开会多次复制循环体,称为展开因子(u),并按步骤u而不是步骤1进行迭代:
- 减少环路开销
- 通过提高流水线性能提高指令并行性
- 改进寄存器、数据缓存或TLB位置
下图在一个示例中说明了所有三种改进。循环开销减少了一半,因为在测试之前执行了两次迭代,并在循环结束时分支。由于可以在存储第一次赋值的结果和更新循环变量的同时执行第二次赋值,因此提高了指令并行性。如果将数组元素分配给寄存器,寄存器局部性将得到改善,因为在循环体中使用了两次a[i]和a[i+1],从而将每次迭代的加载次数从三次减少到两次。
指令流水线的设计不应与应用于系统的其他优化技术分离,例如流水线的指令调度和寄存器的动态分配应该一起考虑,以实现最大的效率。
点击博客园继续阅读:19.5.3.5性能标准和测量
作者:向往
文章来源:https://zhuanlan.zhihu.com/p/590910593