第4章 循环变换

4.1 适配体系结构特征的关键技术

由于高级语言隐藏了底层硬件体系结构的大量细节,如果不经过优化直接将高级程序设计语言编写的程序部署在底层硬件上,往往无法充分利用底层硬件体系结构的处理能力。                               

算子融合不仅可以提高计算密度,还可以避免相邻算子之间通过GPU设备内存通信引入的数据访问开销。

循环变换和不同循环变换之间的组合是实现面向底层硬件体系结构的重要优化手段。本章将基于多面体模型详细介绍优化编译器实现循环变换及不同循环变换之间组合的方式,并介绍各种循环变换的应用场景,以及一些循环变换之间的关系。

4.2 预处理

只要维持程序的循环携带依赖,那么循环变换总是有效的。在开始分析依赖关系之前,优化编译器应该提供一些能够对循环嵌套实现预处理的机制。一方面,预处理能够简化依赖测试或精确依赖分析的过程;另一方面,预处理也能够消除一些复杂的循环携带依赖,为实现更多的循环变换创造环境。

4.2.1 循环正规化

循环正规化使得循环嵌套中每个循环下界从0开始运行到某个确定的上界,并且循环的步长为1.同时,使用新的循环索引变量替换的仿射函数表达式区替换原循环变量在循环内的所有引用。即循环的下界为0,上界为(U-L)/r,步长为1,。原循环索引变量为i,之后为r*i + L。注意r应该是一个具体的常数值而不是符号常亮,否则难以通过静态分析来判定依赖是否存在。尽管可能通过引入谓词条件的方式将非仿射表达式转换为仿射表达式的形式,但这种向上近似的方法往往会导致冗余的循环跌打,程序的语义则通过引入的谓词条件来保证。

4.2.2 死代码删除

在实际应用中,可能会存在一些不会被其他有用的语句引用结果的语句,即程序的死代码。有用的语句是指用于输出程序结果的代码,或者那些再被优化的程序中仍然需要执行的代码。

死代码删除可以在计算出程序的数据流信息之后,利用向下暴露集与程序迭代空间以及依赖关系之间的基本操作来完成,其基本思想是先根据近似数据流分析方法计算出被优化的程序片段的向下暴露集,并依此计算出所有向下暴露集中语句实例的流依赖源点的集合,将二者集合求并,得到一个新的集合,向这些变量写入数据的语句实例都不会是死代码,重复该过程,直到求并之后的集合不再发生变化。由于死代码可以与其他语句产生数据流关系,因此将所有死代码语句导致的依赖从数据流关系和各种依赖关系中删除,减少编译器对程序实施循环变换的约束。

4.2.3 别名分析

别名分析阻碍优化编译器分析依赖关系最关键的因素之一,特别是对以多面体模型为基础的优化编译器而言,因为多面体没有办法判定不同内存地址单元之间的别名关系,也就无法确定语句实例之间是否有依赖。

大多数基于多面体模型的优化编译器只是简单地认为数组之间不再存在别名关系。

一种常用的方法是在程序设计语言级别引入辅助别名分析或者消除别名的语言特性。例如,C语言提供的restrict关键字。另一种方式是优化编译器在实施程序转换时,对可能产生别名关系的数组进行标记,并依靠运行时技术确定别名关系是否存在。

4.2.4 迭代空间分裂

为了减少二维stencil中空间维度之间的长距离依赖在每个维度上的距离向量分量,可以采用一种称为迭代空间分裂的方法。

对原迭代空间中非依赖方向进行切割,同一时间维度上的子空间可以通过折叠,将原来的依赖转换。迭代空间分裂为子空间的折叠创造了条件,但子空间的折叠却需要依靠多面体模型对循环迭代空间进行一维或多维反向的循环偏移或反转变换才能获得有利于循环变换的迭代空间。

4.3 多面体模型中的循环变换

与传统的并行编译器所采用的幺模矩阵相比,多面体模型具备以下几个特点:
        1. 应用范围广。幺模矩阵只能处理完美嵌套循环,而多面体模型对输入程序的约束更少,不仅处理完美循环嵌套,而且对非完美循环也支持。
        2. 表示能力强。幺模矩阵只能处理循环变换、切斜和反转等变换。而多面体还能处理循环分块、合并和分布等在内的几乎所有循环变换。
        3. 优化空间大。多面体模型能处理更多的循环变换的组合。

除了面向通用体系结构的源到源优化工具Pluto和PPCG外,GCC、LLVM、Opn64和IBM XL编译器都集成了多面体优化相关的模块。也在深度神经网络和自动优化和部署受到了广泛的关注:MLIR, TensorComprehensions,Tiramisu, PlaidML/Stripe和Diesel等深度学习编译器中发挥了不可替代的作用。

4.3.1 循环变换分类

根据循环变换对程序特征的改变,分为以下几种:
        1. 改变程序算法设计:是通过对算法的调整来降低时间或空间复杂度的。例如,在不考虑稳定性的前提下,可以使用快排代替冒泡排序。
        2. 改变程序计算顺序:往往是为了提升程序的并行性或数据的局部性。例如循环合并就是为了缩短相邻循环嵌套访问数据之间的“生产-消费”距离、提升数据的时间局部性而实现的循环变换。
        3. 仅改变循环结构、但不改变程序计算顺序:主要是为了生成对目标体系结构友好的代码而设计或实现的。例如循环展开就是一种为了充分利用细粒度指令流水并行而设计的循环变换。
        4. 改变程序数据布局和被访问内存地址单元:充分利用目标体系结构上的存储层次结构。

编译器很难实现第一种循环变换,基于多面体的编译器能够自动实现后三种的循环变换及其组合。后三种循环变换几乎都需要考虑目标体系结构的特征,因此如何在多面体模型中实现对目标体系结构的抽象是实现循环变换的关键。

4.3.2 循环变换的复杂性

基于多面体模型的循环变化将循环的迭代空间表示成空间多面体,并通过多面体上的几何操作达到分析和优化程序的目的。多面体模型利用迭代空间、访存映射、依赖关系和调度表示程序及其语义。其中,调度表示语句实例与其对应的字典序之间的仿射函数,多面体模型实现循环变换的方法就是在满足依赖关系的前提下,将一种调度转换成另一种调度的过程,该过程也被称为调度变换。多面体模型首先将循环嵌套内的语句实例构成的集合表示为空间多面体的形式。从几何角度来看,多面体模型上的调度变换实质上就是多维空间几何的变基过程。

// CPU 一段可并行循环
for(t = 0; t < T; t+=1)
    for(i = 0; i < N; i+=1)
        A[t+1][i] = 0.25 * (A[t][i+1] -2*A[t][i] + A[t][i-1]);

// GPU 二段可并行循环
for(t = 0; t < T; t+=1)
    for(it = 0; it < (N-2)/4; it+=1)
        for(ip = max(1, 4*ip); ip < min(N-1, 4*ip+4); ip+=1)
            A[t+1][ip] = 0.25 * (A[t][ip+1] -2*A[t][ip] + A[t][ip-1]);

只考虑并行性的前提下,循环变换的代码在硬件上的部署似乎看起来没有那么麻烦。然而,一个无法忽视的问题是现代体系结构上的存储层次结构。尽管一些底层的编译技术已经充分考虑到如寄存器分配、指令流水线并行等细粒度策略,但是面向高速缓存的数据局部性优化却是循环变换不得不考虑的问题。

在基于多面体的编译器中,代码生成阶段的输入是表示程序迭代空间的集合和表示程序调度的映射。其中,表示程序调度的映射必须是从语句实例集合到字典序之间的仿射函数。

