并行算法

读完能为 C++17 标准算法选择 seq / par / par_unseq,区分执行许可与性能保证,正确使用 sort / for_each / reduce / transform_reduce / scan,并从代数性质、共享副作用、异常语义和实测收益审查一次并行化。

学习目标

  • 修改标准库算法调用并选择执行策略:给 std::for_each / std::sort / std::reduce / std::transform_reduce 传入合适的策略,并说清 seq / par / par_unseq 授予什么许可、又不保证什么
  • 解释 reduce 为什么要求操作可结合 / 可交换、accumulate 为什么严格顺序:看懂 reduce 靠树形归约并行求和、可重排,而把减法这类不可结合的操作交给 reduce 会算出不确定的错误结果
  • 能回答:一段「对几十个元素各做一次加法」的循环,无脑加上 std::execution::par 会更快还是更慢?为什么?(用「该不该并行」的判断清单当场分析)

机制总览

执行策略、归约律与并行收益

  1. 1

    选择策略

    seq、par 与 par_unseq 分别允许不同并发和向量化自由。

  2. 2

    验证运算律

    reduce 的合并操作要满足结合性,并接受不同分组顺序。

  3. 3

    衡量收益

    数据规模、单项成本和内存带宽共同决定是否加速。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

执行策略、归约律与并行收益

选择算法阶段,判断 callable 是否线程安全、归约是否可重排、数据规模是否值得并行。

选择推理阶段

当前阶段 · 选择策略

seq、par 与 par_unseq 分别允许不同并发和向量化自由。

可核验证据

策略约束、工具链支持与线程观测。

执行策略授权实现改变调度和顺序;只有无共享副作用且满足代数约束的操作才可安全并行。

失效—证据矩阵

执行策略、归约律与并行收益

选择策略

典型失效

par_unseq 中加锁或执行不安全的阻塞操作。

核验证据

策略约束、工具链支持与线程观测。

验证运算律

典型失效

用浮点或非结合操作期待与串行逐项完全相同。

核验证据

随机分块、误差界与结果不变量。

衡量收益

典型失效

小输入无脑并行,调度成本超过计算。

核验证据

规模曲线、带宽指标与串行基线。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

从“执行策略只是许可”开始

前几章你学会了亲手招厨师、分活、排队取菜——那是把并行的每个零件都自己拧。可大多数时候你只想说一句:「把这一筐菜都处理一遍」。要是连「分给几个厨师、要不要同时上」都得自己排班,未免太累。

更省心的办法是:下这句指令时,顺手挑一个并行档位。你可以说「一个厨师顺着做就行」,也可以说「多叫几个厨师分批同时做」,还可以说「多叫几个厨师,而且每人允许一手同时颠两个锅」。同一句「处理这筐菜」,挑的档位不同,快慢就天差地别——而排班的脏活,交给后厨总管去办。

没有这套「挑档位」的本事会怎样?要么你为了让活儿快点,又得回去手搓那套招人、分活、合账的全套流程;要么图省事永远只用顺序版本。更重要的是,档位只是给实现许可par 不承诺创建新线程,也不承诺更快,资源不足时可以顺序执行。

执行策略:算法的「并行档位」

把「挑档位」翻译成 C++17 的话,就是给算法传一个 。它是算法的第一个实参,描述实现可以采用哪类执行方式。C++17 提供三档;C++20 另加只允许无序向量化、不允许多线程并行的 std::execution::unseq,不要把它误写成 C++17 接口:

可交互
执行策略:给「处理这批 8 个元素」附带的并行档位同一个 for_each,换不同策略 → 并行 / 向量化程度不同 → 用时不同seq一个厨师顺序做(8 拍)01234567par4 厨师分块同时做(2 拍)线程0 · 元素0线程0 · 元素4线程1 · 元素1线程1 · 元素5线程2 · 元素2线程2 · 元素6线程3 · 元素3线程3 · 元素7par_unseq4 厨师 + 每人颠两锅(向量化,1 拍)01线程023线程145线程267线程3

第 1 / 6 步 · seq:一个厨师顺序做——元素 0 先处理

同一个 for_each:seq 一个个做(8 拍)、par 分 4 块同时做(2 拍)、par_unseq 再叠加向量化一拍做完。档位越强、用时越短——但并非无脑选最强(见正文)。可暂停、单步、拖进度。

