使用AVX对64位结构进行排序?

时间:2015-07-18 01:42:25

标签: c++ intrinsics avx

我有一个64位结构,它代表几个数据,其中一个是浮点值:

struct MyStruct{
    uint16_t a;
    uint16_t b;
    float f;
}; 

我有四个这样的结构,比如std::array<MyStruct, 4>

是否可以使用AVX对浮动成员MyStruct::f进行数组排序?

2 个答案:

答案 0 :(得分:12)

对不起这个答案很乱;它并没有立刻写出来而且我很懒惰。有一些重复。

我有4个不同的想法:

  1. 正常排序,但将结构移动为64位单位
  2. 矢量化插入排序作为qsort的构建块
  3. 使用cmpps / blendvpd代替minps / maxps的比较器实现对网络进行排序。但是额外的开销可能会导致加速。

  4. 对网络进行排序:加载一些结构,然后进行混洗/混合以获得一些只有浮点数的寄存器和一些仅有效负载的寄存器。使用Timothy Furtak做正常minps / maxps比较器然后cmpeqps min,orig - &gt;的技术有效载荷上的掩码xor-swap。这为每个比较器分配了两倍的数据,但确实需要在比较器之间的两个寄存器上进行匹配混洗。当你完成时还需要重新交错(但是如果你安排你的比较器那么你可以安排你的比较器,那么这些内部解包将以正确的顺序放置最终数据)。

    这也避免了某些CPU在对有效负载中的位模式进行FP比较时可能会出现的减速,这些位模式表示非正规,NaN或无穷大,而不需要在MXCSR中设置非正规零位。

    Furtak的论文建议在使用向量进行大部分排序后进行标量清理,这样可以减少洗牌次数。

  5. 正常排序

    使用常规排序算法时,通过使用64位加载/存储移动整个结构,并在FP元素上进行标量FP比较,可以获得至少一个小的加速。为了使这个想法尽可能地工作,首先使用浮点值对结构进行排序,然后可以movq将整个结构放入xmm reg ,并且浮点值将在ucomiss的低32。然后,您(或者可能是智能编译器)可以使用movq存储结构。

    查看Kerrek SB链接到的asm输出,编译器似乎在高效复制结构方面做得相当糟糕:

    icc似乎分别移动两个uint值,而不是在64b加载中挖掘整个结构。也许它没有包装结构? gcc 5.1在大多数情况下似乎没有这个问题。

    加快插入排序

    对于足够小的问题,大排序通常会通过插入排序进行分而治之。 Insertion sort将数组元素复制为一个,仅在我们发现我们已到达当前元素所属的位置时停止。所以我们需要将一个元素与一个打包元素序列进行比较,如果对任何元素的比较为真,则停止。你闻到了矢量吗?我闻到了矢量。

    # RSI points to  struct { float f; uint... payload; } buf[];
    # RDI points to the next element to be inserted into the sorted portion
    # [ rsi to rdi ) is sorted, the rest isn't.
    ##### PROOF OF CONCEPT: debug / finish writing before using!  ######
    
    .new_elem:
    vbroadcastsd ymm0, [rdi]      # broadcast the whole struct
    mov rdx, rdi
    
    .search_loop:
        sub        rdx, 32
        vmovups    ymm1, [rdx]    # load some sorted data
        vcmplt_oqps ymm2, ymm0, ymm1   # all-ones in any element where ymm0[i] < ymm1[i] (FP compare, false if either is NaN).
        vmovups    [rdx+8], ymm1  # shuffle it over to make space, usual insertion-sort style
        cmp        rdx, rsi
        jbe     .endsearch        # below-or-equal (addresses are unsigned)
        movmskps   eax, ymm2
        test       al, 0b01010101 # test only the compare results for 
    
        jz      .search_loop      # [rdi] wasn't less than any of the 4 elements
    
    .endsearch:
    # TODO: scalar loop to find out where the new element goes.
    #  All we know is that it's less than one of the elements in ymm1, but not which
    add           rdi, 8
    vmovsd         [rdx], ymm0
    cmp           rdi, r8   # pointer to the end of the buf
    jle           .new_elem
    
      # worse alternative to movmskps / test:
      # vtestps    ymm2, ymm7     # where ymm7 is loaded with 1s in the odd (float) elements, and 0s in the even (payload) elements.
      # vtestps is like PTEST, but only tests the high bit.  If the struct was in the other order, with the float high, vtestpd against a register of all-1s would work, as that's more convenient to generate.
    

    这肯定充满了bug,我应该用C语言写一下内部函数。

    这是一个插入排序,其开销可能比大多数都要大,由于处理前几个元素(不要填充向量)的额外复杂性,可能会因为非常小的问题大小而失去标量版本,并且在突破检查多个元素的向量搜索循环之后确定将新元素放在何处。

    可能对循环进行流水线操作,这样我们就不会存储ymm1直到下一次迭代(或者在爆发之后)将保存冗余存储。通过移位/混洗它们来进行寄存器中的比较,而不是字面上进行标量加载/比较可能是一个胜利。这可能最终会导致太多不可预测的分支,而且我没有看到一个很好的方式来结束vmovups的注册表中的高4,以及vmovsd的另一个注册表中的低4。 1}}。

    我可能已经发明了一种插入类型,它是两个世界中最糟糕的:由于在打破搜索循环后会有更多的工作,因此对于小型阵列来说速度很慢,但它仍然是插入类型:数组因为O(n ^ 2)。但是,如果searchloop之外的代码可以变得非常糟糕,那么这可能是qsort / mergesort的小数组端点。

    无论如何,如果有人将这个想法发展成实际的调试和工作代码,请告诉我们。

    update:Timothy Furtak's paper描述了一个用于排序短数组的SSE实现(用作更大排序的构建块,如此插入排序)。他建议使用SSE生成部分排序的结果,然后使用标量操作进行清理。 (在大多数排序的数组上插入排序很快。)

    这导致我们:

    排序网络

    这里可能没有任何加速。 Xiaochen,Rocki和Suda只报告标量加速3.7倍 - &gt;用于32位(int)元素的AVX-512,用于Xeon Phi卡上的单线程mergesort。使用更宽的元素,更少适合矢量注册。 (这对我们来说是4倍:256b中的64b元素,512b中的32b元素。)它们还利用AVX512掩码仅比较某些通道,这是AVX中没有的功能。此外,由于较慢的比较器功能与混洗/混合单元竞争,我们的状况已经变得更糟。

    Sorting networks可以使用SSE / AVX打包比较指令构建。 (更常见的是,使用一对有效执行一组打包的2元素排序的最小/最大指令。)可以通过成对排序的操作构建更大的排序。 This paper by Tian Xiaochen, Kamil Rocki and Reiji Suda at U of Tokyo有一些真正的AVX代码用于排序(没有有效负载),并讨论它如何使用向量寄存器,因为你不能比较同一寄存器中的两个元素(所以排序网络必须设计成不要求)。他们使用pshufd排列元素以进行下一次比较,以便对几个完整数据的寄存器进行排序。

    现在,诀窍是根据仅半个元素的比较来做一对打包的64b元素。 (即使用排序键保持有效负载。)我们可以通过对(key, payload)对数组进行排序来对这些方式进行排序,其中有效负载可以是索引或32位指针(mmap(MAP_32bit),或者x32 ABI)。

    因此,让我们自己建立一个比较器。在排序网络用语中,这是对一对输入进行排序的操作。所以它要么在寄存器之间交换元素,要么不交换。

    # AVX comparator for SnB/IvB
    # struct { uint16_t a, b; float f; }  inputs in ymm0, ymm1
    # NOTE: struct order with f second saves a shuffle to extend the mask
    
    vcmpps    ymm7, ymm0, ymm1, _CMP_LT_OQ  # imm8=17: less-than, ordered, quiet (non-signalling on NaN)
         # ymm7 32bit elements = 0xFFFFFFFF if ymm0[i] < ymm1[i], else 0
    # vblendvpd checks the high bit of the 64b element, so mask *doesn't* need to be extended to the low32
    vblendvpd ymm2, ymm1, ymm0, ymm7
    vblendvpd ymm3, ymm0, ymm1, ymm7
    # result: !(ymm2[i] > ymm3[i])  (i.e. ymm2[i] < ymm3[i], or they're equal or unordered (NaN).)
    #  UNTESTED
    

    您可能需要设置MXCSR以确保int位不会减慢您的FP操作,如果它们恰好代表非正规或NaN浮点数。我不确定这是否仅适用于mul / div,或者是否会影响比较。

    • Intel Haswell:延迟:ymm2准备就绪的5个周期,ymm3的7个周期。吞吐量:每4个周期一个。 (p5瓶颈)。
    • Intel Sandybridge / Ivybridge:延迟:ymm2准备就绪的5个周期,ymm3的6个周期。吞吐量:每2个周期一个。 (p0 / p5瓶颈)。
    • AMD Bulldozer / Piledriver :( vblendvpd ymm:2c lat,2c recip tput):lat:4c表示ymm2,6c表示ymm3。或者更糟糕的是,在cmpps和blend之间存在旁路延迟。 tput:每4c一个。 (矢量P1上的瓶颈)
    • AMD Steamroller:(vblendvpd ymm:2c lat,1c recip tput):lat:4c表示ymm2,5c表示ymm3。或者由于旁路延迟而可能高一级。 tput:每3c一个(向量端口P0 / 1的瓶颈,用于cmp和混合)。

    VBLENDVPD是2 uops。 (它有3个reg输入,因此它不能是1 uop:/)。两个uops只能在shuffle端口上运行。在Haswell,这是唯一的port5。在SnB上,那是p0 / p5。 (IDK为什么Haswell比SnB / IvB减少了混洗/混合吞吐量。)

    如果AMD的设计具有256b宽的矢量单元,那么它们的低延迟FP比较和3输入指令的单宏操作解码将使它们领先。

    通常的minps / maxps对是3个和4个周期的延迟(ymm2/3),每2个周期一个吞吐量(Intel)。 (FP add / sub / compare单元的p1瓶颈)。最公平的比较可能是排序64位双打。如果没有多对独立寄存器进行比较,额外的延迟可能会受到影响。 Haswell减少了一半的吞吐量将大大减少任何加速。

    还要记住,比较器操作之间需要进行混洗,以便将正确的元素排成一行进行比较。 min / maxps使shuffle端口不被使用,但是我的cmpps / blendv版本使它们饱和,这意味着shuffling不能与比较重叠,除非是为了填补数据依赖性留下的空白。

    通过超线程,另一个可以让其他端口保持忙碌的线程(例如端口0/1 fp mul / add单元或整数代码)可以很好地与这个混合瓶颈版本共享核心。

    我尝试了Haswell的另一个版本,它可以进行混合&#34;手动&#34;使用按位AND / OR运算。但结果却变慢了,因为在合并之前两个来源都必须被屏蔽掉。

    # AVX2 comparator for Haswell
    # struct { float f; uint16_t a, b; }  inputs in ymm0, ymm1
    #
    vcmpps ymm7, ymm0, ymm1, _CMP_LT_OQ  # imm8=17: less-than, ordered, quiet (non-signalling on NaN)
         # ymm7 32bit elements = 0xFFFFFFFF if ymm0[i] < ymm1[i], else 0
    vshufps ymm7, ymm7, ymm7, mask(0, 0, 2, 2)  # extend the mask to the payload part.  There's no mask function, I just don't want to work out the result in my head.
    vpand    ymm10, ymm7, ymm0       # ymm10 = ymm0 keeping elements where ymm0[i] < ymm1[i]
    vpandn   ymm11, ymm7, ymm1       # ymm11 = ymm1 keeping elements where !(ymm0[i] < ymm1[i])
    vpor     ymm2, ymm10, ymm11      # ymm2 = min_packed_mystruct(ymm0, ymm1)
    
    vpandn   ymm10, ymm7, ymm0       # ymm10 = ymm0 keeping elements where !(ymm0[i] < ymm1[i])
    vpand    ymm11, ymm7, ymm1       # ymm11 = ymm1 keeping elements where ymm0[i] < ymm1[i]
    vpor     ymm3, ymm10, ymm11  # ymm2 = max_packed_mystruct(ymm0, ymm1)
    
    # result: !(ymm2[i] > ymm3[i])
    #  UNTESTED
    

    这是8 uops,而blendv版本为5 uops。在最后的6和/和/或指令中有很多并行性。但cmpps有3个周期延迟。我认为ymm2将在6个周期内准备就绪,而ymm3已在7中准备好了。(并且可以与ymm2上的操作重叠)。跟随比较器操作的insn可能会改组,将数据放入正确的元素中进行下一次比较。对于整数域逻辑,即使对于vshufps,也没有来自/来自混洗单元的转发延迟,但结果应该出现在FP域中,为vcmpps做好准备。使用vpand代替vandps对于吞吐量至关重要。

    Timothy Furtak的论文提出了一种使用有效负载对密钥进行排序的方法:不要使用密钥包装有效负载指针,而是从比较中生成掩码,并在密钥和密钥上使用它。有效载荷的方式相同。这意味着您必须在数据结构中或每次加载结构时将有效负载与密钥分开。

    见他论文的附录(图12)。他使用键上的标准最小值/最大值,然后使用cmpps查看哪些元素已更改。然后他在xor-swap中间对该掩码进行操作,最终只交换交换密钥的有效负载。