对迭代空间进行粗暴地矩形划分是非法的,因为任意两个水平相邻的分块之间存在依赖环,而多面体模型的代码生成器限制循环只能延(t,i)坐标轴方向切割。细看之后发现,沿i轴获得循环分块是合法的。接下来解决t轴切割。t轴的切割方向与程序中的两个依赖,即左上和右上方向的依赖都相交。如果沿着两个依赖的其中一个方向进行切割并构造新的分块形状。不难发现,这两种分块形状都不会导致任意方向相邻两个分块之间的依赖环。

要构造上述分块,i轴保持不变,另外一个坐标轴的指向左上或右上的依赖方向平行,需要编译器自动实现对循环分块友好的循环变换及这些循环变换的不同组合,这对编译器很重要也很困难。

循环变换的目的有时候是互相冲突的,以程序并行性和数据局部性为例,上述分块没有实现最大化程序并行性的目的,尽管提高了数据局部性。

带有OpenMP编译指示的代码在运行时有同步开销,编译指示在循环嵌套的层数越靠近,同步的开销就越小。循环分块增大了同步开销,因此损失了程序并行性。

4.3.3 Pluto调度算法

Pluto算法以自动实现循环分块为目的,在实施循环分块之前试图寻找能够最大化循环分块可能性的循环变化组合。

Pluto调度算法的核心是兼顾程序并行性和数据局部性的前提下,利用一种定义良好的代价模型,自动确定有利于实现循环分块的顺序关系,通过计算原始程序的顺序关系和新的顺序关系之间的映射函数,自动实现循环分块友好的循环变换组合。

4.4 仿射循环变换

任何能用多维仿射变换表示的循环变换都成为仿射循环变换。Pluto调度算法先进行仿射变换,然后在循环分块。

4.4.1 循环交换interchange

向量化:使用向量寄存器;并行化:无依赖;

循环交换将完美嵌套循环的两个循环顺序进行交换,通过这种技术既可以将可向量化的循环移动到循环嵌套的最内层以提高程序的向量化效果,也可以将可并行化的循环移动到循环嵌套最外层以增加程序的并行粒度,减少同步开销。

Pluto调度算法的代价模型是面向多核架构设计的,所以Pluto算法在实现循环交换时,更倾向于将可并行化的循环移动到循环嵌套的外层,以提高并行的粒度,并降低同步开销。

4.4.2 循环反转reversal

循环反转是一种将循环嵌套某一层循环的迭代方向进行反转,并将循环步长设置为原始值的相反数。

for (int i = 1; i < M; i++)
    for (int j = 1; j < N; j++)
        for (int k = 0; k < K - 1; k++)
            A[i][j][k] = A[i][j-1][k+1] + A[i-1][j][k+1];

// reverse loop
for (int k = K-2; k >= 0; k--)
    #pragema omp parallel for
    for (int i = 1; i < M; i++)
        for (int j = 1; j < N; j++)
            A[i][j][-k] = A[i][j-1][-k+1] + A[i-1][j][-k+1];

4.4.3 循环延展scaling

循环延展通过延展循环索引变量的取值范围和循环步长的技术,目的在于将程序中不规则的依赖转换成灾迭代空间上全局一致的依赖,从而使程序迭代之间的依赖距离向量分量能够用整数表示,依此来为其他循环变换创造机会。

for (int i = 0; i < N; i++)
    f[i] = fin[i];
for (int i = 0; i < N/2; i++)
    g[i] = f[2*i+1] * f[2*i-1];
for (int i = 0; i < N; i++)
    h[i] = g[i/2+1] * g[i/2];

将g[i]循环以延展为2展开,所有循环迭代之间的依赖在整个迭代空间上全局一致,且3个循环还可以合并。

4.4.4 循环倾斜skewing

循环倾斜通过改变完美循环嵌套对应迭代空间形状的循环变换技术,但并不会改变迭代之间的执行顺序。例如(t, i) --> (t, 2t+i)。

