此后,其他人回答了大多数问题,但我想指出一些特定的情况,其中特定的调度类型比其他类型更适合。 调度控制如何在线程之间分配循环迭代。 选择正确的时间表可能会对应用程序的速度产生重大影响。
schedule(runtime)调度表示以循环方式将迭代块静态映射到执行线程。 静态调度的好处在于,OpenMP运行时可以确保,如果您有两个具有相同迭代次数的单独循环,并使用静态调度以相同数量的线程执行它们,那么每个线程将获得完全相同的迭代范围( s)在两个平行区域中。 这在NUMA系统上非常重要:如果在第一个循环中触摸一些内存,它将驻留在执行线程所在的NUMA节点上。 然后,在第二个循环中,同一线程可以更快地访问相同的内存位置,因为它位于同一NUMA节点上。
假设有两个NUMA节点:节点0和节点1,例如 一个两个插槽的Intel Nehalem板,两个插槽均带有4核CPU。 然后,线程0、1、2和3将驻留在节点0上,线程4、5、6和7将驻留在节点1上:
| | core 0 | thread 0 |
| socket 0 | core 1 | thread 1 |
| NUMA node 0 | core 2 | thread 2 |
| | core 3 | thread 3 |
| | core 4 | thread 4 |
| socket 1 | core 5 | thread 5 |
| NUMA node 1 | core 6 | thread 6 |
| | core 7 | thread 7 |
每个内核都可以从每个NUMA节点访问内存,但是远程访问的速度比本地节点访问的速度慢(Intel的速度是1.5倍-1.9倍)。 您运行的是这样的:
char *a = (char *)malloc(8*4096);
#pragma omp parallel for schedule(static,1) num_threads(8)
for (int i = 0; i < 8; i++)
memset(&a[i*4096], 0, 4096);
在这种情况下,如果不使用大页面,则x86上Linux上一个内存页面的标准大小为4096字节。 此代码将整个32 KiB阵列schedule(runtime)归零。dynamic调用仅保留虚拟地址空间,但实际上并未“接触”物理内存(这是默认行为,除非使用其他版本的dynamic,例如将内存归零的版本)。 像guided一样)。 现在,此数组是连续的,但仅在虚拟内存中。 在物理内存中,一半将位于与套接字0相连的内存中,一半在与套接字1相连的内存中。之所以如此,是因为不同的部分被不同的线程置零,并且这些线程位于不同的内核上,并且有一种叫做“首次触摸”的东西。 NUMA策略,这意味着将内存页面分配在首先“接触”内存页面的线程所在的NUMA节点上。
| | core 0 | thread 0 | a[0] ... a[4095]
| socket 0 | core 1 | thread 1 | a[4096] ... a[8191]
| NUMA node 0 | core 2 | thread 2 | a[8192] ... a[12287]
| | core 3 | thread 3 | a[12288] ... a[16383]
| | core 4 | thread 4 | a[16384] ... a[20479]
| socket 1 | core 5 | thread 5 | a[20480] ... a[24575]
| NUMA node 1 | core 6 | thread 6 | a[24576] ... a[28671]
| | core 7 | thread 7 | a[28672] ... a[32768]
现在让我们运行另一个循环,如下所示:
#pragma omp parallel for schedule(static,1) num_threads(8)
for (i = 0; i < 8; i++)
memset(&a[i*4096], 1, 4096);
每个线程将访问已映射的物理内存,并且与第一个循环期间的线程到内存区域的映射相同。 这意味着线程将仅访问位于其本地内存块中的内存,这将是快速的。
现在想象第二个循环使用另一个调度方案:schedule(runtime)。这将把迭代空间“砍”成两个迭代的块,总共将有4个这样的块。 将会发生的是,我们将具有以下线程到内存的位置映射(通过迭代编号):
| | core 0 | thread 0 | a[0] ... a[8191]
| socket 0 | core 1 | thread 1 | a[8192] ... a[16383]
| NUMA node 0 | core 2 | thread 2 | a[16384] ... a[24575]
| | core 3 | thread 3 | a[24576] ... a[32768]
| | core 4 | thread 4 |
| socket 1 | core 5 | thread 5 |
| NUMA node 1 | core 6 | thread 6 |
| | core 7 | thread 7 |
这里发生两件事:
线程4至7保持空闲状态,并且一半的计算能力丢失;
线程2和3会访问非本地内存,这将花费它们大约两倍的时间来完成,在此期间线程0和1将保持空闲状态。
因此,使用静态调度的优点之一是它可以提高内存访问的局部性。 缺点是调度参数选择不当会破坏性能。
schedule(runtime)调度工作以“先到先得”为基础。 两次运行具有相同数量的线程可能(并且很可能会)产生完全不同的“迭代空间”->“线程”映射,因为一个映射可以轻松验证:
$ cat dyn.c
#include
#include
int main (void)
{
int i;
#pragma omp parallel num_threads(8)
{
#pragma omp for schedule(dynamic,1)
for (i = 0; i < 8; i++)
printf("[1] iter %0d, tid %0d\n", i, omp_get_thread_num());
#pragma omp for schedule(dynamic,1)
for (i = 0; i < 8; i++)
printf("[2] iter %0d, tid %0d\n", i, omp_get_thread_num());
}
return 0;
}
$ icc -openmp -o dyn.x dyn.c
$ OMP_NUM_THREADS=8 ./dyn.x | sort
[1] iter 0, tid 2
[1] iter 1, tid 0
[1] iter 2, tid 7
[1] iter 3, tid 3
[1] iter 4, tid 4
[1] iter 5, tid 1
[1] iter 6, tid 6
[1] iter 7, tid 5
[2] iter 0, tid 0
[2] iter 1, tid 2
[2] iter 2, tid 7
[2] iter 3, tid 3
[2] iter 4, tid 6
[2] iter 5, tid 1
[2] iter 6, tid 5
[2] iter 7, tid 4
(当使用schedule(runtime)时,观察到相同的行为)
如果使用dynamic调度运行了schedule(runtime)部分的示例代码,则保留原始位置的机会只有1/70(1.4%),发生远程访问的机会只有69/70(98.6%)。 这个事实经常被忽略,因此实现了次优的性能。
在schedule(runtime)和dynamic调度之间进行选择还有另一个原因-工作负载平衡。 如果每次迭代所花费的时间与平均时间相差很大,那么在静态情况下可能会出现很高的工作失衡。 例如,完成迭代的时间随迭代次数线性增长的情况。 如果在两个线程之间静态分配迭代空间,则第二个线程的工作量将是第一个线程的三倍,因此在2/3的计算时间内,第一个线程将处于空闲状态。 动态计划会带来一些额外的开销,但在这种情况下将导致更好的工作负载分配。 dynamic的一种特殊调度是guided,其中随着工作的进行,每个任务的迭代块越来越小。
由于预编译的代码可以在各种平台上运行,因此,如果最终用户可以控制调度,那就很好了。 这就是OpenMP提供特殊的schedule(runtime)子句的原因。 使用runtime进行调度时,该类型取自环境变量OMP_SCHEDULE的内容。这允许测试不同的调度类型而无需重新编译应用程序,并且还允许最终用户针对其平台进行微调。