由于从越界内存中跳过了cmov,因此难以调试SEGV

时间:2019-01-05 08:11:58

标签: assembly gdb x86-64

我正在尝试将一些高性能的汇编函数编写为练习,并且遇到了奇怪的段错误,该段错误在运行程序时发生,但在valgrind或nemiver中没有。

基本上,一个不应该运行的cmov带有一个超出范围的地址,即使条件始终为false,也使我陷入段错误状态

我有一个快版本和一个慢版本。慢的人一直都在工作。除非我收到一个非ascii字符,否则最快的一个就可以工作,这时除非我在adb或nemiver上运行,否则它会崩溃得很厉害。

ascii_flags是一个128字节的数组(末尾有一点空间),其中包含所有ASCII字符(字母,数字,可打印等)上的标志

这有效:

ft_isprint:
    xor EAX, EAX                ; empty EAX
    test EDI, ~127              ; check for non-ascii (>127) input
    jnz .error
    mov EAX, [rel ascii_flags + EDI]    ; load ascii table if input fits
    and EAX, 0b00001000         ; get specific bit
.error:
    ret

但这不是:

ft_isprint:
    xor EAX, EAX                ; empty EAX
    test EDI, ~127              ; check for non-ascii (>127) input
    cmovz EAX, [rel ascii_flags + EDI]  ; load ascii table if input fits
    and EAX, flag_print         ; get specific bit
    ret

Valgrind实际上确实崩溃了,但是除了内存地址之外没有其他信息,因为我没有设法获得更多的调试信息。

编辑:

我已经编写了三个版本的函数,以考虑到出色的答案:

ft_isprint:
    mov RAX, 128                            ; load default index
    test RDI, ~127                          ; check for non-ascii (>127) input
    cmovz RAX, RDI                          ; if none are found, load correct index
    mov AL, byte [ascii_flags + RAX]        ; dereference index into least sig. byte
    and RAX, flag_print                     ; get specific bit (and zeros rest of RAX)
    ret

ft_isprint_branch:
    test RDI, ~127                          ; check for non-ascii (>127) input
    jnz .out_of_bounds                      ; if non-ascii, jump to error handling
    mov AL, byte [ascii_flags + RDI]        ; dereference index into least sig. byte
    and RAX, flag_print                     ; get specific bit (and zeros rest of RAX)
    ret
.out_of_bounds:
    xor RAX, RAX                            ; zeros return value
    ret

ft_isprint_compact:
    xor RAX, RAX                            ; zeros return value preemptively
    test RDI, ~127                          ; check for non-ascii (>127) input
    jnz .out_of_bounds                      ; if non-ascii was found, skip dereferenciation
    mov AL, byte [ascii_flags + RDI]        ; dereference index into least sig. byte
    and RAX, flag_print                     ; get specific bit
.out_of_bounds:
    ret

经过广泛的测试,在所有类型的数据上,分支功能绝对比cmov函数快大约5-15%。紧凑版本和非紧凑版本之间的差异是所期望的最小。在可预测的数据集上,压缩总是稍快一点​​,而在不可预测的数据上,压缩是稍快一点​​。

我尝试了各种不同的方法来跳过“ xor EAX,EAX”指令,但找不到任何有效的方法。

编辑:经过更多测试,我将代码更新为三个新版本:

ft_isprint_compact:
    sub EDI, 32                             ; substract 32 from input, to overflow any value < ' '
    xor EAX, EAX                            ; set return value to 0
    cmp EDI, 94                             ; check if input <= '~' - 32
    setbe AL                                ; if so, set return value to 1
    ret

ft_isprint_branch:
    xor EAX, EAX                            ; set return value to 0
    cmp EDI, 127                            ; check for non-ascii (>127) input
    ja .out_of_bounds                       ; if non-ascii was found, skip dereferenciation
    mov AL, byte [rel ascii_flags + EDI]    ; dereference index into least sig. byte
.out_of_bounds:
    ret

ft_isprint:
    mov EAX, 128                            ; load default index
    cmp EDI, EAX                            ; check if ascii
    cmovae EDI, EAX                         ; replace with 128 if outside 0..127
                                            ; cmov also zero-extends EDI into RDI
;   movzx EAX, byte [ascii_flags + RDI]     ; alternative to two following instruction if masking is removed
    mov AL, byte [ascii_flags + RDI]        ; load table entry
    and EAX, flag_print                     ; apply mask to get correct bit and zero rest of EAX
    ret

性能如下(以微秒为单位)。 1-2-3显示了执行顺序,以避免缓存优势:

