我正在尝试将一些高性能的汇编函数编写为练习,并且遇到了奇怪的段错误,该段错误在运行程序时发生,但不是在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版本,它既最优雅,又易于更新。感谢您的所有帮助。
最佳答案
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上的分支预测中受益。请参见What setup does REP do?。Andy Glew提到了微代码分支的错误预测,但我认为与正常的分支未命中有所不同,因为似乎有固定成本。)
AVX2 vpmaskmovd/q
/ AVX1 vmaskmovps/pd
。对于掩码为0的元素,故障得到了抑制。即使从合法地址开始,掩码也为全0掩码的掩码加载需要使用基址+索引寻址模式的〜200周期微码辅助。)请参见section 12.9 CONDITIONAL SIMD PACKED LOADS AND STORES和表C-英特尔优化手册中的8。 (在Skylake上,使用全零掩码存储到非法地址也需要帮助。)
早期的MMX / SSE2 maskmovdqu
仅用于存储(并带有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的。请参见32-bit absolute addresses no longer allowed in x86-64 Linux?。[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个字节)差,但是[reg + disp32]
寻址模式在与位置相关的代码和32位绝对地址都可以的情况下非常好。 (例如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
将适合符号扩展字节,但没有TEST r/m32, sign-extended-imm8
编码。但是,与cmp
基本上所有其他立即指令一样,也有这样的编码。相反,您可以使用
cmp edi, 127
/ cmovbe eax, edi
或cmova edi, eax
检查127以上的unsigned。这样可以节省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缓存中)。如果花费额外的成本或引入任何错误的依赖关系,那么在优化速度时就不值得了。