翻译 | 指令调度基础

作者:Jason Robert

 

现代RISC处理器改变了硬件和编译器之间的基本关系。在RISC之前,硬件和编译器有着相对简单的责任划分——硬件负责底层的细节表现,而编译器负责语言翻译和独立于特定机器的高层次优化(如公共子表达式消除)。一些编译器作者将大量精力投入到良好的指令选择算法中,但很少能有真正的性能改进。事实上,许多情况下,一个复杂指令的执行速度要比一组简单的指令来得慢。

随着RISC处理器的出现,情况变得不同了。编译器现在主要负责发挥硬件的性能特性,硬件依赖于智能编译器来生成高度优化的代码。如果没有高质量的编译器,RISC架构就没有意义了——这是一个硬件/软件的协定。

RISC的“哲学”是将“架构/实现”的边界移到更接近硬件的地方,将关键性能特性暴露给编译器,以便编译器能够(希望)利用它们。这一理念可以追溯到上世纪80年代之前:计算机在上世纪60年代由控制数据公司(Control Data Corporation)开发,CDC-6600常常被认为是第一个RISC,它的架构和实现之间完全没有区分,从而几乎将所有的硬件特性暴露给了编译器。

这些表现特性中,尤其是对于现代处理器而言,最为关键的是处理器流水线的组织。为了实现高性能,编译器必须重新排列程序中的指令,以便有效地利用处理器提供的指令级并行性,称为指令调度。这种特殊形式的优化称为“软件流水线”,专门用于处理简单的内层循环。

在当今(2003)的编译器界,如何充分利用超标量和VLIW处理器具有的的指令级并行性是热门话题之一。受基本块大小的限制,积极的调度器必须充分调度分支处的指令。在过去的十年里,人们在这方面进行了大量的研究,但全局指令调度依然是一个待解决的问题。因此,这篇介绍性的文章将只涉及直线代码序列(如基本块)指令调度的基础知识。

 

指令调度的问题

指令调度器面临的问题是:重新排列机器代码指令,旨在最小化执行特定指令序列所需的时钟周期数。不幸的是,在处理器流水线上执行的顺序代码内含着一些指令之间的依赖关系,在指令调度期间执行的任何转换都必须保留这些依赖关系,以维护被调度代码的逻辑。

此外,指令调度器通常有一个次要目标,即最小化寄存器生存期,或至少不要无故延长它们。在实践中,这通常是一个相互冲突的目标,因为限制实时寄存器的数量会引入错误依赖(稍后将进一步讨论)。

指令调度与微代码压缩本质上是一样的问题,只稍有不同(微码压缩和VLIW机器的指令调度是完全相同的问题)。微代码压缩用于确定正确的垂直微操作序列,并在可能的情况下将这些序列组合成更宽、并行的微指令。 如果两个操作之间没有数据依赖关系,并且支持这种形式的微指令编码(格式),则可将两个操作组合。相比之下,指令调度仅垂直改变码序列,并没有明确地将操作组合到一起(除了在VLIW架构上)。

由于指令在流水线结构中重叠执行,第i个时钟周期发出的指令的结果直到第i+n个时钟周期才可用,其中n是相应路径的约束长度(即:指令延迟)。如果在时钟周期i和i+n之间发出的指令尝试引用在周期i发出的指令的结果,则数据相关性被破坏。互锁的流水线将能检测到这种情况,并停止执行有问题的指令直到第i+n个时钟周期,带来处理器时钟的浪费。更糟糕的是,在没有互锁的流水线上,因为引用的值是其它操作的结果,代码会产生不正确的结果!

自动找到代码序列的最优调度结果是一个NP完备问题(任意路径约束),为确定最佳时间表,必须检查所有合法的时间表。即使是以固定两个周期长度的延迟时间这样的限制形式,调度问题仍然是棘手的。

[PalemSimons,1990](在[HennessyGross,1983]中提出了一个较弱的NP完备性证明,但如今这个证明似乎是有缺陷的)。只有在单周期流水线延迟时,问题才能在小于指数的时间内得到最优解,在这种情况下,线性时间解决方案是可能的[BernsteinGertner,1989],[ProebstingFischer,1991]。

