计算机体系结构基础知识介绍之指令集并行的基本编译器技术(循环展开、基本管道调度)

文章探讨了如何通过循环展开和指令调度来提高RISC-V架构下的程序性能。循环展开能减少循环控制开销,增加指令级并行度,而管道调度则涉及指令重排序以消除流水线停顿。通过实例展示了如何减少停顿周期,优化代码以实现更高效的执行时间。文章还提到了展开的限制,包括开销、代码大小和寄存器使用。
摘要由CSDN通过智能技术生成

一、基本管道调度和循环展开

为了保持管道满载,必须通过查找可以在管道中重叠的不相关指令序列来利用指令之间的并行性。 为了避免流水线停顿,相关指令的执行必须与源指令分开一定的时钟周期距离,该距离等于该源指令的流水线延迟。 编译器执行此调度的能力取决于程序中可用的 ILP (指令集并行)量以及管道中功能单元的延迟。

通过注意到每次迭代的主体是独立的,我们可以看到这个循环是并行的。

第一步是将前面的片段翻译为 RISC-V 汇编语言。 在下面的代码段中,x1最初是数组中地址最高的元素的地址,f2包含标量值s。Registerx2是预先计算的,因此Regs[x2]+8是要操作的最后一个元素的地址。

简单的 RISC-V 代码(未安排在管道中)如下所示:

让我们首先看看当这个循环被安排在一个简单的 RISC-V 管道上时,它的运行情况如何,延迟如下图 所示。 

 

 简单解释一下上图:FP指浮点数,指令产生的结果为浮点运算且应用于另一个浮点运算,则延迟3个周期,指令产生的结果为浮点运算且存储则延迟2个,指令产生的结果为读取浮点数且应用于另一个浮点运算则延迟一个,读取浮点数并储存无延迟。

如果没有任何调度,循环将按如下方式执行,需要九个周期:

 我们可以安排循环只获得两个停顿,并将时间减少到七个周期:

fadd.d 之后的停顿供 fsd 使用,重新定位 addi 可以防止 fld 之后的停顿。

在前面的示例中,我们完成一次循环迭代并每七个时钟周期存储回一个数组元素,但对数组元素进行操作的实际工作只需要这七个时钟周期中的三个(加载、添加和存储)。 剩余的四个时钟周期由循环开销 — addi 和 bne 以及两个停顿组成。 为了消除这四个时钟周期,我们需要相对于开销指令的数量获得更多的操作。

相对于分支和开销指令增加指令数量的一个简单方案是循环展开。 展开只是多次复制循环体,调整循环终止代码。

循环展开也可用于改进调度。 因为它消除了分支,所以它允许将来自不同迭代的指令一起调度。 在这种情况下,我们可以通过在循环体内创建额外的独立指令来消除数据使用停顿。 如果我们在展开循环时简单地复制指令,那么使用相同的寄存器可能会阻止我们有效地调度循环。 因此,我们希望每次迭代使用不同的寄存器,从而增加所需的寄存器数量。

这是合并 addi 指令并删除展开期间重复的不必要的 bne 操作后的结果。 请注意,现在必须设置 x2,以便 Regs[x2]+32 成为最后四个元素的起始地址。

 我们消除了 x1 的三个分支和三个减量。 加载和存储上的地址已得到补偿,以允许合并 x1 上的 addi 指令。 这种优化可能看起来微不足道,但事实并非如此; 它需要符号替换和简化。 符号替换和简化会重新排列表达式,从而允许常量折叠,从而允许将诸如 ((i + 1) + 1) 的表达式重写为 (i + (1 + 1)),然后简化为 (i + 2).

如果没有调度,展开循环中的每个 FP 加载或操作都会跟随一个相关操作,因此会导致停顿。 这个展开的循环将在 26 个时钟周期内运行 - 每个 fld 有 1 个停顿,每个 fadd.d 有 2 个,加上 14 个指令发出周期 - 或四个元素中每个元素的 6.5 个时钟周期,但可以对其进行调度以显着提高性能。 循环展开通常在编译过程的早期完成,以便优化器可以暴露并消除冗余计算。