C++17 三档执行策略:seq(顺序)、par(多线程并行)、par_unseq(并行 + 每线程内向量化)。给同一个算法传不同策略,就决定了它并行到什么程度、向量化到什么程度。

第一档 (顺序):元素访问函数都在调用线程执行,彼此不交错,也不得并行化。它不等于给所有算法额外承诺“按输入顺序回调”;顺序仍要看具体算法的规范。

第二档 (并行):允许把工作分给多个线程,但并不保证这样做。代价是:函子必须能被并发调用,不能依赖调用线程身份或调用顺序,更不能产生数据竞争。

第三档 (并行 + 不排序):在多线程许可之上,再允许同一线程内 。互斥锁等阻塞同步可能让同一线程在尚未解锁时再次请求同一把锁,因此属于向量化不安全操作。不要把规则简化成“禁止所有原子或内存分配”:标准对内存分配/释放和无锁原子读改写有例外;工程上仍优先让函子保持纯计算,把合并交给归约算法。

三档不是「越强越好」,而是「越强对你的函子要求越严、且只在活够大够独立时才真划算」(§4 的判断清单专门讲这个)。

并行 STL:给算法传策略,授予执行许可

执行策略真正的妙处在于:调用形状几乎不用改。原来怎么调 std::sortstd::for_eachstd::reduce,现在只是在参数最前面放一个执行策略。这些「能接受执行策略、从而允许并行执行」的算法重载,就叫

从「自己 new 线程、自己分块、自己合并」到「加一个 par」,省下的代码量是巨大的——这正是本章 §6 要带你一行行体会的。for_each 并行处理每个元素、sort 并行排序、reduce 并行求和……都只是多打了几个字符。

reduce vs accumulate:能并行的求和,凭什么能并行

并行求和这件事,藏着一个微妙但关键的区别。老朋友 std::accumulate 你早就用过——它从左到右、一个一个累加:((((0+a)+b)+c)+d),第 ii 步必须等第 i1i-1 步算完。这是严格顺序的,天生无法并行,所以标准库没给它执行策略重载

C++17 新增的 std::reduce 长得几乎一样,却能并行。秘密在于它用 :先把 (a+b)(c+d) 同时算出来,再合并成 ((a+b)+(c+d))。这棵树的每一层内部互不依赖,可以并行。下面这张动画把「线性链 vs 树形」并排演给你看——先猜一猜:

猜一猜:reduce 既然能把 (a+b)(c+d) 拆开并行算,那把它的加号换成减号、做 a-b-c-d,并行出来的结果还对吗?

可交互
accumulate:严格顺序一张张顺着加,第 i 步等第 i-1 步——不能并行reduce:树形归约两两并行求和、再合并——可重排、可并行00+a+b+c+d = 和abcda+bc+d第二层:合并 ↑第一层:并行 ↑⚠ reduce 重排的前提:操作可结合 / 可交换;减法 a−b−c−d 重排即错

第 1 / 4 步 · accumulate:从 0 起,必须 +a → +b → +c → +d 一个一个顺着加(4 步串行,第 i 步等第 i-1 步)

左:accumulate 一个一个顺着加(4 步串行)。右:reduce 先两两并行求和、再合并(2 层)。reduce 能这么重排的前提是操作可结合 / 可交换。可暂停、单步、拖进度。

accumulate 像顺着一摞账一张张加,必须从头到尾顺序来;reduce 像把账分组并行求和、再合并——快得多,但前提是加法可任意结合、换序(减法这类就不行)。

盯住右半区:reduce 之所以敢把求和顺序重排成树形,前提是操作可结合、可交换——加法满足 (a+b)+c == a+(b+c),怎么分组、谁先算结果都一样。可一旦操作不满足这个前提,重排就会算错。这就引出本章一个核心术语 :加法可结合,所以 reduce(+) 怎么并行都对;可减法 a-b-c-d 一旦重排成 (a-b)-(c-d),结果就完全变了。所以 reduce 只对可结合 / 可交换的操作才能保证结果正确——这是它和 accumulate 最本质的分水岭(§6 有正反代码,§7 有踩坑)。

何时该并行:并行不是免费的

