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

Assembly (i386): Math Coprocessor Stack

我正在阅读有关数学协处理器 (Paul Carters PC Assembly Book) 及其进行浮点计算的说明(在 ASM i386 上)。然后我 运行 进入下面的代码,它应该 return 两个给定双精度值的较大双精度值(C 调用约定):

 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 函数的 return 值。如果另一个函数将超过 8 float/double 个值推送到协处理器堆栈,存储在协处理器堆栈 (ST7) 最低成员中的值是否会被丢弃?那么在不清除协处理器堆栈的情况下离开一个函数真的是个问题吗? => (阅读编辑)

我想改变的第二件事是我可能不会使用第 13 行的指令 FCOMP。我理解 它存在的原因是将 ST0 从stack 使 ST1 到达顶部。但是,我认为进行整体比较并设置协处理器标志只是为了弹出值有点开销。我查找了一条仅用于弹出 ST0 的指令,显然有 none。我认为使用 FADDP ST0, ST0(将 ST0 添加到 ST0 并将 ST0 弹出)或 FSTP ST0(将 ST0 的值存储到 ST0 并将 ST0 弹出)会更快。他们只是在我的脑海里看着协处理器的工作量减少了。

我尝试测试 3 个选项(上面代码中的 FSTP ST0FADDP ST0, ST0)的速度,经过几次快速测试后,它们都 运行 非常相似的速度。从价值观中得出结论有点不准确。 显然 FADDP ST0,ST0 快一点,然后是 FSTP ST0,最后是 FCOMP ST0是否有关于使用哪一个的建议?还是我太在意某些对整体速度影响如此微不足道的东西?

我只是问自己,因为 Assembly 是以最快的方式做事,也许在这些方法中选择一种可能会有好处。


编辑:

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

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

有关在现代 CPU 上静态分析此代码的延迟、吞吐量和总微指令所需的一切,请参阅 Agner Fog's microarch guide and instruction tables. Also, the tag wiki for more links

哦,绝对不要使用 ENTER 指令,除非完全优化大小而不关心速度。它出奇地慢,即使在 0, 0 情况下也是如此。


平衡 FP 堆栈:

throws an exception if the stack overflows or underflows

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


C Calling Convention

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

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

; 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 函数内联到该指令是一个糟糕的主意。特别是在所有 XMM 寄存器都被调用破坏的调用约定(如 System V,由非 Windows 使用)中,因此调用者必须 save/restore 所有 doublefloat 跨函数调用临时内存。


如果你必须用 x87 指令写这个

fcomp st0 不是弹出 x87 堆栈的最佳方法。使用 fstp st0 来做到这一点。

看起来您假设是 P6 或更新的 CPU(因为您也使用 FCOMI/FCOMIP), so you might as well take advantage of 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

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

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

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

  • 2x FLD:端口 2 或端口 3 为 2 微指令
  • FUCOMI:p0/p1
  • 3 微指令
  • jcc:p0/p6 1 uop(假设预测未采用)
  • FCMOV: 3 微指令 (2p0 1p5)
  • FSTP 注册:p0/p1
  • 1 uop
  • ret:p6 1 uop(与 p237 负载微融合。这很有趣,我认为 p7 仅用于简单的存储地址。可能是 table 中的错字)

总融合域 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