并行程序设计(3):数据依赖与循环转换

数据依赖与循环转换

数据竞争与同步

共享内存系统

在这里插入图片描述

  • 每个处理器拥有私有存储(缓存)
  • 所有处理器共享内存空间
  • 处理器可以并行进行计算
例子
  • 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 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 = ϕ IjOk=IkOj=OjOk=ϕ

循环中的数据依赖
  • 如果循环中不存在数据依赖 → \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=1j=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,,ic1]=[i1,i2,,ic1],并且 i c < i ’ c i_c< i’_c ic<ic ,迭代 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=1j=1) I ’ = [ 2 , 2 ] ( i = 2 , j = 2 ) I’=[2,2](i=2,j=2) I=[2,2](i=2j=2)
      • 由于数据 A [ 2 , 2 ] A[2,2] A[2,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=II
距离向量与数据依赖合法性
  • 循环转换必须保证数据依赖保持不变
  • 字典序非负(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]
  • 并行粒度探索

    • 通过循环交换,在适当的循环层级获得合适的并行粒度

      • 原循环并行粒度:内层循环内可以并行
      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,di1)>0 或者对所有的 k ( i ≤ k ≤ j ) d k ≥ 0 k(i ≤ k ≤ j) d_k ≥ 0 k(ikj)dk0,那么从 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大小
  • 探索数据局部性

    • 数据重用:D[i]在每一轮j循环被重用。假设N和M足够大,在下一轮循环访问D[i]时, D[i]已经被驱除出缓存。

    • 设缓存的cache line可以容纳b个数组元素,D的cache miss是 M ∗ N / b M*N/b MN/b,B的cache miss是 M ∗ N / b M*N/b MN/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 2MN/b

    • 假设s是b的倍数,D的cache miss是 s / b ∗ N / s = N / b s/b*N/s=N/b s/bN/s=N/b, B的cache miss是 M ∗ N / b M*N/b MN/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 DDD=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,dj1]>0 或者[ [ d 1 , … d j ] = 0 [d_1, … d_{j}] = 0 [d1,dj]=0 时,第 j j j 层循环迭代可以并行
  • 9
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值