【问题标题】:Popcount of SSE vectors for binary correlation?用于二进制相关的 SSE 向量的数量?
【发布时间】:2014-12-31 15:11:23
【问题描述】:

我有这个简单的二进制相关方法,它比 GCC 的 __builtin_popcount(我认为启用 SSE4 时映射到 popcnt 指令)比表查找和 Hakmem 位旋转方法好 x3-4 和 %25。

这里是大大简化的代码:

int correlation(uint64_t *v1, uint64_t *v2, int size64) {
  __m128i* a = reinterpret_cast<__m128i*>(v1);
  __m128i* b = reinterpret_cast<__m128i*>(v2);
  int count = 0;
  for (int j = 0; j < size64 / 2; ++j, ++a, ++b) {
    union { __m128i s; uint64_t b[2]} x;
    x.s = _mm_xor_si128(*a, *b);
    count += _mm_popcnt_u64(x.b[0]) +_mm_popcnt_u64(x.b[1]);
  }
  return count;
}

我尝试展开循环,但我认为 GCC 已经自动执行此操作,因此我最终获得了相同的性能。您认为在不使代码过于复杂的情况下性能进一步提高了吗?假设 v1 和 v2 大小相同,大小是偶数。

我对它目前的表现很满意,但我只是想看看它是否可以进一步改进。

谢谢。

编辑:修复了 union 中的一个错误,结果证明这个错误使这个版本比内置的 __builtin_popcount 更快,无论如何我再次修改了代码,它再次比现在的内置稍快(15%)但我不认为值得为此投入时间。感谢所有 cmets 和建议。

for (int j = 0; j < size64 / 4; ++j, a+=2, b+=2) {
  __m128i x0 = _mm_xor_si128(_mm_load_si128(a), _mm_load_si128(b));
  count += _mm_popcnt_u64(_mm_extract_epi64(x0, 0))
        +_mm_popcnt_u64(_mm_extract_epi64(x0, 1));
  __m128i x1 = _mm_xor_si128(_mm_load_si128(a + 1), _mm_load_si128(b + 1));
  count += _mm_popcnt_u64(_mm_extract_epi64(x1, 0))
        +_mm_popcnt_u64(_mm_extract_epi64(x1, 1));
}

第二次编辑:原来内置是最快的,叹息。特别是使用 -funroll-loops 和 -fprefetch-loop-arrays 参数。像这样的:

for (int j = 0; j < size64; ++j) {
  count += __builtin_popcountll(a[j] ^ b[j]);
}

第三次编辑:

这是一个有趣的 SSE3 并行 4 位查找算法。想法来自Wojciech Muła,实现来自 Marat Dukhan 的answer。感谢@Apriori 提醒我这个算法。下面是算法的核心,它非常聪明,基本上使用 SSE 寄存器作为 16 路查找表和低半字节作为选择表单元格的索引来计算字节的位。然后将计数相加。

static inline __m128i hamming128(__m128i a, __m128i b) {
  static const __m128i popcount_mask = _mm_set1_epi8(0x0F);
  static const __m128i popcount_table = _mm_setr_epi8(0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4);
  const __m128i x = _mm_xor_si128(a, b);
  const __m128i pcnt0 = _mm_shuffle_epi8(popcount_table, _mm_and_si128(x, popcount_mask));
  const __m128i pcnt1 = _mm_shuffle_epi8(popcount_table, _mm_and_si128(_mm_srli_epi16(x, 4), popcount_mask));
  return _mm_add_epi8(pcnt0, pcnt1);
}

在我的测试中,这个版本是相当的;在较小的输入上稍快,在较大的输入上比使用 hw popcount 稍慢。我认为如果它在 AVX 中实现,它应该真的很出色。但我没有时间这样做,如果有人愿意的话,我很想听听他们的结果。

