《See MIPS Run》–附录A 指令的时序和优化

Sina WeiboBaiduLinkedInQQGoogle+RedditEvernote分享




附录A 指令的时序和优化
MIPS CPU高度流水化,所以它们执行代码的速度依赖于流水线的工作情况。有某些情况下,代码的正确性依赖于流水线的工作方式—特别是使用CPU控制协处理器0的指令和寄存器。
通过显式使用寄存器而传递的依赖性相当明显,只不过比较凌乱。除此以外,在隐式使用的寄存器中也有一些偶然的依赖关系。例如状态寄存器中的CPU控制标志会影响到所有指令的指令的执行,改变这些标志必须非常小心。
大部分MIPS指令需要在流水线RD阶段的结束时得到它们的操作数,并且要在随后的ALU阶段的结束时产生这些指令的运行结果,这如图A.1所示的那样。如果所有的指令总能够遵守这些规则,任何指令序列都能够以最大速度正确的运行。在MIPS架构中最大的奥妙就是绝大多数指令都能遵守这些规则。
在由于某些原因而不能做到的情况下,使用从前面的紧邻指令处得到操作数的指令不能及时正确的运行。这种情况能被硬件检测到,然后通过延迟第二条指令直到数据准备好以使之得到修正,或者它可以留给程序员来避免产生试图使用未准备好的数据(pipeline hazard流水线冒险)的指令序列。

A.1 避免冒险:使代码正确

可能的冒险包括下面这些情况:
1.load延迟:在早期的MIPS CPU中的这是一种流水线冒险;紧跟着load指令后面的指令不能引用由load产生的数据。有时候当没有有用的东西能被安全的移到被延迟的指令槽时,需要编译器/汇编器使用一条nop指令。但从R4000开始,MIPS CPU已经是互锁的了,这样冒险就不会影响到通常的用户级指令。
2.乘法单元冒险:从 MIPS CPU的整数乘法器得到的运算结果是互锁的,所以取得这个运算结果的mflo指令没有延迟指令槽。但是整数乘法硬件的独立性产生了自己的问题,参看A.3节。

3.协处理器0冒险:协处理器0控制指令通常用不同于平常的时序来读/写寄存器,这样就产生了流水线问题。其中多数没有互锁。详细信息必须从你的CPU用户手册中查,但是我们将看一下你在R4000 CPU(可能是MIPS CPU中最难处理的)上必须要做的事情。

注意分枝延迟指令槽,尽管它是为了降低流水化而被引入的,但它作为MIPS架构的一部分,因此不再是冒险了;它只是一个特例而已。

A.2 避免互锁来提高性能

只要CPU发生互锁,我们将会损失性能。但是如果用一些巧妙的法子,CPU本来是可以做些有用的事情的。我们想让编译器(or for heavily used function perhaps a dedicated human programmer)重新组织代码来得到最佳运行效果。

编译器—以及人—都发现这是一个挑战。一个高度优化已避免互锁的程序经常将运算的几个阶段分解,然后交错的执行,这样就很难看出将会发生什么。如果代码只是从原来的位置被前后移动了四,五条指令,通常还好处理。更大的移动就会出现越来越大的问题。
在单流水线机器(目前的绝大部分MIPS CPU)中,绝大部分指令使用一个时钟周期,所以对那些用四到五个时钟周期才能完成的指令以及那些成功的和其他指令交迭的指令,我们都有希望重新组织它们。在MIPS CPU中,这些标准只有对那些浮点指令才能很好的符合,所以高级调度机制会提高浮点指令的性能但对整数指令却作用无几(1)。深入讨论这个问题超出了本书的范围;如果你想很好的回顾一下所使用的编译器技术,请看一下Hennessy and Patterson, 《计算机体系结构:一种量化的方法》。如果想知道各种CPU的详细时序,请查一下相应的用户手册。
在第十二章第388页有一个小规模的关于load互锁的代码优化的例子。

A.3 乘法单元冒险:早期修改lo and hi。

