当前位置: 首页 > news >正文

CppCon 2014 学习:Parallelizing the Standard Algorithms Library

Bringing Parallelism to C++(为 C++ 引入并行性)

技术规范(Technical Specification)

  • 是对 C++ 标准库的一个扩展草案(TS = Technical Specification)。
  • 不会改变现有代码语义,而是提供新功能。
  • 专门针对并行和矢量化算法(如 std::for_each, std::sort 等)支持多线程执行。

多厂商协作开发(Multi-vendor collaboration)

  • C++ 标准并不是某一家公司制定的,是由 ISO C++ 委员会协调,多个厂商贡献力量:
    • NVIDIA:提供了 GPU 加速库 Thrust
    • Microsoft:提供了 PPL (Parallel Patterns Library)
    • Intel:提供了广泛使用的 TBB (Threading Building Blocks)
      这些都为并行算法设计提供了实际经验和优化路径。

多个实现正在推进中

  • 各大厂商基于该技术规范开发了各自的实现版本:
    • GCC 的并行 STL;
    • MSVC 并行 STL(使用 PPL);
    • Intel OneAPI 中的并行 STL(基于 TBB);
    • Thrust 对应 GPU 并行。

目标:纳入 C++17

  • 最终目标是将该技术规范转正,成为 C++17 的一部分(事实也确实如此):
    • std::execution::parpar_unseq 等执行策略正式引入;
    • std::for_each, std::reduce, std::sort 等算法可并行执行;
    • 用户只需改参数,而无需更改算法逻辑。

示例(C++17 并行算法):

#include <vector>
#include <algorithm>
#include <execution>std::vector<int> data = {1, 2, 3, 4, 5};
std::for_each(std::execution::par, data.begin(), data.end(), [](int& n) {n *= 2;
});

使用 std::execution::par 参数即可实现多线程并行处理

如需:

  • 各执行策略(seq, par, par_unseq, unseq)的区别;
  • 如何用这些 API 改写已有 C++ 算法;
  • 实测性能或与 OpenMP 比较;

这是对**“并行(parallelism)”**的一个重要澄清和简明解释:

作者的意思:

When I say “parallel”, think “independent”
他强调的是:

  • 并行的核心是任务之间的独立性(independence),而不单纯是“用线程执行”。

并行 ≠ 并发

并行(Parallelism)并发(Concurrency)
实际同时执行看起来同时进行
强调独立任务并发执行强调协调与调度多个任务
可用多线程/多核心实现可用单线程+异步实现
适合计算密集型任务适合IO密集型任务

并发的难点(为什么“Concurrency is hard”):

并发编程涉及:

  • 锁机制(Locking)
  • 互斥(Exclusion)
  • 共享状态(Shared State)
  • 通信(Communication)
  • 数据竞争(Data Races)
    这些都可能导致:
  • 难以调试的问题(race conditions)
  • 性能瓶颈(死锁、活锁)
  • 难以维护的代码

并行更纯粹、更“安全”:

如果你的任务彼此独立(比如并行处理一组图像块),那你就可以“放心并行”而不用担心锁和状态共享。

阐述了并行编程的本质以及设计目标,核心可以总结如下:

并行编程的核心含义:

Parallel programming = 识别可以独立执行的任务
关键不在于“使用线程”,而在于:

  • 分析程序中哪些任务不相互依赖
  • 将这些任务并行运行,从而提升效率

接下来的问题是:

如何把“这些任务可以独立执行”的信息告诉系统?

说明标准和工具正在使这件事变得越来越简单,比如:

  • C++ 并行算法库(如 std::for_each(std::execution::par, ...)
  • 后端自动调度任务,而你不需要管理线程或锁

设计目标:

让“并行”变成像“加法”一样普通的操作,不再需要开发者处理线程、同步等低层细节。

在这里插入图片描述

动机示例(Motivating Example)——“焊接顶点(Weld Vertices)” 背后的问题和解决方案。

问题(Problem)

Marching Cubes 算法会生成所谓的 “triangle soup(三角形汤)”

  • 指生成的网格是大量独立的三角形拼接在一起,
  • 每个三角形的顶点即使在位置上相同,也在数据上是不同的顶点(即顶点没有共享),
  • 这样导致:
    • 顶点冗余
    • 法线不连续 → 视觉不平滑
    • 数据结构臃肿
    • 后续处理和渲染不便

解决方案(Solution)

将这些重复的顶点“焊接”(合并)起来:

  • 检测空间上非常接近或重合的顶点
  • 将它们视为同一个顶点进行合并(共享指针或索引)
  • 优点包括:
    • 网格更紧凑
    • 视觉更连贯(法线可平均)
    • 渲染效率更高
    • 便于物理模拟和碰撞检测等后续处理
      你提供的内容是对“焊接顶点(Weld Vertices)”过程的高层次算法描述。下面是对这个过程的逐步解释:

Motivating Example: Weld Vertices

用高级算法,可以轻松实现顶点焊接。

using namespace std;// 定义顶点类型为二维坐标 (x, y)
using vertex = tuple<float, float>;// 将原始输入顶点拷贝一份,用于后续处理(去重等)
vector<vertex> vertices = input;// 创建索引数组,用于记录每个原始顶点在唯一顶点列表中的索引
vector<size_t> indices(input.size());// 第一步:对顶点按坐标排序,使得重复顶点相邻排列
sort(vertices.begin(), vertices.end());// 第二步:去除相邻重复顶点,只保留唯一顶点
auto redundant_begin = unique(vertices.begin(), vertices.end());// 删除冗余顶点,使 vertices 只包含唯一的顶点
vertices.erase(redundant_begin, vertices.end());// 第三步:为每个原始顶点查找其在唯一顶点列表中的位置(索引)
// 该函数会将 input 中的每个顶点,在 vertices 中用二分查找定位,写入 indices 中
my_find_all_lower_bounds(vertices.begin(), vertices.end(),input.begin(), input.end(),indices.begin());

补充说明:

  • unique() 仅移除连续的重复项,因此要先 sort()
  • my_find_all_lower_bounds(...) 不是标准函数,通常表示对一组值批量执行 std::lower_bound 的过程。

步骤详解(Procedure)

1. Sort triangle vertices

对所有三角形的顶点排序(通常是按空间坐标):

  • 将每个顶点视为一个 3D 向量 (x, y, z)
  • 使用词典序(lexicographic order)或某种哈希函数进行排序
  • 目的:相同或相近的顶点排在一起,便于后续合并

2. 合并连续的重复顶点(collapse spans)

  • 相邻且“几何上等价”的顶点(例如差值在某个ε内)视为同一个顶点
  • 用一个统一的索引替换多个等价点,节省存储
  • 相当于去重

3. Search for each vertex’s unique index

为每个顶点找到它的唯一索引

  • 构建映射(比如 hash map 或 flat array + binary search)从顶点 → 唯一索引
  • 替换三角形面片中原始顶点引用,使用合并后的顶点索引

目的和好处:

  • 🔹 去除冗余数据(重复的顶点)
  • 🔹 提升内存效率
  • 🔹 提供一致的法线计算(重要于渲染和物理模拟)
  • 🔹 网格连接性更明确,便于后处理(如边检测、UV展开等)

你提供的这段代码展示了如何通过并行算法加速“焊接顶点”(Weld Vertices)这个典型图形处理任务。以下是逐行解析和中文注释,重点解释“并行”部分的含义:

using namespace std;
// 引入并行算法命名空间(基于 C++17 技术规范提案 TS: Parallel STL)
using namespace std::experimental::parallel;// 定义顶点类型为 float 的二维坐标 (x, y)
using vertex = tuple<float, float>;// 拷贝输入顶点数据
vector<vertex> vertices = input;// 准备存放索引的数组
vector<size_t> indices(input.size());

第一步:并行排序

// 并行排序:将相同顶点排到一起,便于去重
sort(par, vertices.begin(), vertices.end());
  • sort(par, ...) 表示使用并行执行策略(parallel execution policy)
  • par 是并行版本的 std::sort
  • 比串行版本更快,尤其在大数据集上(如数百万顶点)

第二步:并行去重

// 并行去重:找到相邻重复项的末尾位置
auto redundant_begin = unique(par, vertices.begin(), vertices.end());
  • unique(par, ...) 并行查找重复元素
  • 返回的是重复区域的开始迭代器
  • 注意:并行 unique() 并不会真正删除元素,仅将唯一元素“移到前面”
vertices.erase(redundant_begin, vertices.end());
// 从 vector 中删除重复元素(这一操作是串行的)

第三步:并行查找每个顶点在唯一集合中的索引

my_find_all_lower_bounds(par, vertices.begin(), vertices.end(),input.begin(), input.end(),indices.begin());

这个自定义函数的含义:

  • 对每个原始顶点 input,在唯一集合 vertices 中找出其位置(下标)
  • 并发执行,提高性能
    你可以定义 my_find_all_lower_bounds 如下:
template<typename ExecutionPolicy, typename ForwardIt1, typename ForwardIt2, typename OutputIt>
void my_find_all_lower_bounds(ExecutionPolicy&& policy,ForwardIt1 unique_begin, ForwardIt1 unique_end,ForwardIt2 input_begin, ForwardIt2 input_end,OutputIt output_begin) {transform(policy, input_begin, input_end, output_begin,[&](const auto& v) {return lower_bound(unique_begin, unique_end, v) - unique_begin;});
}

总结:“并行”的好处

步骤串行版本并行版本
排序sort(...)sort(par, ...)
去重unique(...)unique(par, ...)
查找索引手动循环transform(par, ...) + lower_bound
整体处理速度中等(O(n log n))快速,利用多核 CPU,减少总时长
如需进一步并行化 erase 操作(目前是串行的),需要结构化数据布局(例如标记位 + remove_if 风格),不过这通常收益不大,重点优化点还是排序和查找。
using namespace std;
// 引入实验性的并行算法命名空间(虽然我们现在用的是 seq 串行策略)
using namespace std::experimental::parallel;// 定义一个顶点类型:由两个 float 数字组成的元组,表示二维坐标
using vertex = tuple<float, float>;// 拷贝输入顶点列表
vector<vertex> vertices = input;// 为每个输入顶点准备一个索引位置(将来会填入它在唯一顶点表中的位置)
vector<size_t> indices(input.size());// 第一步:对顶点排序(使用串行策略 seq)以便将重复的顶点排在一起
sort(seq, vertices.begin(), vertices.end());// 第二步:去除连续重复的顶点(仍使用串行策略 seq)
auto redundant_begin = unique(seq, vertices.begin(), vertices.end());// 删除排序后多余的重复顶点
vertices.erase(redundant_begin, vertices.end());// 第三步:对每个原始输入顶点,在去重后的顶点表中查找其对应的唯一索引位置
// 将结果填入 indices 数组中
my_find_all_lower_bounds(seq,vertices.begin(), vertices.end(), // 去重后的顶点列表input.begin(), input.end(),       // 原始输入顶点indices.begin());                 // 输出索引结果

你现在使用的是串行执行策略(seq),而不是并行执行策略(par)。我们来逐步理解这段代码的含义以及你这样做的意图和影响:

使用串行策略:seq

using namespace std;
using namespace std::experimental::parallel;
using vertex = tuple<float, float>;
  • vertex 是二维顶点(float, float)
  • std::experimental::parallel 提供并行算法(你虽然引入了它,但用了 seq

初始化数据

vector<vertex> vertices = input;
vector<size_t> indices(input.size());
  • 拷贝输入顶点数据
  • 准备好用于记录每个输入顶点的“唯一索引”位置的数组

第一步:串行排序

sort(seq, vertices.begin(), vertices.end());
  • 使用 seq 执行策略 —— 也就是明确指定使用串行排序
  • 效果跟 std::sort(...) 类似,但语义更明确(并非默认算法选择)

第二步:串行去重

auto redundant_begin = unique(seq, vertices.begin(), vertices.end());
vertices.erase(redundant_begin, vertices.end());
  • unique(seq, ...) 串行查找相邻重复项
  • erase() 删除后面的冗余项

第三步:查找每个顶点的索引位置(串行)

my_find_all_lower_bounds(seq, vertices.begin(), vertices.end(),input.begin(), input.end(),indices.begin());

你应该有这样一个函数:

template<typename ExecutionPolicy, typename ForwardIt1, typename ForwardIt2, typename OutputIt>
void my_find_all_lower_bounds(ExecutionPolicy&& policy,ForwardIt1 unique_begin, ForwardIt1 unique_end,ForwardIt2 input_begin, ForwardIt2 input_end,OutputIt output_begin) {transform(policy, input_begin, input_end, output_begin,[&](const auto& v) {return lower_bound(unique_begin, unique_end, v) - unique_begin;});
}
  • 使用 transform(seq, ...),串行查找
  • 比并行慢,但在小数据集、调试、确定性行为场景下更可取

总结对比:seq vs par

特性seq(你当前使用)par(之前的并行版本)
执行方式串行(单线程)并行(多线程)
性能稳定,但速度较慢更快(大数据集下)
可调试性相对较难追踪
确定性输出更确定性(顺序一致)可能不同线程间有不同顺序
使用建议小数据量、单线程逻辑、调试大数据量、追求性能

自动选择执行策略(串行 vs 并行)的方式,避免程序员在代码中硬编码执行模式,从而达到既灵活又高效的目的。下面是对代码逐行的解释(含中文注释):

using namespace std;
using namespace std::experimental::parallel; // 引入并行算法支持(实验性)// 省略的一些定义,例如 vertex、vertices、input 等...// 根据数据大小选择执行策略
execution_policy exec = seq;                          // 默认使用串行执行
if (input.size() > some_threshold) exec = par;        // 如果数据量超过阈值,改为并行执行// 第一步:对顶点排序,把重复顶点排到一起
sort(exec, vertices.begin(), vertices.end());// 第二步:消除重复顶点
auto redundant_begin = unique(exec, vertices.begin(), vertices.end());
vertices.erase(redundant_begin, vertices.end());// 第三步:查找每个原始顶点在去重后顶点数组中的唯一索引
my_find_all_lower_bounds(exec, vertices.begin(), vertices.end(),input.begin(), input.end(),indices.begin());

理解要点总结:

  • execution_policy exec:表示执行策略,可是 seq(串行)、par(并行)等。
  • 动态决策策略:通过判断 input.size(),自动决定使用串行或并行,避免手动修改代码。
  • sort / unique / 查找索引 全都统一使用 exec,保持风格一致,提升代码维护性。
  • some_threshold 是一个经验值或性能分析得出的分界点,例如 100010000,取决于数据结构、平台、调度器开销等。

优点:

  • 自动化优化,无需你“选择困难症”
  • 更适合写通用库或大规模数据处理系统
  • 不会在小数据上浪费并行调度开销,也不会在大数据上落入串行瓶颈

这个函数模板 my_find_all_lower_bounds 的作用是:

template<class ExecutionPolicy,class ForwardIterator,class InputIterator,class OutputIterator>
// 函数模板,支持并行/串行执行策略
OutputIterator my_find_all_lower_bounds(ExecutionPolicy& exec,ForwardIterator haystack_begin,ForwardIterator haystack_end,InputIterator needles_begin,InputIterator needles_end,OutputIterator result)
{// 使用std::transform对needles区间的每个元素执行操作// 传入执行策略exec以支持并行或串行执行return transform(exec, needles_begin, needles_end, result,[=](auto& needle)  // lambda函数,捕获所有外部变量(按值){// 对haystack区间执行lower_bound查找needle的插入位置auto iter = std::lower_bound(haystack_begin, haystack_end, needle);// 计算从haystack_begin到找到位置的距离,返回该索引return std::distance(haystack_begin, iter);});
}
  • 输入

    • 一个执行策略 exec(比如用于并行或串行执行的策略,比如 std::execution::parstd::execution::seq
    • 一个排好序的区间 [haystack_begin, haystack_end),这是你要在其中查找的“主序列”
    • 一个待查找的元素区间 [needles_begin, needles_end),表示你想要查找的一组“针”
    • 一个输出迭代器 result,用于写入结果
  • 功能
    对 needles 区间内的每个元素,调用 std::lower_bound 在 haystack 区间里找到第一个不小于该元素的位置(返回的是一个迭代器),然后计算这个迭代器相对 haystack_begin 的偏移量(即索引),把这些索引依次写入 result

  • 实现细节
    用了 std::transform 搭配执行策略 exec,对 needles 区间内每个元素执行 [=](auto& needle){ ... } 这个 lambda,lambda 内调用 std::lower_bound 查找。

  • 返回值
    返回的是 std::transform 的返回值,也就是输出迭代器 result 的结束位置。

总结一下:
这个函数就是在一个有序数组(或容器)中,批量查找多个元素的“lower bound”索引,支持并行执行策略。方便批量查询多个元素时能利用并行提高效率。

1. 什么是区间(Range)?

在 C++ 中,常见的操作都是针对**区间(range)**的,区间由两个迭代器表示:

  • 起始迭代器(begin):指向区间第一个元素的位置
  • 结束迭代器(end):指向区间“末尾后”的位置(不包含这个位置本身)

比如有一个数组 arr

int arr[] = {1, 3, 5, 7, 9};

区间 [arr, arr + 5) 就表示从 arr[0]arr[4] 的全部元素。

2. 你的代码里的两个区间

① 主序列区间(haystack)
ForwardIterator haystack_begin,
ForwardIterator haystack_end,

这表示你要查找的有序序列区间。例如:

std::vector<int> haystack = {2, 4, 6, 8, 10};

haystack_begin 指向 haystack 的第一个元素,haystack_end 指向尾后元素(不含)。
这个区间必须排好序,因为 std::lower_bound 只能在有序区间里用。

② 待查找元素区间(needles)
InputIterator needles_begin,
InputIterator needles_end,

这是你想找的若干元素组成的区间,比如:

std::vector<int> needles = {1, 5, 10};

needles_begin 指向 needles 第一个元素,needles_end 指向尾后元素。

3. 函数要做什么?

needles 中的每个元素(1, 5, 10),在 haystack 中查找它的“下界位置”:

  • 1,查找 haystack 中第一个不小于1的位置。
  • 5,查找第一个不小于5的位置。
  • 10,查找第一个不小于10的位置。
    然后返回这些位置的索引。

4. 举个完整例子

std::vector<int> haystack = {2, 4, 6, 8, 10};
std::vector<int> needles = {1, 5, 10};
std::vector<int> results(needles.size());// 调用my_find_all_lower_bounds
my_find_all_lower_bounds(std::execution::seq,haystack.begin(), haystack.end(),needles.begin(), needles.end(),results.begin());// results会是什么?

解释:

  • needle=1std::lower_bound 找到第一个不小于1的位置是 haystack[0](元素2),索引0。
  • needle=5:第一个不小于5的位置是 haystack[2](元素6),索引2。
  • needle=10:第一个不小于10的位置是 haystack[4](元素10),索引4。
    所以 results = {0, 2, 4}

总结

  • 区间就是两个迭代器界定的范围
  • 你的 haystack 区间是查找的主数据序列。
  • 你的 needles 区间是你想批量查找的元素集合。
  • 这个函数对 needles 中的每个元素,都在 haystack 里找位置,并输出索引。

my_find_all_lower_bounds 设计理念和并行编程的一些核心思想,我帮你逐句拆解并解释:

1. Truly general

  • generic in data types (via iterators)
    你的函数使用了模板和迭代器,使它能够适用于各种数据类型和容器。只要容器提供合适的迭代器,它就能工作,而不是局限于某种具体类型(比如数组或 std::vector<int>)。

  • generic in execution (via execution policy)
    函数接受执行策略参数(ExecutionPolicy),这使得同一段代码能根据策略支持串行执行、并行执行,甚至向量化执行。这是现代C++并行算法设计的关键。

2. Composing our higher-level algorithms from lower-level primitives gives us parallelism for free!

  • 通过组合已有的低级算法(比如 std::lower_boundstd::transform),并利用标准库支持的并行执行策略,我们可以轻松实现并行计算,而不必自己写复杂的线程管理代码。
  • 换句话说,写高层算法时只需调用低层并行算法原语,就能“免费”获得并行性能提升

3. How to write parallel programs

这部分说的是写并行程序的方法论。

4. High-level algorithms

  • 使用标准库里提供的高级算法接口(如 std::transformstd::reducestd::sort 等),这些算法本身已经实现了并行化的能力。

5. Control sequential/parallel execution with policies

  • 通过传递不同的执行策略(execution policies),开发者可以控制算法是串行执行还是并行执行。
  • 例如:std::execution::seq 表示串行,std::execution::par 表示并行。
  • 这样,代码结构不变,只需替换执行策略,就能切换执行方式。

6. Communicate dependencies

  • 在并行编程中,必须清楚算法间的数据依赖关系
  • 标准库的并行算法在设计时会明确这些依赖,从而安全地并行执行。
  • 你的函数通过传入执行策略和利用无依赖的算法(transform),让底层库负责处理并行时的依赖和同步细节。

总结

  • 利用泛型编程,支持任意数据类型和容器;
  • 利用执行策略参数,实现代码同样可以串行或并行执行;
  • 组合标准库的算法,不用关心底层线程管理,就能获得并行加速;
  • 通过传递执行策略,程序员明确表达执行方式
  • 保证算法中没有复杂的依赖关系,能安全并行。

1. Execution Policies(执行策略)

  • 执行策略是告诉算法“怎么执行”的参数。
  • 常见的有:
    • std::execution::seq:串行执行,按顺序完成任务。
    • std::execution::par:并行执行,任务会分发到多个线程。
    • std::execution::par_unseq:并行且允许向量化,性能更高,但对代码要求更严格。
  • 它们让你用同一份代码灵活控制执行方式,方便调优和切换。

2. Parallel Algorithms(并行算法)

  • 标准库提供的算法,如 std::sortstd::transformstd::reduce,都支持接受执行策略。
  • 当传入并行执行策略时,这些算法自动利用多线程并行计算,提高效率。
  • 程序员不用手写线程管理,写法和串行算法几乎一样,易用又高效。

3. Parallel Exceptions(并行异常处理)

  • 并行执行时,多个线程同时运行,如果有异常,处理起来更复杂。
  • 标准库定义了并行算法中异常传播的规则:
    • 所有线程中抛出的异常会被收集。
    • 最终会以一个 std::exception_list(异常列表)的形式抛出,包含所有异常信息。
  • 这样程序能安全捕获并行异常,保证异常不会丢失,也方便调试。

C++ 并行执行策略的用法:

1. using namespace std::experimental::parallelism;

  • 这是早期实验性质的并行库命名空间(现在正式标准库是在 std::execution),表示引入并行执行策略。

2. std::vector<int> data = ...

  • 定义一个整数向量 data,假设已初始化。

3. sort(data.begin(), data.end());

  • 普通排序,无执行策略,默认串行执行。

4. sort(seq, data.begin(), data.end());

  • 显式指定执行策略为 seq(顺序执行),保证按顺序执行,等同于普通排序。

5. sort(par, data.begin(), data.end());

  • 允许并行执行,排序操作可以使用多线程加速,提高性能。

6. sort(par_vec, data.begin(), data.end());

  • 允许并行执行且允许向量化,除了多线程,还允许底层使用SIMD指令等向量化手段进一步提升性能。

总结

  • 执行策略作为第一个参数传递给算法,控制算法的执行方式。
  • seq:顺序执行。
  • par:并行执行。
  • par_vec:并行+向量化执行。

动态选择执行策略的思想,具体理解如下:

代码作用

  • 根据数据量大小动态决定排序时是用串行执行还是并行执行

具体解释

size_t threshold = ...;   // 定义阈值,决定何时使用并行
execution_policy exec = seq;  // 默认执行策略是串行(seq)
if (vec.size() > threshold)
{exec = par;  // 如果数据量超过阈值,改用并行执行策略
}
sort(exec, vec.begin(), vec.end());  // 根据 exec 动态调用排序
  • 当数据量较小时,串行执行更高效,避免并行开销。
  • 当数据量较大时,使用并行执行能显著加速排序。
  • 通过变量 exec,代码实现了灵活切换,不用写两套排序逻辑。

总结

  • 执行策略 可以在运行时动态选择,不必在编译时固定。
  • 动态选择执行策略有助于性能优化,尤其针对不同规模的数据。
  • 这展示了执行策略灵活、易用的优点。

这段代码是用幽默和夸张的方式来表达执行策略的扩展性和多样性,帮你理解:

* 执行策略不仅限于标准的串行、并行、并行向量化,理论上可以定义很多不同的策略,让算法在不同的执行环境运行。

代码示例含义逐条解释

sort(vectorize_in_this_thread, vec.begin(), vec.end());
  • 在当前线程内启用向量化执行(SIMD 指令加速)。
sort(submit_to_my_thread_pool, vec.begin(), vec.end());
  • 把任务提交给线程池执行,多线程并行。
sort(execute_on_that_gpu, vec.begin(), vec.end());
  • 在 GPU 上执行排序,利用 GPU 的强大并行能力。
sort(offload_to_my_fpga, vec.begin(), vec.end());
  • 把排序任务下发到 FPGA 硬件上处理。
sort(send_to_the_cloud, vec.begin(), vec.end());
  • 把排序任务发送到云端服务器执行。
sort(launder_through_botnet, vec.begin(), vec.end());
  • 假装通过“僵尸网络”分布式执行。

总结

  • 这些都是“Implementation-Defined(实现定义)”的,**非标准(Non-Standard)**的执行策略。
  • 标准库里没有这些策略,但设计理念就是执行策略可以非常灵活,支持各种平台和硬件。
  • 说明执行策略机制非常强大,可以扩展支持不同的计算资源。

重点理解

  • 执行策略机制使算法调用可以透明地适配不同执行环境。
  • 不同实现可以根据需求定义自有的执行策略。
  • 标准执行策略是基础,实际中可以有更多自定义和专用的策略。

1. 什么是执行策略?

  • 执行策略是一种承诺(promise):它承诺算法在某种允许的重排序(reordering)下执行,但不会改变程序的语义和最终结果。
  • 它是对实现的请求(request):告诉编译器或库“请用某种执行方式(比如串行、并行、向量化)来运行这段代码”。

2. 允许什么样的重排序?

  • 重排序指的是执行语句的顺序可以改变,以利用并行或优化。
  • 但执行策略保证这种重排序不会破坏程序的正确性,也就是说:
    • 不会改变数据依赖关系导致的结果。
    • 不会引入数据竞争或未定义行为(在没有数据竞争的前提下)。
  • 简单来说,就是算法可以安全地重排或并行执行,但最终行为和顺序执行一致。

总结

  • 执行策略是程序员和库之间的“契约”,程序员承诺算法可以在允许的重排序范围内安全执行,库根据这个契约选择执行方式。
  • 它既是一种保证,又是一种请求,让并行和优化成为可能且安全。

**顺序执行(Sequential Execution)**的含义和行为,理解如下:

顺序执行的含义

  • 当使用执行策略 seq 调用算法(比如 algo(seq, begin, end, func);),算法会在调用线程中一个接一个、按顺序地执行操作。
  • 算法对元素的处理完全是顺序的,不会并发或乱序。

具体表现

  • 对每个元素调用传入的函数 func() 时,调用线程会依次调用,一个元素处理完才处理下一个。
  • 所有操作都发生在同一个线程(调用线程)中。

直观图示

调用线程|func()  ->  func()  ->  func()  ->  ... 
  • 依次调用函数,没有交叉或并行。

总结

  • 顺序执行是最简单、安全的执行方式。
  • 保证调用顺序和语义完全符合程序代码的书写顺序。
  • 适用于对顺序有严格要求或者数据量较小,不需要并行的场景。

**可并行执行(Parallelizable Execution)**的行为和规则,帮你理解如下:

1. 使用执行策略 par 调用算法

algo(par, begin, end, func);
  • 算法被允许并行调用 func(),即多个线程可以同时调用这个函数。
  • 线程之间调用的顺序是不确定的(indeterminate order)
  • 在同一个线程内,函数调用是无序的(unsequenced),意味着函数调用可能乱序执行。

2. 线程示意

  • 主调用线程可能负责调度,算法的实际执行发生在多个工作线程中。
  • 各线程上的 func() 调用顺序不保证,也就是说:
调用线程      线程 1          线程 2        线程 N|            |              |             |func()      func()         func()        func()func()      func()         func()        func().           .              .             .
  • 多个线程并发执行 func(),提升性能。

3. 重点理解

  • 并行执行会打乱调用顺序,不同元素处理顺序不可预测。
  • 由于函数在不同线程无序执行,函数必须是线程安全的,不能有副作用或者数据竞争。
  • 适合可独立处理的任务,能显著提高性能。

总结

  • par 策略允许算法并行运行,函数调用无序且跨线程。
  • 你需要保证被调用的函数是安全且无副作用的。
  • 这是实现并行加速的基础。

并行执行时的责任

  • 当你使用并行执行策略(如 par)时,保证程序正确性是调用者(程序员)的责任
  • 具体来说,你必须确保:
    • 不会引入数据竞争(data races),即多个线程不会同时对同一数据进行冲突的读写。
    • 不会出现死锁(deadlocks),即多个线程相互等待资源,导致程序无法继续执行。

解释

  • 并行算法会让多个线程同时调用你的函数。
  • 如果你的函数访问共享资源,没有合适同步,就会出现数据竞争,导致未定义行为。
  • 你需要设计线程安全的代码(比如使用锁、原子操作或避免共享状态)来防止这些问题。

总结

  • 并行执行带来性能提升,但安全性和正确性需要程序员自己负责
  • 并行编程中避免数据竞争和死锁是基本要求。

代码展示了一个**数据竞争(data race)**的典型例子,帮你理解:

代码说明

int a[] = {0,1};
std::vector<int> v;
for_each(par, a, a + 2, [&](int& i)
{v.push_back(i);
});
  • 使用并行执行策略 par 对数组 a 中的元素并行调用 lambda 函数。
  • 在 lambda 中,多个线程同时调用 v.push_back(i); 试图往同一个 std::vector 对象 v 里插入元素。

为什么会有数据竞争?

  • std::vectorpush_back 不是线程安全的。
  • 多个线程同时调用 push_back,修改同一个容器的内部状态,会导致:
    • 内部指针混乱。
    • 数据覆盖、丢失。
    • 程序崩溃或产生未定义行为。

数据竞争的本质

  • 多线程访问和修改共享数据时,没有同步机制(比如锁)保护,导致冲突。

正确做法(示例)

  • v 加锁,或
  • 每个线程先写入自己的局部容器,最后合并,或
  • 使用线程安全的数据结构。

总结

  • 并行执行时,调用者必须保证被调用的代码是线程安全的。
  • 直接在多个线程并发访问普通容器(如 std::vector)会引发数据竞争,必须避免。

这段代码展示了用互斥锁(std::mutex)来保护共享数据,防止数据竞争,但写法不够好,帮你理解:

代码解释

int a[] = {0,1};
std::vector<int> v;
std::mutex mut;
for_each(par, a, a + 2, [&](int& i)
{mut.lock();       // 上锁,保证同一时刻只有一个线程访问 vv.push_back(i);   // 向共享容器添加元素mut.unlock();     // 解锁,允许其他线程访问
});
  • 互斥锁保护了对 v 的访问,避免多个线程同时调用 push_back 导致的数据竞争。
  • 这样写,程序是线程安全的。

为什么说“不要这么做”?

  • 这段代码虽然正确,但效率很低
    • 每次插入都加锁、解锁,频繁的锁操作导致性能瓶颈。
    • 串行化了访问,几乎失去了并行的优势。

更好的做法

  • 使用局部线程容器,每个线程先写入自己的数据,最后合并。
  • 使用支持并发写入的数据结构。
  • 使用锁粒度更粗的策略减少锁操作次数。

总结

  • 加锁保证了安全,但过度加锁会导致性能下降。
  • 并行编程中要在安全性和性能之间权衡,避免频繁锁竞争。

这段代码展示了线程安全且高效的并行写入方式,帮你理解:

代码解释

int a[] = {0,1};
std::vector<int> v(2);  // 预先分配大小为2的vector
for_each(par, a, a + 2, [&](int& i)
{v[i] = i;   // 不同线程写入不同元素,没有数据竞争
});

为什么这段代码是安全的?

  • v 的大小已经预先确定(2个元素),且每个线程写入不同的索引 v[i]
  • 不同线程访问不同内存位置,不存在多个线程同时写同一个元素的情况。
  • 因此没有数据竞争,不需要加锁。

为什么效率更高?

  • 避免了加锁开销。
  • 并行执行时,线程间不会争用同一资源,充分利用并行优势。

总结

  • 并行写入共享容器时,如果能保证每个线程操作独立元素,完全线程安全且高效。
  • 预先分配容器大小,避免并发扩容带来的问题。
  • 这是编写并行代码时推荐的做法。

这段代码演示了一个**可能导致死锁(deadlock)**的错误写法,帮你理解:

代码说明

std::atomic<int> counter = 0;
int a[] = {0,1};
for_each(par, a, a + 2, [&](int& i)
{counter++;   // 线程安全地增加计数器// 自旋等待,直到计数器达到2while(counter.load() != 2){;  // 空循环,等待其他线程}// 做一些复杂操作...
});

为什么可能死锁?

  • 两个线程都进入了lambda。
  • 每个线程执行 counter++,假设计数器达到2后,理论上都应该跳出循环。
  • 但是如果某个线程在等待 counter.load() == 2 时,执行被操作系统挂起(比如被抢占),
    • 另一个线程可能也被挂起或未能成功增加计数器。
  • 结果,两个线程都在自旋等待对方更新 counter,形成死锁
  • 另外,自旋等待非常浪费CPU资源,且没有保障不会无限等待。

正确写法建议

  • 避免在并行代码中用自旋等待进行同步。
  • 使用更高效的同步机制,比如条件变量、信号量、屏障(barrier)。
  • 设计避免线程间强依赖和循环等待。

总结

  • 并行代码中的等待必须小心设计,防止死锁和活锁。
  • 简单的自旋等待容易出问题,不适合复杂同步。

是执行策略 par_vec(并行 + 向量化执行)的含义和行为:

par_vec 执行策略含义

  • algo(par_vec, begin, end, func); 允许算法:
    • 在不同线程间调用函数时是无序的(unsequenced)
    • 在同一线程内调用函数时也是无序的(unsequenced)

具体含义

  • 无序(unsequenced):函数调用之间没有固定顺序,甚至在单线程中,函数调用可以乱序执行。
  • 结合 并行(parallel)向量化(vectorization)
    • 多线程同时执行(并行)。
    • 每个线程内可利用 SIMD 指令对多条数据并行处理(向量化)。

影响和要求

  • 函数必须线程安全,无副作用或正确处理共享状态。
  • 函数必须能安全支持乱序执行(例如不能依赖调用顺序)。
  • 适合计算密集型、数据独立的任务,能充分利用硬件并行和向量化能力。

总结

  • par_vec 提供了最大程度的并行和指令级并行优化。
  • 同时利用多线程和 SIMD 指令加速。
  • 需要程序员确保代码在极其乱序的执行环境下依然正确。

在这里插入图片描述

图示说明了 par_vec(并行 + 向量化执行)策略下函数调用的并发与无序特点,帮你理解:

解释

  • 调用线程(calling thread)启动算法执行,但实际函数调用分布在多个线程上(线程 1、线程 2、线程 N)。
  • 每个线程内部函数调用是无序(unsequenced)的,也就是说函数调用可能被向量化,一次处理多个元素,调用顺序不可预测。
  • 线程之间函数调用也是无序的,多个线程并行执行,不保证先后顺序。

视觉示意(简化版)

调用线程|线程1: func() func() func() func() ...线程2: func() func() func() func() ...线程N: func() func() func() func() ...
  • 每条横线上的多个 func() 调用,可能同时执行,顺序不确定。
  • 跨线程的调用相互独立并行。

重点理解

  • par_vec 充分利用多核多线程 + SIMD 向量指令进行加速。
  • 需要保证 func() 在完全无序并发环境下也能正确工作(线程安全且无顺序依赖)。
  • 这是最高效但对代码要求也最高的执行策略。

parpar_vec 两种执行策略在函数调用顺序上的区别,以及使用它们时调用者的责任:

1. 在不同线程中的函数调用

  • parpar_vec 都是无序(unsequenced)的
    • 多线程之间调用函数没有固定顺序,线程间函数调用顺序不可预测。

2. 在同一线程中的函数调用区别

  • par:函数调用是“未指定顺序”(unspecified order)或者是顺序的(sequenced)
    • 也就是说,同一线程内函数调用可能是顺序的,也可能是不确定顺序,但通常是顺序执行的。
  • par_vec:函数调用完全无序(no sequence exists at all)
    • 在同一个线程里,函数调用也可以乱序,甚至同时向量化执行,多条指令并行运行,没有任何顺序保证。

3. 调用者的责任

  • 无论是 par 还是 par_vec调用者必须保证代码正确性
    • 函数调用之间不能尝试互相同步(比如用锁、条件变量等跨函数调用同步操作),否则可能引发竞态或死锁。
    • 需要保证线程安全,且函数之间不依赖执行顺序。

总结

执行策略不同线程间函数调用同一线程内函数调用
par无序(unsequenced)不确定顺序或顺序执行
par_vec无序(unsequenced)完全无序(乱序、向量化)
  • par_vec 要求更高,适合高度无序和并行优化场景。
  • 程序员要负责避免同步和顺序依赖带来的问题。

几段代码和说明展示了在 par_vec(并行+向量化)执行策略下不同计数方式的区别及最佳实践,帮你理解:

1. 可能死锁(不要这样写)

int counter = 0;
int a[] = {0,1};
std::mutex mut;
for_each(par_vec, a, a + 2, [&](int)
{mut.lock();++counter;mut.unlock();
});
  • 使用互斥锁保护计数器。
  • 但在 par_vec 下,函数调用完全无序且可能并行向量化,频繁加锁解锁会造成性能瓶颈甚至死锁
  • 因为锁的使用与向量化执行模式冲突,效率极低。

2. 正确写法

std::atomic<int> counter = 0;
int a[] = {0,1};
for_each(par_vec, a, a + 2, [&](int)
{++counter;
});
  • 使用原子变量 std::atomic<int>,避免锁竞争。
  • 原子操作线程安全,适合并行+向量化场景。
  • 性能比互斥锁更好,但仍有一定同步开销。

3. 最佳实践

int count = count_if(par_vec, ...);
  • 使用高级并行算法(如 count_if)直接完成计数任务。
  • 标准库算法针对 par_vec 优化,内部实现更高效。
  • 避免手动同步,提高代码简洁和性能。

总结

  • par_vec 里避免使用互斥锁,防止死锁和性能下降。
  • 使用原子操作保证线程安全。
  • 尽量使用高级并行算法,发挥最佳性能。

并行算法(Parallel Algorithms)

  • 标准库中的算法提供了重载版本,可以接受执行策略(execution policy)作为参数。
  • 通过传入不同的执行策略(如 seqparpar_vec),算法可以自动选择顺序执行或并行执行
  • 目标是尽可能将所有算法都实现并行化,以充分利用多核处理器和现代硬件加速性能。

总结

  • 传递执行策略参数,让算法更灵活。
  • 利用并行执行提升性能。
  • 标准库持续扩展并行支持,覆盖更多算法。

这段话介绍了三个新的并行算法及其含义,帮你理解:

1. reduce(归约)

  • 对集合中的元素进行归约操作,比如求和、乘积等。
  • 计算过程:
    result = init + a [ 0 ] + a [ 1 ] + ⋯ + a [ N − 1 ] \text{result} = \text{init} + a[0] + a[1] + \dots + a[N-1] result=init+a[0]+a[1]++a[N1]
  • 从初始值 init 开始,将所有元素累加(或按其他操作结合)得到一个最终结果。

2. exclusive_scan(排他扫描,排他前缀和)

  • 计算前缀和,但结果中第 i 个元素只累加到 a[i-1],不包含当前元素 a[i]
  • 计算过程:
    result [ i ] = init + a [ 0 ] + a [ 1 ] + ⋯ + a [ i − 1 ] \text{result}[i] = \text{init} + a[0] + a[1] + \dots + a[i-1] result[i]=init+a[0]+a[1]++a[i1]
  • 结果数组长度与输入相同,且第一个元素为 init

3. inclusive_scan(包含扫描,包含前缀和)

  • 计算前缀和,结果中第 i 个元素包含当前元素 a[i]
  • 计算过程:
    result [ i ] = init + a [ 0 ] + a [ 1 ] + ⋯ + a [ i ] \text{result}[i] = \text{init} + a[0] + a[1] + \dots + a[i] result[i]=init+a[0]+a[1]++a[i]

总结

  • 这三个算法用于高效的并行计算,尤其适合累积、统计、扫描类操作。
  • 它们有不同的应用场景,特别是在并行数据处理和流式计算中非常有用。

不支持并行化的算法类别,帮你理解:

不支持并行化的算法

  • 二分查找算法(Binary search algorithms)
    由于算法本身依赖有序结构和逐步折半,难以并行。
  • 堆算法(Heap algorithms)
    如堆排序、堆调整,操作高度依赖数据结构的局部性质,难以拆分成并行任务。
  • 排列算法(Permutation algorithms)
    如生成排列、下一排列,涉及顺序依赖,不适合并行。
  • 洗牌算法(Shuffling algorithms)
    随机置换序列,存在数据依赖,难以保证并行正确性。
  • 顺序数值算法(Sequential numeric algorithms)
    需要按顺序逐步计算的数值算法,比如某些递归或动态规划。

总结

  • 这些算法因数据依赖或步骤顺序,目前标准库中没有并行版本
  • 并行化难度大或收益不明显,因此保持顺序执行。

并行算法中异常处理的两种情况,帮你理解:

并行算法抛出的异常类型

  1. bad_alloc

    • 当算法需要的临时内存分配失败时,会抛出 std::bad_alloc 异常。
    • 说明系统内存不足,无法继续执行。
  2. exception_list

    • 并行执行时,用户代码(如传入的函数对象)可能在多个线程中抛出异常。
    • 这些异常会被收集起来,封装成一个 exception_list 对象抛出。
    • exception_list 中包含多个线程抛出的所有异常,方便统一捕获和处理。

总结

  • 并行算法异常处理机制能捕获多线程中多个异常。
  • 提供了集中处理并行异常的方式。
  • bad_alloc 是资源不足异常,exception_list 用于包装多个用户代码异常。

这段代码展示了一个异常处理的简单示例,帮你理解:

// 自定义异常类型,定义一个结构体superstition_error
// 提供what()方法返回错误信息字符串
struct superstition_error {const char* what() { return "eek"; }
};
std::vector<int> data = ...;  // 初始化数据容器
try
{// 遍历data容器中的所有元素std::for_each(data.begin(), data.end(), [](auto x){// 如果遇到数字13,抛出自定义异常superstition_errorif(x == 13){throw superstition_error();}});
}
catch(superstition_error& error)
{// 捕获superstition_error异常// 打印异常信息std::cerr << error.what() << std::endl;
}

代码说明

  • 定义了一个自定义异常类型 superstition_error,它有一个成员函数 what() 返回错误信息 "eek"
  • 遍历 data 容器中的元素,使用 for_each
  • 在遍历过程中,如果遇到数字 13,就抛出 superstition_error 异常。
  • 异常被 try-catch 块捕获,捕获到后打印异常信息。

重点

  • 这段代码是顺序执行的异常处理示例
  • 如果是在并行算法中,异常可能来自多个线程,通常会被封装成 exception_list

简单总结

  • 自定义异常类抛出异常。
  • 异常在外部捕获处理。
  • 并行环境下异常处理更复杂,需要特殊机制。

并行算法中处理异常的示例,帮你理解:

代码解析

struct superstition_error { const char* what() { return "eek"; } 
};
std::vector<int> data = ...;
using namespace std::experimental::parallelism;
try
{for_each(par, data.begin(), data.end(), [](auto x){if(x == 13){throw superstition_error();}});
}
catch(exception_list& errors)
{std::cerr << "Encountered " << errors.size() << " unlucky numbers" << std::endl;reduce(par, errors.begin(), errors.end(), my_handler());
}

关键点说明

  • 并行执行策略 par
    for_each(par, ...) 表示以并行方式执行。
  • 异常抛出
    在并行的多个线程中,遇到数字13会抛出 superstition_error 异常。
  • 异常捕获
    并行环境中,多个线程可能同时抛异常,所有异常会被收集到一个 exception_list 对象中。
  • 异常处理
    • 通过 errors.size() 获取异常数量(即出现多少“坏数字”)。
    • 使用并行算法 reduce 对异常进行聚合处理,调用自定义的 my_handler() 来处理异常列表中的每个异常。

总结

  • 并行算法中的异常不会直接单个抛出,而是封装成 exception_list
  • 调用者负责捕获 exception_list 并统一处理。
  • 可以利用并行算法进一步处理异常,保持并行效率。
#include <iostream>
#include <random>
#include <cmath>
#include <execution>
#include <algorithm>
#include <vector>
// 哈希函数,用于生成伪随机种子
int my_hash(int a)
{a = (a + 0x7ed55d16) + (a << 12);a = (a ^ 0xc761c23c) ^ (a >> 19);a = (a + 0x165667b1) + (a << 5);a = (a + 0xd3a2646c) ^ (a << 9);a = (a + 0xfd7046c5) + (a << 3);a = (a ^ 0xb55a4f09) ^ (a >> 16);return a;
}// 判断点是否落在单位四分之一圆内
bool test_quarter_circle(int seed)
{std::default_random_engine rng(my_hash(seed));std::uniform_real_distribution<float> u01(0, 1);float x = u01(rng);float y = u01(rng);float dist2 = std::sqrt(x * x + y * y);return dist2 <= 1.0f;
}int main()
{// 投掷次数,3亿(300 * 2^20)const int n = 300 << 20;// 用vector存放0到n-1的序列std::vector<int> seeds(n);std::iota(seeds.begin(), seeds.end(), 0);// 并行统计满足条件的点数int count = std::count_if(std::execution::par, seeds.begin(), seeds.end(), test_quarter_circle);double pi_estimate = (4.0 * count) / n;std::cout << "pi is approximately " << pi_estimate << std::endl;return 0;
}

这段内容介绍了一个用蒙特卡洛方法估算 π(圆周率)的示例程序,并展示了其在不同硬件和并行环境下的性能对比。帮你详细理解:

核心思想:估算 π 的蒙特卡洛方法

  • 利用单位正方形内随机投掷点(“投掷飞镖”)
  • 计算落在单位正方形内四分之一圆(半径=1)的点的比例
  • 因为四分之一圆面积是 π × 1 2 / 4 = π / 4 \pi \times 1^2 / 4 = \pi/4 π×12/4=π/4
  • 所以 π ≈ 4 × 落在圆内的点数 总点数 \pi \approx 4 \times \frac{\text{落在圆内的点数}}{\text{总点数}} π4×总点数落在圆内的点数

代码关键点

  1. test_quarter_circle(int seed) 函数

    • 根据输入 seed 生成伪随机数(利用自定义 my_hash 保证随机性)
    • 在 [0,1] 区间生成 x , y x, y x,y 坐标,点位于单位正方形
    • 计算点到原点距离,判断是否在四分之一圆内(即距离 ≤ 1)
  2. my_hash(int a) 函数

    • 一个整数哈希函数,用于随机数引擎的种子,使每个点生成独立随机值
  3. 主程序流程

    • 生成 3 亿个整数序列 [0..n) 代表投掷次数
    • 利用并行算法 count_if(par, ...),统计落入圆内点数
    • 计算并输出 π 的估计值

性能对比

环境硬件时间加速比
单线程 CPUIntel i7 860约 5.1 秒基准 1x
8 线程 CPUOpenMP,Intel i7 860约 0.97 秒约 5.25x
GPU 多线程CUDA,NVIDIA Tesla K20约 0.26 秒约 19.6x

总结

  • 通过简单的并行算法调用,程序可无缝利用多核 CPU 和 GPU 加速
  • 使用标准并行执行策略 (par) 实现代码简洁且性能良好
  • 体现了性能移植性(Performance Portability):同一算法可在不同硬件上高效运行
    在这里插入图片描述

相关文章:

  • 2024 CKA模拟系统制作 | Step-By-Step | 20、题目搭建-节点维护
  • Linux之MySQL安装篇
  • 6个月Python学习计划 Day 10 - 模块与标准库入门
  • OpenHarmony标准系统-HDF框架之音频驱动开发
  • leetcode77.组合:回溯算法中for循环与状态回退的逻辑艺术
  • LeetCode - 206. 反转链表
  • 软件性能之CPU
  • leetcode hot100刷题日记——30.两数之和
  • 设计模式——单例设计模式(创建型)
  • 【MFC】如何设置让exe的控制台不会跟着exe退出而退出
  • 【KWDB 创作者计划】_探秘浪潮KWDB数据库:从时间索引到前沿技术
  • C++ 重载(Overload)、重写(Override)、隐藏(Hiding) 的区别
  • 【Hot 100】121. 买卖股票的最佳时机
  • acwing刷题
  • 江科大IIC读取MPU6050hal库实现
  • 在Windows本地部署Dify详细操作
  • Linux入门(十二)服务管理
  • 建筑兔零基础人工智能自学记录101|Transformer(1)-14
  • LG P5048 [Ynoi2019 模拟赛] Yuno loves sqrt technology III Solution
  • 若依框架-定制化服务搭建
  • wordpress配置全站https/seo公司seo教程
  • 网站做百度权重排名论坛/磁力搜索器在线
  • 深圳网站建设优化排名/seo排名资源
  • 交友网站建设的栏目规划/seo教程技术
  • javascriptjava阿姨/网站推广优化设计方案
  • 无锡建设局评职称网站/百度推广一年大概多少钱