【问题讨论】:

  • 这些可能也已经由编译器完成了。但是您可以预先计算 size64/2 并将所有分配(jx)移出循环。根据您要计算其位的变量的数量,您可以使用 SSE 寄存器而不是整数寄存器来使用(google for)popcount。与往常一样,您应该自己检查性能。
  • 那个联合技巧可能会导致错误的代码。即使它没有暂时通过内存(但很可能会),它仍然会很糟糕。
  • @inetknght:谢谢,我只是在寻找想法。正如您所猜测的那样,这些优化是由编译器进行的,而取消联合实际上会损害性能。另外,我已经在使用 SSE 寄存器了。
  • @harold 我实际上尝试使用 __128i 变量、_mm_load_si128 加载值和 _mm_extract_epi64 访问寄存器的 64 位部分。那个版本结果更糟。
  • 如果您愿意深入研究组装(您真的想在 上获得很多性能吗?),这似乎是一篇不错的文章:wm.ite.pl/articles/sse-popcount.html跨度>

标签: performance optimization x86 bit-manipulation sse


【解决方案1】:

问题在于popcnt(这是 __builtin_popcnt 在英特尔 CPU 上编译的内容)在整数寄存器上运行。这会导致编译器发出指令以在 SSE 和整数寄存器之间移动数据。我对非 sse 版本更快并不感到惊讶,因为在向量和整数寄存器之间移动数据的能力非常有限/缓慢。

uint64_t count_set_bits(const uint64_t *a, const uint64_t *b, size_t count)
{
    uint64_t sum = 0;
    for(size_t i = 0; i < count; i++) {
        sum += popcnt(a[i] ^ b[i]);
    }
    return sum;
}

这运行在大约。小型数据集上每个循环 2.36 个时钟(适合缓存)。我认为它运行缓慢是因为 sum 上的“长”依赖链限制了 CPU 处理更多无序事物的能力。我们可以通过手动流水线循环来改进它:

uint64_t count_set_bits_2(const uint64_t *a, const uint64_t *b, size_t count)
{
    uint64_t sum = 0, sum2 = 0;

    for(size_t i = 0; i < count; i+=2) {
        sum  += popcnt(a[i  ] ^ b[i  ]);
        sum2 += popcnt(a[i+1] ^ b[i+1]);
    }
    return sum + sum2;
}

每个项目运行 1.75 个时钟。我的 CPU 是 Sandy Bridge 型号(i7-2820QM 固定 @ 2.4Ghz)。

四路流水线怎么样?这是每件 1.65 个时钟。 8路呢?每个项目 1.57 个时钟。我们可以得出每个项目的运行时间是(1.5n + 0.5) / n,其中 n 是我们循环中的管道数量。我应该注意到,由于某种原因,当数据集增长时,8 路流水线的性能比其他流水线更差,我不知道为什么。生成的代码看起来没问题。

现在,如果您仔细看的话,每个项目都有一个xor、一个add、一个popcnt 和一个mov 指令。每个循环还有一个 lea 指令(以及一个分支和递减,我忽略了它们,因为它们几乎是免费的)。

$LL3@count_set_:
; Line 50
    mov rcx, QWORD PTR [r10+rax-8]
    lea rax, QWORD PTR [rax+32]
    xor rcx, QWORD PTR [rax-40]
    popcnt  rcx, rcx
    add r9, rcx
; Line 51
    mov rcx, QWORD PTR [r10+rax-32]
    xor rcx, QWORD PTR [rax-32]
    popcnt  rcx, rcx
    add r11, rcx
; Line 52
    mov rcx, QWORD PTR [r10+rax-24]
    xor rcx, QWORD PTR [rax-24]
    popcnt  rcx, rcx
    add rbx, rcx
; Line 53
    mov rcx, QWORD PTR [r10+rax-16]
    xor rcx, QWORD PTR [rax-16]
    popcnt  rcx, rcx
    add rdi, rcx
    dec rdx
    jne SHORT $LL3@count_set_

您可以使用Agner Fog's optimization manual 检查lea 始终是半个时钟周期,而mov/xor/popcnt/popcnt/add 组合显然是 1.5 个时钟周期,尽管我没有完全不明白为什么。

不幸的是,我认为我们被困在这里了。 PEXTRQ 指令通常用于将数据从向量寄存器移动到整数寄存器,我们可以在一个时钟周期内巧妙地适应这条指令和一条 popcnt 指令。添加一个整数 add 指令,我们的流水线至少有 1.33 个周期长,我们仍然需要在某处添加一个向量加载和异或……如果英特尔提供指令来一次在向量和整数寄存器之间移动多个寄存器,它将是一个不同的故事。

