软件流水循环
1. C6000流水线(Pipeline)
一个指令的处理过程并不是一步完成,它被分为三个阶段:取指(Fetch)、译码(Decode)、执行(Excute)。将每一个阶段放入独立的流程车间处理,形成流水线式的处理过程,可以大大加快指令的处理速度。
如图1所示,流水编排后的3个指令只需5个cycle,相比顺序执行的9个cycle大大减少,当指令数增多,流水线的优势将更加明显。
图1 简单的流水线编排示意
实际上,C6000架构把每一个阶段进一步划分为多个子阶段,每个子阶段消耗1个CPU cycle。
取指(4个子阶段):
-
PG: Program address generate (update program counter register)
-
PS: Program address send (to memory)
-
PW: Program (memory) access ready wait
-
PR: Program fetch packet receive (fetch packet = eight 32-bit instructions)
译码(2个子阶段):
-
DP: Instruction dispatch (or assign, to the functional units)
-
DC: Instruction decode
执行(1-10个子阶段,指令间有区别):
-
E1 – E10, where E1 is the first sub stage in the execute stage
图2 高性能的C6000流水线
2. 流水线阻塞
下列两种情况出现时,流水线将被阻塞:
-
当前为load、complex multiply等具有多个延时时隙(delay slot)的指令时,下一指令需多个cycle,等其返回结果后才能往下继续执行
-
跳转指令出现时,CPU无法预知下一步执行哪个分支指令,因此跳转目标指令需等到跳转指令执行到E1阶段才能进入流水线
为了充分利用流水线的资源,避免delay slots造成的阻塞,C6000架构在软件和硬件上分别新增了一个处理机制:
-
软件上:提供软件流水(software pipelining)指令编排
-
硬件上:提供SPLOOP buffer(software pipelining loop buffer)
3. 软件流水
软件流水≠流水线!
软件流水技术指编译器通过重新调整指令的位置,使得原本将发生阻塞的流水线得以充分利用,其重点在“软件”两字。
例如处理下面循环:
for(i=0; i<15; i++)
{
sum += tab[i];
}
传统指令流程(Solution1)和软件流水编排后的指令流程(Solution2)如图3所示:
图3 传统编排 Vs 软件流水编排
从图中看到,经过软件重新编排过的指令将不再发生流水线阻塞,提升运行效率的同时也不影响代码功能的实现。
解释下有关软件流水的三个名词:
-
流水核(Kernel):流水线被充分利用的一段代码
-
流水填充(Prolog):流水核之前的一段填充过程代码
-
流水排空(Epilog):流水核之后的一段排空过程代码
4. SPLOOP Buffer
软件流水将会带来两个主要的缺点:汇编文件代码尺寸的增加并影响代码的中断属性。
SPLOOP Buffer的出现就是为了就解决以上问题。SPLOOP Buffer是C6000内部的一个存储区域,用来装载SPLOOP指令。当一个SPLOOP 第一次被执行时,循环的相关指令被拷贝到SPLOOP Buffer中,整个循环运行过程将从这里取指执行,直到循环结束。
C6000还为SPLOOP Buffer的使用提供了专门的寄存器和操作指令,如果编程者使用汇编/线性汇编编程,需要熟悉这些指令和寄存器,并了解SPLOOP Buffer特有的执行机制(参考文献[1]),如果使用C/C++编程则由编译器自动生成相应指令。
以存储块拷贝函数为例,示意使用SPLOOP Buffer前后的编码效果:
图4 memery copy 使用SPLOOP buffer前
图5 memery copy 使用SPLOOP buffer后
*注意:SPLOOP Buffer最多只能存放14个执行包(每个执行包可含8条指令,顺序/并行),因此如果循环体较复杂将不能使用SPLOOP Buffer。
5. 导致软件流水编排失败的因素
在CCS开发环境中,开启-O2/-O3优化选项,编译器将自动为合适的代码进行软件流水编排,因此编程者需要注意的是使设计的循环体符合软件流水编排的条件。
下列一些因素将可能引起软件流水编排失败:
-
汇编语句嵌入到C/C++代码中
-
出现复杂的流控制语句如goto、break等
-
循环中包含一个调用(内嵌函数除外)
-
需进行软件流水编排的指令太多
-
没有初始化循环计数器
-
循环变量在循环过程中被修改
-
软件流水被关闭:没有使用-O2或-O3选项;使用了-ms2或-ms3选项;使用-mu关闭了软件流水
理解编译器反馈信息
1. 编译器优化循环的几个步骤
循环的运算性能主要取决于编译器能否编排出恰当的软件流水,编译器优化一个循环的过程大致分为三个步骤:
获取循环次数信息。这些信息能帮助编译器判断是否要对循环做自动展开等操作。有时编译器无法从代码中获得完整的这些信息,编译器将会对循环采取保守的优化策略。因此,若要获得最佳的优化性能,编程者应尽可能地提供这些信息给编译器,可通过 MUST_ITERATE 、UNROLL 等 pragma语句。
几个关键参数如下:
-
最小可能循环次数(Minimum Trip Count)
-
最大可能循环次数(Maximum Trip Count)
-
循环倍数系数(Max Trip Count Factor)
收集循环资源和相关图信息。CPU完成一次循环迭代所需的cycle数称为迭代间隔(iteration interval,ii),编译器的优化目标就是最小化ii。
几个关键参数如下:
-
循环执行相关限(Loop Carried Dependency Bound),指循环体中最大的一条依赖路径的距离,而所谓依赖是指当前指令的开始依赖于前面指令的结束。
以下面一段代码为例:
void simple_sum(short *sum, short *in1, unsigned int N)
{
int i;
for (i = 0; i < N; i++)
{
sum[i] = in1[i] + 1;
}
}
其中最大的依赖路径如下图所示,图中显示,下一次数据的加载需等待上一次数据存储完成。
图6 循环依赖路径
很多时候,循环执行相关限是由于编译器对某些指针变量的信息缺乏了解造成,当指针的确切值无法得知时,编译器必须假定任何两个指针都可能指向相同的位置。因此,从一个指针进行加载暗含了其与另一个指针执行存储操作的相关,反之亦然。类似这种相关通常是不必要的,编程者可通过添加“restrict”关键词来消除这种相关。
-
未分配资源限(Unpartitioned Resource Bound):编译器将每条指令分配到A、B运算单元之前,使ii达到最小情况下的资源限。
-
已分配资源限(Partitioned Resource Bound):编译器将每条指令分配到A、B运算单元之后,使ii达到最小情况下的资源限。
寻找软件流水编排策略。编译器首先将ii设为Loop Carried Dependency Bound和Partitioned Resource Bound两个指标中的大值,然后以它为目标寻找编排策略。如果失败,则将ii+1,继续寻找……期间编译器将会反馈出编排失败的原因和一些相关信息。
2. 编译反馈输出相关选项
编程者可以选择查看上述编译器反馈的一些编译信息,来了解代码的优化程度,进而相应调整代码结构。如果要获得反馈输出,需打开-k和-mw选项。
-
-k:保留编译输出的汇编文件
-
-mw:生成详细的软件流水报告
一个反馈信息的例子如下所示:
图7 编译器反馈信息示例
3. 依据反馈信息制定优化策略
Loop Carried Dependency Bound is Much Larger Than Unpartitioned Resource Bound
现象描述:循环执行相关限远大于未分配资源限。
分析:相关路径过长,可能存在潜在的存储器别名混淆问题。
解决方案:
-
运用-pm程序及优化减少存储器指针别名
-
对传入函数且没有存储重叠的指针参数加“restrict”
-
运用-mt选项假定没有存储器别名问题
-
使用.mdep和.no_mdep汇编优化指令
-
如果循环控制复杂,尝试用-mh选项
Uneven Resources
现象描述:对某一运算单元的使用次数为奇数。
分析:如一次循环过程调用了3次乘法,那么分配到A、B两侧后,执行这个循环至少需要2个迭代间隔。如果能将该循环展开一倍,则循环一次需6次乘法,分配到A、B两侧后得到一个3周期的ii,从而改善循环性能。
解决方案:展开循环从而得到偶数个资源。
Larger Outer Loop Overhead in Nested Loop
现象描述:存在循环嵌套时,外循环所用时间占整个循环时间的比重很大。
分析:内循环计数值较小,而编译器将只重点优化内循环。
解决方案:展开内层循环,使嵌套结构变为一个大循环。
T Address Paths Are Resource Bound
现象描述:T地址通道数定义为每次循环迭代从地址总线发出的存储器访问次数,该资源的访问对循环进行有限制。
分析:应减少对T地址通道的访问次数。
解决方案:用宽字节存取指令访问存储器。
冗余循环
1. 什么是冗余循环?
为了填充流水线,软件流水循环结构需要循环迭代至少执行某个次数(循环次数要达到最小循环计数值)。当编译器无法确定循环计数时,默认会产生两个循环:一是不经流水编排的代码,二是循环流水编排的。运行时,若循环计数大于等于最小循环计数值,执行第2个循环,否则执行第1个。因此,总有一个循环不被执行,这个循环就是冗余循环。
冗余循环的出现将引起代码尺寸的增加。
2. 如何避免生成冗余循环
-
设置-ms选项的任一级别都将关闭冗余循环的生成
-
使用MUST_ITERATE pragma伪指令告诉编译器循环次数的具体信息
中断对循环的影响
1. 单分配和多分配
C6000中的寄存器分配被分为单分配和多分配。单分配代码是可中断的,多分配代码是不可中断的。
多分配是指某一特殊寄存器被分配多于一个值。如下为一段多分配的代码。
cycle
1 SUB .S1 A4,A5,A1 ; writes to A1 in single cycle
2 LDW .D1 *A0,A1 ; writes to A1 after 4 delay slots
3 NOP
4 ADD .L1 A1,A2,A3 ; uses old A1 (result of SUB)
5−6 NOP 2
7 MPY .M1 A1,A4,A5 ; uses new A1 (result of LDW)
在第4周期,ADD指令开始时,寄存器被分配两个不同值。一个值是SUB指令在第1周期所写,已经在寄存器中存在。第二个值叫做in-flight值,在第2周期由LDW指令分配。因为LDW指令在第6周期之前没有实际值写入寄存器A1,所以考虑分配为in-flight。
in-flight操作因其不可预见性所以其代码不可中断。
2. 循环不可被中断的情形
所有小于6个cycle的循环都是不可中断的。
即使代码使用单分配,但循环也可能不能使用中断。因为硬件中断保护所有分值操作的延迟间隙,所以只要CPU有悬挂分支,所有中断就保持悬挂。而又因为分支跳转指令有5个延迟时隙,小于6个cycle的循环总有悬挂分支。
3. 如何设置循环体的中断属性?
循环的中断属性可设置为三个级别:
级别0,不可中断
该级别下编译器不能使中断无效,因此需确保中断不会发生,其优势是允许编译器用多分配代码以及产生最小周期间隔的软件流水循环。
-
-mi选项将某一模块设置为该属性
-
可用pragma伪指令对某个特殊函数func进行如下设置:
#pragma FUNC_INTERRUPT_THRESHOLD(func, uint_max);
级别1,任何时刻可中断
该级别编译器处处用单分配,绝不产生小于6个cycle的循环。
-
-mi1选项将某一模块设置为该属性
-
可用pragma伪指令对某个特殊函数func进行如下设置:
#pragma FUNC_INTERRUPT_THRESHOLD(func, 1);
级别2,在阈值周期内不可中断
用户设置一个周期阈值threshold,若循环的cycle数未超过阈值,编译器将在循环周围关中断,并允许寄存器多分配。反之,则产生单分配的代码,循环可中断。如果编译器无法判断循环周期,则假定达到阈值,产生一个可中断循环。
-
-mi(阈值)选项将某一模块设置为该属性
-
可用pragma伪指令对某个特殊函数func进行如下设置:
#pragma FUNC_INTERRUPT_THRESHOLD(func, threshold);
参考文献
【1】Introduction to TMS320C6000 DSP Optimization--SPRABF2,2011.
【2】TMS320C6000 Programmer's Guide--SPRU198K,2011.
【3】田黎育,何佩琨,朱梦宇. TMS320C6000系列DSP编程工具与指南[M].北京:清华大学出版社 2006.
·END·
欢迎来我的微信公众号做客:信号君
专注于信号处理知识、高性能计算、现代处理器&计算机体系
技术成长 | 读书笔记 | 认知升级
幸会~