- 并发编程
- 高性能计算
【免费下载链接】oneTBB
oneAPI Threading Building Blocks (oneTBB)
affinity_partitioner是 oneAPI Threading Building Blocks(oneTBB)并行循环模板(如parallel_for、parallel_reduce)提供的四种分区器之一。它向任务调度器发出提示:循环迭代应当以优化缓存亲和性(cache affinity)的方式分配给线程,从而让同一份数据在多次循环执行中被尽可能交给同一个线程处理,减少缓存未命中与内存带宽消耗。本文以官方参考文档 affinity_partitioner 参考页 为核心骨架,结合用户指南、头文件实现与测试代码,系统讲解它的语义、与其它分区器的区别、典型使用场景、实现机制与性能特性,帮助你判断何时该用它、何时不该用。
affinity_partitioner 是什么:一份面向缓存的调度提示
官方参考文档对它的定义非常简洁:Hints that loop iterations should be assigned to threads in a way that optimizes for cache affinity——即"提示循环迭代应按照优化缓存亲和性的方式分配给线程"。
具体来说,一个affinity_partitioner对象会提示:循环模板(loop template)在切分工作时,应当复用"上一次使用同一个affinity_partitioner对象执行该循环(或另一个循环)"所记录下来的任务亲和性模式(task affinity pattern)。换句话说,它是一份"记忆":把上一次循环中每个子区间在哪个线程上执行过记下来,下一次循环开始时尽量把相同的子区间再次交给相同的线程。
这一语义直接决定了它的两个关键使用前提:
- 必须复用同一个对象:与其它分区器不同(其它分区器可以每次新建,甚至不传),要让循环获得亲和性优化,必须把同一个
affinity_partitioner对象传给要被优化的循环模板。如果每次执行循环都新建一个对象,记忆就会丢失,亲和性优化自然无从谈起; - 亲和性模式跨循环复用:它能优化的场景,是"同一个循环(或结构相似、作用于同一份数据的循环)被反复执行"的迭代型计算,例如时间步进、迭代求解等。
从类定义看,affinity_partitioner是一个极其轻量的类型,声明于头文件 include/oneapi/tbb/partitioner.h:
// Defined in header <oneapi/tbb/partitioner.h> namespace oneapi { namespace tbb { class affinity_partitioner { public: affinity_partitioner() = default; ~affinity_partitioner() = default; }; } // namespace tbb } // namespace oneapi类本身没有公开的数据成员或方法——所有"记忆"能力都由其私有基类与内部实现承载(详见下文"源码级剖析"一节)。这与文档中"满足 ISO C++ [utility.arg.requirements] 中的CopyConstructible要求"的说明一致:分区器对象可以被拷贝、按值传递,语义简单,代价极小。
与其它分区器的对比:四种分区策略一张表看懂
affinity_partitioner不是孤立的,它是 oneTBB 分区器家族的一员。parallel_for、parallel_reduce等并行算法接受一个可选的 partitioner 参数,用来指定循环的执行策略。文档 Partitioner_Summary 给出了四种分区器作用于blocked_range(i,j,g)时的完整对照表:
| Partitioner | Description | When Used withblocked_range(i,j,g) |
|---|---|---|
simple_partitioner | Chunksize bounded by grain size. | g/2 ≤ chunksize ≤ g |
auto_partitioner(default) | Automatic chunk size. | g/2 ≤ chunksize |
affinity_partitioner | Automatic chunk size, cache affinity and uniform distribution of iterations. | g/2 ≤ chunksize |
static_partitioner | Deterministic chunk size, cache affinity and uniform distribution of iterations without load balancing. | max(g/3, problem_size/num_of_resources) ≤ chunksize |
其中:
simple_partitioner:递归切分范围,直到每个子区间都满足!r.is_divisible(),即切到不能再切为止。子区间大小被 grain size 严格上界约束(g/2 ≤ chunksize ≤ g)。它适合"子区间大小必须不超过某个上限"的场景,例如operator()需要一个与区间大小成比例的临时数组,可以用栈上数组代替动态分配。auto_partitioner(默认分区器):自动决定块大小,初始只产生少量大块,仅在发生任务窃取(work stealing)时才进一步细分,从而在"尽量减少切分"与"提供充足的窃取机会"之间取得平衡。块大小只保证下限g/2 ≤ chunksize。affinity_partitioner:同样自动决定块大小(g/2 ≤ chunksize),但额外加入了缓存亲和性与迭代的均匀分布能力——这正是本文的主题。static_partitioner:确定性块大小、确定性亲和性模式,且不做额外的负载均衡,块大小满足max(g/3, problem_size/num_of_resources) ≤ chunksize。适合负载均衡收益小于开销的均衡小任务,或需要移植 OpenMPschedule(static)语义的场景。
从源码结构看,这四种分区器共享同一套基础设施:include/oneapi/tbb/partitioner.h 中定义了partition_type_base、adaptive_mode、proportional_mode、linear_affinity_mode、dynamic_grainsize_mode等一系列策略基类,四种分区类型通过不同的模板组合派生出各自行为:auto_partition_type组合dynamic_grainsize_mode<adaptive_mode<...>>,static_partition_type组合linear_affinity_mode<...>,而affinity_partition_type组合dynamic_grainsize_mode<linear_affinity_mode<...>>。也就是说,affinity 分区器本质上是"动态粒度 + 线性亲和性索引 + 记忆数组"三者的叠加。
为什么默认分区器不是它
文档明确指出,未指定分区器时默认使用auto_partitioner。一般建议在auto_partitioner与affinity_partitioner之间选择,因为它们都会根据可用的执行资源(线程数)来调整块数量。但亲和性记忆是有状态的,需要复用同一对象,这增加了使用约束;而auto_partitioner无状态、零成本,适用于绝大多数场景。因此affinity_partitioner是一个"按需启用的工具,而非默认选择"。
何时该用 affinity_partitioner:四个关键判据
用户指南 Bandwidth_and_Cache_Affinity 明确指出,当以下条件同时成立时,使用affinity_partitioner可以显著提升性能:
- 计算对数据访问的比例很低:每个数据访问只伴随少量计算操作(few operations per data access),此时内存带宽是瓶颈,缓存亲和性的价值最大;
- 循环作用的数据集能装进缓存:数据被循环处理后仍留在各级缓存中(fits in cache),这样"同线程、同数据"才能在下次循环中真正命中缓存;
- 循环(或类似的循环)会针对同一份数据反复执行:只有重复执行,跨循环的记忆才有意义;
- 硬件线程多于两个(尤其是线程数不是 2 的幂时):如果只有两个线程,默认调度通常已能提供足够的缓存亲和性,专门使用亲和性分区器意义不大。
反之,如果数据集超出系统各级缓存的总容量,收益就会很小——因为数据在两次循环之间已经被逐出缓存,亲和性记忆无法转化为缓存命中。
数据集大小与缓存容量:收益的"甜区"
文档用两张图直观说明了"数据集大小与缓存相对大小"如何决定亲和性的收益。
上图(image007.jpg)对比了两种情况:当数据集能够完整容纳在所有计算单元(die 0 至 die 3)的缓存范围内时,亲和性可以带来收益(Benefit from affinity);当数据集延伸超出各计算单元的缓存容量时,亲和性无收益(No benefit from affinity)。
上图(image008.jpg)则是定量曲线:横轴为数组元素数量 N(对数刻度,约 10⁴ 到 10⁸),纵轴为并行加速比(0 到 20)。示例计算为A[i]+=B[i](i属于区间[0,N)),是刻意选取的"每数据访问计算量极低"的例子。可以看到affinity_partitioner的加速比随 N 增大先快速上升,在 N 约为 10⁶ 附近达到接近 18 的峰值,随后因数据集超出缓存容量而迅速回落;而auto_partitioner全程加速比极低。这个"两头低、中间高"的曲线正是缓存亲和性的典型特征:
- N 很小:并行调度开销占主导,加速比本来就低,亲和性无从发挥;
- N 处于中间"甜区":数据集恰好能装进缓存,亲和性收益最大;
- N 很大:数据集太大,无法在两次循环之间由缓存携带,收益消失。
因此文档强调:当"计算量与内存访问量的比值"很低时,affinity_partitioner应被视为工具而非万灵药(a tool, not a cure-all)。
实战示例:时间步进循环中的对象复用
用户指南给出了最经典的用法——时间步进(time stepping)场景:
#include "oneapi/tbb.h" void ParallelApplyFoo( float a[], size_t n ) { static affinity_partitioner ap; parallel_for(blocked_range<size_t>(0,n), ApplyFoo(a), ap); } void TimeStepFoo( float a[], size_t n, int steps ) { for( int t=0; t<steps; ++t ) ParallelApplyFoo( a, n ); }要点分析:
affinity_partitioner对象ap的生存期跨越多次循环执行(t=0..steps-1)。它记住了上一次循环中每个迭代区间在哪个线程执行,从而在下一次循环中把同样的区间交给同样的线程,让该线程操作的数据继续留在它的缓存里;- 示例通过局部静态对象(
static affinity_partitioner ap;)保证对象在多次调用间存活。另一种等价做法是在TimeStepFoo的更外层作用域声明对象,并通过调用链传给parallel_for。两种方式的共同点是:对象必须比"被优化的循环"活得更久; - 注意:这里的
static是函数级静态对象,其生命周期覆盖整个程序运行期,因此能跨所有ParallelApplyFoo调用复用。这也是"同一个 affinity_partitioner 对象必须传给循环模板"这一规则的最直白体现。
另外,affinity_partitioner也能与其它并行算法配合。例如 conformance_parallel_reduce.cpp 与 conformance_parallel_for.cpp 等一致性测试中,都在parallel_for/parallel_reduce上以affinity_partitioner作为分区器参数进行验证(测试文件头部注释标注了它对应的规范编号[algorithms.affinity_partitioner]),说明该分区器是这两个核心算法的一等公民参数。
源码级剖析:亲和性"记忆"是如何实现的
记忆的载体:affinity_partitioner_base 与缓存对齐数组
文档中的公开类affinity_partitioner实际继承自内部基类affinity_partitioner_base(见 include/oneapi/tbb/partitioner.h)。这个基类才是记忆的真正载体:
class affinity_partitioner_base: no_copy { //! Array that remembers affinities of tree positions to affinity_id. slot_id* my_array; //! Number of elements in my_array. std::size_t my_size; ... void resize(unsigned factor) { unsigned max_threads_in_arena = static_cast<unsigned>(max_concurrency()); std::size_t new_size = factor ? factor * max_threads_in_arena : 0; ... my_array = static_cast<slot_id*>(r1::cache_aligned_allocate(new_size * sizeof(slot_id))); std::fill_n(my_array, new_size, no_slot); ... } };关键点:
my_array是一个slot_id*数组,记录"任务树中各位置"到"线程槽位(affinity_id)"的映射;my_size为数组元素个数。若大小为 0,my_array为nullptr;- 数组通过
cache_aligned_allocate分配,即缓存对齐分配——这与该分区器服务于缓存亲和性的初衷一致,避免伪共享等缓存问题; resize(factor)按"因子 × 当前 arena 最大并发线程数"决定数组大小,且在大小相同时保留原值(Retains values if resulting size is the same),这是跨循环复用记忆的关键:只要线程配置没变,记忆数组就不会被重建,之前记录的位置→线程映射得以延续;- 新分配(或因子为 0 清空)时统一用
no_slot填充,表示"尚未记录"。
分区时如何写入与读取记忆:affinity_partition_type
真正参与循环执行的是内部类型affinity_partition_type(include/oneapi/tbb/partitioner.h),它组合了dynamic_grainsize_mode<linear_affinity_mode<...>>。其中两个方法直接对应文档描述的"复用任务亲和性模式":
void note_affinity(slot_id id) { if( my_divisor ) my_array[my_head] = id; } void spawn_task(task& t, task_group_context& ctx) { if (my_divisor) { if (!my_array[my_head]) { // 尚无记录:按线性索引 my_head/factor 给出的槽位生成(初始调度) spawn(t, ctx, slot_id(my_head / factor)); } else { // 已有记录:按上一次记录的位置生成(亲和性复用) spawn(t, ctx, my_array[my_head]); } } else { spawn(t, ctx); } }逻辑可以概括为:
- 首次执行:
my_array中对应位置为no_slot(未记录),spawn_task退化为按my_head / factor的线性槽位索引生成任务,即"初始调度";任务执行完毕后,通过note_affinity把实际执行的线程 id 写回my_array[my_head]; - 后续执行:同一
affinity_partitioner对象再次用于循环时,spawn_task读到my_array[my_head]中上次记录的线程 id,直接用spawn(t, ctx, my_array[my_head])把任务投递给同一个线程,从而让该线程继续处理上次它处理过的数据区间,命中其本地缓存; factor在这里是1 << factor_power(factor_power = 4,即 16),表示数组中"每个任务预留的槽位数";构造函数中还断言factor是 2 的幂、且初始my_max_depth = factor_power + 1 = 5小于 range 池容量__TBB_RANGE_POOL_CAPACITY = 8。
均匀分布与按比例切分:proportional splitting
文档还指出:当Range类型启用按比例切分(proportional splitting)时,affinity_partitioner会使用按比例切分。这在代码中有两处直接体现:
affinity_partition_type的split_type被声明为detail::proportional_split(而非普通的split),其切分走的是proportional_mode::get_split()/do_split()路径(include/oneapi/tbb/partitioner.h),根据内部剩余资源数my_divisor计算左右比例;- 对应的
Range侧,blocked_range提供了按比例切分的构造函数(include/oneapi/tbb/blocked_range.h):
blocked_range( blocked_range& r, proportional_split& proportion ) : ... { ... } static Value do_split( blocked_range& r, proportional_split& proportion ) { // 使用 32 位浮点近似计算右部分大小: // 即使范围达到 2^64 次迭代,计算误差也约为 0.000001% size_type right_part = size_type(float(r.size()) * float(proportion.right()) / float(proportion.left() + proportion.right()) + 0.5f); return r.my_end = Value(r.my_end - right_part); }这样,任务树在切分时就按照线程资源的比例划分迭代空间,使迭代大致均匀地分布到各计算资源上——这正是文档说 affinity 分区器"还负责把数据均匀分布在线程之间"的底层原因。而proportional_split类(proportional_split 参考页)的完整签名是:
namespace oneapi { namespace tbb { class proportional_split { public: proportional_split(std::size_t _left = 1, std::size_t _right = 1); std::size_t left() const; std::size_t right() const; explicit operator split() const; // 对不支持按比例切分的 Range 可退化为基本 split }; }}注意:按比例切分构造函数是Range概念的可选要求(详见 Range named requirement)。若 Range 只提供基本切分构造(R(R& r, split)),proportional_split会通过explicit operator split()隐式退化为普通切分,此时"均匀分布"能力降级,但仍保留记忆与亲和性复用能力。
与 auto_partitioner 的运行时差异:动态粒度
affinity_partition_type还继承了dynamic_grainsize_mode,意味着它同样采用动态粒度策略:维护一个 range 池(range_vector,容量__TBB_RANGE_POOL_CAPACITY = 8)和最大切分深度my_max_depth(初始__TBB_INIT_DEPTH = 5)。当任务被窃取(is_stolen_task检测执行槽位与原始槽位不一致)或发生负载不均衡时,check_being_stolen会通过__TBB_DEMAND_DEPTH_ADD = 1加深切分深度,产生更细的子区间来提供更多窃取机会(见 include/oneapi/tbb/partitioner.h)。也就是说,affinity 分区器并非一味"死板复用"——在动态平衡与亲和记忆之间,它通过tree_node上的m_child_stolen标志感知窃取,必要时依然会细分以平衡负载。
顺带说明,affinity_partitioner与static_partitioner都使用了linear_affinity_mode(线性亲和性索引),因此两者都具备"确定性/可复用的亲和性模式";区别在于static_partitioner不做动态负载均衡(其work_balance直接run_body),而affinity_partitioner保留动态粒度。从结构看,可以认为affinity_partitioner是"static 的亲和性分配思想 + auto 的动态负载均衡"的融合体。
使用建议与注意事项
综合参考文档、用户指南与源码实现,给出以下实践建议:
- 对象生命周期是正确性的前提:
affinity_partitioner必须声明在循环之外、且跨多次循环执行存活(局部 static、外层作用域变量、或按引用/值传入调用链均可)。若在循环体内新建对象,每次执行都会清空记忆(resize重新分配并以no_slot填充),亲和性优化完全失效; - 先用场景判据筛选:优先确认"低计算/内存比 + 数据能装进缓存 + 同一数据重复处理 + 多于两个硬件线程"四条是否满足。任一不满足,收益都可能大打折扣;
- 关注数据规模的"甜区":数据集相对缓存过大时,亲和性记忆无法转化为缓存命中,此时应回归默认的
auto_partitioner; - 区分它与 static_partitioner:需要确定性的跨线程分配、且任务量小且均衡时,
static_partitioner更合适;需要动态负载均衡的同时兼顾缓存亲和性,才选affinity_partitioner; - 不依赖 grain size 上界:与
auto_partitioner类似,affinity_partitioner只保证g/2 ≤ chunksize,不保证 chunksize 的上界——operator()可能收到比 grain size 更大的子区间。如果业务逻辑要求子区间大小必须被 grain size 约束,应改用simple_partitioner(此时g/2 ≤ chunksize ≤ g)。
总结
affinity_partitioner是 oneTBB 分区器家族中唯一一个"有状态、面向缓存亲和性"的分区器:它通过缓存对齐的记忆数组记录任务树各位置上一次执行的线程,在后续循环中把相同区间重新投递给相同线程,并以按比例切分实现迭代的均匀分布、以动态粒度应对负载变化。它的正确用法高度依赖"复用同一对象"与"数据集适配缓存"两个前提,属于典型场景下的性能利器,而非通用的默认选择。理解它的语义边界——何时有收益(低计算内存比、数据在缓存中、重复处理、多线程)、何时无收益(数据集过大、线程过少)——是发挥其价值的关键。
延伸阅读
- affinity_partitioner 参考文档
- auto_partitioner 参考文档
- simple_partitioner 参考文档
- static_partitioner 参考文档
- 分区器总览表(Partitioner Summary)
- 带宽与缓存亲和性(Bandwidth and Cache Affinity)
- Range named requirement
- proportional_split 参考文档
- partitioner.h 头文件实现
- blocked_range.h 中的按比例切分构造
- parallel_for 一致性测试
- parallel_reduce 一致性测试
- 并发编程
- 高性能计算
【免费下载链接】oneTBB
oneAPI Threading Building Blocks (oneTBB)
相关推荐
oneTBB 带宽与缓存亲和性优化:affinity_partitioner 的适用场景、代码实践与源码原理
oneTBB 带宽与缓存亲和性优化:affinity_partitioner 的适用场景、代码实践与源码原理 本篇技术指南聚焦 oneAPI Threading
并发编程高性能计算ng-zorro-antd Switch 加载中状态(nzLoading)完全指南:实现原理、使用场景与源码剖析
ng zorro antd Switch 加载中状态(nzLoading)完全指南:实现原理、使用场景与源码剖析 导读 本文聚焦 ng zorro antd(A
UI组件前端mold 性能优化指南:利用 oneTBB affinity_partitioner 破解带宽瓶颈、提升缓存亲和性与并行加速比
mold 性能优化指南:利用 oneTBB affinity_partitioner 破解带宽瓶颈、提升缓存亲和性与并行加速比 本篇技术指南以当前仓库所内置的
开发工具构建工具系统编程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考