Parallel Programming in Java 是 Coursera 的上的一门课程,一共有四周课程内容,讲述Java中的并行程序设计。这里是第三、四周课程的内容笔记。主要内容为 Parallel Loops 和 Dataflow Synchronization and Pipelining,即 循环并行 和 数据流同步及流水线
循环结构在编程实践中是一类很常见的构建模型。程序中的循环结构主要可分为:
pointer-chasing loop
for(p = HEAD; p != NULL; p.NEXT) {
compute(p);
}
这类循环结构中,因为每次循环处理的指针对象都是相互独立的,因此任务完全可以分开进行处理。可以简单地采用将循环看做 async task 使得计算模型并行化
FINISH{
for(p = HEAD; p != NULL: p = p.NEXT) {
ASYNC
compute(p);
}
}
Iteration loop
for (i : [0 : N-1]) {
A[i] = B[i] + C[i];
}
这类模型和pointer-chasing loop最大的不同处在于这类循环中,循环的次数是可以提前知道的。
简单的使用forall
代替for
来调用现有的API就可以自动的并行化。
或者调用 stream 来并行化 for 循环。
a = IntStream.rangeClosed(0, N-1).parallel().toArray(i -> b[i] + c[i]);
但具有多个返回值的时候还是使用forall
构建会更清晰简单。
一个串行化得矩阵乘法实例
for([i, j] : [0:N-1, 0:N-1]) {
c[i][i] = 0;
for(k : [0:N-1]) {
c[i][j] += A[i][k] * B[k][j]
}
}
并行化时,将外层循环的 for
替换为使用 forall
。内层的 k 循环是不能进行进一步的并行化的,因为在计算的过程中如果进行并行化就会产生数据竞争。
在一个并行循环的模型中,循环体部分可以加入 barrier。作用是进行进程之间的同步,进行第一次执行到 barrier 会等待,待所有线程都到达之后再一次开始。通过加入 barriers,for的循环体被分割为不同的 phases 进行操作,进程会在 phase 之间进行同步,然后继续并行执行。
使用 forall
循环时,将全部循环都创建 task 有时并不是好的方案,应当根据具体的硬件环境(即处理器核心等)创建适合的并行模式,在尽量完全的利用硬件运算优势的前提下减少因为 task 分配造成的开销。
例如对于计算向量和的程序并行化,将整个向量分块会是一个较好的方法,常用的分块方法有两种:
在一般的使用 barrier 的任务中,通常同步操作本身是需要消耗开销的。实际上,这部分开销是可以整合在程序的其他部分中的,即将 barrier 放置在程序的某个步骤中,在执行的同时进行同步。
在 barrier 执行时,实际上分为几个步骤,即 ARRIVE-AWAIT-ADVANCE。因此,显然将这些步骤分离,将不是必须等待的步骤提前执行,让程序拥有更多的“等待缓冲”时间是一个较好的优化思路。phaser object 可以分为两部分:ARRIVE 和 AWAIT-ADVANCE。通常的 barrier 模型可以表示为
forall(i : [1:N]) {
print("HELLO");
myid = LOOKUP(i);
NEXT;
print("bye" + myid);
}
使用 phaser 之后,模型可以表示为:
// initialize phaser ph for use by n tasks ("parties")
Phaser ph = new Phaser(n);
// Create forall loop with n iterations that operate on ph
forall (i : [0:n-1]) {
print HELLO, i;
int phase = ph.arrive();
myId = lookup(i); // convert int to a string
ph.awaitAdvance(phase);
print BYE, myId;
}
ARRIVE 表示当前线程进入了这个 phaser ,但并不需要等待,仍然可以继续执行一些本地化的操作。执行到 AWAIT ADVANCE 时才是真正需要等待同步的地方。因此,使用 phaser 相当于将同步操作和本身需要执行的步骤进行了一定程度的重叠,使关键路径时间缩短。
使用 phase 的同步模型可以使得当数据依赖关系复杂时,保证在效率最高的情况下没有数据竞争地完成任务。以下面的计算依赖关系为例:
Task 0 | Task 1 | Task 2 |
---|---|---|
1a:X=A();//cost=1 | 1b:Y=B();//cost=2 | 1c:Z=C();//cost=3 |
2a:ph0.arrive(); | 2b:ph1.arrive(); | 2c:ph2.arrive(); |
3a:ph1.awaitAdvance(0); | 3b:ph0.awaitAdvance(0); | 3c:ph1.awaitAdvance(0); |
4a:D(X,Y);//cost=3 | 4b:ph2.awaitAdvance(0); | 4c:F(Y,Z);//cost=1 |
5b:E(X,Y,Z);//cost=2 |
上面的任务中,如果不使用 phase 模型,就必须等所有 task 的第一步完成之后进行一次同步,再共同开启后面的程序。但使用 phase 模型可以让任务更加精确地知道自己在等待的任务,一旦该任务完成便可以立即开始。
即流水线并行,适用于可分为多个独立步骤并且需要处理序列化的多个独立输入的任务。
在实现时,每个步骤之间只需要等待前一个步骤完成就可以执行。使用流水线模型,假设要处理n个输入,每次处理需要p步,时间都是1,则 W O R K = n × p , C P L = n + p − 1 , P A R = W O R K C P L = n p n + p − 1 WORK=n\times{p}, CPL=n+p−1, PAR=\frac{WORK}{CPL}=\frac{np}{n + p − 1} WORK=n×p,CPL=n+p−1,PAR=CPLWORK=n+p−1np ,当 n 远大于 p 时,效率趋近于 p,这已经达到了最理想的情况了。
数据流并行模型意在使用 async 来实现并行计算图的构建,从而更加具体地体现并使用数据操作之间的依赖关系。假设现有的数据依赖为:A → C, A → D, B → D, B → E,则可以构建以下模型:
async( () -> {/* Task A */; A.put(); } ); // Complete task and trigger event A
async( () -> {/* Task B */; B.put(); } ); // Complete task and trigger event B
asyncAwait(A, () -> {/* Task C */} ); // Only execute task after event A is triggered
asyncAwait(A, B, () -> {/* Task D */} ); // Only execute task after events A, B are triggered
asyncAwait(B, () -> {/* Task E */} ); // Only execute task after event B is triggered