【问题标题】:How to parallelize std::partition using TBB如何使用 TBB 并行化 std::partition
【发布时间】:2014-05-28 23:21:46
【问题描述】:

有没有人有任何使用 TBB 有效并行化 std::partition 的技巧?这已经完成了吗?

这是我的想法:

  1. 如果数组很小,std::partition it (serial) and return
  2. 否则,使用自定义迭代器将数组视为 2 个交错数组(在缓存大小的块中交错)
  3. 为每对迭代器启动并行分区任务(递归到步骤 1)
  4. 在两个分区/中间指针之间交换元素*
  5. 返回合并的分区/中间指针

*我希望在一般情况下,与数组的长度相比,或者与将数组划分为连续块时所需的交换相比,该区域会很小。

在我尝试之前有什么想法吗?

【问题讨论】:

  • 如果您使用 gcc,您可以从 -fopenmp 开始并定义 _GLIBCXX_PARALLEL 以使用已经编写、测试等的标准库的并行版本。如果您真的想要要并行运行std::partition,您可以包含parallel/algorithm,并调用__gnu_parallel::partition
  • 这需要与 gcc 和 clang 编译。另外,做分区的代码已经在tbb任务中运行了,openmp调度器和tbb调度器能很好地协同工作吗?
  • 期望他们付出了一些努力来让他们一起玩得很好,但我还没有测试过,所以很难确定。
  • 不,如果在最外层的并行循环(TBB 或其他)内调用 OpenMP,通常会创建二次超额订阅。这取决于 OpenMP 规范。通过 OMP_DYNAMIC 模式可以降低超额订阅的程度,但仍然嵌套两个不同的运行时效率低。
  • 顺便说一句,如果您不想依赖 GCC 的扩展但可以使用 GPL3 代码,您可以捆绑它们的实现。如果您真的想避免使用 OpenMP,您也可以将他们的代码转换为 TBB。在我看来,他们使用分而治之的方法概括为任意线程数。 gcc.gnu.org/onlinedocs/gcc-4.6.2/libstdc++/api/…

标签: c++ algorithm sorting parallel-processing tbb


【解决方案1】:

我将其视为并行样本排序的退化情况。 (样本排序的并行代码可以在here 找到。)设N 为项目数。退化样本排序将需要 Θ(N) 临时空间,具有 Θ(N) 工作,以及 Θ(P+ lg N) 跨度(关键路径)。最后两个值对于分析很重要,因为加速仅限于工作/跨度。

我假设输入是一个随机访问序列。步骤是:

  1. 分配一个足够大的临时数组来保存输入序列的副本。
  2. 将输入分成 K 个块。 K 是一个调谐参数。对于具有 P 个硬件线程的系统,K=max(4*P,L) 可能很好,其中 L 是一个常数,用于避免可笑的小块。 “4*P”允许一些负载平衡。
  3. 将每个块移动到其在临时数组中的相应位置,并使用 std::partition 对其进行分区。块可以并行处理。记住每个块的“中间”偏移量。您可能需要考虑编写一个自定义例程,该例程既可以移动(在 C++11 意义上)又可以对块进行分区。
  4. 计算块的每个部分在最终结果中的偏移量。每个块的第一部分的偏移量可以使用exclusive prefix sum 在步骤 3 中中间的偏移量上完成。每个块的第二部分的偏移量可以类似地通过使用每个中间相对于结束它的块。后一种情况下的运行总和成为最终输出序列末尾的偏移量。除非您要处理超过 100 个硬件线程,否则我建议您使用串行独占扫描。
  5. 将每个块的两个部分从临时数组移回原始序列中的适当位置。可以并行复制每个块。

有一种方法可以将第 4 步的扫描嵌入到第 3 步和第 5 步中,这样跨度可以减少到 Θ(lg N),但我怀疑这是否值得增加复杂性。

如果使用 tbb::parallel_for 循环来并行化第 3 步和第 5 步,请考虑使用 affinity_partitioner 帮助第 5 步中的线程从第 3 步中提取它们留在缓存中的内容。

请注意,对于 Θ(N) 的内存加载和存储,分区只需要 Θ(N) 的工作。内存带宽很容易成为加速的限制资源。

【讨论】:

  • 谢谢,这是一个非常好的建议。我还担心内存带宽,这就是为什么我在考虑交错分区的想法。我希望它可以在合并步骤中节省大量复制。如果我在缓存大小的块中交错,也许我也可以避免错误共享问题。我将在星期一编写它,它应该只需要实现特殊的交错迭代器并将它们与 std::partition 并行使用......看起来并不难。
  • 之前没见过affinity_partitioner,看起来很有用,谢谢。
【解决方案2】:

为什么不改用类似于std::partition_copy 的东西呢?原因是:

  • 对于 std::partition,由于结果的递归合并,就地交换与 Adam 的解决方案一样需要对数复杂度。
  • 在使用线程和任务时,无论如何都要为并行性支付内存。
  • 如果对象很重,无论如何交换(共享)指针更合理
  • 如果结果可以同时存储,那么线程就可以独立工作。

