为有效分析和转换张量计算中常见的循环嵌套,多面体模型采用了一个精确的数学框架。此框架依据三个基本组成部分:迭代域定义计算发生的时间,访问函数定义涉及的数据,以及依赖分析理解重排计算的限制。下面我们逐一查看这些内容。
迭代域:定义执行空间
在可能嵌套的循环结构中,语句的每一次执行都发生在由周围循环索引值定义的特定点。我们将这种特定执行称为语句实例。多面体模型捕获了语句执行时所有可能的整数索引向量集合。这个集合被称为迭代域。
形式上,对于嵌套在d个循环中的语句S,其索引变量为i1,i2,…,id,迭代域DS是一个整数向量集合iter=(i1,i2,…,id)∈Zd。这个集合由循环边界定义,循环边界必须可以表示为涉及循环索引和可能存在的符号常量(参数)的仿射不等式,这些常量代表未知的问题大小(如矩阵维度)。
迭代域DS可以表示为:
DS={iter∈Zd∣A⋅iter+b≥0}
其中,A是一个整数矩阵,b是一个整数向量,表示从循环边界推导出的约束。不等式是按元素进行的。任何通过此类线性不等式定义的整数集合都会形成一个整数多面体。
示例:矩阵乘法
考虑矩阵乘法C=A×B中的核心计算:
// 假设C初始化为零
for (int i = 0; i < M; ++i) { // 循环索引 i
for (int j = 0; j < N; ++j) { // 循环索引 j
for (int k = 0; k < P; ++k) { // 循环索引 k
// 语句 S1:
C[i][j] = C[i][j] + A[i][k] * B[k][j];
}
}
}
语句S1嵌套在三个循环中。S1的一个实例由向量iter=(i,j,k)标识。迭代域DS1是所有由循环边界定义的有效(i,j,k)元组的集合:
DS1={(i,j,k)∈Z3∣0≤i<M,0≤j<N,0≤k<P}
这可以使用仿射不等式来书写:
i≥0
−i+(M−1)≥0 (等价于i≤M−1,或i<M)
j≥0
−j+(N−1)≥0
k≥0
−k+(P−1)≥0
这在3D迭代空间(i,j,k)中形成一个有界整数多面体(一个长方体)。M,N,P是符号参数。
访问函数:迭代到数据的映射
在每次迭代iter∈DS中,语句S通常会读写内存位置,这些位置常是数组(张量)的元素。一个访问函数描述了某个语句实例访问哪个内存位置。
多面体模型要求这些访问函数是迭代向量iter的仿射函数。对于像Array[index_expr_1]...[index_expr_n]这样的数组访问,该函数将迭代向量iter映射到数组索引向量(index_expr_1,…,index_expr_n)。这种映射必须采用以下形式:
F(iter)=M⋅iter+c
其中M是一个整数矩阵,c是一个整数向量。
示例:矩阵乘法访问
继续矩阵乘法示例,语句S1涉及四次访问:对C的读写,对A的读,以及对B的读。设迭代向量为iter=(i,j,k)。
-
写入C[i][j]:访问的索引是(i,j)。访问函数FC,write将(i,j,k)映射到(i,j)。
FC,write(i,j,k)=(100100)ijk+(00)=(ij)
这是仿射的。
-
读取C[i][j]:访问的索引是(i,j)。访问函数FC,read与FC,write相同。
FC,read(i,j,k)=(ij)
-
读取A[i][k]:访问的索引是(i,k)。访问函数FA,read将(i,j,k)映射到(i,k)。
FA,read(i,j,k)=(100001)ijk+(00)=(ik)
这是仿射的。
-
读取B[k][j]:访问的索引是(k,j)。访问函数FB,read将(i,j,k)映射到(k,j)。
FB,read(i,j,k)=(000110)ijk+(00)=(kj)
这也是仿射的。
由于所有迭代域都由仿射不等式定义,并且所有访问函数都是仿射的,因此该循环嵌套适用于在多面体模型中进行分析。
数据依赖:理解执行限制
循环顺序改变、分块或并行化等优化只有在保持程序语义的前提下才有效。这要求理解不同语句实例之间的数据依赖。如果两个语句实例访问相同的内存位置,并且其中至少一个访问是写入操作,那么它们之间存在数据依赖。
数据依赖的常见类型有:
- 流(真)依赖(写后读,RAW):语句实例S1(iter1)写入的位置随后被S2(iter2)读取。这决定了S1(iter1)必须在S2(iter2)之前执行。
- 反依赖(读后写,WAR):S1(iter1)从一个位置读取,该位置随后被S2(iter2)覆盖。S1(iter1)仍必须在S2(iter2)之前执行,以便读取原始值。
- 输出依赖(写后写,WAW):S1(iter1)写入的位置随后被S2(iter2)覆盖。顺序对于存储的最终值有影响。S1(iter1)必须在S2(iter2)之前执行。
- 输入依赖(读后读,RAR):S1(iter1)读取的位置随后被S2(iter2)读取。这不强制要求执行顺序,但可能与局部性优化有关。
为了使用多面体框架正式识别依赖,我们寻找满足以下条件的迭代对(iter1,iter2):
- iter1∈DS1且iter2∈DS2(两个实例都是有效的执行点)。
- iter1在iter2之前执行。在原始代码中,这由迭代向量的字典顺序决定。我们将其表示为iter1≺iter2。
- 它们访问相同的内存位置:FS1,mem(iter1)=FS2,mem(iter2),其中FS1,mem和FS2,mem分别是S1和S2中相关内存引用的访问函数。
- 至少有一个访问(S1(iter1)或S2(iter2))是写入操作。
所有满足这些特定类型依赖(例如流依赖)条件的迭代对(iter1,iter2)的集合定义了依赖关系。这种关系本身也常可以表示为另一个多面体。
示例:简单循环中的依赖
考虑这个循环:
for (int i = 1; i < N; ++i) {
A[i] = A[i-1] + B[i]; // 语句 S
}
- 迭代域:DS={i∈Z∣1≤i<N}
- 访问函数(针对数组 A):
- 写入
A[i]:Fwrite(i)=i
- 从
A[i-1]读取:Fread(i)=i−1
我们来查找流依赖(写后读)。我们需要满足以下条件的iter1=i1和iter2=i2:
- i1∈DS, i2∈DS
- i1≺i2 (因为是单循环,所以意味着i1<i2)
- 在i1处的写入访问对应于在i2处的读取访问:Fwrite(i1)=Fread(i2)
i1=i2−1
组合这些条件:1≤i1<N, 1≤i2<N, i1<i2, 和i1=i2−1。
这简化为:对于任何满足2≤i2<N的i2,都存在从迭代i1=i2−1发出的流依赖。
对于所有从1到N−2的i,依赖关系从迭代i存在到迭代i+1。依赖向量是d=iter2−iter1=i2−i1=1。这表明迭代i+1依赖于迭代i中计算的结果。
示例循环中的流依赖。每次迭代i写入A[i],该值由下一次迭代i+1读取。依赖向量是常数,d=(1)。
矩阵乘法中的依赖分析
将此应用于矩阵乘法示例中对C[i][j]的访问:
- S1(i,j,k)写入C[i][j]。
- S1(i,j,k+1)读取C[i][j](当k<P−1时)。
这是一个流依赖:iter1=(i,j,k),iter2=(i,j,k+1)。iter1≺iter2。访问函数FC(iter1)=FC(iter2)=(i,j)。依赖向量是d=iter2−iter1=(0,0,1)。此依赖由最内层循环k承载。
- 类似地,S1(i,j,k)写入C[i][j],S1(i,j,k+1)也写入C[i][j]。这是一个输出依赖,依赖向量同样是d=(0,0,1)。
这些依赖告诉我们,对于给定的(i,j),如果没有归约等机制,k循环的迭代不能随意重排或完全并行化,因为每次迭代都依赖于前一次在C[i][j]中存储的部分和。然而,具有不同(i,j)值的迭代在C数组访问方面是独立的。
通过精确定义迭代域、访问函数以及由此产生的数据依赖,多面体模型为数学推演循环嵌套并推导复杂且保持正确性的性能优化转换提供了依据。下一步涉及利用此依赖信息构建有效的调度,将迭代映射到执行时间步长,从而实现分块和并行化等优化。