按照PolitiFact 的风格,我将您老师的“处理器有时可以并行执行FPU 操作”的说法评价为“半真半假”。在某些意义上和某些条件下,它是完全正确的;在其他意义上,这根本不是真的。因此,做出笼统的陈述是非常具有误导性的,而且很可能会被误解。
现在,您的老师很可能是在非常具体的背景下说的,对他之前已经告诉过您的内容做了一些假设,而您并没有在问题中包含所有这些内容,所以我不会不要责怪他们故意误导。相反,我将尝试澄清这一普遍主张,指出它在某些方面是正确的,而在其他方面是错误的。
最大的症结正是“FPU 操作”的含义。传统上,x86 处理器在单独的浮点协处理器(称为浮点单元,或 FPU)上执行 FPU 操作,即 x87。在 80486 处理器之前,这是安装在主板上的单独芯片。从 80486DX 开始,x87 FPU 直接集成到与主处理器相同的硅片上,因此可用于所有系统,而不仅仅是那些安装了专用 x87 FPU 的系统。今天仍然如此——所有 x86 处理器都具有内置的与 x87 兼容的 FPU,这通常是人们在 x86 微架构上下文中说“FPU”时所指的意思。
但是,x87 FPU 很少再用于浮点运算。尽管它仍然存在,但它已被 SIMD 单元有效地取代,该单元更易于编程且(通常)更高效。
AMD 是第一个通过他们的 3DNow 引入这种专用矢量单元的人! K6-2 微处理器中的技术(大约 1998 年)。由于各种技术和营销原因,它并没有真正被使用,除了在某些游戏和其他专业应用程序中,并且从未在行业中流行(AMD 已经在现代处理器上逐步淘汰它),但它确实支持算术运算打包的单精度浮点值。
当英特尔发布带有 Pentium III 处理器的 SSE 扩展时,SIMD 真正开始流行起来。 SSE 与 3DNow! 类似,因为它支持对单精度浮点值的向量运算,但与它不兼容,并且支持的运算范围稍大。 AMD 也迅速为其处理器添加了 SSE 支持。与 3DNow 相比,SSE 的真正优点!是它使用了一组完全独立的寄存器,这使得编程变得更加容易。在 Pentium 4 中,英特尔发布了 SSE2,它是 SSE 的扩展,增加了对双精度浮点值的支持。 所有处理器都支持 SSE2,这些处理器支持 64 位长模式扩展 (AMD64),这是当今制造的所有处理器,因此 64 位代码几乎总是使用 SSE2 指令操作浮点值,而不是 x87 指令。即使在 32 位代码中,SSE2 指令今天也很普遍,因为自 Pentium 4 以来的所有处理器都支持它们。
除了支持传统处理器之外,现在使用 x87 指令实际上只有一个原因,那就是 x87 FPU 支持一种特殊的“long double”格式,具有 80 位精度。 SSE 仅支持单精度(32 位),而 SSE2 增加了对双精度(64 位)值的支持。如果您绝对需要扩展精度,那么 x87 是您的最佳选择。 (在单个指令的级别上,它的速度与在标量值上运行的 SIMD 单元相当。)否则,您更喜欢 SSE/SSE2(以及后来的指令集 SIMD 扩展,如 AVX 等)而且,当然,当我说“你”时,我不仅仅指汇编语言程序员;我也指编译器。例如,Visual Studio 2010 是最后一个为 32 位构建默认发出 x87 代码的主要版本。在所有更高版本中,都会生成 SSE2 指令,除非您专门将其关闭 (/arch:IA32)。
使用这些 SIMD 指令,可以同时完成多个浮点运算是完全正确的——事实上,这就是重点。即使您使用标量(非压缩)浮点值,如您所展示的代码中所示,现代处理器通常具有多个执行单元,允许同时完成多个操作(假设满足某些条件,正如您所指出的,例如缺乏数据依赖性,以及正在执行哪些特定指令[某些指令只能在某些单元上执行,从而限制了真正的并行性])。
但正如我之前所说,我之所以称这种说法具有误导性,是因为当有人说“FPU”时,它通常被理解为是指 x87 FPU,在这种情况下,独立并行执行的选项是 基本上更有限。 x87 FPU指令都是助记符以f开头的指令,包括FADD、FMUL、FDIV、FLD、FSTP等。这些指令不能配对* 因此永远不能真正独立地执行。
x87 FPU 指令不能配对的规则只有一个特殊例外,那就是FXCH 指令(浮点交换)。 FXCH 可以当它作为一对中的第二条指令出现时,只要对中的第一条指令是FLD,FADD,@ 987654336@、FMUL、FDIV、FCOM、FCHS或FABS,和FXCHG之后的下一条指令也是浮点指令。因此,这确实涵盖了您将使用FXCHG 的最常见情况。正如Iwillnotexist Idonotexist alluded to in a comment,这个魔法是通过寄存器重命名在内部实现的:FXCH 指令实际上并没有交换两个寄存器的内容,正如你想象的那样;它只交换寄存器的名称。在 Pentium 和更高版本的处理器上,寄存器可以在使用时重命名,甚至每个时钟可以重命名多次,而不会导致任何停顿。这个特性实际上对于在 x87 代码中保持最佳性能非常重要。为什么?嗯,x87 的不同之处在于它有一个基于堆栈的接口。它的“寄存器”(st0 到st7)被实现为堆栈,并且一些浮点指令仅对堆栈顶部的值(st0)进行操作。但是,允许您以相当有效的方式使用 FPU 的基于堆栈的接口的功能几乎不能算作“独立”执行。
但是,许多 x87 FPU 操作确实可以重叠。这就像任何其他类型的指令一样工作:因为 Pentium,x86 处理器已经流水线,这实际上意味着指令在许多不同的阶段执行。 (流水线越长,执行阶段越多,这意味着处理器一次可以处理的指令越多,这通常也意味着处理器的时钟速度越快。但是,它还有其他缺点,例如更高的惩罚错误预测的分支,但我离题了。)因此,尽管每条指令仍然需要固定数量的周期才能完成,但一条指令有可能在前一条指令完成之前开始执行。例如:
fadd st(1), st(0) ; clock cycles 1 through 3
fadd st(2), st(0) ; clock cycles 2 through 4
fadd st(3), st(0) ; clock cycles 3 through 5
fadd st(4), st(0) ; clock cycles 4 through 6
FADD 指令需要 3 个时钟周期来执行,但我们可以在每个时钟周期启动一个新的 FADD。如您所见,最多可以在 6 个时钟周期内执行 4 个FADD 操作,这比在非流水线 FPU 上执行的 12 个时钟周期快一倍。
当然,正如您在问题中所说,这种重叠要求两条指令之间没有依赖关系。换句话说,如果第二条指令需要第一条的结果,则两条指令不能重叠。在实践中,不幸的是,这意味着流水线的收益是有限的。由于我前面提到的 FPU 的基于堆栈的体系结构,以及大多数浮点指令都涉及堆栈顶部的值 (st(0)) 的事实,因此指令可以在极少数情况下独立于上一条指令的结果。
解决这个难题的方法是我前面提到的 FXCH 指令的配对,如果您在调度时非常小心和聪明,它可以交错多个独立的计算。 Agner Fog 在他的经典 optimization manuals 的旧版本中给出了以下示例:
fld [a1] ; cycle 1
fadd [a2] ; cycles 2-4
fld [b1] ; cycle 3
fadd [b2] ; cycles 4-6
fld [c1] ; cycle 5
fadd [c2] ; cycles 6-8
fxch st(2) ; cycle 6 (pairs with previous instruction)
fadd [a3] ; cycles 7-9
fxch st(1) ; cycle 7 (pairs with previous instruction)
fadd [b3] ; cycles 8-10
fxch st(2) ; cycle 8 (pairs with previous instruction)
fadd [c3] ; cycles 9-11
fxch st(1) ; cycle 9 (pairs with previous instruction)
fadd [a4] ; cycles 10-12
fxch st(2) ; cycle 10 (pairs with previous instruction)
fadd [b4] ; cycles 11-13
fxch st(1) ; cycle 11 (pairs with previous instruction)
fadd [c4] ; cycles 12-14
fxch st(2) ; cycle 12 (pairs with previous instruction)
在这段代码中,三个独立的计算已经交错:(a1 + a2 + a3 + a4),(b1 + b2 + b3 + b4),和(c1 + c2 + c3 + c4)。由于每个 FADD 需要 3 个时钟周期,在我们开始 a 计算之后,我们有两个“空闲”周期来启动两个新的 FADD 指令,用于 b 和 c 计算,然后返回到 @ 987654372@ 计算。每三个FADD 指令按照常规模式返回到原始计算。在这两者之间,FXCH 指令用于使栈顶 (st(0)) 包含属于适当计算的值。可以为FSUB、FMUL 和FILD 编写等效代码,因为这三个代码都需要 3 个时钟周期并且能够重叠。 (好吧,除此之外,至少在 Pentium 上——我不确定这是否适用于后来的处理器,因为我不再使用 x87——FMUL 指令不是完美流水线的,所以你不能启动一个FMUL 一个又一个时钟周期 FMUL。要么你有一个停顿,要么你必须在其间抛出另一条指令。)
我想这种事情是你老师的想法。然而,在实践中,即使有 FXCHG 指令的魔力,编写真正实现显着并行度的代码也相当困难。您需要有多个可以交错的独立计算,但在许多情况下,您只是在计算一个单一的大公式。有时有一些方法可以独立、并行地计算公式的各个部分,然后在最后将它们组合起来,但是您将不可避免地在其中遇到会降低整体性能的停顿,并且并非所有浮点指令都可以重叠。正如您可能想象的那样,这很难实现,编译器很少这样做(在很大程度上)。它需要有决心和毅力的人手动优化代码,手动调度和交错指令。
更常见的一件事是交错浮点和整数指令。 FDIV 之类的指令很慢(在 Pentium 上大约 39 个周期),并且不能与其他浮点指令很好地重叠;但是,除了第一个时钟周期外,它可以与整数指令重叠。 (总有一些警告,这也不例外:浮点除法不能与整数除法重叠,因为它们在几乎所有处理器上都由相同的执行单元处理。)FSQRT 可以做类似的事情。编译器更有可能执行这些类型的优化,假设您已经编写了整数运算散布在浮点运算周围的代码(内联对此有很大帮助),但在许多情况下,您仍在进行扩展浮点运算点计算,您需要完成的整数工作很少。
现在您对实现真正“独立”浮点运算的复杂性有了更好的了解,以及为什么您编写的 FADD+FMUL 代码实际上并没有重叠或执行得更快,让我简要介绍一下解决您在尝试查看编译器输出时遇到的问题。
(顺便说一句,这是一个很棒的策略,也是我学习如何编写和优化汇编代码的主要方法之一。基于编译器的输出构建仍然是我开始时的方式想要手动优化特定的 sn-p 代码。)
如上所述,现代编译器不会生成 x87 FPU 指令。对于 64 位版本,它们从不,因此您必须从在 32 位模式下编译开始。然后,您通常必须指定一个编译器开关,指示它不要使用 SSE 指令。在 MSVC 中,这是/arch:IA32。在 GCC 和 Clang 等 Gnu 风格的编译器中,这是-mfpmath=387 和/或-mno-sse。
还有一个小问题可以解释您实际看到的内容。您编写的 C 代码使用了 float 类型,它是一种单精度(32 位)类型。正如您在上面了解到的,x87 FPU 在内部使用特殊的 80 位“扩展”精度。精度上的不匹配会影响浮点运算的输出,因此为了严格遵守 IEEE-754 和特定于语言的标准,编译器在使用 x87 FPU 时默认为“严格”或“精确”模式,它们会刷新每个中间操作的精度为 32 位。这就是为什么你会看到你所看到的模式:
flds -4(%ebp)
fadds -8(%ebp) # i = a + b
fstps -32(%ebp)
它在 FPU 堆栈的顶部加载一个单精度值,隐式将该值扩展为具有 80 位精度。这是FLDS 指令。然后,FADDS 指令执行组合加载和添加:它首先加载一个单精度值,隐式将其扩展为具有 80 位精度,并将其添加到 FPU 堆栈顶部的值中。最后,它将结果弹出到内存中的一个临时位置,将其刷新为 32 位单精度值。
您完全正确,您不会使用这样的代码获得任何并行性。即使是基本的重叠也变得不可能。但是像这样的代码是为 precision 而生成的,而不是为速度而生成的。 All sorts of other optimizations are disabled, too, in the name of correctness.
如果您想防止这种情况并尽可能获得最快的浮点代码,即使以牺牲正确性为代价,那么您需要传递一个标志来向编译器表明这一点。在 MSVC 上,这是/fp:fast。在 Gnu 风格的编译器上,比如 GCC 和 Clang,这是-ffast-math。
其他一些相关提示:
当您分析编译器生成的反汇编时,始终确保您正在查看优化的代码。不要为未优化的代码而烦恼;它非常嘈杂,只会让您感到困惑,并且与真正的汇编程序员实际编写的内容不符。然后,对于 MSVC,使用 /O2 开关;对于 GCC/Clang,使用 -O2 或 -O3 开关。
除非您真的很喜欢 AT&T 语法,否则请配置您的 Gnu 编译器或反汇编器以发出 Intel 格式的语法列表。这些将确保输出看起来像您在英特尔手册或其他汇编语言编程书籍中看到的代码。对于编译器,使用选项-S -masm=intel。对于objdump,使用选项-d -M intel。 Microsoft 的编译器不需要这样做,因为它从不使用 AT&T 语法。
* 从 Pentium 处理器(大约 1993 年)开始,在处理器主要部分执行的整数指令可以“配对”。这是通过处理器实际上具有两个主要独立的执行单元来完成的,称为“U”管道和“V”管道。这种配对自然有一些注意事项——“V”管道比“U”管道在它可以执行的指令方面受到更多限制,因此某些指令和某些指令组合是不可配对的——但总的来说,这配对的可能性使 Pentium 的有效带宽翻了一番,使其在相应编写的代码上比其前身(486)快得多。我在这里要说的是,与处理器的主要整数端相比,x87 FPU不支持这种类型的配对。