罗曼定律与多核CPU的快速处理

作者:Roman Shaposhnik

电脑慢得像寒冷天气里的糖蜜一样,多年来一直如此。硬件行业正在努力,但除非软件行业也跟上并采取行动,否则电脑的速度不会变得更快。如今,处理速度更多地与多核有关,而不是GHz,软件需要适应。

我将大胆地提出我自己的硬件多核趋势定律:芯片上的核心数量将每三年翻一番。我这个预测是否会像戈登·摩尔那样准确还有待观察,但目前看来情况不错。随着Intel和AMD推出四核系统,以及Sun推出首款名为Rock的十六核CPU,进一步突破界限,软件行业必须扪心自问的问题是:“我们是否准备好让所有这些执行线程为我们做有用的工作?” 我坚信我们还没有准备好。一种新的范式尚待发明,我们并不真正知道它应该是什么样子。然而,我们所知道的是,用Herb Sutter的话来说,“自OO革命以来,软件开发领域最大的变革正在敲门,它的名字是并发性。”

硬件并行性将渗透到的第一个软件层是您操作系统的内核。而且,据我所知,只有两个内核有能力应对这一挑战:Solaris和Linux。如果有任何Mac OS X——或者我敢说,Windows——的粉丝,我有两句话要对你们说:“256处理器系统”和“完全公平调度器”。现在,拥有一个能够有效地将大量进程多路复用到大量硬件线程的内核,其价值仅取决于您实际并行运行大量单独进程的需求。而且,尽管这对于ISP或配置人员来说是梦想成真,但在我的笔记本电脑上,我很少同时运行超过四个真正活跃的进程。我真正的需求是每个单独的应用程序都能够利用底层硬件并行性。这就是一个基本的硬件概念渗透到另一个软件层——应用层的方式。最终,作为用户层软件开发人员,我们别无选择,只能完全接受并行的世界观。那些讨厌的硬件人员让我们别无选择。

在本文的其余部分,我假设您至少有一个运行Linux内核2.6.x的双核系统,并且您已在/opt/sun/sunstudio中安装了Sun Studio Express并将其添加到您的PATH中,如下所示

PATH=/opt/sun/sunstudio12/bin:$PATH    

我的目标是解释您可以采取哪些实际步骤来教您的旧串行代码一些多核技巧。

逐步向您的串行应用程序添加并行性需要迭代三个基本步骤

  1. 识别并行性。

  2. 表达并行性。

  3. 测量和观察。

而且,即使前两个步骤听起来最令人兴奋,我还是不得不强调步骤3的重要性。并行编程很困难,并且充满了意想不到的性能陷阱。而且,没有其他方法可以确定您的代码是否变快,只能用数字来证明。好消息是这并没有那么困难。如果您碰巧主要为Intel架构开发,则可以使用类似于FFMPEG的START_TIMER/STOP_TIMER的代码进行微基准测试

START_TIMER
do_interesting_stuff();
STOP_TIMER("do_interesting_stuff_tag")  

此外,还有Sun Studio Performance analyzer用于观察宏观行为。您还可以使用诸如Intel的VTune甚至time(1)之类的工具,但无论您做什么,都要确保性能回归测试与常规回归测试一样成为您测试例程的一部分。您有做回归测试,对吧?

在现有应用程序中识别并行性通常从查找具有数据并行特征、任务并行特征的位置以及找出将两者联系起来的调度模型开始。数据并行性通常可以在处理大型全局或静态数据集的应用程序中找到(想想音频、视频和图像处理、游戏引擎和渲染软件)。另一方面,任务并行性主要适用于分支定界计算(想想国际象棋求解器,当一组任务被要求执行类似的计算时,但如果一个任务找到解决方案,则无需等待其他任务)。