虽然这个简单的线性时间情况对于原始RISC处理器的整数处理单元(同加载延迟和分支一起)是足够的,但即使是最基本的浮点单元也还是不适用,更别提超标量或VLIW处理器 (在RISC时代的早期,大多数汇编程序在基本块内自动执行简单的线性时间指令调度,直到更深的调度被迫切需要)。

在许多情况下,穷举搜索策略实际上是小代码序列的一种可行方法,并且在限制条件下经常被编译器使用(或更准确地说,使用分支定界搜索,这实际上是一个更加智能的穷举搜索)。对于较大的代码块,穷举搜索很快变得不切实际,但启发式引导的调度算法可以在几乎线性的时间内达到非常好的结果,通常在10%的最优解内。

 

依赖的类型

在实践中,需要考虑三种类型的数据依赖。

当一条指令读取另一条指令写入的结果时,会产生写后读(read-after-write,RAW)相关性,读指令必须在写指令一定时钟周期后再读取而不会产生阻塞。这是最常见的依赖类型,是依赖指令重叠执行的自然结果。

当一条指令写在另一条指令的操作数上时,会产生反向依赖或称读后写(WAR)依赖。 读指令必须在写指令之前经过适当的周期数才能安全读取,而不阻塞写指令。在大多数流水线上,在流水线开始时读取值,并在结尾时写入,因此反向依赖通常要求读取指令在写入之前,或在超标量/VLIW处理器的相同周期中发出(等待时间0)。如果在寄存器分配之后执行调度,则通常仅存在寄存器之间的反向依赖性,但是对于存储器访问的反向依赖性是顺序代码流水线执行的自然结果。

如果两条指令写入同一个目标,就会产生单个输出或写后写(WAW)依赖关系,但逻辑上的第一条指令永远不会被使用(否则这将是一个正向依赖加反向依赖关系)。除非逻辑上的第一次写入指令具有额外作用,例如写入设备驱动程序,否则它不需要,可以删除。

前两种类型的依赖关系——写后读和读后写,是“真实”的,因为它们代表了程序的真正逻辑。任何写后写依赖通常都是编译过程或结构化编程过程的瑕疵,通常可以通过数据流优化(如死代码消除或部分冗余消除)来删除。第四个组合“读后读”当然不是依赖关系,因为可以以任何顺序执行这两个读取而不影响结果。

除数据依赖性之外,还要记住,程序还包含控制依赖关系,这些依赖关系指定程序的逻辑结构以及特定被执行操作的顺序。例如,if语句的then部分在执行条件测试之前不应执行,只有在条件测试结果为真时才执行。循环、条件、函数调用等都意味着控制依赖关系。这种类型约束下的跨分支指令移动比在直线代码序列中简单地重排指令复杂得多——称为全局指令调度,在本介绍性文章中不涉及。

 

依赖图

像大多数调度问题一样,指令调度通常被建模为DAG评估问题[HennessyGross,1983]。数据依赖图中的每个节点表示单个机器指令,并且每个路径赋予与相关指令等待时间对应的权重。

 

图1 数据依赖图示例

 

所有形式的存储和其副作用是产生依赖源头,这包括通用、特殊及状态寄存器、条件代码和内存位置。检测寄存器冲突是微不足道的,条件代码通常被当作像每条分支代码使用单独的寄存器一样处理,然而消除内存引用却是一个非常复杂的问题。如果不进行复杂深入的别名分析,显然某些内存引用不能相互混淆——同一个基址寄存器的不同偏移量不能引用相同的位置,并且栈中的内存不能与堆或全局区域中的内存重叠。如果没有进一步的别名分析,必须做出保守的假设。

给定如上所示的依赖关系图,指令调度算法必须找到图表的评估顺序,其所有父节点在其子节点之前,至少要留有不少于父子节点间路径加权和的周期数。如果满足这些要求,则被调度的代码序列的语义属性不会改变。