答案 1 :(得分:1)

不幸的是,原始AVX在其128位半部分(即通道)上进行非常有限的混洗,因此很难对完整的256位寄存器的内容进行排序。但是,AVX2具有没有这些限制的混洗操作,因此我们可以以矢量化方式执行一种4种结构。

我将使用this solution的想法。为了对数组进行排序,我们必须进行足够的元素比较,以确定我们需要应用的排列。假设没有元素是NaN,则足以检查每对不同的元素 a b 是否 a&lt; b 以及是否 a&gt; B'/ em>的。有了这些信息,我们可以完全比较任何两个元素,这两个元素必须足以确定最终的排序顺序。这是6对32位元素和两种比较模式,因此我们最终可以在AVX中进行两次shuffle和两次比较。如果您完全确定所有元素都是不同的,那么您可以避免 a&gt; b 比较并减小LUT的大小。

对于重新打包寄存器中的元素,我们可以使用_mm256_permutevar8x32_ps。一条指令允许在32位粒度上进行任意随机播放。请注意,在代码中我假设排序键f是结构的第一个成员(就像@PeterCordes建议的那样),但是如果你相应地改变了混洗掩码,你可以在当前结构中使用这个解决方案。

在我们执行比较之后,我们有两个包含布尔结果的AVX寄存器作为32位掩码。每个寄存器中的前六个掩码很重要,后两个不是。然后我们要将这些掩码转换为通用寄存器中的小整数,以用作查找表中的索引。一般情况下,我们可能需要为它创建完美的散列,但这里没有必要。我们可以使用_mm256_movemask_ps从AVX寄存器中获取通用寄存器中的8位整数掩码。由于每个寄存器的最后两个掩码并不重要,因此我们可以确保它们始终为零。然后得到的索引将在[0..2 ^ 12]范围内。

