【问题标题】:pcmpestri character units and countdown - x86-64 asmpcmpestri 字符单位和倒计时 - x86-64 asm
【发布时间】:2020-07-21 22:47:48
【问题描述】:

我正在尝试在 x86-64 asm 中围绕 pcmpestri 编写一个最小循环(实际上是使用 GDC 编译器嵌入到 Dlang 中的内联 asm)。有几件事我不明白

  1. 我使用 pcmpestri 和两个指向字符串的指针,rax 和 rdx 中的字符串长度是多少?
  2. 如果有,单位是什么?总是以字节为单位计数,还是以字符为单位计数,其中 1 个计数 = 2 个字节(对于 uwords)?
  3. pcmpestri 是否检查短字符串?即 len str1 or str2
  4. pcmpestri 是否将每个块的 rax 和 rax 计数减少 n 还是我必须这样做?总是减去 16 还是(16 或 8,取决于字节/uwords)?
  5. 我是否需要担心下面 fetch 的 128 位对齐?如果它更快,我可以预先检查字符串是否为 128 位对齐,但那样会变得非常混乱。如果我使用不需要 128 位对齐的指令,那会慢多少?见下文
  6. 在 ja 之前使用 lea %[offset], [ %[offset] - 16 ] 会慢吗? (选择它是因为它不设置标志)
  7. 值得循环展开吗?还是一个糟糕的主意?
  8. 我需要将哪些信息传递回高级语言代码? rcx 我知道一件事,标志也是,还是我可以忘记它们? (在较早的例程中,如果未采用 final ja,我将 true if cond 'na' 传回。)
  9. 最后一个问题:传回更新的偏移量怎么样?

省略我必须的序言:

; having reserved say xmm1 as a working variable

loop:   add       %[offset], 16  ; 16 bytes = nbytes of chunk of string
; do I need to count  lengths of strings down ? by 16 per chunk or by (8 or 16) per chunk ?
        movdqa    xmm1, [ %[pstr1] + %[offset] - 16 ]    ; -16 to compensate for pre-add
        pcmpestri xmm1, [ %[pstr1] + %[offset] - 16 ], 0 ; mode=0 or 1 for uwords
        ja      loop

;我该怎么做才能将信息传回主代码? ;我已经传回 rcx = offset-in-chunk,我还需要传回标志吗 ;我通过将 rcx 声明为输出来保留它 ;传递 %[offset] 的值怎么样?还是通过倒计时的长度?

我还没有找到以单词而不是字节为特征的示例。

对于 1 字符串的使用模式,我保留了 xmm1 作为输入参数 xmm reg :

loop:   add       %[offset], 16  ; 16 bytes = nbytes of chunk of string
        pcmpestri xmm1, [ %[pstr1] + %[offset] - 16 ], 0 ; mode=0 or 1 for uwords
        ja      loop

【问题讨论】:

  • 我决定在 asm 代码中做尽可能少的工作,并让逻辑在它之外完成。已经有一个单独围绕 pcmpestri 的单行 asm 包装器,但我们需要在 asm 中包含一条 jna 指令以提高速度,因为 D 代码必须处理传递标志状态的笨重机制是很尴尬的在这样一个热循环中。所以我包含了几个场景,它们使用 jna 指令完成循环 - 一个用于两个指针用例,另一个用于 xmm reg 中的一个 ptr 和一个固定参数。

标签: string assembly optimization x86-64 inline-assembly


【解决方案1】:

See also

; compile with FASM
; Immediate byte constants
EQUAL_ANY       = 0000b
RANGES          = 0100b
EQUAL_EACH      = 1000b
EQUAL_ORDERED       = 1100b
NEGATIVE_POLARITY = 010000b
BYTE_MASK    = 1000000b

; ==== strcmp ====

strcmp_sse42:
  ; Using __fastcall convention, ecx = string1, edx = string2
  mov eax, ecx
  sub eax, edx ; eax = ecx - edx
  sub edx, 16

STRCMP_LOOP:
    add edx, 16
    MovDqU    xmm0, dqword[edx]
    ; find the first *different* bytes, hence negative polarity
    PcmpIstrI xmm0, dqword[edx + eax], EQUAL_EACH + NEGATIVE_POLARITY
    ja STRCMP_LOOP

  jc STRCMP_DIFF

  ; the strings are equal
  xor eax, eax
  ret
STRCMP_DIFF:
  ; subtract the first different bytes
  add eax, edx
  movzx eax, byte[eax + ecx]
  movzx edx, byte[edx + ecx]
  sub eax, edx
  ret


; ==== strlen ====
strlen_sse42:
  ; ecx = string
  mov eax, -16
  mov edx, ecx
  pxor xmm0, xmm0