一旦您确定了应用程序中所有潜在的并行性来源,您就必须决定使用哪种编程技术来表达它。对于用C或C++编写的应用程序,最常用的方法恰好是使用POSIX线程进行显式并行化。这种方法已经存在了几十年,大多数开发人员通常对此有所了解。另一方面,鉴于其固有的复杂性以及它不再是唯一的选择,我将跳过它。

让我们看一下这段示例代码,这恰好是一个非常简单的例程,用于计算2到N之间有多少个质数

 1  #include <stdio.h>
 2  #include <stdlib.h>
 3  #include <math.h>
 4  #include <omp.h>
 5	
 6  /* pflag[v] == 1 if and only if v is a prime number   */
 7  char *pflag;
 8	
 9  int is_prime(int v)
10  {
11      int i;
12      int bound = floor(sqrt(v)) + 1;
13	    
14      for (i = 2; i < bound; i++) {
15          if (v % i == 0) { 
16              pflag[v] = 0;
17              return 0; /* v is NOT a prime number */
18          }
19      }
20      return 1; /* v is a prime number */  
21  }
22	
23  int main(int argc, char **argv)
24  {
25      int i;
26      int total = 0;
27      int N = atoi(argv[1]);
28      int primes[N];     /* array of prime numbers */ 
29      pflag=(char*)alloca(N);
30	
31      for (i = 2; i < N; i++) {
32          pflag[i] = 1; /* all numbers are prime until... */   
33      }                 /* ...proven otherwise            */
34	    
35      for (i = 2; i < N; i++) { /* let the testing begin! */
36          if ( is_prime(i) ) {
37              primes[total] = i;
38              total++;
39          }
40  }
41	 
42  printf("Number of prime numbers between 2 and %d: %d\n",
43         N, total);
44	
45  return 0;
46  }

诚然,这段代码很傻(有些人甚至会说很弱智),但让我们假装它是一个真实的应用程序。在这种情况下,我们肯定会从尽可能多的自动化中受益。而且,如果您考虑一下,没有比编译器更适合帮助我们的工具了——毕竟,它已经负责理解代码的语义以便执行优化。理想情况下,我们需要的应该是一个可以与我们对话的编译器,帮助我们更好地理解源代码,并根据这些信息进行合理的调整。以下是Sun Studio 12让您做到这一点的方式

