如何有效地并行化分治算法

How to parallelize a divide-and-conquer algorithm efficiently?

本文关键字:分治算法 并行化 有效地      更新时间:2023-10-16

在过去的几天里,我一直在刷新我对排序算法的记忆,我遇到了一个我找不到最佳解决方案的情况。

我写了一个快速排序的基本实现,我想通过并行执行来提高它的性能。

我得到的是:

template <typename IteratorType>
void quicksort(IteratorType begin, IteratorType end)
{
  if (distance(begin, end) > 1)
  {
    const IteratorType pivot = partition(begin, end);
    if (distance(begin, end) > 10000)
    {
      thread t1([&begin, &pivot](){ quicksort(begin, pivot); });
      thread t2([&pivot, &end](){ quicksort(pivot + 1, end); });
      t1.join();
      t2.join();
    }
  }
}

虽然这比简单的"无线程"实现要好,但它有严重的限制,即:

  • 如果要排序的数组太大或者递归太深,系统可能会耗尽线程并且执行失败。
  • 在每个递归调用中创建线程的成本可能是可以避免的,特别是考虑到线程不是无限的资源。

我想使用线程池来避免后期线程创建,但我面临另一个问题:

    我创建的大多数线程首先完成所有的工作,然后在等待完成时什么都不做。这导致很多线程只是在等待子调用完成,这似乎不是最优的。

是否有一种技术/实体可以用来避免浪费线程(允许它们重用)?

我可以使用boost或任何c++ 11功能。

如果要排序的数组太大或者递归太深,系统可能会耗尽线程并且执行失败。

所以在最大深度之后依次进行…

template <typename IteratorType>
void quicksort(IteratorType begin, IteratorType end, int depth = 0)
{
  if (distance(begin, end) > 1)
  {
    const IteratorType pivot = partition(begin, end);
    if (distance(begin, end) > 10000)
    {
      if (depth < 5) // <--- HERE
      { // PARALLEL
        thread t1([&begin, &pivot](){ quicksort(begin, pivot, depth+1); });
        thread t2([&pivot, &end](){ quicksort(pivot + 1, end, depth+1); });
        t1.join();
        t2.join();
      }
      else
      { // SEQUENTIAL
        quicksort(begin, pivot, depth+1);
        quicksort(pivot + 1, end, depth+1);
      }
    }
  }
}

对于depth < 5,它将创建最多约50个线程,这很容易使大多数多核cpu饱和-进一步的并行性将不会产生任何好处。

在每个递归调用中创建线程的成本可能是可以避免的,特别是考虑到线程不是无限的资源。

休眠线程的成本并不像人们想象的那么高,但是在每个分支上创建两个新线程是没有意义的,不如重用当前线程,而不是让它进入睡眠状态…

template <typename IteratorType>
void quicksort(IteratorType begin, IteratorType end, int depth = 0)
{
  if (distance(begin, end) > 1)
  {
    const IteratorType pivot = partition(begin, end);
    if (distance(begin, end) > 10000)
    {
      if (depth < 5)
      {
        thread t1([&begin, &pivot](){ quicksort(begin, pivot, depth+1); });
        quicksort(pivot + 1, end, depth+1);   // <--- HERE
        t1.join();
      } else {
        quicksort(begin, pivot, depth+1);
        quicksort(pivot + 1, end, depth+1);
      }
    }
  }
}

与使用depth相比,您可以设置一个全局线程限制,然后仅在未达到限制时创建一个新线程——如果达到了,则按顺序执行。这个线程限制可以是进程范围的,所以对快速排序的并行调用可以避免创建太多的线程。

我不是c++线程专家,但一旦你解决了线程问题,你会有另一个:

对输入分区的调用没有并行化。该调用开销相当大(它需要对数组进行顺序迭代)。

你可以在维基百科中阅读qsort的并行部分:

http://en.wikipedia.org/wiki/Quicksort并行化

它建议一个简单的解决方案来并行qsort与你的方法大致相同的速度,是将数组分成几个子数组(例如,有多少CPU内核),排序每个并行,并使用归并排序技术合并结果。

有更好的并行排序算法,但它们可能会变得相当复杂。

直接使用线程来编写并行算法,特别是分而治之类型的算法是一个坏主意,你会有很差的可伸缩性,很差的负载平衡,并且你知道线程创建的成本是昂贵的。线程池可以帮助解决后者,但如果不编写额外的代码,就无法解决前者。现在几乎所有的现代并行框架都是基于一个基于任务的工作窃取调度器,例如英特尔的TBB,微软的并发运行时(concert)/PPL。

不是从池中生成线程或重用线程,而是将"任务"(通常是闭包+一些簿记数据)放在工作窃取队列中,在某个时候由X个工作线程中的一个运行。通常情况下,线程的数量等于系统中可用的硬件线程的数量,所以如果你生成/排队数百/数千个任务,这并不重要(在某些情况下确实如此,但取决于上下文)。对于嵌套/分而治之/fork-join并行算法来说,这是一个更好的情况。

对于(嵌套的)数据并行算法,最好避免每个元素产生一个任务,因为通常对单个元素的操作,工作粒度太小,无法获得任何好处,并且被调度器管理的开销所抵消,因此在较低级别的偷工作调度器之上,您有一个更高级别的管理来处理将容器划分为块。这仍然是一个比使用线程/线程池好得多的情况,因为您不再基于最佳线程数进行划分。

无论如何,c++ 11中没有这样标准化的东西,如果你想要一个不添加第三方依赖的纯标准库解决方案,你能做的最好的是:

。尝试使用std::async,像vc++这样的一些实现会在底层使用偷取工作的调度器,但是没有保证,c++标准也不强制这样做。

B。在c++ 11自带的标准线程原语之上编写自己的工作窃取调度程序,这是可行的,但要正确实现并不那么简单。

我会说就用Intel TBB吧,它主要是跨平台的,并提供各种高级并行算法,如并行排序。