【问题标题】:Assembly (i386): Math Coprocessor Stack汇编 (i386):数学协处理器堆栈
【发布时间】:2016-09-13 15:21:59
【问题描述】:

我正在阅读有关数学协处理器 (Paul Carters PC Assembly Book) 及其进行浮点计算的指令(在 ASM i386 上)。然后我遇到了以下代码,它应该返回两个给定双精度值中较大的双精度值(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 函数的返回值。如果另一个函数将超过 8 个浮点/双精度值推送到协处理器堆栈,存储在协处理器堆栈 (ST7) 的最低成员中的值不会被丢弃吗?那么在不清除协处理器堆栈的情况下离开函数真的是个问题吗? => (阅读编辑)

我想改变的第二件事是我可能不会使用第 13 行的指令 FCOMP。我理解 将 ST0 从堆栈中弹出的原因使 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是否有关于使用哪一个的建议? 还是我太在意对整体速度影响如此微不足道的事情?

我只是问自己,因为 Assembly 是以尽可能快的方式做事,也许在其中一种方法之间进行选择可能会有好处。


编辑:

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

【问题讨论】:

  • 世界正在迅速耗尽这种代码仍然有意义的机器。特别是当您使用汇编时。请改用 SSE2 代码。如果您不知道它是什么样子,请使用最新的 C 编译器。
  • @HansPassant IIRC NASA 在卫星中确实坚持使用旧的 386-486 CPU 有一段时间了。凭借它们的大晶体管,它们不太容易受到改变某些位值的宇宙射线的影响。但这是几年前的信息,我不知道目前的状态。 :) 在其他地方,它可能与您编写的一样,SSE2 和更多可用。
  • @Ped7g:这使用了 FCOMI,它只在 p6 上可用。

标签: performance assembly floating-point x86 x87


【解决方案1】:

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

有关在现代 CPU 上静态分析此代码的延迟、吞吐量和总 uops 所需的一切,请参阅 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 参数。)


C 调用约定

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

在通常的 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 函数内联到该指令是一个糟糕的主意。特别是在调用约定(如非 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(?) -> fcmov(2c) -> fstp st1 (1c)。这些周期计数适用于 Intel Haswell。总延迟 = 大概 5 + 5(假设 FUCOMI 为 2c)。

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

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

  • 2x FLD:端口 2 或端口 3 2 微指令
  • FUCOMI:p0/p1 3 微指令
  • jcc:p0/p6 为 1 uop(假设预测未采用)
  • FCMOV:3 微指令 (2p0 1p5)
  • FSTP 注册:1 uop 用于 p0/p1
  • ret: 1 uop for p6(微融合了 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

【讨论】:

  • 抱歉拖了一段时间才回复,我还在消化你给我的所有新信息和你的回答!您确实向我明确表示,预测指令延迟并不像弄清楚它的作用那么简单,还要考虑许多 CPU 效率机制,例如寄存器命名(我不得不承认我不知道)。我会继续阅读和学习,直到我完全理解你的所有答案,但为了完整性和向我展示我接受的问题的更广泛的观点!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-10-20
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多