最后一个、也是最常被忽略的判断:这段代码到底该不该并行? 很多人以为「能并行就并行、能选 par_unseq 就选最强」,结果加上 par 反而更慢。原因是并行有它自己的开销——把活分给多个线程、调度它们、最后合并结果,这些都要花时间;还可能撞上第九章讲过的 false sharing、锁争用。当每个元素的活又少又轻、数据量又小时,这笔开销会盖过并行省下的时间。

下面这张对比图给你一份「该不该 par」的判断清单:

该不该传 par?一份判断清单并行不是免费的——分发、调度、合并都要钱,得看活够不够大、够不够独立✓ 值得并行数据量大(百万级元素)每元素处理成本重(一次要算不少)元素间依赖 / 争用无依赖、无共享写、无副作用分发开销被大量重活摊薄 → 加速明显✗ 并行反而亏数据量小(几十、几百个)每元素处理成本极轻(一次加法、一次赋值)元素间依赖 / 争用有依赖 / 共享写 / 锁 / false sharing分发 / 同步开销盖过收益 → 可能更慢
并行有开销(分发、调度、合并、可能的争用)。只有「数据量大 + 每元素处理重 + 元素间无依赖无争用」三条都占,par 才划算;反之小数据、极轻处理、有争用,并行反而比顺序更慢。

一句话总则:数据量大 + 每元素处理够重 + 元素间无依赖无争用,三条都占,par 才划算;反之小数据、极轻处理、有共享写 / 有副作用,老老实实 seq,甚至并行还不如顺序快。这正好呼应第九章的 Amdahl 定律(串行部分卡住上限)和 false sharing(共享写拖垮并行)——并行算法只是把那些道理换了个更省力的接口,底层的账还是同一本。

上手玩一玩这三张图

本章三张图各管一件事,配合 §6 代码一起玩比读十遍管用:

  • 执行策略三档 <ExecutionPolicyDiagram />(主 Demo):单步看同一个 for_each 在 seq / par / par_unseq 三档下的处理节奏——seq 8 拍逐个、par 2 拍分块、par_unseq 1 拍叠加向量化。先想「是不是无脑选最强的就好」,再看正文 §4 的反例。盯住「档位越强、用时越短,但对函子要求越严」这一点。
  • reduce 树形归约 <ReduceTreeDiagram />:左边 accumulate 一个一个顺着加(4 步串行),右边 reduce 两两并行求和再合并(2 层)。重点体会末尾那条警示:reduce 重排的前提是操作可结合 / 可交换,减法一重排就错。
  • 该不该并行 <WhenToParallelizeDiagram />:两栏对照「值得并行」与「并行反而亏」的三条判据。做 §8 分析题前,先把这份清单记牢——并行不是免费的。

代码:给算法加一个执行策略

for_each 加 std::execution::par 一键并行

最直接的并行化:原来怎么 for_each,现在只在最前面塞一个 std::execution::par,算法就自动把元素分给多个线程处理。比如把一张图片的所有像素并行调亮:

#include <algorithm>
#include <execution>
#include <vector>
 
// 给所有元素并行地乘 2:第一个实参传执行策略 std::execution::par
void brighten(std::vector<int>& pixels) {
    std::for_each(std::execution::par, pixels.begin(), pixels.end(),
                  [](int& x) { x *= 2; });
}

和老写法的唯一差别,就是多了 std::execution::par, 这一个实参。算法内部会把 pixels 切成若干块、分给多个线程各跑各的——而每个像素只是独立地乘 2,互不依赖,正是并行的理想场景。

std::sort 并行排序

排序适合展示策略重载。给 std::sortpar 后,实现可以采用并行分治,也可以因数据规模、资源或后端限制退回顺序执行:

#include <algorithm>
#include <execution>
#include <vector>
 
// 并行排序:把 std::execution::par 作为第一个实参传给 std::sort
void parallel_sort(std::vector<double>& data) {
    std::sort(std::execution::par, data.begin(), data.end());
}

比较器必须满足严格弱序,而且要能被并发调用。捕获一个可变计数器并在比较时裸写,会产生数据竞争;比较结果依赖调用次数或外部可变状态,还会破坏排序契约。实际收益取决于元素类型、比较成本、数据分布、规模与实现后端,必须和同一数据上的无策略 sort 做基准对照。

reduce vs accumulate:并排看「能并行的求和」

并行求和是本章的灵魂对照。accumulate 严格顺序、没有执行策略重载;reduce 能传 par 并行——但要求二元操作可结合 / 可交换:

#include <execution>
#include <numeric>
#include <vector>
 
// accumulate:从左到右严格顺序累加,无执行策略重载——天生串行
long sum_accumulate(const std::vector<long>& v) {
    return std::accumulate(v.begin(), v.end(), 0L);
}
 
// reduce:可传执行策略并行;默认用加法,可结合可交换 → 重排安全
long sum_reduce(const std::vector<long>& v) {
    return std::reduce(std::execution::par, v.begin(), v.end(), 0L);
}

假设 long 求和不溢出,两个函数都表达「所有元素之和」。区别在于:accumulate 按顺序折叠,而 reduce 可以改变分组与求值次序——这就是 §5 那张树形归约图。对浮点数,即使用加法,舍入也会让不同分组得到不同末位;需要可复现实验结果时,要固定归约树或使用更稳定的求和方案。

transform_reduce:先映射、再并行归约

很多「先逐元素算一下、再把结果汇总」的活,可以用 std::transform_reduce 一步并行搞定。典型如点积:先把两个向量逐对相乘,再把乘积求和:

#include <execution>
#include <numeric>
#include <vector>
 
// 并行算两个等长向量的点积:先逐对相乘(transform),再并行求和(reduce)
double dot_parallel(const std::vector<double>& a, const std::vector<double>& b) {
    return std::transform_reduce(
        std::execution::par,
        a.begin(), a.end(), b.begin(),
        0.0,                  // 归约初值
        std::plus<>(),        // 归约:求和(可结合 / 可交换)
        std::multiplies<>()); // 映射:逐对相乘
}

transform_reduce 把「映射 + 归约」融成一个算法:映射(multiplies)逐对独立,归约(plus)允许改变分组。它通常能避免显式中间数组,但这里是 double 点积,结果可能随归约分组改变末位;不能把数学上的结合律直接当成 IEEE 浮点的逐位确定性。

scan:保留每个前缀结果

reduce 只返回一个总结果;扫描算法则写出每个前缀的结果。exclusive_scan 在位置 i 写入“初值与输入区间 [first, i) 的归约”,所以输入 {1, 2, 3, 4}、初值 0 会得到 {0, 1, 3, 6}

#include <execution>
#include <numeric>
#include <vector>
 
std::vector<int> prefix_offsets(const std::vector<int>& sizes) {
    std::vector<int> offsets(sizes.size());
    std::exclusive_scan(std::execution::par, sizes.begin(), sizes.end(),
                        offsets.begin(), 0);
    return offsets;
}

同族还有 inclusive_scantransform_inclusive_scantransform_exclusive_scan。它们适合前缀和、压缩布局偏移等任务,但同样可能改变运算分组;输出区间必须满足该算法的重叠规则,不能想当然地原地覆盖。

迭代器要求由具体重载决定,不能笼统说“并行算法都要求随机访问迭代器”。例如 C++17 的多个策略重载以 ForwardIterator 表达最低要求,而 sort 本身仍要求随机访问。写泛型封装时应查目标算法的签名与前置条件,而不是从 par 推断迭代器类别。

注意事项:异常与数据竞争

并行算法还有两条必须记牢的硬约束。第一条:使用 seqparpar_unseq 这些标准执行策略时,元素访问函数的异常若越过算法边界,程序会调用 std::terminate;这条规则连 seq 策略也覆盖。正解是使用不抛函子,或在函子内部把失败转换为明确的结果状态:

#include <algorithm>
#include <execution>
#include <stdexcept>
#include <vector>
 
void process(std::vector<int>& v) {
    // 标准执行策略下,元素访问函数的异常逸出会触发 terminate。
    std::for_each(std::execution::par, v.begin(), v.end(), [](int& x) {
        try {
            if (x < 0) throw std::runtime_error("bad");
            x *= 2;
        } catch (const std::exception&) {
            x = 0; // 就地兜住,异常不逸出并行算法
        }
    });
}

第二条:par 要求函子无数据竞争。多个线程会并发调用它,若函子里多个线程同写一个普通变量,就是 UB。要跨元素汇总,得用 std::atomic(或干脆用 reduce 让标准库替你安全合并):

#include <algorithm>
#include <atomic>
#include <execution>
#include <vector>
 