当一个MIPS CPU发生了中断或异常时,大部分流水线中的指令被中止,并且禁止将运算的结果写回。但是整数乘法单元很少与CPU的其余部分有关联,因此继续运行, 这并不会对异常产生影响。这意味着一旦乘法和除法指令开始以后,不能防止改变乘法单元的运算结果寄存器的lo and hi。
异常可能及时发生以防止mfhi或者mflo完成写回操作,但可能允许后续的乘法或者除法指令开始运行—并且一旦第二个运算开始以后原来的数据将会丢失。
为了避免这个问题,确保至少用两个时钟周期从后面的乘法或除法指令分离mfhi或者mflo指令,那么在所有的MIPS CPU上都是足够的了。好的编译器和汇编器将会为你处理这些的。而且只有你反汇编这些代码,你才会知道它的存在,这样你会发现一些没有料想到的nop指令。

—————————————————————————————————-
1. 这是为何SGI编译器在高度浮点化程序上快得多的一个原因—可能快30%,但在整数代码上比GNU C差一点点。

A.4 避免协处理器0冒险:有多少nop呢?
程序员的问题是,我需要在一对特定指令之间放多少条指令(很可能是nops)才能让它们安全的工作呢?
原则上是可以得到指令对和在它们之间需要多少时钟周期的一份完整清单。但是那太费时间了。但我们能够降低这个工作的规模,我们注意到只有在以下情况时问题只会出现:

1.使用比标准时间(标准时间就是ALU阶段的末端)长的时间来产生数据的指令和/或
2.指令需要使用在标准时间(在这种情况下,标准时间是ALU流水阶段的开始)前准备好的数据
我们不需要列出在标准时刻产生和使用数据的指令,只要列出那些偏离正常途径的指令就可以了。对于其中的每一条指令,当运算结果产生了以及/或者当需要操作数时(1)我们都需要当心。有了这些,我们将能够在最复杂的情况下得到正确的或者高效的指令序列。
表A.1展示了R4000/4400 CPU的时序,这张图原来出现在Heinrich,《R4000/R4400用户手册》(在参考书目可以找到Web地址)中。这个表列出了操作数被使用和运算结果对后续的指令变为可用的流水阶段。

1. 列出运算结果迟了多少时钟周期的或者操作数早了多少时钟周期将是足够的—也是最简单的。但是MIPS系列的图表采用了流水线阶段。
表 A.1 有可能冒险的协处理器0指令和R4000/R4400 CPU的事件时序
在一对有依赖关系的指令之间需要的时钟周期数(通常就是nop指令的数目)是:
ResultPipestage – OperandPipestage – 1

为什么要减1呢?在第n+1流水时期产生的运算结果和一个在第n流水时期必需的操作数产生了理想的流水线,所以不需要nop。实际上减1是流水线运行阶段的人工模拟。
对于其他大部分MIPS CPU你会在相应的用户手册中发现一张相似的表。这里我们用R4000/4400作为例子是因为它长长的流水线(你会在表中看到总共有8级流水);以及作为MIPS III CPU家族的最早产品的地位就意味着,在R4000上很好运行的任何代码序列在任何后续的CPU上都会是安全的(尽管可能不是最优的)。
注意尽管mfc0指令是在后期把数据传送到它的目标通用寄存器,这个滞后的的运算结果在表中并没有标注出来;这是因为它是互锁的。这张表仅仅列出了可能引起冒险的时序。

A.5 协处理器0指令/指令调度

我们在第6章看到了下面的在64位CPU(32位地址空间)上处理TLB扑空的一段代码:

.set normorder
.set noat

TLBmissR4K:
dmfc0 k1, C0_CONTEXT
nop #1
lw k0, 0(k1)
lw k1, 8(k1)
mtc0 k0, C0_ENTRYLO0
mtc0 k1, C0_ENTRYLO1
nop #(2)
tlbwr #
nop #(3)
eret #(4)
nop #(5)

.set at
.set reorder

现在我们能够说明这里边的nops指令的数目了。

(1) R4000 CPU和它的大多数后代不能向下一条指令传递协处理器0寄存器值;dmfc0指令时序和load指令很象Heinrich的R4000/R4400用户手册暗示这个操作在R4000上可能会被完全互锁,并且可以肯定的是任何超过一个时钟周期的延迟都会互锁。但是它也没有变得简单一些,并且这里的nop对性能也不会产生任何不利的影响,所以我们把它保留在里边。

