我的任务是编写 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) - 1
的andps xmm0, [mask]
来清除符号位)。
我认为您可以将其优化为copysign(x^2 - 1, x)
加上检查|x| <= 0
.copysign
SSE 数学只是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
广播双精度的符号位(没有psraq
64位算术右移)。
然后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
(cmpltsd
与cmplesd
)。 由于x^2-1
在数学上是 0,因此从强制结果变为零来获取它与从计算结果中获取它一样有效。 事实上,如果我们关心零的符号,FP 数学会更好,因为我们是在玩copysign
的把戏,而不是实际做1 - x^2
或x^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 输入提供零输出。