ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

mold 链接器中的聚合(Agglomeration)并行设计模式:从 oneTBB 分块调度到源码级实践

mold 链接器中的聚合(Agglomeration)并行设计模式:从 oneTBB 分块调度到源码级实践 mold 链接器中的聚合Agglomeration并行设计模式从 oneTBB 分块调度到源码级实践【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold聚合Agglomeration是并行程序设计中的一种核心模式当任务的并行粒度过于细小、调度与同步开销远超实际计算本身时将细粒度计算合并为较大的块block并串行执行块内计算从而摊薄并行开销。本文以 oneTBBThreading Building Blocks官方用户指南中 Agglomeration 设计模式文档为主线结合 mold一款现代高速链接器仓库中tbb::parallel_for/tbb::parallel_for_each的真实调用讲解聚合模式的原理、块大小与块拓扑的选择准则以及 grainsize 与 partitioner 的调优方法。读完本文你将掌握如何在细粒度并行场景下正确设计聚合策略并能在 oneTBB 循环模板中通过blocked_range与分区器精确控制分块行为。问题细粒度并行的开销淹没有用工作聚合模式解决的问题很直接并行粒度过于细小以至于并行调度或通信的开销淹没了真正有用的计算。大多数算法允许在极细的粒度上并行——每个任务只有几条指令的量级。但线程间的同步通常需要高几个数量级的时钟周期。文档给出的典型例子是两数组的逐元素相加它可以完全并行但如果把每个标量加法都当作独立任务来调度大部分时间都会花在同步上而不是有用的加法上。情境Context并行仅出于性能目的而不是语义必需——这正是聚合可以自由施行的前提。约束Forces文档给出了一个量化的阈值判断标准单个计算可以并行但体量很小。就 oneTBB 的实际使用而言小意味着每个任务少于约 10,000 个时钟周期并行化只是为了性能并非语义上的强制要求即去掉并行不影响程序正确性。这个 10,000 时钟周期的量级并非凭空而来。oneTBB 用户指南在 Automatic_Chunking 一节中给出了更保守的实践经验一个循环通常至少要消耗约一百万时钟周期才值得用parallel_for——例如在 2 GHz 处理器上耗时至少约 500 微秒的循环才可能从parallel_for中受益。解决方案分组串行执行摊薄并行开销聚合的解决方案在概念上极其简单把计算分组成块block块内的计算串行执行。块大小的选择是成败关键存在双向权衡块太小并行开销无法被摊薄性能优势被开销吃掉块太大块的数量过少无法在处理器间均匀分配工作从而限制并行度与负载均衡。换句话说块大小既要大到足以平摊每次调度/同步的开销又要小到足以保留足够的块数量供所有硬件线程瓜分。块拓扑同步最少、缓存流量最小除块大小外块拓扑block topology即块如何切分、如何排列的选择通常由两个关注点驱动最小化块之间的同步最小化块之间的缓存cache line流量。如果计算之间完全独立那么块之间也完全独立此时只需要考虑缓存流量问题。文档特别提醒聚合时务必考虑缓存效应尽量让缓存行cache line不要跨越块边界。因为信息是按缓存行粒度传输的跨块共享缓存行会引入隐式的伪共享false sharing成本。如果循环本身就很小少于约 10,000 个时钟周期那么文档指出它可能根本不值得并行化——因为最优的聚合结果可能就是一个单独的块即全部串行执行。边界与内部比率2D 网格的例子当计算之间存在邻居通信时块形状的选择会出现边界 vs 内部的比率效应。文档给出的例子若计算构成一个 2D 网格且只与最近邻通信那么块内计算量随块面积呈二次方增长跨块通信量随块周长呈线性增长。因此在面积固定的前提下正方形块的周长最小通信开销最低。上图展示了同一 8×8 网格的四种不同聚合方式水平条带、垂直条带、以及不同粒度的矩形分块。文档特别提醒做此类分析时要小心——由于信息以缓存行为单位传输周长最小的块应是相对于底层缓存行网格呈正方形而不是相对于逻辑网格呈正方形。此外还应考虑向量化vectorization包含长连续数据子集的块更容易被编译器向量化。这一因素会与正方形块的通信最优形状产生张力需要根据实际计算特征取舍。递归计算的聚合把子树当作整体对于递归计算如递归排序、分治算法大部分工作集中在叶子端。因此聚合策略是把子树当作整体处理如上图所示底层的多个小任务先被聚合为较大的子树块再逐层向上合并。在实现层面这种聚合通常通过阈值判定实现递归下降时串行展开一旦子问题规模达到某个阈值就转为并行求解。例如一个递归排序可以规定只有当子问题超过某一规模阈值时才并行求解否则串行处理。这正是块大小权衡在递归结构上的自然体现。示例parallel_for 的自动聚合文档指出TBB 的循环模板如oneapi::tbb::parallel_for在接收range参数时支持自动聚合。其底层实现隐式使用auto_partitioner根据运行环境自动选择合适的块大小这是文档推荐给大多数场景的默认行为详见 Elementwise 模式一节。以 oneTBB 指南中经典的卷积convolution示例为例。串行代码的外层循环符合逐元素elementwise模式可以直接用parallel_for并行化oneapi::tbb::parallel_for( 0, xlenclen-1, { float tmp 0; for( int j0; jclen; j ) tmp c[j]*x[i-j]; y[i] tmp; });此形式由parallel_for自动完成聚合。如果需要显式控制聚合则应改用接收显式 range 参数的重载通过blocked_range的 grainsize 参数指定块大小oneapi::tbb::parallel_for( oneapi::tbb::blocked_rangeint(0,xlenclen-1,1000), { int end r.end(); for( int ir.begin(); i!end; i ) { float tmp 0; for( int j0; jclen; j ) tmp c[j]*x[i-j]; y[i] tmp; } } );这里blocked_rangeint(0, n, 1000)表示迭代空间为半开区间[0, n)每块至少约 1000 次迭代——即显式的聚合粒度。参考PCAM 四步设计方法聚合这一术语由Ian Foster在其著作Designing and Building Parallel Programs中提出是该书PCAM并行设计方法论的第三步。oneTBB 用户指南将 PCAM 四步与 oneTBB 的对应关系梳理如下Partitioning划分——把程序拆成尽可能小的任务Communication通信——确定任务间需要哪些通信。使用 oneTBB 时通信通常是缓存行传输虽然是自动发生的但理解任务间发生了哪些缓存行传输有助于指导后续聚合步骤Agglomeration聚合——把小任务合并成大任务。Foster 的书中有大量值得细读的考量清单Mapping映射——把任务映射到处理器上。oneTBB 的任务调度器会自动完成这一步无需程序员干预。PCAM 的前两步划分与通信分析产出的是最细粒度的任务分解而聚合步骤正是为了对抗第一步划分得尽可能小所带来的调度开销——这也解释了为什么聚合是并行程序设计中不可或缺的一环。纵深grainsize 与分区器——聚合粒度的精确控制聚合的粒度在 oneTBB 中由分区器partitioner与grainsize共同控制详见 Controlling_Chunking 一节。要获得对分块的最大控制权需要同时指定两者将simple_partitioner()作为parallel_for的第三个参数传入关闭自动分块在构造 range 时指定 grainsizeblocked_rangeT(begin, end, grainsize)。grainsize 的默认值为 1单位是每块包含的循环迭代次数。#include oneapi/tbb.h void ParallelApplyFoo( float a[], size_t n ) { parallel_for(blocked_rangesize_t(0,n,G), ApplyFoo(a), simple_partitioner()); }grainsize 设定了并行化的最小阈值。上述代码中设chunksize为每个块内的迭代数使用simple_partitioner可保证G/2 ≤ chunksize ≤ G。四种分区器对照Partitioner_Summary 一节总结了与blocked_range(i, j, g)搭配使用时各分区器的行为分区器描述与blocked_range(i,j,g)搭配时的块大小simple_partitioner块大小受 grainsize 约束g/2 ≤ chunksize ≤ gauto_partitioner默认自动选择块大小g/2 ≤ chunksizeaffinity_partitioner自动块大小 缓存亲和性 均匀分布迭代g/2 ≤ chunksizestatic_partitioner确定性块大小 缓存亲和性 均匀分布不做负载均衡max(g/3, problem_size/num_of_resources) ≤ chunksize不指定分区器时默认使用auto_partitioner。一般而言应使用auto_partitioner或affinity_partitioner因为它们会根据可用执行资源动态调整块数量。affinity_partitioner与static_partitioner还可利用Range按指定比例切分的能力在计算资源间近乎均匀地分配迭代。simple_partitioner在以下场景中反而更有优势子范围大小不得超过某个上限——例如operator()需要与范围大小成比例的临时数组时限制子范围即可用自动变量栈上数组替代动态内存分配大子范围可能导致缓存利用低效——例如对同一内存区域反复扫描时限制子范围大小可使反复引用的内存驻留缓存针对特定机器做精细调优时。经验法则与调优实验文档给出了两条重要经验法则grainsize 迭代的operator()执行应至少消耗约 100,000 个时钟周期。例如单次迭代耗时 100 个时钟则 grainsize 至少应为 1000 次迭代并行化循环嵌套时优先并行化最外层循环——外层循环的每次迭代通常比内层循环的单次迭代提供更大的工作粒度。不确定时可按如下实验流程确定 grainsize将 grainsize 设得偏高若毫无头绪可从grainsize100,000起步理由是一次迭代通常至少消耗一个时钟周期运行算法反复将 grainsize 减半观察运行时间随值减小是变快还是变慢。grainsize 设得太高会降低并行度——例如 grainsize 为 1000 而循环只有 2000 次迭代时parallel_for只会在两个处理器上运行即使还有更多处理器空闲。但拿不准时应偏向稍高而非稍低因为太低的 grainsize 会伤害串行性能而串行性能又会在调用树更上层存在其他并行时拖累整体并行性能。文档也给出了定心丸grainsize 无需设置得过于精确——在浮点a[i]b[i]*c、百万索引的测试中grainsize 在 100100,000 的宽范围内都能工作得很好。mold 源码中的聚合实践mold 链接器将 oneTBB 的并行模板大规模用于各类链接任务是观察聚合模式在生产级系统落地的最佳样本。从源码看mold 主要采用两类调用形态1. 以容器为粒度的自动聚合parallel_for_each最典型的用法是对目标文件集合ctx.objs做并行遍历。例如 gc-sections.cc 中垃圾收集阶段多次对每个ObjectFile并行处理mapfile.cc 中并行构建输入段 → 符号映射表tbb::parallel_for_each(ctx.objs, { for (SymbolE *sym : file-symbols) { if (sym-file file sym-get_type() ! STT_SECTION) { if (InputSectionE *isec sym-get_input_section()) { typename MapE::accessor acc; map.insert(acc, {isec, {}}); acc-second.push_back(sym); } } } });这里每个任务是对一个目标文件内全部符号的完整遍历——天然就是一次聚合如果以单条符号为任务粒度调度与并发容器开销将远超符号处理本身。2. 显式 range 的块式并行parallel_for(i64)当工作对象是紧密排列的数组/向量且可按下标切分时mold 使用tbb::parallel_for((i64)0, size, {...})的整数 range 形式由auto_partitioner自动分块。例如 mapfile.cc 中并行格式化每个输入段的 mapfile 行、arch-arm32.cc 中并行重写 ARM32.ARM.exidx展开记录的相对地址tbb::parallel_for((i64)0, num_entries, { i64 offset sizeof(Entry) * i; ent[i].addr sign_extend(ent[i].addr, 31) offset; if (is_relative(ent[i].val)) ent[i].val 0x7fffffff (ent[i].val offset); });这类逐元素、彼此独立的变换任务正是本文主题文档所描述的聚合适用场景单个元素的操作只需几条指令若按元素调度将产生巨大的同步开销交给parallel_for自动分块后每个块处理成百上千个元素调度开销被充分摊薄。类似的模式遍布 icf.cc同代码折叠的哈希计算与排序、gdb-index.ccGDB 索引构建、output-chunks.cc输出段复制与合并等模块均以集合级parallel_for_each 数组下标级parallel_for两种聚合粒度组合出完整的并行流水线。值得一提的是mold 还展示了聚合与串行收尾的典型组合并行阶段计算与整理数据随后用串行代码如ranges::sort/ranges::unique做全局排序或去重见 arch-arm32.cc这与文档块内串行、块间并行的聚合原则一脉相承。小结聚合模式的要点可归结为三条粒度意识任务小于约 10,000 个时钟周期时调度/同步开销可能反噬性能parallel_for的自动分块auto_partitioner通常已足够但可通过blocked_range的 grainsize 与simple_partitioner获得精确控制拓扑意识块形状应在同步最小化与缓存流量最小化间权衡注意缓存行粒度的伪共享与边界/内部比率效应递归结构则用阈值把子树聚合为整体调优意识优先并行最外层循环grainsize 让operator()约消耗 100,000 时钟周期拿不准时偏向稍大并用倍增/减半实验法快速收敛。mold 链接器的源码证明这套来自 oneTBB 用户指南的设计模式在大规模生产软件中完全可落地——理解聚合模式也就理解了 mold 这类高性能工具如何用粗粒度并行榨干多核性能的核心方法论。【免费下载链接】moldmold: A Modern Linker 项目地址: https://gitcode.com/GitHub_Trending/mo/mold创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表