如果在寄存器分配之后执行调度,则可能有两个额外的条件——在原始代码序列的开始处活动的值应该在新的代码序列开始处保持活动,在原始代码序列的结尾处活动的值应该在新的代码序列结尾处保持活动。现代优化器在寄存器分配之前进行指令调度,因此不必遵守这些约束,除非是寄存器分配后的调度情况。

内存访问(例如加载操作)会给依赖图带来一定程度的不确定性——加载指令的延迟不是固定的,实际上会因缓存命中与否而变化很大。许多调度程序只是假设每次访问都为主数据缓存命中,基于这一假设,大约90%的内存访问实际上是D缓存命中。更复杂的方法试图通过尽可能均匀地分配内存操作,以平衡内存访问的不可预知性[KernsEggers,1993],[LoEggers,1995]。

控制依赖关系经转化为数据依赖关系,可以直接表示在数据依赖图上,但是它们通常是通过基本块结构和控制流图来直接表示。这里,控制流问题将不被考虑(本文不包括全局指令调度)。

由于硬件限制而强加的结构性危害也必须以特殊的方式处理,因为它们表示图上的无向路径。例如,两条加法指令可能使用相同的硬件而相互冲突,因此他们不能同时进行,但任何排序都可以避免这种冲突。 这样的信息通常使用定时启发或资源预留表来建模。

依赖关系图中的节点和路径往往用信息进行注释,以帮助调度程序在准备好的指令之间做出决定。尽管大部分信息可以在图形构建期间获得,但是对于只有用图形创建方向相反的遍历才能获得的信息,有时候需要执行一个单独的遍历。例如,如果图形是在正向传递中创建的,诸如到终点的距离信息则必须通过单独的后向遍历来获得。另外,一些信息本质上是动态的,直到实际调度完成才能获得。

不管构建的方向如何,可以用两种不同的方式来确定新节点应该连接到哪个节点。最简单的方法是完全比较法,每个节点都检查其与其它节点的依赖关系。由于每个节点必须与除自身以外的其它每个节点进行比较,因此必须在构图过程中进行(n2-n)/2次比较,这种构造的时间阶数为O(n2)。由于可能存在大量的节点(对于全局调度,有时候数百甚至数千),这种方法不可接受。尽管如此,许多早期的调度程序确实采用了完全比较法。这样的调度程序通过fpppp SPEC基准测试时编译得非常缓慢(含有10000条指令的基本块)。

更聪明的方法是跟踪后面的指令来决定机器资源,因为只有这些指令会与新的指令相冲突。这种被称为表建模的方法在时间上与指令数量成正比,因为机器资源的数量不随着指令的增加而变化[SmothermanEtc,1991]。相较于完全比较法,它还有其他的优势,最重要的是不会在构图过程中生成不必要的传递路径。

一些传递路径是必要的,但是受维护图的时间限制。幸运的是,表建模方法正确地维护着这些路径,因为它们结合了不同类型的依赖关系。图2显示了必要传递路径的最常见示例——反向依赖和正向依赖对的结果。 即使删除了路径C,指令的顺序也不会改变,但从乘法到减法的指令延迟只剩下一个周期,显然这是不正确的。

 

图2 一个必要的传递路径示例

 

启发式调度

在指令调度问题上有很多启发式方法。其中大部分遵循了John Hennessy和Thomas Gross于1983年制定的一般方法[HennessyGross,1983],该方法基于标准清单调度(另一个值得注意的方法是通过改进的Sethi-Ullman表达式评估来补偿某些形式的流水线延迟[ProebstingFischer,1991],然而这种方法不容易扩展到现代超标量处理器带来的更复杂的调度问题)。

列表调度法的工作原理是维护一个准备好的列表,其中包含可以在特定时间点合法执行的所有指令。调度程序重复从就绪列表中选择一条指令,将其从列表中删除并使用(将其插入到最终时间表中),这使得列表中的另一条指令进入就绪状态。整个过程的关键是用一组启发方式来选择最佳指令。

通常,启发式调度被组合使用,称为优先级程序,以尝试针对特定情况选择发布最佳指令。虽然现在有数以百计流行的启发方式,但它们可以分为简单的几大类[Krishnamurthy,1990],[SmothermanEtc,1991] ...

 

