oneTBB 规范解读:实现 ParallelReduceBody 命名要求,写出正确的 parallel_reduce 归约体
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
导读
ParallelReduceBody是 oneTBB(oneAPI Threading Building Blocks)为parallel_reduce与parallel_deterministic_reduce算法定义的一组命名要求(named requirement):任何想以"命令式形式"参与并行归约的自定义类型,都必须满足这组要求。本篇文章以 mold 仓库内随附的 oneTBB 规范文档 par_reduce_body.rst 为主体,结合 oneTBB 头文件中的任务调度实现与测试用例,完整讲解归约体(Body)的四个必需成员、split分裂标记的语义、与Range要求的协作关系,以及运行时并发安全约束。读完本文,你将能够编写出正确、高效且可被并行调度器安全分裂与合并的自定义归约体,并能区分parallel_reduce与parallel_deterministic_reduce在 Body 使用方式上的关键差异。
一、什么是 ParallelReduceBody:规范文档的核心定位
在 oneTBB 的算法体系中,parallel_reduce提供两种调用形式(详见 parallel_reduce_func.rst):
- 函数式形式
parallel_reduce(range, identity, func, reduction):配合 lambda 表达式使用,返回归约结果; - 命令式形式
parallel_reduce(range, body):把归约状态封装进一个自定义的Body对象,通过成员函数完成累加与合并,最大限度减少数据复制。
ParallelReduceBody命名要求约束的正是命令式形式中的Body类型。规范文档指出:
A type
BodysatisfiesParallelReduceBodyif it meets the following requirements.
mold 仓库在third-party/目录下完整随附了 oneTBB 的规范文档(RST 源码)与实现源码,因此本文所有结论均可直接在仓库内交叉验证:规范文本位于 par_reduce_body.rst,实现位于 parallel_reduce.h。
二、核心要求:归约体必须实现的四个成员
规范文档给出了ParallelReduceBody的全部四个必需成员(pseudo-signature),任何候选类型都必须完整提供,缺一不可。
1. 分裂构造函数:Body::Body( Body&, split )
Body::Body( Body&, split )这是整个命名要求中最特殊、也最容易写错的一个成员。它由库在任务分裂时调用:当调度器决定把一个归约任务分成两个并发执行的子任务时,会用当前Body与一个split类型的哑元参数构造出另一个独立的新 Body。
规范原文特别强调了它的并发语义:
Splitting constructor. Must be able to run concurrently with
operator()and methodjoin.
即:分裂构造函数必须能够在另一个线程正在执行同一个 Body 的operator()或join的同时被调用——它只读取原 Body 的初始状态,不与其发生数据竞争。这一约束直接决定了 Body 内部状态的设计边界。
在 oneTBB 的实现中,split是一个空标记类型,定义于 _range_common.h:
class split {};其头注释说明了设计意图:Dummy type that distinguishes splitting constructor from copy constructor(用于将分裂构造函数与拷贝构造函数区分开的哑元类型)。正是这个额外的哑元参数,使Body(Body&, split)与拷贝构造函数Body(const Body&)在重载决议上天然区分,库内部才能安全地通过"以split调用"这一手段触发分裂逻辑,而不会误触发拷贝。
2. 析构函数:Body::~Body()
Body::~Body()规范要求 Body 必须具备(即类型必须是可析构的)。这在实践中几乎总是自动满足,但需要注意的是:调度器会在分裂出的子任务结束后于任意线程上析构子 Body,因此析构函数同样不能假定"只在自己的线程运行"。
3. 子区间累加:void Body::operator()(const Range& range)
void Body::operator()(const Range& range)operator()负责把range所代表的子区间上的元素累加进当前 Body 的归约状态中。规范明确了两点约束:
Range类型必须满足 Range 命名要求(可递归分裂、可判空、可判可分割性);- 同一个 Body 实例可能被调度器先后用于处理多个互不相邻的子区间(见下文"运行时语义"一节),因此
operator()必须具有累加(accumulate)语义,而不是"覆盖"语义。
规范原文:Accumulates result for a subrange.Rangetype must meet theRangerequirements.
4. 结果合并:void Body::join( Body& rhs )
void Body::join( Body& rhs )join是归约的"合并"环节:当一个父任务的两个子任务都完成归约后,调度器调用父 Body 的join,把rhs中累积的结果并入this。
规范原文:Joins results. The result in rhs should be merged into the result ofthis.
合并方向是有语义约定的:在 parallel_reduce_func.rst 中进一步说明——归约操作只需满足结合律(associative),不要求满足交换律(commutative)。对于非交换操作op,left.join(right)应当把left更新为left op right的结果(而不是反过来)。例如矩阵乘法这类非交换运算,正确的join顺序是决定结果正确性的关键。
三、分裂语义与 Split 标记:从哑元到调度器契约
ParallelReduceBody并非孤立的命名要求,它与 oneTBB 的Splittable命名要求一脉相承。规范文档 splittable.rst 说明,库在两类场景中使用分裂构造函数:
- 划分 Range:把一个区间切成两个可并发处理的子区间;
- 派生 Body:把一个函数对象"分叉"成两个可并发运行的函数对象。
对于 Body 而言,分裂构造函数的正确写法通常是"从原 Body 复制初始状态(identity 值),而不是复制当前累积结果"。以 oneTBB 规范文档中的经典Sum示例(见 parallel_reduce_func.rst)为例:
#include "oneapi/tbb/parallel_reduce.h" #include "oneapi/tbb/blocked_range.h" using namespace oneapi::tbb; struct Sum { float value; Sum() : value(0) {} Sum( Sum& s, split ) { value = 0; } void operator()( const blocked_range<float*>& r ) { float temp = value; for( float* a=r.begin(); a!=r.end(); ++a ) { temp += *a; } value = temp; } void join( Sum& rhs ) { value += rhs.value; } }; float ParallelSum( float array[], size_t n ) { Sum total; parallel_reduce( blocked_range<float*>( array, array+n ), total ); return total.value; }这里分裂构造函数Sum(Sum&, split)把新 Body 的value重置为 0(加法单位元),而不是复制参数s中已累加的值——这正是"分裂出的每个新 Body 从单位元开始独立累加"的标准写法。
值得注意的是,规范文档还给出了把该示例推广到任意结合操作op的三条规则(parallel_reduce_func.rst):
- 把 0 替换为
op的单位元; - 把
+=替换为op=或逻辑等价写法; - 把类名
Sum改为与op更贴切的名字。
proportional_split:可选的按比例分裂
除基础分裂外,Range与Body体系还支持可选的proportional_split按比例分裂构造函数。该类型同样定义于 _range_common.h,携带left()/right()两个比例值,允许调度器按工作负载比例切分区间。需要说明的是,这是Range命名要求的可选能力;对于 Body,oneTBB 内部的分裂路径会通过range_split_object_provider(见 _range_common.h)自动降级为基础split,因此自定义 Body 只需实现基础分裂构造函数即可与各类分区器兼容。
四、Range 要求:Body 的 operator() 操作对象的类型契约
规范文档明确指出operator()接受的Range必须满足 Range 命名要求,其完整要求为:
| 成员 | 签名 | 语义 |
|---|---|---|
| 拷贝构造 | R::R(const R&) | 可拷贝 |
| 析构 | R::~R() | 可析构 |
| 判空 | bool R::empty() const | 区间为空返回 true |
| 可分割 | bool R::is_divisible() const | 区间可分成两个子区间返回 true |
| 基础分裂 | R::R(R& r, split) | 把r分成两个子区间 |
| 按比例分裂 | R::R(R& r, proportional_split) | (可选)按比例切分r |
Range的理想形态是"可以递归分裂,直到每个子区间代表的串行工作量小到不值得再分"。典型实现(如blocked_range)通过grainsize(粒度)参数控制分割深度,grainsize 规定了"被认为是不可再分"的最大区间尺寸。
由于Range声明了分裂构造函数与拷贝构造函数,编译器不会再隐式生成默认构造函数,因此规范提醒(range.rst):需要显式定义默认构造函数或其它构造函数,才能在程序中创建Range实例。
另外规范还约定了分裂的方向惯例:若值集合有方向感,分裂构造函数应当构造"第二部分"并把自己的参数更新为"第一部分",这样parallel_reduce在串行执行时会以递增顺序遍历区间,与普通顺序循环一致。
五、运行时语义:调度器究竟如何使用 Body
理解了成员签名之后,还需要理解调度器在运行时如何使用这些成员,这决定了正确性约束的来历。从 parallel_reduce.h 的实现可以还原完整的调用链:
5.1 分裂发生在哪:start_reduce::execute与zombie_space
start_reduce是承载归约任务的内部任务类型。在其execute(parallel_reduce.h)中可以看到关键逻辑:当执行的是"右子任务"且父节点引用计数为 2 时,调度器会在reduction_tree_node预留的zombie_space内存上,以placement new 调用 Body 的分裂构造函数生成一个"僵尸"子 Body:
if( is_right_child && my_parent->m_ref_count.load(std::memory_order_acquire) == 2 ) { tree_node_type* parent_ptr = static_cast<tree_node_type*>(my_parent); my_body = static_cast<Body*>(new( parent_ptr->zombie_space.begin() ) Body(*my_body, split())); parent_ptr->has_right_zombie = true; }注意其中的std::memory_order_acquire内存序:注释说明这是为了在"左任务已完成"时同步my_body指向的数据。这正是规范要求"分裂构造函数必须能与operator()/join并发运行"的底层原因——分裂发生时,左半部分任务可能仍在运行。
5.2 合并发生在哪:reduction_tree_node::join
每个分裂点都会创建一个reduction_tree_node(parallel_reduce.h),其join方法在被取消时跳过合并、否则调用left_body.join(*zombie_space.begin()):
void join(task_group_context* context) { if (has_right_zombie && !context->is_group_execution_cancelled()) left_body.join(*zombie_space.begin()); }整个归约过程由此构成一棵归约树:任务向下递归分裂(每次分裂通过构造函数派生新 Body),向上回溯时通过join逐层合并。finalize中调用的fold_tree负责自底向上展开这棵树并递减父节点引用计数。
5.3 函数式形式如何复用 Body 契约:lambda_reduce_body
有趣的是,函数式形式的parallel_reduce(range, identity, func, reduction)内部也是通过一个适配器lambda_reduce_body(parallel_reduce.h)来满足ParallelReduceBody要求的:它持有Value my_value,其分裂构造函数把新实例的my_value重置为identity,operator()调用func(range, std::move(my_value)),join调用reduction合并两个值。也就是说,无论用户用哪种形式,底层任务图都遵循同一套 Body 分裂/合并协议。
六、并发安全与行为不确定性
规范文档对 Body 的运行时行为给出了三条非常重要的"不可依赖"警告(见 parallel_reduce_func.rst):
- 分裂时机不确定:
parallel_reduce只会"在 Range 分裂时"才可能分裂 Body,但反过来不一定成立——Range 分裂未必伴随 Body 分裂; - 子区间不保证连续:同一个 Body 对象处理的多个子区间不保证是连续的,用户不能依赖这一点;
- 分裂决策非确定:
parallel_reduce对 Body 分裂的选择是非确定性的——它受工作窃取(work stealing)等运行时行为影响。
此外,规范明确:调度器可能在一个 Body 的operator()或join正在并发执行时复制该 Body(用于分裂)。在典型用法下这不需要额外努力就能保证安全(因为分裂构造函数只读初始状态),但自定义 Body 若持有共享的可变状态(如全局计数器、共享指针指向的可变对象),则必须自行保证并发安全。
串行执行是另一个重要特例:当任务图退化为串行时,parallel_reduce从左到右顺序执行,此时不会调用分裂构造函数,也不会调用join(parallel_reduce_func.rst)。这意味着不能依赖"只要调用parallel_reduce就一定会触发join"来执行必要逻辑。
空间复杂度方面,规范给出的结论是:若 Range 与 Body 各占O(1)空间且 Range 近似均分,则空间复杂度为O(P×log(N)),其中N为 Range 大小、P为线程数(parallel_reduce_func.rst)。
七、确定性变体:parallel_deterministic_reduce 中的 Body
ParallelReduceBody命名要求同样适用于parallel_deterministic_reduce(见 parallel_deterministic_reduce_func.rst),但两者的 Body 使用契约存在本质差异:
| 行为 | parallel_reduce | parallel_deterministic_reduce |
|---|---|---|
| Body 分裂 | 仅当 Range 分裂时才可能分裂,决策非确定 | 每次Range 分裂必然调用 Body 分裂构造函数 |
| 分裂/合并序列 | 不保证可复现 | 对相同输入完全可复现 |
| 支持的 partitioner | auto / simple / static / affinity | 仅simple_partitioner与static_partitioner |
| 合并方向 | left.join(right)表示left op right | 同左,但执行次序确定 |
parallel_deterministic_reduce之所以限定分区器,是因为auto_partitioner与affinity_partitioner会响应随机的任务窃取行为,从而破坏分裂/合并序列的确定性(parallel_deterministic_reduce_func.rst)。
在实现层面,确定性变体使用独立的deterministic_reduction_tree_node(parallel_reduce.h):它在树节点内直接持有一个Body right_body,构造时即调用right_body{input_left_body, detail::split()}完成分裂,join时执行left_body.join(right_body)。相比非确定性路径在zombie_space上临时 placement new 的写法,确定性路径把右子 Body 作为树节点的普通成员,其构造/析构时机与树节点完全绑定,从而保证每次运行执行完全相同的分裂与合并序列。
需要特别提醒(见 parallel_deterministic_reduce_func.rst 的 caution):由于simple_partitioner不会自动合并(coarsen)区间,使用parallel_deterministic_reduce时必须指定合适的 grain size,否则可能产生过多细粒度任务。
即使分裂/合并序列确定,规范也指出结果可能仍与等价的串行算法不同(parallel_deterministic_reduce_func.rst)——这一点在浮点求和等非精确运算场景下尤其值得留意。
八、仓库中的真实用例:从测试代码看正确写法
mold 仓库随附的 oneTBB 测试代码为ParallelReduceBody的写法提供了大量可直接对照的实例。
8.1 最小合规 Body:test_parallel_reduce.cpp
在 test_parallel_reduce.cpp 中,测试用例Test Unsupported Partitioners定义了一个最小化的合规 Body:
struct Body { float value; Body() : value(0) {} Body(Body&, tbb::split) { value = 0; } void operator()(const tbb::blocked_range<int>&) {} void join(Body&) {} };这个结构完整覆盖了四个必需成员:默认构造(初始化单位元 0)、分裂构造(同样重置为 0)、operator()(空实现即可编译通过)、join。它是验证"什么样的类型满足ParallelReduceBody"的最小可编译样例。
8.2 携带多字段归约状态的 Body:conformance_enumerable_thread_specific.cpp
conformance_enumerable_thread_specific.cpp 中的parallel_vector_reduce_body展示了如何归约比标量更复杂的容器状态:
template <typename R, typename T> struct parallel_vector_reduce_body { T sum; size_t count; typedef std::vector<T, oneapi::tbb::tbb_allocator<T> > container_type; parallel_vector_reduce_body ( ) : count(0) { test_helper<T>::init(sum); } parallel_vector_reduce_body ( parallel_vector_reduce_body<R, T> &, oneapi::tbb::split ) : count(0) { test_helper<T>::init(sum); } void operator()( const R &r ) { for (typename R::iterator ri = r.begin(); ri != r.end(); ++ri) { const container_type &v = *ri; ++count; for (typename container_type::const_iterator vi = v.begin(); vi != v.end(); ++vi) { test_helper<T>::sum(sum, *vi); } } } void join( const parallel_vector_reduce_body &b ) { test_helper<T>::sum(sum,b.sum); count += b.count; } };该示例有三点值得借鉴:
- 分裂构造与默认构造都重置全部状态(
count(0)与init(sum)),保证派生 Body 从单位元出发; operator()只做累加(++count、sum),支持处理任意多个不相邻子区间;join逐字段合并(sum与count都合并),若漏掉任一字段将导致结果不完整。
测试随后通过oneapi::tbb::parallel_reduce(vs.range(1), pvrb)调用它,并把pvrb.sum与串行求得的期望值比较(conformance_enumerable_thread_specific.cpp),验证归约结果的正确性。
8.3 测试还验证了什么
同文件中的TestSplitting<tbb::simple_partitioner>、TestSplitting<tbb::static_partitioner>等模板调用(test_parallel_reduce.cpp)说明:同一个合规 Body 应当能够与simple_partitioner、static_partitioner、auto_partitioner、affinity_partitioner等各类分区器协同工作。而Test Unsupported Partitioners用例(test_parallel_reduce.cpp)则通过重载决议歧义验证了parallel_deterministic_reduce不接受auto_partitioner与affinity_partitioner——与规范文档的限定完全一致。
九、完整示例与常见错误清单
9.1 函数式形式(lambda 写法,与命令式等价)
同样是数组求和,函数式形式更简洁(parallel_reduce_func.rst):
#include "oneapi/tbb/parallel_reduce.h" #include "oneapi/tbb/blocked_range.h" using namespace oneapi::tbb; float ParallelSum( float array[], size_t n ) { return parallel_reduce( blocked_range<float*>( array, array+n ), 0.f, [](const blocked_range<float*>& r, float init)->float { for( float* a=r.begin(); a!=r.end(); ++a ) init += *a; return init; }, []( float x, float y )->float { return x+y; } ); }在 C++20 环境下,oneTBB 还提供了概念(concept)层面的静态校验:parallel_reduce_body概念要求 Body 满足splittable<Body>且支持body(range)与body.join(rhs)(parallel_reduce.h),配合splittable概念(要求可从T&与tbb::detail::split构造,见 _range_common.h),编译器可以在实例化阶段就给出清晰的诊断信息。
9.2 常见错误清单
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 分裂构造函数复制当前累积值而非单位元 | 结果被重复累加,数值错误 | 分裂构造中把状态重置为单位元 |
join合并顺序颠倒(rhs op this) | 非交换操作(如矩阵乘法)结果错误 | 遵循this = this op rhs |
operator()使用覆盖而非累加语义 | 同一 Body 处理多个子区间时丢失数据 | 始终做累加 |
| Body 持有共享可变状态且不加同步 | 数据竞争(规范明确要求并发安全) | 用线程局部存储或原子操作 |
依赖join必定被调用 | 串行执行时逻辑缺失 | 串行路径不会触发分裂与合并 |
| 期望子区间连续 | 逻辑错误 | 子区间不保证连续 |
十、总结
ParallelReduceBody是 oneTBB 并行归约体系的核心契约:它用四个成员——分裂构造函数、析构函数、operator()、join——把"归约状态如何初始化、如何累加、如何合并"完整地刻画出来。split哑元参数将分裂构造函数与拷贝构造函数区分开,而调度器通过start_reduce/reduction_tree_node在任务图上以"分裂即构造、完成即合并"的方式驱动归约树。
理解这组命名要求,不仅能帮助你写出正确的自定义归约体,还能让你明白parallel_reduce与parallel_deterministic_reduce在确定性上的本质区别(后者每次 Range 分裂必触发 Body 分裂,且仅支持 simple/static 分区器)。对于更深入的学习,建议对照阅读本仓库中的以下文件:
- 规范原文:par_reduce_body.rst、range.rst、splittable.rst;
- 算法说明:parallel_reduce_func.rst、parallel_deterministic_reduce_func.rst;
- 核心实现:parallel_reduce.h、_range_common.h;
- 测试用例:test_parallel_reduce.cpp、conformance_enumerable_thread_specific.cpp。
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考