$ cc -g -fast prime.c -o prime 
$ er_src prime 
.....  
Source loop below has tag L3 
35.     for (i = 2; i < N; i++) { /* let the testing begin! */

Function is_prime inlined from source file prime.c into the code
for the following line.  1 loops inlined 
Loop in function is_prime, line 14 has tag L4
36.          if ( is_prime(i) ) {

终于!您的编译器实际上用通俗易懂的人类语言向您解释了它对源代码应用了哪些转换以使其更快(-fast)。不仅如此,它还识别并标记了所有关键区域(例如循环),您稍后可以导航和检查这些区域以了解并行化的潜力。识别并行性变得更容易了。但是表达并行性呢?将其中一些也委托给编译器是否完全不可能?毕竟,我们太懒得使用POSIX线程了(此外,无论如何,它们就像并行编程的GOTO一样)。好消息是,使用正确的编译器,这可能的。但是,在我们开始之前,让我们记住我们的“三步并行化程序”中的第三步,并建立性能基线

$ cc -fast prime.c -o prime
$ collect ./prime 2000000
Creating experiment database test.1.er ...
Number of prime numbers between 2 and 2000000: 148933
$ er_print -statistics test.1.er
                   Execution for entire program

                                  Start Label: Total
                                    End Label: Total
                            Start Time (sec.): 0.028
                              End Time (sec.): 3.364
                              Duration (sec.): 3.336
                        Total LWP Time (sec.): 3.337
                       Average number of LWPs: 1.000
....................................................

-fast命令行选项指示Sun Studio C编译器为编译发生的同一架构生成尽可能快的代码。最后两个命令实际上运行生成的executable并报告main函数的运行时统计信息。现在我们知道,无论我们对源代码做什么,结果都不应慢于3.336秒。考虑到这一点,让我们尝试要求编译器不仅在识别并行性(-xloopinfo)方面做到最好,而且在表达并行性(-xautopar)方面也做到最好

$ cc -fast -xloopinfo -xautopar prime.c -o prime
"prime.c", line 14: not parallelized, loop has multiple exits
"prime.c", line 14: not parallelized, loop has multiple exits 
                    (inlined loop)
"prime.c", line 31: PARALLELIZED, and serial version generated
"prime.c", line 35: not parallelized, unsafe dependence (total)

因此,仅使用两个额外的命令行选项,编译器就足够聪明地并行化了第31行的循环(-xautopar),并且足够诚实地解释了为什么其他两个循环(第14行和第35行)不容易并行化(-xloopinfo)。这非常令人印象深刻,但让我们看看它是否给我们带来了任何加速

$ export OMP_NUM_THREADS=4
$ collect ./prime 2000000
Creating experiment database test.2.er ...
Number of prime numbers between 2 and 2000000: 148933
$  er_print -statistics test.2.er | grep Duration
                               Duration (sec.): 3.331

很好。它没有变慢(尽管也没有明显变快),但话又说回来,我们不必对源代码做任何事情。编译器为我们做了一切(除了通过将OMP_NUM_THREADS环境变量设置为4来让运行时系统最多使用四个线程)。或者它做了吗?第35行的那个循环呢?它看起来并不比第31行的循环更复杂。似乎编译器过于保守了,我们需要介入并帮助它一下。这次,让我们用OpenMP表达并行性。

OpenMP的正式(且枯燥的)定义指出,它是一个“API,支持在所有架构(包括UNIX平台和Windows NT平台)上使用C/C++和Fortran进行多平台共享内存并行编程”。就我个人而言,我喜欢将OpenMP视为一种帮助编译器在数据依赖关系失控时利用应用程序中数据并行性的方法。简而言之,OpenMP是您在-xautopar抱怨时使用的东西。鉴于此,对于C和C++,OpenMP通过#pragmas表示,添加这些提示非常安全(尽管确保建议的并行操作没有并发问题仍然是您的责任)。与任何#pragma一样,如果编译器不理解它,它将跳过它。(在撰写本文时,以下免费提供的Linux编译器支持OpenMP 2.5:Intel Compilers、GCC 4.2和Sun Studio 12。)

那么,我们如何使用OpenMP来增强编译器对第35行循环的信心呢?只需将以下pragma添加到第34行

34 #pragma omp parallel for
35 for (i = 2; i < N; i++) { /* let the testing begin! */
36     if ( is_prime(i) ) {

并且,不要忘记将-xopenmp添加到命令行选项集中

$ cc -fast -xloopinfo -xautopar -xopenmp prime.c -o prime
"prime.c", line 14: not parallelized, loop has multiple exits
"prime.c", line 14: not parallelized, loop has multiple exits 
                    (inlined loop)
"prime.c", line 31: PARALLELIZED, and serial version generated
"prime.c", line 35: PARALLELIZED, user pragma used

好极了!现在我们应用程序中的三个循环中有两个已完全并行化。让我们看看它运行得有多快

$ collect ./prime 2000000
Creating experiment database test.3.er ...
Number of prime numbers between 2 and 2000000: 146764
$ er_print -statistics test.3.er | grep Duration 
                              Duration (sec.): 1.132

毫无疑问,294%是一个令人印象深刻的加速,但是为什么我们丢失了一些质数呢?现在我们报告了146,764个质数,而不是148,933个。也许编译器保守的做法是正确的,不应该并行化那个讨厌的循环。我们应该回去删除我们的OpenMP pragma吗?还不是时候。即使我们刚刚在同一应用程序的并行化版本中发现了一个错误(这恰恰表明引入错误是多么容易,以及当您尝试并行化任何内容时,回归测试变得多么重要),我们仍然走在正确的轨道上。问题是,我们如何找到实际问题?

并行编程的麻烦在于它使应用程序的某些部分具有不确定性。这意味着现在您必须处理以前不必处理的各种问题,例如竞争条件,而死锁和资源分配问题是主要的难题。实际上,引入的不确定性的数量使POSIX线程在大多数实际情况下非常脆弱——以至于一位关键的并行计算研究人员Edward A. Lee教授在他的文章“线程的问题”中做出了一个非常可怕的预测

我推测,大多数多线程通用应用程序实际上都充满了并发错误,以至于随着多核架构变得司空见惯,这些错误将开始表现为系统故障。这种情况对于计算机供应商来说是黯淡的:他们的下一代机器将广为人知,因为许多程序在这些机器上崩溃。

正如您所看到的,OpenMP即使引入的不确定性比POSIX线程少得多,也仍然不是万能药。毕竟,即使我们对其的简单使用也足以引入一个错误。似乎无论我们如何表达并行性,我们需要的都是一种可以帮助我们发现并发错误的工具。

据我所知,Linux上有两个免费提供的此类工具:Intel Thread Checker和Sun Studio Thread Analyzer。以下是如何使用后者来对抗数据竞争(请注意,我们需要一个额外的编译时命令行选项-xinstrument=datarace才能进行线程分析,并且我们必须通过指定-r on来要求collect记录数据竞争事件)

$ cc -fast -xloopinfo -xautopar -xopenmp -xinstrument=datarace 
 ↪prime.c -o prime   
$ collect -r on ./prime 2000000
Creating experiment database tha.1.er ...
Number of prime numbers between 2 and 2000000: 148933
$ er_print -races tha.1.er
No race information recorded in experiments

奇怪。我们不仅得到了正确的结果,而且线程分析器似乎也没有注意到任何异常。它坏了吗?不完全是。您看,使并发错误如此难以追踪的原因是,它们中的大多数都是间歇性的。与不确定行为的大多数表现形式一样,它们会随着应用程序的运行方式、系统上运行的其他内容以及您是否使用诸如传统调试器之类的工具而出现和消失。线程分析器仅报告实际发生的问题。好吧,如果第一次不成功

$ collect -r on ./prime 2000000
Creating experiment database tha.2.er ...
Number of prime numbers between 2 and 2000000: 114833
$ er_print -races tha.2.er
Total Races:  2 Experiment:  tha.2.er

Race #1, Vaddr: (Multiple Addresses)
   Access 1: Write, main -- MP doall from line 34 
    [_$d1B34.main] + 0x00000172, line 37 in "prime.c"
   Access 2: Write, main -- MP doall from line 34 
    [_$d1B34.main] + 0x00000172, line 37 in "prime.c"
Total Traces: 1

Race #2, Vaddr: 0xbffff28c
   Access 1: Write, main -- MP doall from line 34 
    [_$d1B34.main] + 0x00000189, line 38 in "prime.c"
   Access 2: Write, main -- MP doall from line 34 
    [_$d1B34.main] + 0x00000189, line 38 in "prime.c"
Total Traces: 1

答对了!我们重现了这个错误,我们的工具尽职尽责地报告了竞争条件发生的实际位置:第37行和第38行。当两个线程找到质数并尝试更新primes数组和total变量时,事情就会出错——这是竞争条件的教科书式示例。但是,这很容易修复。我们必须序列化进入这两行代码的线程。我们可以用OpenMP做到这一点吗?当然可以

37 #pragma omp critical
38 {
39    primes[total] = i;
40    total++;
41 }    

有了这个,让我们看看最终的加速是多少

$ cc -fast -xloopinfo -xautopar -xopenmp prime.c -o prime
$ collect ./prime 2000000
Creating experiment database test.4.er ...
Number of prime numbers between 2 and 2000000: 148933
$ er_print -statistics test.4.er | grep Duration 
                              Duration (sec.): 1.130

速度提高了2.95倍。对于15分钟的工作和四行额外的OpenMP pragma给编译器提示来说,还不错!

一些遗留问题

OpenMP和-xautopar对于C语言似乎工作得很好,但是C++呢?它们是否能与现代C++的使用方式(其中散布着泛型和模板元编程)很好地结合?简短的回答是,没有简短的回答。但是,让我们通过以下现代C++[滥]用的示例亲自看看

#include <vector> 
#include <iterator> 
#include <algorithm> 
#include <iostream> 
void standard_input_sorter() {
   using namespace std; 
   vector<string> v;
   copy(istream_iterator<string>(cin), istream_iterator<string>(),
        back_inserter(v));
   sort(v.begin(), v.end());
}

$ CC -c -fast -xloopinfo -xautopar -xopenmp -library=stlport4 sorter.cc

上面产生了一个相当长的抱怨列表,解释了为什么STLport库的特定部分无法并行化。这里的关键问题是,C++的某些领域默认情况下非常难以并行化。即使使用OpenMP,诸如并发容器访问之类的事情也弊大于利。我们必须重写STL吗?嗯,似乎Intel几乎做到了。Intel一直在开发它所谓的Thread Building Blocks (TBB) C++库,其成名之处恰恰在于——使现代C++并行化。不妨尝试一下,看看它是否适合您。如果您对利用任务并行性感兴趣,我尤其推荐它。但是,话又说回来,即使是最简单的示例(例如计算斐波那契数),TBB所使用的现代C++的数量也真的让我感到难过。即将到来的OpenMP 3.0标准中定义的任务似乎不那么令人担忧。

结论

硬件中存在向并发性的基本趋势。多核系统现在正在进入笔记本电脑和台式机。不幸的是,除非软件工程师开始考虑这些趋势,否则现代硬件在使单个应用程序运行得更快方面几乎无能为力。当然,并行编程是困难且容易出错的,但是借助最新的工具和编程技术,它的意义远不止POSIX线程。诚然,本文仅触及了可用内容的表面。希望本文提供的信息足以成为大多数读者开始认真思考应用程序中并发性的转折点。我们的高清摄像机需要它,地球上的每个游戏玩家也需要它。

资源

摩尔定律: www.intel.com/technology/mooreslaw/index.htm

Sun Studio Express: developers.sun.com/sunstudio/downloads/express/index.jsp

FFMPEG: ffmpeg.mplayerhq.hu

Intel VTune: www.intel.com/cd/software/products/asmo-na/eng/239145.htm

Intel Thread Checker: www.intel.com/cd/software/products/asmo-na/eng/291669.htm

TotalView Debugger: www.totalviewtech.com/index.htm

POSIX线程: www.mhpcc.edu/training/workshop2/pthreads/MAIN.html

OpenMP: www.openmp.org

在游戏中使用OpenMP的有效方法: https://www.cmpevents.com/Sessions/GD/EffectiveUse.ppt

Intel Thread Building Blocks: osstbb.intel.com

Cilk: supertech.csail.mit.edu/cilk

“线程的问题”: www.eecs.berkeley.edu/Pubs/TechRpts/2006/EECS-2006-1.pdf

“免费午餐结束了:软件中并发性的根本转变”: gotw.ca/publications/concurrency-ddj.htm

Roman Shaposhnik于1994年开始了他的编译器职业生涯,当时他不得不为他刚刚发明的编程语言编写翻译器(这种语言太奇怪了,没有人愿意做这项工作)。他第一次接触UNIX是Slackware 3.0,从那时起他就迷上了它。目前,他在Sun Microsystems的Developer Products Group工作。人们经常发现他正在思考如何让电脑更快,又不会让应用程序开发人员发疯的问题。他在blogs.sun.com/rvs上运行一个博客,可以通过电子邮件rvs@sun.com与他联系。

加载Disqus评论