-O3 a.out
1 cond 153185, 2 branch 238341 3 no_table 145436
1 cond 148928, 3 branch 248954 2 no_table 116629
2 cond 149599, 1 branch 226222 3 no_table 117428
2 cond 117258, 3 branch 241118 1 no_table 147053
3 cond 117635, 1 branch 228209 2 no_table 147263
3 cond 146212, 2 branch 220900 1 no_table 147377
-O3 main.c
1 cond 132964, 2 branch 157963 3 no_table 131826
1 cond 133697, 3 branch 159629 2 no_table 105961
2 cond 133825, 1 branch 139360 3 no_table 108185
2 cond 113039, 3 branch 162261 1 no_table 142454
3 cond 106407, 1 branch 133979 2 no_table 137602
3 cond 134306, 2 branch 148205 1 no_table 141934
-O0 a.out
1 cond 255904, 2 branch 320505 3 no_table 257241
1 cond 262288, 3 branch 325310 2 no_table 249576
2 cond 247948, 1 branch 340220 3 no_table 250163
2 cond 256020, 3 branch 415632 1 no_table 256492
3 cond 250690, 1 branch 316983 2 no_table 257726
3 cond 249331, 2 branch 325226 1 no_table 250227
-O0 main.c
1 cond 225019, 2 branch 224297 3 no_table 229554
1 cond 235607, 3 branch 199806 2 no_table 226286
2 cond 226739, 1 branch 210179 3 no_table 238690
2 cond 237532, 3 branch 223877 1 no_table 234103
3 cond 225485, 1 branch 201246 2 no_table 230591
3 cond 228824, 2 branch 202015 1 no_table 226788

no table版本的速度与cmov差不多,但是不允许使用易于实现的本地变量。除非针对零优化中的可预测数据,否则分支算法会更糟。我在那里没有任何解释。

我将保留cmov版本,该版本既最优雅,又易于更新。感谢您的所有帮助。

2 个答案:

答案 0 :(得分:8)

cmov是一项ALU选择操作,始终在 检查条件之前读取两个源。使用内存源不会改变这一点。如果条件为假,这不像ARM谓词,其行为类似于NOP。 cmovz eax, [mem]也无条件地写入 EAX,无论条件如何,零扩展到RAX中。

