将 16 字节字符串与 SSE 进行比较

Posted

技术标签:

【中文标题】将 16 字节字符串与 SSE 进行比较【英文标题】:Compare 16 byte strings with SSE 【发布时间】:2015-08-13 22:11:04 【问题描述】:

我有 16 字节的“字符串”(它们可能更短,但您可以假设它们最后用零填充),但您可能不认为它们是 16 字节对齐的(至少并非总是如此)。

如何编写一个例程将它们(是否相等)与 SSE 内在函数进行比较?我发现这个代码片段可能会有所帮助,但我不确定它是否合适?

register __m128i xmm0, xmm1; 
register unsigned int eax; 

xmm0 = _mm_load_epi128((__m128i*)(a)); 
xmm1 = _mm_load_epi128((__m128i*)(b)); 

xmm0 = _mm_cmpeq_epi8(xmm0, xmm1); 

eax = _mm_movemask_epi8(xmm0); 

if(eax==0xffff) //equal 
else   //not equal 

谁能解释一下或者写一个函数体?

它需要在 GCC/mingw 中工作(在 32 位 Windows 上)。

【问题讨论】:

_mm_load_epi128 不存在,你的意思是_mm_load_si128 或者你说它们可以不对齐,_mm_loadu_si128 半相关:Find the first instance of a character using simd 【参考方案1】:

向量比较指令根据对应的源元素之间的比较,将其结果作为一个掩码,由全1(真)或全0(假)的元素组成。

请参阅 https://***.com/tags/x86/info 获取一些链接,这些链接会告诉您这些内在函数的作用。

问题中的代码看起来应该可以工作。

如果您想找出 哪些 元素不相等,请使用 movemask 版本(pmovmskbmovmskps)。您可以tzcnt / bsf 对第一个匹配项进行位扫描,或者popcnt 对匹配项进行计数。 All-equal 为您提供0xffff 掩码,non-equal 为您提供0


您可能想知道 SSE4.1 ptest 在这里是否有用。它是可用的,但实际上并没有更快,特别是如果您对结果进行分支而不是将其转换为布尔值 0 / -1。

 // slower alternative using SSE4.1 ptest
__m128i avec, bvec;
avec = _mm_loadu_si128((__m128i*)(a)); 
bvec = _mm_loadu_si128((__m128i*)(b)); 

__m128i diff = _mm_xor_si128(avec, bvec);  // XOR: all zero only if *a==*b

if(_mm_test_all_zeros(diff, diff))   //equal 
 else    //not equal 

在 asm 中,ptest 是 2 微指令,不能与 jcc 条件分支进行宏融合。因此,pxor + ptest + 的总分支对于前端来说是 4 微秒,并且仍然会破坏其中一个输入,除非您有 AVX 将异或结果放入第三个寄存器。

如果您有更宽的元素,您可以在pcmpeqd/q 的结果上使用movmskpsmovmskpd 以获得4 位或2 位掩码。如果您想要位扫描或 popcnt 而不用每个元素除以 4 或 8 个字节,这将非常有用。 (或者使用 AVX2、8 位或 4 位而不是 32 位掩码。)

ptest 仅在您不需要任何额外指令来为其构建输入时才是一个好主意:测试是否全零,带或不带掩码。例如检查每个元素或某些元素中的一些位。

【讨论】:

似乎ptest 指令在当前的英特尔架构上使用了 2 个微指令。而且,它没有与它之后的条件跳转融合。因此,您的解决方案生成 4 个微指令,而 OP 的代码仅生成 3 个微指令。有关详细信息,请参阅this question。据说这意味着您的解决方案在紧密循环中会变慢。 @stgatilov:很好。 PTEST 可能具有较低的延迟(较低的误判惩罚),但我认为您是对的,在这种情况下 pcmpeq/pmovmskb/ cmp/jne 的微指令更少,因为 ptest 不能直接比较相等。负载可以折叠到pcmpeq 中,也可以折叠到pxor 中。我也不确定错误预测的惩罚:我只是查看了 Agner Fog 的延迟表,没有做过实验。 在将pmovmskb 替换为ptest 后,我观察到实际基准测试和IACA 分析结果中的吞吐量都降低了。似乎 ptest 指令只有在您绝对需要检查 all 寄存器的 128 位是否为零(这种情况很少发生)时才有用。如果您正在进行任何比较(这是最流行的情况),那么在它之后使用ptest 是有害的。好吧,也许它的延迟更低,谁知道呢…… @stgatilov:更新了我的答案。请注意,pcmpeq / ptest 只能测试 all-not-equal。这就是为什么我使用pxor / ptest 来保持与 OP 代码相同的语义。不过,pcmpeq 应该与 Intel 上的 pxor 一样快。 ptest 在 p0 和 p5 上运行(具有 2c 延迟)。 pcmpeqb 可以在端口 1 或端口 5 上运行,因此它可以使用 ptest 没有的端口。 (pxor 可以在所有 3 个向量执行端口 p0/1/5 上运行。)但是,如果您有不同的语义,您的测试可能会出现比预期更少的分支错误预测。 我正在测试一些更复杂的代码(与 UTF 转换相关),正如 IACA 报告的那样,这是 前端 受限的。所以端口对我来说并不重要,只有微指令的数量。此外,分支属于“从不采用”或“始终采用”类型(用于返回错误),因此它们被完美预测。也许ptest 在其他情况下并不差,但对我来说,它现在是否可用是非常值得怀疑的。【参考方案2】:

好吧,我不确定这是否会更快,但它可以通过单个 SSE 4.2 指令内在来完成:检查 PCMPISTRI(打包比较隐式长度字符串,返回索引)是否有进位和/或溢出标志:

if (_mm_cmpistrc(a, b, mode))   // checks the carry flag (not set = equal)
  // equal
