+ All Categories
Home > Documents > 英特尔® C++编译器 - Intel® Software · 2 legal information information in this document is...

英特尔® C++编译器 - Intel® Software · 2 legal information information in this document is...

Date post: 30-Aug-2019
Category:
Upload: others
View: 0 times
Download: 0 times
Share this document with a friend
67
1 英特尔 ® C++ 编译器 Cilk 语言扩展
Transcript

1

英特尔® C++编译器

Cilk 语言扩展

2

Legal Information

INFORMATION IN THIS DOCUMENT IS PROVIDED IN CONNECTION WITH INTEL(R)

PRODUCTS. NO LICENSE, EXPRESS OR IMPLIED, BY ESTOPPEL OR OTHERWISE,

TO ANY INTELLECTUAL PROPERTY RIGHTS IS GRANTED BY THIS DOCUMENT.

EXCEPT AS PROVIDED IN INTEL'S TERMS AND CONDITIONS OF SALE FOR SUCH

PRODUCTS, INTEL ASSUMES NO LIABILITY WHATSOEVER, AND INTEL DISCLAIMS

ANY EXPRESS OR IMPLIED WARRANTY, RELATING TO SALE AND/OR USE OF

INTEL PRODUCTS INCLUDING LIABILITY OR WARRANTIES RELATING TO FITNESS

FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR INFRINGEMENT OF ANY

PATENT, COPYRIGHT OR OTHER INTELLECTUAL PROPERTY RIGHT.

UNLESS OTHERWISE AGREED IN WRITING BY INTEL, THE INTEL PRODUCTS ARE

NOT DESIGNED NOR INTENDED FOR ANY APPLICATION IN WHICH THE FAILURE

OF THE INTEL PRODUCT COULD CREATE A SITUATION WHERE PERSONAL

INJURY OR DEATH MAY OCCUR.

Intel may make changes to specifications and product descriptions at any time, without

notice. Designers must not rely on the absence or characteristics of any features or

instructions marked "reserved" or "undefined." Intel reserves these for future definition

and shall have no responsibility whatsoever for conflicts or incompatibilities arising from

future changes to them. The information here is subject to change without notice. Do not

finalize a design with this information.

The products described in this document may contain design defects or errors known as

errata which may cause the product to deviate from published specifications. Current

characterized errata are available on request.

Contact your local Intel sales office or your distributor to obtain the latest specifications

and before placing your product order. Copies of documents which have an order number

and are referenced in this document, or other Intel literature, may be obtained by calling

1-800-548-4725, or by visiting Intel's Web Site.

Intel processor numbers are not a measure of performance. Processor numbers

differentiate features within each processor family, not across different processor families.

See http://www.intel.com/products/processor_number for details.

This document contains information on products in the design phase of development.

BunnyPeople, Celeron, Celeron Inside, Centrino, Centrino Atom, Centrino Atom Inside,

Centrino Inside, Centrino logo, Core Inside, FlashFile, i960, InstantIP, Intel, Intel logo,

Intel386, Intel486, IntelDX2, IntelDX4, IntelSX2, Intel Atom, Intel Atom Inside, Intel Core,

3

Intel Inside, Intel Inside logo, Intel. Leap ahead., Intel. Leap ahead. logo, Intel NetBurst,

Intel NetMerge, Intel NetStructure, Intel SingleDriver, Intel SpeedStep, Intel StrataFlash,

Intel Viiv, Intel vPro, Intel XScale, Itanium, Itanium Inside, MCS, MMX, Oplus, OverDrive,

PDCharm, Pentium, Pentium Inside, skoool, Sound Mark, The Journey Inside, Viiv Inside,

vPro Inside, VTune, Xeon, and Xeon Inside are trademarks of Intel Corporation in the

U.S. and other countries.

* Other names and brands may be claimed as the property of others.

Copyright . 2010, Intel Corporation. All rights reserved.

4

英特尔® C++编译器 Cilk 语言扩展 ............................................................................................... 1

Legal Information ............................................................................................................................ 2

1. 介绍............................................................................................................................................. 7

1.1 目标读者 ............................................................................................................................... 7

1.2 前提条件 ............................................................................................................................... 7

1.3 排字约定 ............................................................................................................................... 7

1.4 附加资源和信息 ................................................................................................................... 7

2. 新手上路 ..................................................................................................................................... 8

2.1 编译运行一个 Cilk用例 ..................................................................................................... 8

2.1.1 编译生成 qsort ........................................................................................................... 8

2.1.2 执行 qsort ................................................................................................................... 9

2.1.3 观察多核系统中的加速 ............................................................................................... 9

2.2 改写一个 C++程序 .............................................................................................................. 10

2.2.1 从一个串行程序开始 ................................................................................................. 11

2.2.2 使用_Cilk_spawn加入并行性 .................................................................................. 12

2.2.3 编译,执行和测试 ..................................................................................................... 14

3. 编译、运行和调试 Cilk 程序 ................................................................................................. 15

3.1 设定工作线程数量 ............................................................................................................. 15

3.1.1 环境变量 ..................................................................................................................... 15

3.1.2 程序控制 ..................................................................................................................... 15

3.2 串行化................................................................................................................................. 15

3.2.1 如何创建串行化 ......................................................................................................... 16

3.3 调试策略 ............................................................................................................................. 16

4. Cilk 语言特性说明 ................................................................................................................. 17

5. Cilk关键字 .............................................................................................................................. 18

5.1 cilk_spawn ......................................................................................................................... 18

5.2 cilk_sync ........................................................................................................................... 19

5.3 cilk_for ............................................................................................................................. 19

5.3.1 串行或并行结构的 cilk_for ................................................................................... 20

5.3.2 衍生发生在串行循环内的串行或并行结构 ............................................................. 21

5.3.3 cilk_for 循环体 ....................................................................................................... 21

5.3.4 cilk_for 类型要求 ................................................................................................... 22

5.3.5 cilk_for限制 ............................................................................................................ 23

5.3.6 cilk_for的粒度 ........................................................................................................ 24

5

5.4预处理宏 .............................................................................................................................. 25

6. Cilk 执行模型 ......................................................................................................................... 26

6.1 Strands ............................................................................................................................... 26

6.2 工时和跨度 ......................................................................................................................... 27

6.3 strand到工作线程的映射 ................................................................................................ 29

6.4 异常处理 ............................................................................................................................. 31

7. Reducers ................................................................................................................................... 33

7.1 使用 Reducers – 一个简单的例子 ................................................................................ 33

7.2 Reducers是如何工作的 .................................................................................................... 35

7.2.1 无密取方式 ................................................................................................................. 36

7.2.2 密取方式 ..................................................................................................................... 36

7.2.3 使用 reducer_opadd<>的实例 .................................................................................. 36

7.2.4 延迟语义 ..................................................................................................................... 37

7.2.5 操作的安全性 ............................................................................................................. 37

7.3 安全性和性能考虑 ............................................................................................................. 37

7.3.1 安全性 ......................................................................................................................... 38

7.3.2 确定性 ......................................................................................................................... 38

7.3.3 性能 ............................................................................................................................. 38

7.4 Reducer 库 ......................................................................................................................... 38

7.5 使用 Reducers – 另一个例子 ........................................................................................ 40

7.5.1 字符串 Reducer .......................................................................................................... 40

7.5.2 List reducer (使用用户定义类型) ....................................................................... 41

7.5.3 递归函数中的 Reducers ............................................................................................ 42

8. 操作系统相关事项 ................................................................................................................... 43

8.1在 Cilk程序上使用其它工具 ............................................................................................ 43

8.2 和操作系统线程的一般交互 ............................................................................................. 43

8.3 Microsoft Foundation Class 和 Cilk程序 ................................................................ 44

9. Cilk运行系统 API ................................................................................................................... 46

9.1 __cilkrts_set_param ....................................................................................................... 46

9.2 __cilkrts_get_nworkers ................................................................................................. 46

9.3 __cilkrts_get_worker_number .............................................................................................. 46

9.4 __cilkrts_get_total_workers .................................................................................................. 47

10. 理解竞争条件 ......................................................................................................................... 48

10.1 数据竞争 ........................................................................................................................... 48

6

10.2 良性竞争 ........................................................................................................................... 49

10.3 解决数据竞争 ................................................................................................................... 49

10.3.1 纠正程序中的错误 ................................................................................................... 50

10.3.2 使用局部变量而不是全局变量 ............................................................................... 50

11. 使用锁的注意事项 ................................................................................................................. 53

11.1 锁引起的确定性竞争 ....................................................................................................... 53

11.2 死锁................................................................................................................................... 54

11.3锁竞争对并行性的影响 .................................................................................................... 55

11.4跨越 strand边界的锁 ...................................................................................................... 55

12. Cilk程序性能方面的注意事项 ............................................................................................ 57

12.1 粒度................................................................................................................................... 57

12.2 首先优化串行程序 ........................................................................................................... 57

12.3 程序和程序段计时 ........................................................................................................... 58

12.4 常见性能隐患 ................................................................................................................... 58

12.5 高速缓存效率和内存带宽 ............................................................................................... 59

12.6 伪共享 ............................................................................................................................... 59

12.7 内存分配瓶颈 ................................................................................................................... 60

Appendix A. 怎样写一个新的 Reducer ...................................................................................... 61

Reducer的组件 .......................................................................................................................... 61

恒等值 ........................................................................................................................................ 62

The Monoid................................................................................................................................. 62

写 Reducer – 一个“Holder”的例子 .................................................................................... 63

附录 B: 参考读物 .......................................................................................................................... 66

Cilk 总体说明: ......................................................................................................................... 66

串行语义: ................................................................................................................................... 66

例子: ........................................................................................................................................ 66

竞争条件: ................................................................................................................................... 66

7

1. 介绍

本书描述了如何使用英特尔 C/C++编译器中 Cilk 语言扩展。它帮你在新的或已有

的 C/C++程序中加入并行操作。本版本支持编译运行于 Microsoft Windows和 Linux 操

作系统,IA-32和 Intel 64 架构的程序。

本书的大部分内容适合所有的平台,不同之处会明示。

Cilk语言扩展是英特尔® Cilk Plus的组成部分。

1.1 目标读者

本书为用 Cilk 语言扩展来引入并行的 C/C++程序开发人员而写。读者需有 C/C++

编程经验,具有专家级学识则更佳。

1.2 前提条件

预知安装指令和系统需求,请参见版本说明。

安装 Intel Parallel Composer 2011, 然后至少成功编译一个例子程序,验证安装准

确,熟悉编译器和其他工具。

1.3 排字约定

等宽字体表示命令,代码和程序输出。

斜体字表示该词在术语表中有定义

