在现代(SSE2 +)x86处理器上合并多达4096个32位浮点数的数组的已排序子集的快速方法是什么?
请假设以下内容:
可行性的主要标准:比in-L1 LSD基数排序更快。
我很想知道在给定上述参数的情况下是否有人知道合理的方法! :)
答案 0 :(得分:8)
这是一种非常天真的方式。 (请原谅任何凌晨4点谵妄引起的伪代码错误;)
//4x sorted subsets
data[4][4] = {
{3, 4, 5, INF},
{2, 7, 8, INF},
{1, 4, 4, INF},
{5, 8, 9, INF}
}
data_offset[4] = {0, 0, 0, 0}
n = 4*3
for(i=0, i<n, i++):
sub = 0
sub = 1 * (data[sub][data_offset[sub]] > data[1][data_offset[1]])
sub = 2 * (data[sub][data_offset[sub]] > data[2][data_offset[2]])
sub = 3 * (data[sub][data_offset[sub]] > data[3][data_offset[3]])
out[i] = data[sub][data_offset[sub]]
data_offset[sub]++
修改强>
使用AVX2及其聚集支持,我们可以同时比较多达8个子集。
编辑2:
根据类型转换,有可能在Nehalem上每次迭代削减3个额外的时钟周期(mul:5,shift + sub:4)
//Assuming 'sub' is uint32_t
sub = ... << ((data[sub][data_offset[sub]] > data[...][data_offset[...]]) - 1)
编辑3:
通过使用两个或更多max
值,可能会在某种程度上利用无序执行,尤其是当K变大时:
max1 = 0
max2 = 1
max1 = 2 * (data[max1][data_offset[max1]] > data[2][data_offset[2]])
max2 = 3 * (data[max2][data_offset[max2]] > data[3][data_offset[3]])
...
max1 = 6 * (data[max1][data_offset[max1]] > data[6][data_offset[6]])
max2 = 7 * (data[max2][data_offset[max2]] > data[7][data_offset[7]])
q = data[max1][data_offset[max1]] < data[max2][data_offset[max2]]
sub = max1*q + ((~max2)&1)*q
编辑4:
根据编译器的智能,我们可以使用三元运算符完全删除乘法:
sub = (data[sub][data_offset[sub]] > data[x][data_offset[x]]) ? x : sub
编辑5:
为了避免代价高昂的浮点数比较,我们可以简单地reinterpret_cast<uint32_t*>()
数据,因为这会导致整数比较。
另一种可能性是利用SSE寄存器,因为它们没有输入,并明确使用整数比较指令。
这是因为操作符< > ==
在解释二进制级别的浮点时产生相同的结果。
编辑6:
如果我们充分展开循环以使值的数量与SSE寄存器的数量相匹配,我们就可以对正在进行比较的数据进行分级。
在迭代结束时,我们将重新传输包含所选最大/最小值的寄存器,并将其移位。
虽然这需要稍微重新编写索引,但它可能比使用LEA
乱丢循环更有效。
答案 1 :(得分:3)
这更像是一个研究课题,但我确实发现this paper讨论了使用d-way合并排序最小化分支错误预测。
答案 2 :(得分:2)
最明显的答案是使用堆的标准N路合并。那将是O(N log k)。子集的数量在16到256之间,因此最坏的情况(每个16个项目有256个子集)将是8N。
缓存行为应该......合理,但并不完美。大多数操作所在的堆可能始终保留在缓存中。写入的输出数组部分也很可能位于缓存中。
你所拥有的是16K数据(具有已排序子序列的数组),堆(1K,最差情况)和排序输出数组(再次为16K),并且您希望它适合32K缓存。听起来像是一个问题,但也许不是。最可能被换出的数据是插入点移动后输出数组的前面。假设已排序的子序列分布相当均匀,应该经常访问它们以使它们保持在缓存中。
答案 3 :(得分:2)
您可以合并int数组(昂贵)分支。
typedef unsigned uint;
typedef uint* uint_ptr;
void merge(uint*in1_begin, uint*in1_end, uint*in2_begin, uint*in2_end, uint*out){
int_ptr in [] = {in1_begin, in2_begin};
int_ptr in_end [] = {in1_end, in2_end};
// the loop branch is cheap because it is easy predictable
while(in[0] != in_end[0] && in[1] != in_end[1]){
int i = (*in[0] - *in[1]) >> 31;
*out = *in[i];
++out;
++in[i];
}
// copy the remaining stuff ...
}
注意(* [in]中的* - [1]中的*)&gt;&gt; 31等于[0]中的* - * [1]&lt; 0等于* [0]&lt; *在[1]。我之所以用bithift技巧而不是
写下来的原因int i = *in[0] < *in[1];
并非所有编译器都为&lt;版本
不幸的是你使用的是浮点数而不是整数,它们起初看起来像是一个showstopper因为我没有看到如何在[0]&lt;中重新实现*。 *在[1]分支免费。但是,在大多数现代体系结构中,您将正浮点数(也不是NAN,INF或类似的东西)的位模式解释为整数,并使用&lt;来比较它们。你仍然会得到正确的结果。也许你将这个观察延伸到任意浮标。
答案 4 :(得分:2)
核心思想是你可以减少合并两个任意长的列表来合并 k 连续值的块(其中 k 的范围可以从4到16):第一个阻止是z[0] = merge(x[0], y[0]).lo
。为了获得第二个块,我们知道剩余的merge(x[0], y[0]).hi
包含来自nx
的{{1}}和x
元素中的ny
个元素,以及y
。但是nx+ny == k
不能包含z[1]
和x[1]
中的元素,因为这需要y[1]
包含多个z[1]
元素:所以我们只需要找出需要添加nx+ny
和x[1]
中的哪一个。具有较低第一个元素的那个必然首先出现在y[1]
中,所以这只是通过比较它们的第一个元素来完成的。我们只是重复一遍,直到没有更多的数据要合并。
伪代码,假设数组以z
值结束:
+inf
(请注意这与通常的合并标量实现类似)
在实际实现中,条件跳转当然不是必需的:例如,您可以通过a := *x++
b := *y++
while not finished:
lo,hi := merge(a,b)
*z++ := lo
a := hi
if *x[0] <= *y[0]:
b := *x++
else:
b := *y++
技巧有条件地交换x
和y
,然后无条件地读取{{1 }}
xor
本身可以用bitonic排序实现。但是如果 k 很低,则会有很多指令间依赖性导致高延迟。根据您必须合并的阵列数量,您可以选择 k 足够高,以便屏蔽*x++
的延迟,或者如果可以交错几个双向合并。有关详细信息,请参阅该文章。
编辑:下面是 k = 4时的图表。所有渐近线都假设 k 是固定的。
大灰色框正在合并两个大小 n = m * k 的数组(在图片中, m = 3)。
最后,为了扩展我们的双向合并以合并多个阵列,我们以经典的分治方式安排了大灰盒子。每个级别的元素数量都具有线性复杂度,因此使用 n log( n / n0 )) > n0 排序数组的初始大小, n 是最终数组的大小。
答案 5 :(得分:1)
你可以做一个简单的合并内核来合并K列表:
float *input[K];
float *output;
while (true) {
float min = *input[0];
int min_idx = 0;
for (int i = 1; i < K; i++) {
float v = *input[i];
if (v < min) {
min = v; // do with cmov
min_idx = i; // do with cmov
}
}
if (min == SENTINEL) break;
*output++ = min;
input[min_idx]++;
}
没有堆,所以很简单。坏的部分是它是O(NK),如果K很大则可能是坏的(不像堆的实现是O(N log K))。那么你只需选择一个最大K(4或8可能是好的,然后你可以展开内循环),并通过级联合并做更大的K(通过对列表组进行8向合并来处理K = 64,然后是8方式合并结果)。