mold 项目内嵌 oneTBB 指南:使用嵌套流图(Nested Flow Graphs)构建分层并行计算
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
导读
本指南围绕 mold 仓库中集成的 oneTBB(Threading Building Blocks)流图库,深入讲解"嵌套流图"这一高级用法:当一个流图节点收到消息后,如何在节点内部再构造并执行一个独立的子图,以及如何通过复用持久化子图消除重复构造的开销。读完本文,你将掌握两种嵌套流图的完整写法、wait_for_all()的准确语义(何时必须调用、何时可以省略),以及graph对象生命周期与底层任务调度机制,从而在复杂的多层并行流水线设计中写出正确且高效的代码。
本文基于 use_nested_flow_graphs.rst(oneTBB 用户指南"嵌套并行性技巧"章节之一,见 Flow_Graph_nested_parallelism_tips.rst)整理扩充,并参考了仓库中 oneTBB 头文件与源码实现。
一、为什么需要"嵌套流图"?
oneTBB 的流图(flow graph)允许以两种方式组织并行性:
- 在流图节点内嵌套算法——节点的 body 内部调用
parallel_for、parallel_invoke等并行算法; - 在流图节点内嵌套另一个流图——节点的 body 内部构造一个独立的
graph对象(内部图),连接若干节点,投递初始消息并等待其完成。
本文主题是第二种方式。典型场景是:外层图负责粗粒度的任务分发(如按输入数据分片),而每个分片内部又有自己的一段依赖关系或数据流关系需要调度,此时直接在内层节点中嵌套一个子图,可以让外层节点与内层节点各司其职,代码结构也更贴近问题本身的层次。
在给出完整示例前,先澄清两个关键概念,因为它们直接对应示例代码中两种不同的内层节点类型。
1.1 依赖图(Dependence Graph):continue_node+continue_msg
依赖图中,节点之间通过oneapi::tbb::flow::continue_msg类型的消息传递"已完成"信号,边构成计算的偏序关系。与一般数据流图不同,依赖图节点不会为每条消息都派生任务,而是统计收到的消息数量,只有当该数量等于其前驱节点总数时才执行 body。continue_node构造函数的两个参数分别是所属图和 body 函数对象:
template< typename Body > continue_node( graph &g, Body body);完整介绍参见 Dependence_Graph.rst。
1.2 数据流图(Data Flow Graph):function_node
数据流图中,节点是"接收并发送数据消息"的计算单元。function_node< Input, Output >接收输入类型消息,调用 body,并将返回值作为输出消息发送给后继节点。完整介绍参见 Data_Flow_Graph.rst。
二、示例一:在节点内构造并执行临时嵌套图
原文档给出了如下示例:外层图g有两个节点a和b。节点a收到消息后,构造并执行一个内层依赖图;节点b收到消息后,构造并执行一个内层数据流图:
graph g; function_node< int, int > a( g, unlimited, []( int i ) -> int { graph h; node_t n1( h, = { cout << "n1: " << i << "\n"; } ); node_t n2( h, = { cout << "n2: " << i << "\n"; } ); node_t n3( h, = { cout << "n3: " << i << "\n"; } ); node_t n4( h, = { cout << "n4: " << i << "\n"; } ); make_edge( n1, n2 ); make_edge( n1, n3 ); make_edge( n2, n4 ); make_edge( n3, n4 ); n1.try_put(continue_msg()); h.wait_for_all(); return i; } ); function_node< int, int > b( g, unlimited, []( int i ) -> int { graph h; function_node< int, int > m1( h, unlimited, []( int j ) -> int { cout << "m1: " << j << "\n"; return j; } ); function_node< int, int > m2( h, unlimited, []( int j ) -> int { cout << "m2: " << j << "\n"; return j; } ); function_node< int, int > m3( h, unlimited, []( int j ) -> int { cout << "m3: " << j << "\n"; return j; } ); function_node< int, int > m4( h, unlimited, []( int j ) -> int { cout << "m4: " << j << "\n"; return j; } ); make_edge( m1, m2 ); make_edge( m1, m3 ); make_edge( m2, m4 ); make_edge( m3, m4 ); m1.try_put(i); h.wait_for_all(); return i; } ); make_edge( a, b ); for ( int i = 0; i < 3; ++i ) { a.try_put(i); } g.wait_for_all();2.1 逐段解析
- 外层拓扑:
a与b之间通过make_edge( a, b )串联,主循环向a投递 3 个整数消息(a.try_put(i),i = 0,1,2),最后g.wait_for_all()等待整张外层图空闲。 - 节点
a的内层依赖图:以msg_t(即const continue_msg &)为消息类型的node_t(即continue_node< continue_msg >)构成。n1是唯一入度为零的节点,投递一个continue_msg()即可启动整条链:n1 → n2、n3 → n4。由于依赖图按前驱计数触发,n4必须等n2与n3都完成才会执行——这正是"依赖图"偏序语义的体现。 - 节点
b的内层数据流图:4 个function_node< int, int >,每个都声明unlimited并发度。m1接收外层传入的i,链式传递m1 → m2、m3 → m4,每个节点打印并原样返回消息值。 - lambda 捕获方式:
a与b的 body 均按值捕获i([=]),保证并发执行时互不干扰。 - 同步点:每个内层 body 在投递初始消息后都调用
h.wait_for_all(),使该节点阻塞到内层图全部完成;外层最后g.wait_for_all()收尾。
2.2 消息投递与等待的异步语义
值得强调的是,流图中的所有执行都是异步的:
a.try_put(i)只会快速返回:库内部递增计数器、派生一个任务来执行a的 body(见 Dependence_Graph.rst 中关于"节点收到消息即派生任务"的说明);- body 任务执行 lambda、向所有后继节点投递消息;
- 只有
wait_for_all()会真正阻塞,而且阻塞期间调用线程仍可参与执行 oneTBB 工作池中的其他任务。
这一"等待但不闲置"的行为在源码中也有对应实现:graph::wait_for_all()最终会进入d1::wait(...),在等待期间线程会去窃取并执行工作池中的任务,见 _flow_graph_impl.h 中关于"waiting thread will go off and steal work while it is blocked"的注释。
三、优化动机:每次都重建内层图是冗余的
原文档明确指出:如果嵌套图在节点多次调用之间结构保持不变,那么每次调用都重新构造它就是多余的。重建图只会增加执行开销。观察示例一:节点b的 4 个function_node、4 条边每次调用都完全一样,完全可以只构造一次、反复使用。
这是因为 oneTBB 的graph对象本身是可复用的:节点一旦构造并连好边,只要图对象未被销毁,就可以反复向入口节点投递消息。真正需要重建的,只是那些"每次结构不同"的图。
3.1 graph 对象的职责与生命周期
关于graph对象,有两点官方强调的约束(见 Graph_Object.rst):
- graph 不拥有节点。必须保证
graph对象的生命周期长于所有加入它的节点以及与之相关的任何活动; - 销毁前必须
wait_for_all()。即使使用智能指针,也要注意节点与图的析构顺序,确保节点不会先于图被销毁。
从源码看,graph类在析构时会调用wait_for_all再销毁根任务与上下文(见 _flow_graph_impl.h 的注释 "Calls wait_for_all, then destroys the root task and context"),显式提前wait_for_all依然是更稳妥的做法。
四、示例二:复用持久化嵌套图
基于上述优化思路,原文档将节点b改为复用一张在外层图构造之前就创建好的持久化图h:
graph h; function_node< int, int > m1( h, unlimited, []( int j ) -> int { cout << "m1: " << j << "\n"; return j; } ); function_node< int, int > m2( h, unlimited, []( int j ) -> int { cout << "m2: " << j << "\n"; return j; } ); function_node< int, int > m3( h, unlimited, []( int j ) -> int { cout << "m3: " << j << "\n"; return j; } ); function_node< int, int > m4( h, unlimited, []( int j ) -> int { cout << "m4: " << j << "\n"; return j; } ); make_edge( m1, m2 ); make_edge( m1, m3 ); make_edge( m2, m4 ); make_edge( m3, m4 ); graph g; function_node< int, int > a( g, unlimited, []( int i ) -> int { graph h; node_t n1( h, = { cout << "n1: " << i << "\n"; } ); node_t n2( h, = { cout << "n2: " << i << "\n"; } ); node_t n3( h, = { cout << "n3: " << i << "\n"; } ); node_t n4( h, = { cout << "n4: " << i << "\n"; } ); make_edge( n1, n2 ); make_edge( n1, n3 ); make_edge( n2, n4 ); make_edge( n3, n4 ); n1.try_put(continue_msg()); h.wait_for_all(); return i; } ); function_node< int, int > b( g, unlimited, & -> int { m1.try_put(i); h.wait_for_all(); // optional since h is not destroyed return i; } ); make_edge( a, b ); for ( int i = 0; i < 3; ++i ) { a.try_put(i); } g.wait_for_all();4.1 关键改动对比
| 方面 | 示例一(临时子图) | 示例二(持久子图) |
|---|---|---|
| 内层图位置 | b的 body 内部局部构造,随作用域结束销毁 | 外层图g之前构造,生命周期横跨多次调用 |
| 节点/边构造次数 | 每次调用都重建 | 只构造一次,反复投递消息 |
| 捕获方式 | b的 body 按值捕获i | b的 body 按引用捕获h([&]),以便访问持久化的m1 |
| 同步点 | 必须h.wait_for_all() | h.wait_for_all()变为可选 |
注意:在示例二中,a节点仍保留"每次构造临时依赖图"的写法,这与b的持久化复用形成对照——是否复用取决于你的嵌套图结构是否在多次调用间保持不变。
4.2 何时可以省略h.wait_for_all()——原文档的核心结论
原文档最后一段给出了一个容易被忽略、但对正确性至关重要的问题:修改后的代码中,是否每次调用b的 body 都必须调用h.wait_for_all()?
答案是否定的。结论可以精确表述为:
- 示例一中必须调用:内层图
h是 body 内的局部对象,在作用域结束(body 返回)时被销毁。若 body 返回前不等待图空闲,销毁过程中可能仍有任务在访问这些节点,属于未定义行为; - 示例二中可选:
h是持久化对象,不会随b的调用结束而销毁。因此,b的 body 完全可以只调用m1.try_put(i)就返回,让内层图的任务在后台异步执行——原文档明确认可这种写法:"It would be valid in the body ofbabove to callm1.try_put(i)and then return without waiting forhto become idle." - 何时仍应调用:如果业务要求
b的 body 阻塞直到内层图处理完这批消息(例如后续逻辑依赖内层图的计算结果),则调用h.wait_for_all()。原文档注释也标注了"optional since h is not destroyed"。
五、深入理解graph与wait_for_all的底层机制
为了准确使用嵌套流图,有必要理解graph对象在 oneTBB 实现中的角色。在仓库源码 _flow_graph_impl.h 中,graph类(第 297 行起)定义如下关键能力:
- 任务隔离与上下文:每个
graph拥有独立的task_group_context,并关联一个内部task_arena(构造时尝试附加当前 arena,失败则创建默认初始化 arena,见 _flow_graph_impl.h)。这意味着嵌套的graph h拥有自己的执行上下文,其任务调度与graph g相对隔离,这正是"可以独立 wait"的基础。 wait_for_all()的语义:等待图空闲,并且"释放等待"计数与"保留等待"计数相等;等待线程在阻塞期间会离开去窃取工作池任务(_flow_graph_impl.h)。调用还会重置cancelled/caught_exception状态,并在发生异常时把caught_exception置为真。reserve_wait/release_wait:外部实体可声明"仍将与图交互",使wait_for_all直到配对的release_wait调用到达后才返回(_flow_graph_impl.h)。这为"异步投递消息、稍后统一等待"的嵌套用法提供了线程安全的协调手段。reset与cancel:graph还支持reset(reset_flags)重置全图节点状态,以及cancel()取消关联任务组的执行(_flow_graph_impl.h)。若嵌套图需要跨调用清空节点缓冲或撤销执行,可借助这些接口,但需注意reset是线程不安全的。
因此,示例二"复用图 + 可选等待"能够成立的根本原因是:graph h及其节点是持久对象,内层任务的执行上下文(arena + context)在多次调用间持续有效;而示例一必须等待,则纯粹是 C++ 对象生命周期约束——局部graph h在 body 返回时析构,析构会触发wait_for_all并销毁根任务与上下文(见 _flow_graph_impl.h),在此之前必须确保所有相关活动已结束。
六、实践建议与注意事项
综合原文档与仓库源码,在 mold 项目或任何基于 oneTBB 的项目中使用嵌套流图时,建议遵循以下原则:
- 按结构是否变化选择策略:嵌套图拓扑每次不同 → 每次构造临时子图(示例一);拓扑固定 → 提升为持久化子图并在外层图之前构造(示例二),省去重复的节点创建与建边开销。
- 明确同步边界:临时子图必须
h.wait_for_all();持久化子图按需调用——需要阻塞等待结果就调用,允许后台异步处理就不调用。两者对正确性的影响不同,切勿混淆。 - 严格遵守生命周期:
graph不拥有节点,必须保证graph的生命周期长于所有节点;销毁前调用wait_for_all()。若使用智能指针,同样要保证析构顺序(节点先于图销毁)。 - 注意捕获方式:复用持久化图时,外层节点 body 需按引用(
[&])捕获内层图入口节点;而每次重建临时图时按值([=])捕获输入参数更安全,可避免并发调用间的数据竞争。 - 理解等待线程的行为:
wait_for_all()阻塞期间,调用线程仍会参与工作池任务执行,因此嵌套等待不会造成线程空转浪费;但如果内层任务依赖外层任务协作推进,仍需警惕线程数量不足时的潜在死锁场景(原文档在依赖图章节中提示:线程不足时部分已派生任务会等待可用线程)。
七、总结
嵌套流图是 oneTBB 流图体系中组织"多层并行"的核心手段:外层图负责粗粒度任务划分,内层图负责细粒度的依赖或数据流调度。本文完整复现了原文档的两份示例代码,并从仓库源码层面解释了其成立的前提——graph对象拥有独立的执行上下文、wait_for_all()的精确语义、以及对象生命周期对"必须等待/可以不等"的决定性影响。
关键结论再强调一次:临时子图必须等待图完成再让节点返回;持久化子图是否等待,取决于你的业务是否需要该节点的 body 阻塞到内层图处理完毕。理解这一点,就能在正确性与性能之间做出恰当权衡。
延伸阅读:本文主题在 oneTBB 用户指南中归属于"嵌套并行性技巧"章节,配套内容还包括 use_nested_algorithms.rst(节点内嵌套并行算法);流图基础概念可继续阅读 Graph_Object.rst、Dependence_Graph.rst 与 Data_Flow_Graph.rst。
【免费下载链接】moldmold: A Modern Linker 🦠项目地址: https://gitcode.com/GitHub_Trending/mo/mold
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考