路径名在Windows操作系统以反斜线("\")分割,在Linux操作系统中以斜线("/")

分割,在两操作系统都可时,以斜线("/")分割。

1.4 附加资源和信息

附录 B: 参考读物单包括了附加资源。

8

2. 新手上路

英特尔® C++编译器的新功能 Cilk语言扩展(以下简称”Cilk”) 为C/C++语言

增加了细粒度任务支持,使对新的和现有软件增加并行性来充分发掘多处理器能力变得

更加容易。

Cilk的设计特别适合但不限于“二分法”的算法。它将问题分解成可以独立完成

的子问题(任务),然后再将这些执行结果合并起来。另外,对于那些常常使用“二

分法”算法的递归函数, Cilk同样也支持的很好。

任务既可以在不同的函数里实现,也可以在一个迭代的循环中完成。Cilk的关键

字能有效地标识可并行执行的函数调用和循环,同时,Cilk的运行环境能有效率地将

这些任务调度到空闲的处理器上运行。

本章节将包含如下内容:

编译生成代码,运行和调试一个Cilk案例程序

用Cilk扩展功能改写一个简单的C++程序

编译生成改写的程序,测试其竞争条件,评估其并行运行性能。

在接下来的章节中,术语worker表示在一个Cilk程序里,一个用于执行任务的操

作系统线程

2.1 编译运行一个 Cilk 用例

每一个用例都有一个单独的目录,详情请查阅Release Notes文件中的描述。这

里用qsort用例作说明。

假设你在一台多核系统上安装了Intel® Parallel Composer 2011。如果是一台

单核系统,你可以编译和调试该用例,但是性能上没有任何提高。

2.1.1 编译生成 qsort

本章节,我们使用缺省设置。有关完整和详细的编译选项描述请参阅“编译,执

行和调试”一章。

在Window*系统中,Intel® C++编译器的命令行执行文件名是icl,在Linux*

系统中的文件名是icc。

Linux*系统

1. 将目录切换到qsort目录上(cd INSTALLDIR/examples/qsort)

2. 执行make命令。如果make执行失败,检查并确认在PATH环境变量的设置中包

9

含了INSTALLDIR/bin目录才能搜索到Cilk

3. 将编译生成的可执行文件qsort存放在当前目录里。

Window*系统 微软的Visual Studio*用户:打开项目(如:qsort.sln)然后

编译生成发布版本。生成的可执行文件将存放在

EXDIR\qsort\Release\qsort.exe。

如果使用命令行编译生成qsort.exe文件,请使用cl命令:icl qsort.cpp 如

果你在编译中遇到了错误信息,请检查环境变量的设置是否正确。方法是:使用鼠标

右击项目名,然后选择 Intel Parallel Composer 2011>Use Intel C++。

2.1.2 执行 qsort

首先,qsort的执行要正确。在没有参数的情况下,执行程序将创建并排序一个

10,000,000整数型数组。下面的符号 > 表示命令提示符:

>qsort

Sorting 10000000 integers

5.641 seconds

Sort succeeded.

2.1.3 观察多核系统中的加速

通常,Cilk程序会查询操作系统信息,尽可能多地使用多核处理器。 当然,也可

以通过设置CILK_NWORKERS环境变量来控制workers的数量。

下面是一个在8核系统中的执行结果, 执行速度的加速程度与应用程序的并行性

和有多少有效处理器相关。

Linux*系统:

>CILK_NWORKERS=1 qsort

Sorting 10000000 integers

2.909 seconds

Sort succeeded.

>CILK_NWORKERS=2 qsort

Sorting 10000000 integers

1.468 seconds

Sort succeeded.

>CILK_NWORKERS=4 qsort

Sorting 10000000 integers

0.798 seconds

Sort succeeded.

>CILK_NWORKERS=8 qsort

Sorting 10000000 integers

0.438 seconds

Sort succeeded.

10

Window*命令行:

>set CILK_NWORKERS=1

>qsort

Sorting 10000000 integers

2.909 seconds

Sort succeeded.

>set CILK_NWORKERS=2

>qsort

Sorting 10000000 integers

1.468 seconds

Sort succeeded.

>set CILK_NWORKERS=4

>qsort

Sorting 10000000 integers

0.798 seconds

Sort succeeded.

>set CILK_NWORKERS=8

>qsort

Sorting 10000000 integers

0.438 seconds

Sort succeeded.

微软Visual Studio*:

用鼠标右击项目,然后在相关菜单里选择Properties(属性)。

在配置目录里,选择Intel Debugging然后在Number of Cilk Threads属性中

设置使用workers的参数值。

2.2 改写一个 C++程序

下面描述了如何使用Cilk创建一个并行程序的操作步骤:

1. 通常,一开始要有一个已经实现了所希望进行并行化的基本功能或算法的串行

C++程序。要确保该串行程序是正确无误的。因为虽然在串行程序中的任何bug

仍会在并行程序中发生,但是这些bug在并行程序中将更加难辨别和调试。

2. 找出程序中可以从并行操作中获益的代码段。那些执行时间相对长,并且能独立

执行的操作是首选修改目标。

3. 用三个Cilk关键字标明那些能并行执行的任务:

• _Cilk_spawn(或cilk_spawn, 如果程序包含了<cilk/cilk.h>文件)表示

对一个函数(”子”)的调用,能与调用者(”父”)一起被并行处理。

• _Cilk_sync(或cilk_sync, 如果程序包含了<cilk/cilk.h>文件)表示所

有衍生的”子”函数完成后,才能继续后续代码执行。

11

• _Cilk_for(或cilk_for, 如果程序包含了<cilk/cilk.h>文件)表示一个

循环包含的迭代可以被并行执行。

4. 编译程序

• Window* 操作系统:选择使用icl命令行工具,或者在微软的Visual Studio*

下进行编译。如果使用Vistual Studio*上开发,确保已经从Intel Parallel

Composer相关菜单中选择了 ” Use Intel C++”

• Linux*操作系统:使用icc命令

5. 执行程序。如果没有竞争条件,并行程序将输出和串行程序相同的结果。

6. 通过使用reducer, 锁, 或者重写代码解决任何由于竞争条件而产生的冲突问题。

这里用一个排序程序作为例子来演示上述步骤。

2.2.1 从一个串行程序开始

下面以Quicksort为例,演示如何用Cilk编写一个并行化程序。

使用函数名 sample_qsort以避免和标准C函数库中的qsort函数的冲突。例中的

一些语句行被删除,但是保留了相应的行号。

9 #include <algorithm>

10

11 #include <iostream>

12 #include <iterator>

13 #include <functional>

14

15 // Sort the range between begin and end.

16 // "end" is one past the final element in the range.

19 // This is pure C++ code before Cilk conversion.

20

21 void sample_qsort(int * begin, int * end)

22 {

23 if (begin != end) {

24 --end; // Exclude last element (pivot)

25 int * middle = std::partition(begin, end,

26 std::bind2nd(std::less<int>(),*end));

28 std::swap(*end, *middle); // pivot to middle

29 sample_qsort(begin, middle);

30 sample_qsort(++middle, ++end); // Exclude pivot

31 }

32 }

33

34 // A simple test harness

35 int qmain(int n)

36 {

37 int *a = new int[n];

38

12

39 for (int i = 0; i < n; ++i)

40 a[i] = i;

41

42 std::random_shuffle(a, a + n);

43 std::cout << "Sorting " << n << " integers"

<< std::endl;

44

45 sample_qsort(a, a + n);

48

49 // Confirm that a is sorted and that each element

// contains the index.

50 for (int i = 0; i < n-1; ++i) {

51 if ( a[i] >= a[i+1] || a[i] != i ) {

52 std::cout << "Sort failed at location i="

<< i << " a[i] = "

53 << a[i] << " a[i+1] = " << a[i+1]

<< std::endl;

54 delete[] a;

55 return 1;

56 }

57 }

58 std::cout << "Sort succeeded." << std::endl;

59 delete[] a;

60 return 0;

61 }

62

63 int main(int argc, char* argv[])

64 {

65 int n = 10*1000*1000;

66 if (argc > 1)

67 n = std::atoi(argv[1]);

68

69 return qmain(n);

70 }

2.2.2 使用_Cilk_spawn 加入并行性

现在,可以开始在qsort程序中引入并行。

Cilk_spawn关键字表示一个函数(“子”)可以和其后语句(“父”)并行执行。

关键字允许但不要求并行操作。当系统有多个处理器可用时Cilk会动态地决定哪些操

作会被并行执行。_Cilk_sync语句表示它将等待同一函数中的所有_Cilk_spawn请求

被处理完成后,该函数才能继续执行。_Cilk_sync语句不会影响在其他函数中衍生的

并行strand。

21 void sample_qsort(int * begin, int * end)

13

22 {

23 if (begin != end) {

24 --end; // Exclude last element (pivot)

25 int * middle = std::partition(begin, end,

26 std::bind2nd(std::less<int>(),*end));

28 std::swap(*end, *middle); // pivot to middle

29 _Cilk_spawn sample_qsort(begin, middle);

30 sample_qsort(++middle, ++end); // Exclude pivot

31 _Cilk_sync;

32 }

33 }

通过在程序中包含头文件<cilk/cilk.h>,前面的用例可以用简化的Cilk关键字形

式来重新编写。新的宏提供了没有下划线的小写方式的命名。下面的程序行显示了用

简化命名的cilk_spawn和cilk_sync。本书接下来的部分都将使用这种简化的命名。

19 include <cilk/cilk.h>

21 void sample_qsort(int * begin, int * end)

22 {

23 if (begin != end) {

24 --end; // Exclude last element (pivot)

25 int * middle = std::partition(begin, end,

26 std::bind2nd(std::less<int>(),*end));

28 std::swap(*end, *middle); // pivot to middle

29 cilk_spawn sample_qsort(begin, middle);

30 sample_qsort(++middle, ++end); // Exclude pivot

31 cilk_sync;

32 }

33 }

在上述两例中,第29行的语句将衍生出对sample_qsort的一次可异步执行的递

归调用。这样,当sample_qsort在第30行再次被调用时,第29行的语句可能还没有

执行完成。在第31行的cilk_sync语句表示在同一函数里的所有cilk_spawn请求执行

完成前,该函数不能继续执行。

在每一个函数的末尾都有一个系统隐含的cilk_sync用来等待当前函数衍生的所有

任务完成,所以第31行的cilk_sync语句是多余的,但是这里为了使代码更清晰而加

入了这行。

上面的改动采用了一个典型的二分法策略来实现递归算法的并行化。在每一层的

递归调用中,会产生一个两路的并行;“父”strand(第29行)继续执行当前的函数;

而“子”strand执行其他递归调用。这种递归调用能产生相当多的并行运算。

14

2.2.3 编译,执行和测试

完成上述改动后,现在编译生成并执行带有Cilk版本的qsort程序。

编译生成qsort,并运行程序:

Linux* 操作系统:

icc qsort.cpp -o qsort

Window* 操作系统命令行

icl qsort.cpp

微软Visual Studio*:

编译生成一个Release配置的版本程序。

2.2.3.1 在命令行执行 qsort 程序

>qsort

Sorting 10000000 integers

5.641 seconds

Sort succeeded.

2.2.3.2 观察多核系统中并行化的性能加速

通常,Cilk程序通过操作系统查询并使用所有可用的核。用户可以通过设置环境

变量CILK_NWORKERS的参数值控制工作线程的实际并行数。

下面分别显示在单核和双核系统中执行qsort的情况:

Linux* 操作系统命令行:

>CILK_NWORKERS=1 qsort

Sorting 10000000 integers

2.909 seconds

Sort succeeded.

>CILK_NWORKERS=2 qsort

Sorting 10000000 integers

1.468 seconds

Sort succeeded.

Window* 命令行:

>set CILK_NWORKERS=1

>qsort

Sorting 10000000 integers

2.909 seconds

Sort succeeded.

>set CILK_NWORKERS=2

>qsort

Sorting 10000000 integers

1.468 seconds

Sort succeeded.

15

3. 编译、运行和调试 Cilk 程序

本章说明了如何编译,运行和调试Cilk程序

3.1 设定工作线程数量

缺省地, 工作线程的数量被设定等同于运行系统的核的总数。 这样的缺省值设

定, 能够满足大多数情况下的要求。

通过程序逻辑控制, 或者环境变量, 你可以增加或者减少工作线程的数量。 你

可能希望使用比处理器核数量更少的工作线程数量, 运行测试, 或者为其它程序保留

资源。在某些情况下, 你也可能通过创建比已有处理器核数量更多的工作线程的办法

来超额使用。这种方法适用于, 当你的程序的工作线程必须等待锁释放, 或者你希望

在单个核的计算机上测试并行程序。

3.1.1 环境变量

你可以使用环境变量 CILK_NWORKERS 来指定工作线程的数量

Windows* 操作系统: set CILK_NWORKERS=4

Linux* 操作系统: export CILK_NWORKERS=4

3.1.2 程序控制

在程序中第一次调用包含衍生(cilk_spawn 或 cilk_for)的函数前, 你可以

通过如下系统调用来设置期望的工作线程数量

__cilkrts_set_param("nworkers","N"), 这里 N 是一个十进制数, 或者用

0x 或 0 开始的十六进制数或八进制数。 这个调用设定的工作线程数量将会取代

CILK_NWORKERS 环境变量设定的值。例如:

if (0!= __cilkrts_set_param("nworkers","4"))

{

printf(“Failed to set worker count\n”)

return;

}

3.2 串行化

Cilk语言扩展设计为具有串行语义。 也就是说, 每一个Cilk程序对应于一个等价

的C/C++程序。这样的C/C++程序被称作Cilk程序的串行化(Serialization)。

这种串行化对于Cilk程序的调试特别有帮助。

16

3.2.1 如何创建串行化

头文件 cilk_stub.h 包含了将Cilk关键字和系统库调用定义为对应的串行形

式的宏。在其它头文件前面引用 cilk_stub.h 就可以生成串行化。

Linux* 操作系统:

Intel 编译器提供了命令行选项来实现串行化。下面是两个等价的选项:

-cilk_serialize

-include cilk/cilk_stub.h

举例来说, 生成串行化的 reducer 范例, 只要编译 reducer.cpp (它包含 Cilk

关键字) :

icc -O2 -cilk_serialize -o reducer_serial reducer.cpp

Windows* 操作系统:

Intel 编译器提供了命令行选项来实现串行化。下面是两个等价的选项:

/Qcilk_serialize

/FI cilk/cilk_stub.h

举例来说, 生成串行化的 reducer 范例, 只要编译 reducer.cpp (它包含 Cilk

关键字) :

icl /O2 /Qcilk_serialize reducer.cpp

3.3 调试策略

调试一个并行程序往往比调试一个串行程序困难许多。Cilk的使用在设计上就尽

可能地简化并行调试的挑战。先从调试串行化开始。

请按照下述的这些原则最大程度地减少调试并行程序的问题:

• 如果你转换已有的 C/C++ 程序, 请先调试和测试串行的版本.

• 当你已经有了并行的 Cilk 程序, 测试和调试串行化版本. 基于串行的程序和串行化

的Cilk程序都是串行的C/C++程序, 你可以使用已有的串行调试工具和技巧。

• 你可以使用标准的调试器(Linux*下的gdb, 和Windows的Visual Studio* 调试器),

他们都能和Cilk程序很好地合作, 虽然某些时候结果难以解释。

• 如果你的程序在串行化或者只有一个Cilk工作线程时运行正常, 但是有多个Cilk工

作线程时不正确,你的程序可能存在数据竞争。如果确实如此,请继续如下工作:

• 重新组织代码, 消除竞争冲突

• 使用 Cilk reducer

• 使用互斥锁 (例如Intel® Threading Building Blocks 中提供了类似的一个

mutex 锁), 其它锁,或者原子操作

调试未经优化的程序可能会比较容易。 不使用优化, 例如取消函数内嵌, 调试

时可以得到更加准确的调用堆栈信息;此外, 编译器也不会试图重新排列指令执行顺

序和优化寄存器的使用。

17

4. Cilk 语言特性说明

Cilk提供了一系列相关的元素, 如: 关键字, 命令行选项, 环境变量和编译指

示。下表提供了说明

Cilk 元素 说明

关键字

_Cilk_spawn 修改函数调用说明, 告诉Cilk运行系统此函数会

(但不是必须)与调用者并行运行

_Cilk_sync 指出当前函数无法超越此位置,继续和它衍生出

去的儿子并行执行

_Cilk_for 指出一个循环允许不同的循环间隔并行运行; 它

是正常C/C++ for 循环的替代者

这个说明将循环分解成若干个包含一个或者多个

循环间隔的块。每一个块串行执行, 并且在循环

的执行过程中被衍生成块

编译指导

cilk grainsize 指定一个cilk_for循环的处理规模

预定义宏

__cilk 指定Cilk语言扩展的版本号信息。本次发布数值

设置为 200

环境变量

CILK_NWORKERS 指定工作线程的数量

编译器选项

/Qcilk-serialize, cilk_serialize 说明一个Cilk程序应该被串行化

/Qintel-extensions[-], -

[no]intel-extensions

启用或禁止 Intel 语言扩展(包含Cilk). 缺省启

用Intel 语言扩展.

头文件

cilk.h 定义宏, 提供名字和对应的简单转换

(cilk_spawn,

cilk_sync 和 cilk_for)

Cilk_api.h 声明 Cilk 运行库函数和类

cilk_stub.h 用来串行化 Cilk 应用

reducer.h 包含通常的 reducer 定义

reducer_file.h

reducer_list.h

reducer_max.h

reducer_min.h

reducer_opadd.h

reducer_opand.h

reducer_opor.h

reducer_opxor.h

reducer_ostream.h

reducer_string.h

在 Reducers 库中提供的 reducer。 参见

Reducer 库.

18

5. Cilk 关键字

本章描述了三个Cilk关键字:_Cilk_spawn, _Cilk_sync 和 _Cilk_for

头文件<cilk/cilk.h>提供了简化的关键字的宏定义(cilk_spawn, cilk_sync 和

cilk_for). 本章中将使用cilk.h中定义的名字。

5.1 cilk_spawn

cilk_spawn 关键字修改函数调用语句,以告知Cilk运行系统该函数可能(但不

是必须)和调用者并行执行。cilk_spawn语句可以是下面三种形式之一:

type var = cilk_spawn func(args); //func()有返回值

var = cilk_spawn func(args); // func()有返回值

cilk_spawn func(args); // func()没有返回值

func是可能与当前strand并行执行的函数名称。一个strand是一串没有任何并行

控制的串行指令序列。包含cilk_spawn的程序段可以和之后的func部分并行执行。

Var是一个类型为func返回类型的变量。它接收函数调用的返回值,被称为接收

者。对于返回值为空的函数,则必须省略接收者。

args是被衍生的函数的参数。这些参数在衍生发生前就被评估。需要注意的是要

确保传递引用和传递地址的参数具有至少直到cilk_sync的生命周期,否则被衍生的函

数可能在变量结束生命周期后仍过期使用这些变量。这就是数据竞争的一个例子。

一个被衍生的函数被称为衍生它的函数的子任务。相反的,执行cilk_spawn语句

的函数被称为是被衍生的函数的父任务。

衍生函数可以使用任意的函数表达形式。如下例,你可以使用函数指针或者成员

函数指针:

var = cilk_spawn (object.*pointer)(args);

但你不能在另一个函数的参数中使用cilk_spawn语句:

g(cilk_spawn f()); // 不允许

cilk_spawn [&]{ g(f()); }();

正确的方法是衍生一个函数来调用f()和g()。这可以很容易的用C++ lambda来实现:

cilk_spawn [&]{ g(f()); }();

19

注意到上面的表达方式和下面是不同的:

cilk_spawn g(f());

在后面的语句中,f()是在衍生g()以前在父任务中执行的,而在lamda表达方式

中,f()和g()都是在子任务中执行。

5.2 cilk_sync

cilk_sync语句表示当前函数不能继续和衍生的子任务并行执行。在所有子任务执

行完之后,当前函数才能继续执行。

语法规则如下:

cilk_sync;

cilk_sync只同步由该函数衍生的子任务。其他函数的子任务不受影响。

在每个包含cilk_spawn的函数和try块的结尾,存在隐式的cilk_sync。原因如下:

• 确保程序资源的使用不会超出程序并行的部分.

• 确保无竞争的并行程序能与相应的串行程序具有同样的行为. 一个普通的无衍生的函

数调用,不管在被调用的函数内部是否存在衍生的情况下,执行的行为都是一样的。

• 没有可能产生副作用或没有释放资源的剩余的strands继续执行.

• 被调用的函数在返回时已完成所有的操作。

5.3 cilk_for

cilk_for 循环用于取代常规的C/C++ for循环,它允许循环迭代并行执行。

cilk_for的语法规则基本如下:

cilk_for (declaration;

conditional expression;

increment expression)

body

• declaration必须定义和初始化单个变量,称作“控制变量”。构造函数的句法格

式没有要求。如果变量类型具有缺省的构造函数,则不需要显示赋初值。

• 条件表达式 conditional expression 必须用如下的比较操作符比较控制变量

和一个终止表达式:

• < <= != >= >

• 终止表达式和控制变量可以出现在比较操作符的任意端,但是控制变量不能发生在

终止表达式中。终止表达式的值不能随不同迭代而改变。

• 增量表达式increment expression 必须使用下列支持的操作符来增加控制变量

或从控制变量减除。

+=

20

-=

++ (前缀或后缀)

-- (前缀或后缀)

增加(或被减除)到控制变量的数值,和循环终止表达式一样,不能随不同迭代

而改变。

Cilk运行环境将一个cilk_for循环转换成基于循环迭代的递归往复。这种递归

往复使用了有效的二分法策略。

cilk_for 循环的例子有:

cilk_for (int i = begin; i < end; i += 2)

f(i);

cilk_for (T::iterator i(vec.begin()); i != vec.end(); ++i)

g(i);

在C而非C++中,循环控制变量可以提前定义:

int i;

cilk_for (i = begin; i < end; i += 2)

f(i);

一个合法的Cilk程序的串行化和类似的C/C++程序有相同的行为,而cilk_for的

串行化就是将cilk_for换成for的结果。因此,一个cilk_for的循环必须是一个合法的

C/C++ for循环,但是cilk_for循环有比C/C++ 的for循环更多的限制。

因为循环体要并行执行,循环控制标量和非本地变量均不能被修改,否则将导致

数据竞争。Reducer可以用来防止竞争。

5.3.1 串行或并行结构的 cilk_for

使用cilk_for并不同于衍生每次循环迭代。事实上,Intel编译器将循环体转换成

一个函数,然后用二分法策略递归的调用它;这极大的提高了性能。这两种策略的区

别可以清楚的用一个有向无环图表示出来。

首先,考虑cilk_for的有向无环图,假定N=8次迭代,粒度为1。数字标示了指令

的串行执行顺序,也就是strands;这些数字指明了每一个strand处理哪次迭代。

21

在每次分配的工作中,剩下工作的一半由子任务来完成,另一半在后继部分。重

要的是,循环本身和衍生新工作的开销和循环体一同被平均分配。

如果每次迭代执行的时间均为T,那么跨度,即从程序开始到程序结束开销最大的

路径,就是log2(N) * T 或者对于8次迭代是3 * T。不管迭代次数或工作线程多少,

运行时的行为都很均衡。

5.3.2 衍生发生在串行循环内的串行或并行结构

下面是衍生每次迭代的串行循环的有向无环图。在这种情况下,工作是不均衡的,

因为每个子任务在引起同步调度开销前只做了一次迭代的工作。对于一个短小,或者

循环体执行的部分远高于控制和衍生开销的循环来说,并没有太多可测量的性能差别。

但是对于具有很多简单迭代的循环来说,开销的代价将覆盖掉并行所带来的好处。

5.3.3 cilk_for 循环体

cilk_for循环体定义了一个特殊的区域,它限制了cilk_for和其中的cilk_sync语

句的作用范围。cilk_for循环中的cilk_sync语句仅仅等待所有在同一次迭代中衍生出

的子任务执行完成。它不需要和任何其他的迭代同步,也不需要和所在函数的其他子

22

任务同步。并且,在每次循环迭代的结尾(在调用块作用域内变量的析构函数之后)

有一个隐式的cilk_sync。因此,如果一个函数在进入到一个cilk_for循环时具有开销

很大的子任务,那么在它离开该cilk_for循环时也将具有开销同样大小的子任务。任何

在cilk_for循环中衍生的子任务将确保在循环结束前同步。相反的,在进入循环前就存

在的子任务不会在循环执行过程中同步。cilk_for的这一特性可以被有效利用起来(见

异常处理)。

如果一个异常是在cilk_for的循环体中抛出的(而且不是在同一次迭代中处理),

那么某些迭代可能没有执行。不同于串行执行,我们不能预测哪些迭代执行了哪些没

有执行。没有迭代(除了抛出异常的那个)会在中途取消。

Windows操作系统:Cilk 函数不支持Microsoft*结构化的异常处理(具体的,

/EHa 编译选项和、C/C++中的__try, __except, __finally 和 __leave 扩

展)

5.3.4 cilk_for 类型要求

你可以小心的使用自定义的C++数据类型作为cilk_for的控制变量类型。对每一

种自定义的数据类型,你需要提供一些方法来帮助运行时系统计算出循环的大小以进

行分配。类型,如整数类型和STL随机访问迭代器已经具备差值类型,则不需要额外

的工作。

假定控制变量被定义成类型variable_type,而循环终止表达式的类型为

termination_type,如下所示:

extern termination_type end;

extern int incr;

cilk_for (variable_type var; var != end; var += incr) ;

你必须提供一个或两个函数来告诉编译器循环执行多少次;这些函数帮助编译器

可以计算出variable_type 和 termination_type变量的整数差值。

difference_type operator-(termination_type, variable_type);

difference_type operator-(variable_type, termination_type);

• 参数类型不必完全相同,但必须是可从termination_type 或 variable_type

转换的。

• 循环递增计数时,需要使用第一种格式中的operator-;第二种格式是在循环递减计

数的情况下使用。

• 参数可以通过const引用或者值传递

• 程序在运行时将根据是正递增还是负递增来调用两个函数中的某一个。

• 你可以使用任何整数类型作为difference_type的返回值类型,但是两个函数必

须使用相同的类型。

• difference_type是否是有符号或无符号的并不重要。

同时你也需要通过如下定义告诉运行系统如何对控制变量实现加操作:

variable_type::operator+=(difference_type);

23

如果你在循环中需要写成“-=”或者“—”而不是“+=”或者“++”,则需定

义operator-=而不是operator+=。

这些操作符函数必须和普通算术保持一致。编译器假定对一个数作两次加法等同

于一次加两个数。如果

X - Y == 10

那么

Y + 10 == X

5.3.5 cilk_for 限制

为了利用二分法策略并行化一个循环,运行时系统必须提前计算迭代的总次数和

每次迭代时循环控制变量的值。因此,控制变量必须像一个整数一样,可以加、减和

比较,即使是用户定义的类型也是如此。整数,指针和标准模板库中的随机访问迭代

器均具备这样的整数行为而满足此要求。

另外,一个cilk_for循环具有一些在标准的C/C++ for循环中不存在的限制。如

果违反了下面的限制,编译器会报告错误或提示。

• 必须只存在一个循环控制变量,而且循环的初始化从句必须对它赋值。下列形式是

不支持的:

cilk_for (unsigned int i, j = 42; j < 1; i++, j++)

•循环控制变量不能在循环体内修改。下列形式是不支持的:

cilk_for (unsigned int i = 1; i < 16; ++i) i = f();

•终止和递增值会在循环开始前计算,而不会在每次循环迭代时再次计算。因此在循环

体内修改这两个值中的任何一个都不会增加或减少迭代次数。下列形式是不支持的:

cilk_for (unsigned int i = 1; i < x; ++i) x = f();

•在C++中,控制变量必须在循环语句上定义, 而不是在循环外。下列形式在C中支持,

但在C++中不支持:

int i; cilk_for (i = 0; i < 100; i++)

• 一条break 或者 return 语句在cilk_for循环体中将不能工作;编译器会产生一条错

误信息。在这样的语境中使用break和return将留作未来探索性的并行支持。

• 一条goto语句只能在goto 的目标行也存在于当前循环体中时,才能在cilk_for循环

中使用。如果goto语句转入或转出cilk_for循环体,编译器会产生错误信息。类似的,

goto语句不能从一个cilk_for循环体外跳转至循环体内。

• cilk_for循环不能回绕。例如,在C/C++中你可以写:

for (unsigned int i = 0; i != 1; i += 3);

而且这个定义是正确的,只是执行的行为会意外。它意味着循环2,863,311,531次。

24

这样一个循环如果转换成cilk_for循环将在Cilk中产生不可预计的结果。

• 一个cilk_for循环不能是无限循环,比如:

cilk_for (unsigned int i = 0; i != i; i += 0);

5.3.6 cilk_for 的粒度

cilk_for语句将循环划分到包含一次或者多次循环迭代的区块中。每个区块中的

循环迭代串行执行,并且在循环的执行过程中,作为一个区块而被衍生。每个区块中

包含的最大迭代次数被称为粒度。

在具有较多迭代的循环中,一个相对大的粒度可以有效的减少开销。另一方面,

对于迭代次数较少的循环,小粒度可以增加程序的并行度,在处理器数量增加时提高

性能。

5.3.6.1 设置粒度

使用cilk grainsize编译指示来指定一个cilk_for循环的粒度:

#pragma cilk grainsize = expression

例如,你可以写:

#pragma cilk grainsize = 1

cilk_for (int i=0; i<IMAX; ++i) { . . . }

如果你没有指定粒度,系统计算出一个适合多数情况的缺省值。缺省值的设置如

同添加了下列编译指示:

#pragma cilk grainsize = min(512, N / (8*p))

其中N是循环迭代次数,p是当前程序创建的工作线程个数。这个公式将产生最少

为8,最多为512的并行度。对于迭代次数很少的循环(少于8*工作线程个数)粒度

就设成1,每次循环迭代都会并行执行。对于迭代次数超过(4096*工作线程个数)次迭

代的循环,粒度就设成512。

如果你将粒度设成0,缺省值公式也将会被使用。如果粒度小于0,结果是未定义

的。

编译指示中的表达式是在运行时计算的。例如,下面的例子使用了工作线程数量

来设置粒度:

#pragma cilk grainsize = n/(4*__cilkrts_get_nworkers())

5.3.6.2 运行时的循环划分

执行的区块的数目近似于迭代次数N除以粒度K的值。

Intel编译器生成一个使用二分法的递归来执行循环。控制结构用伪代码描述如下:

void run_loop(first, last)

{

if (last - first) < grainsize)

25

{

for (int i=first; i<last ++i) LOOP_BODY;

}

else

{

int mid = (last-first)/2;

cilk_spawn run_loop(first, mid);

run_loop(mid, last);

}

}