for(t = 0; t < T; t+=1)
    for(i = 0; i < N; i+=1)
        A[t+1][i] = 0.25 * (A[t][i+1] -2*A[t][i] + A[t][i-1]);

4.4.5 循环合并fussion

循环合并是一种通过将多个循环嵌套融合在一起形成一个统一的迭代空间,从而便于编译器实现其他优化的循环变换技术。循环合并本身是一个非常复杂的过程,合并后的循环可能会失去并行性或无法实现循环分块,因为可能产生的依赖问题。循环合并通过循环的融合缩短程序数据之间的“生产--使用”距离,从而提高数据的局部性。

4.4.6 循环分裂fission

循环分裂是一种将一个循环嵌套在分裂成多个循环嵌套的循环变换技术。循环分裂通过将循环嵌套某层循环上的循环携带依赖转换为循环无关依赖,来提升循环的并行性。

4.5 近似仿射变换

在仿射循环变换中,用于循环变换的表达式只包含加减法,乘法以及这些操作的组合,并且表示循环变换的仿射变换中不会涉及存在量词。在一些循环变换中,除法、取模以及存在量词是不可避免的,所以需要借助近似仿射表达式来表示循环变换。

4.5.1 循环分块blocking

循环分块将循环嵌套空间划分成不同的分块并改变循环迭代执行顺序的循环变换技术。如果在未经循环倾斜的原始迭代空间上能够实现正确的循环分块,那么就称为矩形分块。在经过一些仿射循环变换的组合之后才能进行循环分块所得到形状可能是平行四边形后者其他形状。

4.5.2 循环分段strip-mining

循环分段将一个循环的迭代空间分成多个子集,并将每个子集作为一个调度单元进行执行的循环变换技术。在多核架构中,循环分段的实现往往是隐式地分配单个循环迭代空间的不同子集给不同线程。但是在多级并行硬件抽象的体系结构,例如GPU上的线程块和线程,循环分段必须显式执行。

由编译器显式实现的循环分段往往会带来额外的循环执行开销,因为引入了新的循环边界判断条件。除非能提升程序的并行性,否则并不鼓励在优化编译器中显示地实现循环分段。循环分段也可看做循环分块在单层循环上的特殊情况。

4.5.3 循环展开压紧unroll and jam

循环展开压紧将某两层的外层循环展开后再将内层循环进行合并的循环变换技术,压紧效果在于减少了向量寄存器取数操作的次数,和展开效果在于增加不同计算功能部件之间的指令流水线并行效率。

4.6 代码生成过程的循环变换

代码生成过程中的循环变换包括分块分离、循环展开和循环剥离。

4.6.1 分块分离tile isolation

在迭代空间边界上由于与迭代空间相交而被舍去部分区域的分块被称为半块,其他没有被舍去的分块为整块。分块分离式一种将半块和整块分离,以消除循环嵌套中的重叠边界条件限定条件(min、max)。

分开分离通过消除生成代码中循环边界的min和max等限定边界操作,使得生成的代码能够对一些领域特定的加速芯片友好。

4.6.2 循环展开unrolling

循环展开是一种通过展开循环迭代来降低循环条件分支开销的循环变换技术,提高指令间流水并行,并降低用于循环边界判定的控制开销,还为向量化创造条件,但注意寄存器溢出。

4.6.3 其他循环优化

循环剥离peeling将循环某些迭代从循环主体中剥离出来的循环变换技术。循环中有时只有部分迭代会产生依赖,当这些循环迭代是循环的前几个或后几个迭代的时候,优化编译器就可以通过循环剥离的方法,将原来循环转换为两部分。

循环判断外提unswitching将循环内与循环索引无关的条件谓词外提到循环外的技术,降低分支开销,减少代码量。

4.7 循环压紧

循环压紧coalescing将多层循环嵌套压缩成单层循环的技术。

你可能感兴趣的:(多面体编译理论与深度学习实践,赵婕,性能优化,并行计算,循环优化)