C++17并行计算利器: 深入理解std::execution及其性能优化

在现代 C++ 中, 提升程序性能的需求日益增长, 而并行计算和矢量化逐渐成为高性能计算的核心手段. 为了帮助开发者简化并行化操作, C++17 引入了 std::execution 命名空间, 为标准算法提供了一种简单且强大的执行策略机制. 这篇博客将深入介绍 std::execution 的概念, 使用方法及其优势.


什么是 std::execution?

std::execution 是 C++ 标准库中的一个命名空间, 定义了几种 执行策略(Execution Policies), 用于控制标准算法的执行模式. 这些执行策略可以显式指定算法是以串行, 并行还是矢量化的方式执行.

通过使用 std::execution, 开发者可以更高效地利用多核 CPU 和 SIMD 指令集, 从而在性能上实现显著提升, 而无需手动管理多线程或矢量化的复杂逻辑.


执行策略概览

std::execution 提供了以下四种执行策略:

  1. std::execution::seq(串行执行):

    • 算法按默认的顺序依次执行.
    • 无并行化, 也不进行矢量化.
  2. std::execution::par(并行执行):

    • 算法以多线程的方式并行执行.
    • 适合多核处理器的计算密集型任务.
  3. std::execution::par_unseq(并行且矢量化执行):

    • 算法在并行的基础上进一步允许矢量化.
    • 利用硬件能力实现更高效的计算.
  4. std::execution::unseq(矢量化执行):

    • 算法允许矢量化, 但不保证迭代顺序.
    • 利用 SIMD 指令提升单线程性能.

std::execution 的使用方法

std::execution 的核心用途是与 C++ 标准算法相结合, 通过简单地传递执行策略作为参数, 指定算法的执行模式.

示例 1: 并行排序

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

int main() {
  // 生成随机数据集
  constexpr size_t kTotalCount = 1'000'000;
  std::vector<int> data(kTotalCount);
  std::random_device rd;
  std::mt19937 gen(rd());
  std::uniform_int_distribution<> dis(1, kTotalCount);
  for (auto& d : data) {
    d = dis(gen);
  }

  // 串行排序并计时
  auto start_seq = std::chrono::high_resolution_clock::now();
  std::sort(data.begin(), data.end());
  auto end_seq = std::chrono::high_resolution_clock::now();
  std::cout << "串行排序耗时: "
            << std::chrono::duration_cast<std::chrono::milliseconds>(end_seq -
                                                                     start_seq)
                   .count()
            << " ms" << std::endl;

  // 打乱数据顺序
  std::shuffle(data.begin(), data.end(), gen);

  // 并行排序并计时
  auto start_par = std::chrono::high_resolution_clock::now();
  std::sort(std::execution::par, data.begin(), data.end());
  auto end_par = std::chrono::high_resolution_clock::now();
  std::cout << "并行排序耗时: "
            << std::chrono::duration_cast<std::chrono::milliseconds>(end_par -
                                                                     start_par)
                   .count()
            << " ms" << std::endl;

  std::shuffle(data.begin(), data.end(), gen);

  // 矢量化排序并计时
  auto start_unseq = std::chrono::high_resolution_clock::now();
  std::sort(std::execution::unseq, data.begin(), data.end());
  auto end_unseq = std::chrono::high_resolution_clock::now();
  std::cout << "矢量化排序耗时: "
            << std::chrono::duration_cast<std::chrono::milliseconds>(
                   end_unseq - start_unseq)
                   .count()
            << " ms" << std::endl;

  std::shuffle(data.begin(), data.end(), gen);

  // 并行且矢量化排序并计时
  auto start_par_unseq = std::chrono::high_resolution_clock::now();
  std::sort(std::execution::par_unseq, data.begin(), data.end());
  auto end_par_unseq = std::chrono::high_resolution_clock::now();
  std::cout << "并行且矢量化排序耗时: "
            << std::chrono::duration_cast<std::chrono::milliseconds>(
                   end_par_unseq - start_par_unseq)
                   .count()
            << " ms" << std::endl;
  return 0;
}

