粒度与并行性能

粒度与并行性能 (PDF 270KB)

摘要

实现出色并行性能的关键是选择适合应用的粒度。 粒度是指并行任务的实际工作量。 如果粒度太细,则并行性能会因通信开销增加而受到影响。 如果粒度太粗,则并行性能会因负载不均衡而受到影响。 为确保实现最佳并行性能,开发人员应确定适合并行任务的粒度(通常粒度越大越好),同时还应避免负载不均衡和通信开销增加的情况发生。

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

背景

多线程应用的并行任务工作量大小(粒度)会对其并行性能产生很大影响。 在分解一项应用使之适用于多线程处理时,开发人员通常采用的方法是从逻辑上将问题分割成尽量多的并行任务,或者 在并行任务内根据共享数据与执行顺序决定进行哪些必要的通信。 由于分割任务、将任务分配给线程以及在任务之间进行数据通信(共享)涉及到一定的成本,开发人员通常需要聚合或整合分割的任务,用于避免随之产生的开销,尽量实现应用高效运行。 通过聚合分割的任务可确定并行任务的最佳粒度。

粒度通常与工作负载在线程之间的均衡程度有关。 尽管平衡大量小型任务的工作负载更容易,但这样做却可能导致通信和同步等方面的并行开销过高。此时,开发人员可以通过将小型任务整合成一项任务,增加每项任务的粒度(工作量)来减少并行开销。 您可以借助英特尔® Parallel Amplifier 等工具确定应用的适宜粒度。

本文将列举下列代码示例向您展示如何通过减少通信开销和确定线程的适宜粒度来提高并行程序的性能。 本文所列出的所有代码示例均为质数计数算法,该算法采用一套简单的强力测试方法来执行,让每个潜在质数除以所有可能存在的因数直到除数被找到或该数字经证实为质数为止。 由于正奇数可以被(4k+1)或(4k+3)(其中k = 0)整除,因此使用下列代码还可以计算各种形式的质数数量。 所列举的代码示例将计算从 300 到 100 万的所有质数数量。

下列为采用 OpenMP* 的并行版代码:
 

#pragma omp parallel 
{ int j, limit, prime;
#pragma omp for schedule(dynamic, 1) 
  for(i = 3; i <= 1000000; i += 2) {
    limit = (int) sqrt((float)i) + 1;
    prime = 1; // assume number is prime
    j = 3;
    while (prime && (j <= limit)) {
      if (i%j == 0) prime = 0;
      j += 2;
    }
  
    if (prime) {
      #pragma omp critical
      {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
      }
    }
  }
}

运行该代码的通信开销(表现为同步开销)较高,且个别任务的规模太小,不足以分配给多个线程。 在循环内部存在一个可用于为增加计数变量提供安全机制的关键区域。 这一关键区域可将同步与锁定开销添加至并行循环(如图 1 中英特尔® Parallel Amplifier 视图所示)。
 


图 1. 锁定与等待分析结果显示,OpenMP* 关键区域是同步开销产生的原因。

在一个大型数据集内,根据数值增加计数变量是削减开销的常用方法。 通过清除关键区域和添加 OpenMP reduction 子句可避免生成锁定与同步开销:
 

#pragma omp parallel 
{
  int j, limit, prime;
  #pragma omp for schedule(dynamic, 1) 
    reduction(+:numPrimes,numP41,numP43) 
  for(i = 3; i &;lt;= 1000000; i += 2) {
    limit = (int) sqrt((float)i) + 1;
    prime = 1;  // assume number is prime
    j = 3;
    while (prime && (j &;lt;= limit))
    {
      if (i%j == 0) prime = 0;
      j += 2;
    }

    if (prime)
    {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
    }
  }
}

根据循环所执行的迭代次数在循环体内清除关键区域可以使迭代执行速度提升几个数量级。 然而,运行上述代码可能仍然会产生一些并行开销。 这些开销主要由工作量过小的任务导致。 Schedule(dynamic,1)子句规定调度程序一次可向每个线程动态分配一次迭代(数据块)。 每个辅助线程会处理一次迭代,接着返回调度程序,并同步获取另外一次迭代。 通过增加数据块大小,我们可以增加分配给线程的每项任务的工作量,进而缩减每个线程与调度程序实现同步所需的时间。

