跳到主要内容

C++ 17并行算法

引言

现代计算机通常配备多核处理器,但传统的C++算法只能在单个核心上运行,无法充分利用硬件资源。C++17标准引入了并行算法库,允许程序员通过简单的接口利用多核处理器进行并行计算,提高程序执行效率。

这个特性对于处理大量数据的应用尤其重要,比如图像处理、数据分析、科学计算等领域。

执行策略

C++17引入了三种执行策略,可以指定算法应该如何执行:

  1. std::execution::seq - 顺序执行(默认行为)
  2. std::execution::par - 并行执行
  3. std::execution::par_unseq - 并行且向量化执行

要使用这些策略,需要包含头文件:

cpp
#include <execution>

基本用法示例

以下是一个简单的例子,展示了如何使用并行算法来加速向量元素求和:

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <execution>
#include <chrono>

int main() {
// 创建一个包含大量元素的向量
std::vector<int> numbers(100'000'000, 1);

// 测量顺序执行的时间
auto start = std::chrono::high_resolution_clock::now();
int sum1 = std::reduce(std::execution::seq, numbers.begin(), numbers.end(), 0);
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> seq_time = end - start;

// 测量并行执行的时间
start = std::chrono::high_resolution_clock::now();
int sum2 = std::reduce(std::execution::par, numbers.begin(), numbers.end(), 0);
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> par_time = end - start;

std::cout << "顺序求和结果: " << sum1 << std::endl;
std::cout << "并行求和结果: " << sum2 << std::endl;
std::cout << "顺序执行时间: " << seq_time.count() << " ms" << std::endl;
std::cout << "并行执行时间: " << par_time.count() << " ms" << std::endl;
std::cout << "加速比: " << seq_time.count() / par_time.count() << std::endl;

return 0;
}

输出示例

顺序求和结果: 100000000
并行求和结果: 100000000
顺序执行时间: 180.25 ms
并行执行时间: 40.75 ms
加速比: 4.42
备注

实际输出结果可能因硬件配置(CPU核心数、内存带宽等)而异。

支持并行执行的算法

C++17中支持并行执行策略的算法有很多,以下是常用的一部分:

非修改序列操作

  • std::all_of, std::any_of, std::none_of
  • std::for_each, std::for_each_n
  • std::find, std::find_if, std::find_if_not
  • std::count, std::count_if

修改序列操作

  • std::copy, std::copy_if, std::copy_n
  • std::transform
  • std::replace, std::replace_if
  • std::fill, std::fill_n
  • std::generate, std::generate_n
  • std::remove, std::remove_if

排序与相关操作

  • std::sort, std::stable_sort
  • std::partial_sort
  • std::merge

数值操作

  • std::reduce
  • std::transform_reduce
  • std::exclusive_scan, std::inclusive_scan
  • std::transform_exclusive_scan, std::transform_inclusive_scan

深入理解执行策略

1. 顺序执行 (std::execution::seq)

这是默认的执行策略,算法将在单线程中按顺序执行,不会引入任何并行性或向量化。

cpp
std::sort(std::execution::seq, v.begin(), v.end());
// 等价于
std::sort(v.begin(), v.end());

2. 并行执行 (std::execution::par)

允许算法在多个线程上并行执行,但不保证向量化操作。

cpp
std::sort(std::execution::par, v.begin(), v.end());

3. 并行且向量化执行 (std::execution::par_unseq)

允许算法在多个线程上并行执行,并且可以使用SIMD指令进行向量化。这可能会改变操作的顺序,因此函数必须是无副作用的。

cpp
std::sort(std::execution::par_unseq, v.begin(), v.end());
注意

使用par_unseq策略时,传递给算法的任何函数都不应有副作用或依赖于特定的执行顺序,因为操作可能会以任意顺序执行,甚至跨线程重叠执行。

何时使用并行算法

并行算法并非在所有情况下都能带来性能提升。以下是一些应考虑的因素:

  1. 数据规模:对于小规模数据,并行执行的开销可能超过收益。通常数据量需要达到一定规模才能观察到明显的性能提升。

  2. 操作复杂度:如果每个元素需要进行复杂的计算,并行化带来的收益会更明显。

  3. 硬件限制:性能提升受CPU核心数、内存带宽等硬件因素限制。

  4. 算法本身:某些算法天然适合并行化(如reducetransform),而其他算法则可能因为数据依赖性而难以并行化。

实际应用案例

图像处理

并行算法非常适合图像处理任务,因为可以同时处理多个像素:

cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
#include <chrono>

// 简化的像素结构
struct Pixel {
unsigned char r, g, b;
};