换句话说,循环被反复的分割成两部分,直到剩下的区块中的迭代次数小于等于

粒度。在一个区块中实际执行的迭代次数通常都小于粒度。

例如,考虑一个迭代16次的cilk_for循环:

cilk_for (int i=0; i<16; ++i) { ... }

如果粒度为4,就正好执行4个区块,每个区块包含4次迭代。然而,如果粒度设

成5,将划分成4个不均等的区块,分别包含5,3,5 和 3次迭代。

如果你仔细看算法,你会发现对于同样具有16次迭代的循环,粒度为2和3将会产

生完全相同的划分,均为8个包含2次迭代的区块。

5.3.6.3 选择一个好的粒度值

缺省的粒度值通常都能产生好的性能。你也可以依照下面的准则选择一个不同的

值:

• 如果每次循环迭代执行的工作量区别很大,并且执行时间较长的迭代可能被不均匀

的划分,那么就有必要减少粒度。这将减少一个耗时的区块在其他区块执行完之后仍

然继续执行的可能性,这种可能的情况将导致一些闲置的工作线程没有可以执行的任

务。

• 如果每次循环迭代的工作量都比较小, 那么就有必要增加粒度。然而,缺省值通常

在这种情况下都是适用的,你也不需要冒降低并行度的风险。