在这个示例中, std::execution::par 指定 std::sort 可以利用多线程并行化排序操作. 笔者测试了不同的编译器, 结果如下:

  1. GCC, 未开-O3优化
    串行排序耗时: 348 ms
    并行排序耗时: 381 ms
    矢量化排序耗时: 381 ms
    并行且矢量化排序耗时: 370 ms
  2. Clang 18.1.3, 未开-O3优化
    串行排序耗时: 355 ms
    并行排序耗时: 406 ms
    矢量化排序耗时: 419 ms
    并行且矢量化排序耗时: 384 ms
  3. MSVC
    串行排序耗时: 309 ms
    并行排序耗时: 81 ms
    矢量化排序耗时: 321 ms
    并行且矢量化排序耗时: 57 ms

可以看到 GCC/Clang 对此方面的优化不是很好, 而 MSVC 则对并行排序优化的比较好.

示例 2: 并行处理数据

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

int main() {
    std::vector<int> data(1'000'000, 1);

    // 串行归约操作并计时
    auto start_seq = std::chrono::high_resolution_clock::now();
    int sum_seq = std::accumulate(data.begin(), data.end(), 0);
    auto end_seq = std::chrono::high_resolution_clock::now();
    std::cout << "串行归约耗时: "
              << std::chrono::duration_cast<std::chrono::milliseconds>(end_seq - start_seq).count()
              << " ms" << std::endl;

    // 并行归约操作并计时
    auto start_par = std::chrono::high_resolution_clock::now();
    int sum_par = std::reduce(std::execution::par, data.begin(), data.end(), 0);
    auto end_par = std::chrono::high_resolution_clock::now();
    std::cout << "并行归约耗时: "
              << std::chrono::duration_cast<std::chrono::milliseconds>(end_par - start_par).count()
              << " ms" << std::endl;

    // 输出结果
    std::cout << "串行归约结果: " << sum_seq << std::endl;
    std::cout << "并行归约结果: " << sum_par << std::endl;

    return 0;
}

在上述代码中, std::reduce 使用 std::execution::par 实现了并行化的数据归约(reduce)操作.

输出结果:

  1. GCC 14.2
    串行归约耗时: 14 ms
    并行归约耗时: 9 ms
  2. Clang 18.1.3
    串行归约耗时: 16 ms
    并行归约耗时: 12 ms
  3. MSVC
    串行归约耗时: 8 ms
    并行归约耗时: 4 ms

std::execution 支持的算法

在 C++17 中, 标准库中的许多算法都支持执行策略, 包括但不限于:


使用建议与注意事项

  1. 线程安全性:

    • 并行执行策略(parpar_unseq)要求代码中的所有数据操作是线程安全的, 尤其是写入共享资源时需要加锁.
  2. 适用场景:

    • 对于小规模数据集, 串行执行(seq)可能性能更好, 因为并行化带来的开销可能超过其带来的收益.
    • 对于数据独立的计算任务, 并行策略(parpar_unseq)能显著提升性能.
  3. 硬件依赖:

    • 并行化性能提升取决于硬件是否支持多核处理和 SIMD 指令.
  4. 非确定性行为:

    • 并行策略(parpar_unseq)不保证执行顺序, 因此对顺序敏感的任务不适合使用这些策略.

性能提升的核心原因


总结

std::execution 是 C++17 提供的一个强大工具, 它为标准算法赋予了更灵活的执行模式, 简化了并行计算的实现方式. 通过指定执行策略, 开发者可以更高效地利用硬件资源, 从而显著提升程序性能.

无论是大规模数据处理还是高性能计算场景, std::execution 都是不可或缺的利器. 熟练掌握它, 将为你的 C++ 编程技能增添一份强大的武器.

源码链接

源码链接