应用parallel_for(用于随机访问迭代器)或tbb::parallel_for_each(用于非随机访问迭代器)开始处理输入范围非常简单。每个任务都可以独立存储“真”和“假”结果。有很多方法可以存储结果,其中一些来自我的脑海:

  • 使用tbb::parallel_reduce(仅适用于随机访问迭代器),将结果本地存储到任务主体,然后将它们从另一个任务移动附加到join()
  • 使用tbb::concurrent_vector 的方法grow_by() 将本地结果复制成一堆,或者在到达时单独复制push() 每个结果。
  • tbb::combinable TLS 容器中缓存线程本地结果并在稍后合并它们

std::partition_copy 的确切语义可以通过从上面的临时存储中复制来实现,或者

  • (仅适用于随机访问输出迭代器)使用atomic<size_t> 游标来同步存储结果的位置(假设有足够的空间)

【讨论】:

  • 我的“交错分区”想法是试图绕过合并的对数复杂性。忽略最坏的情况,数组的两个交错切片通常应该具有相似的值分布。如果是这样,两个生成的分区/中间指针应该最终彼此靠近。原始数组的结尾部分(begin->middle_low、middle_high->end)将被正确分区,不需要任何合并。只有中间范围(middle_low->middle_high)的元素需要交换。
  • @atb,如果对象与缓存行大小对齐(对于数组/向量..)或分配在单独的缓存行中(对于列表),则不会导致(很多)错误共享。否则,如果对象被紧紧地放在一个数组中,我担心交叉访问会导致大量的错误共享惩罚,因此“常量”复杂性将无济于事,因为常量太大。交错可能对只读结构有用。
【解决方案3】:

您的方法应该是正确的,但为什么不遵循常规的分而治之(或 parallel_for)方法呢?对于两个线程:

  1. 将数组一分为二。把你的 [start, end) 变成 [start, middle), [middle, end)。
  2. 在两个范围上并行运行 std::partition。
  3. 合并分区结果。这可以通过 parallel_for 来完成。

这应该可以更好地利用缓存。

【讨论】:

  • 我不喜欢这种方法,因为我认为在合并过程中需要太多的交换。平均一半的阵列可能需要交换。合并交错数组要简单得多,因为分区段中的顺序是任意的,因此大多数元素可以单独保留,只需要清理数组的中间部分。但你是对的,它可能对缓存不友好......
  • 是的,但是交换速度非常快。交错保证减少缓存命中的非连续元素。我不知道您实现跨步迭代器的计划,但我能想到的唯一方法会使代码更丑陋,并使编译器优化代码的工作更加困难。工作效率高的方法并不总是最快的。你征求意见,那是我的。
  • 如果需要合并,parallel_reduceparallel_for 更适合
  • @Anton 嗯?你有两个范围,你需要交换它们的内容。那是怎么减少的?
  • 如果您需要两个子范围,如果不在 parallel_reduce 中,您如何获得它们?它的join操作保证没有其他线程处理相同的子范围
【解决方案4】:

在我看来这应该很好地并行,在我尝试之前有什么想法吗?

嗯...也许有几个:

  • 没有真正的理由创建比您拥有的内核更多的任务。由于您的算法是递归的,因此您还需要保持跟踪,以免在达到限制后创建额外的线程,因为这只是不必要的努力。
  • 请记住,拆分和合并数组会消耗您的处理能力,因此请以某种方式设置拆分大小,这实际上不会减慢您的计算速度。拆分一个 10 元素的数组可能很诱人,但不会让你到达你想去的地方。由于std::partition 的复杂性是线性的,因此很容易高估任务的速度。

既然你问并给出了一个算法,我希望你在这里真的需要并行化。如果是这样 - 没有什么要补充的,算法本身看起来真的很好:)

【讨论】:

  • 我认为 tbb 喜欢有更多的任务然后线程,所以它可以做更好的负载平衡?但我同意下限,我试图在步骤 1 中暗示。
  • @atb 图书馆不能创造奇迹。如果您的 CPU 可以一次处理 X 任务(所以 - 简化 - 它有 X 内核),无论您使用什么库,它都不会做更多事情。我不是 Intels TBB 方面的专家,也许它会为您处理不必要的线程,这可以在手册中找到。
  • @atb 是对的,创建更多任务的原因,它有助于负载平衡工作。即使对于看起来平衡良好的工作,它仍然是有意义的,因为缓存未命中、中断和抢占会造成不平衡。当然,创建太多任务会导致过多的开销,因此 TBB 建议将任务保持在至少 10K 时钟。 auto_partitioner 也很好地找到了 parallel_forparallel_reduce 产生的任务数量的中庸之道
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2016-10-12
  • 2011-10-10
  • 2023-03-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多