x86 ASM 程序根据 x 的值查找 f=x^2-1 取反或归零



我的任务是编写 x86 asm 代码,该代码将输出:

  • (-x^2 + 1) 如果 x 为 <= -1;
  • 如果 x 的绝对值小于 1,则为 0;
  • (x^2 - 1) 如果 x>= 1。

这就是我想出的:

  • 第一个问题是,在第一种情况下,结果不是负面的。如果我输入 -3,它会导致 8 而不是 -8。
  • 其次,如果 x 的值小于 1,例如 0.5,则输出 -0.75 而不是 0。

我是组装的初学者,这让我有点困惑,所以如果你能帮助我让它正常工作,我将不胜感激。

代码如下:

extern printf, scanf
global main
section .data 
in_fmt  db "%lf", 0
out_fmt db "Result: %lf", 10, 0

one dq 1.0
minus_one dq -1.0
section .bss
x resb 8
f resb 8
temp resb 8

section .text
main:
mov rbp, rsp; for correct debugging
;scanf()
mov rdx, x           
mov rcx, in_fmt       
sub rsp, 40            
call scanf
add rsp, 40

fld qword[x] ;x (input) is pushed on stack


;if x <= -1
fld qword[minus_one] ;-1 and x are on stack
fcomi
jle case1

fstp qword[temp] ;x is on stack

;if |x| <= 1 
fld qword[one] ;1 and x are on stack
fcomi
jle case2


case3:
fstp qword[temp] ;x is on stack
fmul qword[x] ;x*x is on stack
fsub qword[one] ;x*x + 1 is on stack
fstp qword[f] ; stack empty
jmp print

case2:
mov qword[f], 0
jmp print

case1:
fstp qword[temp] ;x is on stack
fmul qword[x] ;x*x is on stack
fmul qword[minus_one] ;-(x*x) is on stack
fadd qword[one] ;-(x*x) + 1 is on stack
fstp qword[f] ;stack empty

print:
mov rcx, out_fmt
mov rdx, qword[f]
sub rsp, 40
call printf
add rsp, 40

xor rax, rax
ret

使用调试器单步执行代码并查看寄存器。 此外,FP 分支使用与无符号整数相同的条件,如jae。 但是,如果您花一些时间想出一种更有效的方法来计算结果,则不需要在此处进行分支。

