优化我们的程序(数据篇):程序并行化

寻找无同步的并行性

在前文介绍过数据的空间维度,我们知道外层循环如果迭代的是独立的维度,那么彼此互不影响,也就是说,独立的维度循环可以交换迭代深度。

循环嵌套交换

有程序如下:

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];
		}
	}
}


画出流程图:

CPU计算T
CPU处理D数组

再进一步发现,T作为一个中间值,其实是严重影响了程序的并行性的,我们可以尝试将它直接消去,不过在处理器和内存充足的条件下,我们其实是有更好的办法,我们可以尝试把T改写成数组,也就是:

每个CPU从T数组中获取值
每个CPU分别处理D数组

将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个数。

仿射空间划分的依据可以根据循环嵌套的作用域进行划分,在前面我们已经知道如何使用线性代数来表示循环,因此,对于在前文提到的程序,将[p1, p2]作为CPU的ID,对第二层之内的循环有:
$$
\begin{pmatrix}
p1 \
p2
\end{pmatrix}

\begin{pmatrix}
1&0\
0&1
\end{pmatrix}
*
\begin{pmatrix}
i\
j
\end{pmatrix}
+
\begin{pmatrix}
0\
0
\end{pmatrix}
对于最里层的两个循环,它不仅在上面的作用范围内,而且它还有一个局部维度 k ,因此有: 对于最里层的两个循环,它不仅在上面的作用范围内,而且它还有一个局部维度k,因此有: 对于最里层的两个循环,它不仅在上面的作用范围内,而且它还有一个局部维度k,因此有:
\begin{pmatrix}
p1 \
p2
\end{pmatrix}

\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}
p1 \
p2
\end{pmatrix}

\begin{pmatrix}
0&1&0\
0&0&1
\end{pmatrix}
*
\begin{pmatrix}
k\
i\
j
\end{pmatrix}
+
\begin{pmatrix}
0\
0
\end{pmatrix}
$$
我们发现,不同作用域下,仿射划分的向量系数的秩不一样,我们知道矩阵的秩的实际意义就是相对独立的维度,正如笔者前面所说的,如果我们选择一个仿射划分,应当选择所有语句的秩的最大值。

你可能感兴趣的:(算法,程序优化,线性代数,计算机科学)