也许使用 x87 浮点单元计算 ex 的最有效方法是以下指令序列:
; Computes e^x via the formula 2^(x * log2(e))
fldl2e ; st(0) = log2(e) <-- load log2(e)
fmul [x] ; st(0) = x * log2(e)
fld1 ; st(0) = 1 <-- load 1
; st(1) = x * log2(e)
fld st(1) ; st(0) = x * log2(e) <-- make copy of intermediate result
; st(1) = 1
; st(2) = x * log2(e)
fprem ; st(0) = partial remainder((x * log2(e)) / 1) <-- call this "rem"
; st(1) = 1
; st(2) = x * log2(e)
f2xm1 ; st(0) = 2^(rem) - 1
; st(1) = 1
; st(2) = x * log2(e)
faddp st(1), st(0) ; st(0) = 2^(rem) - 1 + 1 = 2^(rem)
; st(1) = x * log2(e)
fscale ; st(0) = 2^(rem) * 2^(trunc(x * log2(e)))
; st(1) = x * log2(e)
fstp st(1)
结果留在st(0)。
如果您已经在浮点堆栈上输入了x,那么您可以稍微修改指令序列:
; st(0) == x
fldl2e
fmulp st(1), st(0)
fld1
fld st(1)
fprem
f2xm1
faddp st(1), st(0)
fscale
fstp st(1)
这将 x 乘以 log2e,找到除以 1 的余数,将 2 取幂为该值的幂(f2xm1 也减去 1,然后我们再加 1) ,最后将其缩放 x × log2e。
另一种实现——本质上是suggested by fuz,让人想起 MSVC 为 C 标准库中的 exp 函数生成的代码——如下:
; st(0) == x
fldl2e
fmulp st(1), st(0)
fld st(0)
frndint
fsub st(1), st(0)
fxch ; pairable with FSUB on Pentium (P5)
f2xm1
fld1
faddp st(1), st(0)
fscale
fstp st(1)
主要区别在于使用frndint 和fsub 来获得-1.0 到+1.0 范围内的值,这是f2xm1 所要求的,而不是使用fprem 来获得余数除以 1 后。
为了了解这些指令的相对成本,我们将从Agner Fog's instruction tables 中提取数据。一种 ”?”表示对应的数据不可用。
Instruction AMD K7 AMD K8 AMD K10 Bulldozer Piledriver Ryzen
-------------------------------------------------------------------------------------------
FLD/FLD1/FLDL2E [all very fast, 1-cycle instructions, with a reciprocal throughput of 1]
FADD(P)/FSUB(P) 1/4/1 1/4/1 1/4/1 1/5-6/1 1/5-6/1 1/5/1
FMUL(P) 1/4/1 1/4/1 1/4/1 1/5-6/1 1/5-6/1 1/5/1
FPREM 1/7-10/8 1/7-10/8 1/?/7 1/19-62/? 1/17-60/? 2/?/12-50
FRNDINT 5/10/3 5/10/3 6/?/37 1/4/1 1/4/1 1/4/3
FSCALE 5/8/? 5/8/? 5/9/29 8/52/? 8/44/5 8/9/4
F2XM1 8/27/? 53/126/? 8/65/30? 10/64-71/? 10/60-73/? 10/50/?
上面使用的符号是“ops/latency/reciprocal throughput”。
Instruction 8087 287 387 486 P5 P6 PM Nehalem
-------------------------------------------------------------------------------------------
FLD 17-22 17-22 14 4 1/0 1/? 1/1 1/1
FLD1 15-21 15-21 24 4 2/0 2/? 2/? 2/?
FLDL2E 15-21 15-21 40 8 5/2 2/? 2/? 2/?
FADD(P)/FSUB(P) 70-100 70-100 23-34 8-20 3/2 1/3 1/3 1/3
FMUL(P) 90-145 90-145 29-57 16 3/2 1/5 1/5 1/5
FPREM 15-190 15-190 74-155 70-138 16-64 (2) 23/? 26/37 25/14
FRNDINT 16-50 16-50 66-80 21-30 9-20 (0) 30/? 15/19 17/22
FSCALE 32-38 32-38 67-86 30-32 20-32 (5) 56/? 28/43 24/12
F2XM1 310-630 310-630 211-476 140-279 13-57 (2) 17-48/66 ~20/? 19/58
对于 P5 之前的版本,该值只是循环计数。对于 P5,符号是循环计数,括号中表示与其他 FP 指令重叠。对于 P6 及更高版本,表示法是 µops/latency。
很明显,f2xm1 是一条缓慢而昂贵的指令,但两种实现都使用它并且很难避免。尽管它很慢,但它仍然方式比将其实现为循环快。
(如果您愿意为了速度而牺牲代码大小,那么一种可能的方法是绕过缓慢的f2xm1 指令,那就是基于查找表的方法。如果您搜索,您可以找到几篇关于此发表的论文,尽管他们中的大多数都在付费墙后面。:-( Here's one publicly-accessible reference。事实上,这可能是用 C 编写的优化数学库为实现 exp 所做的事情。Ben Voigt wrote similar code in C#。与往常一样,基本问题在这里,您是否在优化大小或速度?换句话说,您是否经常调用此函数并且需要它尽可能快?或者您是否不经常调用它并且只需要精确,不会在二进制文件中占用太多空间,也不会通过从缓存中逐出热路径上的其他代码来减慢它的速度。)
但是fprem 与frndint 呢?
嗯,在 AMD CPU 上,fprem 的解码操作数比frndint 少,尽管在现代 AMD CPU 上frndint 比fprem 更快。再说一次,我想说这无关紧要,因为在那些 CPU 上,您将编写 SSE2/AVX 代码,而不是使用传统的 x87 FPU。 (这些指令在后来的型号英特尔微架构上也会变慢,比如 Sandy Bridge、Haswell 等,但同样的论点也适用于那里,所以我在编译数据时完全忽略了这些。)真正重要的是这段代码如何执行较旧的 CPU,在较旧的 CPU 上,使用 fprem 的版本显然是赢家。
然而,在 Intel CPU 上,情况正好相反:frndint 通常比 fprem 快,但在 P6(Pentium Pro、Pentium II 和 Pentium III)上除外。
但是,我们只是在谈论frndint 与fprem——我们实际上是在谈论fprndint+fsub 与fprem。 fsub 是一个相对较快的指令,但是如果不执行它并对其计时,就很难预测该代码将具有什么性能。循环计数只能告诉我们这么多,而且fprem 序列总体上更短(指令更少,更重要的是,字节更少——18 对 22),这可能意味着显着的速度提升。
如果您不想费心对其进行基准测试,或者您想要在所有 CPU 上运行良好的通用程序,两种实现都很好。它们都不会在性能方面引起轰动,但正如我之前所说,它们都将明显比尝试计算泰勒级数的循环快。那里的开销会影响你的表现。