按照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的扩展,增加了对双精度浮点值的支持。支持64位长模式扩展(AMD64)的所有处理器都支持SSE2,这是今天制造的所有处理器,因此64位代码几乎总是使用SSE2指令来操作浮点值,而不是x87指令。即使在32位代码中,SSE2指令也是常用的,因为自Pentium 4以来,所有处理器都支持它们。
除了支持旧处理器外,今天实际上只有一个原因可以使用x87指令,那就是x87 FPU支持特殊的“长双精度”格式,具有80位精度。 SSE仅支持单精度(32位),而SSE2增加了对双精度(64位)值的支持。如果您绝对需要扩展精度,则x87是您最好的选择。 (在单个指令级别上,它的速度与标量值上的SIMD单元相当。)否则,您更喜欢SSE / SSE2(以及后来的指令集的SIMD扩展,如AVX等)。当然,当我说“您”时,我不仅指汇编语言程序员;我也指编译器。例如,Visual Studio 2010是最后一个默认为32位生成x87代码的主要版本。在所有以后的版本中,除非您明确关闭它们(/ arch:IA32),否则将生成SSE2指令。
使用这些SIMD指令,完全可以同时执行多个浮点操作-实际上,这就是整个重点。即使在使用标量(非打包)浮点值的代码中,现代处理器通常具有多个执行单元,允许同时执行多个操作(假设满足某些条件,例如没有数据依赖性,正如您所指出的,并且还执行哪些特定指令[某些指令只能在某些单元上执行,从而限制了真正的并行性])。
但是,正如我之前所说的,我认为这个说法是误导性的,因为当有人说“FPU”时,通常指的是x87 FPU,而在这种情况下,独立并行执行的选项
非常有限。 x87 FPU指令都是以
f
开头的助记符,包括
FADD
、
FMUL
、
FDIV
、
FLD
、
FSTP
等。这些指令
不能成对
*出现,因此永远无法真正独立执行。
只有一个特殊例外,即
FXCH
指令(浮点交换)不遵循x87 FPU指令不能成对出现的规则。当
FXCH
作为一对指令中的第二条指令出现时,它
可以与第一条指令配对,前提是该对指令中的第一条指令是
FLD
、
FADD
、
FSUB
、
FMUL
、
FDIV
、
FCOM
、
FCHS
或
FABS
,且紧随
FXCHG
之后的下一条指令也是浮点指令。因此,这确实涵盖了您最常使用
FXCHG
的情况。正如
Iwillnotexist Idonotexist在评论中所暗示的那样,这个神奇的功能是通过寄存器重命名在内部实现的:
FXCH
指令实际上并不交换两个寄存器的内容,它只交换寄存器的名称。在奔腾处理器和更高版本的处理器上,寄存器可以在使用过程中进行重命名,甚至可以每个时钟周期重命名多次,而不会产生任何停顿。这个特性实际上非常重要,可以保持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在他经典的
优化手册的早期版本中给出了以下示例:
fld [a1]
fadd [a2]
fld [b1]
fadd [b2]
fld [c1]
fadd [c2]
fxch st(2)
fadd [a3]
fxch st(1)
fadd [b3]
fxch st(2)
fadd [c3]
fxch st(1)
fadd [a4]
fxch st(2)
fadd [b4]
fxch st(1)
fadd [c4]
fxch st(2)
在这段代码中,三个独立的计算被交替进行:(
a1
+
a2
+
a3
+
a4
),(
b1
+
b2
+
b3
+
b4
)和(
c1
+
c2
+
c3
+
c4
)。由于每个
FADD
需要3个时钟周期,在我们启动
a计算后,我们有两个“空闲”周期来启动两个新的
FADD
指令用于
b和 c 计算,然后返回到
a计算。每第三个
FADD
指令返回到原始计算,遵循一个正常的模式。在此期间,
FXCH
指令用于使堆栈顶部(
st(0)
)包含属于适当计算的值。可以编写等效的代码来使用
FSUB
、
FMUL
和
FILD
,因为所有三个都需要3个时钟周期并且能够重叠。(嗯,除了至少在奔腾处理器上——我不确定这是否适用于后来的处理器,因为我不再使用x87——
FMUL
指令不是完全流水线化的,因此您不能在另一个
FMUL
之后立即启动
FMUL
指令。你要么有停顿,要么必须在中间加入另一个指令。)
我想象你的老师考虑的就是这种情况。但实际上,即使使用了
FXCHG
指令的魔力,编写真正实现显著并行性水平的代码也是相当困难的。您需要有多个独立的计算可以交错进行,但在许多情况下,您只是在计算单个大公式。有时候可以独立地并行计算公式的各个部分,然后在最后将它们组合起来,但您不可避免地会有停滞,从而降低整体性能,并且并非所有浮点指令都可以重叠。正如您可以想象的那样,这很难实现,以至于编译器很少做到(在任何显著程度上)。这需要手动优化代码的决心和毅力,手动调度和交错指令。
有时可以交错使用浮点数和整数指令。像
FDIV
这样的指令速度较慢(在 Pentium 上约为 39 个周期),且与其他浮点指令重叠效果不佳;但是,在其第一个时钟周期之外,它可以与整数指令并行。 (总会有例外情况,这也不例外:由于几乎所有处理器都由同一执行单元处理,因此浮点除法不能与整数除法并行。)
FSQRT
也可以做类似的事情。编译器更可能执行这些类型的优化,假设您已经编写了将整数操作穿插在浮点操作周围的代码(内联可大大提高此功能),但是,在许多情况下,您进行扩展浮点计算时需要做很少的整数工作。
现在您对实现真正“独立”的浮点运算的复杂性有了更好的理解,以及为什么您编写的
FADD
+
FMUL
代码实际上没有重叠或执行得更快,请让我简要介绍您在尝试查看编译器输出时遇到的问题。
(顺便说一下,这是一种非常好的策略,也是我学习如何编写和优化汇编代码的主要方式之一。并且在想要手动优化特定代码片段时,仍然可以从编译器的输出开始构建。)
如上所述,现代编译器不生成 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位单精度值。
你完全正确,像这样的代码不会获得任何并行性。甚至基本的重叠也变得不可能。但是这样的代码是为了精度而生成的,而不是为了速度。各种其他优化也被禁用了,以确保正确性。
如果您想防止这种情况并获得最快的浮点代码,即使以牺牲正确性为代价,那么您需要向编译器传递一个指示标志。在MSVC上,这是“/ fp:fast”。对于GCC和Clang等Gnu风格的编译器,则是“-ffast-math”。
还有一些相关的提示:
- 当您分析编译器生成的反汇编代码时,请始终确保您正在查看经过优化的代码。不要使用未经优化的代码;它非常嘈杂,只会让您感到困惑,并且与实际的汇编程序员编写的代码不匹配。对于MSVC,请使用“/ O2”开关;对于GCC / Clang,请使用“-O2”或“-O3”开关。
- 除非您真的喜欢AT&T语法,否则请配置您的Gnu编译器或反汇编程序以发出Intel格式的语法列表。这将确保输出看起来像您在Intel手册或其他汇编语言编程书籍中看到的代码。对于编译器,请使用选项“-S -masm = intel”。对于“objdump”,请使用选项“-d -M intel”。Microsoft的编译器不需要这样做,因为它从不使用AT&T语法。
* 从 Pentium 处理器开始(约在 1993 年),在主处理器上执行的整型指令可以“配对”。这是通过处理器实际上具有两个基本独立的执行单元——称为“U”管和“V”管来实现的。自然地,这种配对有一些注意事项——“V”管在能够执行的指令方面比“U”管更受限制,因此某些指令和某些指令组合是不可配对的。但总的来说,这种配对的可能性使 Pentium 的有效带宽加倍,使它在按照相应方式编写的代码上显著快于它的前身(486)。我的意思是,与处理器的主整数端相反,x87 FPU 不支持此类配对。
st(0)
被写入、读取,然后由独立计算再次写入,硬件将在不可见的情况下重命名st(0)
,以避免独立计算中的WAR(写后读)数据危险。 - Iwillnotexist Idonotexistmovss xmm0,-somvevalue(%rbp)
和addss xmm0,xmm1
或它们的矢量表兄弟movaps xmm0,-somvevalue(%rbp)
和addps xmm0,xmm1
。矢量寄存器也可以重命名,并且它们比x87单元中的硬件浮点堆栈更容易使用。特别是它们更好地暴露了并行性。 - Iwillnotexist Idonotexist