assembly x86 asm程序,根据x的值求f=x^2-1的负或零

8fq7wneg  于 2023-04-21  发布在  其他
关注(0)|答案(1)|浏览(146)

我的任务是编写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
qlfbtfca

qlfbtfca1#

使用调试器单步调试代码并查看寄存器。此外,FP分支使用与无符号整数相同的条件,如jae。但是如果您花一些时间来寻找更有效的方法来计算结果,则无需在此处进行分支。
x86-64通常情况下,你会想使用SSE 2 mulsd/addsd进行标量FP数学。特别是如果你不打算使用有用的x87指令,如fabs作为检查|x| < 1.0的一部分。(你可以很容易地对SSE 2做同样的事情,使用andps xmm0, [mask],其中掩码是align 16/mask: dq (1<<63) - 1来清除符号位)。
我认为你可以将其优化为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代码显然在main返回后不使用RBP。类似于Jester指出的printf碰巧只关心RDX中的variadic参数的副本,而不是XMM 1。(https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view = msvc-170#varargs)
只是为了好玩,我很好奇这样做的效率有多高。
事实证明,如果我们使用FMA和AVX 1,只有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,即使使用默认值以外的舍入模式(最接近,使用偶数尾数作为抢七)。 因此,可能在XMM 0中计算x^2 - 1,然后pshufd xmm1, xmm0, 0b01010101(复制double的高dword)/psrad xmm1, 31来广播double的符号位(没有psraq64位算术右移)。 然后andnps xmm1, xmm0将其归零(与全零位)或不加修改(与全1位)根据该符号位的倒数。我猜在copysign之前做pshufd/psrad,(有一条andnpd指令,但它的功能与andnps相同,但在机器码中长了1个字节,所以它是无意义的。) 我看了看GCC/clang编译器的输出(Godbolt)对于一个接受double并返回你想要的值的函数,对于一个简单的分支版本,比如问题描述,以及copysign版本。它们做的工作比必要的多一点,但是clang确实为copysign版本制作了完全无分支的代码,像我建议的那样将if (x*x - 1 >= 0)转换为按位操作,包括andnpd来应用条件。这很有趣:) 它使用xorpd-zeroing +cmpltsd来实际与零进行比较,而不是在符号位上进行算术右移以获得相同的结果,这可能更有效(xor-zeroing非常便宜,但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),但在Intel上可能会更慢,特别是在桤木Lake E-cores(4 uops)上。https://uops.info/。非VEX版本(SSE4.1 blendvpd,它隐式地接受XMM 0中的混合控制)在Skylake和更高版本(包括Alder Lake P和E内核)上只有1 uop。
我认为我们可以重新安排x^2-1在XMM 0中,而两个源在其他寄存器中。非AVX版本blendvpd xmm1, xmm2必须覆盖其中一个输入,所以这对于Windows来说很好,因为我们希望结果在XMM 1中,而不是XMM 0中。
我们没有使用任何YMM寄存器的上半部分,因此将128位AVX指令和传统SSE混合使用是安全的,而不会产生转换损失。
如果我们不介意返回-0.0而不是+0.0作为负输入,我们可以保存另一个寄存器,使用vandpdx & signmask结果,基本上是copysign(0.0, x)。问题指定返回0,这与IEEE FP数学中的-0.0不同,即使它与-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,ε的平方在舍入误差中丢失。
我的FMA/blendvpd版本有一个bug:如果输入为-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 vs. cmplesd)。由于x^2-1在那一点上在数学上是0,因此通过将结果强制为零来获得它与从计算结果中获得它一样有效。事实上,如果我们关心零的符号,则对于FP数学更好,因为我们是在玩copysign的把戏,而不是实际执行1 - x^2x^2 - 1,这两个都将产生+0.0
但是如果我们想直接使用x^2 - 1减法的符号位,这就像|x| >= 1.0比较。
正如布鲁斯Dawson)在一本优秀的series of articles about FP math中所写的那样,借用了道格拉斯·亚当斯(Douglas Adams)的一句话:
[浮点]数学很难。
你不会相信这有多么的困难。我的意思是,你可能认为很难计算出芝加哥和洛杉矶的火车什么时候会相撞,但这对浮点数学来说只是花生。
我花了大约几个小时的时间来处理这个问题,以使asm“更简单”和更有效,而不仅仅是天真地分支问题描述的方式。我很有信心现在两个版本都是正确的,但我只是在写这个答案的时候很晚才抓住了FMA版本的-0.0输出,而当时我正在处理vblendvpd版本。只有在这一点上注意到一个错误的评论比较版本,它实际上是做|x| > 1.0|x| <= 1.0,而不是|x| >= 1.0像我以为我在做。:P
巧妙地使用浮点数学通常是可能的,并且通常可以导致更有效的代码,但是100%确定它在所有情况下都是正确的需要careful testing
(我还没有测试过+-Infinity输入,但两个版本都应该可以工作,产生正确符号的无穷大。对于NaN输入,我们应该得到一个带有输入符号的NaN输出。或者+0.0,这取决于我们如何比较。0.0 < NaN是false,所以我认为所有NaN输入都会为任何NaN输入给予零输出。)

相关问题