STRLEN_LOOP:
    add eax, 16
    PcmpIstrI xmm0, dqword[edx + eax], EQUAL_EACH
    jnz STRLEN_LOOP

  add eax, ecx
  ret

; ==== strstr ====
strstr_sse42:
  ; ecx = haystack, edx = needle

  push esi
  push edi
  MovDqU xmm2, dqword[edx] ; load the first 16 bytes of neddle
  Pxor xmm3, xmm3
  lea eax, [ecx - 16]

  ; find the first possible match of 16-byte fragment in haystack
STRSTR_MAIN_LOOP:
    add eax, 16
    PcmpIstrI xmm2, dqword[eax], EQUAL_ORDERED
    ja STRSTR_MAIN_LOOP

  jnc STRSTR_NOT_FOUND

  add eax, ecx ; save the possible match start
  mov edi, edx
  mov esi, eax
  sub edi, esi
  sub esi, 16

  ; compare the strings
@@:
    add esi, 16
    MovDqU    xmm1, dqword[esi + edi]
    ; mask out invalid bytes in the haystack
    PcmpIstrM xmm3, xmm1, EQUAL_EACH + NEGATIVE_POLARITY + BYTE_MASK
    MovDqU xmm4, dqword[esi]
    PAnd xmm4, xmm0
    PcmpIstrI xmm1, xmm4, EQUAL_EACH + NEGATIVE_POLARITY
    ja @B

  jnc STRSTR_FOUND

  ; continue searching from the next byte
  sub eax, 15
  jmp STRSTR_MAIN_LOOP

STRSTR_NOT_FOUND:
  xor eax, eax

STRSTR_FOUND:
  pop edi
  pop esi
  ret

From Implementing strcmp

英特尔内部指南中的一些 ASM 显示了操作:

