如何加速LUT查找的直方图?

时间:2016-09-01 08:30:27

标签: c++ optimization histogram simd

首先,我有一个数组int a[1000][1000]。所有这些整数都在0到32767之间,它们是已知的常量:它们在程序运行期间永远不会改变。

其次,我有一个数组b [32768],它包含0到32之间的整数。我使用这个数组将a中的所有数组映射到32个bin:

int bins[32]{};
for (auto e : a[i])//mapping a[i] to 32 bins.
    bins[b[e]]++;

每次,数组b都将使用新数组进行初始化,我需要将数组a (每个包含1000个int)中的所有1000个数组哈希到1000个数组,每个数组包含32个int代表每个垃圾箱里都落入了多少个。

int new_array[32768] = {some new mapping};
copy(begin(new_array), end(new_array), begin(b));//reload array b;

int bins[1000][32]{};//output array to store results .
for (int i = 0; i < 1000;i++)
    for (auto e : a[i])//hashing a[i] to 32 bins.
        bins[1000][b[e]]++;

我可以在0.00237秒内映射1000 * 1000个值。有没有其他方法可以加快我的代码? (像SIMD一样?)这段代码是我程序的瓶颈。

1 个答案:

答案 0 :(得分:4)

这实质上是一个直方图问题。您使用16位查找表将值16位值5位值映射,但之后它只是对LUT结果进行直方图编程。有关直方图的更多信息,请参见下文。

首先,您可以使用尽可能小的数据类型来增加LUT(和原始数据)的密度。在x86上,将8位或16位数据的零或符号扩展加载到寄存器中的成本几乎与常规32位int加载完全相同(假设两者都在高速缓存中命中),并且8位或16位存储也和32位存储一样便宜。

由于您的数据大小超过了L1 d-cache大小(所有最近的英特尔设计都是32kiB),并且您以分散的模式访问它,您可以从缩小缓存空间中获得很多收益。 (有关更多x86性能信息,请参阅标记wiki,尤其是Agner Fog的内容。

由于a在每个平面中的条目少于65536个,因此您的bin计数永远不会溢出16位计数器,因此bins也可以是uint16_t

你的copy()毫无意义。为什么要复制到b[32768]而不是让内部循环使用指向当前LUT的指针?您以只读方式使用它。如果您无法更改产生不同LUT的代码以生成int或{{}}首先是{1}}。

这个版本利用了这些想法和一些直方图技巧,并编译成看起来不错的asm(Godbolt compiler explorer: gcc6.2 -O3 -march=haswell (AVX2)):

uin8_t

内循环asm看起来像这样:

int8_t

使用来自rbp的32位偏移(而不是来自rsp的8位偏移,或使用另一个寄存器:/),代码密度并不是很好。尽管如此,平均指令长度还不长,以至于它可能会在任何现代CPU上对指令解码造成瓶颈。

多个箱柜的变体:

由于您无论如何都需要做多个直方图,只需要并行执行4到8个直方图,而不是为单个直方图切片。展开因子甚至不必是2的幂。

这样就无需在最后uint8_t循环// untested //#include <algorithm> #include <stdint.h> const int PLANES = 1000; void use_bins(uint16_t bins[PLANES][32]); // pass the result to an extern function so it doesn't optimize away // 65536 or higher triggers the static_assert alignas(64) static uint16_t a[PLANES][1000]; // static/global, I guess? void lut_and_histogram(uint8_t __restrict__ lut[32768]) { alignas(16) uint16_t bins[PLANES][32]; // don't zero the whole thing up front: that would evict more data from cache than necessary // Better would be zeroing the relevant plane of each bin right before using. // you pay the rep stosq startup overhead more times, though. for (int i = 0; i < PLANES;i++) { alignas(16) uint16_t tmpbins[4][32] = {0}; constexpr int a_elems = sizeof(a[0])/sizeof(uint16_t); static_assert(a_elems > 1, "someone changed a[] into a* and forgot to update this code"); static_assert(a_elems <= UINT16_MAX, "bins could overflow"); const uint16_t *ai = a[i]; for (int j = 0 ; j<a_elems ; j+=4) { //hashing a[i] to 32 bins. // Unrolling to separate bin arrays reduces serial dependencies // to avoid bottlenecks when the same bin is used repeatedly. // This has to be balanced against using too much L1 cache for the bins. // TODO: load a vector of data from ai[j] and unpack it with pextrw. // even just loading a uint64_t and unpacking it to 4 uint16_t would help. tmpbins[0][ lut[ai[j+0]] ]++; tmpbins[1][ lut[ai[j+1]] ]++; tmpbins[2][ lut[ai[j+2]] ]++; tmpbins[3][ lut[ai[j+3]] ]++; static_assert(a_elems % 4 == 0, "unroll factor doesn't divide a element count"); } // TODO: do multiple a[i] in parallel instead of slicing up a single run. for (int k = 0 ; k<32 ; k++) { // gcc does auto-vectorize this with a short fully-unrolled VMOVDQA / VPADDW x3 bins[i][k] = tmpbins[0][k] + tmpbins[1][k] + tmpbins[2][k] + tmpbins[3][k]; } } // do something with bins. An extern function stops it from optimizing away. use_bins(bins); }

