对程序块或过程中的操作进行排序以有效利用处理器资源的任务称为指令调度(instruction scheduling)。调度器的输入是由目标机汇编语言操作组成的一个部分有序的列表,输出是同一列表的一个有序版本。
一组指令的执行时间严重依赖于其执行顺序,指令调度会重排一个过程中的各个指令,使每个周期执行尽可能多的指令,以改进其运行时间。处理器常见指令的典型延迟周期:
指令调度的主导技术是一种贪婪启发式算法,称为表调度。表调度器运行在无分支代码上,使用各种优先级排序(priority ranking)方案来指引其选择。编译器编写者已经发明了若干框架,用于在代码中大于基本程序块的区域上调度指令。这些区域和循环调度器只是创造条件,使编译器能够将表调度应用到一个更长的操作序列上。
指令调度器的3个主要目标:
假定执行下列代码的处理器只有一个功能单元,load和store需要花费3个周期,mult花费两个周期,其他指令均花费一个周期。在这些假定下,原来的代码(下图a)要花费22个周期,调度后的代码(下图b)只需要花费13个周期。
调度后的代码将长延迟的指令与引用其结果的指令隔离开来,这种分离使得不依赖其结果的指令能够与长延迟指令并发执行 。调度后的代码在前三个周期发射load指令,其结果分别在4、5、6周期就绪。这种调度需要一个额外的寄存器r3来保存第3个并发执行的load指令的结果,但它使得处理器在等待从内存加载第一个算术运算操作数的同时执行一些有用的工作。这种操作之间的重叠执行,实际上隐藏了内存操作的延迟。程序块各处也应用的同一思想隐藏了mult操作的延迟。
这里将会介绍针对单发射的表调度算法,由于市场上的主流处理器都具有多个功能单元,可以在每个周期发射多条指令,所以后面也会介绍如何扩展表调度算法以支持多发射处理器。
指令调度问题定义在基本程序块的依赖关系图 D D D之上, D D D有时候也称为前趋图, D D D的定义是:对于程序块b,其依赖关系图 D = ( N , E ) D = (N,E) D=(N,E),b中的每个操作在 D D D中对应于一个结点( N N N是结点集合)。对于两个结点n1和n2,如果n2使用了n1的结果,那么 D D D中有一条边连接了n1和n2( E E E是边的集合)。 D D D中的边表示程序块中值的流动,其中的每个结点有两个属性,分别是操作类型和延迟。
D D D不是树,它是由多个有向无环图(Directed acyclic graph,DAG)形成的森林,因而,结点可以有多个父结点,而 D D D也可以有多个根结点。在 D D D中没有前趋结点的那些结点(如下图中的a、c、e和g)称为该图的叶结点,由于叶结点不依赖于任何其他操作,它们可以尽早调度执行。 D D D中没有后继结点的结点(如下图中的i)称为该图的根结点,根结点是图中最受限制的结点,因为直至其所有祖先都已经执行之后,它们才能执行。
给出一个代码片断的依赖关系图 D D D,调度S将每个结点n(n ∈ \isin ∈ N)映射到一个非负整数,表示对应操作应该在哪一个周期发射,这里假定第一个操作在周期1发射。这里为指令提供了一个清晰简洁的定义,即第 i i i条指令是操作集合 { n ∣ S ( n ) = i } \{n|S(n)=i\} {n∣S(n)=i}。调度必须满足3个约束:
编译器只应当产生满足所有3个约束的调度。给出一个良构、正确、可行的调度,该调度的长度只是最后一个操作完成的周期编号,假定第一个指令在周期1发射。调度长度可以如下计算:
L ( S ) = m a x x ∈ N ( S ( n ) + d e l a y ( n ) ) L(S) = max_{x \in N} (S(n) + delay(n)) L(S)=maxx∈N(S(n)+delay(n))
随调度长度的概念而来的是时间最优调度(time-optimal schedule)的概念,如果对包含同一组操作的所有其他调度 S j S_j Sj,都有 L ( S i ) ⩽ L ( S j ) L(S_i) \leqslant L(S_j) L(Si)⩽L(Sj) ,那么调度 S i S_i Si是时间最优的。
沿穿越依赖关系图的路径计算总延迟,能够暴露有关该程序块的额外细节。对上文例子中的依赖关系图 D D D标注累积延迟的有关信息,将得到下图。从一个结点到计算结束处(根结点)的路径长度被作为结点的上标给出。其值清楚地说明了路径abdfhi
是最长的(累计延迟为3+1+2+2+2+3=13
),它是决定这个例子总体执行时间的关键路径(依赖关系图中延迟最长的路径)。
编译器如何调度这一计算呢?
cdfhi
,所以c会作为第二条指令调度;acebdgfhi
,这与上图12-1b给出的调度后的代码是匹配的。反相关:如果操作x位于操作y之前,且y定义了一个x中使用的值,那么称操作x反相关于操作y,记作y → \to → x。调换其执行次序,将导致x计算出一个不同的值,所以调度器无法将y移到x之前,除非它重命名y的结果。
调度器至少可以用两种方法来生成正确的代码。一种是发现输入代码中存在的反相关并在最终的调度中遵守这种关系,这可以调度生成正确的代码,但是想对于未调度的代码,性能提升并不是很高。
另一种是用重命名值来避免反相关。编译器如果可以系统化的重命名程序块的值,则可以在调度代码之前消除反相关,这样生成的代码性能提升相对较高。但是这中方式存在一个潜在问题,即可能会增加对寄存器的需求,并迫使寄存器分配器逐出更多的值。
最简单的重命名方案在每个值生成时为其分配一个新名字,如对上图12-1a 中的代码重命名将产生下列代码,其依赖关系是没有歧义的,不包含反相关。
调度还可以用执行时间之外的其他值度量。同一程序块的两个调度 S i S_i Si和 S j S_j Sj对寄存器的需求可能是不同的,即 S j S_j Sj中活跃值的最大数目可能小于 S i S_i Si中的最大数目。如果处理器要求调度器为空闲的功能单元插入nop指令,那么 S i S_i Si包含的操作可能少于 S j S_j Sj,因而执行时需要取的指令也较少。这不完全依赖于调度长度。例如,在具有可变周期nop指令的处理器上,将多个nop操作串在一起会产生较少的操作,且实际发射的指令数可能也会变少。最后, S j S_j Sj在目标系统上的执行能耗可能低于 S i S_i Si,因为它从来不使用某个功能单元,取的指令数目较少,或者在处理器的取指逻辑和译码逻辑之间传输的比特数较少。
调度的根本操作是,根据各个操作开始执行的周期,将各个操作分组。对于每个操作,调度器必须选择一个周期。对于每个周期,调度器必须选择一组操作。为平衡这两种视角,调度器必须确保,每个操作只在当其操作数可用时才能发射。
在调度器将操作i
放置在周期c
中时,这一决策将影响到任何依赖于i
结果的操作(在 D D D中从i
可达的任何操作)的最早置放。如果在周期c
中可以合法地执行多个操作,那么调度器的选择可能会改变对许多操作(直接或间接依赖于每个可能置于c
中的操作)的最早置放。
【本节总结】:局部指令调度器必须为每个操作指定一个执行周期(这些周期从基本程序块入口开始编号)。在这一过程中,调度器必须确保调度中的任一周期包含的操作都没有超出硬件发射指令的能力。在静态调度处理器上,调度器必须确保每个操作都仅在其操作数就绪后发射 ,这要求调度器向调度中插入nop指令。在动态调度处理器上,调度器应该使执行导致的预期拖延数量最小化 。
表调度是一个贪婪启发式方法,而非一个具体的算法,用以调度基本程序块中的各个操作。
经典表调度将范围限制到无分支代码序列,即运行在一个基本程序块上,使得我们可以忽略一些复杂的调度情况。如,在调度器考虑多个程序块时,一个操作数可能取决于此前在不同程序块中的定义,这在操作数何时就绪的问题上产生了不确定性;而跨越程序块边界的代码移动则产生了另一组复杂情况,可能将操作移动到其此前并不存在的某条路径上,还可以在必要时从某条路径上删除操作。(下一节探讨跨程序块的调度)
为将表调度应用到程序块,调度器遵循一个包含四个步骤的计划。
重命名和 D D D的构建比较简单的,常见的优先级计算会遍历依赖关系图 D D D并在其上计算一些量度。算法的核心和理解它的关键在于最后一步——调度算法。下图12-3给出了这一步骤的基本框架,其中假定目标处理器只有一个功能单元。
调度算法抽象地模拟了被调度程序块的执行,算法会忽略值和操作的细节,而专注于 D D D中各条边所规定的时序约束。为跟踪时间,算法在变量Cycle
中维护了一个模拟时钟。它将Cycle
初始化为1,并在穿越程序块处理时对其加1。
算法使用两个列表来跟踪操作。Ready
列表包含了当前周期 可执行的所有操作。如果一个操作位于Ready
之中,那么其所有操作数都已经计算完成。最初,Ready
包含了 D D D中的所有叶结点,因为它们并不依赖于程序块中的其他操作。Active
列表包含了在更早的周期中发射但尚未完成的所有操作。每次调度器对Cycle
加1时,它会从Active
中删除Cycle
之前已经完成的任何操作op
。算法接下来核对op
在 D D D中的每个后继结点,以确定相应结点是否能够移入Ready
列表中,即是否其所有操作数都已经就绪。
表调度算法遵循一种简单的规范。在每个时间步上,算法会考虑前一周期完成的所有操作,调度当前周期已经就绪的操作,并对Cycle
加1。当模拟时钟表明每个操作都已经完成时,这个过程就会停止。如果通过delay
指定的所有延迟时间都是精确的,且 D D D的叶结点的所有操作数在第一个周期都是可用的,那么这种模拟运行时间应该与实际执行时间是匹配的。还可以有一个简单的后处理趟,来重排各个操作并插入必要的nap指令。
算法还必须遵守最后一个约束。必须对程序块结束处分支或跳转指令进行调度,以使程序计数器在程序块执行结束之前不发生(突然)变化。因此,如果 i i i 是程序块末尾的分支指令,它不可能早于周期 L ( S ) + 1 − d e l a y ( i ) L(S)+1-delay(i) L(S)+1−delay(i)调度执行。因而,单周期分支操作必须在程序块的最后一个周期调度执行,而双周期分支指令必须不早于程序块的最后第二个周期调度执行。
该算法生成的调度的质量,主要取决于从Ready
队列挑选操作的机制。考虑最简单的场景,其中Ready
列表在每次迭代中至多包含一项。在这种受限情形下,算法必定能生成最优调度。第一个周期只可能执行一个操作。( D D D中必须至少有一个叶结点,而我们的限制确保了其中刚好有一个叶结点)在后续的每个周期,算法没得选择:或者是Ready
包含一个操作,算法调度其执行;或者是Ready
为空,算法无法调度任何操作来在该周期发射执行。当在某些周期Ready
列表包含多个操作时,会出现困难。
当算法必须在几个就绪操作中进行选择时,所作的选择就变得很关键。算法应该选用具有最高优先级得分的操作。在平分的情况下,应该使用一个或多个其他条件来打破平局(参见3.4节)。如果采用此前建议的度量方式(度量的结果,即为从当前结点到 D D D中根结点、按延迟为权重计算长度时最长路径的长度),那么在构造调度时,将总是选择当前周期关键路径上的结点。在调度优先级的影响可预测的范围内,这种方案在寻找最长路径时应该能够提供较为平衡的结果。
内存操作通常具有不确定和可变的延迟。在具有多级高速缓存的机器上,load操作实际延迟的变动范围颇大:可能是0个周期,也可能是数百甚至于数千周期。如果调度器假定延迟为最坏情形,那么会冒处理器长时间空闲的风险。如果假定延迟为最佳情形,那么可能因缓存失效而导致处理器执行发生停顿。实际上,编译器根据可用于“覆盖”load
操作延迟的指令级并行性的数量,分别为每个load
单独计算相应的延迟,这样做可以得到良好的结果。这种方法称为平衡调度(balancedscheduling),它根据包围load
操作的代码来调度load
操作,而非根据将执行load
操作的硬件。这种方法将局部可用的并行性散布到程序块中的各个load
处。这种策略通过为每个load
操作调度尽可能多的额外延迟,从而减轻了缓存失效的影响。而在没有缓存失效的情况下,它不会使执行减速。
上图12-4给出了对于一个程序块中各个load
操作延迟的计算。算法将每个load
的延迟都初始化为1。接下来,算法考虑程序块的依赖关系图 D D D中的每个操作 i i i。算法会发现 D D D中与 i i i无关的各个计算,称为 D i D_i Di。概念上,该任务是 D D D上的一个可达性问题。通过从 D D D中删除 i i i的每个直接/间接 的前趋/后继 结点,以及与这些结点相关联的边,我们即可计算出 D i D_i Di。
算法接下来将查找 D i D_i Di的连通分量。对于每个分量 C C C,算法会查找穿越 C C C的任一路径上load
操作的最大数目 N N N。在 C C C中最多有 N N N个load
操作可共享操作 i i i的延迟,因此算法将 d e l a y ( i ) / N delay(i)/N delay(i)/N加到 C C C中每个load
的延迟上。对于一个给定的load
操作 l l l,上述做法将各个独立操作 i i i的延迟中 l l l所占的份额累加起来,其中 独立操 i i i可用于覆盖 l l l延迟。使用该值作为 d e l a y ( l ) delay(l) delay(l)可以产生一个调度,将各个独立操作富余的延迟平均分配给程序块中的所有load
操作。
表调度算法包含了几个实际上不一定成立的假定。该算法假定每个周期只能发射一个操作,而大多数处理器可以在每个周期发射多个操作。为处理这种情况,我们必须扩展算法中的while
循环,使之在每个周期为每个功能单元分别寻找一个可发射的操作。最初的扩展很简明:编译器编写者可以添加一个遍历各个功能单元的循环。
当一些操作可以在多个功能单元执行而且其他操作不可以时,就会出现相应的复杂情况。编译器编写者可能需要选择一种遍历功能单元的顺序,以便先调度限制较多的功能单元,而后调度限制较少的单元。在寄存器集合被分区的处理器上,调度器可能需要将一个操作放置在其操作数驻留的分区中,或者将其调度到分区间传输设施处于空闲状态的周期中。
在程序块边界处,调度器需要考虑下述事实:在前趋块中计算的一些操作数在当前块的第一个周期可能是不可用的。如果编译器在CFG上按逆后序对各个程序块调用表调度器,那么编译器可以确保:调度器能够知道在当前程序块入口处需要等待多少个周期,才能等到操作数沿CFG中的前向边进入当前程序块。(这种解决方案无助于处理循环控制分支指令;对于循环调度的讨论,请参见“五、高级主题”。)
指令调度的复杂性,使得编译器编写者使用相对廉价的启发式技术即表调度算法的变体,而非试图求出问题的最优解。实际上,表调度能够产生良好的结果,它通常可以建立最优或接近最优的调度。但类似于许多贪婪算法,其行 为是不健壮的:输入的很小改变可能导致解的巨大变化。
用于打破平局的方法学对由表调度所产生调度的质量有着巨大影响。当两个或更多项具有同样的优先级时,调度器应该根据另一种优先级排序打破平局。良好的调度器对每个操作可能设置有两三个用于打破平局的优先级,调度器会按照某种一致的次序应用这些优先级。除了早先描述的以延迟为权重计算的路径长度之外,调度器还可以使用下列优先级。
Ready
队列上保留更多的操作。delay
。这种度量方式会尽早调度长延迟操作。在程序块中,调度器会优先调度这些操作执行,此时将余下更多的操作可用于“覆盖“其延迟。遗憾的是,这些优先级方案没有哪一个能够在总体调度质量上占绝对优势。每个方案都在一些例子上表现不错,而在其他例子上表现较差。因而,就使用哪些优先级或以什么顺序应用优先级,并没有什么一致意见。
从3.2中已经学习到,表调度算法运行在依赖关系图上,从叶结点到根结点进行处理,从程序块中第一个周期到最后一个周期来建立调度。对该算法的另一种表述按相反的方向运行在依赖关系图上,即从根结点到叶结点来进行调度。第一个被调度的操作最后一个发射,而最后一个被调度的操作第一个发射。算法的这个版本称为后向(backward)表调度,原版本称为前向(forward)表调度。
表调度并不是编译中代价高昂的一个部分。因而,一些编译器会用启发式规则的不同组合运行调度器若干次,并保留质量最好的调度,每次运行调度器都可以重用大部分准备工作(重命名、建立依赖关系图和计算一部分优先级)。在这样的方案中,编译器应该考虑同时使用前向调度和后向调度。
实际上,前向调度和后向调度中没有哪一个始终比另一个好。前向和后向表调度之间的差别在于调度器考虑各个操作的顺序。如果调度的质量极度依赖于对某一小组操作的续密排序,那么这两个方向上的调度策略可能会产生显著不同的结果。如果关键操作存在于叶结点附近,那么前向调度似乎更可能将这些操作共同考虑,而后向调度则必须穿越程序块的其余部分才能到达这些操作。对称地,如果关键操作存在于根结点附近,那么后向调度可能会综合考察它们,而前向调度则必须按照在程序块另一端所作决策规定的顺序,在遍历整个程序块后才能看到这些操作。
为从Ready
列表中选择一个操作,按照到目前为止的描述,需要对Ready
进行线性扫描。这使得创建和维护Ready
的代价接近 O ( n 2 ) O(n^2) O(n2)。将列表替换为优先队列可以将操纵Ready
的代价降低到 O ( n log 2 n ) O(n\log_2n) O(nlog2n),而实现的难度仅有稍许增加。
类似的方法可以降低操纵Active
列表的代价。在调度器向Active
添加一个操作时,它可以为其指定一个优先级,优先级值等于操作完成的周期编号。寻找最小优先级的优先队列会将当前周期完成的所有操作推向最前端,实现的代价相对于简单的列表实现仅有少许增加。
在Active
的实现中,进一步的改进也是可能的。调度器可以维护一组独立的列表,每个列表对应于一个周期,包含了将在该周期完成的各个操作。覆盖所有操作延迟所需的列表数目是 M a x L a t e n c y = m a x n ∈ D d e l a y ( n ) MaxLatency=max_n \isin D delay(n) MaxLatency=maxn∈Ddelay(n)。当编译器在Cycle
周期调度操作n时,它将n
添加到Worklist[(Cycle + de1ay(n)) mod Maxlatency]
。在需要更新Ready
队列时,所有需要考虑的操作(实际上是考虑其后继结点)都在Worklist[Cycle mod Maxlatency]
中。这种方案会使用少量额外的空间,而各个Worklist上操作数目的和等于Active
列表上操作的数目。各个Worklist在空间上会有少最开销。在向Worklist插入时每次会使用稍多一点时间,来计算应该使用哪个Worklist。作为回报,这避免了搜索Active
的 n 2 n^2 n2级代价,而代之以对较小的Worklist的线性遍历。
一些处理器包含了对乱序(OutOfOrder,OOO)执行指令的硬件支持。我们称此类处理器为动态调度处理器(dynamically scheduled machine)。为支持乱序执行,动态调度处理器需要在指令流中前瞻以寻找能够提前执行的操作(与静态调度处理器相比)。为做到这一点,动态调度处理器需要在运行时建立和维护一部分依赖关系图。它使用这部分依赖关系图来发现每个指令何时可以执行,并在最早的“合法”时机发射每条指令。
何时乱序处理器能够相对于静态调度作出改进?如果运行时环境好于调度器所作的假定,那么乱序硬件发射一个操作的时机可能早于静态调度。这可能发生在程序块边界处(如果操作数变为可用的时间早于最坏情形假定),也可能发生在可变延迟操作的情形。因为乱序处理器知道实际运行时地址,它还可以消除一些load-store
依赖关系,这是调度器做不到的。
乱序执行并不会消除指令调度的必要性。因为前瞻窗口(动态发射的保留站)是有限的,拙劣的调度很难通过乱序执行改进。例如,容纳50条指令的前瞻窗口,不可能将100条整数指令后接100条浮点指令变为<整数指令,浮点指令>
对的形式交错执行。但它可以将较短的指令序列交错执行,比如说长度为30的情况。乱序执行可以通过改进良好但非最优的调度来帮助编译器。
一种相关的处理器特性是动态寄存器重命名。与ISA允许编译器引用的寄存器相比,这种方案向处理器提供了更多的物理寄存器。处理器可以通过使用额外的物理寄存器( 对编译器是隐藏的)来打破发生在其前瞻窗口内部的反相关,以实现通过反相关连接起来的两个引用。
与值编号算法类似,从单个基本程序块移动到较大范围也可以改进编译器所生成代码的质量。就指令调度而言,对于大于一个基本程序块、小于整个过程的区域,前人已提议许多不同的调度方法,这些方法几乎都使用表调度算法作为重排指令的引擎。他们利用一种基础设施将基本算法封装起来,使之能够考虑更长(如多个程序块)的代码序列。接下来将学习编译器应用表调度的上下文环境来提高调度质量的3种思想。
在超局部值编号中已经应用过扩展基本程序块(EBB)的概念。下图给出的一个简单代码片断,其中有一个大EBB:{B0,B1,B3,B4} 和两个一般的EBB:{B5} 和 {B6}。大的EBB有两条路径 1,B2,B4> 和1,B3>,二者以B1为公共前缀。
为使表调度获得更大的上下文环境,编译器可以将EBB中的路径如 1,B2,B4> 作为单个基本程序块处理,只要编译器妥善考虑了共享的路径前缀(如 1,B2,B4> 和1,B3> 的公共前缀B1)以及过早退出的情况(如 B1 → \to →B3 和 B2 → \to →B5)。这种方法使编译器能够将其卓有成效的将表调度应用到更长的操作序列中,其效果是增加可以共同调度的代码的比例,这应该会改进执行时间。
为了解共享前缀和过早退出是如何使表调度复杂化的,我们考虑上图例子中的路径 1,B2,B4> 中代码移动的可能性。这种代码移动可能需要调度器插入补偿代码(compensation code,插入到程序块Bi中,用以抵消不包含Bi的代码路径上跨程序块的代码移动所带来副效应的代码)以维护正确性。
补偿代码的问题也说明了调度器应该按何种顺序考虑EBB中的各条路径。因为第一个调度的路径几乎不需要补偿代码,调度器应该按可能执行频度的顺序来选择路径,它可以同全局代码置放算法一样,使用剖析数据或估算。
调度器可以采取措施减轻补偿代码的影响。它可以使用变量活跃信息来避免前向移动带来的一部分补偿代码。如果被移动操作的结果在路径外程序块的入口处是不活跃的,那么无需为该程序块添加补偿代码。通过简单地禁止跨越程序块边界的后向移动,即可完全避免后向移动所需的所有补偿代码。虽然这种约束限制了调度器改进代码的能力,但它避免了延长其他路径,而仍然向调度器提供了一些改进代码的时机。
EBB调度的机制很简单。为调度一条EBB路径,调度器在区域上执行重命名(如有必要)。接下来,它对整条路径建立单一的依赖关系图,忽略任何过早退出的情况。它会计算选择就绪操作和打破平局所需的优先级度量。最后,调度器会应用表调度,类似于单个程序块的处理。每次调度器将一个操作指派到调度中一个具体周期的具体指令中时,它会插入任何必要的补偿代码。
跟踪调度扩展了路径调度的基本概念,使之超越了EBB中路径的范围。跟踪调度不再专注于EBB,而是试图构造穿越CFG的最大长度无环路径,并将表调度算法应用到这些路径(或踪迹)上。因为跟踪调度与EBB调度有同样的补偿代码问题,所以编译器选择路径时,应该确保先调度“热“路径(即执行最频繁的那些路径),而后再考虑较“冷”的路径。
踪迹:穿越CFG的一条无环的路径,该路径是利用剖析信息选择的。
为建立供调度的踪迹,编译器需要访问CFG中各条边的剖析信息,下图表给出了我们的例子中各条边的执行计数。为建立一条踪迹,调度器可以使用一种简单的贪婪方法。开始建立一条踪迹时,先选择CFG中执行最频繁的边。在我们的例子中,调度器将首先选择边 1,B2>,建立初始踪迹 1,B2>。接下来,调度器会考察进入踪迹第一个结点的边或离开踪迹最后一个结点的边,并选择执行计数最高的边。在例子中,调度器会选择 2,B4>(放弃 2,B5>),形成踪迹 1,B2,B4>。由于B4只有一个后继结点B6,调度器将选择 4,B6> 作为下一条边并产生踪迹 1,B2,B4,B6>。
当算法用尽可能的边(像本例中这样),或遇到循环控制分支指令,构造踪迹的过程将停止。后一个条件防止调度器构造最终导致将操作移出循环的踪迹,其中隐含的假定是,早期优化已经进行了循环不变量代码移动(如缓式代码移动),调度器遇到循环控制分支指令时不应该再考虑插入补偿代码。
给定一条踪迹,调度器可以将表调度算法应用到整个踪迹,正如同EBB调度将该算法应用到穿越EBB的路径那样。任给一个踪迹,可能有插入补偿代码的额外时机。该踪迹可能有中间的入口点,即踪迹中部具有多个前趋结点的程序块。
为调度整个过程,跟踪调度器需要构造一个踪迹并调度它。接下来,调度器将踪迹中的程序块从考虑范围内移除,并选择下一个执行最频繁的跟踪进行调度。在调度这个踪迹时,要求必须遵守此前调度的代码所规定的任何约束。这个处理过程会一直待续下去:选择一个踪迹,调度,将其从考虑范围内移除,直至所有程序块都已经调度完毕为止。EBB调度可以认为是跟踪调度的一种退化情形,这种情况下禁止了踪迹的中间入口点。
CFG中的汇合点限制了EBB调度或跟踪调度可用的时机,为改进调度结果,编译器可以通过复制程序块,创造出更长且没有汇合点的路径。超级块复制刚好有这种效果。
下图给出了在我们一直使用的例子中实施程序块复制所能产生的CFG。程序块B5已经被复制,为从B2和B3发出的路径分别创建了一个程序块实例;类似地,B6被复制了两次,为进入该程序块的每条路径分别创建了一个唯一的实例。这些复制的做法消除了CFG中所有的汇合点,整个CFG图形成了一个EBB。
如果编译器判断 1,B2,B4,B6> 是热路径,它将先调度 1,B2,B4,B6>。接下来,它可以调度 5, B‘6>,并使用已调度过的 1,B2> 作为前缀;也可以调度 3, B’5, B‘‘6>,并使并使用已调度过的 1> 作为前缀。
通过复制构建上下文信息的这种方式与EBB调度比较。后者根据B1调度B3,而调度B5和B6时无法利用此前的上下文,因为B5和B6具有多个前趋结点,从各个前趋结点进入这两个程序块时的上下文环境是不一致的,在这种情况下,EBB调度器不可能比局部调度做得更好。前者的代价是多出语句 j j j和 k k k的一个副本以及语句 l l l的两个副本,但可以简化CFG,比如消除B4 → \to →B6分支语句。
如果一个过程的最后一个操作是自我递归调用,那么该过程是尾递归的。当编译器检测到一个尾调用时,它可以将该调用转换为一个到过程入口点的跳转。从调度器的观点来看,复制可以改进这种情况。
下图给出了一个尾递归例程的抽象CFG图,图中已经优化过尾调用。可以沿两条路径进入程序块B1:从过程入口发出的路径和从B2发出的路径。这迫使调度器对B1的前趋结点使用最坏情况假定。通过复制B1,编译器可以使控制流只沿一条边进入B’1,这可以改进区域性调度的结果。为进一步简化该情况,编译器可以将B’1合并到B2的末端,从而建立一个只包含单个程序块的循环体,由此产生的循环可以视情况利用局部调度器或循环调度器进行调度。
专门化的循环调度技术可以产生能改进局部调度、EBB调度和跟踪调度结果的调度,这是因为一个简单的原因:它们可以考虑值围绕整个循环的流动,包含循环控制分支指令在内。循环调度器目的是减少停顿、互锁或nap,如果循环体在默认调度之后不包含停顿、互锁或nap,那么循环调度器也不可能改进其性能。如果循环体足够长,使得循环控制部分的效应只占运行时间的一小部分,那么专门化的循环调度器也不可能带来显著的改进。仅当默认调度器不能为循环生成紧凑而高效的代码时,专门化的循环调度技术才有意义。
循环核:软件流水线化循环的核心部分,核以交错方式执行了循环的大部分迭代。
要使流水线化的循环正确执行,代码必须首先执行一段填补流水线的起始代码。如果核执行来自原来循环3个迭代的操作,那么核的每次迭代会处理原来循环每次活动迭代的大致1/3。为开始执行,起始代码必须执行足够多的工作来准备迭代1的最后1/3、迭代2的第二次1/3和迭代3的第一个1/3。在循环核完成之后,需要执行对应的收尾代码来完成最后一次迭代,即清空流水线。在例子中,需要执行倒数第二次迭代的最后2/3和最后一次迭代的最后1/3。起始代码和收尾代码部分会增加代码长度。虽然具体增加的长度是循环本身以及核并发执行的迭代数目的函数,但起始代码和收尾代码使循环所需的代码数量加倍也并非罕见。
为把这些思想阐述得具体些,可以考虑以下用C语言编写的循环:
for(i = 1; i < 200; i++)
z[i] = x[i] * y[i];
下图12-7
给出了编译器可能为该循环生成的优化之后的代码。代码已经针对具有单个功能单元的机器调度过,其中假定load
和store
需要花费3个周期,mult
花费两个周期,其他指令均花费一个周期。第一列给出了周期计数,这些计数已针对循环中的第一个操作(标号L1处)进行了规格化。
循环前代码为每个数组初始化了一个指针( r @ x r_{@x} r@x、 r @ y r_{@y} r@y和 r @ z r_{@z} r@z)。它为 r @ x r_{@x} r@x的范围计算了一个上界,保存在 r u b r_{ub} rub中,循环结束处的条件判断就使用了 r u b r_{ub} rub。循环体加载x和y,执行乘法,将结果存储到z。在长延迟操作发射之后,调度器用其他操作填充了所有的发射槽。在load
的延迟期间,目前的调度会更新 r @ x r_{@x} r@x和 r @ y r_{@y} r@y。在乘法的延迟期间,调度会执行比较操作。它向store
之后的发射槽里填充了对 r @ z r_{@z} r@z的更新和分支指令。对于只有一个功能单元的机器来说,这产生了一个紧凑的调度。
如果我们在具有两个功能单元、延迟相同的超标量处理器上运行同一份代码,会发生什么。假定load
/store
必须在功能单元0上执行,而如果在操作数就绪之前发射操作会导致功能单元停顿,且处理器不能向停顿的单元发射操作。下图12-8
给出了循环的第一次迭代的执行轨迹。周期3的mult
会停顿,因为 r x r_{x} rx和 r y r_{y} ry均未就绪。它在周期4停顿以等待 r y r_{y} ry,在周期5再次开始执行,在周期6末尾生成 r z r_{z} rz。这迫使storeA0
一直停顿到周期7的开始处。假定硬件可以判断 r @ z r_{@z} r@z包含的地址与 r @ x r_{@x} r@x和 r @ y r_{@y} r@y不同,那么处理器可以在周期7发射第二次迭代中的第一个loadA0
操作。反之,处理器将一直停顿,直至store
操作完成。
使用两个功能单元可以改进执行时间。它将循环前的执行时间缩短一半,到2个周期。它将两次连续迭代之前的时间缩短了1/3,到6个周期。关键路径执行的速度基本上达到了我们的预期,乘法在 r y r_{y} ry就绪之前发射,会被处理器尽快执行。而一旦 r z r_{z} rz就绪,就会执行store
。一些发射槽被浪费了(周期6中的单元0,周期1和4中的单元1)。
重排线性代码可以改变执行调度。例如,将对 r @ x r_{@x} r@x的更新移动到 r @ y r_{@y} r@y的load
操作之前,使得处理器能够在同一周期发射对 r @ x r_{@x} r@x和 r @ y r_{@y} r@y的更新和以这些寄存器为偏移量的load
操作。这使得一部分操作能够在调度中较早发射,但并没有做什么来加速关键路径。最终结果是相同的,都是一个花费6个周期的循环。使代码流水线化可以减少每个迭代所需的时间,如图12-9
所示。在本例中,流水线化可以将每个迭代所需的周期数从6个降低到5个,5.2将阐述能够生成该调度的算法。
为产生软件流水线化的循环,调度器需要遵循一个简单的计划。首先,它需要估算核中耗费的周期数,称为启动间隔(initiation interval)。其次,它会试图调度核,如果处理过程失败,它会将核的大小加1并重试。(这个过程必定会停止,因为在核的长度超过非流水线化循环的长度之前,调度就能够成功)最后,调度器生成与调度过的核相匹配的起始代码和收尾代码。
又要烂尾了!这个算法内容不多,就上面说的三个流程。但是我读了好几遍都理解不透彻,等以后彻底理解了再补上。