- 并发编程
- 高性能计算
【免费下载链接】oneTBB
oneAPI Threading Building Blocks (oneTBB)
本篇技术指南聚焦 oneAPI Threading Building Blocks(oneTBB)中affinity_partitioner的实战应用:当并行循环性能受限于处理器与内存之间的系统带宽、需要对缓存亲和性进行显式优化时,如何正确使用这一分区器,以及它与auto_partitioner在调度策略上的本质差异。读完本文,你将掌握affinity_partitioner的适用条件判断、正确生命周期管理、数据结构规模匹配分析,并从源码层面理解其“记住上一次迭代运行位置”的亲和性记忆机制。
问题背景:并行循环为何“跑不快”
对于足够简单的函数Foo,即使把它写成并行循环,示例也可能无法表现出理想的加速比。原因往往是处理器与内存之间的系统带宽不足——多个线程同时从内存搬运数据时,总线与内存子系统成为瓶颈,CPU 计算核心大量时间在等待数据到达,而非执行计算。
此时通常有两种解决思路:
- 重构算法以更好地利用缓存:重组数据访问模式、提高数据局部性、减少单次数据访问对应的计算开销。重构通常同时惠及并行程序与串行程序,是首选方向;
- 使用
affinity_partitioner:在部分场景下无需重构,它不仅能自动选择grainsize(粒度),还能针对缓存亲和性做优化,并尝试把数据在多个线程之间均匀分布。
affinity_partitioner 的适用条件
affinity_partitioner在以下场景可以显著提升性能:
| 条件 | 说明 |
|---|---|
| 计算密度低 | 每次数据访问对应的操作数很少(few operations per data access),即“计算 / 内存访问”比值低 |
| 数据可装进缓存 | 循环作用的数据集合大小能够放入缓存(cache) |
| 循环反复执行 | 同一循环(或相似循环)在同一份数据上反复运行 |
| 硬件线程多于两个 | 可用硬件线程数大于 2(尤其是线程数不是 2 的幂时)。若只有两个线程,oneTBB 的默认调度通常已能提供足够的缓存亲和性,无需显式干预 |
理解最后一条:默认调度器在创建任务时按接近均匀的方式把初始任务分配给各个线程,线程数少时,工作窃取(work stealing)与初始分配本身就能让同一数据大概率留在原线程的缓存中;线程数变多、缓存争用加剧后,才需要显式的亲和性记忆来维持“数据与线程绑定”。
标准用法:从示例代码到正确写法
oneTBB 官方文档给出如下示例,展示了affinity_partitioner的核心用法:
#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 ); }要点拆解:
parallel_for( blocked_range<size_t>(0,n), ApplyFoo(a), ap ):第三个参数ap即分区器对象。blocked_range把区间[0, n)交给分区器按需切分;- 分区器生命周期决定亲和性是否生效:
affinity_partitioner对象ap必须“活在循环迭代之间”。它内部记录了上次各段迭代运行在哪个线程,从而在下次执行时把同一段迭代交还给上次执行它的线程,实现缓存命中; - 示例代码采用局部
static对象的方式保证生命周期正确——ap在首次调用时构造,跨TimeStepFoo的所有时间步存活。另一个等价做法是把ap声明在TimeStepFoo中迭代循环之外的作用域,并沿调用链向下传给parallel_for。
注意:把
affinity_partitioner声明为函数内普通局部变量是不可行的——它会在每次ParallelApplyFoo返回时销毁,亲和性记录随之丢失,分区器退化为普通分区,无法发挥记忆作用。
更贴近实际工程的做法是把分区器作为调用链中传递的对象:
void ParallelApplyFoo( float a[], size_t n, affinity_partitioner& ap ) { parallel_for(blocked_range<size_t>(0,n), ApplyFoo(a), ap); } void TimeStepFoo( float a[], size_t n, int steps ) { affinity_partitioner ap; // 生命周期覆盖所有迭代 for( int t=0; t<steps; ++t ) ParallelApplyFoo( a, n, ap ); }两种方式在语义上等价,选择哪种取决于项目代码风格与可测试性需求。
适用性边界:数据集大小决定收益
亲和性带来的收益并非普遍存在。如果数据集无法完整装入系统各级缓存,收益可能微乎其微。下图对比了两种情形:当数据集大小适配全部核心的缓存容量时,每个核心都能在其专属缓存中覆盖对应数据段,亲和性带来正向收益;当数据集远大于缓存容量时,数据会跨越多个核心缓存分布,亲和性无法减少跨核心访问的开销。
加速比随数据规模的典型变化
下图给出了一个典型实验曲线:对A[i]+=B[i](i属于[0,N))这一“计算极少、内存访问极多”的极端示例,并行加速比随数组大小N的变化关系:
观察该曲线可以发现三个区间:
- N 很小:并行调度开销主导,加速比提升有限;
- N 处于中间“甜蜜区”:数据集恰好能在两次循环调用之间被缓存携带,
affinity_partitioner发挥最大优势,加速比出现峰值; - N 很大:数据集太大,无法在循环调用之间保留在缓存中,亲和性失效,加速比回落。
作者特意强调:该示例是为“戏剧化效果”而选,现实中很难看到如此剧烈的变化幅度。但规律是普适的——affinity_partitioner是一个工具,而非万能药。当“计算 / 内存访问”比值很低时,是否使用它需要依据数据规模与缓存容量的相对关系来判断。
源码视角:亲和性是如何被“记住”的
要真正用好affinity_partitioner,理解其底层实现很有帮助。以下分析基于当前仓库 include/oneapi/tbb/partitioner.h。
亲和性记忆表:slot 数组
affinity_partitioner的核心数据由基类affinity_partitioner_base维护(partitioner.h):
class affinity_partitioner_base: no_copy { slot_id* my_array; // 记录树中各位置的亲和性 id;my_size==0 时为 nullptr std::size_t my_size; // my_array 的元素个数 ... };my_array是一块按cache_aligned_allocate分配的对齐数组,元素类型为slot_id(线程槽位编号),初始值no_slot表示“尚未记录”;- 数组大小按
factor × max_threads_in_arena计算,其中factor与 arena 内最大线程数相关,因此数组容量会随可用并发度动态调整。
运行时任务如何绑定到指定线程槽位
oneTBB 调度器在执行任务时,通过execution_data携带槽位信息(include/oneapi/tbb/detail/_task.h):
struct execution_data { task_group_context* context{}; slot_id original_slot{}; // 任务被创建时的线程槽位 slot_id affinity_slot{}; // 任务期望执行的槽位(由分区器指定) };相关判定辅助函数包括:
execution_slot(ed):任务当前实际执行的槽位;original_slot(ed):任务创建时所在的槽位;is_same_affinity(ed):亲和性槽位未指定或与当前执行槽位一致;is_stolen(ed):original_slot != execution_slot,即任务被其他线程窃取执行。
affinity_partition_type::spawn_task是亲和性生效的关键路径(partitioner.h):
void spawn_task(task& t, task_group_context& ctx) { if (my_divisor) { if (!my_array[my_head]) { spawn(t, ctx, slot_id(my_head / factor)); // 首次执行:按数组索引线性分配 } else { spawn(t, ctx, my_array[my_head]); // 后续执行:绑定到上次的槽位 } } else { spawn(t, ctx); } }- 首次执行:
my_array[my_head]为no_slot,任务按my_head / factor计算出初始槽位并均匀铺开,这正是文档所说“把数据在多个线程之间均匀分布”的实现; - 后续执行:读取出上次记录的
slot_id,通过spawn(t, ctx, slot_id)将任务直接投递到对应线程的本地队列,使同一段数据回到上次执行它的线程,从而命中该线程缓存中残留的数据。
与之配对的是note_affinity(slot_id id)(partitioner.h):任务实际运行时记录当前执行的槽位回写到my_array[my_head],形成“读旧值 → 绑线程 → 写新值”的记忆闭环。需要留意的是,任务若创建得过深(超出亲和性数组可记忆的深度),注释明确指出不应保存其亲和性,以避免 LIFO 顺序覆盖。
与 auto_partitioner 的调度差异
affinity_partitioner在类层次上由dynamic_grainsize_mode<linear_affinity_mode<affinity_partition_type>>组合而来,而auto_partitioner使用dynamic_grainsize_mode<adaptive_mode<auto_partition_type>>(partitioner.h)。两者的共同点是都继承dynamic_grainsize_mode,即都具备“自适应粒度 + 工作窃取反馈”能力:
dynamic_grainsize_mode维护一个range_pool(容量__TBB_RANGE_POOL_CAPACITY = 8)与最大分割深度my_max_depth(初值__TBB_INIT_DEPTH = 5);当任务被窃取(is_stolen_task(ed)为真)时会加深分割深度(每次增加__TBB_DEMAND_DEPTH_ADD = 1),以产生更多更小的任务块供给空闲线程;affinity_partitioner额外叠加linear_affinity_mode,在自适应分裂的基础上增加线性索引my_head与亲和性记忆表,使生成的子任务按索引均匀映射到不同线程并记住绑定关系;auto_partitioner只做自适应分裂与窃取反馈,不维护任何跨循环调用的状态,因此它对“反复执行同一数据”的场景没有缓存亲和性收益。
图 2 的实验曲线也印证了这一点:在数据规模处于甜蜜区时,affinity_partitioner的加速比显著高于auto_partitioner(后者几乎不随 N 变化);而数据规模超出缓存容量后,两者趋于一致。
测试验证:如何确认亲和性分布生效
仓库测试对affinity_partitioner的行为有专门覆盖(test/tbb/test_parallel_for.cpp):
namespace correctness中的测试:仅验证正确性,即使用affinity_partitioner时parallel_for不会挂起(test_parallel_for.cpp);namespace uniform_distribution中的测试更有意思:测试体内部使用SpinBarrier等待所有线程汇合(test_parallel_for.cpp),注释明确指出——“Body 确保初始工作分发是通过亲和机制均匀完成的,而不是通过工作窃取”。也就是说,如果affinity_partitioner没有把各段迭代均匀铺到所有线程,测试会因部分线程永远等不到其他线程而挂起。该测试同时用static_partitioner()做对照(test_parallel_for.cpp)。
这说明 oneTBB 不仅提供了亲和性记忆能力,还通过测试把“均匀分布 + 不依赖窃取”确立为affinity_partitioner的契约行为。
实践建议
综合文档与源码,给出如下实操建议:
- 先重构,后分区器:缓存友好重构优先,它是并行与串行程序共同的收益来源;
- 按条件判断是否使用:低计算密度 + 数据可装入缓存 + 同一数据反复循环 + 多线程(非 2 的幂),四者同时满足时值得一试;
- 保证分区器生命周期:用局部
static对象,或声明在迭代循环之外的作用域并沿调用链传递;切勿让它在每次循环迭代中新建销毁; - 监测数据规模:数据集过大(超出缓存容量)时收益消失,此时应放弃
affinity_partitioner并考虑分块或数据布局优化; - 必要时自行基准验证:真实程序的“计算 / 内存访问”比很难与示例曲线完全一致,用代表性数据集实测
affinity_partitioner与auto_partitioner(默认分区器)的差异,是最可靠的决策依据。
affinity_partitioner的适用性可概括为:它是缓存亲和性优化工具箱中的一件利器,但只有数据规模与缓存容量匹配、循环反复访问同一份数据时,它的“记忆”才有价值。正确判断场景、正确管理生命周期,是发挥其价值的两项前提。
- 并发编程
- 高性能计算
【免费下载链接】oneTBB
oneAPI Threading Building Blocks (oneTBB)
相关推荐
mold 性能优化指南:利用 oneTBB affinity_partitioner 破解带宽瓶颈、提升缓存亲和性与并行加速比
mold 性能优化指南:利用 oneTBB affinity_partitioner 破解带宽瓶颈、提升缓存亲和性与并行加速比 本篇技术指南以当前仓库所内置的
开发工具构建工具系统编程pydictor工具集详解:合并、去重、比较、统计与筛选工具
pydictor工具集详解:合并、去重、比较、统计与筛选工具 pydictor是一款强大的黑客字典生成工具,专为暴力破解攻击设计。本文将详细介绍其工具集中的合并
isle-portable性能调优:内存带宽与缓存优化技巧
isle portable性能调优:内存带宽与缓存优化技巧 在LEGO Island 1997 的现代化项目isle portable中,内存带宽和缓存优化是提
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考