(2) 从表A.1,mtc0在流水线的第7步写EntryLo1寄存器,而tlbwr指令需要数据在流水线的第5步准备好。所以只需要一个nop(是这样计算的,7 – 5 – 1)。对于一些其它的CPU可能并不需要这个nop,但是为了可移植性的原因,还是值得保留下它的。

(3) tlbwr没有明显的相关性,但事实上非常重要的一点是在我们返回到用户态代码前,它所有的边际作用都会被完成。tlbwr只有到流水线的第8步才能完成写TLB,而正常指令的预取需要TLB在流水线的第二步准备好;我们必须在tlbwr和异常返回之间保留5个指令槽。eret后面跟着它的分枝延迟指令槽—在这种情况下在表中(5)处有一个nop—而且(由于R4000的长长的流水线的缘故)流水线会在分枝指令后面回填充一个”两时钟周期”的延迟。尽管如此,还是只有四条指令;所以我们需要在表中(3)处eret之前添加一个nop。

(4) 另外一个依赖性存在于eret之间,eret会将状态寄存器SR(EXL)域复位到它正常的用户态,并且是用户程序的第一个指令预取状态。但是,这个时序超出程序员的能力之外,所以机器已经内置了,分枝延迟时间槽加上”两时钟周期”如此之长的分枝延迟足够了。

有了这张表,你应该能够做任何事!

A.6 协处理器0标志和指令

正如前面我们看到的,一些CPU控制寄存器(协处理器0)包含了位字段值或者标志,这些位字段值或者标志有其他指令运行而产生的副作用。一个常用的经验方法是假设在执行了一条mtc0指令后,三个指令周期内任何这样的副作用将是不可预知的,
但是下面的情况需要特别注意:

1.启用/禁用一组协处理指令:如果你通过改变SR(CU)中的一位而启用了一个协处理器(使它特定的指令可用), mtc0指令在流水线第7步生效,因此新的运算值必须在协处理器指令的流水线第2步稳定下来。所以在这种情况下需要发射四个中间指令。

2.启用/禁用中断:如果你写SR(IE), SR(IM), 或者SR(EXL)而改变了CPU的中断状态,表A.1告诉我们在流水线的第7步开始生效。中断信号在指令流水线的第3步被采样,以决定是继续处理指令还是被中断所抢占。这意味着在新的中断状态被安全的安装之前,三条指令(是这样算出的:7 – 3 – 1)必须被执行。

在三条指令运行期间,中断能够被检测出来,并且能够引发异常。但是在被中断的指令前发射的指令改变了状态寄存器,所以规则告诉我们状态寄存器的改变还将会发生。

设想一下你已经通过设置异常等级位SR(EXL)禁用了中断。你通常只会在一个地方这样做,那就是在异常处理例程结束的地方。任何复杂情况的异常处理例程都保存异常开始处的SR值,当控制准备返回到用户态程序时再恢复这些值,这样异常开始处的SR(EXL) 的部分值就被设置了。
跟在设置SR(EXL)的指令后面有三个指令槽, 如果中断发生这三个中的一个时,那么中断异常发生了可SR(EXL)已经被设置过。那将发生非常古怪的事情,包括异常返回地址EPC没有被保存等(1)。这种情况是不可恢复的,所以当你设置SR(EXL)时确保中断已经被禁用是至关重要的;如果能确保你至少在三条指令时间之前清除SR(IE)和/或SR(IM),这样你就能做到这一点。

3.TLB改变和指令预取:在改变TLB和指令地址转换生效之间有五条指令的延迟。除此之外,有一个单条目缓冲器(single entry cache)被用于指令地址转换(称为微TLB),这种指令地址转换通过加载EntryHi而被隐式的覆盖掉;这也可能延迟生效的时间。

你只有在一个未映射的地址空间运行代码时必须显式的更新TLB。kseg0是通常的选择。 

(没有打分)

雁过留声

Comments are closed.