• 如果你改变了粒度,执行一下性能测试来确保你让循环变快了,而不是变慢。

5.4 预处理宏

__cilk

这个宏是由编译器自动定义的,并设置成Cilk语言扩展的版本号。在当前发布的版本

中值为200,表示2.0版。

26

6. Cilk 执行模型

Cilk 编程可被视作对传统串行编程的一个逾越。为了使用户能够编写高效且缩放性

良好的程序,这里需要对它的某些关键概念进行一下解释。

关键概念包括:

Strand:理解一个 Cilk 程序结构的最佳方式是一个由并行控制点连接起来的 strand 图。

工时(work),跨度(span),和并行度(Parallelism): Cilk 程序的性能期望值可以通过工时,

跨度和并行度来分析。

6.1 Strands

传统串行程序的描述通常使用调用关系图或类层次图。并行编程在这些串行分析

的基础上增加了另外一层。为了图解,了解和分析某个 Cilk 程序的并行性能,你需要

把代码中串行执行的部分和那些可以并行执行的部分区分开来。

单词 strand 用来描述程序中串行部分。更准确地说,strand 的定义是“不包含任何

并行控制结构的任意指令序列”。

根据这个定义,一个串行程序可以由许多短到只有单条指令 strand 序列构成,或

者是由包含整个程序的单个 strand 构成,或者是由其它任意划分构成。一个不是某个

更长 strand 一部分的 strand 被称为最大 strand,也就是起点和终点为并行控制结构的

strand.

Cilk 程序包含两种并行控制结构 – 衍生(spawn)和同步(sync)。(并行循环,即

_cilk_for,其实是将某个问题分解为多个衍生和同步的简便写法)下图包含 4 个 strand

(1,2,3,4),一个衍生(A)和一个同步(B)。在该图中,只有 strand(2)和

strand(3)可以并行执行。

在该图中,strands 表示为直线和弧线(边),并行控制结构表示为圆形节点(顶

点)。该 strand 图表示了一个用来表达某个 Cilk 程序中串行/并行结构的有向无环图

(DAG)。

反映上图结构的 Cilk 程序片段如下所示:

27

do_stuff_1(); //执行 strand 1

_cilk_spawn_func_3(); //在 A 时刻衍生 strand 3

do_stuff_2(); //执行 strand 2

_cilk_sync; //在 B 时刻进行同步

do_stuff_4(); //执行 strand 4

在 cilk 程序中,一次衍生有且只有一个输入 strand 和两个输出 strand。一次同步有

两个或更多输入 strand,有且只有一个输出 strand。下图是一个包含两次衍生(A 和 B)

和一次同步(C)的 DAG。在这个程序中,标记为(2)和(3)的 strand 可以并行执

行,标记为(3),(4)和(5)的 strand 也可以并行执行。

DAG 表示了 Cilk 程序执行中的串行/并行结构。对于不同的输入,同一个 Cilk 程

序可能有不同的 DAG。例如,某个衍生可能会条件执行。

但是,DAG 不依赖于程序所运行之上的处理器数目;事实上,在单个处理器上运

行 Cilk 程序就可以确定 DAG。后面的部分将会描述执行模型并解释如何在可用的处理

器上对工时进行划分。

6.2 工时和跨度

一旦有了一个描述 Cilk 程序串行/并行结构的方法,你就可以开始分析这个程序的

性能和缩放性。

考虑如下图所示的一个更为复杂的 Cilk程序:

该 DAG 表示了一个 Cilk 程序的并行结构。在每个 strand 上加上以毫秒为单位的

28

strand 执行时间作为标签有助于构造一个拥有此 DAG 的 Cilk程序:

6.2.1.1 工时

完成该程序所需的处理器时间总数是所有标签数字之和。该值被定义为工时。

在该 DAG 中,所示的 25 个 strand 的工时是 181 毫秒。如果该程序在单个处理器

上执行,那么它将运行 181 毫秒。

6.2.1.2 跨度

跨度,有时也被称作关键路径长度,是由程序起点到终点最长的一条路径。在这

个 DAG 中,跨度是 68 毫秒,如下图所示:

在理想状态下(例如,没有调度开销时)以及无限多处理器可用时,该程序会运

行 68 毫秒。

你可以使用工时和跨度来预测一个 Cilk 程序在多处理器系统上的加速比和缩放性。

当分析某个 Cilk 程序时,你需要考虑它在不同数目的处理器上执行的时间。下面的写

法有助于这项工作:

T(P)是程序在 P 个处理器上的执行时间

T(1)是工时

T(∞)是跨度

在两个处理器上,执行时间不会小于 T(1)/2。一般情况下,有如下工时定律:

T(P)>= T(1)/P

29

与之相类似,在 P 个处理器上,执行时间不会小于在无限多个处理器上的执行时间。

因此,有如下跨度定律:

T(P)>= T(∞)

6.2.1.3 加速比和并行度

如果一个程序在 2 个处理器上运行速度加倍,那么加速比是 2。因此,P 个处理器

上的加速比是:

T(1)/T(P)

加速比公式的一个有趣的结果是如果 T(1)比 T(P)增长的更快,那么加速比

会随着工时的增加而增加。某些算法为了利用额外的处理器会做一些附加工作。这样

的算法在 1 个或 2 个处理器上运行时通常会比相应的串行算法慢,但是在 3 个或更多

的处理器上运行时就开始显现出加速。这种情况并不常见,但这里仍然值得提一下。

最大的可能加速比会在无限多个处理器上获得。并行度被定义为假定在无限多个

处理器上运行时加速比。因此,并行度可以表示为:

T(1)/T(∞)

并行度设定了可获得的加速比的上限。例如,如果你正在进行并行化的程序具有

2.7 的并行度,那么在 2 到 3 个处理器的系统上得到合理的加速比,但是在 4 个或更多

的处理器上不会有额外的加速比。因此,针对数目较少的处理器调优的算法在更大规

模的机器上不会有好的缩放性。一般而言,如果程序并行度小于可用处理器数目的 5-

10 倍,那么就不会得到线性加速。这是因为调度程序不会总是让所有处理器保持忙碌

状态。

6.3 strand 到工作线程的映射

在前面我们用 DAG 说明了 Cilk程序的串行/并行结构。请记住 DAG 并不依赖于处

理器的数目。执行模型描述了运行调度程序是如何将 strand 映射到工作线程。

引入并行后多个 strand 可以并行执行。但是在 Cilk 程序中,可以并行执行的

strand 并不一定会并行执行。调度程序会动态作出决定。

考虑下面的 Cilk程序片断:

do_init_stuff(); // 执行 strand 1

cilk_spawn func3(); // 衍生 strand 3 (子任务)

do_more_stuff(); // 执行 strand 2 (延续部分)

cilk_sync;

do_final_stuff; // 执行 strand 4

下面是这段代码的 DAG:

30

执行这个 Cilk 程序的操作系统线程我们称之为工作线程。当有多个工作线程可用时,

这个程序有两种可能的执行方式:

整个程序由单一工作线程执行,或

调度程序选择在不同的工作线程上执行 strand(2)和(3)。

为了保证串行语义,衍生出来的函数(子任务,即例中的 strand(3))总是和进入

衍生节点的那个 strand 在同一个工作线程上执行。于是,在这个例子中,strand(1)

和 strand(3)总是在同一个工作线程上执行。

如果此时有其它工作线程可用,那么 strand(2)(延续部分)可以在另外的工作线

程上执行。我们称这样为密取(steal),也就是说延续部分被新的工作线程密取了。

一个新的示意图有助于说明这两种情况。在单一工作线程上执行的情况如图所示:

如果第二个工作线程被调度执行,那么该线程将开始执行延续部分,strand(2)。

第一个工作线程将继续执行到位于(B)的同步节点。在下面的示意图中,第二个工作

线程表现为用虚线表示的 strand(2)。在同步之后,strand(4)可以有任一工作线程继

续执行。在当前的实现中,strand(4)将由最后到达同步点的工作线程执行。

执行模型的相关细节会带来一些影响,这些影响将会在 Cilk 工作线程和系统线程

交互,以及 reducer 部分中加以介绍。到目前为止,关键思想包括:

在 cilk_spawn 之后,子任务总是和调用者在同一工作线程(即系统线程)上执行。

在 cilk_spawn 之后,延续部分可以在另一工作线程上执行。如果这样,我们称延

31

续部分被另一工作线程密取。

在 cilk_sync 之后,程序执行可以由进入同步接点的任一工作线程继续。

6.4 异常处理

Cilk 语言扩展试图尽可能地保持 C++异常处理语义。通常这要求在有异常等待处

理时限制并行度,以及程序在进行异常处理时不能依赖于并行。异常处理逻辑如下所

述:

如果一个异常被抛出且未在繁衍的子任务中被捕获,那么该异常将会在下一个同

步点被父任务重新抛出。

如果父任务或其它子任务也抛出一个异常,那么在串行执行顺序下第一个被抛出

的异常具有优先权。那些逻辑上靠后的异常将被丢弃。目前没有机制能够收集并

行抛出的多个异常。

strand 抛出异常不会中止已有的子任务或兄弟任务;这些 strand 将会继续正常执行

到结束为止。

如果一个 try block 包含一个 cilk_spawn 和/或 cilk_sync,那么在进入 try block之前

和 try block 结束时(在析构函数调用之后)各有一个隐式的 cilk_sync。当由于某个异

常而退出某个 try block,函数 block,或者 cilk_for 结构之前,程序会将自动执行一个

同步。(注意,对于 cilk_for 内部的同步而言,其作用域限于同一循环内部的衍生。)函

数在开始执行 catch block 之前不会有活动的子任务。这些隐式的同步保证了 catch语句

和串行执行方式相同。

隐式的同步可能会限制程序的并行度。在 try block 之前的隐式同步会过早和发生

在 try block 之前的衍生进行同步,就象下面的例子中那样:

void func() {

cilk_spawn f();

try { // oops! 隐式同步阻止了f()的并行执行

cilk_spawn g();

h();

}

catch (...) {

// 处理来自g()或者 h(), 而不是 f()的异常

}

}

如果你的程序中存在这样的问题,这里有几种解决的途径:

将 try block 里面的内容移到一个独立的函数中。通过将 cilk_spawn 相

关语句移出 try block,你可以消除自动生成的 cilk_sync。前面的例子可

以改写为:

void gh()

32

{

cilk_spawn g();

h();

}

void func() {

cilk_spawn f();

try { // 不包含 cilk_spawn,所以没有隐式的同步

gh();

}

catch (...) {

// 处理来自 gh(),而不是 f()的异常

}

}

将整个 try/catch 语句或者只是 try block 放入一个只迭代执行一次的 cilk_for 循

环中。cilk_for 循环体是一个和其它部分相隔离的上下文,所以其中的衍生和同

步不会和外围的部分相互作用。如果你要多次使用这种方法,可以写一个宏加

以简化。上面的例子可以这样写:

#define CILK_TRY cilk_for(int _temp = 0; _temp < 1;

++_temp) try

void func() {

cilk_spawn f();

CILK_TRY { // try包含在cilk_for中,所以不会和f()同步

cilk_spawn g();

h();

}

catch (...) {

// 处理来自g()或h(),而不是 f()的异常

}

}

33

7. Reducers

本章描述了 Cilk 中的 Reducer, Reducer 的使用以及如何开发用户自己的

Reducers。

为了解决在并行代码中存取非本地变量的问题,Cilk 语言扩展中引入了了

Reducers。从概念上来说, Reducer 是一个能被多个并行执行的 strands 安全使用的

变量。 运行环境保证了每个工作线程存取一个该变量的私有拷贝,在不需要锁的情况

下消除了线程间竞争的可能性。 当多个 strands 在进行同步时, 这些私有变量被合

并并被复制到单个变量。 当然, 运行环境会在必要的时候才创建私有拷贝以便减少

系统开销。

Reducers有下面这些重要属性:

Reducers允许无竞争的可靠存取非本地变量。

Reducers 不需要加锁,因而避免了由于对非本地变量加锁而带来的锁竞争问

题, 以及由此而引起的无法并行的问题。

在正确的定义和使用情况下, Reducers 保留了串行语义。 使用 Reducers 的

Cilk 程序的结果与串行版本的结果是一致的, 该结果不依赖于目标机器的处

理器数目, 也不依赖于工作线程的调度。 Reducers 的使用不需要对现存的

代码结构做明显的修改。

Reducers的实现是高效的。

与定义在控制结构比如循环上的实现不一样, Reducers 的使用不依赖于程序

的控制结构。

Reducers是通过 C++模板提供接口给运行环境来实现的。

本章主要涵盖了下面这些内容:

使用 Cilk提供的 Reducers.

使用时需要考虑的内容, 包括合适的操作, 性能以及 Reducers 的限制等。

在某些情况下, 你可能需要编写你自己的 Reducer。 参见附件 A: 编写你自己的

Reducer

7.1 使用 Reducers – 一个简单的例子

这个例子演示了使用 Reducer 进行并行的累加计算。 请参考下面的串行程序。

该程序重复的调用 compute()函数, 对结果进行累加到 total 变量。

#include <iostream>

unsigned int compute(unsigned int i)

{

return i; // 返回通过i计算得到的值。

}

