负载平衡与并行性能

负载平衡与并行性能 (PDF 199KB)

摘要

实现线程之间应用工作负载平衡是确保出色性能的关键。 实现负载平衡主要是为了最大限度缩短线程的闲置时间。 在尽量减少工作任务分配开销的情况下,在所有线程之间平均分配工作负载,可以减少浪费在不能进行运算的闲置线程上的时间,进而可显著提高性能。 然而,实现完美的负载平衡绝非易事,这主要取决于应用并行性、工作负载、线程数量、负载平衡策略和线程实施情况。

本文是“英特尔多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。
 

背景

在执行计算任务时拥有一个闲置内核无异于拥有一项废弃资源,在该内核上实施有效并行操作会延长线程化应用的整体运行时间。 内核处于闲置状态的原因有很多种,需要从内存或 I/O 中取出便是其中一个原因。 尽管完全避免内核进入闲置状态不太可能,但编程人员仍然可以采取一些措施来缩短闲置时间,如采用重叠 I/O、内存预取的方式或重新排列数据访问模式的顺序,提高高速缓存利用率。

同样,闲置线程在执行多线程任务时也相当于废弃资源。 分配给各线程的工作量不一样会导致名为“负载不均衡”的状况发生。 这种不均衡程度越大,处于闲置状态的线程就会越多,完成计算任务所需的时间便会越长。 分配给可用线程的各部分计算任务越均衡,完成整个计算任务的时间将会越短。

例如,一项任务由十二项独立任务组成,完成这些独立任务所需要的时间分别是: {10, 6, 4, 4, 2, 2, 2, 2, 1, 1, 1, 1}。 假设现有四个线程共同承担这项计算任务,最简单的任务分配法是按照上述时间排列顺序为每个线程分配三项任务, 即线程 0 完成所有分配的任务需要 20 个时间单元(10+6+4),线程 1 需要 8 个时间单元(4+2+2),线程 2 需要 5 个时间单元(2+2+1),线程 3 则只需要 3 个时间单元(1+1+1)。 图 1(a)展示了这一任务分配状态,由此可见,完成全部十二项任务总共需要 20 个时间单元(完成整个任务所需时间应以最后完成的子任务用时为准)。
 

 

图 1. 四个线程之间的任务分配示例。


您也可以采用一种更合理的任务分配法,即线程 0 完成一项任务所需时间是 {10},线程 1 完成四项任务所需时间是 {4, 2, 1, 1},线程 2 完成三项任务所需时间是 {6, 1, 1},而线程 3 完成四项任务所需时间是 {4, 2, 2, 2}(如图 1(b)所示)。 这样安排时间的优势是完成整个任务只需 10 个时间单元,四个线程中只有两个线程分别闲置了 2 个时间单元。
 

建议

如果完成所有任务所需时间长度相同,则在可用线程之间实施静态任务分配(即将整个任务划分为相同数量的子任务组并将每个子任务组分配给每个线程)是一种简单且合理的解决方案。 但实际上就算事先已知道所有任务的执行时间长度,要找到一个在线程间实施最佳任务分配的方法仍然十分困难。 如果各项子任务的执行时间长度不同,则可能需要采用一种更加动态的任务分配法来分配线程任务。

在默认情况下,OpenMP* 向线程调度迭代的策略是静态调度(如果不是则会另外注明)。 当迭代之间的工作负载不同以及负载模式不可预知时,采用动态调度迭代的方法可以更好地平衡负载。 动态调度和指数调度这两种静态调度替代方案都会通过 schedule 子句指定。 在动态调度下,迭代数据块分配给线程;一旦分配完成,线程会申请获得一个新的迭代数据块。 Schedule 子句的可选数据块参数会指明用于动态调度的迭代数据块固定尺寸。
 

#pragma omp parallel for schedule(dynamic, 5)
  for (i = 0; i < n; i++)
  {
    unknown_amount_of_work(i);
  }

