【发布时间】:2011-02-16 17:33:13
【问题描述】:
回答另一个 Stack Overflow 问题 (this one) 我偶然发现了一个有趣的子问题。对 6 个整数的数组进行排序的最快方法是什么?
由于问题级别很低:
- 我们不能假设库是可用的(并且调用本身有它的成本),只有普通的 C
- 为避免清空指令流水线(成本非常高),我们可能应该尽量减少分支、跳转和所有其他类型的控制流中断(例如隐藏在
&&中的序列点后面的那些)或||)。 - 空间有限,尽量减少寄存器和内存使用是一个问题,理想情况下,就地排序可能是最好的。
真的,这个问题是一种高尔夫,其目标不是最小化源长度,而是最小化执行时间。我将其称为“Zening”代码,正如Michael Abrash 及其sequels 的书名Zen of Code optimization 中使用的那样。
至于为什么有趣,有好几层:
- 示例简单易懂,易于测量,涉及的 C 技能不多
- 它显示了为问题选择好的算法的效果,以及编译器和底层硬件的效果。
这是我的参考(幼稚,未优化)实现和我的测试集。
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
原始结果
随着变体的数量越来越多,我将它们全部收集在一个测试套件中,可以在here 找到。感谢 Kevin Stock,实际使用的测试比上面显示的要简单一些。您可以在自己的环境中编译和执行它。我对不同目标架构/编译器的行为非常感兴趣。 (好的,伙计们,把它放在答案中,我会为新结果集的每个贡献者 +1)。
一年前我向 Daniel Stutzbach(打高尔夫球)给出了答案,因为他是当时最快解决方案(排序网络)的来源。
Linux 64 位、gcc 4.6.1 64 位、Intel Core 2 Duo E8400、-O2
- 直接调用 qsort 库函数:689.38
- 朴素实现(插入排序):285.70
- 插入排序(Daniel Stutzbach):142.12
- 插入排序展开:125.47
- 排名顺序:102.26
- 寄存器排名顺序:58.03
- 排序网络 (Daniel Stutzbach):111.68
- 排序网络 (Paul R):66.36
- 使用快速交换对网络 12 进行排序:58.86
- 排序网络 12 重新排序交换:53.74
- 排序网络 12 重新排序简单交换:31.54
- 使用快速交换重新排序的排序网络:31.54
- 使用快速交换 V2 重新排序的排序网络:33.63
- 内联冒泡排序 (Paolo Bonzini):48.85
- 展开插入排序 (Paolo Bonzini):75.30
Linux 64 位、gcc 4.6.1 64 位、Intel Core 2 Duo E8400、-O1
- 直接调用 qsort 库函数:705.93
- 朴素实现(插入排序):135.60
- 插入排序 (Daniel Stutzbach):142.11
- 插入排序展开:126.75
- 排名顺序:46.42
- 寄存器排名顺序:43.58
- 排序网络 (Daniel Stutzbach):115.57
- 排序网络 (Paul R):64.44
- 使用快速交换对网络 12 进行排序:61.98
- 排序网络 12 重新排序交换:54.67
- 排序网络 12 重新排序简单交换:31.54
- 带快速交换的重新排序排序网络:31.24
- 使用快速交换 V2 重新排序的排序网络:33.07
- 内联冒泡排序 (Paolo Bonzini):45.79
- 展开插入排序 (Paolo Bonzini):80.15
我同时包含了 -O1 和 -O2 结果,因为令人惊讶的是,对于几个程序而言,O2 的效率低于 O1。不知道具体有什么优化有这个效果?
对建议解决方案的评论
插入排序 (Daniel Stutzbach)
正如预期的那样,最小化分支确实是个好主意。
排序网络 (Daniel Stutzbach)
优于插入排序。我想知道主要效果是否不是来自避免外部循环。我尝试通过展开插入排序进行检查,确实我们得到了大致相同的数字(代码是here)。
排序网络 (Paul R)
迄今为止最好的。我用来测试的实际代码是here。还不知道为什么它的速度几乎是其他排序网络实现的两倍。参数传递?快速最大值?
使用快速交换对网络进行排序 12 SWAP
按照 Daniel Stutzbach 的建议,我将他的 12 交换排序网络与无分支快速交换(代码为 here)结合起来。它确实更快,是迄今为止最好的,利润率很小(大约 5%),正如使用少掉 1 次交换所预期的那样。
有趣的是,无分支交换似乎比在 PPC 架构上使用 if 的简单交换效率低很多(4 倍)。
调用库 qsort
为了提供另一个参考点,我还按照建议尝试调用库 qsort(代码为 here)。正如预期的那样,它要慢得多:慢了 10 到 30 倍......随着新的测试套件变得很明显,主要问题似乎是第一次调用后库的初始加载,它与其他的相比并没有那么差版本。在我的 Linux 上,它只慢了 3 到 20 倍。在其他人用于测试的某些架构上,它似乎甚至更快(我对此感到非常惊讶,因为库 qsort 使用更复杂的 API)。
排名顺序
Rex Kerr 提出了另一种完全不同的方法:对数组中的每一项直接计算其最终位置。这是有效的,因为计算排名顺序不需要分支。这种方法的缺点是它需要三倍于数组的内存量(一份数组和变量的副本来存储排名顺序)。性能结果非常令人惊讶(也很有趣)。在我的 32 位操作系统和 Intel Core2 Quad E8300 的参考架构上,循环计数略低于 1000(如使用分支交换对网络进行排序)。但是当在我的 64 位机器(Intel Core2 Duo)上编译和执行时,它的表现要好得多:它成为迄今为止最快的。我终于找到了真正的原因。我的 32 位机器使用 gcc 4.4.1,而我的 64 位机器使用 gcc 4.4.3,最后一个在优化这个特定代码方面似乎要好得多(其他建议几乎没有区别)。
更新:
正如上面公布的数据所示,更高版本的 gcc 仍然增强了这种效果,并且排名顺序始终是任何其他替代方案的两倍。
使用重新排序的交换对网络 12 进行排序
Rex Kerr 提案与 gcc 4.4.3 的惊人效率让我想知道:内存使用量是 3 倍的程序怎么可能比无分支排序网络更快?我的假设是,它具有较少的读写依赖,从而可以更好地使用 x86 的超标量指令调度程序。这给了我一个想法:重新排序交换以最大程度地减少写入后读取的依赖关系。更简单地说:当您执行SWAP(1, 2); SWAP(0, 2); 时,您必须等待第一次交换完成才能执行第二次交换,因为两者都访问公共存储单元。当您执行SWAP(1, 2); SWAP(4, 5);时,处理器可以并行执行两者。我试过了,它按预期工作,排序网络的运行速度快了大约 10%。
使用简单交换对网络进行排序 12
在最初的帖子 Steinar H. Gunderson 建议一年后,我们不应该试图智取编译器并保持交换代码简单。这确实是一个好主意,因为生成的代码快了大约 40%!他还提出了使用 x86 内联汇编代码手动优化的交换,仍然可以节省更多的周期。最令人惊讶的是(它说明了程序员的心理),一年前没有人尝试过那个版本的交换。我用来测试的代码是here。其他人提出了编写 C 快速交换的其他方法,但它产生的性能与具有良好编译器的简单交换相同。
“最佳”代码如下:
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); \
const int b = max(d[x], d[y]); \
d[x] = a; d[y] = b; }
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
如果我们相信我们的测试集(是的,它很差,它的唯一好处是简短、简单且易于理解我们正在测量的内容),那么生成的代码的平均循环数如下40 个循环(执行 6 个测试)。这使得每次交换平均为 4 个周期。我称之为惊人的快。还有其他可能的改进吗?
【问题讨论】:
-
你对整数有什么限制吗?例如,我们是否可以假设对于任何 2 x,y
x-y和x+y都不会导致下溢或上溢? -
您应该尝试将我的 12-swap 排序网络与 Paul 的无分支交换功能结合起来。他的解决方案将所有参数作为堆栈上的单独元素而不是指向数组的单个指针传递。这也可能会有所作为。
-
请注意,rdtsc 在 64 位上的正确实现是
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");,因为 rdtsc 将答案放在 EDX:EAX 中,而 GCC 期望它在单个 64 位寄存器中。您可以通过在 -O3 处编译来查看该错误。另请参阅下面我对 Paul R 关于更快 SWAP 的评论。 -
@Tyler:你如何在没有分支的情况下在装配级别实现它?
-
@Loren:
CMP EAX, EBX; SBB EAX, EAX将根据EAX分别大于或小于EBX将0 或0xFFFFFFFF 放入EAX。SBB是“借位减法”,与ADC对应(“进位加法”);您引用的状态位 是 进位位。再说一次,我记得ADC和SBB在 Pentium 4 上的延迟和吞吐量与ADD和SUB相比非常糟糕,并且在 Core CPU 上仍然慢两倍。由于80386还有SETccconditional-store和CMOVccconditional-move指令,但是也很慢。
标签: algorithm sorting optimization gpgpu sorting-network