汇编(i386):数学协处理器堆栈

时间:2016-09-13 15:22:00

标签: performance assembly floating-point x86 x87

我正在阅读关于数学协处理器(Paul Carters PC Assembly Book)及其进行浮点计算的指令(在ASM i386上)。然后我遇到了以下代码,它应该返回两个给定双值的较大的两倍( C Calling Convention ):

 1    %define d1 ebp+8
 2    %define d2 ebp+16
 3    global dmax
 4    
 5    segment .text
 6    dmax:
 7        enter 0,0
 8    
 9        fld qword [d2]
10        fld qword [d1] ;Now ST0 = d1 and ST1 = d2
11        fcomip st1 ;Compares ST0 with ST1 and pops ST0 out
12        jna short d2_bigger ;If not above (ST0<ST1)
13        fcomp st0 ;Get rid of ST0, which is actually d2 now (line 11)
14        fld qword [d1]
15        jmp short exit
16    d2_bigger:
17    exit:
18        leave
19        ret

我正在考虑改变这段代码。首先,我可能在比较(第11行)上使用FCOMI而不是FCOMIP来避免1个不必要的协处理器寄存器弹出。这样做,如果ST0 = ST1则根本没有弹出(因为它已经在堆栈的顶部)。我可以看到没有这样做的唯一原因是它会留下一堆不完整的协处理器寄存器。但是,我认为C的唯一相关值是ST0,它将是double函数的返回值。如果另一个函数将超过8个浮点/双精度值推送到协处理器堆栈,那么存储在协处理器堆栈(ST7)的最低成员中的值是否会被丢弃?那么在不清除协处理器堆栈的情况下离开函数真的是一个问题吗? =&GT; ( READ EDIT

我想改变的第二件事是我可能不会在第13行使用指令FCOMP。我理解它是将ST0弹出堆栈的原因< / strong>使ST1达到顶峰。但是,我认为进行整体比较并设置协处理器标志只是为了弹出值有点花费。我只找了一个指令来弹出ST0,显然没有。我认为使用FADDP ST0, ST0(将ST0添加到ST0并弹出ST0)或FSTP ST0(将ST0的值存储到ST0并弹出ST0)会更快。他们只是看起来像是对协处理器的工作量减少了。

我尝试测试3个选项的速度(上面代码中的那个,FSTP ST0FADDP ST0, ST0),经过几次快速测试后,它们都以非常相似的速度运行。从价值观中得出结论是不可能的。 显然 FADDP ST0,ST0有点快,其次是FSTP ST0,最后是FCOMP ST0是否建议使用哪一个?或者我是否因为对整体速度产生如此微不足道的影响而烦恼太多?

我只是质疑自己,因为大会是以最快的方式做事,也许选择其中一种方法可能会带来好处。

修改

我正在阅读Intel 64和IA-32指令集参考,显然,如果堆栈溢出或下溢,协处理器会抛出异常(异常#IS)。所以使用堆栈而不是清空它(在这种情况下,只留下ST0,因此C将弹出它的返回值)显然不是一个选项。

1 个答案:

答案 0 :(得分:3)

现代CPU处理x87寄存器堆栈操作,类似于它们为无序执行所需的寄存器重命名方式。 P87版本的x87指令执行时具有与非流行版本相同的性能特征。

对于在现代CPU上静态分析此代码的延迟,吞吐量和总uop所需的一切,请参阅Agner Fog's microarch guide and instruction tables。此外, tag wiki for more links

哦,绝对不要使用ENTER指令,除非完全优化尺寸而不关心速度。即使在0, 0案例中,它也非常缓慢。

平衡FP堆栈:

  如果堆栈溢出或下溢,

会抛出异常

大多数操作系统默认屏蔽FP异常。行为中更重要的部分是ST0在触发溢出的FLD之后保存垃圾。所以你的结论是正确的:遵循x87堆栈的ABI规则很重要:在函数调用时堆栈为空,并在返回时清空或保持浮点/双返回值。 (我不知道任何以不同方式做事的ABI,但你可以有一个调用约定,它在x87寄存器而不是堆栈中传递了一些FP args。)

  

C呼叫公约

在所有x86平台上没有针对C的单一调用约定。许多32位代码在堆栈上传递double args,并在ST(0)中返回它们,就像你正在做的那样。所以除了术语之外,这还不错。

在通常的64位调用约定中,double args在XMM寄存器中传递(每个arg在其自己的寄存器的低位元素中)。还有32位调用约定假定SSE2并以这种方式传递double。在那种情况下:

; 64-bit Windows or non-Windows, or 32-bit-with-double-in-SSE2 calling convention:
global dmax
section .text
dmax:
    maxsd   xmm0, xmm1
    ret

是的,there's an instruction for std::max(double,double) 。此时,函数调用比指令有更多的开销,并且使用asm函数而不是让C编译器将C函数内联到该指令是一个可怕的想法。特别是在调用约定(如非Windows使用的System V)中,所有XMM寄存器都被调用破坏,因此调用者必须将所有doublefloat临时值保存/恢复到内存中调用

如果您必须使用x87说明书

fcomp st0不是弹出x87堆栈的最佳方式。使用fstp st0来执行此操作。

看起来你正在假设一个P6或更新的CPU(因为你使用FCOMI/FCOMIP),所以你也可以利用FCMOVcc而不是使用分支。

; 32-bit args-on-the-stack
section .text
; when one input is NaN, might return NaN or might return the other input
; This implements the C expression  (d1 < d2)
global dmax
dmax:
    fld     qword [esp+12]
    fld     qword [esp+4]     ; ST0 = d1 and ST1 = d2

    fucomi  st0, st1
    jp     handle_nan         ; optional.  MAXSD does this for free.  If you leave this out, I suggest using fcomi instead of fucomi, to raise #IA on NaN
    FCMOVb  st0, st1          ; st0 = (st0<st1) : st1 : st0.  (Also copies if unordered because CF=1 in that case, too.  But we don't know which operand was NaN.)

    ;; our return value is in st0, but st1 is still in use.
    fstp    st1               ; pop the stack while keeping st0.  (store it to st1, which becomes st0 after popping)
    ; alternative: ffree st1   ; I think this should work
    ret

handle_nan:
    faddp                     ; add both args together to get a NaN, whichever one was NaN to start with.
    ret

这有一个非常可预测的分支(NaN可能永远不会在实际使用中发生,否则它总会发生)。关键路径是通过存储器的往返行程,用于arg通过(~5个周期),然后是fucomi(?) - &gt; fcmov(2c) - &gt; fstp st1(1c)。这些循环计数适用于Intel Haswell。总延迟=大概5 + 5(假设FUCOMI为2c)。

使用FFREE st1(如果可行),将使最终的fstp脱离关键路径。 FXCHG(零延迟)然后弹出st0也可能使其离开关键路径。英特尔有可能像FXCHG一样实现零延迟的FSTP ST1(在寄存器重命名阶段处理),但我不认为现有的微体系结构就是这种情况。 (并且不太可能成为未来的功能,因为x87大部分都已过时.IIRC,英特尔Skylake通过使更多x87指令共享相同的执行端口,略微降低了某些x87内容与Haswell的吞吐量。)

Intel Haswell吞吐量:Agner Fog的电子表格没有列出FUCOMI的延迟,但它是3 uops。 FCMOV也是3 uops,具有2个周期延迟。分支实现(可能在弹出st0之前有条件地运行FXCHG)可能是好的,如果在它预测得很好的情况下使用的话。无论如何,总的uop数:

  • 2x FLD:port2或port3
  • 为2 uops
  • FUCOMI:p0 / p1为3 uops
  • jcc:p0 / p6为1 uop(假设未采取预测)
  • FCMOV:3 uops(2p0 1p5)
  • FSTP reg:1 uop for p0 / p1
  • ret:1 uop for p6(micro-fused with a load for p237。这很有趣,我认为p7只适用于简单的商店地址。也许是表格中的拼写错误)

总融合域uops:10(不计算ret)。因此需要2.5个周期来发布(以4个为一组)。特定执行端口可能存在瓶颈,但我没有检查它。

结果gcc同意我的实施选择:)

see the code on the Godbolt compiler explorer,使用gcc6.2 -m32 -mfpmath=387 -O3 -march=haswell

编译
double dmax(double a, double b) { return a<b ? b : a; }

    fld     QWORD PTR [esp+4]
    fld     QWORD PTR [esp+12]    ;; it doesn't matter which order you load args in, IDK why I chose reverse order
    fucomi  st, st(1)
    fcmovbe st, st(1)             ;; moving when they're equal matches the C, but of course doesn't matter
    fstp    st(1)
    ret