借助英特尔® Parallel Amplifier 修复线程失衡

借助英特尔® Parallel Amplifier 修复线程失衡 (PDF 302KB)

摘要

线程应用性能的限制因素之一是负载不平衡。 平衡线程间的工作量对应用性能来说至关重要。 负载平衡的主要目的是最大限度地削减线程的闲置时间,同时以最小的工作分享开销帮助所有线程平均分享工作负载。 英特尔® Parallel Amplifier 为英特尔® Parallel Studio 的一个产品,主要用于微调并行应用,进而实现多核处理器的最佳性能。 英特尔 Parallel Amplifier 能够快速找出多核性能的瓶颈,并帮助开发人员加速识别和修复此类问题的进程。 实现完美的负载平衡绝非易事,这主要取决于应用、负载和线程执行内的并行能力。

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

背景

一般来说,线程独立任务(负载)的映射和调度通常以两种形式发生:静态和动态。 如果所有任务的长度相同,那么应将可用线程任务进行简单的静态分工,将总任务量平等划分并分配给每个线程,此为最佳解决方案。 或者说,当个别任务长度不同时,向线程动态分配任务为最佳解决方案。

英特尔 Parallel Amplifier 提供的同步分析将测量应用是如何利用某一特定系统上的可用核。 在分析过程中,Parallel Amplifier 收集并提供关于多少线程正在活动的信息,活动线程即正在运行或排队的线程,它们并没有在一个指定的等待或阻碍 API 进行等候。 正在运行的线程数量对应着一个应用的并发级别。 通过比较并发等级和处理器的数量,英特尔 Parallel Amplifier 将应用如何利用系统中的处理器进行分类。

为了展示英特尔 Parallel Amplifier 如何识别热点与负载不平衡问题,下面为一个 C 语言编写的示例程序。 此程序计算一个基于三位距离的微粒系统的潜在能量。 这是为一个多线程应用,它使用本机的 Win32* 线程,并创建由 NUM_THREADS 变量指定的线程数量。 本次讨论的范围不包括 Win32 线程的介绍、线程方法或如何引入线程。 此次将主要演示英特尔 Parallel Amplifier 如何帮助识别负载不平衡,促进可扩展并行应用的开发。

for (i = 0; i < NUM_THREADS; i++)
{
  bounds[0][i] = i * (NPARTS / NUM_THREADS);
  bounds[1][i] = (i + 1) * (NPARTS / NUM_THREADS);
}
for (j = 0; j < NUM_THREADS; j++)
{
  tNum[j] = j;
  tHandle[j] = CreateThread(NULL,0,tPoolComputePot,&tNum[j],0,NULL);
}


DWORD WINAPI tPoolComputePot (LPVOID pArg) {
  int tid = *(int *)pArg;
  while (!done)
  {
    WaitForSingleObject (bSignal[tid], INFINITE);
    computePot (tid);
    SetEvent (eSignal[tid]);
  }
  return 0;
}

下面给出的例程中,每个线程均可并行执行。 在 computePot例程中,每个线程均可使用由线程的分配标识号 (tid) 索引的存储边界,进而修复使用颗粒的起始和终止范围。 在每个线程将其迭代空间(开始与结束值)初始化后,开始计算颗粒的潜在能力。

void computePot (int tid) {
  int i, j, start, end;
  double lPot = 0.0;
  double distx, disty, distz, dist;
  start = bounds[0][tid];
  end = bounds[1][tid];

  for (i = start; i < end; i++)
  {
    for (j = 0; j < i-1; j++)
    {
      distx = pow ((r[0][j] - r[0][i]), 2);
      disty = pow ((r[1][j] - r[1][i]), 2);
      distz = pow ((r[2][j] - r[2][i]), 2);
      dist = sqrt (distx + disty + distz);
      lPot += 1.0 / dist;
    }
  }
  gPot[tid] = lPot;
}

热点分析用于找出应用中的热点,从而帮助采用适合的函数。 该应用运行所消耗的总时间大约为 15.4 秒,其运行于一个基于英特尔® 酷睿™ 2 四核处理器的单路系统上。 热点分析表明,computePot 例程为主要热点,消耗了大量 CPU 时间(23.331 秒)。 深究 computePot 函数源显示了该热点(图 1 )的主要贡献者。
 

图 1. 热点分析的源代码视图


并发性分析结果显示,相同例程的 CPU 利用率较差(图 2),应用程序平均使用 2.28 个内核。 主要热点并没有利用全部的可用内核;CPU 的利用率大部分时间或者较差(仅使用一个内核)或者一般(使用 2-3 个内核)。 接下来的问题是,是否存在一些负载不平衡,导致 CPU 利用率较差。 最简单的方法是选择函数-线程-自下而上数或是线程-函数-自下而上数来作为新的粒度,如图 4 所示。

 

 

图 2. 并发性分析结果。

 

 

 

 

图 3. 并发性结果摘要视图。