int main(int argc, char* argv[])

{

34

unsigned int n = 1000000;

unsigned int total = 0;

// 计算整数1到n的和

for(unsigned int i = 1; i <= n; ++i)

{

total += compute(i);

}

// 1到n的求和结果应该是 n * (n+1) / 2

unsigned int correct = (n * (n+1)) / 2;

if (total == correct)

std::cout << "Total (" << total << ") is correct" << std::endl;

else

std::cout << "Total (" << total << ") is WRONG, should be " <<

correct << std::endl;

return 0;

}

把该程序转变为 Cilk 程序, 把 for 循环改成 cilk_for 循环可以使得循环并行的

执行, 但是这样的修改会产生对 total 变量的数据竞争。 为了解决该竞争问题, 我

们可以把 total 设为 Reducer。 具体来说, 我们通过 reducer_opadd 来实现这个转变。

reducer_opadd是一个具有+操作的类型定义。程序的修改如下所示。

#include <cilk/cilk.h>

#include <cilk/reducer_opadd.h>

#include <iostream>

unsigned int compute(unsigned int i)

{

return i; // 通过i计算返回值

}

int main(int argc, char* argv[])

{

unsigned int n = 1000000;

cilk::reducer_opadd<unsigned int> total;

// 计算 1..n

cilk_for(unsigned int i = 1; i <= n; ++i)

{

total += compute(i);

}

//1到n的求和结果应该是n * (n+1) / 2

unsigned int correct = (n * (n+1)) / 2;

if (total.get_value() == correct)

std::cout << "Total (" << total.get_value() << ") is correct" <<

std::endl;

else

35

std::cout << "Total (" << total.get_value() << ") is WRONG, should be "<<

correct << std::endl;

return 0;

}

在串行程序的下列改动显示了如何使用一个Reducer:

1. 包含合适的reducer头文件。

2. 声明一个归约变量为 reducer_kind<类型>而不是单独的某一类型。

3. 引入并行, 修改for 循环为cilk_for 循环。

4. 在cilk_for循环结束后通过get_value()方法来得到reducer的最终值。

注意: Reducers是作为对象来定义的。 他们不能直接的进行拷贝操作。 如果你通过

memcpy()来对一个reducer对象进行拷贝, 得到的结果是不定的。你需要定义一个拷

贝构造函数来实现对Reducer的复制。

7.2 Reducers 是如何工作的

对Reducers的机制和语义的解释可以帮助高级程序开发人员理解和控制Reducers

的使用, 同样也可以提供给程序员开发自己的Reducers所需要的背景知识。

在最简单的情况下, 一个Reducer就是一个包括数值, 特征名称和归约函数的对

象。

在Reducer库中提供的Reducers提供了附加的接口以保证Reducers的使用是安全和

一致的。

本节中,在声明reducer时创建的对象被称为“最左实例”(leftmost

instance)。

下面的章节演示了一个简单的例子, 讨论了程序运行时系统的实时行为。

首先, 考虑两种可能的cilk_spawn执行方式, 密取和非密取。 Reducer的行为

是很简单的:

如果密取行为没有发生, reducer的行为与通常的变量是一样的。

如果密取发生了, 当前主线程的后续操作会得到一个新的具有某个恒等值的

工作环境视图,其它线程根据该工作环境视图密取该主线程的后续操作。主

线程拥有spawn之前的Reducer视图, 并根据该视图执行spawn出来的新任务。

当相应的同步发生时, 其它线程得到的计算值与主线程的计算值通过归约操

作进行合并。 其它工作线程所密取的工作视图被注销, 当前主线程的视图

对象得到更新。

下面通过图例来说明这种行为:

36

7.2.1 无密取方式

如图所示, 在cilk_spawn (A)后没有密取发生。

在这个例子中, strand(1)中的reducer对象可以直接被strand(3) 和strand(4)

修改。 因为没有密取发生, 没有新的工作视图产生, 同样也没有reduce操作被调用。

7.2.2 密取方式

Strand(2), cilk_spawn(A)的后续操作被密取:

在这个例子中, strand(1)的reducer对象在strand(3)中是可见的。 后续操作

strand(2)得到一个具有某个恒等值的工作视图。 在(B)点同步的时候, (2)中得到的

新的reducer会与strand(3)中的合并。

7.2.3 使用 reducer_opadd<>的实例

这是一个使用reducer_opadd<>对整数进行并行求和累加的例子。 在该例子中,

恒等值为0, 归约函数将右值加到左值。

1 #include <cilk/cilk.h>

2 #include <cilk/reducer_opadd.h>

3 reducer_opadd<int> sum;

4

5 void addsum()

6 {

7 sum += 1;

8 }

9

10 int main()

11 {

12 sum += 1;

13 cilk_spawn addsum();

14 sum += 1;

// 此处的sum的值取决于密取是否发生

15 cilk_sync;

16 return sum.get_value();

17 }

37

首先, 考虑在单个处理器上执行的串行程序, 无密取发生。 在这种情况下,

没有新的sum对象生成,所有的操作将作用在最左实例上。因为没有新的视图对象生成,

归约操作没有被调用。 sum的值从0一直被加到最终值3。

在上述情况下, 因为没有密取发生, cilk_sync语句被当成空操作。

现在, 考虑密取发生的情况。 当执行到第12行的时候, 产生一个新的sum对象,

恒等值为0. 在第12行执行完后, sum值为1. 当前主线程得到新的恒等值(0), 子线

程得到主线程的在spawn时候的sum值(1). 这种密取方式有利于reducers保持精确的结

果而不需要进行相关的通讯。子线程在第5行执行完后得到sum值(2).

当程序执行到cilk_sync语句时, 如果多个strands合并时有多个不同的sum对象,

那么这些对象会通过归约操作进行合并。 在这个例子中, 归约操作是一个加法, 这

样在主线程中的sum (值为1)会与子线程中的sum(值为2)进行合并得到最终的sum值3.

在主线程中新产生的sum对象相关视图会被注销。

7.2.4 延迟语义

我们可以认为每个strand都有一个reducer的私有视图。基于性能的考虑, 这些

视图的生成是延迟的, 也就是说, 视图的创建必须满足下面两个条件:

首先, 新的视图的创建必须在密取发生以后。

其次, 只有在新的strand里面首次存取reducer的时候, 新的视图才会被创建。

系统在改存取点创建新的视图实例, 根据视图对象构造函数定义赋予reducer恒

等值。

如果新的视图被创建, 他会在cilk_sync点与先前的视图进行合并。 如果没有新

的视图生成, 合并是不必要的。(从逻辑上说, 你也可以认为一个初始化视图

被创建,然后被合并了, 这些应该都是空操作。)

7.2.5 操作的安全性

对于一个Reducer对象来说,我们可以仅仅定义reducer的恒等值以及他的归约

函数。 然而为了操作安全和方便的考虑, 最好能通过操作符重载来定义必要的

函数以便限制在reducer上的操作。

比如, reducer_opadd定义了+=, -=, ++, --, + 和-操作。 某些操作比如乘

法(*)和除法(/)不能够提供确切一致的语义, 因而在reducer_opadd的定义中不

提供这些操作。

7.3 安全性和性能考虑

总的来说, 在并行执行过程中,reducers不需要加锁就可以得到可重现的结

果。 他的结果与串行执行的结果是一致的。

当开发你的程序的时候, 请注意下列安全性和性能的考虑。

38

7.3.1 安全性

为了得到严格确定的结果, 所有的对reducer的操作(修改与合并)必须符合

结合律。

定义在reducer库中提供的reducer操作是满足结合律的。 总的来说, 如果你

仅仅使用这些reducer库中提供的操作来存取reducer, 你可以得到确定的串行语

义。 使用不满足结合律的reducers操作是可能的, 比如开发你自己的不符合结

合律的reducer操作, 或者使用不安全的操作存取和修改reducer。

7.3.2 确定性

当使用reducers进行浮点类型操作时,这些操作不是严格的符合结合律的。

不同的操作循序可能得到不同的结果。在这种情况下, 得到的结果可能跟

strands执行的循序有关。 对与某些程序来说, 这些结果上的差异是可以接受的,

但是需要注意的是, 对于程序的多次运行, 你可能无法得到完全一样的结果。

7.3.3 性能

正常情况下reducer仅仅带来很少的或者没有实时性能开销。 然而, 下面一

些情况可能导致明显的性能开销。 这些性能开销与实际发生的密取数量是成比例

的。

当你创建非常大数量的reducers(比如, 一个reducers数组)的时候, 请注

意程序的密取开销, 而且该开销与程序的reducers数量是成正比的。

当你定义具有非常多特征元素的reducers的时候, 创建reducers的恒等值的

开销可能是比较大的。 这类开销发生在密取发生后,reducer被引用的时候。

如果你的reducer的合并开销非常大, 请注意这样的合并会在每次成功的密取

后的同步时发生。

7.4 Reducer 库

系统提供的reducer库见下表所示。对于每个reducer的描述可以参见他所在头文件

中的注释。 在下表中, 中间一列显示了reducer的特征元素和更新操作(可能有多

个)。 在后面的章节中我们会解释这些概念。

Reducer/头文件 特征/更新 描述

reducer_list_append

<cilk/reducer_list.h>

空列表

push_back()

使用append操作创建一个

列表。不论有多少工作线

程或者工作线程是怎样被

调度的, 最终列表的顺序

和同等串行程序创建的列

表顺序一致。

reducer_list_prepend 空列表 使用prepend操作创建一个

39

<cilk/reducer_list.h> push_front() 列表。

reducer_max

<cilk/reducer_max.h>

构造函数参数

cilk::max_of

在一组数据中寻找最大

值。 该参数在构造函数中

具有一个初始化的最大

值。

reducer_max_index

<cilk/reducer_max.h>

构造函数参数

cilk::max_of

在一组数据中寻找最大值

以及该最大值所在元素的

索引。 该参数在构造函数

中具有一个初始化最大值

及索引。

reducer_min

<cilk/reducer_min.h>

构造函数参数

cilk::min_of

在一组数据中寻找最小

值。 该参数在构造函数中

具有一个初始化最小值。

reducer_min_index

<cilk/reducer_min.h>

构造函数参数

cilk::min_of

在一组数据中寻找最小值

以及该最小值所在元素的

索引。 该参数在构造函数

中具有一个初始化最小值

及索引。

reducer_opadd

<cilk/reducer_opadd.h>

0

+=, =, -=, ++,

--

执行求和操作

reducer_opand

<cilk/reducer_opand.h>

1 / true

&, &=, =

执行逻辑或者按位与操作

reducer_opor

<cilk/reducer_opor.h>

0 / false

|, |=, =

执行逻辑或者按位或操作

reducer_opxor

<cilk/reducer_opxor.h>

0 / false

^, ^=, =

执行逻辑或者按位异或操

reducer_ostream

<cilk/reducer_ostream.h>

构造函数参数

<<

提供一个能并行写的输出

流。 为了保证输出流次序

的一致性, 输出被缓存起

来直到没有更多的待定输

出。这样的输出结果与串

行程序的执行结果总是保

持相同。

reducer_basic_string

<cilk/reducer_string.h>

空字符串, 构造函数参

+=, append

使用append或者+=操作创

建一个字符串。 字符串通

过一个子字符串列表来实

现, 这样可以减少拷贝以

及内存碎片。 当

get_value()被调用的时

40

候, 这些子字符串被合并

成一个输出字符串。

reducer_string

<cilk/reducer_string.h>

空字符串, 构造函数参

+=, append

提供针对char类型的

reducer_basic_string操

作的快捷方式

reducer_wstring

<cilk/reducer_string.h>

空字符串, 构造函数参

+=, append

提供针对wchar_t类型的

reducer_basic_string操

作的快捷方式

7.5 使用 Reducers – 另一个例子

下面的章节描述了如何使用其他类型的reducers, 包括字符串和列表reducers.

7.5.1 字符串 Reducer

reducer_string 构造8-bit字符的字符串。 在例子程序中使用+=(字符串联)

作为字符更新操作。

这个例子演示了reducer的工作是如何保证串行语义的。 在一个串行for循环中,

reducer串联字符’A’ 到 ‘Z’, 然后打印出:

The result string is: ABCDEFGHIJKLMNOPQRSTUVWXYZ

cilk_for 循环使用二分法将操作分成两份, 对每一份再分成两份, 直到每份的

操作趋于一个合理的长度。 这样, 第一个工作线程将会处理产生字符串”ABCDEF”,

第二个工作线程产生“GHIJKLM”, 第三个产生”NOPQRS”, 第四个工作线程生成”

TUVWXYZ”。 程序运行时,系统会调用reducer的reduce方法将各个工作线程的结果合

并, 并且得到的结果是符合英语字母顺序的字符串。

字符的合并是符合结合律的(但是不符合交换律), 因而操作的先后次序无关紧

要。 比如, 下面两个表达式是相等的:

1. "ABCDEF" concat ("GHIJKLM" concat ("NOPQRS" concat "TUVWXYZ"))

2. ("ABCDEF" concat "GHIJKLM") concat ("NOPQRS" concat "TUVWXYZ")

不管cilk_for如何创建不通的工作区域, 结果总是相同的。

对get_value()的调用会执行归约操作, 合并子字符串到单一输出字符串。 我们

为什么要用get_value()来得到字符串呢?你可以考虑一下在该点取得输出是否是合

理的。 当然, 你可以在你想要的任何时候提取计算值, 但你不应该这么做。 否则,

你取到的结果可能是一个不期望得到的中间值, 当然, 在任何情况下, 这种中间值

是没有意义的。 在我们所举的例子中, 中间值可能是“GHIJKLMNOPQRS”, 这是”

GHIJKLM”和”NOPQRS”的合并。

Cilk reducer提供串行的语义, 这种串行语义只有在平行计算的结尾处才能得到

保证, 比如在cilk_for循环的结尾处, 此时所有的reduce操作均已完成。 你不应该

41

在cilk_for循环中间的任何地方调用get_value(), 此时的计算值是不精确和无意义的。

不像先前整数加操作的例子, 对字符串的操作是不符合交换律的。 你可以基于