size := (imm8[0] ? 16 : 8) // 8 or 16-bit characters
UpperBound := (128 / size) - 1
BoolRes := 0
// compare all characters
aInvalid := 0
bInvalid := 0
FOR i := 0 to UpperBound
    m := i*size
    FOR j := 0 to UpperBound
        n := j*size
        BoolRes.word[i].bit[j] := (a[m+size-1:m] == b[n+size-1:n]) ? 1 : 0
        
        // invalidate characters after EOS
        IF i == la
            aInvalid := 1
        FI
        IF j == lb
            bInvalid := 1
        FI
        
        // override comparisons for invalid characters
        CASE (imm8[3:2]) OF
        0:  // equal any
            IF (!aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && !bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 0
            FI
        1:  // ranges
            IF (!aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && !bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 0
            FI
        2:  // equal each
            IF (!aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && !bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 1
            FI
        3:  // equal ordered
            IF (!aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 0
            ELSE IF (aInvalid && !bInvalid)
                BoolRes.word[i].bit[j] := 1
            ELSE IF (aInvalid && bInvalid)
                BoolRes.word[i].bit[j] := 1
            FI
        ESAC
    ENDFOR
ENDFOR
// aggregate results
CASE (imm8[3:2]) OF
0:  // equal any
    IntRes1 := 0
    FOR i := 0 to UpperBound
        FOR j := 0 to UpperBound
            IntRes1[i] := IntRes1[i] OR BoolRes.word[i].bit[j]
        ENDFOR
    ENDFOR
1:  // ranges
    IntRes1 := 0
    FOR i := 0 to UpperBound
        FOR j := 0 to UpperBound
            IntRes1[i] := IntRes1[i] OR (BoolRes.word[i].bit[j] AND BoolRes.word[i].bit[j+1])
            j += 2
        ENDFOR
    ENDFOR
2:  // equal each
    IntRes1 := 0
    FOR i := 0 to UpperBound
        IntRes1[i] := BoolRes.word[i].bit[i]
    ENDFOR
3:  // equal ordered
    IntRes1 := (imm8[0] ? 0xFF : 0xFFFF)
    FOR i := 0 to UpperBound
        k := i
        FOR j := 0 to UpperBound-i
            IntRes1[i] := IntRes1[i] AND BoolRes.word[k].bit[j]
            k := k+1
        ENDFOR
    ENDFOR
ESAC
// optionally negate results
FOR i := 0 to UpperBound
    IF imm8[4]
        IF imm8[5] // only negate valid
            IF i >= lb // invalid, don't negate
                IntRes2[i] := IntRes1[i]
            ELSE // valid, negate
                IntRes2[i] := -1 XOR IntRes1[i]
            FI
        ELSE // negate all
            IntRes2[i] := -1 XOR IntRes1[i]
        FI
    ELSE // don't negate
        IntRes2[i] := IntRes1[i]
    FI
ENDFOR
// output
IF imm8[6] // most significant bit
    tmp := UpperBound
    dst := tmp
    DO WHILE ((tmp >= 0) AND a[tmp] == 0)
        tmp := tmp - 1
        dst := tmp
    OD
ELSE // least significant bit
    tmp := 0
    dst := tmp
    DO WHILE ((tmp <= UpperBound) AND a[tmp] == 0)
        tmp := tmp + 1
        dst := tmp
    OD
FI

来自Intel Intrinsic Guide

【讨论】:

  • Jay 和 Peter,感谢您的慷慨帮助。我是一名经验丰富的专业 asm 程序员,从几十年前开始,但我发现各种没有帮助的示例以及对指令的描述非常混乱。
  • 我已经阅读了上面的“实现 strcmp”文章。我有一个问题 - 在无携带情况下,ECX 仍然有效吗?
【解决方案2】:

在您的主循环中(两个输入字符串的剩余长度 >=16),如果您知道数据中没有 0 字节,请使用 pcmpistri(隐式长度字符串版本)。 pcmpistri 在大多数 CPU 上明显更快且微指令更少,这可能是因为它只有 3 个输入(包括立即数)而不是 5 个。(https://uops.info/

我是否需要担心下面 fetch 的 128 位对齐?

movdqa 当然可以,但令人惊讶的是,SSE4.2 字符串指令不会在未对齐的内存操作数上出错!对于所有先前指令的传统 SSE(非 VEX)编码(除了未对齐的 mov,如 movups / movdqu),16 字节内存操作数必须对齐。 Intel's manual notes: "另外,如果内存操作数未对齐 16 字节边界,则此指令不会导致 #GP"。

当然,您仍然必须避免进入未映射的页面,例如对于在未映射页面前 7 个字节开始的 5 字节字符串,16 字节内存操作数仍会出现页面错误。 (Is it safe to read past the end of a buffer within the same page on x86 and x64?) 与 AVX-512 掩码加载不同,我在英特尔手册中没有看到任何提及内存源操作数的“忽略”部分的故障抑制。

对于显式长度的字符串,这很容易:您知道何时肯定距离较短字符串的末尾很远,因此您可以只对最后一次迭代进行特殊处理。 (无论如何你都想这样做,所以你可以在主循环中使用pcmpistri)。

例如如果字符串至少有 16 个字节长,则在字符串的最后一个字节处执行未对齐操作,或者检查 (p&amp;4095) &lt;= (4096-16) 以避免在获取字符串结尾时出现跨页加载。

所以在实践中,如果两个字符串具有相同的相对对齐方式,您可以只处理字符串的未对齐开头,然后进入一个使用来自两者的对齐负载的循环(因此您可以继续使用movdqa)。这不能分页,因此在加载任何包含任何字符串字节的对齐向量时不会出错。

相对错位更难。

为了性能,请注意 SSE4.2 仅在 Nehalem 和更新版本上受支持,其中 movdqu 相对高效(如果指针恰好在运行时对齐,则与 movdqa 一样便宜)。我认为 AMD 支持是类似的;直到 Bulldozer 具有 AVX 和廉价的未对齐负载。缓存行拆分仍然会造成一些伤害,因此如果您希望 large 字符串很常见,那么通过做一些额外的检查可能会伤害短字符串大小写和/或已经对齐的大小写是值得的。

也许看看 glibc 的 SSE2 / AVX memcmp 实现是做什么的;它具有从 2 个可能未对齐的数组中读取 SIMD 向量的相同问题。彼此。 (简单的字节相等is faster with pcmpeqb 所以它不会使用 SSE4.2 字符串指令,但加载哪些 SIMD 向量的问题是相同的)。


pcmpestri 是否检查短字符串?

是的,这就是采用 2 个输入长度的全部意义(在 XMM1 的 RAX 和 XMM2 的 RDX 中)。请参阅 Intel 的 pcmpestri 的 asm 手册条目。

pcmpestri 是否将 rax 和 rax 每块减少 n 或我必须这样做

如果这是你想要的,你必须这样做; pcmpestri 查看 XMM1 的第一个 RAX 字节/字(最多 16 / 8)和 XMM2/mem 的第一个 RDX 字节(字)(最多 16 / 8),并输出到 ECX 和 EFLAGS。就这些。同样,英特尔的手册对此非常清楚。 (虽然理解实际的聚合和比较选项相当复杂!)

如果您想在循环中使用它,您可以将这些寄存器设置为 16 并正确计算它们以在循环后进行剥离的最终迭代。或者您可以每次迭代将它们递减 16; pcmpestri 似乎是为此而设计的,如果 EDX 和/或 EAX 分别


另请参阅https://www.strchr.com/strcmp_and_strlen_using_sse_4.2,了解 SSE4.2 字符串指令执行的处理步骤的有用高级图片,以便您了解如何设计有用的方法来使用它们。还有一些例子,比如实现strcmpstrlen。英特尔在 SDM 中的详细文档在细节上陷入了困境,难以从大局中把握。

(一个好的展开的 SSE2 实现可以在那些简单的功能上击败 SSE4.2,但一个简单的问题就是一个很好的例子。)


我需要将什么信息传递回高级语言代码?

理想情况下,您应该有适当的 intrinsics,而不仅仅是内联 asm 的包装器。

这可能取决于高级代码想要用它做什么,尽管对于pcmpestri 具体来说,所有信息都存在于 ECX 中(整数结果)。 CF = (ECX == 0)OF = ECX[0](低位)。如果 GDC 有 GCC6 标志输出语法,我猜它不会伤害,除非它诱使编译器制作更糟糕的代码来接收这些输出。

如果您使用 inline-asm 基本上为 SSE4.2 字符串指令创建内部函数,则可能值得查看英特尔的 C 内部函数设计:https://software.intel.com/sites/landingpage/IntrinsicsGuide/

例如一个用于 ECX 结果,int _mm_cmpestri (__m128i a, int la, __m128i b, int lb, const int mode);
每个单独的 FLAG 输出位各一个,例如 _mm_cmpestro

但是,英特尔的设计存在缺陷。例如,至少对于隐式长度字符串版本,我记得获得整数结果 让编译器直接从指令中分支到 FLAGS 的唯一方法是使用两个不同的内在函数相同的输入,并取决于编译器一起优化它们。

使用内联 asm,可以轻松描述多个输出并优化掉未使用的输出。但不幸的是,C 没有多个返回值的语法,我猜英特尔不希望有一个具有引用输出 arg 和返回值的内在函数。

在 ja 之前使用 lea %[offset], [ %[offset] - 16 ] 会更慢吗? (选择它是因为它不设置标志)

我会先加载movdqa,然后是add,然后是pcmpistri。这使 movdqa 寻址模式更简单、更小,并让第一次迭代的加载提前 1 个周期开始执行,而无需等待 add 的延迟(如果索引位于关键路径上;如果您从0)

在这里使用索引寻址模式可能没有害处(像pcmpe/istri 这样的多uop 指令可能无论如何都不能微熔负载,movdqa / movdqu 不在乎)。但在其他情况下,展开并使用指针增量是值得的:Micro fusion and addressing modes

可能值得展开 2。我建议计算 uops 以查看它是否刚好高于 4 的倍数,和/或在 Skylake 和 Zen 等几个 CPU 上尝试。

【讨论】:

  • 非常感谢您的慷慨彼得。我关于传回信息的问题是因为我打算(仅)传回进位标志的状态,我想知道这是否是正确的决定。我已经编写了一个围绕 pcmpestri 的单行包装器,它返回 RCX 和“a/na”标志状态,因此高级语言代码可以有效地进行自己的 jna 跳转,即使它确实可以直接访问标志。所以 SETNA 指令将这两个位的状态作为布尔值传递回来。
  • 我真诚的道歉,但我不理解关于页面错误的观点。如果需要页面错误,那么除了它显然会大大减慢速度之外,为什么这是一件坏事?这是这里的重点,关于优化,还是还有其他我不知道的问题?如果是关于优化,我是否应该直接到字符串的末尾,因为长度是已知的,并使用 PREFETCHxx 指令来获取字符串中的最后一个字节,从而确保页面及时错误地进入内存?这会完全有帮助还是无关紧要?非常感谢。
  • @CecilWard:我说的是 invalid 页面错误(Unix SIGSEGV / Windows Access Violation),即跨越两页的广泛负载,一个包含字符串数据,以及另一个未映射的开始。如果一个字节一次的算法不会触及第二页,你也不能这样做,因为它甚至可能没有被映射。去阅读Is it safe to read past the end of a buffer within the same page on x86 and x64? - 是的在一个页面内,但不是跨越页面边界。因此,您必须小心未对齐的负载。
  • @CecilWard:对于pcmpestri,FLAGS 中的所有信息也可通过test ecx, something 或通过比较长度获得。这使得浪费 setna 指令 100% 毫无意义,包括调用者甚至不会读取该布尔值。如果调用者想对此进行分支,他们会在 C 或 D 中执行 if(ecx_res &amp; 1) {},如果您使用 setnc dil,编译器将发出 test cl, 1 而不是 test dil,dil
  • 明白。我并不是要重新实现 C 库的字符串函数,而只是以最小的开销向 D 用户提供对指令的访问权限,因此任何其他的琐事,例如处理您向我指出的重要案例,我可以很容易比合法的多读 15 个字节,这些可以留给 D 代码处理,更高效,更容易在 D 中编写优化器可以做的那部分。
猜你喜欢
  • 2011-12-27
  • 1970-01-01
  • 2020-08-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-10-05
  • 2018-06-11
  • 1970-01-01
相关资源
最近更新 更多