这是x86-64;通常您希望使用SSE2mulsd/addsd进行标量FP数学运算。 特别是如果您不打算使用有用的 x87 指令(如fabs作为检查|x| < 1.0的一部分。 (您可以使用SSE2轻松执行相同的操作,使用掩码为align 16/mask: dq (1<<63) - 1andps xmm0, [mask]来清除符号位)。

我认为您可以将其优化为copysign(x^2 - 1, x)加上检查|x| <= 0.copysignSSE 数学只是andps隔离符号位并orps应用它的问题,因为您只关心"目标"为非负的情况。 否则,您还将andnps清除目标中的符号位,以将符号位 1 与另一个位的其他位混合。


与您的FP逻辑无关,但是mov rbp, rsp; for correct debugging很奇怪,没有push rbp。(并将sub rsp, 40调整为32以保持堆栈对齐。main是一个应遵循调用约定的函数,并且 RBP 是调用保留的。

它恰好有效,因为调用main的 CRT 代码显然在主返回后不使用 RBP。类似于Jester指出的printf碰巧只关心RDX中可变参数的副本,而不是XMM1中。 (https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170#varargs)


只是为了好玩,我很好奇这能有多有效地完成。

事实证明,如果我们使用 FMA 和 AVX1,只有 4 条 FP 指令实际对 FP 值进行操作,而不计算负载和将寄存器归零。 否则 6.

你不需要单独的fabs(x)x^2 - 1只有在|x| <= 1.0时才为负数。接近 1.0 但不完全相等的数字在平方时会远离 1.0,因此 FP 舍入误差不会导致任何数字x^2 - 1 >= 0.0|x|<1.0',即使使用默认模式以外的舍入模式(最近,甚至尾数作为平局)。

因此,也许在XMM0中计算x^2 - 1,然后pshufd xmm1, xmm0, 0b01010101(复制双精度的高字)/psrad xmm1, 31广播双精度的符号位(没有psraq64位算术右移)。

然后andnps xmm1, xmm0根据该符号位的倒数将其归零(AND 具有全零位)或使其保持不变(AND 具有全一位)。我想在copysign之前做pshufd/psrad,但之后andnps。(有一个andnpd指令,但它的功能与andnps相同,但在机器代码中长 1 个字节,所以它毫无意义。

我查看了GCC/clang编译器输出(Godbolt),该函数采用双精度并返回所需的值,用于简单的分支版本(如问题描述)和copysign版本。他们做了比必要的更多的工作,但 clang 确实为 copysign 版本制作了完全无分支的代码,像我建议的那样将if (x*x - 1 >= 0)转换为按位操作,包括应用条件andnpd。 所以这很有趣:)

它使用xorpd-zeroing +cmpltsd来实际与零进行比较,而不是在符号位上进行算术右移以获得相同的结果,这可能更有效(异或归零非常便宜,但cmpltsd竞争与其他 FP 数学相同的执行端口)。

我自己去优化它,看看如果没有AVX,我们是否需要许多movaps寄存器复制指令;结果不是。 如果我们在循环中执行此操作,则只有一个,而不是每次都将常量重新加载到新的寄存器中。

我使用的是Linux而不是Windows,因此库函数(printf/scanf)的调用约定使用不同的寄存器,并且没有阴影空间。 我没有优化阴影空间,所以更容易转换回Windows。

; tested and works on x86-64 GNU/Linux; for Windows only the scanf/printf calls need to change
; nasm -felf64 foo.asm && gcc -no-pie foo.o
extern printf, scanf
global main
default rel
section .rodata             ; read only data.  Windows calls this .rdata
signmask: dq 1<<63        ; aka -0.0
one: dq 1.0

in_fmt:  db "%lf", 0
out_fmt: db "Result: %g", 10, 0      ; %g to output numbers close to 0 in scientific notation.

section .text
main:
;  mov rbp, rsp; for correct debugging.  Not needed, and not sufficient for backtracing through main on its own.
;scanf()
sub rsp, 40
lea rdi, [in_fmt]
lea rsi, [rsp+32]      ; scan into stack space, no need for a global
xor  eax, eax          ; x86-64 System V not Windows: AL = 0 xmm args
call scanf
movsd   xmm1, [rsp+32]
;  pcmpeqd xmm2, xmm2
;  psllq   xmm2, 63               ; sign-bit mask, -0.0
movsd   xmm2, [signmask]       ; or use a memory constant
andps   xmm2, xmm1             ; sign bit of x
mulsd   xmm1, xmm1
subsd   xmm1, [one]           ; x*x - 1.0
orps    xmm2, xmm1            ; copysign from original x
xorps   xmm0, xmm0
cmpltsd xmm0, xmm1            ; 0 < x*x-1   aka x*x-1 > 0.0 which is also |x| > 1.0.   This produces a +0.0 result instead of -0.0 for an input of -1.0
andps   xmm0, xmm2            ; zero if |x| < 1.0, else the copysign result
.print:
lea rdi, [out_fmt]
mov eax, 1          ; x86-64 System V not Windows: AL = 1 xmm arg
; first FP arg in XMM0 for x86-64 SysV.
; on Windows it would go in XMM1 and RDX because it's the second overall arg.
call printf
add rsp, 40

xor eax, eax
ret

使用AVX + FMA,我们可以保存更多指令

vmovsd   xmm1, [rsp+32]            ; x
vandpd  xmm2, xmm1, [signmask]     ; xmm2 = sign bit of x, leaving xmm1 unmodified
; signmask is probably 16-byte aligned, that's why I put it at the start of .rdata.  We don't care that the top 8 bytes are of this 16-byte load, as long as they don't fault e.g. by going off the end of a page.
; we don't *need* 16-byte alignment with AVX instructions, so I didn't use align 16
vfmsub213sd xmm1, xmm1, [one]  ; x*x - 1.0   like mulsd / subsd but without rounding error between mul and sub
vorps     xmm2,xmm2, xmm1         ; copysign from original x
vxorpd    xmm0,xmm0, xmm0         ; zero a register to blend with.
vblendvpd xmm0, xmm2, xmm0, xmm1  ; pick values from xmm2 (copysign) and xmm0 (0.0) according to sign bit in xmm2 (x^2-1)
; 1 uop on Zen.  2 uops on Intel before Ice Lake, 3 uops on Alder Lake.

vblendvpd保存一条指令(vcmpltsd),但在英特尔上可能会变慢,尤其是在桤木湖 E 核(4 uops)上。 https://uops.info/。非 VEX 版本(SSE4.1blendvpd隐式采用 XMM0 中的混合控制)在 Skylake 及更高版本(包括 Alder Lake P 和 E 内核)上只有 1 uop。

我认为我们可以重新排列在 XMM0 中有 x^2-1,而其他寄存器中的两个来源。 非AVX版本,blendvpd xmm1, xmm2,必须覆盖其中一个输入,所以这对于Windows来说很好,无论如何我们都希望在XMM1中产生结果,而不是XMM0。

我们没有使用任何YMM寄存器的上半部分,因此混合128位AVX指令和传统SSE是安全的,而不会受到转换损失。

如果我们不介意返回-0.0而不是+0.0负输入,我们可以保存另一个寄存器,使用x & signmask的结果来自vandpd,这基本上是copysign(0.0, x). 该问题指定返回0,这与 IEEE FP 数学中的-0.0不同,即使它与它比较相等。


两个版本都可以在+-1.0附近的边界附近正常工作;FMA只是效率提升;我们不需要在减去之前不将x*x舍入到最接近的double的额外精度。 当输入接近 1 + DBL_EPSILON(大于 1.0 的最小数字,可表示为双精度),我们得到 2x DBL_EPSILON 的输出(2x 2.22e-16 约为 4.44e-16),例如

$ ./a.out
1.00000000000000012
Result: 4.44089e-16
$ ./a.out
-1.00000000000000012
Result: -4.44089e-16

(1+epsilon)^2 - 1(1^2 + 2*epsilon + epsilon^2) - 1的,而 epsilon 平方在舍入误差中丢失。

我的FMA/blendvpd版本有一个错误:输入-1.0,它产生-0而不是+0。 在默认舍入模式下,1.0 - 1.0的正确结果是 +1.0。

$ ./sse-compare
-1.0
Result: 0
$ ./fma-blend
-1.0          
Result: -0
$ ./fma-blend
-0.99999999999999 
Result: 0

cmpltsd版本通过检查|x| > 1.0而不是|x| >= 1.0(cmpltsdcmplesd)。 由于x^2-1在数学上是 0,因此从强制结果变为零来获取它与从计算结果中获取它一样有效。 事实上,如果我们关心零的符号,FP 数学会更好,因为我们是在玩copysign的把戏,而不是实际做1 - x^2x^2 - 1两者都会产生+0.0

但是,如果我们想直接使用x^2 - 1减法的符号位,那就像|x| >= 1.0比较。


正如布鲁斯·道森(Bruce Dawson)在一系列关于FP数学的优秀文章中所写的那样,借用道格拉斯·亚当斯(Douglas Adams)的一句话:

[浮点]数学很难。

你只是不会相信它是多么巨大,巨大,令人难以置信的困难。我的意思是,你可能认为很难计算从芝加哥和洛杉矶出发的火车何时会相撞,但这只是花生到浮点数学。

我花了大约几个小时来解决这个问题,使"更简单"和更有效的asm,而不仅仅是天真地分支问题描述的陈述方式。 我现在非常有信心两个版本都是正确的,但我只是在使用vblendvpd版本时在写这个答案时很晚才发现 FMA 版本的-0.0输出,直到那时才注意到比较版本评论中的一个错误,它实际上是在做|x| > 1.0|x| <= 1.0, 不像我想象的那样|x| >= 1.0。:P

聪明地使用浮点数学通常是可能的,并且通常可以导致更有效的代码,但 100% 确定它在所有情况下都是正确的需要仔细测试。

(我还没有用+-无穷大输入进行测试,但两个版本都应该可以工作,产生无穷大的正确符号。 对于 NaN 输入,我们应该得到一个带有输入符号的 NaN 输出。 或 +0.0,具体取决于我们的比较方式。0.0 < NaN是错误的,所以我认为所有 NaN 输入都会为任何 NaN 输入提供零输出。

相关内容

  • 没有找到相关文章

最新更新