阻塞保护/指令类别启发式

阻塞保护启发方式试图检测指令将在哪个特定时间点停顿。这常涉及维护每个指令的最早执行时间(如果调度是后向执行,则为最近的执行时间),指令不可能在最早的执行时间之前启动而不会产生停顿。 为了维护每个指令的最早执行时间,调度器保存当前时间,并且每次发出指令时,其每个子节点的最早执行时间被更新为当前时间和相关路径的权重和。

检测延迟更为灵活的方法是将每个就绪指令的指令类别与最后发布指令(或超标量处理器的指令组)的指令类别进行比较。不幸的是,使用这样的方式又给调度器增加了另一项指令遍历任务,将线性时间算法增加到O(n2)。另一方面,它为处理许多超标量和VLIW处理器的细微分组规则提供了一定程度的灵活性。

涉及单个扩展的(非流水线)硬件资源(例如浮点除法单元),可以通过特殊启发方式或通过使用明确的资源预留表来处理。如果所需资源不可用,但可以使用替代指令,有些调度程序甚至考虑将指令从一个类迁移到另一个类。 例如,移位操作可以被转换成乘法,或者可以使用浮点单元对合适边界内的值执行整数运算。后一种方法通过将浮点单元设计为双整数单元来更好地处理[SastryEtc,1998]。

 

关键路径启发式

正如您所预料的那样,关键路径启发式决定了哪些指令落在关键路径上。与一些更简单的调度问题不同,指令调度是一个资源受限问题。因此,最佳指令序列可能比关键路径长,并且可能不会及时在关键路径上发布指令。由于最佳时间表不能小于关键路径,所以这些指令通常在调度时相对较早地发布,因为它们可能会限制总的执行时间。

关键路径启发式的最有用的是距结束的距离——沿着特定指令到代码序列末尾可能路径的最大距离,遍历器挑选距结束的最大距离作为关键路径。由于图形不包含后向沿,因此每条指令到结束的最大距离可以通过简单的反向传递或反向图形构建来计算。

另一种需要在图上进行两遍的方法是:计算每个节点的最早开始时间和最晚开始时间,然后确定每个指令的弹性时间(最迟 - 最早)。自然,弹性时间为零的指令处于关键路径上。 尽管这比计算到结束的距离效率低,但确实提供了可用于平衡启发式的“弹性”信息。请注意,最早的开始时间与最早的执行时间不一样。最早的开始时间是静态测量离开叶子的最大路径长度,而最早的执行时间是由指令类别信息维护的动态值。

 

揭示/平衡启发式

揭示/平衡启发式(有时称为“公平性”启发式)试图通过确定在发出特定指令时将有多少节点添加到就绪列表中,以此平衡图形的过程。简单的方法包括计算每条指令的子节点数量或单亲子节点的数量;更复杂的方法包括计算到子节点的总延迟,后代数量或后代总路径长度。也许最好的指标是未被覆盖的子节点的数量,这是一个动态的启发式算法,它只计算只有一个未发布父节点的子节点的数量(即将被发布的节点)。

 

寄存器使用启发式

寄存器使用启发式尝试减少寄存器的活动范围,以便稍后的寄存器分配更成功。一个简单的例子是计算特定指令中创建或消除的寄存器的数量。更复杂的寄存器使用方法是在创建寄存器的指令即将发布时,动态促进其子节点。这种方法不是简单地将创建寄存器的指令推迟到消除寄存器的指令之后,而是试图将创建,使用和消除特定寄存器的指令放在一起。 GNU指令调度程序使用这种启发式方法取得了相当大的成功。

 

参考文献

【1】Basic Instruction Scheduling(and Software Pipelining).http://www.lighterra.com/

 

 

·END·

 

想进一步跟踪本博客动态,欢迎关注我的个人微信订阅号:信号君

 

信号君:寻求简单之道

技术成长 | 读书笔记 | 认知升级

扫描二维码关注信号君

  

 

猜你喜欢

转载自www.cnblogs.com/ncdxlxk/p/9231235.html