有比qsort更快的排序程序吗?

9
这不是一个算法问题,而是一个实现问题。 我有一个数据结构长这样:
struct MyStruct {
   float val;
   float val2;
   int idx;
}

我需要遍历一个大约有四千万个元素的数组,并将“val”字段分配为该元素,将“idx”字段分配为该元素的索引。

接下来我会调用:

MyStruct* theElements = new MyStruct[totalNum];
qsort(theElements, totalNum, sizeof(MyStruct), ValOrdering);

然后,一旦我填写了val2,就可以使用相反的过程进行操作。

qsort(theElements, totalNum, sizeof(MyStruct), IndexOrdering);

哪里

static int ValOrdering(const void* const v1, const void* const v2)
{
  if (((struct MyStruct*) v1)->val < ((struct MyStruct*) v2)->val)
    return -1;

  if (((struct MyStruct*) v1)->val> ((struct MyStruct*) v2)->val)
    return 1;

  return 0;
}

static int IndexOrdering(const void* const v1, const void* const v2)
{
  return ((struct MyStruct*) v1)->idx- ((struct MyStruct*) v2)->idx;
}

这个设置需要4秒钟来执行两种排序。在3Ghz i5处理器上对4000万个元素进行排序,4秒钟似乎是一个很长的时间; 是否有更快的方法?我正在使用带有Intel编译器的vs2010(具有排序功能,但似乎不支持此类结构体)。

更新:使用std :: sort可以缩短大约0.4秒的运行时间,调用方式如下:
std::sort(theElements, theElements + totalPixels, ValOrdering);
std::sort(theElements, theElements + totalPixels, IndexOrdering);

并且

bool GradientOrdering(const MyStruct& i, const MyStruct& j){
    return i.val< j.val;
}
bool IndexOrdering(const MyStruct& i, const MyStruct& j){
    return i.idx< j.idx;
}

在谓词中添加“inline”关键字似乎没有关系。由于我有一个四核机器,规范也允许,所以下一步我将尝试某种多线程排序。

更新2:在@SirGeorge和@stark的建议下,我查看了通过指针重定向完成的单个排序:

bool GradientOrdering(MyStruct* i, MyStruct* j){
    return i->val< j->val;
}
bool IndexOrdering(MyStruct* i, MyStruct* j){
    return i->idx< j->idx;
} 

尽管只有一次排序调用(到GradientOrdering例程),但结果算法需要5秒,比qsort方法多1秒。看起来std::sort暂时是胜利者。

更新3:看起来英特尔的tbb::parallel_sort是获胜者,在我的系统上将单个排序的运行时间降至0.5秒(因此,两个排序为1.0秒,这意味着它从最初的4.0秒缩放得非常好)。我尝试使用Microsoft提出的并行花哨方法here,但由于我已经在使用tbb,而且parallel_sort的语法与std::sort的语法相同,因此我可以使用早期的std::sort比较器来完成所有工作。
我还使用了@gbulmer的建议(真的是敲醒我头脑的意识),即我已经有了原始索引,所以不需要进行第二次排序,我只需要将第一个数组中的索引分配到第二个数组中,并按照排序顺序进行排序。我可以利用这种内存使用,因为我只在至少拥有4 GB RAM的64位机器上部署(提前工作出这些规格是很好的); 没有这些知识,第二次排序将是必要的。
@gbulmer的建议提供了最大的加速,但原始问题询问最快的排序。 std :: sort是最快的单线程,parallel_sort是最快的多线程,但没有人给出这个答案,所以我把检查交给了@gbulmer。

3
std::sort = 更多的类型信息和更多的内联机会。 - James McNellis
你可以尝试使用多线程归并排序。 - manasij7479
3
您了解数据分布吗?还是完全随机的? - Milan Babuškov
@JamesMcNellis-- 感谢你的提示,我现在正在检查它。 - mmr
idx 的值是多少?它是否为数组中的原始位置?idx 是否会改变?如果不会,就没有必要进行第二次排序。请参见我的答案。 - gbulmer
显示剩余4条评论
6个回答