最后,我们从预先计算的LUT加载一个具有4096个元素的混洗掩码,并将其传递给_mm256_permutevar8x32_ps。因此,我们获得了一个AVX寄存器,其中包含4种类型正确排序的结构。预先计算LUT是您的家庭作业=)

以下是最终代码:

__m256i lut[4096];    //LUT of 128Kb size must be precomputed
__m256 Sort4(__m256 val) {
    __m256 aaabbcaa = _mm256_permutevar8x32_ps(val, _mm256_setr_epi32(0, 0, 0, 2, 2, 4, 0, 0));
    __m256 bcdcddaa = _mm256_permutevar8x32_ps(val, _mm256_setr_epi32(2, 4, 6, 4, 6, 6, 0, 0));
    __m256 cmpLt = _mm256_cmp_ps(aaabbcaa, bcdcddaa, _CMP_LT_OQ);
    __m256 cmpGt = _mm256_cmp_ps(aaabbcaa, bcdcddaa, _CMP_GT_OQ);
    int idxLt = _mm256_movemask_ps(cmpLt);
    int idxGt = _mm256_movemask_ps(cmpGt);
    __m256i shuf = lut[idxGt * 64 + idxLt];
    __m256 res = _mm256_permutevar8x32_ps(val, shuf);
    return res;
}

Here您可以看到生成的程序集。总共有14条指令,其中2条用于加载恒定的混洗掩码,其中一条是由于movemask结果无用的32位→64位转换。所以在一个紧密的循环中它将是11-12指令。 IACA表示循环中的四个呼叫在Haswell上具有16.40个周期的吞吐量,因此它似乎实现了每个呼叫4.1个周期的吞吐量。

当然,除非您要在一个批处理中处理更多输入数据,否则128 Kb查找表太多了。通过添加完美的散列(当然牺牲速度)可以减小LUT尺寸。很难说有四个元素可以订购多少,但明显少于 4! * 2 ^ 3 = 192 。我认为256元素LUT是可能的,甚至可能是128元素LUT。通过完美的散列,将两个AVX寄存器组合成一个带有shift和xor的寄存器可能会更快,然后执行_mm256_movemask_epi8一次(而不是做两个_mm256_movemask_ps并在之后组合它们。)