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::par
,par_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
是一个经验值或性能分析得出的分界点,例如1000
、10000
,取决于数据结构、平台、调度器开销等。
优点:
- 自动化优化,无需你“选择困难症”
- 更适合写通用库或大规模数据处理系统
- 不会在小数据上浪费并行调度开销,也不会在大数据上落入串行瓶颈
这个函数模板 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::par
或std::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=1
:std::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_bound
和std::transform
),并利用标准库支持的并行执行策略,我们可以轻松实现并行计算,而不必自己写复杂的线程管理代码。 - 换句话说,写高层算法时只需调用低层并行算法原语,就能“免费”获得并行性能提升。
3. How to write parallel programs
这部分说的是写并行程序的方法论。
4. High-level algorithms
- 使用标准库里提供的高级算法接口(如
std::transform
、std::reduce
、std::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::sort
、std::transform
、std::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::vector
的push_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()
在完全无序并发环境下也能正确工作(线程安全且无顺序依赖)。 - 这是最高效但对代码要求也最高的执行策略。
par
和 par_vec
两种执行策略在函数调用顺序上的区别,以及使用它们时调用者的责任:
1. 在不同线程中的函数调用
par
和par_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)作为参数。
- 通过传入不同的执行策略(如
seq
、par
、par_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[N−1] - 从初始值
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[i−1] - 结果数组长度与输入相同,且第一个元素为
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)
需要按顺序逐步计算的数值算法,比如某些递归或动态规划。
总结
- 这些算法因数据依赖或步骤顺序,目前标准库中没有并行版本。
- 并行化难度大或收益不明显,因此保持顺序执行。
并行算法中异常处理的两种情况,帮你理解:
并行算法抛出的异常类型
-
bad_alloc
- 当算法需要的临时内存分配失败时,会抛出
std::bad_alloc
异常。 - 说明系统内存不足,无法继续执行。
- 当算法需要的临时内存分配失败时,会抛出
-
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×总点数落在圆内的点数
代码关键点
-
test_quarter_circle(int seed)
函数- 根据输入
seed
生成伪随机数(利用自定义my_hash
保证随机性) - 在 [0,1] 区间生成 x , y x, y x,y 坐标,点位于单位正方形
- 计算点到原点距离,判断是否在四分之一圆内(即距离 ≤ 1)
- 根据输入
-
my_hash(int a)
函数- 一个整数哈希函数,用于随机数引擎的种子,使每个点生成独立随机值
-
主程序流程
- 生成 3 亿个整数序列
[0..n)
代表投掷次数 - 利用并行算法
count_if(par, ...)
,统计落入圆内点数 - 计算并输出 π 的估计值
- 生成 3 亿个整数序列
性能对比
环境 | 硬件 | 时间 | 加速比 |
---|---|---|---|
单线程 CPU | Intel i7 860 | 约 5.1 秒 | 基准 1x |
8 线程 CPU | OpenMP,Intel i7 860 | 约 0.97 秒 | 约 5.25x |
GPU 多线程 | CUDA,NVIDIA Tesla K20 | 约 0.26 秒 | 约 19.6x |
总结
- 通过简单的并行算法调用,程序可无缝利用多核 CPU 和 GPU 加速
- 使用标准并行执行策略 (
par
) 实现代码简洁且性能良好 - 体现了性能移植性(Performance Portability):同一算法可在不同硬件上高效运行