繁体   English   中英

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

[英]Fast merge of sorted subsets of 4K floating-point numbers in L1/L2

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

请假设以下内容:

  • 整套的大小最多为4096件
  • 子集的大小可以讨论,但我们假设最初在16-256之间
  • 通过合并使用的所有数据应该优选地适合L1
  • L1数据高速缓存大小为32K。 16K已经用于数据本身,因此您可以使用16K
  • 所有数据都已经在L1中(具有尽可能高的置信度) - 它刚刚通过一种操作进行操作
  • 所有数据都是16字节对齐的
  • 我们想尽量减少分支(出于显而易见的原因)

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

考虑到上述参数,我很想知道是否有人知道这样做的合理方法! :)

这是一种非常天真的方式。 (请原谅任何凌晨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乱丢循环更有效。

这更像是一个研究课题,但我确实发现本文讨论了使用d-way合并排序最小化分支错误预测。

想到的最明显的答案是使用堆的标准N路合并。 那将是O(N log k)。 子集的数量在16到256之间,因此最坏情况的行为(每个16个项目的256个子集)将是8N。

缓存行为应该......合理,尽管不完美。 大多数操作所在的堆可能始终保留在缓存中。 写入的输出数组部分也很可能位于缓存中。

你拥有的是16K数据(带有排序子序列的数组),堆(1K,最坏情况)和排序输出数组(再次16K),并且你希望它适合32K缓存。 听起来像是一个问题,但也许不是。 最可能被换出的数据是插入点移动后输出数组的前面。 假设已排序的子序列是相当均匀分布的,应该经常访问它们以使它们保持在缓存中。

您可以合并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] [*] in [1])>> 31等于* in [0] - * in [1] <0,相当于[in] [0] <* in [1]。 我之所以用bithift技巧而不是写下来的原因

int i = *in[0] < *in[1];

并非所有编译器都为<version生成分支免费代码。

不幸的是你使用的是浮点数而不是整数,它们最初看起来像是一个showstopper,因为我没有看到如何在[1]分支中自由地实现* [0] <*。 但是,在大多数现代体系结构中,您将正向浮点(也没有NAN,INF或类似的东西)的位模式解释为整数并使用<进行比较,您仍然可以获得正确的结果。 也许你将这个观察延伸到任意浮标。

已经详细研究了SIMD排序算法。 论文在多核SIMD CPU架构上进行排序的高效实现描述了一种有效的算法,用于执行您所描述的内容(以及更多内容)。

核心思想是你可以减少合并两个任意长的列表来合并k个连续值的块(其中k的范围从4到16):第一个块是z[0] = merge(x[0], y[0]).lo 为了获得第二个块,我们知道剩余的merge(x[0], y[0]).hi包含来自x nx元素和来自y ny元素,其中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++

(注意这与通常的合并标量实现有多相似)

在实际实现中,条件跳转当然不是必需的:例如,您可以通过xor技巧有条件地交换xy ,然后无条件地读取*x++

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


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

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

    在此输入图像描述

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

    图

你可以做一个简单的合并内核来合并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路合并结果)。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM