用C++ 17并行算法实现更好的性能【翻译自微软】

本文介绍了如何利用C++ 17的并行算法来提升程序性能,通过示例展示了如何使用并行算法进行排序,并强调了并行化并非总是更快,需要考虑硬件、算法安全性和执行策略。同时,文中也探讨了MSVC并行算法的局限性和设计目标,以及如何测试并行算法的性能。
摘要由CSDN通过智能技术生成

原文链接
博主只是翻译…

用C++ 17并行算法实现更好的性能

作者:Billy

2018年9月11日

这篇文章是微软的C++产品团队和其他客人回答我们从客户那里收到的问题的一系列常规文章的一部分。这些问题可以是任何C++相关的:MSVC工具集,标准语言和库,C++标准委员会,ISOCPP.ORG,CppCon等等。今天的帖子是由Billy O’Neal写的

C++ 17增加了对标准库的并行算法的支持,以帮助程序利用并行执行来提高性能。MSVC在15.5中首次添加了对某些算法的实验支持,15.7中删除了实验标记。

并行算法标准中描述的接口并不能精确地说明给定工作负载的并行化方式。特别是,该接口旨在以一种适用于异构机器的通用形式表示并行性,允许类似于SSE、AVX或NEON公开的SIMD并行性、类似于GPU编程模型公开的向量“通道”以及传统的线程并行性。

我们的并行算法实现目前完全依赖于库支持,而不是编译器的特殊支持。这意味着我们的实现将与当前使用我们标准库的任何工具一起工作,而不仅仅是MSVC的编译器。特别是,我们测试它是否与clang/llvm以及支持IntelliSense的EDG版本一起工作。

如何使用并行算法

要使用并行算法库,可以执行以下步骤:

在程序中查找一个您希望使用并行性进行优化的算法调用。好的候选者是比O(N)更有效的算法,其工作方式类似于排序,并且在分析应用程序时显示出占用了合理的时间。

验证您提供给算法的代码是否可以安全地并行化。

选择并行执行策略。(执行策略如下所述。)

如果您还没有,请调用#include以使并行执行策略可用。

将一个执行策略作为第一个参数添加到算法调用中以进行并优化。

对结果进行基准测试,以确保并行版本得到改进。并行化并不总是更快的,特别是对于非随机访问迭代器,或者当输入大小或很小时,或者当额外的并行性在外部资源(如磁盘)上产生争用时。

为了举例,这里有一个程序,我们想让它更快。它乘以一百万个双打需要多长时间。

//  debug: cl /EHsc /W4 /WX /std:c++latest /Fedebug /MDd .\program.cpp
//  release: cl /EHsc /W4 /WX /std:c++latest /Ferelease /MD /O2 .\program.cpp
#include <stddef.h>
#include <stdio.h>
#include <algorithm>
#include <chrono>
#include <random>
#include <ratio>
#include <vector>

using std::chrono::duration;
using std::chrono::duration_cast;
using std::chrono::high_resolution_clock;
using std::milli;
using std::random_device;
using std::sort;
using std::vector;

const size_t testSize = 1'000'000;
const int iterationCount = 5;

void print_results(const char *const tag, const vector<double>& sorted,
                   high_resolution_clock::time_point startTime,
                   high_resolution_clock::time_point endTime) {
  printf("%s: Lowest: %g Highest: %g Time: %fms\n", tag, sorted.front(),
         sorted.back(),
         duration_cast<duration<double, milli>>(endTime - startTime).count());
}

