ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

mold 项目内幕:深入解读 oneTBB ParallelForBody 具名要求与 parallel_for 并行循环

mold 项目内幕:深入解读 oneTBB ParallelForBody 具名要求与 parallel_for 并行循环 mold 项目内幕深入解读 oneTBB ParallelForBody 具名要求与 parallel_for 并行循环【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold导读本文以 oneTBB 官方规范文档中的ParallelForBody具名要求named requirement为核心系统讲解在oneapi::tbb::parallel_for并行循环中作为循环体的函数对象Body必须满足的接口契约可拷贝构造、可析构、以及接收可变 Range 引用的const operator()。文章不仅逐条拆解规范原文的伪签名与语义还深入 oneTBB 头文件源码验证其实现并结合 mold 链接器源码中大量tbb::parallel_for的实际调用场景说明这一要求如何支撑起链接器数百万输入节input sections的并行处理。读完本文你将掌握编写正确、高效、可复用的parallel_for循环体的全部要点。一、ParallelForBody 是什么ParallelForBody是 oneTBBoneAPI Threading Building Blocks为parallel_for算法定义的一个具名要求named requirement规范编号为[req.parallel_for_body]。它的作用是描述可以作为parallel_for循环体使用的类型Body必须满足的接口约束从而让任何满足这些约束的用户自定义类型函数对象/仿函数都能安全地交给parallel_for并行执行。该规范文档位于本仓库的 par_for_body.rst是 oneTBB 规范specification中named requirements → algorithms章节的组成部分。与之配套的还有 par_for_index.rst索引类型要求、par_for_func.rst函数对象要求以及 range.rst范围类型要求共同构成parallel_for完整的类型契约体系。为什么 Body 需要独立的具名要求parallel_for的两种主要形态parallel_for(first, last, step, f)按整数索引并行执行循环for (auto i first; i last; i step) f(i);parallel_for(range, body, partitioner)对可递归划分的 Range 并行应用 body。第二种形态中parallel_for会递归地把 range 切分成多个子区间并为每个子区间拷贝一份 body然后由不同的工作线程并发调用body(range)。正是这种按区间复制、多线程调用的执行模型决定了 body 必须满足一套严格的接口要求——这正是ParallelForBody具名要求存在的意义。二、ParallelForBody 的三大核心要求规范以伪签名 语义Pseudo-Signature, Semantics表格的形式给出要求类型Body满足ParallelForBody必须同时满足以下三个条件1. 拷贝构造函数Body::Body( const Body )语义为 Body 提供拷贝构造能力。这是并行执行的基础。从规范对parallel_for的描述可以看到算法会递归拆分 range 直到每个子区间的is_divisible()为假并为每个子区间制作 body 的副本makes copies of the body for each of these subranges随后对每一对 body/子区间调用Body::operator()。因此每产生一个子任务就至少需要一次 body 拷贝拷贝构造函数是必经之路。值得注意的两点工程含义如果 Body 内部持有互斥锁、文件句柄等不可拷贝资源需要谨慎设计例如使用std::shared_ptr共享底层状态否则拷贝构造会失败或产生错误的语义拷贝的代价直接叠加在并行开销上若 Body 体积很大拷贝会成为性能瓶颈详见下文最佳实践。2. 析构函数Body::~Body()语义为 Body 提供正确的析构能力。每个被拷贝出来的 body 副本最终都要被销毁。这里有一个容易被忽视的细节规范在 parallel_for_func.rst 中特别说明Some of the copies of the range and body may be destroyed afterparallel_forreturns.即部分 range 和 body 的副本可能在parallel_for返回之后才被销毁。这是任务调度器延迟回收任务对象的正常现象。在典型用法中不会造成问题但如果你的 body 带有复杂的析构副作用例如在析构中打印日志、回收资源、记录时间戳在分析执行轨迹execution traces或编写此类对象时就要留意析构时机并不严格同步于parallel_for的返回点。3. 函数调用运算符void Body::operator()( Range range ) const语义把 body 应用到给定的 range 上。其中Range类型必须满足 Range requirements。这是 body 真正干活的入口。几个关键点参数是Range非 const 引用因为parallel_for可能要求 body 在调用过程中消费range例如递归推进遍历位置所以规范要求接受可变引用成员函数本身是const这意味着 body 在逻辑上不应修改自身的状态多个线程并发调用同一 body 副本的不同子区间时const约束保证了共享状态的安全前提operator()接收的是一个 range 而非单个元素与parallel_for_each中operator()(value)的按元素粒度不同这里一次调用处理一个可能仍包含多个元素、但已不可再分的子区间body 内部需要自行遍历该子区间。与 ParallelForIndex / ParallelForFunc 的分工需要澄清的是ParallelForBody只约束按 Range 迭代的形态。若使用整数索引形态parallel_for(first, last, f)则约束对象变为ParallelForIndex要求Index可从int构造、支持拷贝/赋值//比较运算以及-、、*、/算术D为j-i的结果类型需可转换为size_t典型的Index是整数类型和指针ParallelForFunc要求void F::operator()(Index index) const即按单个索引值应用函数。规范建议优先使用整数类型作为ParallelForIndex见 par_for_index.rst 中的 NOTE。三种要求分别对应parallel_for的不同重载形态避免混淆即可正确选择约束模型。三、配套的 Range 要求body 操作的对象是什么ParallelForBody::operator()的参数类型是Range而Range必须满足 Range requirements规范编号[req.range]。理解 body 之前必须先理解 range 的能力因为 body 是作用在 range 上的Range 要求伪签名语义R::R( const R )/R::~R()拷贝构造与析构bool R::empty() constrange 是否为空bool R::is_divisible() constrange 是否还能划分成两个子区间R::R( R r, split )基本分割构造把r分成两个子区间建议尽量均分R::R( R r, proportional_split proportion )可选按比例分割构造关键约定规范原文Range 通过分割构造函数递归细分一个 Range 类型因为同时声明了分割构造与拷贝构造编译器不会自动生成默认构造函数需要显式定义或添加其他构造来创建实例当值集合有方向感时按惯例分割构造函数应把后半部分作为新构造的对象、把原参数更新为前半部分这样parallel_for、parallel_reduce、parallel_scan在串行执行时会从左到右递增序处理与普通串行循环的行为一致理想的递归分割应持续到每个部分串行执行比继续分割更高效为止因此典型的 Range 类型会提供控制分割程度的机制。最常用的 Range 实现是 blocked_range 类模板定义于头文件oneapi/tbb/blocked_range.h它表示一个可递归分割的半开区间[begin, end)templatetypename Value class blocked_range { public: using size_type size_t; using const_iterator Value; blocked_range( Value begin, Value end, size_type grainsize1 ); blocked_range( blocked_range r, split ); blocked_range( blocked_range r, proportional_split proportion ); size_type size() const; // 返回 end()-begin() bool empty() const; // 返回 !(begin()end()) size_type grainsize() const; // 返回粒度 bool is_divisible() const; // 返回 size()grainsize() const_iterator begin() const; // 含下界 const_iterator end() const; // 不含上界 };blocked_range通过grainsize粒度参数控制分割程度当size() grainsize()时认为可分割is_divisible()为真反之不可分割。规范给出的例子blocked_rangeint r(5, 14, 2);构造出包含 5 到 13含的区间粒度 2r.begin()5、r.end()14基本分割blocked_rangeint s(r, split);之后r表示[i, i(j-i)/2)s表示[i(j-i)/2, j)比例分割blocked_rangeint s(r, proportional_split(2, 3));之后r表示[i, i2*(j-i)/(23))s表示[i2*(j-i)/(23), j)。blocked_range还提供blocked_range2d、blocked_range3d、blocked_nd_range等多维变体见 algorithms/blocked_ranges 目录它们同样满足 Range 要求可以作为ParallelForBody::operator()的参数类型。四、与 parallel_for 算法的关系body 的执行模型ParallelForBody要求本身并不孤立存在它服务于 parallel_for 算法规范编号[algorithms.parallel_for]。parallel_for定义于头文件oneapi/tbb/parallel_for.h命名空间oneapi::tbb提供多组重载// 整数索引形态 templatetypename Index, typename Func void parallel_for(Index first, Index last, const Func f); // step 隐含为 1 templatetypename Index, typename Func void parallel_for(Index first, Index last, Index step, const Func f); // Range/Body 形态 templatetypename Range, typename Body void parallel_for(const Range range, const Body body); templatetypename Range, typename Body void parallel_for(const Range range, const Body body, /* partitioner */); templatetypename Range, typename Body void parallel_for(const Range range, const Body body, /* partitioner */, task_group_context context);其中partitioner可以是const auto_partitioner、const simple_partitioner、const static_partitioner或affinity_partitioner之一所有重载都可额外接受task_group_context以便让任务在指定的上下文中执行默认在自有的绑定上下文中执行。规范明确描述了 body 的执行语义这是理解ParallelForBody的关键parallel_forrecursively splits the range into subranges to the point such thatis_divisible()is false for each subrange, and makes copies of the body for each of these subranges. For each such body/subrange pair, it invokesBody::operator().即parallel_for递归拆分 range直到每个子区间的is_divisible()为假为每个子区间拷贝一份 body对每一对 (body 副本, 子区间) 调用Body::operator()。需要牢记的三个执行语义不保证并行parallel_for不保证迭代一定并行执行任务数少或线程池受限时可能退化为串行串行执行时parallel_for从左到右处理各区间禁止跨迭代依赖若一个较小编号lesser的迭代等待较大会greater的迭代完成可能发生死锁——因为并行调度并不保证编号顺序因此循环体内部不得依赖迭代的执行顺序非确定顺序parallel_for可能以非确定顺序执行迭代不能依赖任何特定的执行顺序来保证正确性但从效率角度可以预期它倾向于处理连续的值段consecutive runs of values。复杂度规范给出如果 range 和 body 占用O(1)空间且 range 能分割成近似相等的片段那么空间复杂度为O(P log(N))其中N是 range 大小P是线程数。这正体现了递归分割 每线程一份任务链的树形任务结构。五、源码级验证ParallelForBody 在 oneTBB 中的实现规范文档描述的是契约而 parallel_for.h 中可以看到契约如何被落地实现。1. C20 概念约束编译期契约在 C20 概念可用的编译配置下__TBB_CPP20_CONCEPTS_PRESENT头文件直接把ParallelForBody要求表达为 conceptparallel_for.h 第 36-55 行附近template typename Body, typename Range concept parallel_for_body std::copy_constructibleBody std::invocableconst std::remove_reference_tBody, Range;可以看到这个 concept 精确对应规范的三项要求std::copy_constructibleBody—— 拷贝构造 析构std::invocableconst std::remove_reference_tBody, Range——const的 Body 可以被Range调用即void Body::operator()(Range) const。同样地parallel_for_index与parallel_for_function两个 concept 也在同文件中定义分别要求索引支持构造/拷贝/比较/减法与加法运算、函数对象可被单个索引调用。头文件中的重载声明也大量使用了__TBB_requires(tbb_rangeRange parallel_for_bodyBody, Range)形式的约束如 parallel_for.h 第 226-302 行把具名要求直接嵌入到重载签名中。2. start_for 任务body 的拷贝与调用现场parallel_for的核心实现是内部任务类型start_forRange, Body, Partitionerparallel_for.h 第 58-146 行任务对象持有const Body my_body;并通过分割构造函数splitting constructor生成子任务start_for( start_for parent_, ... )用父任务的 range 和 body 构造右孩子run_body( Range r )中通过tbb::detail::invoke(my_body, r)调用 body——这正是规范中Body::operator()(Range)的调用点run()在非空 range 上分配根任务通过execute_and_wait进入任务调度循环等全部子任务完成后返回。这与规范描述的递归分割 每子区间一份 body 副本 调用operator()完全吻合每一次start_for任务的创建背后都至少对应一次 Body 拷贝构造每一个子任务执行结束都对应一次 Body 析构。3. 整数索引形态如何适配 Body 契约有趣的是整数形态parallel_for(first, last, step, f)本身并不直接要求ParallelForBody。实现parallel_for.h 第 304-316 行先把整数区间包装成blocked_rangeIndex再用parallel_for_body_wrapperFunction, Index适配成 bodytemplate typename Function, typename Index class parallel_for_body_wrapper : detail::no_assign { const Function my_func; const Index my_begin; const Index my_step; public: void operator()( const blocked_rangeIndex r ) const { Index b r.begin(); Index e r.end(); Index ms my_step; Index k my_begin b*ms; for ( Index i b; i e; i, k ms ) { tbb::detail::invoke(my_func, k); } } };这个 wrapper 就是满足ParallelForBody要求的典型 body可拷贝持有引用成员、const operator()、接收blocked_range。同时注意实现中对step 0会抛出std::invalid_argumentnonpositive_step异常与规范step 必须为正、省略时隐含为 1的描述一致若first last则整个循环体为空、不执行任何调用。4. 测试印证仓库中的一致性测试 test_parallel_for.cpp 与 conformance_parallel_for.cpp 覆盖了parallel_for的各种重载与 partitioner 组合可从测试角度进一步印证规范描述的行为如迭代非确定顺序、grainsize 控制分割粒度等。六、实战mold 链接器如何践行 ParallelForBody本仓库mold一个现代链接器将 oneTBB 作为并行运行时在src/与lib/下大量使用tbb::parallel_for处理链接过程中的高吞吐阶段是理解ParallelForBody工程价值的绝佳案例。搜索统计显示tbb::parallel_for不含parallel_for_each在以下文件中被直接调用文件调用次数典型场景src/passes.cc11输出节构造、符号表处理、分片并行计算等核心链接 passsrc/output-chunks.cc10输出块内容生成如压缩调试信息、填充区src/icf.cc8相同代码折叠ICF迭代比较节内容lib/compress.cc4并行压缩zlib/zstd 等src/gdb-index.cc1并行读取各目标文件的调试信息单元src/mapfile.cc2并行排序 map 输出中的符号其他—gc-sections.cc、relocatable.cc、thunks.cc、arch-arm32.cc 等例 1整数索引形态lambda 即满足 ParallelForFuncmold 最常见的写法是整数索引 lambda。例如 src/gdb-index.cc 第 803 行为每个目标文件并行读取调试单元std::vectorDebugUnits file_units(ctx.objs.size()); tbb::parallel_for((i64)0, (i64)ctx.objs.size(), { ObjectFileE file *ctx.objs[file_idx]; DebugUnits units file_units[file_idx]; // 读取该文件的 .debug_info 节、pubnames 并去重…… });这里i64long long满足ParallelForIndexlambda 满足ParallelForFunc底层由parallel_for_body_wrapper适配成ParallelForBody。关键点每个文件的结果写入file_units[file_idx]中与文件一一对应的独立槽位多个迭代之间无共享写入天然避免了数据竞争——这正是body 的operator()之间互不干扰的典型设计。类似的还有 src/passes.cc 第 759 行为每个目标文件的存活输入节分配输出节。这里使用了tbb::enumerable_thread_specificMapType caches做线程本地缓存配合互斥锁mu合并全局 map正是为了规避多个 body 副本并发写共享容器时的锁竞争tbb::enumerable_thread_specificMapType caches; tbb::parallel_for((i64)0, (i64)ctx.objs.size(), { // Make a per-thread cache of the main map to avoid lock contention. MapType cache caches.local(); for (InputSectionE *isec : ctx.objs[i]-sections) { // ... 按节属性归类到 cache 或创建新的 OutputSection ... } });例 2Range 形态body 直接满足 ParallelForBodysrc/mapfile.cc 第 35 行使用了 Range 形态的parallel_for(map.range(), lambda)其中的Map是 mold 内部自实现的哈希表位于 lib/lib.h其range()返回可分割的区间类型tbb::parallel_for(map.range(), [](const typename MapE::range_type range) { for (auto [k, v] : range) ranges::stable_sort(v, {}, SymbolE::value); });这里的 lambda 即为一个ParallelForBody可拷贝、const调用、接收Range。哈希表的不同桶区间被并行排序每个 body 副本只处理自己分配到的子区间。它同时体现了body 内部自行遍历子区间的语义——operator()里有一个内层for循环遍历 range 中的元素。例 3并行压缩lib/compress.cc 中同样使用tbb::parallel_for对多个数据块并行执行压缩zlib/zstd每个块由独立 body 处理结果写入预先分配好的独立缓冲区同样遵循无交叉依赖的 body 编写范式。从 mold 用例提炼的编写准则结合规范与 mold 源码可以总结出编写合格ParallelForBody的实践准则保证可拷贝且拷贝廉价body 会被每个子区间复制一份若携带大对象优先持有const、std::shared_ptr或指针避免深拷贝放大开销operator()必须是const不要在调用中修改 body 自身状态需要写共享数据时使用互斥锁、原子变量或线程本地存储如 mold 的enumerable_thread_specific按索引/区间槽位隔离写入让每个迭代只写自己负责的槽位如file_units[file_idx]从根上消除竞争不得依赖执行顺序不要在迭代间建立等待后续迭代的依赖规范明确指出这可能导致死锁串行退化时按递增序执行只是惯例而非保证不要依赖 body 的析构时机部分副本会在parallel_for返回后才析构不要在析构中编排关键副作用用 grainsize 控制粒度通过blocked_range的 grainsize 参数平衡分割开销与负载均衡mold 中处理千万级输入节时对此尤为敏感。七、总结ParallelForBody是 oneTBBparallel_for算法与用户代码之间的接口契约拷贝构造 析构 const operator()(Range)三项要求看似简单却精确匹配递归分割 range、按子区间复制 body、多线程并发调用的并行执行模型。在 parallel_for.h 中这份契约被编译为parallel_for_body概念与start_for任务实现的运行时结构在 mold 项目中它被广泛应用于 passes.cc、icf.cc、mapfile.cc、compress.cc 等链接热路径支撑起对数十万乃至数百万对象文件的并行处理。理解并遵守这份契约是写出正确、高效、可扩展的 oneTBB 并行代码的第一步。延伸阅读本文涉及的规范文档均位于 third-party/tbb/doc/main/specification/source包括 parallel_for 算法规范、Range 要求、blocked_range 类 以及 ParallelForIndex、ParallelForFunc 等姊妹要求oneTBB 头文件实现见 oneapi/tbb/parallel_for.h测试印证见 test/tbb/test_parallel_for.cpp。【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表