第一个 OpenMP 程序
入门:OpenMP 的一大特点就是您只需完成标准的 GCC 安装即可。支持 OpenMP 的程序必须使用 -fopenmp
选项进行编译。(也可以参考在VS中使用OpenMP)
让我们先从一个 Hello, World! 打印应用程序开始,它包括一个额外的编译指示.
清单1:使用了 OpenMP 的 Hello World 程序
1 #include <iostream> 2 #include<omp.h> 3 int main() 4 { 5 #pragma omp parallel 6 { 7 std::cout << "Hello World! "; 8 } 9 }
在使用 g++ 编译和运行清单 1 中的代码时,控制台中应该会显示一个 Hello, World!。现在,使用 -fopenmp
选项重新编译代码。清单 2 显示了输出
-fopenmp
命令编译并运行代码1 tintin$ g++ test1.cpp -fopenmp 2 tintin$ ./a.out 3 Hello World! 4 Hello World! 5 Hello World! 6 Hello World!
发生了什么?#pragma omp parallel
仅在您指定了 -fopenmp
编译器选项后才会发挥作用。在编译期间,GCC 会根据硬件和操作系统配置在运行时生成代码,创建尽可能多的线程。每个线程的起始例程为代码块中位于指令之后的代码。这种行为是 隐式的并行化,而 OpenMP 本质上由一组功能强大的编译指示组成,帮您省去了编写大量样本文件的工作。(为了进行比较,您需要了解使用 Portable Operating System Interface (POSIX) 线程 [pthreads] 实现您刚才的程序将会怎样)。我这个测试是在虚拟机里进行的,使用的计算机运行的是 Intel® Core i5 处理器,分配有两个物理核心,每个核心有两个逻辑核心,因此清单 2 的输出看上去非常合理(4 个线程 = 4 个逻辑核心)。
接下来,让我们详细了解并行编译指示。
使用 OpenMP 实现并行处理的乐趣
使用编译命令的 num_threads
参数控制线程的数量非常简单。下面显示了 清单 1 中的代码,可用线程的数量被指定为 5(如 清单 3 所示)。
num_threads
控制线程的数量1 #include <iostream> 2 #include<omp.h> 3 4 int main() 5 { 6 #pragma omp parallel num_threads(5) 7 { 8 std::cout << "Hello World! "; 9 } 10 }
这里没有使用 num_threads
方法,而是使用另一种方法来修改运行代码的线程的数量。这还会将我们带到您将要使用的第一个 OpenMP API:omp_set_num_threads
。在 omp.h 头文件中定义该函数。不需要链接到额外的库就可以获得 清单 4 中的代码,只需使用 -fopenmp
。
omp_set_num_threads
对线程的创建进行调优#include <iostream> #include<omp.h> int main() { omp_set_num_threads(5); #pragma omp parallel { std::cout << "Hello World! "; } }
最后,OpenMP 还使用了外部环境变量控制其行为。您可以调整 清单 2 中的代码,通过将 OMP_NUM_THREADS
变量设置为 6
,就可以将 Hello World! 输出六次。清单 5 显示了代码的执行.
1 tintin$ export OMP_NUM_THREADS=6 2 tintin$ ./a.out 3 Hello World! 4 Hello World! 5 Hello World! 6 Hello World! 7 Hello World! 8 Hello World!
目前,我们已经讨论了 OpenMP 的三个方面:编译指示、运行时 API 和环境变量。同时使用环境变量和运行时 API 会出现什么情况?运行时 API 将获得更高的优先权。
一个实际示例for
循环中执行顺序处理1 int main( ) 2 { 3 int a[1000000], b[1000000]; 4 // ... some initialization code for populating arrays a and b; 5 int c[1000000]; 6 for (int i = 0; i < 1000000; ++i) 7 c[i] = a[i] * b[i] + a[i-1] * b[i+1]; 8 // ... now do some processing with array c 9 }
显然,您可以将 for
循环分为几个部分,在不同的核心中运行它们,任何 c[k]
与 c
数组中的其他元素都是独立的。清单 7 显示了 OpenMP 如何帮助您实现这一点。
for
循环中使用 parallel for
指令进行并行处理1 int main( ) 2 { 3 int a[1000000], b[1000000]; 4 // ... some initialization code for populating arrays a and b; 5 int c[1000000]; 6 #pragma omp parallel for 7 for (int i = 0; i < 1000000; ++i) 8 c[i] = a[i] * b[i] + a[i-1] * b[i+1]; 9 // ... now do some processing with array c 10 }
parallel for 编译指示可以帮助您将 for
循环工作负载划分到多个线程中,每个线程都可以在不同的核心上运行,这显著减少了总的计算时间。清单 8 演示了这一点。
清单 8. 理解 omp_get_wtime
1 #include <omp.h> 2 #include <math.h> 3 #include <time.h> 4 #include <iostream> 5 6 int main(int argc, char *argv[]) { 7 int i, nthreads; 8 clock_t clock_timer; 9 double wall_timer; 10 double c[1000000]; 11 for (nthreads = 1; nthreads <=8; ++nthreads) { 12 clock_timer = clock(); 13 wall_timer = omp_get_wtime(); 14 #pragma omp parallel for private(i) num_threads(nthreads) 15 for (i = 0; i < 1000000; i++) 16 c[i] = sqrt(i * 4 + i * 2 + i); 17 std::cout << "threads: " << nthreads << " time on clock(): " << 18 (double) (clock() - clock_timer) / CLOCKS_PER_SEC 19 << " time on wall: " << omp_get_wtime() - wall_timer << " "; 20 } 21 }
在 清单 8 中,可以通过不断增加线程的数量来计算运行内部 for
循环的时间。omp_get_wtime
API 从一些任意的但是一致的点返回已用去的时间,以秒为单位。因此,omp_get_wtime() - wall_timer
将返回观察到的所用时间并运行 for
循环。clock()
系统调用用于预估整个程序的处理器使用时间,也就是说,将各个特定于线程的处理器使用时间相加,然后报告最终的结果。在我的 Intel Core i5 计算机中,清单 9 显示了最终的报告结果。
清单 9. 运行内部 for 循环的统计数据
1 threads: 1 time on clock(): 0.020236 time on wall: 0.0204209 2 threads: 2 time on clock(): 0.015448 time on wall: 0.00773731 3 threads: 3 time on clock(): 0.021125 time on wall: 0.00737248 4 threads: 4 time on clock(): 0.032429 time on wall: 0.00890261 5 threads: 5 time on clock(): 0.018109 time on wall: 0.00647498 6 threads: 6 time on clock(): 0.017804 time on wall: 0.00584579 7 threads: 7 time on clock(): 0.018956 time on wall: 0.00893618 8 threads: 8 time on clock(): 0.018512 time on wall: 0.00647849
尽管处理器时间在所有执行中都是相同的(应该是相同的,除去创建线程和上下文切换的时间),但是所用时间将随着线程数量的增加而逐渐减小,这意味着数据在核心中实现了并行处理。关于指令语法,最后一点需要注意的是:#pragma parallel for private(i)
意味着循环向量 i
将被作为一个线程本地存储进行处理,每个线程有一个该向量的副本。线程本地向量未进行初始化.
OpenMP 的临界区段(critical section)
您没有真正考虑过 OpenMP 是如何自动处理临界区段的,是吗?当然,您不需要显式创建一个互斥现象 (mutex),但是您仍然需要指定临界区段。下面显示了相关语法:
1 #pragma omp critical (optional section name) 2 { 3 // no 2 threads can execute this code block concurrently 4 }
pragma omp critical 之后的代码只能由一个线程在给定时间运行。同样,optional section name
是一个全局标识符,在同一时间,两个线程不能使用相同的全局标识符名称运行临界区段。请考虑 清单 10 中的代码。
1 #pragma omp critical (section1) 2 { 3 myhashtable.insert("key1", "value1"); 4 } 5 // ... other code follows 6 #pragma omp critical (section1) 7 { 8 myhashtable.insert("key2", "value2"); 9 }
在这一代码的基础上,您可以作出一个很安全的假设:永远不会出现两个散列表同时插入的情况,因为临界区段名是相同的。这与您在使用 pthread 时处理临界区段的方式有着明显的不同,后者的特点就是使用(或滥用)锁。
使用 OpenMP 实现锁和互斥
有趣的是,OpenMP 提供了自己的互斥(因此,它并不是全部关于编译指示):omp_lock_t
,它被定义为 omp.h 头文件的一部分。常见的 pthread 形式的语法操作也适用,甚至 API 名称也是类似的。您需要了解以下 5 个 API:
omp_init_lock
:此 API 必须是第一个访问omp_lock_t
的 API,并且要使用它来完成初始化。注意,在完成初始化之后,锁被认为处于未设置状态。omp_destroy_lock
:此 API 会破坏锁。在调用该 API 时,锁必须处于未设置状态,这意味着您无法调用omp_set_lock
并随后发出调用来破坏这个锁。omp_set_lock
:此 API 设置omp_lock_t
,也就是说,将会获得互斥。如果一个线程无法设置锁,那么它将继续等待,直到能够执行锁操作。omp_test_lock
:此 API 将在锁可用时尝试执行锁操作,并在获得成功后返回1
,否则返回0
。这是一个非阻塞 API, 也就是说,该函数不需要线程等待就可以设置锁。omp_unset_lock
:此 API 将会释放锁。
清单 11 显示了一个遗留的单线程队列,它被扩展为可使用 OpenMP 锁实现多线程处理。请注意,这一行为并不适合所有场景,这里主要用它来进行快速演示。
清单 11. 使用 OpenMP 扩展一个单线程队列1 #include <openmp.h> 2 #include "myqueue.h" 3 4 class omp_q : public myqueue<int> { 5 public: 6 typedef myqueue<int> base; 7 omp_q( ) { 8 omp_init_lock(&lock); 9 } 10 ~omp_q() { 11 omp_destroy_lock(&lock); 12 } 13 bool push(const int& value) { 14 omp_set_lock(&lock); 15 bool result = this->base::push(value); 16 omp_unset_lock(&lock); 17 return result; 18 } 19 bool trypush(const int& value) 20 { 21 bool result = omp_test_lock(&lock); 22 if (result) { 23 result = result && this->base::push(value); 24 omp_unset_lock(&lock); 25 } 26 return result; 27 } 28 // likewise for pop 29 private: 30 omp_lock_t lock; 31 };
嵌套锁
OpenMP 提供的其他类型的锁为 omp_nest_lock_t
锁的变体。它们与 omp_lock_t
类似,但是有一个额外的优势:已经持有锁的线程可以多次锁定这些锁。每当持有锁的线程使用 omp_set_nest_lock
重新获得嵌套锁时,内部计数器将会加一。当一个或多个对 omp_unset_nest_lock
的调用最终将这个内部锁计数器重置为 0
时,就会释放该锁。下面显示了用于 omp_nest_lock_t
的 API:
omp_init_nest_lock(omp_nest_lock_t* )
:此 API 将内部嵌套计数初始化为0
。omp_destroy_nest_lock(omp_nest_lock_t* )
:此 API 将破坏锁。使用非零内部嵌套计数对某个锁调用此 API 将会导致出现未定义的行为。omp_set_nest_lock(omp_nest_lock_t* )
:此 API 类似于omp_set_lock
,不同之处是线程可以在已持有锁的情况下多次调用这个函数。omp_test_nest_lock(omp_nest_lock_t* )
:此 API 是omp_set_nest_lock
的非阻塞版本。omp_unset_nest_lock(omp_nest_lock_t* )
:此 API 将在内部计数器为0
时释放锁。否则,计数器将在每次调用该方法时递减。
对任务执行的细粒度控制
在并行计算中,粒度是计算与通信之比的定性度量。最有效的粒度取决于算法及其运行的硬件环境,在大多数情况下,与通信和同步相关的开销相对于执行速度而言是高的,因此具有粗粒度是有利的;但细粒度并行可以帮助减少由于负载不平衡导致的开销。
我们已经了解了所有线程以并行的方式运行 pragma omp parallel
之后的代码块。您可以对代码块中的代码进一步分类,然后由选定的线程执行它。请考虑 清单 12 中的代码。
1 #include<iostream> 2 #include<omp.h> 3 using namespace std; 4 5 int main( ) 6 { 7 #pragma omp parallel num_threads(8) 8 { 9 cout << "All threads run this "; 10 #pragma omp sections 11 { 12 #pragma omp section 13 { 14 cout << "This executes in parallel "; 15 } 16 #pragma omp section 17 { 18 cout << "Sequential statement 1 "; 19 cout << "This always executes after statement 1 "; 20 } 21 #pragma omp section 22 { 23 cout << "This also executes in parallel "; 24 } 25 } 26 } 27 return 0; 28 }
pragma omp sections 和 pragma omp parallel
之间的代码将由所有线程并行运行。pragma omp sections
之后的代码块通过 pragma omp section
进一步被分为各个子区段。每个 pragma omp section
块将由一个单独的线程执行。但是,区段块中的各个指令始终按顺序运行。清单 13 显示了清单 12 中代码的输出。
1 All threads run this 2 This executes in parallel 3 Sequential statement 1 4 This always executes after statement 1 5 This also executes in parallel 6 All threads run this 7 All threads run this 8 All threads run this 9 All threads run this 10 All threads run this 11 All threads run this 12 All threads run this
在清单 13 中,您将再次一开始就创建 8 个线程。对于这 8 个线程,只需使用三个线程执行 pragma omp sections
代码块中的工作。在第二个区段中,您指定了输出语句的运行顺序。这就是使用 sections
编译指示的全部意义。如果需要的话,您可以指定代码块的顺序。
理解与并行循环一起使用的 firstprivate
和 lastprivate
指令
在前文中,我们了解了如何使用 private
声明线程本地存储。那么,您应当如何初始化线程本地变量呢?在运行之前使用主线程中的变量的值同步本地变量?此时,firstprivate
指令就可以发挥作用了。
firstprivate 指令
使用 firstprivate(variable)
,您可以将线程中的变量初始化为它在主线程中的任意值。请参考 清单 14 中的代码。
1 #include <stdio.h> 2 #include <omp.h> 3 4 int main() 5 { 6 int idx = 100; 7 #pragma omp parallel private(idx) 8 { 9 printf("In thread %d idx = %d ", omp_get_thread_num(), idx); 10 } 11 }
下面是我得到的输出。您的结果可能有所不同。
1 In thread 4 idx = 0 2 In thread 1 idx = 32660 3 In thread 2 idx = 0 4 In thread 3 idx = 0 5 In thread 5 idx = 0 6 In thread 6 idx = 0 7 In thread 0 idx = 0 8 In thread 7 idx = 0
清单 15 显示了带有 firstprivate
指令的代码。和期望的一样,输出在所有线程中将 idx
初始化为 100
。
firstprivate
指令初始化线程本地变量1 #include <stdio.h> 2 #include <omp.h> 3 4 int main() 5 { 6 int idx = 100; 7 #pragma omp parallel firstprivate(idx) 8 { 9 printf("In thread %d idx = %d ", omp_get_thread_num(), idx); 10 } 11 }
还要注意的是,您使用了 omp_get_thread_num( )
方法访问线程的 ID。这与 Linux®top
命令显示的线程 ID 不同,并且这只是 OpenMP 用于跟踪线程数量的一种方式。如果您准备将 firstprivate
用于您的 C++
代码,那么还要注意,firstprivate
指令使用的变量是一个副本构造函数,用于从主线程的变量初始化自身,因此对您的类使用一个私有的副本构造函数肯定会产生不好的结果。现在让我们了解一下lastprivate
指令,该指令在很多方面与 firstprivate
正好相反。
lastprivate
指令
与使用主线程的数据初始化线程本地变量不同,您现在将使用最后一次循环计数生成的数据同步主线程的数据。清单 16 中的代码运行了并行的 for
循环。
for
循环,没有与主线程进行同步1 #include <stdio.h> 2 #include <omp.h> 3 4 int main() 5 { 6 int idx = 100; 7 int main_var = 2120; 8 9 #pragma omp parallel for private(idx) 10 for (idx = 0; idx < 12; ++idx) 11 { 12 main_var = idx * idx; 13 printf("In thread %d idx = %d main_var = %d ", 14 omp_get_thread_num(), idx, main_var); 15 } 16 printf("Back in main thread with main_var = %d ", main_var); 17 }
在我的拥有 4 个核心的开发计算机上,OpenMP 为 parallel for
块创建了 3 个线程。每个线程执行两次循环迭代。main_var
的最终值取决于最后一个运行的线程,即该线程中的 idx
的值。换言之,main_var
的值并不取决于 idx
的最后一个值,而是任意一个最后运行的线程中的 idx
的值。清单 17 的代码解释了这一点。
1 In thread 2 idx = 6 main_var = 36 2 In thread 2 idx = 7 main_var = 49 3 In thread 2 idx = 8 main_var = 64 4 In thread 0 idx = 0 main_var = 0 5 In thread 0 idx = 1 main_var = 1 6 In thread 0 idx = 2 main_var = 4 7 In thread 3 idx = 9 main_var = 81 8 In thread 3 idx = 10 main_var = 100 9 In thread 3 idx = 11 main_var = 121 10 In thread 1 idx = 3 main_var = 9 11 In thread 1 idx = 4 main_var = 16 12 In thread 1 idx = 5 main_var = 25 13 Back in main thread with main_var = 25
多次运行清单 17 中的代码,确定主线程中的 main_var
的值始终依赖于最后运行的线程中的 idx
的值。那么如果您希望同步主线程的值与循环中 idx
的最终值,该怎样做呢?这就是 lastprivate
指令发挥其作用的地方,如 清单 18 所示。与清单 17 中的代码相似,多次运行清单 18 中的代码会发现主线程中的 main_var
的最终值为 121
(idx
为最终的循环计数器值)。
多次运行清单 17 中的代码,确定主线程中的 main_var
的值始终依赖于最后运行的线程中的 idx
的值。那么如果您希望同步主线程的值与循环中 idx
的最终值,该怎样做呢?这就是 lastprivate
指令发挥其作用的地方,如 清单 18 所示。与清单 17 中的代码相似,多次运行清单 18 中的代码会发现主线程中的 main_var
的最终值为 121
(idx
为最终的循环计数器值)。
lastprivate
指令实现同步1 #include <stdio.h> 2 #include <omp.h> 3 4 int main() 5 { 6 int idx = 100; 7 int main_var = 2120; 8 9 #pragma omp parallel for private(idx) lastprivate(main_var) 10 for (idx = 0; idx < 12; ++idx) 11 { 12 main_var = idx * idx; 13 printf("In thread %d idx = %d main_var = %d ", 14 omp_get_thread_num(), idx, main_var); 15 } 16 printf("Back in main thread with main_var = %d ", main_var); 17 }
main_var always
值的等于 121)In thread 1 idx = 3 main_var = 9 In thread 1 idx = 4 main_var = 16 In thread 1 idx = 5 main_var = 25 In thread 2 idx = 6 main_var = 36 In thread 2 idx = 7 main_var = 49 In thread 2 idx = 8 main_var = 64 In thread 0 idx = 0 main_var = 0 In thread 0 idx = 1 main_var = 1 In thread 0 idx = 2 main_var = 4 In thread 3 idx = 9 main_var = 81 In thread 3 idx = 10 main_var = 100 In thread 3 idx = 11 main_var = 121 Back in main thread with main_var = 121
最后一个注意事项:要支持对 C++
对象使用 lastprivate
操作符,则需要相应的类具有公开可用的 operator=
方法。
使用 OpenMP 实现 merge sort
让我们看一个 OpenMP 将会帮助您节省运行时间的真实示例。这并不是一个对 merge sort
进行大量优化后的版本,但是足以显示在代码中使用 OpenMP 的好处。清单 20 显示了示例的代码。
1 #include <omp.h> 2 #include <vector> 3 #include <iostream> 4 using namespace std; 5 6 vector<long> merge(const vector<long>& left, const vector<long>& right) 7 { 8 vector<long> result; 9 unsigned left_it = 0, right_it = 0; 10 11 while(left_it < left.size() && right_it < right.size()) 12 { 13 if(left[left_it] < right[right_it]) 14 { 15 result.push_back(left[left_it]); 16 left_it++; 17 } 18 else 19 { 20 result.push_back(right[right_it]); 21 right_it++; 22 } 23 } 24 25 // Push the remaining data from both vectors onto the resultant 26 while(left_it < left.size()) 27 { 28 result.push_back(left[left_it]); 29 left_it++; 30 } 31 32 while(right_it < right.size()) 33 { 34 result.push_back(right[right_it]); 35 right_it++; 36 } 37 38 return result; 39 } 40 41 vector<long> mergesort(vector<long>& vec, int threads) 42 { 43 // Termination condition: List is completely sorted if it 44 // only contains a single element. 45 if(vec.size() == 1) 46 { 47 return vec; 48 } 49 50 // Determine the location of the middle element in the vector 51 std::vector<long>::iterator middle = vec.begin() + (vec.size() / 2); 52 53 vector<long> left(vec.begin(), middle); 54 vector<long> right(middle, vec.end()); 55 56 // Perform a merge sort on the two smaller vectors 57 58 if (threads > 1) 59 { 60 #pragma omp parallel sections 61 { 62 #pragma omp section 63 { 64 left = mergesort(left, threads/2); 65 } 66 #pragma omp section 67 { 68 right = mergesort(right, threads - threads/2); 69 } 70 } 71 } 72 else 73 { 74 left = mergesort(left, 1); 75 right = mergesort(right, 1); 76 } 77 78 return merge(left, right); 79 } 80 81 int main() 82 { 83 vector<long> v(1000000); 84 for (long i=0; i<1000000; ++i) 85 v[i] = (i * i) % 1000000; 86 v = mergesort(v, 1); 87 for (long i=0; i<1000000; ++i) 88 cout << v[i] << " "; 89 }
使用 8 个线程运行 merge sort
使运行时的执行时间变为 2.1 秒,而使用一个线程时,该时间为 3.7 秒。此处您惟一需要注意的是线程的数量。我使用了 8 个线程:具体的数量取决于系统的配置。但是,如果不明确指定的话,那么有可能会创建成千上百个线程,并且系统性能很可能会下降。前面讨论的 sections
编译指示可以很好地优化 merge sort
代码。
结束语
本文到此结束。我们在文章中介绍了大量内容:OpenMP 并行编译指示;学习了创建线程的不同方法;了解了 OpenMP 提供的更好的时间性能、同步和细粒度控制;并通过 merge sort
实际应用了 OpenMP。但是,有关 OpenMP 需要学习的内容还有很多,学习 OpenMP 的最好的地方就是 OpenMP 项目网站。请务必查看 参考资料 部分,获得有关的其他详细信息。
相关主题
- 请务必访问 OpenMP 项目网站。
- 有关提高 merge-sort 性能的更多信息,请参阅 Atanas Radenski 撰写的文章 Shared Memory, Message Passing, and Hybrid Merge Sorts for Standalone and Clustered SMPs。