依赖分析能够找出程序代码中那些部分必须串行执行,那些部分可以并行执行,这可以根据粒度分为两个层次:
1. 指令级依赖:分析相邻几条指令间的依赖以决定这几条指令是否能够利用流水线执行。
2. 循环级依赖:分析循环迭代过程中是否存在指令级依赖,这是循环并行化的基础工作。
指令级依赖分析是优化流水线性能的基础,而循环级依赖分析则是向量化和数据并行的基础。
如果能够确定循环中不存在依赖,那么该循环便可由多个控制流同时执行。
细粒度的循环依赖分析可以确定代码是否能够被向量化。
理想地说,依赖分析应当是编译器的工作,实际上目前指令级依赖分析主要由编译器承担,但是一些依赖是编译器无法发现的,这就必须由人脑来解决。
通常两条代码之间无依赖是指两条代码的任意输入和输出之间没有依赖。
5.1 指令级依赖
指令级依赖主要有以下几个方面:
1. 资源依赖:资源依赖是指由于处理器本身的某些限制,导致本来没有依赖的指令有了依赖。处理器具有多级流水线,流水线的每一级负责不同的工作,如果多条无依赖的指令都需要流水线的同一级,那么这些指令之间存在资源化依赖。
2. 数据依赖:是指下一条对数据操作的指令必须等待上一条操作该数据的指令完成。常见的数据依赖有读后写、写后读、写后写等。由于不依赖于具体的硬件实现,数据依赖比资源依赖更易发现。
3. 控制依赖:由于分支、跳转导致的依赖,由于某些语句只有在控制条件成立才能够被执行,因此这些语句依赖控制条件的成立与否。
5.1.1 资源依赖
如果一条指令执行所需要的资源都满足的话(功能单元空闲、操作数已到达、指令发射队列未满),不考虑退休缓冲区满(没实际意义)。这条指令就会被发射到处理器上执行。这些资源包括:
1. 寄存器:用来保存运算的结果。如果有寄存器依赖的话,那么这条指令就不会被执行;(数据依赖和寄存器溢出?)
2. 存储缓冲区:用来保存等待写入缓存/内存的运算结果。使用存储缓冲区的原因在于,如果不使用存储器缓冲区的话,那么只有结果写入缓存后才能执行下一条指令,写缓存的延迟通常比较大,会导致流水线停顿。如果存储缓冲区被用完,那么这条指令必须等待直到有存储缓冲区可用,即指令无法发射。
3. 读取缓冲区:用来保存读取的缓存/内存数据。由于读取内存的延迟比较大,读取缓冲区的存在使得流水线不会停顿。如果读取缓冲区不可用,那么指令将不会被发射;
4. 分支缓冲区:用来保存分支预测的结果和等待提交(commit)的指令;
5. 流水线: 如果指令需要执行的流水线正在被其他指令占用,那么指令就不会被发射;
假设某个处理器上多条L1访问映射到同一条缓存线,那么即使这些访问没有依赖,也需要串行处理,这就是缓存的结构导致的依赖。
即提高硬件利用效率又保证提供必需的带宽,许多处理器的缓存层次(从寄存器到内存)都采用了存储体(bank)的方式组织,其中每个存储体可同时、独立地提供带宽。
一些处理器的指令发射单元一个时钟周期只能发射一条指令,那么对于某个具体的指令,它很难获得接近1的IPC。
如果处理器不能提供足够的寄存器,那么就需要从下一级缓存中读取,此时寄存器的数量和一级缓存的带宽就有可能成为结构化的依赖。
5.1.2 数据依赖
从数据的观点看,对同一个数据进行操作的两条指令之间的关系有4种情况;
1. 读后读:如果后一条指令的输入是前一次指令的输入,这称为读后读。读后读的两条指令可以并行操作。(不构成依赖)。
2. 读后写:如果后一条指令的输出是前一次指令的输入,称为读后写,也称为反依赖。读后写的两条指令不能直接并行操作,一旦交换它们的顺序,将会产生不同的结果。如下所示:
s1: area = PI * r * r;
s2: r = 0.2;
在上面的代码中,S2改变了r的值,如果更改S1和S2的顺序将会产生不正确的结果。解决读后写依赖的方法非常简单:如上例中只需要将S2中的r变量重新命名为p即可。现在的编译器能够自动进行这种优化,而且一些处理器硬件上提供了寄存器重命名机制来帮助处理器在运行时处理这种依赖关系。
3. 写后读:如果后一条指令的输入是前一次指令的输出,称为“写后读”,也称为“流依赖”。写后读的两条指令不能并行。如下所示:
s1: PI = 3.14;
s2: r = 5.0;
s3: AREA = PI * r * r;
很明显,只有s1和s2的写完成,s3才能计算,这说明s3依赖于s1和s2,即只有s1和s2完成,s3才能开始执行。
写后写:如果后一条指令的输出是前一次指令的输出,称为“写后写”,也称为“输出依赖”。写后写的两条指令不能直接并行执行,因为结果不确定。但是如果使用一些像原子指令、临界区、锁等互斥机制,也有可能并行(因为已经串行化了)。
s1: PI = 3.14;
s2: r = 5.0;
s3: AREA = PI* r * r;
s4: r = 2.0;
s5: AREA = PI * r * r;
s3和s5都写了AREA变量,如果调换两者顺序,最终的AREA值将会不正确。
解决写后写依赖的方法非常简单:如上例只需要将s5中的AREA变量重新命名为ar,同时将s4的r变量重新命名为p即可。现代编译器提供了寄存器重命名机制来帮助处理器在运行时处理这些依赖关系。
5.1.3 控制依赖
由于某些语句只有在控制条件成立才能够被执行,因此这些语句依赖于控制条件的成立与否。示例代码如下所示:
s1: if(y > 3){
s2: x += 5;
s3: }
很明显,s2依赖于s1,因为只有s1完成后,s2才能执行。实际上为了降低控制依赖导致的损失,许多现代处理器并不要求s2等待s1执行完,而是采用分支预测。分支预测失败的代价很高,因此分析并去除控制依赖仍然很有意义。
5.2 循环级依赖
(1) 常见循环依赖
1. 循环内部依赖,这和指令级依赖一致;
2. 循环间依赖,指下一次循环的结果依赖上一次循环的计算结果。循环间依赖会阻碍编译器做循环变换优化,影响循环的流水线执行效率。
(2) 常见的去除依赖方法
通过去除代码中的依赖,可以增强代码的流水线执行、指令级并行或线程级并行能力。循环级依赖的粒度大于指令级依赖,因此去除循环级依赖通常更重要。
常见的去除依赖的技术如下:
1. 使用寄存器来保存有依赖的存储器访问,将存储器依赖转化为寄存器或读写依赖,降低依赖对性能的影响(大缓存让这方法意义不大);
2. 使用临时存储器来保存有依赖的读写,在有必要的时再合并;
3. 重新组织代码顺序,尽量使得对每个元素的处理在某一次循环内完成;
5.2.1 循环数据依赖
如果下一次循环所读写的数据是前面几次循环的写入数据,那么就存在依赖。代码如下所示:
for(int i = 0; i < n; i++)
{
s1: a[i + 1] = b[i] + c;
s2: d[i] = a[i] + e;
}
假设i=5,此时s1写入a[6],而s2要a[5],可见s2和前一次循环的s1相互依赖,即s1中写a中元素和s2中读a的元素存在写后读依赖,可以使用一个寄存器变量来保存s1计算的a[i+1],故代码可转化为:
abefore = a[0],aafter;
for(int i = 0; i < n; i++)
{
s1: aafter = b[i] + c;
s2: a[i + 1] = aafter;
s3: d[i] = abefore + e;
s4: abefore = afater;
}
此转化把循环内4次访存转化为3次,但是并没有去掉写后读依赖,更进一步,如果将s2前提一次循环,那么代码可转化为如下形式;
d[0] = a[0] + e;
for(int i = 1; i < n; i++)
{
s1: x = b[i - 1] + c;
s2: a[i] = x;
s3: d[i] = x + e;
}
a[n] = b[n - 1] + c;
优化后,从原来代码中两次内存读、内存写减为现在的两次写,一次读,且去掉循环间依赖,即s2和s3可以并行。去除循环间数据依赖后,在支持不对齐的向量加载指令的处理器上,s1、s2、s3还可以向量化。
下面是一个更复杂的case;
for (int i = 0; i < n; i++)
{
s1: a[i] = b[i] + 1;
s2: c[i] = a[i] + 2;
s3: d[i] = c[i + 1] + 1;
}
假设i=6,此时s2写入c[6],s3读c[7],而i=7时,s2写入c[7],故存在读后写的依赖。
对于上面的示例代码,可以使用一个临时变量保存a[7]值,这可将代码转换为:
for (int i = 0; i < n; i++)
{
s1: x = b[i] + 1;
s2: a[i] = x;
s3: c[i] = x + 2;
s4: d[i] = c[i + 1] + 1;
}
从上面代码可以看出,s4操作的c是原始的c数组中的值,和s3中对c的更新无关,但是虽说无关,却降低了流水线执行能力,并且阻止了向量化和并行化的可能。我们也将原始的c复制到另一个数组cb,以增加流水线效率,如下所示:
memcpy(cb, c + 1, n * sizeof(c[0]));
for (int i = 0; i < n; i++)
{
s1: x = b[i] + 1;
s2: a[i] = x;
s3: c[i] = x + 2;
s4: d[i] = cb[i] + 1;
}
虽然这种做法提高了代码的乱序执行能力和使得循环能够被向量化,但是却增加了一次内存分配、一次释放和一次拷贝的开销。实际应用中需要权衡。
一个更好的方法是循环拆分,拆分为如下两个循环:
for (int i = 0; i < n; i++)
{
s4: d[i] = c[i + 1] + 1;
}
for (int i = 0; i < n; i++)
{
s1: x = b[i] + 1;
s2: a[i] = x;
s3: c[i] = x + 2;
}
上面的代码已经可以接着执行向量化、循环展开等优化,但是对c[i]的读写还可以进一步提升流水线,如下所示:
x = b[0] + 1;
a[0] = x;
c[0] = x + 2;
for (int i = 1; i < n; i++)
{
s4: d[i - 1] = c[i] + 1;
s1: x = b[i] + 1;
s2: a[i] = x;
s3: c[i] = x + 2;
}
d[n - 1] = c[n] + 1;
实际上面的代码还可以接着进行一些可能的优化。
5.2.2 循环控制依赖
如果控制条件依赖于前几次或后几次循环写入的值,那么就存在循环间控制依赖,实例代码如下所示;
for (int i = 1; i < n; i++)
{
s1: a[i] = b[i] + c[i];
s2: if (a[i - 1]){
s3: c[i] = a[i] + 2;
}
}
s2所依赖的a[i - 1]由前一次循环的s1写入,两者之间存在依赖。
一些常见的分支优化办法同时也可以用于去除循环间控制依赖,如上所示的代码,如果使用一个变量来保存前一次写入的a[i]的值,就去掉了一次存储器读取。如下所示;
prev = a[0];
for (int i = 1; i < n; i++){
s1: next = b[i] + c[i];
s2: if (prev){
s3: c[i] = next + 2;
}
s4: a[i] = next;
prev = next;
}
在使用一个临时变量保存c[i]的值,代码转换为:
prev = a[0];
for (int i = 1; i < n; i++){
tempc = c[i]
s1: next = b[i] + tempc;
s2: if (prev){
s3: tempc = next + 2;
}
s4: c[i] = tempc;
s5: a[i] = next;
prev = next;
}
在X86处理器上,此时可以使用条件转移指令来去除控制依赖,如下所示;
prev = a[0];
for (int i = 1; i < n; i++){
tempc = c[i]
s1: next = b[i] + tempc;
s2: c[i] = prev ? next + 2 : tempc;
s3: a[i] = next;
s4: prev = next;
}
进行此转换后,还可以进行循环展开、向量化。
5.3 寄存器重命名
许多依赖关系都可以通过变量重命名来解决或缓解。现代编译器也使用此技术,只是受限于处理器指令集结构规定的逻辑寄存器数量限制,汇编程序能够使用的寄存器是非常有限的,因此编译器很多时候无能为力。
在许多高性能的处理器实现中,物理寄存器的数量远大于逻辑寄存器的数量。如Intel IA64指令集只有16个逻辑寄存器,而物理寄存器的近200个。如果处理器在执行时能够把一个逻辑寄存器映射到多个物理寄存器就可以实现变量重命名。通常称处理器硬件支持变量重命名的机制为“寄存器重命名”。
寄存器重命名能够更好地发挥处理器流水线的性能。不过代码优化人员并不能很好地利用它们,因为:
1) 处理器厂商没有提供其寄存器重命名算法的详细细节。寄存器重命名算法比较复杂,而且这些算法也是处理器设计中的重要技术,处理器厂商并不愿意公开太多细节。
2) 行为由处理器运行时决定的。由于不能直接在汇编指令中看到物理寄存器,因此代码优化人员无法确定他的优化是否能够帮助处理器更好地进行寄存器重命名工作。
5.4 本章小结
依赖分析应当是编译器的工作,实际上指令级依赖分析的工作主要由编译器承担,但是一些依赖是编译器无法发现的,这就必须要由人脑来解决。
本章简单介绍了指令级依赖和循环级依赖,并给出许多如何去除依赖的示例,最后笔者以简单介绍处理器硬件支持的寄存器重命名结束。
指令级依赖主要分为资源依赖,数据依赖和控制依赖。循环级依赖主要分为循环数据依赖和循环控制依赖。