就大多数CPU而言(无序调度程序等),cmovcc reg, [mem]的处理方式与adc reg, [mem]完全相同:3输入1输出ALU指令。 (与adc不同,{cmov编写标志,但不要紧记。)微融合内存源操作数是一个单独的uop,恰好是同一x86指令的一部分。这也是ISA规则的工作方式。

实际上,对于cmovz作为selectz来说,更合适的助记符


x86的唯一条件负载(不会在错误的地址上出错,只是可能运行缓慢)是

  • 受条件分支保护的正常负载。导致运行错误负载的分支错误预测或其他错误推测得到了相当有效的处理(也许开始了页面遍历,但是一旦识别出错误推测,就无需等待执行正确的指令流)内存操作由推测执行开始。

    如果在页面上没有找到TLB,您将无法阅读,那么直到有故障的负载达到报废状态(已知是非推测性的,因此实际上发生#PF页面错误异常)之后,发生的事件不会更多这不可避免地会很慢)。在某些CPU上,这种快速处理会导致Meltdown攻击。 >。<请参阅http://blog.stuffedcow.net/2018/05/meltdown-microarchitecture/

  • rep lodsd的RCX = 0或1。(不是快速或高效,但是微代码分支是特殊的,不能从分支预测中受益,在Intel CPU上。请参阅{{3 }}。Andy Glew提到了微代码分支的错误预测,但我认为这些错误预测与正常的分支未命中有所不同,因为似乎存在固定成本。)
  • AVX2 What setup does REP do? / AVX1 vmaskmovps/pd 。对于掩码为0的元素,故障得到了抑制。即使从合法地址开始,掩码也为全0掩码的掩码加载需要使用基址+索引寻址模式的〜200个周期的微码辅助。英特尔优化手册中的C-8。 (在Skylake上,使用全零掩码存储到非法地址也需要帮助。)

    早期的MMX / SSE2 vpmaskmovd/q仅用于存储(并带有NT提示)。只有具有dword / qword(而不是字节)元素的类似AVX指令才具有加载形式。

  • AVX512屏蔽的负载
  • AVX2会收集一些/所有遮罩元素清除。

...也许还有其他我忘记的东西。 TSX / RTM事务内部的正常负载:故障中止事务 而不是引发#PF。但是您不能指望错误的索引错误,而不仅仅是从附近的某个地方读取虚假数据,因此这实际上不是有条件负载。它也不是超级快。


另一种选择可能是cmov一个无条件使用的地址,选择要从中加载的地址。例如如果您有0可以从其他地方加载,那就可以了。但是随后您必须在不使用寻址模式的寄存器中计算表索引,因此可以cmov的最终地址。

或者只是CMOV索引,并在表的末尾填充一些零字节,以便您可以从table + 128加载。

或者使用分支,它可能在很多情况下都能很好地预测。但是对于像法语这样的语言,也许不是,您会在普通文本中找到低128位和较高Unicode代码点的组合。


代码审查

请注意,[rel]仅在寻址模式中不涉及任何寄存器(RIP除外)时才起作用。 RIP相对寻址取代了两种冗余方式(以32位代码)之一来编码[disp32]。它使用较短的非SIB编码,而ModRM + SIB仍可以对没有寄存器的绝对[disp32]进行编码。 (对于[fs: 16]之类的地址很有用,相对于具有段基的线程本地存储而言,偏移量较小。)

如果您只想在可能的情况下使用相对RIP寻址,请在文件顶部使用default rel [symbol]将是RIP相对的,但[symbol + rax]不会。不幸的是,NASM和YASM默认为default abs

[reg + disp32]是在位置相关的代码中索引静态数据的一种非常有效的方法,只是请不要自欺欺人地认为它可能是相对于RIP的。参见section 12.9 CONDITIONAL SIMD PACKED LOADS AND STORES

[rel ascii_flags + EDI]也很奇怪,因为您正在以寻址模式使用x86-64代码的32位寄存器。通常没有理由花一个地址大小的前缀将地址截断为32位。

但是,在这种情况下,如果您的表位于虚拟地址空间的低32位中,而您的函数arg仅指定为32位(因此,允许调用方将垃圾放在RDI的高32位),使用[disp32 + edi]而不是mov esi,edi或进行零扩展的东西实际上是一个胜利。如果您是故意这样做的,请明确说明为什么要使用32位寻址模式。

但是在这种情况下,对索引使用cmov会对您零扩展到64位。

使用字节表中的DWORD加载也很奇怪。您偶尔会越过缓存行边界并遭受额外的延迟。


@fuz显示了使用相对于RIP的LEA 索引的CMOV的版本。

在可以使用32位绝对地址的位置相关代码中,请务必使用它来保存指令[disp32]寻址方式比RIP相对(长1个字节)要差,但是当位置相关代码和32位绝对地址都可以时,[reg + disp32]寻址方式就很好。 (例如x86-64 Linux,但OS X的可执行文件始终映射在低32位之外。)请注意,它不是rel

; position-dependent version taking advantage of 32-bit absolute [reg + disp32] addressing
; not usable in shared libraries, only non-PIE executables.
ft_isprint:
    mov     eax, 128               ; offset of dummy entry for "not ASCII"
    cmp     edi, eax               ; check if ascii
    cmovae  edi, eax               ; replace with 128 if outside 0..127
              ; cmov also zero-extends EDI into RDI
    movzx   eax, byte [ascii_flags + rdi] ; load table entry
    and     al, flag_print         ; mask the desired flag
      ; if the caller is only going to read / test AL anyway, might as well save bytes here
    ret

如果表中的任何现有条目都具有您想要用于高输入的相同标志,例如也许您不会在隐式长度字符串中看到条目0,但仍可以将EAX异或为零,并将表保留为128字节,而不是129。

test r32, imm32占用的代码字节超出了您的需求~127 = 0xFFFFFF80将适合符号扩展字节,但没有maskmovdqu编码。不过,cmp有这样一种编码,就像基本上所有其他立即指令一样。

您可以改用cmp edi, 127 / cmovbe eax, edicmova edi, eax检查127以上的未签名。这样可以节省3个字节的代码大小。或者,我们可以使用cmp reg,reg和表索引使用的128来节省4个字节。

对于大多数人来说,在数组索引之前进行范围检查也比通过高位检查更为直观。

and al, imm8仅2个字节,而and r/m32, sign-extended-imm8仅3个字节。只要调用者仅读取AL,它就不会在任何CPU上变慢。在Sandybridge之前的Intel CPU上,与AL进行AND运算后读取EAX可能会导致部分寄存器停顿/减速。如果我没记错的话,Sandybridge不会重命名用于读取-修改-写入操作的部分寄存器,并且IvB和更高版本根本不会重命名low8部分寄存器。

您也可以使用mov al, [table]代替movzx来保存另一个代码字节。较早的mov eax, 128已经打破了对EAX的旧值的任何错误依赖,因此它不应该降低性能。但是movzx并不是一个坏主意。

当其他所有条件都相同时,较小的代码大小几乎总是更好的(对于指令缓存来说,甚至有时在打包到uop缓存中)。如果花费额外的成本或引入任何虚假的依赖关系,那么在优化速度时就不值得了。

答案 1 :(得分:4)

如Peter Cordes所述,cmovCC无条件地从内存加载。要缓解此问题,您可以做的一件事是首先在edi上执行有条件移动,以清除字符超出范围的edi,从而导致有条件移动从ascii_flags[0]加载并避免您的问题。方便地,eax已经很清楚了。

还请注意,您可能希望避免将32位寄存器用作基址和索引寄存器,因为它们需要额外的前缀来表示,并且在某些体系结构上可能较慢。只需使用其64位版本即可。

ft_isprint:
    xor EAX, EAX                ; empty EAX
    test EDI, ~127              ; check for non-ascii (>127) input
    cmovnz EDI, EAX             ; clear EDI if not ascii
    cmovz EAX, [ascii_flags + RDI]  ; load ascii table if input fits
    and EAX, flag_print         ; get specific bit
    ret

要解决Peter Cordes的其他问题,我实际上将使用如下代码:

; PIC/PIE safe version, doing only a byte load
ft_isprint:
    lea   rsi, [rel ascii_flags] ; load address of ascii_flags
    mov   eax, 128               ; load offset of dummy entry for "not ASCII"
    test   edi, ~127             ; check if ascii
    cmovz  eax, edi              ; load proper entry if ascii
    movzx  eax, byte [rsi + rax] ; load table entry
    and    eax, flag_print       ; mask the desired flag
    ret