else
  // unequal

模式将是(对于您的情况):

const int mode = 
  SIDD_UBYTE_OPS |         // 16-bytes per xmm
  SIDD_CMP_EQUAL_EACH |    // strcmp
  SIDD_NEGATIVE_POLARITY;  // find first different byte

不幸的是,这条指令的文档记录很差。 因此,如果有人找到一个体面的资源,聚合了所有模式组合和生成的标志,请分享。

【讨论】:

它不起作用,因为它不会比较任一寄存器中超过零字节的字节。而且,不,它不会更快。 PCMPISTRI 是重量最轻的 SSE4.2 字符串 insn,但它仍然是 3 微指令(全部用于端口 0)和 Haswell 上的 11 个周期延迟。 insn 设置标志以及设置ecx,但test/jcc 仍然是一个uop,与单独的jcc 相同。英特尔的 insn 参考手册记录了所有内容,但理解字符串操作的描述进展缓慢,因为它们非常复杂并且有多种模式。 ***.com/tags/x86/info 有链接。 糟糕,忘记了 OP 询问的是零填充字符串,而不是任意 16B 向量。字符串指令有可能,但不会像测试完全相等的简单比较那样快。如果 OP 有一个零字节的字符串,然后是尾随垃圾,那么字符串指令可能是长度为 <= 15 的字符串的最佳选择。 @PeterCordes:感谢您的补充。我只是添加了这个来展示解决这个问题的另一种方法或稍微不同的问题。 --- 再次阅读 intel man,我收回它的记录很差。英特尔拱门确实解释了所有内容,但从那里得到有用的东西还有很长的路要走。现在查看 intel opt man,我承认它在第 10 章中通过示例进行了更详细的解释。 感谢 ch10 的文档提示。我只是在看第 4.1 节(这是 insn set ref 手册的一部分)。它有一个显示处理步骤的框图,可以更容易地查看每个imm8 位影响的步骤。完全同意使用这些多功能指令从文档到工作代码可能需要很长时间!太糟糕了,它们仍然是微编码的,所以几乎不值得使用。我猜这是鸡和蛋的事情。除非有很多用户,否则不值得花硅来让它们快速...... 体面的资源,解释了pcmpistri 的工作方式:strchr.com/strcmp_and_strlen_using_sse_4.2【参考方案3】:

我会尽力帮助解决被遗忘的有人可以解释这个问题部分。

register __m128i xmm0, xmm1; 
register unsigned int eax; 

这里我们声明了一些变量。 __m128i 是 SSE 寄存器上整数运算的内置类型。请注意,变量的名称根本不重要,但作者已将它们命名为在汇编中调用相应的 CPU 寄存器。 xmm0, xmm1, xmm2, xmm3, ...都是SSE操作的寄存器。 eax 是通用寄存器之一。

register 关键字很早以前就被用来建议编译器将变量放在 CPU 寄存器中。我认为,今天它完全没用。详情请见this question。

xmm0 = _mm_loadu_si128((__m128i*)(a)); 
xmm1 = _mm_loadu_si128((__m128i*)(b)); 

此代码已按照@harold 的建议进行了修改。这里我们从给定的内存指针(可能未对齐)加载 16 个字节到变量xmm0xmm1。在汇编代码中,这些变量很可能直接位于寄存器中,因此这些内在函数会产生未对齐的内存负载。将指针转换为__m128i* 类型是必要的,因为intrinsic 接受这种指针类型,尽管我不知道英特尔为什么这样做。

xmm0 = _mm_cmpeq_epi8(xmm0, xmm1); 

在这里,我们比较xmm0 变量中的每个字节与xmm1 变量中的相应字节是否相等。后缀_epi8 表示对 8 位元素进行操作,即字节。它有点类似于memcmp(&xmm0, &xmm1, 16),但会产生其他结果。它返回一个 16 字节的值,其中包含相等值的每个字节的 0xFF,以及具有不同值的每个字节的 0x00

eax = _mm_movemask_epi8(xmm0); 

这是来自 SSE2 的一条非常重要的指令,通常用于编写带有一些 SSE 条件的if 语句。它从 XMM 参数中的 16 个字节中获取最高位,并将它们写入单个 16 位整数。在汇编层面,这个数字位于通用寄存器中,让我们可以在之后快速检查它的值。

if(eax==0xffff) //equal 
else   //not equal

如果两个 XMM 寄存器的所有 16 字节都相等,则_mm_cmpeq_epi8 必须返回一个设置了所有 128 位的掩码。然后_mm_movemask_epi8 将返回完整的 16 位掩码,即0xFFFF。如果任何两个比较的字节不同,则_mm_cmpeq_epi8 将用零填充相应的字节,因此_mm_movemask_epi8 将返回设置了相应位not 的16 位掩码,因此它将小于@ 987654349@.

另外,这里是封装在函数中的解释代码:

bool AreEqual(const char *a, const char *b) 
  __m128i xmm0, xmm1; 
  unsigned int eax; 
  xmm0 = _mm_loadu_si128((__m128i*)(a)); 
  xmm1 = _mm_loadu_si128((__m128i*)(b)); 
  xmm0 = _mm_cmpeq_epi8(xmm0, xmm1); 
  eax = _mm_movemask_epi8(xmm0); 
  return (eax == 0xffff); //equal 

【讨论】:

以上是关于将 16 字节字符串与 SSE 进行比较的主要内容,如果未能解决你的问题,请参考以下文章

使用 x64 SSE / AVX 寄存器进行字符串反转

将两个向量<bool> 与 SSE 进行比较

将字符串与字符串进行比较(DatagramPacket 中的字节数组)

_mm_cmpistrm SSE4.2 固有模式

在 SSE 中进行比较时的奇怪行为

SSE4内存与差异位置比较