我有一个包含多个数据的64位结构体,其中之一是浮点数值:
struct MyStruct{
uint16_t a;
uint16_t b;
float f;
};
假设我有四个这样的结构体,放在一个 std::array<MyStruct, 4>
中。
是否可以使用 AVX 对数组进行排序,排序依据是浮点成员变量 MyStruct::f
?
我有一个包含多个数据的64位结构体,其中之一是浮点数值:
struct MyStruct{
uint16_t a;
uint16_t b;
float f;
};
假设我有四个这样的结构体,放在一个 std::array<MyStruct, 4>
中。
是否可以使用 AVX 对数组进行排序,排序依据是浮点成员变量 MyStruct::f
?
普通排序,但将结构体作为64位单元移动
矢量化的插入排序作为qsort的构建块
排序网络,使用cmpps
/blendvpd
实现比使用minps
/maxps
更好。然而,额外的开销可能会降低速度提升。
排序网络:加载一些结构体,然后洗牌/混合以获得一些仅为浮点数的寄存器和一些仅为有效载荷的寄存器。使用Timothy Furtak的技术进行正常的minps
/maxps
比较器,然后cmpeqps min,orig
->在有效载荷上执行掩码异或交换。这样每个比较器可以排序两倍的数据,但需要在比较器之间匹配两个寄存器的洗牌。完成后需要重新插入(但如果将比较器排列成使得内部解包将最终数据放置在正确顺序中的方式,则这很容易通过unpcklps / unpckhps进行)。
这样做还可以避免一些CPU在处理表示非规范化数、NaN或无穷大的有效负载位模式时可能出现的减速,而不需要通过设置MXCSR中的非规范化数为零位来实现。
Furtak的论文建议在使用向量进行排序后进行标量清理,这将大大减少洗牌的数量。
使用常规排序算法时,使用64位加载/存储移动整个结构体,并对FP元素进行标量FP比较可以获得至少小幅加速。为了使这个想法尽可能地发挥作用,请先按浮点值对结构体进行排序,然后可以使用movq
将整个结构体移到xmm寄存器中,浮点值将位于ucomiss
的低32位。然后你(或者一个聪明的编译器)可以使用movq
存储结构体。
查看Kerrek SB链接的汇编输出,编译器似乎在高效地复制结构体方面做得不太好:
icc
似乎分别移动两个uint值,而不是通过64位加载来捆绑整个结构体。也许它没有压缩结构体?gcc
5.1大部分时间似乎没有这个问题。
大型排序通常使用分治法,对于足够小的问题则使用插入排序。 插入排序通过复制数组元素一个一个地向前移动,只有当我们找到当前元素所属位置时才停止。因此,我们需要将一个元素与一系列打包的元素进行比较,如果比较为真,则停止。你闻到向量了吗?我闻到了向量。
# 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.
这个程序充满了错误,我本应该使用C语言和内部函数来编写它。
这是一种插入排序算法,可能比大多数排序算法的开销更高,对于非常小的问题规模,它可能会输给标量版本,因为需要处理前几个元素的额外复杂性(不要填充向量),并且在中断向量搜索循环后找出新元素放置的位置时有更多的复杂度。
通过将循环进行流水线处理,以便在下一次迭代之前(或中断后)不存储ymm1,可以节省冗余存储。通过移位/重排寄存器中的比较,而不是直接进行标量加载/比较,可能是一种胜利的方法。这可能会导致太多不可预测的分支,我没有看到一种好的方式来使高4位打包到一个寄存器中供vmovups使用,另一个寄存器中供vmovsd使用。
我可能发明了一种插入排序算法,它兼具两种最糟糕的特点:对于小数组而言速度慢,因为在跳出搜索循环后还需要更多的工作;对于大数组而言速度也慢,因为时间复杂度为O(n^2)。然而,如果搜索循环外部的代码能够被改进得更好,那么这个算法可以作为qsort/mergesort的小数组终点非常有用。排序网络可以使用SSE / AVX打包比较指令构建。(更常见的是,使用一对min / max指令,有效地进行一组打包的2元素排序。)更大的排序可以通过进行成对排序的操作来构建。 这篇来自东京大学的Tian Xiaochen,Kamil Rocki和Reiji Suda的论文提供了一些真实的AVX代码进行排序(没有有效载荷),并讨论了向量寄存器的复杂性,因为您无法比较在同一个寄存器中的两个元素(因此,必须设计排序网络不需要这样做)。 它们使用pshufd
对齐元素以进行下一次比较,以从仅对几个寄存器的数据进行排序构建出更大的排序。
# 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
ymm2
准备好需要5个时钟周期,等待ymm3
需要7个时钟周期。吞吐量为每4个时钟周期执行一次。(p5瓶颈)。ymm2
准备好需要5个时钟周期,等待ymm3
需要6个时钟周期。吞吐量为每2个时钟周期执行一次。(p0/p5瓶颈)。vblendvpd ymm
: 2c 延迟时间,2c 循环吞吐量): 延迟时间: 等待ymm2
需要4个时钟周期,等待ymm3
需要6个时钟周期,或因cmpps和blend之间的旁路延迟而更差。吞吐量为每4个时钟周期执行一次。(向量P1瓶颈)vblendvpd ymm
: 2c 延迟时间,1c 循环吞吐量): 延迟时间: 等待ymm2
需要4个时钟周期,等待ymm3
需要5个时钟周期,或因旁路延迟而更高。吞吐量为每3个时钟周期执行一次 (cmp和blend的向量端口P0/1瓶颈)。VBLENDVPD
是2个微操作。由于它有3个寄存器输入,因此不能是1个微操作:/。这两个微操作只能在混洗端口上运行。在Haswell上,这仅限于port5。在SnB上,这是p0/p5。(我不知道为什么Haswell与SnB/IvB相比将混洗/混合吞吐量减半了。)
如果AMD设计具有256位宽的矢量单元,则其低延迟FP比较和解码3输入指令的单个宏操作将使其领先。
通常的minps/maxps对的延迟为3和4个周期(ymm2/3
),吞吐量为每2个周期1个(Intel)。(FP加/减/比较单元上的p1瓶颈)。最公平的比较可能是对64位双精度数进行排序。额外的延迟,如果没有多个独立寄存器对可以比较,可能会有所损失。在Haswell上减半的吞吐量将严重影响任何加速。
# 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
cmpps
查看哪些元素发生了更改。然后,他会在xor-swap的中间ANDs该掩码,以便仅交换已交换键的有效载荷。很遗憾,原始的AVX在其128位半部分(即lanes)上有非常有限的洗牌能力,因此很难对完整的256位寄存器内容进行排序。然而,AVX2具有无此限制的洗牌操作,因此我们可以以向量化的方式对4个结构体进行排序。
我将使用这个解决方案的想法。为了对数组进行排序,我们必须进行足够的元素比较,以确保确定需要应用的置换。鉴于没有元素是NaN,则检查每对不同元素a和b是否a < b和a > b就足够了。有了这些信息,我们就可以完全比较任意两个元素,这足以确定最终的排序顺序。这是6对32位元素和两种比较模式,因此我们可以在AVX中进行两次洗牌和两次比较。如果您绝对确定所有元素都是不同的,则可以避免a > b比较并减小LUT的大小。
对于寄存器内元素的重新打包,我们可以使用_mm256_permutevar8x32_ps
。一个指令允许在32位粒度上进行任意洗牌。请注意,在代码中,我假设排序键f
是您的结构体的第一个成员(正如@PeterCordes所提出的),但是如果您相应地更改洗牌掩码,则可以轻松地将此解决方案用于您当前的结构。
在我们执行比较之后,我们有两个AVX寄存器,它们包含32位掩码作为布尔结果。每个寄存器中的前六个掩码很重要,而最后两个掩码则不重要。然后,我们希望将这些掩码转换为通用寄存器中的小整数,以用作查找表中的索引。在一般情况下,我们可能需要为其创建完美哈希,但在这里不是必需的。我们可以使用_mm256_movemask_ps
从AVX寄存器中获取8位整数掩码,以在通用寄存器中使用。由于每个寄存器中的最后两个掩码不重要,因此我们可以确保它们始终为零。然后,得到的索引将在[0..2^12)范围内。
最后,我们从预计算的具有4096个元素的LUT中加载洗牌掩码,并将其传递给_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;
}
这里可以看到生成的汇编代码。总共有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寄存器合并成一个,再进行移位和异或操作,然后执行一次_mm256_movemask_epi8
(而不是执行两次_mm256_movemask_ps
,然后将它们组合在一起)可能更快。
pmovzxbd
来压缩LUT四倍(使用内部函数将该指令与内存操作数一起发出很难,但即使是movq / pmovzx ymm,也可以)。由于索引很小,你甚至可以将洗牌掩码打包成每个元素4位,并在运行时解包(没有pextr
将需要多个指令)。 - Peter Cordes
icc
输出也只是使用标量运算((u)comiss
根据比较低浮点元素设置标志)。是的,它使用指令的v
版本,因为这是标量FP数学与-march=native
编译时的编译方式,以避免需要在其与其他代码之间使用vzeroupper
。 - Peter Cordes