尽管采用上述方法可以提高并行性能,但请务必记住:过度增加粒度会导致负载不均衡(上文已提及)。 例如,在下列代码中将数据块大小增加到 10000:
 

#pragma omp parallel
{
  int j, limit, prime;
  #pragma omp for schedule(dynamic, 100000) 
    reduction(+:numPrimes, numP41, numP43)
  for(i = 3; i <= 1000000; i += 2)
  {
    limit = (int) sqrt((float)i) + 1;
    prime = 1; // assume number is prime
    j = 3;
    while (prime && (j <= limit))
    {
      if (i%j == 0) prime = 0;
      j += 2;
    }

    if (prime)
    {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
    }
  }
}

通过 Parallel Amplifier 对上述代码的执行情况进行的分析显示,使用四个线程完成的计算量分布不均衡(如图 2 所示)。 在该计算示例中,每个数据块的工作量各不相同,可用于指派任务的数据块太少(四个线程瓜分十个数据块),因而才会导致负载不均衡的情况发生。 随着潜在质数的值不断增加(从 for 循环开始),需要运行更多迭代来让质数除以尽可能多的因数(在 while循环中)。 这样一来,每个数据块完成全部工作量所需的 while 循环迭代比旧数据块要多。
 


图 2. 并发性分析结果显示,每个线程所使用的执行时间存在不均衡性。

在为程序选择合适的粒度时应采用更适宜的工作量大小(100)。 此外,连续任务之间存在的工作量差异在旧数据块上表现得不太明显,通过采用静态调度程序替代动态调度程序可以进一步消除并行开销。 下列代码显示改写 schedule 子句将最终削减该代码片断的开销,最大限度提高整体并行速度。
 

#pragma omp parallel
{
  int j, limit, prime;
  #pragma for schedule(static, 100) 
    reduction(+:numPrimes, numP41, numP43)
  for(i = 3; i <= 1000000; i += 2)
  {
    limit = (int) sqrt((float)i) + 1;
    prime = 1;  // assume number is prime
    j = 3;
    while (prime && (j <= limit))
    {
      if (i%j == 0) prime = 0;
      j += 2;
    }
  
    if (prime)
    {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
    }
  }
}

建议

多线程代码的并行性能取决于其粒度:如何在线程之间分配工作任务以及如何在这些线程之间进行通信。 下面就通过调整粒度提高并行性能提供一些指导:
 

  • 了解您的应用
    • 了解需要并行处理的应用的各个部分正在执行的工作任务数量。
    • 了解应用的通信要求。 同步化是一种常见的通信形式,但实施同步化需要考虑在各个内存层面(高速缓存、主内存等)上进行讯息传递和数据共享的开销。
  • 了解您的平台和线程模式
    • 了解采用线程模式在目标平台上实施并行和同步化的成本。
    • 确保应用的每项并行任务工作量比线程负担大。
    • 最大限度减少同步操作和同步化成本。
    • 使用英特尔® 线程构建模块并行算法中的分区对象 (partitioner object),支持任务调度程序为每项工作任务选择合适的粒度以及实现执行线程上的负载平衡。
  • 了解您的工具
    • 在英特尔® Parallel Amplifier“锁定与等待”分析中,锁定、同步和并行开销高是通信开销过高的标志。
    • 在英特尔® Parallel Amplifier“并发性”分析中,负载不均衡是粒度过大或线程间任务分配需要优化的标志。

使用指南

尽管上述代码示例多次提及 OpenMP,但本文所提供的所有建议和指导均适用于 Windows 线程和 POSIX* 线程等其它线程模式。 所有线程模式都会产生与其各类功能(如实施并行化、锁定、关键区域、讯息传递等)相关的开销。本文在此建议在保持负载平衡的情况下减少通信开销和增加每个线程的工作量,这一建议适用于所有线程模式。 但因不同线程模式所产生的不同成本可能导致开发人员选择不同的粒度。
 

更多资源

现代代码社区

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

英特尔® 线程构建模块

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

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

Ding-Kai Chen 等,"并行系统上同步化与粒度的影响",第 17 届年度计算机架构国际会议记录,1990 年,美国华盛顿西雅图。

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