14

通常情况下,位于中的C++的std::sort将超过qsort,因为它允许编译器优化掉函数指针上的间接调用,并且使编译器更容易执行内联。然而,这只会得到一个恒定的加速;qsort已经使用了非常快的排序算法。

请注意,如果您决定切换到std::sort,则比较函数对象将需要更改。 std::sort接受简单的小于比较运算符返回bool,而std::qsort接受一个根据输入返回-1、0或1的函数对象。


常数时间加速很好;我想O(N log N)的转换操作不是免费的。我会检查一下,谢谢。 - mmr
@mmr:实际上,类型转换可能是免费的。毕竟,你只是让编译器将指针解释为不同类型的指针,而不是执行整数转换或类似操作。 - Billy ONeal
@mmr 实际上,类型转换操作并不会产生任何开销(除非涉及到多态类型或需要转换的类型,例如从浮点数到整数等),考虑到类型和类型系统仅存在于编译时。 - Seth Carnegie
1
@SethCarnegie:另一个需要进行类型转换的地方可能是类似于float->int或int->float之类的情况。 - Billy ONeal
@BillyONeal 这是真的,我只考虑了指针转换。但你是对的,我修改了我的评论。 - Seth Carnegie
显示剩余2条评论

5

与缓存相比,数据集非常庞大,因此它将受到缓存到内存的限制。

使用间接寻址会使情况变得更糟,因为指针有缓存,而且内存访问的顺序更加随机,即与邻居进行比较。程序正在针对CPU中的任何预取机制进行工作。

考虑将结构拆分为两个结构,在两个数组中。

作为一个实验,将第一次传递与仅传递一个结构{ float val; int idx; }进行比较。

如果它是缓存和带宽绑定的,则应该会有显着的差异。

如果缓存局部性是一个关键问题,可能值得考虑多路合并或Shell排序;任何可以改善局部性的方法都可以尝试。

