在前文介绍过数据的空间维度,我们知道外层循环如果迭代的是独立的维度,那么彼此互不影响,也就是说,独立的维度循环可以交换迭代深度。
有程序如下:
for(i = 0; i < 5; i++){
for(j = 0; j < 3; j++){
Z[i][j] = 1;
}
}
我们一眼就能看出可以交换循环:
for(j = 0; j < 3; j++){
for(i = 0; i < 5; i++){
Z[i][j] = 1;
}
}
现在我们的目的是寻找普遍规律,即:在满足什么条件下外层嵌套循环可以交换?
笔者给出结论:动态访问之间不存在依赖关系时。
程序约束如下:
0 < i < 5
0 < j < 3
明显是不存在依赖的。
现在看另一个例子:
for(i = 0; i < 9; i++;){
for(j = 9; j > 0; j--){
Z[i][j] = Z[i + j][i - j + 10];
}
}
观察该循环,列不等式如下:
0 < i < 9
0 < j < 9
假设有两组不同访问i,j,i1,j1使得:
i = i1 + j1
j = i1 - j1 + 10
带入不等式:
0 < i1 + j1 < 9
0 < i1 - j1 + 10 < 9
由原不等式可得:
i1 < 9 - j1
i1 < j1 - 1
显然,该不等式有解,该循环嵌套是存在依赖关系的,也就是说,我们并不能对其进行优化。
依赖关系具体体现,就是在与数据的一个维度里,却存在多个迭代维度,如果交换外层循环迭代,我们会发现不同迭代的作用到数据具体维度时,它们的数据访问顺序会被打断。
当不同数组的维度的迭代维度不单一时,是很难发现程序的并行性的,但是我们都知道,每个数据维度只要一个迭代维度就能全部遍历,像这样的多个迭代作用的情况是非常少的。
所以我们也发现了另一种可能性:如果每个数据维度与每个迭代维度对应,数据之间是不是不存在依赖关系了?
举例,以下是一个多重网格算法:
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
AP[j][i] = ...;
T = 1/(1 + AP[j][i]);
D[2][j][i] = T*AP[j][i];
DW[1][2][j][i] = T*DW[1][2][j][i];
}
}
for(k = 3; k <= k1 - 1; k++){
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
AM[j][i] = AP[j][i];
AP[j][i] = ...;
T = ...AP[j][i] - AM[j][i]*D[k-1][j][i]...;
D[k][j][i] = T*AP[j][i];
DW[1][k][j][i] = T*(DW[1][k][j][i] + DW[1][k-1][j][i])...;
}
}
}
for(k = k1-1; k > 2; k--){
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
DW[1][k][j][i] = DW[1][k][j][1] + D[k][j][i]*DW[1][k+1][j][i];
}
}
}
尽管这些代码和数组操作非常长,我们却发现嵌套循的迭代维度和数组的访问维度都是相对独立的,那么让我们尝试将循环嵌套改写:
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
AP[j][i] = ...;
T = 1/(1 + AP[j][i]);
D[2][j][i] = T*AP[j][i];
DW[1][2][j][i] = T*DW[1][2][j][i];
}
}
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
for(k = 3; k <= k1 - 1; k++){
AM[j][i] = AP[j][i];
AP[j][i] = ...;
T = ...AP[j][i] - AM[j][i]*D[k-1][j][i]...;
D[k][j][i] = T*AP[j][i];
DW[1][k][j][i] = T*(DW[1][k][j][i] + DW[1][k-1][j][i])...;
}
}
}
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
for(k = k1-1; k > 2; k--){
DW[1][k][j][i] = DW[1][k][j][1] + D[k][j][i]*DW[1][k+1][j][i];
}
}
}
合并如下:
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
AP[j][i] = ...;
T = 1/(1 + AP[j][i]);
D[2][j][i] = T*AP[j][i];
DW[1][2][j][i] = T*DW[1][2][j][i];
for(k = 3; k <= k1 - 1; k++){
AM[j][i] = AP[j][i];
AP[j][i] = ...;
T = ...AP[j][i] - AM[j][i]*D[k-1][j][i]...;
D[k][j][i] = T*AP[j][i];
DW[1][k][j][i] = T*(DW[1][k][j][i] + DW[1][k-1][j][i])...;
}
for(k = k1-1; k > 2; k--){
DW[1][k][j][i] = DW[1][k][j][1] + D[k][j][i]*DW[1][k+1][j][i];
}
}
}
画出流程图:
再进一步发现,T作为一个中间值,其实是严重影响了程序的并行性的,我们可以尝试将它直接消去,不过在处理器和内存充足的条件下,我们其实是有更好的办法,我们可以尝试把T改写成数组,也就是:
将T改写为数组,其实是比直接消去更有优势的,因为这样做,每个CPU可以先分别计算T数组中的每个值,并行的粒度会减小。
另外,如果我们分配任务给多CPU,每个CPU会分别计算T并且进行处理,但是,我们假设CPU与数组数量并是一一对应的,那么此时每个CPU都会使用一个T,其实这就是一个数组,因此,我们需要将T改写成数组:
for(j = 2; j <= j1; j++){
for(i = 2; i <= i1; i++){
AP[j][i] = ...;
T[j][i] = 1/(1 + AP[j][i]);
D[2][j][i] = T[j][i]*AP[j][i];
DW[1][2][j][i] = T[j][i]*DW[1][2][j][i];
for(k = 3; k <= k1 - 1; k++){
AM[j][i] = AP[j][i];
AP[j][i] = ...;
T[j][i] = ...AP[j][i] - AM[j][i]*D[k-1][j][i]...;
D[k][j][i] = T[j][i]*AP[j][i];
DW[1][k][j][i] = T[j][i]*(DW[1][k][j][i] + DW[1][k-1][j][i])...;
}
for(k = k1-1; k > 2; k--){
DW[1][k][j][i] = DW[1][k][j][1] + D[k][j][i]*DW[1][k+1][j][i];
}
}
}
在学会如何分析数据独立性与循环独立后,我们可以开始尝试将代码分配到具体的处理器上以实现并行。
假设循环迭代维度和数组维度都是一一对应且独立的,那么K维的循环独立就对应K维的处理器空间,每个维度的步长可分配对应的CPU个数。
\begin{pmatrix}
0&1&0\
0&0&1
\end{pmatrix}
*
\begin{pmatrix}
k\
i\
j
\end{pmatrix}
+
\begin{pmatrix}
0\
0
\end{pmatrix}
$$
由于我们的处理器是2维的,但是K的作用域不止一个,因此矩阵系数为0。
在前文已经介绍过了依赖关系的产生,具体到数组访问上,就是该等式成立:
F1i1 + c1 = F2i2 + c2
对于循环迭代,只要迭代的范围合理即可:
B1i1 + b1 >= 0
B2i2 + b2 >= 0
CPU分配的依据是循环迭代的维度,我们知道循环迭代满足的不等式关系如下:
B1i1 + b1 >= 0
B2i2 + b2 >= 0
具体循环:
for(i = 0; i < b; i++){
.....
}
可将0 < i < b写成上述不等式
这是单独的一个循环的作用范围,该丢番图不等式的求解结果,每一个结果都可以对应一个处理器,
现在我们引入了CPU的数量,也就是说,有依赖关系的数据应访问同一个CPU:
两组数据CPU分配如下:
C1i1 + c1 = C2i2 + c2
具体解释就是:假设两组循环遍历次数是a * b,且[0,a]和[0,b]就是不同维度处理器的坐标范围,我们要考虑的是具体数组访问的冲突情况。
但是,假设遍历是从10到10+a,另一个遍历是从12到12+b,那么我们就要将遍历的维度线性变换到实际的处理器维度,这就是CPU矩阵分配的实质,例如:访问维度是[12,12],处理器是[0, 0],这两个矩阵的映射需要做一个线性变换。
单一语句访问下的分配只要做一次线性映射即可,如果是多语句的情况,又要再做多次线性变换,也就是说,我们必须满足所有语句的处理器分配,如果我们满足了访问维度最复杂的语句的分配,那么其他简单的也应该会被满足,最复杂的访问,也就意味着是所有语句中矩阵的秩最大的那个。
如果发生了冲突访问但是并没有将操作映射到同一个CPU上,那么这两个CPU必须同步,这是我们不愿看到的。
\begin{pmatrix}
0&1&0\
0&0&1
\end{pmatrix}
*
\begin{pmatrix}
k\
i\
j
\end{pmatrix}
+
\begin{pmatrix}
0\
0
\end{pmatrix}
$$
我们发现,不同作用域下,仿射划分的向量系数的秩不一样,我们知道矩阵的秩的实际意义就是相对独立的维度,正如笔者前面所说的,如果我们选择一个仿射划分,应当选择所有语句的秩的最大值。