int main() {
  random_device rd;

  // generate some random doubles:
  printf("Testing with %zu doubles...\n", testSize);
  vector<double> doubles(testSize);
  for (auto& d : doubles) {
    d = static_cast<double>(rd());
  }

  // time how long it takes to sort them:
  for (int i = 0; i < iterationCount; ++i)
  {
    vector<double> sorted(doubles);
    const auto startTime = high_resolution_clock::now();
    sort(sorted.begin(), sorted.end());
    const auto endTime = high_resolution_clock::now();
    print_results("Serial", sorted, startTime, endTime);
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

// compile with:
//  debug: cl /EHsc /W4 /WX /std:c++latest /Fedebug /MDd .\program.cpp
//  release: cl /EHsc /W4 /WX /std:c++latest /Ferelease /MD /O2 .\program.cpp
#include <stddef.h>
#include <stdio.h>
#include <algorithm>
#include <chrono>
#include <random>
#include <ratio>
#include <vector>
 
using std::chrono::duration;
using std::chrono::duration_cast;
using std::chrono::high_resolution_clock;
using std::milli;
using std::random_device;
using std::sort;
using std::vector;
 
const size_t testSize = 1'000'000;
const int iterationCount = 5;
 
void print_results(const char *const tag, const vector<double>& sorted,
                   high_resolution_clock::time_point startTime,
                   high_resolution_clock::time_point endTime) {
  printf("%s: Lowest: %g Highest: %g Time: %fms\n", tag, sorted.front(),
         sorted.back(),
         duration_cast<duration<double, milli>>(endTime - startTime).count());
}
 
int main() {
  random_device rd;
 
  // generate some random doubles:
  printf("Testing with %zu doubles...\n", testSize);
  vector<double> doubles(testSize);
  for (auto& d : doubles) {
    d = static_cast<double>(rd());
  }
 
  // time how long it takes to sort them:
  for (int i = 0; i < iterationCount; ++i)
  {
    vector<double> sorted(doubles);
    const auto startTime = high_resolution_clock::now();
    sort(sorted.begin(), sorted.end());
    const auto endTime = high_resolution_clock::now();
    print_results("Serial", sorted, startTime, endTime);
  }
}
并行算法依赖于可用的硬件并行性,因此请确保您测试的是您关心其性能的硬件。你不需要太多的核心来显示编译成功,许多并行算法都是分而治之的,这些问题无论如何都不能显示出线程数量的完美缩放,但是更多的仍然更好。在本例中,我们在一个18核36线程的Intel7980XE系统上进行了测试。在该测试中,该程序的调试和发布版本生成了以下输出:

[文本]

.\调试.exe

用1000000双测试…

序列号:最低:1349最高:4.29497E+09时间:310.176500ms

序列号:最低:1349最高:4.29497E+09时间:304.71480ms

序列号:最低:1349最高:4.29497E+09时间:310.345800ms

序列号:最低:1349最高:4.29497E+09时间:303.302200ms

序列号:最低:1349最高:4.29497E+09时间:290.694300ms

C:\users\bion\desktop&gt;.\release.exe

用1000000双测试…

序列号:最低:2173最高:4.29497E+09时间:74.590400毫秒

序列号:最低:2173最高:4.29497E+09时间:75.703500ms

序列号:最低:2173最高:4.29497E+09时间:87.839700毫秒

序列号:最低:2173最高:4.29497E+09时间:73.822300ms

序列号:最低:2173最高:4.29497E+09时间:73.757400毫秒

[/文本]

接下来,我们需要确保我们的排序函数调用可以安全地运行。如果“元素访问函数”(即迭代器操作、谓词和您要求算法代表您执行的任何其他操作)遵循正常的“任意数量的读卡器或至多一个编写器”编译规则,则算法可以安全地并行。此外,它们不能溢出(或者很少抛出溢出,如果它们确实溢出,那么终止程序是可以的)。
接下来,选择一个执行策略。

目前,STD:标准包括并行策略,由STD::Real::Par和并行未排序策略表示,STD::执行::PARIOUNSEQ。除了并行策略公开的需求之外,并行未排序策略还要求元素访问函数,能够兼容前进进度保证的情况。这意味着它们不获取密码或者执行需要线程并发执行才能取得进展的操作。例如,如果并行算法在GPU上运行并尝试获取一个旋转锁,旋转锁上的线程可能会阻止GPU上的其他线程执行,这意味着旋转锁可能永远不会被持有它的线程解锁,从而使程序死锁。在C++的标准中,您可以阅读更多关于算法[并行.DENNS]和[算法,并行,Exc]部分的细节要求。如果有疑问,请使用并行策略。在这个例子中,我们使用的是内置的double less than运算符,它不带任何锁,以及标准库提供的迭代器类型,因此我们可以使用并行的未排序策略。

请注意,VisualC++实现以相同的方式实现并行和并行未排序策略,因此您不应该期望在我们的实现上使用PARIUUNSEQ更好的性能,但是可能存在可以在将来使用额外的自由的实现。

在上面的双精度排序示例中,我们现在可以添加

#include < execution >

在我们的计划中处于领先地位。由于我们使用并行未排序策略,所以我们将STD::执行::PARIUnSEQ到算法调用站点。(如果我们使用并行策略,我们将使用STD::执行::PAR)。
for (int i = 0; i < iterationCount; ++i)
{
  vector<double> sorted(doubles);
  const auto startTime = high_resolution_clock::now();
  // same sort call as above, but with par_unseq:
  sort(std::execution::par_unseq, sorted.begin(), sorted.end());
  const auto endTime = high_resolution_clock::now();
  // in our output, note that these are the parallel results:
  print_results("Parallel", sorted, startTime, endTime);
}

1
2
3
4
5
6
7
8
9
10

for (int i = 0; i < iterationCount; ++i)
{
  vector<double> sorted(doubles);
  const auto startTime = high_resolution_clock::now();
  // same sort call as above, but with par_unseq:
  sort(std::execution::par_unseq, sorted.begin(), sorted.end());
  const auto endTime = high_resolution_clock::now();
  // in our output, note that these are the parallel results:
  print_results("Parallel", sorted, startTime, endTime);
}

最后,我们的检查一下:


[文本]

.\调试.exe

用1000000双测试…

平行:最低:6642最高:4.29496E+09时间:54.815300ms

平行:最低:6642最高:4.29496E+09时间:49.613700ms

平行:最低:6642最高:4.29496E+09时间:49.504200ms

平行:最低:6642最高:4.29496E+09时间:49.194200ms

平行:最低:6642最高:4.29496E+09时间:49.162200ms

.-释放.exe

用1000000双测试…

平行:最低:18889最高:4.29496E+09时间:20.971100ms

并行:最低:18889最高:4.29496E+09时间:17.510700ms

平行:最低:18889最高:4.29496E+09时间:17.823800ms

平行:最低:18889最高:4.29496E+09时间:20.230400ms

平行:最低:18889最高:4.29496E+09时间:19.461900ms

[/文本]

结果是程序对于这个输入速度更快。你如何衡量你的程序效率,取决于你自己的标准。并行化确实有一些耗费,如果n足够小,那么它将比串行版本慢,这取决于内存和缓存效果,以及特定于特定工作负载的其他因素。在本例中,如果我将n设置为1000,则并行和串行版本的运行速度大致相同,如果我将n更改为100,则串行版本的运行速度快10倍。并行化可以带来巨大的优化,但是选择在哪里应用它是很重要的。

MSVC并行算法实现的局限性

我们构建了并行反向,它比我们的测试硬件上的串行版本慢1.6倍,即使是对于n的大值。我们还用另一个并行算法实现hpx进行了测试,得到了类似的结果。这并不意味着标准委员会将它们添加到STL中是错误的;它只是意味着我们的实现目标没有看到改进的硬件。因此,我们为仅按顺序排列、复制或移动元素的算法提供签名,但实际上不并行。如果我们得到一个并行性更快的例子的反馈,我们将研究并行化这些。受影响的算法有:

copy
copy_n
fill
fill_n
move
reverse
reverse_copy
rotate
rotate_copy
swap_ranges
一些算法目前尚未实现,将在未来的版本中完成。我们在Visual Studio 2017 15.8中发现的算法有:
 adjacent_difference
adjacent_find
all_of
any_of
count
count_if
equal
exclusive_scan
find
find_end
find_first_of
find_if
for_each
for_each_n
inclusive_scan
mismatch
none_of
partition
reduce
remove
remove_if
search
search_n
sort
stable_sort
transform
transform_exclusive_scan
transform_inclusive_scan
transform_reduce

MSVC并行算法实现的设计目标

虽然该标准规定了并行算法库的接口,但它根本没有说明应该如何并行算法,甚至没有说明应该在什么硬件上并行算法。C++的一些实现可以通过使用GPU或其他异构计算硬件在目标上实现并行化。复制对于我们的实现并行化没有意义,但是对于以GPU或类似加速器为目标的实现确实有意义。我们在实施过程中重视以下方面:

带平台锁的组合

微软先前发布了一个并行框架conct,它为标准库的部分提供了支持。conct允许不同的工作负载透明地使用可用的硬件,并允许线程完成彼此的工作,这可以增加总体计算量。基本上,只要线程在运行conct工作负载时正常进入睡眠状态,它就会挂起当前正在执行的任务,而运行其他准备运行的任务。这种非阻塞行为减少了上下文切换,并且可以产生比我们的并行算法实现使用的Windows线程池更高的总吞吐量。但是,这也意味着conct工作负载不与操作系统同步原语(如srwlock、nt events、信号量、com单线程单元、窗口过程等)组合在一起。我们认为,这是不可接受的折衷,因为在stand中“默认”再库中是不接受的。

标准的并行未排序策略允许用户声明他们支持轻量级用户模式调度框架(如conct)所具有的各种限制,因此我们将来可能会考虑提供类似conct的行为。然而,目前我们只有利用平行政策的计划。如果您能够满足这些要求,那么无论如何都应该使用并行的未排序策略,因为这可能会提高其他实现或将来的性能。

调试版本中的可用性能

标准的并行未排序策略允许用户声明他们支持轻量级用户模式调度框架(如conct)所具有的各种限制,因此我们将来可能会考虑提供类似conct的行为。然而,目前我们只有利用平行政策的计划。如果您能够满足这些要求,那么无论如何都应该使用并行的未排序策略,因为这可能会提高其他实现或将来的性能。

调试版本中的可用性能

我们关心调试性能。需要打开优化器才能实际使用的解决方案不适合在标准库中使用。如果我向上一个示例程序添加了concurrency::parallel_sort调用,conct的parallel sort在发行版中速度稍快,但在调试中几乎慢了100倍:

for (int i = 0; i < iterationCount; ++i)
{
  vector<double> sorted(doubles);
  const auto startTime = high_resolution_clock::now();
  Concurrency::parallel_sort(sorted.begin(), sorted.end());
  const auto endTime = high_resolution_clock::now();
  print_results("ConcRT", sorted, startTime, endTime);
}
1
2
3
4
5
6
7
8
	
for (int i = 0; i < iterationCount; ++i)
{
  vector<double> sorted(doubles);
  const auto startTime = high_resolution_clock::now();
  Concurrency::parallel_sort(sorted.begin(), sorted.end());
  const auto endTime = high_resolution_clock::now();
  print_results("ConcRT", sorted, startTime, endTime);
}


[文本]

C:\users\bion\desktop&gt;.\debug.exe

用1000000双测试…

浓度:最低:5564最高:4.29497E+09时间:23910.081300ms

浓度:最低:5564最高:4.29497E+09时间:24096.29700ms

浓度:最低:5564最高:4.29497E+09时间:23868.09850ms

浓度:最低:5564最高:4.29497E+09时间:24159.756200ms

浓度:最低:5564最高:4.29497E+09时间:24950.541500ms

C:\users\bion\desktop&gt;.\release.exe

用1000000双测试…

浓度:最低:1394最高:4.29496E+09时间:19.019000ms

浓度:最低:1394最高:4.29496E+09时间:16.348000ms

浓度:最低:1394最高:4.29496E+09时间:15.699400ms

浓度:最低:1394最高:4.29496E+09时间:15.907100ms

浓度:最低:1394最高:4.29496E+09时间:15.859500毫秒

[/文本]

我们实现中的调度由Windows系统线程池处理。线程池利用标准库不可用的信息,例如系统上的其他线程正在做什么、内核资源线程正在等待什么以及类似的信息。

它选择何时创建更多线程,以及何时终止线程。它还与其他系统组件共享,包括那些不使用C++的组件。

有关线程池代表您(和我们)所做的优化类型的详细信息,请查看Pedro Teixeira关于线程池的讨论,以及有关CreateThreadPoolwork、SubmitThreadPoolwork、WaitForThreadPoolWorkCallbacks和CloseThreadPoolWork Fu的正式文档。NCTIONS。

最重要的是,并行性是一种优化

如果我们不能提出一个实用的基准,即并行算法以合理的n值取胜,它就不会被并行化。我们认为在n=1000’000’000时速度是不可接受的折衷的两倍,当n=100时速度慢了3个数量级。如果您想要“不管代价如何都要并行”,那么还有许多其他的与MSVC一起工作的实现,包括HPX和线程构建块。

类似地,C++标准允许并行算法分配内存,并且在无法获取内存时抛出STD::BADYOLL。在我们的实现中,如果无法获得额外的资源,我们将返回到算法的串行版本。

测试并行算法并加速自己的应用程序

花费超过O(N)时间(类似于排序)并且使用大于2000的N调用的算法是考虑应用并行性的好地方。我们希望确保此功能按您预期的方式运行;请尝试一下。如果您有任何反馈或建议,请告诉我们。我们可以通过以下评论、电子邮件(visualcpp@microsoft.com)联系我们,您可以通过帮助>报告产品中的问题或通过开发人员社区提供反馈。你也可以在twitter(@visualc)和facebook(msftvisualcp)上找到我们。

本文于2018-10-31进行了更新,以表明分区在15.8中是并行的。


博主的第一次翻译,不好请多指出.(部分参考自百度翻译)。

原文地址

作者 : Billy

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值