尝试对记录的缓存大小子集进行排序,然后进行多路归并排序(可能值得查看处理器缓存管理器规范,以了解它是否清楚地说明了它尝试预测的预取流的数量。同样,通过减小从RAM流入的结构体的大小来减小数据集的大小可能会获胜。

idx字段是如何派生的?听起来它是数组中的原始位置。它是原始记录的索引吗?

如果是这种情况,只需分配第二个数组,并将第一个复制到第二个中:

struct { float val; float val2; int idx } sortedByVal[40000000];
struct { float val; float val2 } sortedbyIdx[40000000];

for (int i=0; i<40000000; ++i) {
    sortedbyIdx[sortedByVal[i].idx].val = sortedByVal[i].val;
    sortedbyIdx[sortedByVal[i].idx].val2 = sortedByVal[i].val2;
}

没有第二种排序方法。如果是这种情况,将val2值的分配与此传递合并。

编辑

我很好奇相对性能,所以我编写了一个程序来比较“库”C排序函数,qsort、mergesort、heapsort,并将排序与复制到idx进行比较。它还重新排序已排序的值,以便更好地处理。这也非常有趣。我没有实现和测试Shell排序,在实践中经常击败qsort。

该程序使用命令行参数选择哪种排序方式,以及是否按idx排序,或者只是复制。代码:http://pastebin.com/Ckc4ixNp

运行时间的抖动非常明显。我应该使用CPU时钟,进行多次运行,并呈现更好的结果,但那是“读者的练习”。

我在一台旧的MacBook Pro 2.2GHz Intel Core 2 Duo上运行了这个程序。其中一些时间是OS C特定的。

计时(稍作格式化):

qsort(data, number-of-elements=40000000, element-size=12)
Sorting by val - duration =            16.304194
Re-order to idx by copying - duration = 2.904821
Sort in-order data - duration =         2.013237
Total duration = 21.222251
User Time:       20.754574
System Time:      0.402959

mergesort(data, number-of-elements=40000000, element-size=12)
Sorting by val - duration =            25.948651
Re-order to idx by copying - duration = 2.907766
Sort in-order data - duration =         0.593022
Total duration = 29.449438
User Time:       28.428954
System Time:      0.973349

heapsort(data, number-of-elements=40000000, element-size=12)
Sorting by val - duration =            72.236463
Re-order to idx by copying - duration = 2.899309
Sort in-order data - duration =        28.619173
Total duration = 103.754945
User Time:       103.107129
System Time:       0.564034
警告: 这些是单次运行。需要多次运行才能得出合理的统计数据。
Pastebin上的代码实际上对“减小大小”的8字节数组进行排序。在第一次排序时,只需要val和idx,在添加val2时数组被复制,因此第一个数组中不需要val2。这种优化使排序函数复制更小的结构体,并在缓存中适合更多的结构体,这是很好的。我很失望这只能让qsort获得了几个百分点的提升。我解释这意味着qsort快速地将被排序的块变成了符合缓存大小的尺寸。
同样的减小大小策略可以使堆排序获得超过25%的提升。
针对8字节结构体的计时,没有val2:
qsort(data, number-of-elements=40000000, element-size=8)
Sorting by val - duration =            16.087761
Re-order to idx by copying - duration = 2.858881
Sort in-order data - duration =         1.888554
Total duration = 20.835196
User Time:       20.417285
System Time:      0.402756

mergesort(data, number-of-elements=40000000, element-size=8)
Sorting by val - duration =            22.590726
Re-order to idx by copying - duration = 2.860935
Sort in-order data - duration =         0.577589
Total duration = 26.029249
User Time:       25.234369
System Time:      0.779115

heapsort(data, number-of-elements=40000000, element-size=8)
Sorting by val - duration =            52.835870
Re-order to idx by copying - duration = 2.858543
Sort in-order data - duration =        24.660178
Total duration = 80.354592
User Time:       79.696220
System Time:      0.549068

警告: 这些是单次运行。需要多次运行才能获得合理的统计数据。


完全进行这种转换会彻底打败OP最初进行排序的原因。此外,这些数据无论如何都不适合缓存,所以我怀疑你将为此付出代价。(即使有4000万个整数,也将占用160MB) - Billy ONeal
@Billy ONeal - 我拆分结构体的建议首先是为了获得一些证据。我认为一些统计数据会有助于讨论。如果排序是缓存和内存带宽受限,那么减小数据的大小可能会产生很大的影响。这个实验应该花费几十分钟来尝试。如果它显示出有实质性的影响,那么基于这个选择排序是值得的。 - gbulmer
如果缩小数据的大小不能解决问题,那么这样做是没有用的。而舍弃一半的数据则可以达到这个目的。 - Billy ONeal
@Billy ONeal - 同意,快速和错误=错误。我还没有阅读过导致您得出结论的过程的解释。问题说“遍历数组...,将'val'字段分配为元素,将'idx'字段分配为索引。”此时,没有val2。对数组进行排序,“然后,一旦我填写了val2,就会反转该过程”。反转该过程会重新排序idx顺序。据我所知,我们不知道idx顺序是什么。也许可以在不进行排序的情况下恢复原始idx顺序。我可能是错的,但OP需要解释idx的价值才能知道。 - gbulmer
显然,idx只是原始顺序。消除第二个排序可以将NlogN转换为N。 - stark

3

当按索引排序时,基数排序可能比快速排序更快。你可能希望在2的幂次方的基础上进行排序(这样你可以使用位运算而不是模除)。


3
+1 -- 但是:基数排序在渐近意义下更快,但在实际实现中通常具有相当可怕的常数因子。值得尝试,但不要认为去掉那个额外的 lg n 是一个巨大的好处。大多数编程语言没有将基数排序包含在它们的标准库中也是有原因的。 - Billy ONeal

3

std::sort()应该比现在快10%以上。但是,你需要两件事情:

  1. 使用函数指针需要编译器进行英雄般的检测,以确定该函数可以内联。具有内联函数调用运算符的函数对象相对容易内联。
  2. 在调试模式下,std::sort()的核心不会被优化,而qsort()则会被大量优化:尝试在发布模式下编译。

1

所有排序算法都是已知的并且可用。它们很容易实现。对它们进行基准测试。

快速排序可能不是在所有情况下最快的算法,但平均而言它相当高效。然而,排序4000万条记录在3-4秒内完成也并非罕见。

编辑

我将总结我的评论:已经证明,在图灵(这里拼写正确!!!)模型下,比较排序算法受到Ω(n log n)的限制。因此,在复杂度方面,改进的空间不大,但魔鬼在细节中。要发现复杂度等效算法之间的性能差异,需要对它们进行基准测试并查看结果。

然而,如果您对数据有一些附加知识(例如- idx将在某个预设的相对较小的范围内),则可以使用非比较排序的算法,并具有复杂度改进。您仍应进行基准测试,以确保改进实际上正在发生在您的数据中,但对于大量数据,Ω(n log n)和Ω(n)之间的差异可能会很明显。这样算法的一个例子是桶排序。

有关更全面的列表和复杂度分析,请从此处开始。


4
如何确定所有排序算法都已知? - Seth Carnegie
@SethCarnegie,事实上已经证明,在没有数据的特定先前知识(即原始排序)的情况下,您无法在旅行模型中以低于O(NLogN)的速度进行排序,因此即使有其他算法可以发现,复杂性仍然相同。我的观点是,现在需要基准测试来决定哪种方法对OP更快。 - littleadv
@littleadv:是图灵(Turing),不是旅游(Touring) :) - Billy ONeal
那么,你有特定的算法想法吗?否则,这个回答对帮助来说不是很有用。 - mmr
@mmr - 有很多种排序算法,你可以对它们进行基准测试,或选择最适合你的。 归并排序,快速排序,基数排序,堆排序等等,你命名它。 如果您能找到排序数据的某些缩小属性,则可以通过使用桶排序等算法来提高性能。 这里是全面的列表:http://en.wikipedia.org/wiki/Sorting_algorithm - littleadv