reducer 库中提供的reducer_list_append, 使用类似的代码来实现后加或前加一个

元素到列表。如下列程序所示:

#include <cilk/cilk.h >

#include <cilk/reducer_string.h>

#include <iostream>

int main()

{

// ...

cilk::reducer_string result;

cilk_for (std::size_t i = 'A'; i < 'Z'+1; ++i) {

result += (char)i;

}

std::cout << "The result string is: " << result.get_value() <<

std::endl;

return 0;

}

在这个例子中, 每次循环均只更新了一次reducer。 当然, 你也可以更新多次,

比如:

cilk_for (std::size_t i = 'A'; i < 'Z'+1; ++i) {

result += (char)i;

result += tolower((char)i);

}

这样得到的输出结果就是:

AaBb...Zz

7.5.2 List reducer (使用用户定义类型)

reducer_list_append使用STL 列表append方法创建一个列表。 恒等值是一个空

的列表。 下面这个例子与前面的字符串例子基本相同, 只是reducer_list_append的

声明需要定义一个类型, 如下面的代码所示:

#include <reducer_list.h>

int main()

{

// ...

cilk::reducer_list_append<char> result;

cilk_for (std::size_t i = 'A'; i < 'Z'+1; ++i) {

result.push_back((char)i);

}

std::cout << "String = ";

std::list<char> r;

r = result.get_value();

42

for (std::list<char>::iterator i = r.begin();

i != r.end(); ++i) {

std::cout << *i;

}

std::cout << std::endl;

}

7.5.3 递归函数中的 Reducers

Reducers并不仅限与在cilk_for循环中使用。 Reducers可以工作在任何形式的控

制流中, 比如递归函数。 下面这个例子显示了如果利用reducer来构造一棵树的元素

的有序列表。 最后得到的列表与串行程序得到的结果是一样的, 而不管有多少个工

作线程, 也不管工作线程是如何被调度的。

#include <reducer_list.h>

Node *target;

cilk::reducer_list_append<Node *> output_list;

...

// 按顺序输出一棵树的节点元素。

void walk (Node *x)

{

if (NULL == x)

return;

cilk_spawn walk (x->left);

output_list.push_back (x->value);

walk (x->right);

}

43

8. 操作系统相关事项

本章描述了下列和操作系统特定相关的注意事项:

Cilk 程序如何与操作系统线程交互

Cilk 程序如何与 Microsoft Foundation Class (MFC)交互

8.1 在 Cilk 程序上使用其它工具

由于 Cilk 程序拥有和 C/C++标准不同的堆栈布局和调用规则,那些应用于二进制

代码的工具(包括类似 valgrind 的内存检查工具和代码覆盖工具)可能无法用于 Cilk

并行程序。很多时候通过只使用一个工作线程(通过把环境变量 CILK_NWORKERS

设置为 1)来运行程序就可以了。如果这样不能奏效,你可以在 Cilk 程序的串行版本

上使用这些工具。

8.2 和操作系统线程的一般交互

在和操作系统线程一起工作时请记住下面几点:

工作线程就是操作系统线程

运行时系统会通过操作系统自身的相关机制来分配一个“工作线程”集合。

Cilk 程序不会总是 100%用掉所有可用的处理器。

在运行一个Cilk程序时,你可能会看到即使在并没有并行工作时,它也会消耗掉

系统所有的处理器资源。这种现象通过像Windows* 任务管理器“性能”标签页这样

的程序可以明显的观察到;所有的CPU都处于忙的状态,即使只有一个strand在执行

时也这样。

事实上,运行调度程序仍然会给其它程序分配CPU。如果没有其它程序请求处理

器,Cilk工作线程会立即重新运行进行工作密取。因此,Cilk程序看起来像一直占用

100%的处理器时间,但是事实上对系统或其它程序没有不利的效果。

使用操作系统线程接口时要小心

Cilk strands并不是操作系统线程。同一Cilk strand在运行过程中不会在工作线

程间迁移。但是在cilk_spawn,cilk_sync,或cilk_for语句后工作线程会发生变更,

这是因为这些语句会中止一个或多个strand并创建一个或多个新的strand。而且,你

无法控制某个特定strand由哪一个工作线程来执行。

上述内容会从多方面影响一个程序,最重要的是:

44

不要使用Windows线程本地存储或Linux Pthread线程专有数据,因为在工作

密取OS线程可能会发生变更。作为替代,可以使用其它编程技巧,比如前面讨

论的Cilk holder reducer。

不要跨越cilk_spawn,cilk_sync或cilk_for语句使用操作系统锁或互斥锁,

因为只有加锁的线程能进行解锁操作。

8.3 Microsoft Foundation Class 和 Cilk 程序

注意:本小节仅适用于Windows*程序员

Microsoft Foundation Class(MFC)库依赖于线程本地存储来完成由包装类

到GDI对象句柄的映射。Cilk strand本身并不能保证一直运行在任何特定线程上,所

以使用Cilk的并行程序无法安全地调用MFC函数。

在基于MFC的应用程序中完成计算密集型任务有两种典型的实现方式:

用户界面(UI)线程创建一个计算线程来执行计算密集型任务。计算线程通过

向UI线程发送消息来进行更新,使得UI线程空闲出来得以相应UI请求。

计算密集型代码在UI线程中执行,同时直接更新UI,并偶尔执行Message

Pump来处理其他UI请求。

由于运行时系统可能切换操作系统线程,Cilk代码必须和像MFC这样依赖于线程

本地存储的代码进行隔离。

向MFC程序中添加计算代码:

1. 利用操作系统相关机制(_beginthreadex或AfxBeginThread)创建一个

计算线程。所有将被转换成使用Cilk的C++代码必须运行在这个线程中。

计算线程使得主(UI)线程可以运行Message Pump并对UI进行更新。

2. 将UI窗口的句柄(HWND)传给计算线程。当计算线程需要更新UI时,必

须通过调用PostMessage向UI线程发送一个消息。PostMessage将收集

并这个消息放入和创建窗口的线程相关联的消息队列中。这里不能使用

SendMessage。SendMessage是运行在当前执行线程,而不是正确的

(UI)线程上的。

3. 对C++程序进行测试,保证逻辑和线程管理正确无误。

4. 向计算线程中的程序逻辑中加入Cilk结构。

5. 在程序中止前,主(UI)线程必须使用WaitingForSingleObject()等待计

算线程完成。

例子QuickDemo展示了一个使用Cilk的MFC应用程序。

其它的建议:

45

当主UI线程创建计算线程后,不要等待该线程完成。创建计算线程的函数必须

返回以使得Message Pump可以运行。

传给计算线程的任何数据一定不能是分配是在堆栈上的。如果是的话,这些数

据将很快随着线程创建函数的返回和释放数据而变得无效。

传递给计算线程的数据块应当由计算线程使用完后,在向UI线程发送完成的消

息前加以释放。

要使用PostMessage函数而不是CWnd::PostMessage,其主要原因是在计

算线程的Cilk代码中要避免线程本地变量。

一段(较短的)使用Cilk的计算代码可以在UI线程中直接调用,前提是这些计

算代码表现为一个“黑盒”且不与Message Pump或其它任何线程进行通信。

这个特点使得你可以调用某些非交互性函数而不用考虑它们是否使用了Cilk。

46

9. Cilk 运行系统 API

Cilk程序需要运行系统和库函数的支持,运行系统和库函数将自动链接到使用了

Cilk语言扩展的程序中。运行系统提供了少量的几个用户可访问函数用于控制应用程

序的执行。

头文件cilk/cilk_api.h里包含了Cilk运行函数和类的声明。下面是这些函数和类

的定义说明。这个头文件并没有定义针对Cilk reducers的接口(interface)定义。这

个接口在本书相应的接口章节中有描述。

9.1 __cilkrts_set_param

int __cilkrts_set_param(const char* name, const char* value);

该函数使程序员可以控制Cilk运行系统的多个参数。它的两个字符串参数构成了

一个名/值对。下面是支持的name值:

nworkers: 参数value指定可使用工作线程数量,缺省用十进制表示,也可以通过

前缀0x或0分别用十六或八进制表示。如果程序中没有调用这个函数,工作线程的数

量由环境变量CILK_NWORKERS的值决定。缺省情况下,工作线程的数量和处理器

的数量相同。该函数只有在第一次调用包含cilk_spawn或cilk_for的函数前才有效。

成功返回0否则返回非0。不成功有可能是由于变量无法识别,非法的参数值,或

在一个不正确的时间调用等。

9.2 __cilkrts_get_nworkers

int __cilkrts_get_nworkers(void);

该函数返回被分配用于处理Cilk任务的工作线程数量,同时冻结该数量值,防止

__cilkrts_set_paramd对其进行修改。如果在一段串行代码中调用,该函数将返

回1。

工作线程的ID值不一定在一个连续的范围里,所以工作线程的ID值可能会大于

__cilkrts_get_nworkers的返回值。

9.3 __cilkrts_get_worker_number

int __cilkrts_get_worker_number(void);

该函数返回一个整数值,表示执行该函数的Cilk工作线程号。

47

9.4 __cilkrts_get_total_workers

int __cilkrts_get_total_workers(void);

在一个特定时间里,Cilk运行系统有可能分配比活动工作线程数量更多的工作线程。

该函数返回所有工作线程的总数,包括哪些不正在使用的workers。换句话说,该函

数将返回比可分配给工作线程使用的最大ID值大一的值。典型情况下,这个值比实际

执行Cilk任务的工作线程数稍大一些。你可以创建一个大小为该函数返回值的数组,

然后使用工作线程ID为索引值访问其元素。和__cilkrts_get_nworkers函数一样,

该函数将冻结工作数量值,防止__cilkrts_set_param对其进行修改。如果在一段

串行代码中调用,该函数将返回1。

48

10. 理解竞争条件

竞争是并行程序中导致错误的主要原因。本章将描述竞争条件并提供避免或纠正

竞争条件的编程技巧。

10.1 数据竞争

当两个并行 strand 访问同一内存且至少其中之一执行写操作时,就会产生确定性

竞争。程序的运行结果将依赖于哪一个 strand“赢得竞争”而先访问内存。

数据竞争是确定性竞争的一个特例。数据竞争是当两个不拥有公共锁的并行 strand

访问同一内存且至少其中之一执行写操作时产生的竞争条件。程序的运行结果将依赖

于哪一个 strand“赢得竞争”而先访问内存。

如果并行访问被锁保护,那么根据我们的定义,就不会产生数据竞争。但是,使

用锁的程序可能不会给出确定的结果。锁可以通过防止数据结构在被更新的过程中出

现可见的中间状态来保证其一致性,但是却无法保证确定的结果。

例如,在下面的程序中:

int a = 2; // 定义一个多个工作线程可见的变量

void Strand1()

{

a = 1;

}

int Strand2()

{

return a;

}

void Strand3()

{

a = 2;

}

int main()

{

int result;

cilk_spawn Strand1();

49

result = cilk_spawn Strand2();

cilk_spawn Strand3();

cilk_sync;

std::cout << "a = " << a << ", result = " << result << std:endl;

}

由于 Strand1(),Strand2()和 Strand3()会并行执行,a和 result 的最终值会因

为执行顺序而不定。

Strand1()可能在 Strand2()读取 a 之前或之后写入 a,因此 Stand1()和

Strand2()之间存在读/写竞争。

Strand3()可能在 Strand1()写入 a 之前或之后写入 a,因此 Stand3()和

Strand1()之间存在写/写竞争。

10.2 良性竞争

某些数据竞争是良性的。换句话说,尽管存在竞争,但这些竞争并不会影响程序

的输出。

这里有一个简单的例子:

bool bFlag = false;

cilk_for (int i=0; i<N; ++i)

{

if (some_condition())

bFlag = true;

}

if (bFlag)

do_something();

这个程序中存在对变量 bFlag 的写/写竞争。但是,所有的写操作都写入相同值

(true)而且它的值在 cilk_for 结尾处隐含的 cilk_sync 之前不会被读取。

在这个例子中,数据竞争是良性的。无论循环迭代的顺序如何,程序的结果都是

一样的。

10.3 解决数据竞争

有一些方法可以解决数据竞争:

纠正程序中的错误

使用局部变量而不是全局变量

重新构造代码

更改算法

50

使用 reducer

使用锁

10.3.1 纠正程序中的错误

qsort-race 中的竞争条件是一个程序逻辑错误。导致竞争的原因是对 sort 的递归调

用使用了重叠区域,从而并行引用了相同的内存空间。解决方法是纠正这个错误。

10.3.2 使用局部变量而不是全局变量

考虑下面的程序:

#include <cilk/cilk.h>

#include <iostream>

const int IMAX=5;

const int JMAX=5;

int a[IMAX * JMAX];

int main()

{

int idx;

cilk_for (int i=0; i<IMAX; ++i)

{

for (int j=0; j<JMAX; ++j)

{

idx = i*JMAX + j; // 这是一个竞争。

a[idx] = i+j;

}

}

for (int i=0; i<IMAX*JMAX; ++i)

std::cout << i << " " << a[i] << std::endl;

return 0;

}

这个程序在变量 idx 上存在竞争,因为它被 cilk_for 循环并行访问。由于 idx 只是

在循环内部使用,通过把 idx 变成循环内的局部变量可以轻易地解决这个竞争:

int main()

{

// int idx; // 去掉全局变量

cilk_for (int i=0; i<IMAX; ++i)

{

for (int j=0; j<JMAX; ++j)

{

int idx = i*JMAX + j; // 把 idx 声明为局部变量

a[idx] = i+j;

51

}

}

}

10.3.2.1 重新构造代码

在某些情况下,简单的重写代码就可以消除竞争。这里是另外一种解决前面程序中竞

争的方法:

int main()

{

// int idx; // 去掉全局变量

cilk_for (int i=0; i<IMAX; ++i)

{

for (int j=0; j<JMAX; ++j)

{

// idx = i*JMAX + j; // 不使用 idx

a[i*JMAX + j] = i+j; // 直接计算数组下标

}

}

}

10.3.2.2 更改算法