在实际程序中,我们通常不知道循环的上限。 假设它是 n,并且我们想要展开循环以制作主体的 k 个副本。 我们生成一对连续的循环,而不是单个展开的循环。 第一个执行 (n mod k) 次,并且其主体是原始循环。 第二个是展开的主体,由迭代 (n/k) 次的外循环包围。 对于较大的 n 值,大部分执行时间将花费在展开的循环体中。

在这里详细解释一下为什么这样做:

这是因为循环展开可以减少循环控制的开销,提高指令级并行(ILP)的程度,从而提高程序的性能。循环展开是一种编译器优化技术,它将循环体中的指令复制多份,使得每次迭代执行更多的工作,同时减少了循环次数和循环条件判断的次数。例如,如果一个循环体有4条指令,每次迭代执行一次,那么循环展开后可以将循环体复制两份,每次迭代执行8条指令,同时将循环次数减半。这样可以节省循环控制指令(如分支、跳转、递减等)所占用的时钟周期,也可以增加流水线中不相关指令的数量,从而提高流水线的效率。

当我们不知道循环的上界时,我们可以使用一种叫做条带划分(Strip Mining)的技术,将一个大的循环分解为两个小的循环。第一个循环执行原始的循环体,次数为总迭代次数对展开因子(即复制份数)取模的结果;第二个循环执行展开后的循环体,次数为总迭代次数除以展开因子的结果。这样可以保证循环展开后的程序和原始程序的功能相同。例如,如果一个循环要执行1001次,我们想要将它展开为4份,那么我们可以生成两个循环:第一个循环执行原始的循环体1次(1001 mod 4 = 1),第二个循环执行展开后的循环体250次(1001 -1/ 4 = 250)。对于大的迭代次数,大部分的执行时间会花在第二个循环中,因为它执行了更多的工作量,同时减少了更多的控制开销。

 展开循环的执行时间已降至总共 14 个时钟周期,即每个元素 3.5 个时钟周期,而在任何展开或调度之前每个元素为 8 个周期,展开但未调度时为 6.5 个周期。

 二、循环展开和调度总结

 大多数指令集并行技术的关键是了解何时以及如何更改指令之间的顺序。  在实践中,这个过程必须通过编译器或硬件以有条不紊的方式执行。 为了获得最终展开的代码,我们要求如下:

■ 通过发现循环迭代是独立的(循环维护代码除外),确定展开循环是有用的。
■ 使用不同的寄存器以避免由于使用相同的寄存器进行不同的计算而强制产生的不必要的约束(例如,名称依赖性)。
■ 消除额外的测试和分支指令并调整循环终止和迭代代码。
■ 通过观察来自不同迭代的加载和存储是独立的来确定展开循环中的加载和存储可以互换。 这种转换需要分析内存地址并发现它们并不引用相同的地址。
■ 安排代码,保留产生与原始代码相同的结果所需的任何依赖性。

所有这些转换的关键要求是理解一条指令如何依赖于另一条指令以及在给定依赖性的情况下如何更改或重新排序指令。

三种不同的原因限制了循环展开的收益:(1) 每次展开所摊销的开销量减少,(2) 代码大小限制, (3) 编译器限制。

我们首先考虑循环开销的问题。 当我们展开循环四次时,它在指令之间产生了足够的并行性,可以在没有停顿周期的情况下调度循环。 事实上,在 14 个时钟周期中,只有 2 个周期是循环开销:维护索引值的 addi 和终止循环的 bne。 如果循环展开八次,则开销将从每个元素的 1/2 周期减少到 1/4。

展开的第二个限制是代码大小的增加。 对于较大的循环,代码大小的增长可能是一个问题,特别是如果它导致指令缓存未命中率增加。

另一个比代码大小更重要的因素是激进的展开和调度所造成的寄存器的潜在短缺。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

D了一天bug忘了编译

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值