构建分层并行计算)
mold 项目内嵌 oneTBB 指南使用嵌套流图Nested Flow Graphs构建分层并行计算【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold导读本指南围绕 mold 仓库中集成的 oneTBBThreading Building Blocks流图库深入讲解嵌套流图这一高级用法当一个流图节点收到消息后如何在节点内部再构造并执行一个独立的子图以及如何通过复用持久化子图消除重复构造的开销。读完本文你将掌握两种嵌套流图的完整写法、wait_for_all()的准确语义何时必须调用、何时可以省略以及graph对象生命周期与底层任务调度机制从而在复杂的多层并行流水线设计中写出正确且高效的代码。本文基于 use_nested_flow_graphs.rstoneTBB 用户指南嵌套并行性技巧章节之一见 Flow_Graph_nested_parallelism_tips.rst整理扩充并参考了仓库中 oneTBB 头文件与源码实现。一、为什么需要嵌套流图oneTBB 的流图flow graph允许以两种方式组织并行性在流图节点内嵌套算法——节点的 body 内部调用parallel_for、parallel_invoke等并行算法在流图节点内嵌套另一个流图——节点的 body 内部构造一个独立的graph对象内部图连接若干节点投递初始消息并等待其完成。本文主题是第二种方式。典型场景是外层图负责粗粒度的任务分发如按输入数据分片而每个分片内部又有自己的一段依赖关系或数据流关系需要调度此时直接在内层节点中嵌套一个子图可以让外层节点与内层节点各司其职代码结构也更贴近问题本身的层次。在给出完整示例前先澄清两个关键概念因为它们直接对应示例代码中两种不同的内层节点类型。1.1 依赖图Dependence Graphcontinue_nodecontinue_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 Graphfunction_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.rstgraph 不拥有节点。必须保证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改为复用一张在外层图构造之前就创建好的持久化图hgraph 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 按值捕获ib的 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与cancelgraph还支持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),仅供参考