并发分析时间值结构对应下列应用类型:

  • 空闲: 程序中所有线程都在等待;没有线程正在运行。
  • 较差: 在默认情况下,较差利用率通常定义为线程数量增至目标并发的 50%。
  • 较好: 在默认情况下,较好利用率是指线程数量为目标并发的 51-85%。
  • 理想: 在默认情况下,理想利用率是指线程数量为目标并发的 86-115%。

 

图 4. 并发分析结构显示出函数线程组


图 4 表明,并行执行这个例程的 4 个工作线程的工作量并不相同,因此可能导致负载不平衡和较差的 CPU 利用率。 这种行为将阻止多线程应用进行相应的扩展。 仔细观察源代码可以发现,主例程内的外部循环根据创建在线程池内(起始边界 3D [0][标识号],结束边界 3D [1][标识号])的工作线程数量,静态划分颗粒迭代。 这个例程内的内部循环使用外部索引作为退出条件。 这样一来,外部循环使用的颗粒数量越多,内部循环执行迭代次数也就越多。 因此,每对颗粒仅能对潜在的能源计算做一次有用功。 这种静态分布明确分配了不同的计算量。

修复负载不平衡的一种办法是更加动态地向线程分配颗粒。 例如,不再分配连续颗粒组,因为在原始版本中,每个线程均是以受线程识别号 (tid) 索引的颗粒而开始运行,因此能够计算所有颗粒,其颗粒数量与线程数量不相同。 例如,当运行两个线程时,一个线程处理偶数颗粒,另一个线程处理奇数颗粒。

 

void computePot(int tid) {
  int i, j;
  double lPot = 0.0;
  double distx, disty, distz, dist;
  for(i=tid; i<NPARTS; i+= NUM_THREADS ) { //<-for( i=start; i<end; i++ )
    for( j=0; j<i-1; j++ ) {
    distx = pow( (r[0][j] - r[0][i]), 2 );
    disty = pow( (r[1][j] - r[1][i]), 2 );
    distz = pow( (r[2][j] - r[2][i]), 2 );
    dist = sqrt( distx + disty + distz );
    lPot += 1.0 / dist;
    }
  }
  gPot[tid] = lPot;

此次更改之后分析应用并发性,显示得出热点函数目前正在高效利用所有可用内核。
 

 

图 5. 更改之后的并发分析结构


概要面板(图 6)显示了一个快速结果回顾和更改影响。 耗时从大约 15. 4 秒减少至 9.0 秒,CPU 平均利用率从 2.28 增长至 3.9. 帮助工作线程执行平等的计算量,速度提高了 1.7 倍,用时减少了大约 41.5%。

 

 

 

 

 

 

图 6. 负载平衡版本概要视图


概要和并行结构表示,应用程序的新版本几乎使用了所有可用内核,并且串行代码段(较差使用率)和未充分使用段均已消失。

 

 

 

建议

目前存在许多种线程方法,每个方法均提供了处理线程任务分布的不同机制。 一些常用线程方法包括以下几个:

 

 

  • 显式或本地线程方法(例如,Win32 和 POSIX* 线程)
  • 线程抽象化
    • 英特尔® 线程构建模块
    • OpenMP*

显式线程方法(例如Win32 和 POSIX* 线程)没有任何能力来自动为线程安排一系列独立任务。 编程人员必须根据需要将这种能力编入应用程序中。 正如这个例子所示,静态的任务调度是最简单直接的方法。 而动态调度任务则可通过两种相关的方法轻松予以实施: 生产商/消费者和管理人员和工作人员 前者,一个或多个线程(生产商)列队放置任务,而消费者线程根据需要进行任务处理。 生产者/消费者模式通常适用于在任务分配给消费者线程之前需要进行预处理之时(但也并非一定得采用这种模式)。 管理人员/工作人员模式,工作线程与管理线程结合,每当工作量增多时,可直接接受分配任务。

无论采用哪种模式,必须使用正确的线程数量和组合,以确保执行所需计算任务的线程并没有闲置。 然而,单个管理线程易于编码并可确保任务的合理分配,消费者线程有时闲置,可能需要消费者数量减少或是生产商线程增多。 采用何种解决方案主要取决于算法以及需要分配的任务数量与执行时间长度。

OpenMP 提供了四种适用于迭代工作共享结构的调度方法。 通常默认使用静态的迭代调度。 当每迭代工作量不同,并且模式无法估计时,动态的迭代调度能更好的平衡工作负载。

当运行动态调度时,可能出现微构架伪共享问题。 伪共享属于降低性能的模式访问问题。 当两个或多个线程重复写入相同的缓存行(64 位英特尔构架 )时,将会出现伪共享。 当线程之间动态分配工作负载时,应特别予以注意。

英特尔® 线程构建模块(英特尔® TBB)是一种基于运行的并发编程模式,由基于模板的运行库组成,可帮助开发人员充分利用多核处理器的最新性能。 借助并发集合与并行计算,英特尔® TBB 帮助开发人员编写可扩展应用。 它提供一种采取工作窃取机制的分而治之的调度算法,因此开发人员不必担心各种调度算法。 借助工作窃取机制,英特尔 TBB 可动态地平衡工作线程之间的任务。
 

更多资源

现代代码社区

OpenMP* 规范

英特尔® 线程构建模块

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

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

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