最佳解决方案之一,虽然并不那么容易甚至不可能,是找到一个对问题进行划分

的算法使得并行被限制在不会产生竞争的计算部分。

10.3.2.3 使用 reducer

Reducer 是设计成可安全地以并行方式使用的对象。详见 Reducer 一章。

10.3.2.4 使用锁

锁可以用来解决数据竞争条件。这种方法的缺点在使用锁的注意事项一章中会加

以讨论。存在不同种类的锁,包括:

Intel® Threading Building Block 库中的 tbb:mutex 对象

Windows*或 Linux*操作系统中的系统锁

原子指令,即用于保护一个读取-更改-写入指令序列的短寿命高效率锁

下面的程序包含在 sum上的竞争,因为语句 sum=sum+i 既读取又写入 sum:

#include <cilk/cilk.h>

int main()

{

int sum = 0;

cilk_for (int i=0; i<10; ++i)

{

sum = sum + i; // 存在对 sum的竞争

}

52

}

使用锁来解决竞争:

#include <cilk/cilk.h>

#include <mutex.h>

#include <iostream>

int main()

{

tbb::mutex mut;

int sum = 0;

cilk_for (int i=0; i<10; ++i)

{

mut.lock();

sum = sum + i; // 用锁进行保护

mut.unlock();

}

std::cout << "Sum is " << sum << std::endl;

return 0;

}

请注意这个例子仅用于展示锁。通常 Reducer 是更好的解决这类竞争的方法。

53

11. 使用锁的注意事项

在硬件或操作系统中有多种同步机制的实现。

Cilk可以识别下列加锁机制:

Intel® Threading Building Blocks 库提供了 tbb::mutex用于创建临界区代

码。临界区代码中对共享内存及其它共享资源的更新与访问是安全的。Intel®

Parallel Studio 工具可以识别该加锁机制,对于通过 tbb::mutex进行保护的

内存访问不会报告数据竞争。例子 qsort-mutex展示了如何使用 tbb::mutex。

Windows*操作系统:CRITICAL_SECTION 对象的功能和 cilk::mutex 对象基本相

同。对于通过 EnterCriticalSection()、TryEnterCriticalSection()或

LeaveCriticalSection()进行保护的访问,Intel® Parallel Studio 工具不会

报告数据竞争。

Linux*操作系统:Posix* Pthread互斥锁(pthread_mutex_t)的功能和

cilk::mutex 基本相同。对于通过 pthread_mutex_lock()、

pthread_mutex_trylock()或 pthread_mutex_unlock()进行保护的访问,

Intel® Parallel Studio 工具不会报告数据竞争。

Intel® Parallel Studio 工具可以识别原子机器指令, C/C++程序员可以通过

编译器基本函数来使用这些指令。

下面是一些有用的和锁相关的术语和信息:

这些术语在使用中可以相互替代:“获取”、“进入”或者“锁上”一个锁(或

者“互斥量”)。

某个 strand(或线程)获得某个锁又被称为“拥有”这个锁。

只有拥有锁的 strand 才能够“释放”,“离开”或者“解锁”该锁。

在任一时刻,只有一个 strand 能拥有某个锁。

tbb::mutex 是通过使用操作系统的互斥操作来实现的。

在并行程序中,锁竞争会带来性能问题。虽然锁可以解决数据竞争问题,但是使

用锁的程序的运行结果往往是非确定的,因此建议尽可能的避免使用锁。

在接下来的部分,我们会深入探讨这些问题。

11.1 锁引起的确定性竞争

即使你正确地使用锁来保护某个资源(比如一个简单变量、一个列表,或者是其

它数据结构),两个 strand 对该资源进行修改操作的实际顺序仍然是不确定的。举例

来说,假定下面的代码片断是某个衍生函数的一部分,那么多个 strand 可以并行地执

行这段代码:

. . .

// Update是一个函数,它会修改全局变量 gv

54

sm.lock();

Update(gv);

sm.unlock();

. . .

由于多个 strand 会相互竞争去获取锁 sm,即使采用相同的输入数据,变量 gv 被

更新的顺序在程序多次执行中也会发生变化。这是不确定性的来源,但是它并不属于

数据竞争(数据竞争的定义请见 10.1 节)。

如果更新操作是可交换的,比如整数加法,这种不确定性不会导致不同的最终结

果。但是,很多常见操作,比如像某个列表末尾追加一个元素,是不可交换的,于是

结果就会随着锁被获取的顺序发生变化。

11.2 死锁

如果多个 strand 以不同的顺序来获取多个锁,而每一个 strand 都拥有其它

strand正在获取的锁,这就产生了死锁,这些 strand 就处于死锁状态。

下面是一个简单的例子,包含了两个 strand 片断,其目的是把一个元素由一个列表移

动到另一个列表中,使得该元素始终只在其中一个列表中。L1 和 L2 是两个列表,sm1

和 sm2 是两个用于保护 L1和 L2的 cilk::mutex对象。

// 代码片段A。把列表L1的头部移动到列表L2的尾部。

sm1.lock();

sm2.lock();

L2.push_back(*L1.begin);

L1.pop_front();

sm2.unlock();

sm1.unlock();

...

...

// 代码片段B。把列表L2的头部移动到列表L1的尾部。

sm2.lock();

sm1.lock();

L1.push_back(*L2.begin);

L2.pop_front();

sm2.unlock();

sm1.unlock();

如果一个 strand 执行代码片段 A,已经锁上了 sm1 并准备锁上 sm2,而此时另一

个 strand 执行代码片段 B,正在准备锁上 sm1,这样就会发生死锁,两个 strand 都无

法继续执行。

在代码中使用相同的顺序来获取锁,这是解决死锁最常用的办法。比如,可以把

代码片段 B 前两行代码交换一下,这样就能避免发生死锁了。你也可以调换解锁的顺

序,但仅仅调换解锁的顺序并不能完全避免死锁。

55

11.3 锁竞争对并行性的影响

如果并行的 strand 在并发地尝试去访问一个共享的锁,那么他们是无法并行地运

行的。在一些程序中,锁可能会使并行方面的性能优势丧失殆尽。在极端的情况下,

程序的运行速度甚至可能比程序串行执行速度还要慢得多。因此,尽量用 reducer 来

替代锁。

如果你必须使用锁,请遵循以下原则:

拥有同步对象(锁)的时间越短越好。获取锁,更新数据,然后释放锁。拥有锁

的时候,不要进行无关的操作。如果你的应用程序必须长时间的拥有一个锁,那

么这样的应用程序可能不适合做并行化。该原则对确保获取锁的 strand 释放该锁

有帮助。

在同一个作用域内获取和释放同一个锁。在不同的作用域获取锁和释放锁容易使

人混淆,导致忘记释放锁而造成死锁。该原则也能确保获取锁的 strand 会释放该

锁。

不要让锁跨越 cilk_spawn 或 cilk_sync边界,这也包括跨越 cilk_for 循环。下

一节会详细解释该原则。

使用相同的顺序获取多个锁,以避免死锁。使用相反的顺序释放这些锁,这不是

必须的,但是可以提高性能。

11.4 跨越 strand 边界的锁

最好而且最容易的做法是避免锁跨越 strand 边界。同辈的 strand 可以使用同一

个锁,但是父 strand 和子 strand共享一个锁存在潜在的问题。这些问题如下:

cilk_spawn 或 cilk_sync 边界后创建出的 strand 并不一定与父 strand执行在同

一个 OS线程中。大部分加锁同步对象,比如 Windows*的 CRITICAL_SECTION,必

须在分配它的线程中被释放掉。

cilk_sync 将应用程序暴露给 Cilk 运行时同步对象。这些同步对象会以不可预料的

方式影响应用程序。请看一下代码:

#include <cilk/cilk.h>

#include <mutex.h>

#include <iostream>

int child (tbb::mutex &m, int &a)

{

m.lock();

a++;

m.unlock();

}

int parent(int a, int b, int c)

{

56

cilk::mutex m;

try

{

cilk_spawn child (&m, a);

m.lock();

throw a;

}

catch (...)

{

m.unlock();

}

}

在包含了cilkcilk_spawn的try语句块后面有一个隐含的cilk_sync。如果发生异

常,程序只有等到所有的子strand完成才能继续执行。如果父strand在子strand前获

得这个锁,catch语句块将无法执行,因为子strand无法获得该锁,也就无法完成执行,

这样,程序就死锁了。使用“保护”对象或“作用域锁”(scoped lock)对象也无法

解决这个问题,因为保护对象的析构函数直到catch语句块退出时才会被执行到。

更糟的是不可见的try语句块。如果语句中使用析构函数声明了局部变量,就会有一个

隐含的try语句块包围。因此,在程序衍生或着获取一个锁的时候,它可能已经在一个

try语句块内部了。

如果一个函数拥有一个锁,而且子strand会去获取这个锁,那么这个函数在释放

这个锁之前不应该做任何可能抛出异常的事情。但是,大部分函数都无法保证不抛异

常,因此我们建议你遵守以下规则:

父strand与子strand不要获取同一个锁。也就是说,锁住同辈strand,但不要锁

住子strand。

如果你需要锁住子strand,请把获取锁、做一些工作、释放锁这些代码放到一个

单独的函数中,然后在子strand中调用这个函数。

如果一个父strand需要获取一个锁、给一些变量赋值、然后释放锁,在它拥有这

个锁的时候没有try语句块、可能抛异常的函数调用(包括重载运算符)、衍生和

同步的情况下,这一系列操作是安全的。请记住,在获取锁之前先将要赋的值计

算好。

57

12. Cilk 程序性能方面的注意事项

并行程序有很多性能方面的注意事项,而且有很多可以调优的机会。

一般而言,Cilk 运行库通过叫作工作密取的调度算法可以高效的利用处理器资源。这

种算法被设计成可以让工作从一个处理器移动到另一个处理器的次数最小化。

另外,这个算法确保了工作线程使用的内存大小处于线性范围内。换句话说,一

个 Cilk 程序运行在 N 个工作线程上时占用的内存空间大小不会大于运行在单个工作线

程时所占用内存的 N 倍。

本章将会讨论一些使用 Cilk编写的多线程程序中常见的问题。

12.1 粒度

二分法通常会构建不同大小子问题的优化混合,是一种有效的并行策略。工作密

取调度程序可以有效的将任务块分配到各个处理器上,只要没有太多过大或者过小的

任务块。如果整个任务被分成少量的较大任务块,那么就不会有足够的并行度使所有

处理器保持忙碌状态。如果任务块都很小,那么任务调度带来的额外开销就有可能超

过并行带来的好处。

粒度对于使用 cilk_for 和 cilk_spawn 的并行程序可能会是一个问题。如果使用

cilk_for,你可以通过设置循环粒度值来控制粒度。另外,如果使用嵌套循环,计算任

务本身所具有的特性将决定你是在外层循环上,内层循环上,或者两者都使用 cilk_for

可以获得最佳性能。如果使用 cilk_spawn,请小心避免衍生很小的任务块。尽管

cilk_spawn 的额外开销相对较小,衍生极小的任务仍会对性能产生损害。

12.2 首先优化串行程序

首先要确保已经应用了包括编译器优化在内的常用优化方法使 C/C++串行程序拥

有良好的性能。

作为用于展示串行程序优化重要性的一个简例,让我们看一下 matrix_multiply 这

个例子,其中的循环结构被安排成可以使 cache miss 降到最低。优化的结果为:

cilk_for(unsigned int i = 0; i < n; ++i) {

for (unsigned int k = 0; k < n; ++k) {

for (unsigned int j = 0; j < n; ++j) {

A[i*n + j] += B[i*n + k] * C[k*n + j];

}

}

}

和把内层两重循环(k 循环和 j 循环)进行交换的程序相比较而言,这种安排方式

在多次测试中都取得了显著的性能优势。在串行版本和 Cilk 并行版本中都出现了这些

性能差异。例子 matrix 拥有类似的循环结构。但是值得注意的是,我们并不能够在所

有的系统上都保证有这样的性能提高,这是因为还有许多和架构相关的因素能够对性

58

能产生影响。

12.3 程序和程序段计时

若要找到并了解性能瓶颈就必须对性能进行测量。程序中即使很小的改变都可能

导致很大的,有时甚至会令人吃惊的性能差异。性能调试的唯一可靠手段就是经常进

行测量---在各种不同的系统上进行尤佳。你可以使用任意工具或技巧,但是只有有效

的测量能够判定优化是否有效。

然而,有时性能测量可能会产生误导,所以采取某些预防措施并对潜在的性能异常现

象进行了解是很重要的。大多数预防手段都简单但在实践中却可能会被忽略。

正在运行的其它程序能够影响性能测量。即使处于空闲状态的某些程序,比如

Microsoft* Word,也会消耗处理器时间并扰乱测量。

在测量程序某两点之间的时间的时候,如果有其它 strand 在和包含起始点的函

数一起并行执行,那么就不要测量这两点之间所花的时间。

多核笔记本或其它系统上的动态频率调整特性可能会产生出乎意料的结果,尤

其是在你试图通过增加工作线程数目来使用更多的核时。因为在你增加工作线

程数目激活更多的核时,系统可能会调整时钟频率来降低功耗从而导致整体性

能的下降。

12.4 常见性能隐患

如果一个程序拥有了足够的并行度和并行负载却仍然没有较好的加速比,那么就

可能存在其它一些影响性能的因素。这里列出一些常见的原因,其中某些已在其它地

方讨论。

cilk_for 的 GrainSize 设置。如果粒度值过大,程序逻辑的并行度就会降低。如

果粒度值过小,每次衍生产生的额外开销就会抵消并行带来的好处。英特尔编

译器和运行系统使用一个缺省的公式来计算粒度值。这个公式在大多数情况下

都工作的很好。如果你的程序使用了 cilk_for,你可以通过试验不同的粒度值来

进行性能调优。

锁竞争。锁通常会降低程序并行度而影响性能。锁的用法可以通过性能优化工

具来进行进行分析。

高速缓存效率和内存带宽。将在本章后面讨论。

伪共享(False Sharing)。将在本章后面讨论。

