快速合并L1 / L2中4K浮点数的排序子集

时间:2012-07-18 22:35:39

标签: c++ c performance algorithm assembly

在现代(SSE2 +)x86处理器上合并多达4096个32位浮点数的数组的已排序子集的快速方法是什么?

请假设以下内容:

  • 整套的大小最多为4096件
  • 子集的大小可以讨论,但我们假设最初在16-256之间
  • 通过合并使用的所有数据最好都适合L1
  • L1数据缓存大小为32K。 16K已经用于数据本身,所以你有16K可以玩
  • 所有数据已经​​在L1中(尽可能高度自信) - 它刚刚被排序操作
  • 所有数据均为16字节对齐
  • 我们希望尽量减少分支(显而易见的原因)

可行性的主要标准:比in-L1 LSD基数排序更快。

我很想知道在给定上述参数的情况下是否有人知道合理的方法! :)

6 个答案:

答案 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)

已经详细研究了SIMD排序算法。论文Efficient Implementation of Sorting on Multi-Core SIMD CPU Architecture描述了一种有效的算法,用于执行您描述的内容(以及更多内容)。

核心思想是你可以减少合并两个任意长的列表来合并 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+nyx[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++ 技巧有条件地交换xy,然后无条件地读取{{1 }}

xor本身可以用bitonic排序实现。但是如果 k 很低,则会有很多指令间依赖性导致高延迟。根据您必须合并的阵列数量,您可以选择 k 足够高,以便屏蔽*x++的延迟,或者如果可以交错几个双向合并。有关详细信息,请参阅该文章。


编辑:下面是 k = 4时的图表。所有渐近线都假设 k 是固定的。

  • 大灰色框正在合并两个大小 n = m * k 的数组(在图片中, m = 3)。

    enter image description here

    1. 我们对大小 k 的块进行操作。
    2. “整块合并”框通过比较它们的第一个元素,逐块合并两个数组。这是一个线性时间操作,它不消耗内存,因为我们将数据流式传输到块的其余部分。性能并不重要,因为延迟将受到“merge4”块延迟的限制。
    3. 每个“merge4”框合并两个块,输出较低的 k 元素,并将上层 k 元素提供给下一个“merge4”。每个“merge4”框执行有限数量的操作,“merge4”的数量在 n 中是线性的。
    4. 因此合并的时间成本在 n 中是线性的。而且因为“merge4”的延迟低于执行8次串行非SIMD比较,所以与非SIMD合并相比,速度会有很大提升。
  • 最后,为了扩展我们的双向合并以合并多个阵列,我们以经典的分治方式安排了大灰盒子。每个级别的元素数量都具有线性复杂度,因此使用 n log( n / n0 )) > n0 排序数组的初始大小, n 是最终数组的大小。

    diagram

答案 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方式合并结果)。