int main() {
// 模拟一张高分辨率图片
constexpr size_t width = 8000;
constexpr size_t height = 6000;
std::vector<Pixel> image(width * height, {100, 100, 100});

auto start = std::chrono::high_resolution_clock::now();

// 顺序执行图像亮度调整
std::for_each(std::execution::seq, image.begin(), image.end(),
[](Pixel& p) {
p.r = static_cast<unsigned char>(p.r * 1.2);
p.g = static_cast<unsigned char>(p.g * 1.2);
p.b = static_cast<unsigned char>(p.b * 1.2);
});

auto mid = std::chrono::high_resolution_clock::now();

// 并行执行同样的操作
std::for_each(std::execution::par, image.begin(), image.end(),
[](Pixel& p) {
p.r = static_cast<unsigned char>(p.r * 0.8);
p.g = static_cast<unsigned char>(p.g * 0.8);
p.b = static_cast<unsigned char>(p.b * 0.8);
});

auto end = std::chrono::high_resolution_clock::now();

std::chrono::duration<double, std::milli> seq_time = mid - start;
std::chrono::duration<double, std::milli> par_time = end - mid;

std::cout << "顺序处理时间: " << seq_time.count() << " ms\n";
std::cout << "并行处理时间: " << par_time.count() << " ms\n";
std::cout << "加速比: " << seq_time.count() / par_time.count() << "\n";

return 0;
}

数据分析

对大型数据集进行分析时,并行算法可以显著提高性能:

cpp
#include <iostream>
#include <vector>
#include <random>
#include <algorithm>
#include <numeric>
#include <execution>
#include <chrono>

int main() {
// 准备大型数据集
constexpr size_t dataSize = 100'000'000;
std::vector<double> data(dataSize);

// 生成随机数据
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_real_distribution<> dis(0.0, 100.0);

std::generate(data.begin(), data.end(), [&]() { return dis(gen); });

// 顺序计算均值和方差
auto start = std::chrono::high_resolution_clock::now();

double sum = std::reduce(std::execution::seq, data.begin(), data.end(), 0.0);
double mean = sum / dataSize;

double sqSum = std::transform_reduce(std::execution::seq,
data.begin(), data.end(), 0.0, std::plus<>(),
[mean](double x) { return (x - mean) * (x - mean); });

double variance = sqSum / dataSize;

auto mid = std::chrono::high_resolution_clock::now();

// 并行计算均值和方差
double parSum = std::reduce(std::execution::par, data.begin(), data.end(), 0.0);
double parMean = parSum / dataSize;

double parSqSum = std::transform_reduce(std::execution::par,
data.begin(), data.end(), 0.0, std::plus<>(),
[parMean](double x) { return (x - parMean) * (x - parMean); });

double parVariance = parSqSum / dataSize;

auto end = std::chrono::high_resolution_clock::now();

std::chrono::duration<double, std::milli> seq_time = mid - start;
std::chrono::duration<double, std::milli> par_time = end - mid;

std::cout << "顺序计算 - 均值: " << mean << ", 方差: " << variance << "\n";
std::cout << "并行计算 - 均值: " << parMean << ", 方差: " << parVariance << "\n";
std::cout << "顺序计算时间: " << seq_time.count() << " ms\n";
std::cout << "并行计算时间: " << par_time.count() << " ms\n";
std::cout << "加速比: " << seq_time.count() / par_time.count() << "\n";

return 0;
}

注意事项和最佳实践

  1. 线程安全:使用并行算法时,确保传递给算法的任何函数或谓词是线程安全的。

  2. 避免副作用:特别是使用par_unseq策略时,确保操作没有副作用且不依赖执行顺序。

  3. 适当的数据规模:在足够大的数据集上使用并行算法才能体现性能优势。

  4. 硬件考虑:性能增益取决于可用的处理器核心数。在多核系统上收益更明显。

  5. 测试验证:总是测量并行和串行版本的性能,确保并行化真的提高了性能。

  6. 编译器支持:确保你的编译器完全支持C++17并行算法。可能需要链接额外的库,如TBB (Thread Building Blocks)。

cpp
// 编译命令示例(使用GCC)
// g++ -std=c++17 -pthread -ltbb yourfile.cpp -o yourprogram

总结

C++17并行算法为开发人员提供了一种简单而强大的方式来利用多核处理器的计算能力,无需直接管理线程。通过选择适当的执行策略,可以在保持代码清晰性的同时显著提高性能。

关键要点:

  1. C++17引入三种执行策略:seqparpar_unseq
  2. 大多数标准库算法都支持这些执行策略
  3. 并行化适合大规模数据和计算密集型任务
  4. 使用并行算法时需注意线程安全和避免副作用
  5. 性能提升取决于数据规模、操作复杂度和硬件配置

练习题

  1. 修改上面的图像处理示例,实现一个简单的图像模糊滤镜,比较顺序执行和并行执行的性能。

  2. 使用并行算法实现一个单词频率统计程序,从一个大型文本文件中统计单词出现频率。

  3. 实现一个并行版本的归并排序,并与标准库的std::sort在不同执行策略下进行性能比较。

额外资源

提示

如果你对C++并行编程感兴趣,可以继续学习C++20引入的协程(coroutines)和C++23推出的执行器(executors),这些特性进一步丰富了C++的并发编程模型。