// par 要求函子无数据竞争:用 atomic 计数,避免多线程同写普通变量
std::size_t count_positive(const std::vector<int>& v) {
    std::atomic<std::size_t> n{0};
    std::for_each(std::execution::par, v.begin(), v.end(), [&n](int x) {
        if (x > 0) n.fetch_add(1, std::memory_order_relaxed);
    });
    return n.load();
}

并行调用还可能在失败发生前完成一部分元素修改;terminate 不会替你回滚。需要事务式“全成或全不成”时,先写入独立临时结果,验证全部成功后再提交,或改用能显式传播错误与取消的任务模型。

容易踩的坑

小结

  • 执行策略授予许可seq 禁止并行,par 允许并行,par_unseq 还允许同一线程内不排序执行;后二者都可退回顺序执行,不保证新线程、SIMD 或提速
  • 算法契约仍有效:比较器必须满足严格弱序且可并发调用,元素操作不能产生数据竞争,也不能依赖调用顺序或线程身份
  • 归约与扫描会改变分组:减法不能直接交给 reduce;浮点加法也可能出现末位差异,需要确定性时应固定归约方案
  • 标准策略有失败边界:元素访问函数的异常逸出会 std::terminate,且此前副作用不会自动回滚;需要传播、取消与提交协议时改用显式任务
  • 性能必须实测:确认目标标准库和后端,以无策略版本为基线测不同规模;标准策略不提供线程池、线程数或粒度控制旋钮

练习

问题 1(改代码型) 下面这段代码想并行统计一个 vector<int> 里正数的个数,但它在 par 函子里裸写了共享变量 count,有数据竞争。请改对它,并说明为什么裸写会出错。

#include <algorithm>
#include <execution>
#include <vector>
 
std::size_t count_positive_buggy(const std::vector<int>& v) {
    std::size_t count = 0;
    std::for_each(std::execution::par, v.begin(), v.end(),
                  [&count](int x) {
                      if (x > 0) ++count; // ← 多线程同写 count:数据竞争 UB
                  });
    return count;
}

问题 2(C 型分析题) 同事把一段「对一个 vector<double> 用减法做累减」的代码从 accumulate 改成了 reduce(par, ...) 想加速,结果发现并行跑出来的结果每次都不一样。请分析根因,并说明这个需求到底该怎么写。

问题 3(分析题) 给定两段需求,各该选 seqpar 还是 par_unseq?说明理由(结合「该不该并行」清单与三档对函子的要求)。 (a) 对一个一千万元素vector<float>,每个元素做一次较重的数学变换(多次三角函数 + 开方),元素间完全独立、无共享写、无副作用。 (b) 对一个只有 20 个元素的小 vector,每个元素查一张共享的缓存表并在未命中时加锁写入

名词解释

本章出现的专业名词,用大白话再讲一遍。

执行策略

execution policy。作为策略重载的第一个实参,描述实现可以怎样执行元素访问函数。它表达许可和相应的函子约束,不承诺创建线程、使用 SIMD 或提高性能。

std::execution::seq

C++17 顺序执行策略。元素访问函数在调用线程执行、彼此不交错,算法不得并行化;具体访问顺序仍由那个算法自己的语义决定。

std::execution::par

C++17 并行执行策略。允许元素访问函数在调用线程或库管理的线程中并发执行,也允许实现因资源不足退回顺序执行。函子必须支持并发调用且不能产生数据竞争。

std::execution::par_unseq

C++17 并行且不排序执行策略。除允许并行外,还允许同一线程中的元素访问函数彼此不排序并交错;调用互斥锁等向量化不安全函数会导致未定义行为。

向量化

vectorization。实现可以用 SIMD 或其他方式让多个元素操作不排序地推进;par_unseq 授予这种许可,但不保证编译器一定生成向量指令。

并行算法(并行 STL)

C++17 为多种标准算法增加的执行策略重载。策略作为第一个实参,线程、分块、合并及是否退回顺序执行由实现决定;可用后端和链接要求要在目标工具链验证。

树形归约

tree reduction。把输入分组产生部分结果,再逐层合并到单个结果。它暴露并行机会,也会改变运算的分组和求值次序。

可结合性

associativity。若 (a op b) op ca op (b op c) 等价,运算可结合。reduce 会改变分组;若还可能交换操作数,则要同时检查交换性。数学实数加法可结合,但有限精度浮点加法并不严格满足。

讨论

评论区加载中…