指数调度最初会向线程分配大型迭代数据块;分配给所需线程的迭代数量会随着未分配迭代集的减少而减少。 由于分配模式不同,指数调度的开销往往少于动态调度。 Schedule 子句的可选数据块参数会指明在指数调度下一个数据块中所分配的迭代最低数量。
 

#pragma omp parallel for schedule(guided, 8)
  for (i = 0; i < n; i++)
  {
    uneven_amount_of_work(i);
  }

其中一个特例是迭代之间的工作负载单调递增或递减。 例如,下三角形矩阵中每行元素数量会以正则表达式的形式增加。 在此类情况下,通过静态调度设置一个相对较低的数据块尺寸(创建大量数据块/任务)可能有助于实现良好的负载平衡,同时还不会产生采用动态调度或指数调度所导致的开销。
 

#pragma omp parallel for schedule(static, 4)
  for (i = 0; i < n; i++)
  {
    process_lower_triangular_row(i);
  }

如果调度策略不明显,采用 运行时 调度可以随意改变数据块尺寸和调度类型,而无需对程序进行重新编译。

在使用英特尔® 线程构建模块(英特尔® TBB)的parallel_for 算法时,调度程序会将迭代空间划分为可分配给线程的小型任务。 一旦某些迭代的计算用时比其它迭代长,英特尔® TBB 调度程序能够从线程中动态“盗取”任务,以便更好地实现线程间的工作负载平衡。

显式线程模式(如 Windows* 线程、Pthreads* 和 Java* 线程)无法自动为线程调度一系列独立任务。 编程人员必须根据需要将这种能力编入应用程序中。 静态调度任务是一种十分简单、直接的调度方法, 而动态调度任务则可通过两种相关的方法轻松予以实施: 生产者/消费者(Producer/Consumer)模式和老板/工人(Boss/Worker)模式。 在前一个模式下,一个线程(生产者)会将任务置入共享队列结构中,而消费者线程会根据需要清除要处理的任务。 然而在非必要的情况下,如果部分预处理需在消费者线程获取任务之前完成,这时通常采用生产商/消费者模式。

在老板/工人模式下,工人线程与老板线程会在需要直接分配的工作任务增多时会合。 在划分任务十分简单的情况下(如将各类指数分配给数组进行处理),可以采用具备适宜同步化程度的全局计数器来取代单独的老板线程, 即工人线程访问当前数值并针对下一条需要承担更多工作任务的线程调整(可能增加)计数器。

无论采用哪种任务调度模式,您都必须使用适量的线程和正确的线程组合,以确保这些肩负工作任务的线程执行所需计算任务,而不是进入闲置状态。 例如,如果消费者线程有时处于闲置状态,则您需要减少消费者线程数量或可能需要再配备一条生产者线程。 采用何种解决方案主要取决于算法以及需要分配的任务数量与执行时间长度。
 

使用指南

所有动态任务调度方法都将因分配任务而产生一定的开销。 将独立的小型任务整合成为一项可分配的工作任务有助于减少上述开销;相应地,如果采用 OpenMP schedule 子句,您需要在任务内设置代表最少迭代次数的非默认数据块尺寸。 将一项任务划分成多项计算任务的最佳方法取决于需要完成的计算量、线程的数量以及执行计算任务时可以使用的其它资源。
 

更多资源

现代代码社区

Clay Breshears, 《并发的艺术》, O'Reilly Media, Inc., 2009年。

英特尔® 线程构建模块

面向开放源代码的英特尔线程构建模块

James Reinders, 英特尔线程构建模块: 针对多核处理器并行机制配置 C++, O'Reilly Media, Inc. Sebastopol, CA, 2007 年。

M. Ben-Ari, 《并行与分布式编程的原则》,(第二版),Addison-Wesley,2006 年。

有关编译器优化的更完整信息,请参阅优化通知