Doug Lea State University of New York at Oswego Oswego NY 13126 315−341−2688 dl@cs.oswego.edu
本文描述了一种Java框架的设计、实现和性能,该Java框架用于支持一种并行编程风格,在该并行编程中,可以通过以下方式解决问题: 在该框架中,通过(递归)将问题分解为并行解决的子任务,等待它们完成,然后合成结果来解决问题。总体设计是为Cilk设计的工作窃取框架的变体。主要的实现技术围绕任务队列和工作线程的有效构造和管理。测得的性能显示出大多数程序的并行加速性能良好,但也暗示了可能的改进。
Fork/Join并行是获得良好并行性能的最简单和最有效的设计技术之一。Fork/join算法是我们熟悉的分治算法的并行版本,其典型形式为:
Result solve(Problem problem) {
if (problem is small)
directly solve problem
else {
split problem into independent parts
fork new subtasks to solve each part
join all subtasks
compose result from subresults
}
}
fork操作启动一个新的并行fork/join子任务。join操作导致当前任务不继续执行,直到分支子任务完成。Fork/join算法,就像其他分治算法一样,几乎总是递归的,重复地分割子任务,直到它们足够小,可以使用简单的、短的顺序方法来解决。 一些相关的编程技术和示例将在第二版[7]的Java并发编程的4.4节中讨论。本文讨论的设计(第2节),实现(第3节)和性能(第4节) FJTask,一个支持这种编程风格的JavaTM框架。FJTask是util的一部分。并发包来自http://gee.cs.oswego.edu。
Fork/join程序可以使用任何框架运行,这些框架支持并行执行的子任务的构造,以及等待它们完成的机制。 然而java.lang.Thread(以及Java线程通常基于的POSIX pthreads)是支持fork/join程序的次优工具:
简而言之,标准线程框架过于笨重,无法支持大多数fork/join程序。但是,由于线程也构成了许多其他并发和并行编程风格的基础,因此仅为了支持这种风格而消除开销或调优线程本身的调度是不可能的(至少是不现实的)。 虽然这些想法具有较长的历史,但第一个为这些问题提供系统解决方案的框架是 Cilk[5]。Cilk和其他轻量级可执行框架层特殊用途的fork/join支持之上的操作系统的基本线程或进程机制。这一策略同样适用于Java,即使Java线程反过来是分层到较低层次的操作系统能力。创建这样一个Java轻量级执行框架的主要优点是使fork/join程序能够以一种更可移植的方式编写,并且能够在广泛范围内运行的jvm。 FJTask框架是基于Cilk中使用的设计的一种变体。其他变种被看到在Hood[4], Filaments[8], stackthreads[10],和相关的系统依赖于轻量级可执行任务。所有这些框架将任务映射到线程的方式与操作系统将线程映射到的方式差不多,但是在执行映射时利用了fork/join程序的简单性、规律性和约束。虽然所有这些框架都可以适应(不同程度)以不同风格编写的并行程序,但它们对fork/join设计进行了优化:
作为这个框架如何出现在程序员面前的标准例子,这里是一个计算斐波那契函数的类:
class Fib extends FJTask {
static final int threshold = 13;
volatile int number; // arg/result
Fib(int n) {
number = n;
}
public static void main(String[] args) {
try {
int groupSize = 2; // for example
FJTaskRunnerGroup group =
new FJTaskRunnerGroup(groupSize);
Fib f = new Fib(35); // for example
group.invoke(f);
int result = f.getAnswer();
System.out.println("Answer: " +
result);
} catch (InterruptedException ex) {
}
}
int getAnswer() {
if (!isDone())
throw new IllegalStateException();
return number;
}
public void run() {
int n = number;
if (n <= threshold) // granularity ctl
number = seqFib(n);
else {
Fib f1 = new Fib(n − 1);
Fib f2 = new Fib(n − 2);
coInvoke(f1, f2);
number = f1.number + f2.number;
}
}
int seqFib(int n) {
if (n <= 1) return n;
else return seqFib(n−1) + seqFib(n−2);
}
}
这个版本的运行速度至少是同等程序的30倍,在同等程序中,每个新任务都在一个新的java.lang.Thread中运行。第4节中描述的平台上的线程。它在这样做的同时保持了多线程Java程序的内在可移植性。对于程序员来说,只有两个典型的调优参数:
fork/join框架的核心在于它的轻量级调度机制。FJTask适应基本战术在Cilk工作窃取调度:
正如[5]中更详细地讨论的,对于每个线程处理自己的任务使用LIFO规则,但是用于窃取其他任务的FIFO规则对于广泛的递归fork/join设计是最优的。在形式上,该方案有两个基本优势: 它通过让窃取线程在deque的另一端来窃取以减少争用。它还利用了早期生成“大型”任务的递归分治算法的特性。因此,旧的被窃取的任务可能会提供更大的工作单元,从而导致被窃取的线程进一步进行递归分解。 作为这些规则的一个结果,为基本操作使用相对较小任务粒度的程序往往比那些只使用粗粒度分区或不使用递归分解的程序运行得更快。尽管在大多数fork/join程序中,被窃取的任务相对较少,但创建许多细粒度的任务意味着只要工作线程准备运行它,它就可能可用。
这个框架大约使用了800行java代码来实现。主要在类FJTaskRunner中,java.lang.Thread的子类。FJTasks本身只维持一个布尔完成状态,并通过委托给当前工作线程来执行所有其他操作。FJTaskRunnerGroup类用于构建工作线程,维护一些共享状态(例如,steal操作所需的所有工作线程的标识),并帮助协调启动和关闭。在util.concurrent中有更详细的实现文档。本节只讨论在实现此框架时遇到的两组问题和解决方案:支持有效的deque操作(push、pop和take),以及管理用于线程获取新工作的steal协议。
了实现高效和可伸缩的执行,必须尽可能快地进行任务管理。creating、pushing和随后poping(或者更不频繁地taking)任务类似于顺序程序中的过程调用开销。更低的开销使程序员能够采用更小的任务粒度,从而更好地利用并行性。 任务分配本身是JVM的职责。Java垃圾收集使我们不必创建一个特殊用途的内存分配器来维护任务。这大大降低了实现所需代码的复杂性和行数FJTasks与其他语言中的类似框架进行了比较。 deque的基本结构采用了每个deque使用一个(尽管可调整大小)数组的通用方案,以及两个索引:顶索引就像一个基于数组的堆栈指针,在push和pop时改变。基本索引仅通过take进行修改。由于FJTaskRunner操作都与deque的具体细节密切相关(例如,fork只是调用push),因此该数据结构直接嵌入到类中,而不是作为单独的组件定义。 由于deque数组由多个线程访问,有时没有完全同步(见下文),但单个Java数组元素不能声明为volatile,因此每个数组元素实际上是对维护单个volatile引用的小转发对象的固定引用。这一决定最初是为了确保与Java内存规则的一致性,但它所涉及的间接级别最终提高了测试平台上的性能,可能是通过减少缓存争用来实现的来访问附近的元素,由于间接操作,这些元素在内存中分布得更大一些。 deque实现中的主要挑战是同步和避免同步。即使在具有优化的同步设施[2]的jvm上,为每个push和pop操作获取锁的需求也成为瓶颈。然而,Cilk[5]采用的策略的调整提供了基于以下观察的解决方案:
if (−−top >= base) ...
take pre−increments base:
if (++base < top) ...
在每一种情况下,他们必须检查这是否可能导致deque变成空通过比较两个索引。对于潜在的冲突使用一个非对称规则:pop重新检查状态,并在获得deque锁(与take持有的锁相同)后尝试继续,只有当deque真正为空时才退出。而take操作只是立即后退,通常然后试图从另一个受害者那里偷东西。这种不对称是与Cilk中使用的其他类似方案唯一的显著差异。 使用volatile修饰的索引还使pull操作能够在不同步的情况下继续进行,除非deque数组即将溢出,在这种情况下,它必须首先获得deque锁来调整数组的大小。否则,只需确保top只在deque数组槽被填充后更新,就可以抑制任何获取的干扰。 在初始实现之后,发现有几个jvm不符合Java内存模型[6]规则,该规则要求volatile字段对写入后进行准确的读操作。作为一种解决方案,将pop在锁定状态下重试的条件调整为在出现两个或更少的元素时触发,并且take操作添加了一个二级锁以确保内存屏障。只要所有者线程最多错过一个索引更改(这里用于在读取volatile字段时保持正常内存顺序的平台),并且只会导致很小的性能下降,这就足够了。
工作窃取框架中的工作线程对它们正在运行的程序的同步要求一无所知。它们只是generate, push, pop, take, manage任务的状态和执行任务。这种方案的简单性使得在所有线程都有大量工作时能够高效执行。然而,这种简化是以在没有足够工作时依赖启发式为代价的;例如,在启动一个main任务,在它完成后,和周围的全球完全停止同步点使用一些fork/join算法。 这里的主要问题是,当工作线程没有本地任务并且不能从其他线程窃取任务时该怎么办。如果程序运行在一个专用的多处理器上,那么人们就可以通过依赖忙碌等待旋转循环来窃取工作。然而,即使在这里,尝试窃取也会增加争用,这甚至会降低那些非空闲线程的速度(由于3.1节中的锁定协议)。此外,在此框架的更典型的使用上下文中,应该说服操作系统尝试运行其他不相关的可运行进程或线程。 在Java中实现这一点的工具很弱,没有保证(参见[6,7]),但通常在实践中是可以接受的(类似的技术在Hood[3]中描述)。如果线程无法从任何其他线程获取工作,则在尝试其他窃取之前降低其优先级,执行Thread.yield,并在其FJTaskRunnerGroup中将自己注册为inactive。如果所有其他人都不活动,它们都会阻止等待其他主要任务。否则,在给定数量的额外旋转之后,线程进入休眠阶段,在该阶段中,线程将休眠(最多100毫秒),而不是在两次窃取尝试之间屈服。这些强制sleep会导致程序出现人为的延迟,这些程序需要很长时间才能完成任务。但这似乎是最好的通用折衷方案。未来版本的框架可能会提供额外的控制方法,这样程序员就可以在影响性能时覆盖默认值。
在编译器和jvm的性能几乎持续改进的情况下,性能度量只是暂时的。然而,本节报告的度量揭示了框架的一些基本属性。 下表简要描述了七个fork/join测试程序的集合。这些程序是对util中可用的演示程序的修改。并发包。选择它们是为了显示可以在此框架内运行的问题类型的多样性,以及为一些常见的并行测试程序获得结果。
Program | Description |
---|---|
Fib | 第2节中显示的Fibonnaci程序使用参数47和粒度阈值13运行。 |
Integrate | 递归计算高斯求集公式(2*i-1)x^(2i-1),对1到5的奇数进行求和,从-47到48进行积分。 |
Micro | 最佳移动finder为棋盘游戏,运行与4步前的计算。 |
Sort | 亿个数字的归并/快速排序(基于Cilk的算法)。 |
MM | 乘以2048乘以2048的双精度矩阵。 |
LU | 分解4096x4096的双精度矩阵。 |
Jacobi | 带屏障的迭代松弛法:a上最近邻平均100步4096x4096的double矩阵。 |
在主要测试中,程序运行在一个30 CPU的Sun上在Solaris 7上运行Solaris生产1.2JVM(1.2.2_05发行版的早期版本)的Enterprise 10000。JVM使用环境参数运行,环境参数为线程映射选择“绑定线程”,内存参数在4.2节中讨论。下面报道的其他一些测量是在4 CPU的Sun Enterprise 450上运行的。
程序运行时使用非常大的输入参数,以最小化计时器粒度和JVM预热工件。通过在启动计时器之前运行初始问题集,可以避免其他一些预热效果。大多数数据是三次运行的中值,但有些数据(包括4.2 - 4.4节中的大多数后续测量数据)仅反映单次运行,因此有些噪声。
可伸缩性的度量计算是通过大小为1-30的工作线程组来执行相同的问题而获得的。无法知道JVM是否总在可用时将每个线程映射到不同的CPU。虽然这两种方法都没有证据,但是将新线程映射到cpu的滞后可能会随着线程数量的增加而增加,并且/或在不同的测试程序中系统地变化。
但是,通常情况下,结果表明增加线程数量会可靠地增加所使用的cpu数量。 加速的名称为Time-n / Time1。集成程序的总体加速速度是最好的(30个线程加速28.2)。最糟糕的是LU分解程序(30个线程加速15.35)。 另一种度量可伸缩性的方法是任务率,即执行单个任务(可以是递归步骤,也可以是叶步)所花费的平均时间。下图显示了单个仪表化运行捕获任务速率的数据。理想情况下,每个线程在单位时间内处理的任务数量应该是恒定的。它们通常会随着线程数量的增加而略微减少,这表明存在一些可伸缩性限制。注意,在任务率上存在相当大的差异,这反映了任务粒度上的差异。最小的任务大小在Fib中可以看到,即使阈值设置为13,它在运行时每秒也会生成和执行280万个任务,当运行30个线程的时候。
四个因素似乎可以解释在那些没有线性规模的程序的加速尾部脱落。其中三个对于任何并行框架都是常见的,但是我们将从惟一的一个开始FJTask(相对于Cilk等),GC效果。
在许多方面,现代GC工具都是fork/join框架的完美匹配:这些程序可以生成大量的任务,几乎所有的任务在执行后都会迅速变成垃圾。在任何给定时间,确定性fork/join程序最多只需要p(其中p是线程的数量)乘以这些程序[10]的顺序版本所需的记录空间。 分代半空间复制收集器(包括JVM中用于这些度量的收集器——参见[1])很好地处理了这个问题,因为它们只遍历和复制非垃圾单元。通过这样做,它们规避了手工并行内存管理中最棘手的问题之一,即跟踪由一个线程分配但随后被另一个线程使用的内存。垃圾收集器不知道内存的来源,所以不需要处理这类问题。 作为分代复制收集总体优越性的一个简单指标,如果在该JVM上禁用了分代复制阶段(在这种情况下,JVM完全依赖于标记-清除),则Fib的四线程运行在使用本文报告的主要实验中使用的内存设置时将在9.1秒内运行。
然而,当以如此高的速度分配内存,以至于必须几乎连续地停止线程以执行收集时,这些GC机制就变成了伸缩性问题。下图显示了三种内存设置之间的加速差异(此JVM支持可选参数来设置内存参数):使用默认的4兆字节半步,使用64兆字节半步,并将内存大小扩展为线程数(2 + 2p兆字节)。在更小的半空间中,停止线程和收集垃圾的开销会随着额外线程导致的垃圾生成速率的攀升而增大。 根据这些结果,在所有其他测试运行中使用了64m半空间。更好的策略应该是根据每个测试中的线程数量来扩展内存量。(如图所示,这会使所有的加速看起来更线性一些)。另外,或者另外,程序特定的任务粒度阈值可以随着线程数量的增加而成比例地增加。
有四个测试程序创建并操作非常大的共享数组或矩阵:对数字排序,对矩阵进行乘法、分解或执行松弛。其中,排序可能是对必须在处理器间移动数据的后果最敏感的,从而将整个系统的内存带宽聚合起来。为了帮助确定这些影响的性质,排序程序被重新划分为四个版本,bytes, shorts, ints, longs分别进行排序。每个版本只在0-255的范围内排序数据确保它们在其他方面是相等的。数据元素越宽,内存流量就越多。 结果显示,增加的内存流量导致较差的速度,尽管他们没有提供明确的证据,tail−offs是唯一的原因。 元素宽度也影响绝对性能。例如,只使用一个线程,对字节进行排序需要122.5秒,而对long进行排序需要242.4秒。
正如3.2节所讨论的,工作窃取框架有时会在处理频繁的全局同步任务时遇到问题。工作线程继续轮询更多的工作,即使没有任何工作,从而产生争用,在FJTask中,有时甚至迫使线程进入空闲睡眠。 Jacobi程序说明了一些结果问题。这个程序执行100个步骤,在每一步中,所有单元都根据一个简单的最近邻平均公式进行更新。一个全局(基于树的)屏障将每个步骤分隔开。为了确定同步效果的大小,这个程序的一个版本是每10步才同步的。缩放差异显示了当前策略的影响,并表明需要在该框架的未来版本中包含其他方法,以允许程序员覆盖默认参数和策略。(但是请注意,此图可能稍微夸大了纯同步效果,因为10步版本也可能保持更大的任务局部性。
与其他fork/join框架一样,FJTask针对工作线程本地使用它们创建的绝大多数任务的情况进行了优化。如果没有这样做,性能可能会受到影响,原因有两个:
如图所示,在大多数程序中,被窃取任务的相对数量最多只有几个百分点。然而,随着线程数量的增加,LU和MM程序会在工作负载中产生更大的不平衡(因此偷取的东西也相对更多)。对这些程序进行一些算法调整可能会减少这种影响,从而导致更好的加速。
不可能对跨语言和框架的程序执行权威的或者甚至是非常有意义的度量。但是,部分度量指标至少可以显示出FJTask框架相对于用其他语言编写的类似框架的一些基本优势和局限性(这里是C和c++)。下面的图表比较了基于或类似于提供的Cilk, Hood, Stackthreads, and/or Filaments distributions的程序的性能。这些操作都是基于4线程的4-CPU的450企业版。:为了避免重新配置其他框架或它们的测试程序,所有测试都使用比上面使用的更小的问题集运行。所有的结果代表最好的三运行,使用编译器和运行时设置,似乎提供了最快的时间。Fib运行时没有任何粒度阈值;也就是说,隐式阈值为1。(Filaments Fib的prune设置为1024,这使得它的行为更类似于其他版本。) 通过不同的框架和测试程序用类似的方法将一个线程加速到四个线程(介于3.0和4.0之间),因此,附图关注的是绝对表现的差异。然而,因为所有这些框架的多线程方面都是快速的,大多数这些差异反映了不同的编译器在应用程序特定代码质量上的差异,优化开关和配置参数。事实上,不同的选择比这里使用可能产生几乎任何相对性能排名的框架在许多高性能应用程序。 通常(基于JVM),FJTask主要对数组和矩阵进行浮点计算的程序上执行结果会更差。尽管jvm在不断改进,但与强大的后端优化器相比,它们仍然不总是具有C和c++程序的竞争力。虽然没有在这个图表中显示,所有程序的FJTask版本在编译时没有启用优化,比这些其他框架中的程序版本运行得更快,一些非正式测试表明,大多数剩余的差异是由于数组边界检查和相关的运行时义务造成的。当然,这些问题吸引了JVM和编译器开发人员的大量关注和努力。对于计算密集型程序,可以观察到的代码质量差异可能会减少。
本文证明了在纯Java中支持可移植的、高效的、可伸缩的并行处理是可能的,并且为程序员提供了一个方便的API,他们可以通过遵循一些简单的设计规则和设计模式(如[7]中介绍和讨论的)来利用这个框架。这里测量的示例程序的性能特征为框架的用户提供了进一步的指导,并对框架本身的一些潜在改进提出了建议。 即使可伸缩性结果只是基于一个单独的JVM,一些主要的实证结果应该更普遍地适用:
除了增量的改进之外,这个框架的未来工作可能还包括构建有用的应用程序(与演示和测试相反)、在生产程序负载下的后续评估、在不同jvm上的度量以及开发用于多处理器集群的扩展。
这项工作部分得到sun实验室的合作研究资助。感谢Ole Agesen, Dave Detlefs,Christine Flood, Alex Garthwaite和SUN 实验室的Steve Heller 以及SUN实验室Java主题组的建议、帮助和评论。David Holmes, Ole Agesen, Keith Randall, Kenjiro Taura和匿名审稿人对本文的草稿提供了有用的评论。比尔·皮格指出了read−after−write的局限性这在第3.1节讨论的jvm。非常特别感谢Dave Dice预留时间在30多个企业进行测试。