原子操作。编译器提供的原子操作会对高速缓存行进行加锁。因此,这些操作

会和锁竞争一样影响性能。此外,由于对高速缓存是整行加锁,这样还可能导

致伪共享。

59

12.5 高速缓存效率和内存带宽

良好的高速缓存效率对于串行程序很重要,对于多核系统上的并行程序更是如此。

多个核对总线带宽的竞争限定了内存和处理器之间进行数据传输的速度。因此在设计

和实现并行程序时要考虑到高速缓存效率和数据/空间局部性。关于这些的例子可以参

考“首先优化串行程序”一节中的 matrix 和 matrix_multiply。

判定带宽问题的一个简单方法是同时运行串行程序的多个拷贝,在系统中的每个核上

分配一个拷贝。如果串行程序的平均耗时比运行一个拷贝时长很多,那么就很可能是

程序使系统带宽达到了饱和。其原因可能是内存带宽限制,或者是磁盘,网络 I/O 带

宽限制。

这些带宽引起的性能问题很多时候是和特定系统相关的。例如,在一个双核系统

(简称 S2C)运行例子程序 matrix,“ iterative parallel”版本可能会比“ iterative

sequential”慢很多(4.431 秒和 1.435 秒)。但是,“iterative parallel”版本在另一个

拥有多达 16 核和同样数目工作线程的系统上的测试中却表现出近似于线性的加速比。

这里是在 S2C 系统上的结果:

1) Naive, Iterative Algorithm. Sequential and Parallel.

Running Iterative Sequential version...

Iterative Sequential version took 1.435 seconds.

Running Iterative Parallel version...

Iterative Parallel version took 4.431 seconds.

Parallel Speedup: 0.323855

内存带宽系统间差异常有多种复杂且不可预测的原因(包括内存速度,内存通道

数目,高速缓存和内存页表架构,同一芯片上的 CPU 数目等)。请记住这些因素可能

会引起意料之外且不一致的性能结果。这些现象是并行程序本身所固有的特性,并不

专属于 Cilk程序。

12.6 伪共享

伪共享是共享内存并行处理中的常见问题。当两个或更多核拥有同一个高速缓存

行的拷贝时就会产生伪共享。

如果某个核执行内存写操作,那么其它核上与该内存地址对应的高速缓存行就会

失效。此时其它核并没有使用(读取或写入)该内存中的数据,但仍有可能使用处于

同一高速缓存行中的其它数据元素。这时第二个核必须重新将该行读入高速缓存才能

再次访问所需数据。

这样,高速缓存硬件保证了数据一致性,但是在伪共享频繁发生的情况下,其在

性能上的代价可能会变得非常高昂。判定是否存在伪共享问题的一个好办法是使用硬

件计数器或其它性能分析工具去捕获异常增大的最外层高速缓存缺失。

作为一个简单的例子,我们考虑一个衍生出来的函数包含一个将某个数组所有元

素值都加上一的 cilk_for 循环。该数组被声明为拥有 volatile 属性用于强制编译器生成

内存写入指令,而不是把值放在寄存器中或是对循环进行优化。

volatile int x[32];

void f(volatile int *p)

60

{

for (int i = 0; i < 100000000; i++)

{

++p[0];

++p[16];

}

}

int main()

{

cilk_spawn f(&x[0]);

cilk_spawn f(&x[1]);

cilk_spawn f(&x[2]);

cilk_spawn f(&x[3]);

cilk_sync;

return 0;

}

其中 a[]的元素长度为 4 字节,那么长度为 64 字节(x86 系统的常见值)的高速缓

存行一次可以放入 16 个元素。这段代码里没有数据竞争,所以循环的结果将会是正确

的。但是,各个 strand 更新相邻的数组元素所带来的对高速缓存行的竞争会使性能恶

化,有时甚至会很严重。例如,在一个 16 核的机器上所作的测试显示单个工作线程的

程序性能是 16 个工作线程的大约 40 倍,尽管在不同的系统上结果可能会变化很多。

12.7 内存分配瓶颈

众所周知,某些 Linux 和 Windows 系统上缺省使用的内存分配系统在并行程序中

会成为瓶颈。在这些系统中当一个程序在堆上分配或释放(比如使用 malloc,free,

new,或者 delete)内存空间时,运行库会使用互斥锁来保护堆数据结构不被破坏。即

使在单处理器系统上这个锁的开销仍然会很大。当多个 strand 试图同时分配或释放内

存空间时,对于锁的竞争会大幅降低程序的并行度。这意味着堆管理程序无法有效扩

展到多处理器系统上。

对于这个问题的一个解决方法是使用可缩放的内存管理器,比如 Intel® Threading

Building Block 库(TBB)提供的内存管理器。在 Cilk 程序中推荐使用 TBB 可缩放内

存分配器。

61

Appendix A. 怎样写一个新的 Reducer

你可以写一个自定义的reducer如果没有一个提供的Cilk reducer能满足你的要

求。

任何已提供的Cilk reducer都可以作为开发新的reducer的模型,尽管这些例子

会相对复杂一些。它们的实现可以在安装路径下的include目录下的reducer_*.h头

文件中找到。有两个Cilk例子包含比较简单的自定义reducer:

Hanoi例程包含一个自定义的reducer,使用递归的二分法,而不是一个cilk_for

循环,来构造出移动的步骤。

linear-recurrence 的例子包含一个自定义的reducer,使得一个线性递归能够

并行执行。

下面的部分将介绍另外两个例子。

Reducer 的组件

一个reducer可以被分成四个逻辑部分:

. 一个”View”类 – 这是reducer中的私有数据。 构造和析构函数必须是公有

的,这样运行系统才能调用他们。构造函数要根据你的reducer的标识值初始

化视图。标识值在后面会讨论。视图类要将封装的reducer类设为友阾,这样

它才能访问这些私有数据成员,或者由它来提供访问的方法。

一个”Monoid”类。独异点是一个数学概念,下面会正式定义。现在,你只需要

知道你的Monoid类必须从cilk::monoid_base<View>导出,而且必须包含

一个名为reduce的公共静态成员,它具有下列形式:

static void reduce (View *left, View *right);

一个超级对象提供每个strand的视图。它是一个按如下定义的私有成员变量:

private:

cilk::reducer<Monoid> imp_;

reducer的剩下部分提供了可以访问和修改数据的程序。习惯上,会有一个

get_value()的成员返回reducer的值。

在reducer库中的reducer使用结构struct,而不是类class来定义视图(view)

62

和独异点(Monoid)类。回忆C++中struct和class的唯一区别就是类的缺省访问是

私有的,而结构的缺省访问是公共的。

恒等值

恒等值是这样的值,当和另一个值以任一顺序(两侧同一)结合时就产生第二个

值。例如:

• 0是加法运算的恒等值

x = 0 + x = x + 0

• 1是乘法运算的恒等值

x = 1 * x = x * 1.

• 空串是字符串关联的恒等值

"abc" = "" concat "abc" = "abc" concat ""

The Monoid

在数学中,一个monoid包含一组值(类型)的集合,一个在集合上的相联操作,

和一个对集合和该操作的恒等值。所以例如(整数,+,0)是一个monoid,(实数,

*,1)也是。

在reducer库中,一个Monoid由类型T和下列5个函数定义:

reduce(T *left, T *right) 计算 *left = *left OP *right

identity(T *p)构造恒等值到未初始化的*p。

destroy(T *p) 对p指向的对象调用析构函数。

allocate(size) 返回一个指向字节数量为size的初始存储空间。

deallocate(p) 释放p指向的初始存储空间。

着5个函数必须是静态的或常量。一个满足Cilk Monoid要求的类通常是无状态的,

但是有时会包含用来初始化同一对象的状态。

monoid_base类模板是一个对很大一部分Monoid类有用的基类,这些Monoid

类的恒等值是类型T的缺省构造值,由操作符new来分配。由monoid_base导出的类

只需要定义和实现reduce函数就可以了。

Reduce函数将“右边”实例数据合并到“左边”的实例中。在运行时系统调用

reduce函数后,它就销毁右边的实例。

为了得到确定的结果,reduce函数必须实现结合操作,尽管不需要是可交换的。

如果实现的正确,reduce函数将保留串行的语义。这意味着应用程序串行执行或者使

用单个工作线程的结果,和由多个工作线程运行的结果是一样的。运行时系统以及

reduce函数的结合性保证了结果是相同的,无论工作线程或处理器的个数多少。

63

写 Reducer – 一个“Holder”的例子

这个例子说明了如何写一个简单的reducer;它没有更新的方法,而且reduce

方法不做任何事情。一个”Holder”类似于线程本地存储,但不涉及在“和操作系统

线程的一般交互”章节中描述的那些陷阱。

这里我们有意的打破除了在完全同步的情况下不能调用get_value()的规则。

这样的reducer有实际的用处。假定有一个全局的临时缓冲区,for循环的每次迭

代都要使用它。这在串行程序中是安全的,但是对于一个转换成cilk_for的循环就是不

安全的了。

考虑下面的这个倒置元素为point的数组的程序,它在交换值时使用了一个全局

的临时变量temp。

#include <cilk/cilk.h>

#include <cstdio>

class point

{

public:

point() : x_(0), y_(0), valid_(false) {};

void set (int x, int y) {

x_ = x;

y_ = y;

valid_ = true;

}

void reset() { valid_ = false; }

bool is_valid() { return valid_; }

int x() { if (valid_) return x_; else return -1; }

int y() { if (valid_) return y_; else return -1; }

private:

int x_;

int y_;

bool valid_;

};

point temp; // temp is used when swapping two elements

int main(int argc, char **argv)

{

int i;

point ary[100];

for (i = 0; i < 100; ++i)

ary[i].set(i, i);

64

cilk_for (int j = 0; j < 100 / 2; ++j)

{

// reverse the array by swapping 0 and 99, 1 and 98, etc.

temp.set(ary[j].x(), ary[j].y());

ary[j].set (ary[100-j-1].x(), ary[100-j-1].y());

ary[100-j-1].set (temp.x(), temp.y());

}

// print the results

for (i = 0; i < 100; ++i)

printf ("%d: (%d, %d)\n", i, ary[i].x(), ary[i].y());

return 0;

}

对全局变量temp存在数据竞争,但是串行程序可以正常工作。在这个例子中,

我们可以简单而安全的在cilk_for()中声明temp。然而下面描述的“holder”模型可

以用来提供一种位于Cilk strand范围内的存储形式。

解决方法就是实现和使用一个”holder” reducer.

Point_holder类就是reducer。 他使用了point 类作为视图类。Monoid类包含

了一个内容为空的reduce函数,因为保留哪个版本都不重要。point_holder的其他方

法用来访问和更新reducer的数据。

class point

{

// Define the point class here, exactly as above

};

// define the point_holder reducer

class point_holder

{

struct Monoid: cilk::monoid_base<point>

{

// reduce function does nothing

static void reduce (point *left, point *right) {}

};

private:

cilk::reducer<Monoid> imp_;

public:

point_holder() : imp_() {}

void set(int x, int y) {

point &p = imp_.view();

p.set(x, y);

}

bool is_valid() { return imp_.view().is_valid(); }

65

int x() { return imp_.view().x(); }

int y() { return imp_.view().y(); }

}; // class point_holder

为了在例程中使用point_holder reducer, 需要将 temp的声明替换成使用

reducer类。

替换

point temp; // temp is used when swapping two elements

为:

point_holder temp; // temp is used when swapping two elements

原程序的其他部分保留不变

总结一下point_holder reducer是如何工作的:

1. 一个新的point_holder创建时,缺省的构造函数就创建了一个新的实例。也就

是说,在一次密取后引用temp时。

2. reduce方法不做任何事情。因为temp被用作临时存储,不需要合并(归约)

左边和右边的实例。

3. 缺省的析构函数调用析构函数释放内存。

4. 因为在一次循环迭代中产生的本地视图的值没有和其他迭代的值结合,调用x()

和y()来获得cilk_for中的本地值是有效的。

5. 因为reduce()函数不做任何事情,缺省的构造函数不需要提供一个真实的恒等

值。

66

附录 B: 参考读物

以下提供Cilk概念和功能的附加资源。

Cilk 总体说明:

Cilk 实现项目站点(http://supertech.csail.mit.edu/cilkImp.html) 是通向MIT

Cilk项目的网关. 项目概述(http://supertech.csail.mit.edu/cilk/) 提供广泛的Cilk

历史, 实践和理论背景信息 Cilk语言扩展是基于MIT的cilk语言的概念和实现。

Cilk-5 多线程语言的实现 (http://supertech.csail.mit.edu/papers/cilk5.pdf)

由Matteo Frigo, Charles E. Leiserson, and Keith H. Randall著

结构化并行的力量(回答关于Cilk的常见问题)

(http://software.intel.com/en-us/articles/The-Power-of-Well-Structured-

Parallelism-answering-a-FAQ-about-Cilk)

串行语义:

并行程序需要串行语义的四个原因

(http://software.intel.com/en-us/articles/four-reasons-why-parallel-

programs-should-have-serial-semantics/).

例子:

确定性竞争潜伏在你的多核程序中?

(http://software.intel.com/en-us/articles/Are-Determinacy-Race-Bugs-

Lurking-in-YOUR-Multicore-Application)

重新审议全局变量 (http://software.intel.com/en-us/articles/Global-Variable-

Reconsidered)

这些文章讨论旧reducer语法, 但其概念仍适用。.

竞争条件:

什么是竞争条件?What Are Race Conditions? Some Issues and

Formalizations

(http://portal.acm.org/citation.cfm?id=130616.130623), by Robert Netzer

and Barton Miller.

67

A solution to N-body interactions

(http://software.intel.com/en-us/articles/A-cute-technique-for-avoiding-

certain-race-conditions/)讨论一个将计算分割后各部分并行执行时不会产生竞争的

机制。

Making Your Cache Go Further in These Troubled Times

(http://software.intel.com/enus/articles/Making-Your-Cache-Go-Further-in-These-Troubled-

Times) 讨论缓存友好的算法中的问题。


Recommended