数据依赖与循环转换
数据竞争与同步
共享内存系统
- 每个处理器拥有私有存储(缓存)
- 所有处理器共享内存空间
- 处理器可以并行进行计算
例子
- n个数求和问题
- 串行代码:
sum = 0;
for (i = 0; i < n; i++)
{
x = Compute_next_value(. . . );
sum += x;
}
-
如何进行并行编程?
-
划分
-
将 n n n 个数的求和问题进行划分,假设 t t t 是处理器或线程数量,则可以划分成 t t t 个 n / t n/t n/t 个数据的求和问题,最后把结果再相加;
-
-
通信
- t t t 个部分和通信,计算出全部数据的和
-
组合、映射
- 划分步骤划分出的任务数恰好等于处理器或线程数,可以不进行组合直接完成映射
-
-
并行编程
-
n = 24,t = 8, 线程编号为t0到t7
int block_length_per_thread = n/t; int start = id * block_length_per_thread; for (i=start; i<start+block_length_per_thread; i++) { x = Compute_next_value(…); sum += x; }
-
没有解决sum的并行访问的问题
-
两个线程同时更新sum变量时,发生错误
-
-
临界区
共享数据访问
- 共享内存系统并行编程最基本的挑战
- 当多个线程需要更新共享资源时,结果可能是不可预知的结果
- 与更新指令执行顺序相关
- 竞争条件(Race Condition)
- 当执行的结果取决于两个或多个事件的执行时间时,就存在竞争条件
- 临界区(Critical Section)
- 对共享存储区域进行更新的代码段,或会造成竞争条件的代码段。
如何解决临界区问题
- 在循环内和线程间,变量sum存在依赖关系。
- 读→加→写回,必须是原子操作才能保持程序的正确性
- 原子性(Atomicity)
- 指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。
- 互斥锁(Mutual Exclusion)
- 在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。
- 某段代码被标记了互斥锁,那么在任何情况下,最多只能有一个线程可以执行该段代码。
加上互斥锁
对临界区加入互斥锁,保证在任何情况下,最多只能有一个线程可以执行该段代码。
int block_length_per_thread = n/t;
mutex m;
int start = id * block_length_per_thread;
for (i=start; i<start+block_length_per_thread; i++)
{
my_x = Compute_next_value(…);
mutex_lock(m);
sum += my_x; //临界区加入互斥锁后变成原子操作
mutex_unlock(m);
}
但是,上述的修改却把并行程序改成了串行程序
并行性
互斥锁降低并行性
- 互斥访问的结果是串行访问。
- 互斥锁变量是共享数据,锁变量为1表示锁占用,为0表示空闲。
- 线程为了获得互斥锁,至少需要经过几个层次的cache
提高并行度
为每个线程定义私有变量:my_sum
my_sum = 0; //线程都使用自己的私有变量my_sum保存本地和
my_first_i = . . .;
my_last_i = . . .;
for (my_i= my_first_i; my_i< my_last_i; my_i++)
{
my_x = Compute_next_value(…);
my_sum += my_x; //该段代码不再是临界区,可以并行访问
}
定义私有变量后累积
对sum变量更新的代码为临界区,需要用互斥锁
int block_length_per_thread = n/t;
mutex m;
int my_sum;
int start = id * block_length_per_thread;
for (i=start; i<start+block_length_per_thread; i++) {
my_x = Compute_next_value(…);
my_sum += my_x;
}
mutex_lock(m);
sum += my_sum; //临界区加入互斥锁
mutex_unlock(m);
指定某个线程进行累积
nt block_length_per_thread = n/t;
mutex m;
shared my_sum[t];
int start = id * block_length_per_thread;
for (i=start; i<start+block_length_per_thread; i++)
{
my_x = Compute_next_value(…);
my_sum[id] += my_x;
}
//指定id=0的线程进行累加
if (id == 0)
{
sum = my_sum[0];
for (i=1; i<t; i++)
sum += my_sum[i];
}
上述的代码修改并不能保证所有的线程都结束
同步
线程同步
- 其他线程还没结束,主线程就开始计算累加结果了
- 通过设置路障(Barrier)让主线程等待其他线程都执行结束
- 同步点又称为路障(barrier),只有所有线程都抵达此路障,线程才能继续运行下去,否则会阻塞在路障处。
加入路障
int block_length_per_thread = n/t;
mutex m;
shared my_sum[t];
int start = id * block_length_per_thread;
for (i=start; i<start+block_length_per_thread; i++)
{
my_x = Compute_next_value(…);
my_sum[t] += x;
}
Synchronize_cores(); // barrier for all threads
if (id == 0)
{ // master thread
sum = my_sum[0];
for (i=1; i<t; i++)
sum += my_sum[t];
}
数据依赖
数据依赖(Data Dependence)
-
如果两个存储访问操作访问相同的存储位置(同一个变量),其中一个访问是写(或更新)操作,则两个操作间存在数据依赖
-
数据依赖关系体现了两个存储访问操作的执行顺序,必须按照该顺序执行两个操作才能得到正确的结果
a = b + c; d = 2 * a;
-
两个不同的程序语句之间可能存在数据依赖关系,同一程序语句的两个不同的动态执行之间也可能存在数据依赖关系
-
判断语句是否存在数据依赖(假设 i i i 在 i ’ i’ i’ 之前执行)
- 数据访问
i
i
i 和
i
’
i’
i’ 存在数据依赖,当且仅当
- i i i 和 i ’ i’ i’ 至少其中一个为写操作;
- i i i 和 i ’ i’ i’ 访问同一个变量(或同一个存储位置)
- 数据访问
i
i
i 和
i
’
i’
i’ 存在数据依赖,当且仅当
可并行性定理
-
I j I_j Ij 是进程 P j P_j Pj 要读的数据集合, O j O_j Oj是进程 P j P_j Pj 要写的数据集合。
-
如果进程 P j P_j Pj 和另一个进程 P k P_k Pk 可以并行执行, 则
I j ∩ O k = I k ∩ O j = O j ∩ O k = ϕ I_j \cap O_k = I_k\cap O_j = O_j\cap O_k = ϕ Ij∩Ok=Ik∩Oj=Oj∩Ok=ϕ
循环中的数据依赖
-
如果循环中不存在数据依赖 → \to → 并行loop是安全的
for (i=1; i<=n; i++) A[i] = B[i] + C[i];
- 可以以任何顺序执行循环:i++、i–
-
存在数据依赖
- 循环体依赖(Loop-Carried Dependence):不同迭代间存在数据依赖
for (i=2; i<5; i++) A[i] = A[i-2]+1;
- 循环无关依赖(Loop-Independent dependence):迭代内存在数据依赖
for (i=1; i<=3; i++) A[i] = B[i]+1; C[i] = A[i] * 2;
判别循环数据依赖
-
简单观察
- 将循环展开,按照数据依赖定理寻找循环数据依赖
- 忽视了循环结构,不实用
-
利用迭代空间(Iteration Space),结合循环语句找到依赖关系
-
以空间形式显性的表示循环迭代
-
迭代空间:循环数据依赖
-
特征化迭代空间
-
迭代实例( Iteration instance )
- 用迭代空间中的坐标来表示
- 例如
[
1
,
1
]
[1,1]
[1,1] 表示
i
=
1
,
j
=
1
i=1,j=1
i=1,j=1 的迭代实例
- n维离散笛卡尔空间表示n层嵌套循环
-
字典序(Lexicographic order)
-
迭代的串行执行顺序,例如 [ 0 , 0 ] , [ 0 , 1 ] , . . . , [ 0 , 6 ] , [ 1 , 1 ] , [ 1 , 2 ] , [ 1 , 3 ] , . . . , [ 1 , 6 ] , . . . [0,0], [0,1], ..., [0,6],[1,1], [1,2], [1,3], ..., [1,6], ... [0,0],[0,1],...,[0,6],[1,1],[1,2],[1,3],...,[1,6],...
-
字典序小于(lexicographically less than):当且仅当存在 [ i 1 , i 2 , … , i c − 1 ] = [ i ’ 1 , i ’ 2 , … , i ’ c − 1 ] [i_1, i_2,…, i_{c-1}] = [i’_1, i’_2,…, i’_{c-1}] [i1,i2,…,ic−1]=[i’1,i’2,…,i’c−1],并且 i c < i ’ c i_c< i’_c ic<i’c ,迭代 I I I 字典序小于 I ’ I’ I’,表示为 I < I ’ I<I’ I<I’。
-
-
-
迭代间的依赖关系可以通过循环内语句的数据依赖获得
- 例如迭代实例
I
=
[
1
,
1
]
(
i
=
1
,
j
=
1
)
I=[1,1](i=1,j=1)
I=[1,1](i=1,j=1) 和
I
’
=
[
2
,
2
]
(
i
=
2
,
j
=
2
)
I’=[2,2](i=2,j=2)
I’=[2,2](i=2,j=2)
- 由于数据 A [ 2 , 2 ] A[2,2] A[2,2] 的写后读,因此有循环体依赖(红色箭头所示)
- 例如迭代实例
I
=
[
1
,
1
]
(
i
=
1
,
j
=
1
)
I=[1,1](i=1,j=1)
I=[1,1](i=1,j=1) 和
I
’
=
[
2
,
2
]
(
i
=
2
,
j
=
2
)
I’=[2,2](i=2,j=2)
I’=[2,2](i=2,j=2)
距离向量(Distance Vectors)
- 简明地描述一个迭代空间的迭代之间的依赖关系
- 对于迭代空间的每一个维度,距离值就是存在数据依赖的迭代间相隔的迭代数量
- 迭代 I I I 到迭代 I ’ I’ I’ ( I < I I<I I<I’ )存在数据依赖,那么该循环存在距离向量 D D D ,且 D = I ’ − I D=I’- I D=I’−I。
距离向量与数据依赖合法性
- 循环转换必须保证数据依赖保持不变
- 字典序非负(lexicographically nonnegative)
- 定义:当距离向量的最左边元素为正数,或者距离向量所有元素都为0时。
- 当字典排序非负时,依赖关系是合法的
- 迭代 I I I 不能依赖于后面执行的迭代 I ’ I’ I’ ,
- 即如果 I < I I<I I<I’ 那么不存在迭代 I ’ I’ I’ 到迭代 I I I 的数据依赖
方向向量(Direction Vectors)
-
方向向量与距离向量一样,用来表示循环体依赖
-
方向向量的每个元素为<、>或 =
- < < <:迭代 I I I 到 $I’ $ 存在循环体依赖且 I < I I<I I<I’ ;
- > > >:迭代 I I I 到 I ’ I’ I’ 存在循环体依赖且 I > I I>I I>I’ ;
- = = =:循环无关依赖
-
例如:距离向量为 [ 1 , 1 ] [1,1] [1,1] ,那么方向向量为 [ < , < ] [<,<] [<,<]
-
合法循环体依赖的方向向量: ( [ = , = ] , [ = , < ] , [ < , = ] , [ < , < ] , [ < , > ] ) ([=,=],[=,<], [<,=], [<,<],[<,>]) ([=,=],[=,<],[<,=],[<,<],[<,>])
-
不合法循环体依赖的方向向量: ( [ = , > ] , [ > , < ] , [ > , = ] , [ > , > ] ) ([=,>],[>,<], [>,=], [>,>]) ([=,>],[>,<],[>,=],[>,>])
数据重用与局部性
考虑局部性
- 关注处理器对本地缓存的数据访问
- 称为数据划分或者数据分配问题
- 同时也考虑处理器内部缓存与寄存器中的数据局部性
- 虽然可能不是并行编程的问题,但是如果不考虑这种局部性将造成性能损失
- 降低存储访问开销
- 最大化存储访问带宽
数据局部性(Data Locality)
- 被重用的数据存储在较快的存储器中(缓存或寄存器)
- 计算重复执行(循环),获得更好的局部性的方法
- 适当的数据分配与放置
- 代码的重排转换
数据重用(Data Reuse)
-
相同数据或者相邻数据被多次使用。数据重用是计算固有的性质。
-
时间上的重用和空间上的重用
- 相同的数据在相邻迭代中(第i次和第i+1次迭代)重复使用
for (i=1; i<N; i++) for (j=1; j<N; j++) A[j]= A[j+1]+A[j-1];
- A[j]在外层循环(for (i=1; i<N; i++)),在不同迭代(时间)被重复使用
- A[j]所在的缓存行(空间)在内层循环(for (j=1; j<N; j++))被重复使用
- 与多维数组如何存储相关:按行存储和按列存储
探索重用——局部性优化
- 探索重用也就是进行局部性优化
- 循环转换会重排存储访问序列,从而提升局部性
- 这些转换对于并行也是非常有用的
- 要考虑两个问题
- 安全:循环转换是否保持了数据依赖
- 收益:循环转换是否能带来性能上的收益
循环置换
-
循环转换(Loop Transformations)
- 循环转换用于组织计算序列和内存访问序列,以更好地适应多核处理器的内部结构以及指令级并行。
-
探索局部性和并行性的循环转换技术
- 循环交换(Loop Interchange)
- 循环分块(Loop Tile)
- 循环展开(Loop unroll)
- 循环融合(Loop Fusion)
循环交换(Loop Interchange)
-
循环交换:交换循环的顺序来改变数据遍历的顺序。
-
数据局部性探索
- 通过循环交换,探索数组A的数据局部性
- 原循环:读 A [ 0 ] [ 0 ] A[0][0] A[0][0], 写 A [ 1 ] [ 0 ] A[1][0] A[1][0], 读 A [ 0 ] [ 1 ] A[0][1] A[0][1],写 A [ 1 ] [ 1 ] A[1][1] A[1][1], 读 A [ 0 ] [ 2 ] A[0][2] A[0][2], 写 A [ 1 ] [ 2 ] A[1][2] A[1][2],……
- 交换后:读 A [ 0 ] [ 0 ] A[0][0] A[0][0], 写 A [ 1 ] [ 0 ] A[1][0] A[1][0], 读 A [ 1 ] [ 0 ] A[1][0] A[1][0],写 A [ 2 ] [ 0 ] A[2][0] A[2][0], 读 A [ 2 ] [ 0 ] A[2][0] A[2][0], 写A [ 3 ] [ 0 ] [3][0] [3][0]
- 通过循环交换,探索数组A的数据局部性
-
并行粒度探索
-
通过循环交换,在适当的循环层级获得合适的并行粒度
- 原循环并行粒度:内层循环内可以并行
for (i= 0; i<3; i++) for (j=0; j<6; j++) //此循环层可以并行 A[i+1][j]=A[i][j]+B[j];
- 交换后并行粒度:外层循环内可以并行
for (j=0; j<6; j++) //此循环层可以并行 for (i= 0; i<3; i++) A[i+1][j]=A[i][j]+B[j];
-
-
循环交换安全性问题
- 循环交换前后的方向向量
- 交换前:[<,=],循环体依赖由i循环携带
- 交换后:[=,<],仍由i循环携带,因此依赖关系不会改变。此类循环转换是安全的
- 定理:
- n n n 维循环的距离向量为 D = ( d 1 , … d n ) D = (d_1, … d_n) D=(d1,…dn),当 ( d 1 , … d i − 1 ) > 0 (d_1, … d_{i-1}) > 0 (d1,…di−1)>0 或者对所有的 k ( i ≤ k ≤ j ) d k ≥ 0 k(i ≤ k ≤ j) d_k ≥ 0 k(i≤k≤j)dk≥0,那么从 i i i 到 j j j 的循环层进行循环交换是安全的。
- 循环交换前后的方向向量
-
合法循环转换
-
如果循环转换保持了数据依赖,那么循环转换是安全的,或者称为合法循环转换
-
根据方向向量判断循环转换的合法性
-
[ = , = ] [=,=] [=,=]:循环无关依赖,循环转换是安全的。
-
[ = , < ] [=,<] [=,<]:循环体依赖由内+层j循环携带,循环交换后方向向量为 [ < , = ] [<,=] [<,=] 仍由 j j j 循环携带,依赖关系不改变。此类循环转换是安全的。
-
[ < , = ] [<,=] [<,=]:循环体依赖由外层 i i i 循环携带,循环交换后方向向量为 [ = , < ] [=,<] [=,<] 仍由 i i i 循环携带,依赖关系不改变。此类循环转换是安全的。
-
[ < , < ] [<,<] [<,<]: 两层循环都携带循环体依赖,方向向量为正,循环转换后方向向量仍为正,依赖关系不改变。此类循环转换是安全的。
-
[ < , > ] [<,>] [<,>]:循环体依赖由外层i循环携带,循环交换后方向向量为 [ > , < ] [>,<] [>,<] 是不合法的方向向量,改变了依赖关系。此类循环转换是不安全的。
-
[ > , ∗ ] [ = , > ] [>,*][=,>] [>,∗][=,>]:原循环不可能存在该类方向向量
- 不合法循环体依赖的方向向量: ( [ = , > ] , [ > , < ] , [ > , = ] , [ > , > ] ) ([=,>],[>,<], [>,=], [>,>]) ([=,>],[>,<],[>,=],[>,>])
-
-
循环分块(Loop Tile)
-
循环分块:重排循环迭代,让有数据重用的迭代相继执行
-
优化缓存、寄存器等容量有限的存储设备的数据重用
-
i和j循环分块
- 循环分块方法:采用strip-mine-and-interchange分块法
- 首先进行strip-mine(分块执行),增加循环层次
- 然后进行interchange,使循环分块执行
- Tilling从最内层循环开始,因为需要考虑cache大小
- 循环分块方法:采用strip-mine-and-interchange分块法
-
探索数据局部性
-
数据重用:D[i]在每一轮j循环被重用。假设N和M足够大,在下一轮循环访问D[i]时, D[i]已经被驱除出缓存。
-
设缓存的cache line可以容纳b个数组元素,D的cache miss是 M ∗ N / b M*N/b M∗N/b,B的cache miss是 M ∗ N / b M*N/b M∗N/b
for (j=1; j<M; j++) for (i=1; i<N; i++) D[i] = D[i] +B[j,i];
cache miss= 2 M ∗ N / b 2M*N/b 2M∗N/b
-
假设s是b的倍数,D的cache miss是 s / b ∗ N / s = N / b s/b*N/s=N/b s/b∗N/s=N/b, B的cache miss是 M ∗ N / b M*N/b M∗N/b
for (ii=1; ii<N; ii+=s) for (j=1; j<M; j++) for (i=ii; i<min(ii+s-1,N); i++) D[i] = D[i] +B[j,i];
cache miss= ( 1 + M ) N / b (1+M)N/b (1+M)N/b
-
循环分块的本质是在外层循环探索内层循环时间上和空间上的重用
-
·
循环展开(Loop unroll)
-
循环展开:简单的将多个循环展开到一个循环内
-
并行探索
-
展开不超过原始循环的迭代次数就是安全的。
-
展开增加了循环内部的并行度
for (i=0; i<4; i++) for (j=0; j<8; j+=2) //此循环内可以并行 { A[i][j] = B[j+1][i]; A[i][j+1] = B[j+2][i]; }
- 减少了循环次数即减少了控制语句,提升性能
-
-
j循环展开、i循环展开
// 原循环 for (i=0; i<4; i++) for (j=0; j<8; j++) A[i][j] = B[j+1][i] + B[j+1][i+1]; //展开后的循环 for (i=0; i<4; i+=2) { for (j=0; j<8; j+=2) { A[i][j] = B[j+1][i] + B[j+1][i+1]; A[i+1][j] = B[j+1][i+1] + B[j+1][i+2]; A[i][j+1] = B[j+2][i] + B[j+2][i+1]; A[i+1][j+1] = B[j+2][i+1] + B[j+2][i+2]; } }
- 探索数据局部性
-
数组B在寄存器的时间上重用
- B [ j + 1 ] [ i + 1 ] B[j+1][i+1] B[j+1][i+1] 和 B [ j + 2 ] [ i + 1 ] B[j+2][i+1] B[j+2][i+1]
-
循环展开越多,数据重用越多,数据局部性越好。
-
- 探索数据局部性
循环融合(Loop Fusion)
-
循环融合:就是将多个循环融合在一起。
// 原来的两个循环 for (i=1; i<=n; i++) A[i] = B[i]+1; for (j=1; j<=n; j++) C[j] = A[j]/2; //合并后的循环 for (i=1; i<=n; i++) { A[i] = B[i]+1; C[i] = A[i]/2; }
-
保持循环间数据依赖的数据融合才是安全的
-
探索数据局部性
- 循环融合后优化了数据局部性:数组A的访问
- 减少了循环次数即减少了控制语句,提升性能
并行性分析
并行探索
-
减少了循环次数即减少了控制语句,提升性能
for (i=0; i<4; i+=2) { for (j=0; j<8; j+=2) { A[i][j] = B[j+1][i] + B[j+1][i+1]; A[i+1][j] = B[j+1][i+1] + B[j+1][i+2]; A[i][j+1] = B[j+2][i] + B[j+2][i+1]; A[i+1][j+1] = B[j+2][i+1] + B[j+2][i+2]; } }
-
一次迭代内,指令级并行性提升。
并行分析
1维循环
-
D = [ 0 ] D=[0] D=[0]
for (j=1; j<N; j++) A[j] = A[j] + 1;
-
D = [ 1 ] D=[1] D=[1]
for (j=1; j<N; j++) B[j] = B[j-1] + 1;
-
1维循环:如果所有的距离向量 D ∈ D , D = 0 D∈D,D=0 D∈D,D=0,那么此循环可以并行执行
n维循环
- 可能多层循环存在循环体依赖
- 判别哪层循环可以并行
- 假设循环的距离向量为 D = [ d 1 , … d n ] D = [d_1, … d_n] D=[d1,…dn] ,如果在i层循环存在循环体依赖,那么 d i d_i di,是 D D D 中第一个非零的值。
- 如果某层循环不存在循环体依赖,那么该层循环的迭代可以并行执行
- N N N 维循环的距离向量为 D = [ d 1 , … d n ] D = [d_1, … d_n] D=[d1,…dn] ,当 [ d 1 , … d j − 1 ] > 0 [d_1, … d_{j-1}] > 0 [d1,…dj−1]>0 或者[ [ d 1 , … d j ] = 0 [d_1, … d_{j}] = 0 [d1,…dj]=0 时,第 j j j 层循环迭代可以并行