在使用之前将.L2: movzx ecx, WORD PTR [rdx] add rdx, 8 # pointer increment over ai[] movzx ecx, BYTE PTR [rsi+rcx] add WORD PTR [rbp-64272+rcx*2], 1 # memory-destination increment of a histogram element movzx ecx, WORD PTR [rdx-6] movzx ecx, BYTE PTR [rsi+rcx] add WORD PTR [rbp-64208+rcx*2], 1 ... repeated twice more 归零,而不是将整个事物归零。这样,当你启动时,所有的bin都会在L1缓存中变热,而这项工作可能会与内循环中负载更重的工作重叠。

硬件预取程序可以跟踪多个顺序流,因此不必担心从bins[i][k] = sum(tmpbins[0..3][k])加载时会出现更多缓存未命中。 (也为此使用向量加载,并在加载后将它们切片)。

关于直方图的有用答案的其他问题:

AVX2收集LUT的说明

如果您要在英特尔Skylake上运行此功能,您甚至可以使用AVX2收集指令执行LUT查找。 (在Broadwell上,它可能是收支平衡的,而且在Haswell上它会丢失;它们会支持vpgatherdd (_mm_i32gather_epi32),但是不能提供高效的实现。希望Skylake避免使用相同的缓存线当元素之间存在重叠时多次。)

是的,即使最小的聚集粒度是32位元素,您仍然可以从bins[i..i+unroll_factor][0..31](比例因子= 2)的数组中收集。这意味着你在每个32位向量元素的高半部分而不是0中得到垃圾,但这不重要。缓存行拆分并不理想,因为我们可能会对缓存吞吐量造成瓶颈。

收集元素的高半部分中的垃圾并不重要,因为您使用a无论如何都只提取有用的16位。 (并使用标量代码执行过程的直方图部分)。

可以潜在地使用另一个聚集来从直方图箱中加载,只要每个元素来自直方图箱的单独切片/平面。否则,如果两个元素来自同一个bin,那么当您将递增的向量手动分散回直方图(使用标量存储)时,它只会递增1。分散存储的这种冲突检测是AVX512CD存在的原因。 AVX512确实有散布指令,也有收集(已经在AVX2中添加)。

<强> AVX512

有关重试的示例循环,请参阅page 50 of Kirill Yukhin's slides from 2014,直到没有冲突为止;但它并没有显示a如何用uint16_tpextrw)来实现(它在与之冲突的所有前面元素的每个元素中返回一个位图)。正如@Mysticial指出的那样,简单的实现并不像冲突检测指令只是产生掩码寄存器结果而不是另一个向量那样简单。

我搜索过但没有找到英特尔发布的关于使用AVX512CD的教程/指南,但可能他们认为get_conflict_free_subset()__m512i _mm512_conflict_epi32 (__m512i a))使用了vpconflictd的结果对某些情况很有用,因为它也是AVX512CD的一部分。

也许你已经&#34;假设&#34;做一些更聪明的事情,而不仅仅是跳过所有有冲突的元素?也许是为了检测标量回退会更好的情况,例如所有16个dword元素都有相同的索引? _mm512_lzcnt_epi32向结果的所有32位元素广播一个掩码寄存器,这样就可以将掩码寄存器值与来自vplzcntd的每个元素中的位图对齐。 (AVX512F的元素之间已经有比较,按位和其他操作)。

Kirill的幻灯片列表vpconflictd(来自AVX512F)以及冲突检测说明。它从vpbroadcastmw2d生成一个掩码。即AND元素在一起,如果它们不相交,则将该元素的掩码结果设置为1。

可能也相关:http://colfaxresearch.com/knl-avx512/"For a practical illustration, we construct and optimize a micro-kernel for particle binning particles",有一些AVX2代码(我认为)。但是,它还没有完成免费注册。基于该图,我认为他们将实际的散布部分作为标量,在一些矢量化的东西之后产生他们想要散布的数据。第一个链接表示第二个链接是&#34;用于以前的指令集&#34;。