1

你现在正在排序结构体数组,这意味着数组中的每个交换都至少是两个赋值(复制整个结构体)。你可以尝试对指向结构体的指针数组进行排序,这将节省大量复制(只需复制指针),但会使用更多内存。指针数组排序的另一个优点是,您可以拥有其中的几个(每个以不同方式排序)-再次需要更多内存。但是,额外的指针间接引用可能很昂贵。您还可以尝试同时使用其他人提出的两种方法:使用指向指针数组的std::qsort,并查看在您的情况下是否有任何加速。


每个比较都需要通过指针进行额外的间接引用。所以虽然我同意这值得尝试,但可能不会有帮助,甚至可能会有害。 - Nemo
这里的一个优势是您不必进行两次排序,因为您可以保留原始数组。 - stark
他也可以同时尝试两种方法:使用C++ std::qsort和指针数组,看看是否有任何好处。 - sirgeorge
将Billy ONeal所说的一切内容加上缓存消耗和缓存带宽。当结构体直接排序时,相邻元素很可能在同一个缓存行中。但是使用指向元素的指针会导致真正的缓存瓶颈。数据集比缓存大得多,因此可能是缓存/内存带宽成为了性能杀手。 - gbulmer
@gbulmer 我不是在反驳Billy ONeal所说的话,但我不同意你所说的。你所说的“邻居”究竟是什么意思?在qsort的情况下,你正在访问数组中相距相当远的元素。我还写了,在他的情况下这是一个实验的问题,并指出了我的建议的优缺点。再次强调:在他的情况下,实验将有所帮助,而不是学术上的讨论。 - sirgeorge
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接