我正在尝试编写一些高性能的汇编函数作为练习,并遇到了一个奇怪的segfault,发生在运行程序时,但不是在valgrind或nemiver。
基本上,一个不应该运行的cmov,一个越界的地址,即使条件总是假,也会使我产生segfault
我有一个快的和一个慢的版本。慢的那个一直在工作。快速的一个工作,除非它收到一个非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条答案
按热度按时间aydmsdu91#
cmov
是一个ALU选择操作,它总是在 * 检查条件之前读取两个源 *。使用内存源不会改变这一点。它不像ARMAssert的指令,如果条件为假,则其行为就像NOP。cmovz eax, [mem]
也无条件地 * 写入 * EAX,零扩展到RAX,而不管条件如何。就大部分CPU而言(无序调度器等),
cmovcc reg, [mem]
的处理方式与adc reg, [mem]
完全相同:3输入1输出ALU指令。(adc
写标志,不像cmov
,但不用在意。)微融合存储器源操作数是一个单独的uop,恰好是同一条x86指令的一部分。这也是伊萨规则的工作方式。所以实际上,更适合
cmovz
的助记符是selectz
。(或AArch 64的csel
。)英特尔APX(Advanced Performance Extensions)将添加
CFCMOVcc
(有条件故障CMOVcc)-有条件加载/有条件存储指令,这些指令 * 确实 * 使用假 predicate (如AVX 512掩蔽)抑制内存故障。(使用cmovcc
操作码的EVEX前缀进行编码。)x86的唯一条件加载(不会在错误地址上出错,只是可能运行缓慢)是:
*有条件分支保护的正常加载。导致运行故障加载的分支误预测或其他误推测被相当有效地处理(可能开始页面行走,但是一旦误推测被识别,正确的指令流的执行不必等待由推测执行开始的任何存储器操作)。
如果在您无法读取的页面上有一个TLB命中,那么在故障加载达到退休之前不会发生太多事情(已知是非推测性的,因此实际上会发生
#PF
页面故障异常,这不可避免地会很慢)。在某些CPU上,这种快速处理会导致Meltdown攻击。>.<参见http://blog.stuffedcow.net/2018/05/meltdown-microarchitecture/。rep lodsd
,RCX=0或1。(* 不 * 快速或高效,但微代码分支是特殊的,不能从英特尔CPU上的分支预测中受益。参见What setup does REP do?。Andy Glew提到了微代码分支错误预测,但我认为这些与正常的分支错误不同,因为似乎有一个固定的成本。*AVX2
vpmaskmovd/q
/ AVX1vmaskmovps/pd
。对于掩码为0的元素,将抑制故障。即使从法律的地址加载全0掩码,也需要大约200个周期的微码辅助,并采用基址+索引寻址模式。)参见英特尔优化手册中的第 12.9节“条件SIMD打包加载和存储” 和表C-8。(在Skylake上,使用全零面罩的非法地址的商店也需要帮助。早期的MMX/SSE 2
maskmovdqu
是只存储的(并且具有NT提示)。只有具有dword/qword(而不是byte)元素的类似AVX指令具有加载形式。cfmovcc reg, reg/[mem]
和cfmovcc dst, src1, src2/[mem]
。也是商店版本。据推测,它们使用AVX-512屏蔽标量加载/存储所使用的一些相同硬件支持。(APX还提供了大量的新内容,例如大多数传统标量整数指令的3操作数编码,32个GPR,最后是一个写入整个32/64位寄存器的setcc
形式。……也许还有一些我忘记了。TSX / RTM事务中的正常加载:错误会中止事务 * 而不是引发#PF。但是你不能指望一个坏的索引错误,而不是仅仅从附近的某个地方阅读伪造的数据,所以这不是一个真正的条件加载。它也不是超级快进入一个交易。
另一种方法可能是**
cmov
您无条件使用的地址**,选择从哪个地址加载。例如,如果你有一个0
从其他地方加载,这将工作。但是这样你就必须在寄存器中计算表索引,而不是使用寻址模式,所以你可以cmov
最终地址。或者只需要CMOV索引并在表的末尾填充一些零字节,这样就可以从
table + 128
加载。或者使用分支,它可能会很好地预测很多情况。但对于像法语这样的语言来说可能不是这样,因为在普通文本中,您会发现低128和更高的Unicode码位的混合。
代码审核
请注意,
[rel]
仅在寻址模式中不涉及寄存器(RIP除外)时才工作。RIP相对寻址取代了2种冗余方式之一(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?和a question about indexing arrays(RIP相对莱亚静态地址,并使用2寄存器寻址模式。)[rel ascii_flags + EDI]
也很奇怪,因为您在x86-64代码中的寻址模式中使用了32位寄存器。通常没有理由花费地址大小前缀来将地址截断为32位。然而,在这种情况下,如果您的表位于虚拟地址空间的低32位,并且您的函数arg仅指定为32位(因此允许调用者在RDI的高32位中留下垃圾),那么使用
[disp32 + edi]
而不是mov esi,edi
或其他零扩展实际上是一种胜利。如果你是故意这样做的,一定要说明为什么你使用32位寻址模式。但是在这种情况下,在索引上使用
cmov
将零扩展到64位。从字节表中使用DWORD加载也很奇怪。您偶尔会跨越缓存行边界并遭受额外的延迟。如果你只需要一个字节,使用
movzx eax, byte [mem]
。@fuz显示了一个在索引上使用RIP相对莱亚 * 和 * CMOV的版本。
在位置相关代码中,32位绝对地址是可以的,无论如何都要使用它来保存指令。
[disp32]
寻址模式比RIP-relative(长1个字节)差,但当位置相关代码和32位绝对地址正常时,[reg + disp32]
寻址模式完全正常。(例如x86-64 Linux,但不是OS X,其中可执行文件总是Map到低32位之外。)请注意,它不是rel
。字符串
如果表中的任何现有条目具有与高输入相同的标志,例如也许条目
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以上的无符号。这节省了3个字节的代码大小。或者我们可以通过使用cmp reg,reg
来保存4个字节,使用我们用于表索引的128
。数组索引前的范围检查对大多数人来说也比检查高位更直观。
and al, imm8
只有2个字节,而and r/m32, sign-extended-imm8
的3个字节。它在任何CPU上都不会慢,只要调用者只读AL。在Sandybridge之前的Intel CPU上,在AND到AL之后阅读EAX可能会导致部分寄存器停顿/减速。如果我没记错的话,Sandybridge不会重命名读-修改-写操作的部分寄存器,IvB和后来的版本根本不会重命名low 8部分寄存器。您也可以使用
mov al, [table]
而不是movzx
来保存另一个代码字节。早期的mov eax, 128
已经打破了对EAX旧值的任何假依赖,因此它不应该有性能下降。但是movzx
不是一个坏主意。当所有其他条件相同时,较小的代码大小几乎总是更好(对于指令缓存占用空间,有时用于打包到uop缓存中)。但是,如果它花费了任何额外的uops或引入了任何错误的依赖关系,那么在优化速度时就不值得了。
laawzig22#
正如Peter Cordes所解释的,
cmovCC
无条件地从内存加载。为了缓解这个问题,你可以先在edi
上做一个条件移动,如果字符超出范围,就清除edi
,导致条件移动从ascii_flags[0]
加载,避免你的问题。方便的是,eax
在您这样做时已经清除了。还请注意,您可能希望避免将32位寄存器作为基址和索引寄存器,因为它们需要额外的前缀来表示,并且在某些架构上可能会更慢。只需要使用64位的对应项即可。
字符串
为了解决Peter Cordes的其他问题,我实际上会使用这样的代码:
型