我正在为D编程语言开发并行化库。现在我对基本原语(并行foreach,map,reduce和tasks / futures)非常满意,我开始考虑一些更高级别的并行算法。并行化的更明显的候选者之一就是排序。
我的第一个问题是,在现实世界中有用的排序算法的并行版本,还是主要是学术性的?如果它们有用,它们在哪里有用?我个人很少在我的工作中使用它们,仅仅是因为我通常使用比单一sort()调用更粗糙的并行级别将我的所有核心100%挂起。
其次,似乎快速排序对于大型阵列来说几乎是令人尴尬的并行,但我无法获得接近线性的加速,我相信我应该得到。对于快速排序,唯一固有的串行部分是第一个分区。我尝试并行化快速排序,在每个分区之后,并行排序两个子阵列。在简化的伪代码中:
// I tweaked this number a bunch. Anything smaller than this and the
// overhead is smaller than the parallelization gains.
const smallestToParallelize = 500;
void quickSort(T)(T[] array) {
if(array.length < someConstant) {
insertionSort(array);
return;
}
size_t pivotPosition = partition(array);
if(array.length >= smallestToParallelize) {
// Sort left subarray in a task pool thread.
auto myTask = taskPool.execute(quickSort(array[0..pivotPosition]));
quickSort(array[pivotPosition + 1..$]);
myTask.workWait();
} else {
// Regular serial quick sort.
quickSort(array[0..pivotPosition]);
quickSort(array[pivotPosition + 1..$]);
}
}
即使对于非常大的阵列,第一个分区所用的时间可以忽略不计,与纯粹的串行版本的算法相比,我只能在双核上获得大约30%的加速。我猜测瓶颈是共享内存访问。有关如何消除这个瓶颈或瓶颈可能是什么的任何见解?
编辑:我的任务池具有固定数量的线程,等于系统中的核心数减1(因为主线程也可以工作)。此外,我正在使用的等待类型是工作等待,即如果任务已启动但未完成,则调用workWait()
的线程会从池中窃取其他作业并执行它们,直到它等待的任务完成为止。如果任务未启动,则在当前线程中完成。这意味着等待效率不高。只要有工作要做,所有线程都将保持忙碌。
答案 0 :(得分:7)
请记住,我不是平行排序方面的专家,人们将研究职业排除在平行排序之外......但
1)它们在现实世界中是否有用。
当然它们是,如果你需要对昂贵的东西(比如字符串或更糟糕的东西)进行排序,而你并不挂钩所有核心。2)Quicksort似乎会给出线性加速,但事实并非如此。分区步骤是一个连续的瓶颈,如果你进行分析,你会看到这个,并且它会在四核心上达到2-3倍。
如果你想在较小的系统上获得良好的加速,你需要确保你的每个任务开销非常小,理想情况下你需要确保没有太多的线程在运行,即不超过2个在双核上。线程池可能不是正确的抽象。
如果你想在更大的系统上获得更好的加速,你需要查看基于扫描的并行排序,有关于此的论文。比特排序也很容易并行化。并行基数排序也很有用,PPL中有一个(如果你不反对Visual Studio 11)。
答案 1 :(得分:3)
我不是专家但......这就是我要看的:
首先,我听说根据经验,从一开始就看一小部分问题的算法往往更适合作为并行算法。
看看你的实现,尝试使并行/串行开关走另一条路:对数组进行分区并并行排序,直到你有N个段,然后去串行。如果你或多或少地为每个并行案例抓取一个新线程,那么N应该是你的核心数。 OTOH如果您的线程池具有固定大小并充当短期代表的队列,那么我将使用核心数量的N~2倍(以便核心不会因为一个分区更快完成而处于空闲状态)。
其他调整:
myTask.wait();
,而不是有一个等待所有任务的包装函数。答案 2 :(得分:1)
“我的第一个问题是,在现实世界中有用的排序算法的并行版本” - 取决于您在实际工作中正在处理的数据集的大小。对于小数据集,答案是否定的。对于较大的数据集,它不仅取决于数据集的大小,还取决于系统的特定体系结构。
阻止预期性能提升的限制因素之一是系统的缓存布局。如果数据可以适合核心的L1缓存,那么通过在多个核心之间进行排序几乎没有什么收获,因为在每次迭代排序算法之间会导致L1缓存未命中的惩罚。
同样的理由适用于具有多个L2缓存和NUMA(非统一内存访问)体系结构的芯片。因此,您希望分配排序的核心越多,则需要相应地增加smallestToParallelize常量。
您确定的另一个限制因素是共享内存访问或内存总线争用。由于内存总线每秒只能满足一定数量的内存访问;除了对主内存进行读写操作之外,其他内核基本上什么都不会给内存系统带来很大的压力。
我应该指出的最后一个因素是线程池本身,因为它可能没有您想象的那么高效。因为您拥有从共享队列中窃取并生成工作的线程,所以该队列需要同步方法;并且根据这些方法的实现方式,它们可能会在代码中导致非常长的连续部分。
答案 3 :(得分:1)
我不知道这里的答案是否适用,或者我的建议是否适用于D.
无论如何......
假设D允许它,总是有可能向缓存提供预取提示。有问题的核心要求将很快(不是立即)的数据加载到某个缓存级别。在理想情况下,数据将在核心开始工作时获取。更有可能的是,预取过程或多或少会在某种程度上导致等待状态少于数据被“冷”的情况。
您仍然会受到整体缓存到RAM吞吐量容量的限制,因此您需要组织数据,以便在核心的独占缓存中存储如此多的数据,以便在那里花费相当长的时间在必须写更新数据之前。
代码和数据需要根据缓存行的概念(每个64字节的获取单位)进行组织,缓存行是缓存中最小的单元。这应该导致对于两个内核,需要组织工作,使得内存系统每个内核工作一半(假设100%可伸缩性),就像之前只有一个内核工作且工作尚未组织一样。四个核心的四分之一等等。这是一个相当大的挑战,但绝不是不可能的,它只取决于你在重组工作中的想象力。与往常一样,有些解决方案无法构思......直到某人做到这一点!
我不知道WYSIWYG D与C的比较 - 我使用它 - 但总的来说,我认为开发可扩展应用程序的过程可以通过开发人员在其实际机器代码生成中影响编译器的程度得到改善。对于解释型语言,解释器会进行大量的内存工作,以至于您无法识别一般“背景噪音”的改进。
我曾经写过一个多线程的shellort,在两个内核上运行速度比一个运行速度快70%,而在三个核心运行速度比一个核心运行速度快100%。四个核心比三个核心慢。所以我知道你面临的两难困境。
答案 4 :(得分:0)
我想指出面向类似问题的外部排序[1]。通常,这类算法主要用于处理大量数据,但它们的主要观点是它们将大块分成较小且不相关的问题,因此并行运行非常好。你“只”需要将之后的部分结果拼接在一起,这不是那么平行(但与实际排序相比相对便宜)。
外部合并排序对于未知数量的线程也可以很好地工作。您只需任意拆分工作负载,并在每个空闲时将每个元素的n个元素分配给一个线程,直到所有工作单元都完成为止,此时您可以开始加入它们。