我手头没有 AVX2 cpu(256 位向量寄存器上的异或是 AVX2 的一项功能),但我的向量化加载实现在数据量较小的情况下性能很差,并且每个项目至少达到 1.97 个时钟周期.

作为参考,这些是我的基准:

“pipe 2”、“pipe 4”和“pipe 8”是上述代码的 2、4 和 8 路流水线版本。 “sse load”的不良显示似乎是lzcnt/tzcnt/popcnt false dependency bug 的表现,gcc 通过使用相同的寄存器进行输入和输出来避免这种情况。 “sse load 2”如下:

uint64_t count_set_bits_4sse_load(const uint64_t *a, const uint64_t *b, size_t count)
{
    uint64_t sum1 = 0, sum2 = 0;

    for(size_t i = 0; i < count; i+=4) {
        __m128i tmp = _mm_xor_si128(
                    _mm_load_si128(reinterpret_cast<const __m128i*>(a + i)),
                    _mm_load_si128(reinterpret_cast<const __m128i*>(b + i)));
        sum1 += popcnt(_mm_extract_epi64(tmp, 0));
        sum2 += popcnt(_mm_extract_epi64(tmp, 1));
        tmp = _mm_xor_si128(
                    _mm_load_si128(reinterpret_cast<const __m128i*>(a + i+2)),
                    _mm_load_si128(reinterpret_cast<const __m128i*>(b + i+2)));
        sum1 += popcnt(_mm_extract_epi64(tmp, 0));
        sum2 += popcnt(_mm_extract_epi64(tmp, 1));
    }

    return sum1 + sum2;
}

【讨论】:

  • 如何使用pshufb 让popcnt 留在xmm regs 中?比较如何?
  • 对于pshufb,您仍然需要将数据从xmm 寄存器传输到整数(“通用”)寄存器,然后才能使用popcnt。我不确定你想告诉我什么。
  • 没有转移。 pshufb(嗯,其中两个)实现了 popcnt,所以没有实际的 popcnt
  • 啊。我明白你的意思。不幸的是,我似乎无法比默认实现更快(它已经比 4 路流水线慢得多)。我正在使用这个 sse-popcnt 实现:stackoverflow.com/a/17531164/3371224.
  • 可惜,值得一试
【解决方案2】:

看看here。有一个 SSSE3 版本大大优于 popcnt 指令。我不确定,但您也可以将其扩展到 AVX。

【讨论】:

  • 我以前看过那些,但我对结果很怀疑。测试是在 4-5 年前完成的,看起来编译器在 4 年内变得更好了。但无论如何,我都会尝试尝试一下。谢谢。
  • 另外根据:strchr.com/crc32_popcnt?allcomments=1#comment_482 只快了 %3。
  • @mdakin 如果是虚假广告,我深表歉意。上次我看这个时,我正在实现文章的 CRC32 一半。我使用了该指令,并做了一个 3 路切片(我必须使用读取屏障来防止编译器重新排序指令),它的速度几乎提高了三倍。虽然我认为他们在这里谈论的切片是另一回事。无论如何,这当然不是你感兴趣的部分。
  • @mdakin 既然您提到了展开,这有点令人怀疑。当然,任何合理的编译器都会展开循环。也许他们只测试了他们的汇编代码。由于您计算了这么多位,而不仅仅是一个单词,您可能可以在这里进行一些优化。例如,在循环内部对并行数据进行独立操作的代码可能会很好地流水线化。您可能想查看指令的延迟和吞吐量,或者只是通过实验确定最佳位置。
  • @mdakin 另外,看起来他们正在将弹出计数添加到字节向量中(最多 32 次以避免溢出),然后再对最终值的组件求和,我认为这会有所帮助很多。也就是说,在最里面的循环中,您只有 _mm_shuffle_epi8 和 _mm_adds_epu8。任何可能以各种尺寸展开循环;我发现有时它甚至对现代编译器也有帮助。我很好奇你发现了什么。我想花一些时间尝试找到最快的方法来做到这一点,不幸的是我不确定我是否拥有它。我很想知道你发现了什么。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-04-24
  • 1970-01-01
  • 2012-10-29
  • 2013-09-27
  • 1970-01-01
  • 2020-09-08
相关资源
最近更新 更多