x86处理器能够独立或并行执行FPU操作吗?

11
我的老师说处理器有时可以并行执行FPU操作,就像这样:
float a = 3.14;
float b = 5.12;
float c;
float d = 3.02;
float e = 2.52;
float f;
c = a + b;
f = e + d;

据我所知,上述2个添加操作比以下操作执行速度更快:

float a = 3.14;
float b = 5.12;
float c;
float d = 3.02;
float e = 2.52;
float f;
c = a + b;
f = c + d;

因为处理器必须等待 c 计算完成。我想验证这一点,所以我编写了一个执行第二个操作的函数,并通过检查时间戳计数器来测量时间:
flds    h # st(7)
flds    g # st(6)
flds    f # st(5)
flds    e # st(4)
flds    d # st(3)
flds    c # st(2)
flds    b # st(1)
flds    a # st(0)
fadd    %st, %st(1) # i = a + b
fmul    %st, %st(2) # j = i * c
fadd    %st, %st(3) # k = j + d
fmul    %st, %st(4) # l = k + e
fadd    %st, %st(5) # m = l + f
fmul    %st, %st(6) # n = m * g
fadd    %st, %st(7) # o = n + h

那些不是独立的。现在,我正在尝试编写独立的代码。但问题是,无论我实际上做什么,值总是保存到ST(0)(无论使用哪个指令),可选地可以弹出,但这仍然意味着我们必须等待计算。
我查看了编译器生成的代码(gcc -S)。它在st寄存器上并不像这样操作。对于每个数字,它执行以下操作:
flds number
fstps -some_value(%ebp)

例如,对于a和b(其中-4(%ebp)是a,-8(%ebp)是b):

flds    -4(%ebp)
fadds   -8(%ebp) # i = a + b
fstps   -32(%ebp)

首先它加载到FPU,然后弹回正常堆栈。然后,它弹出一个值(到st(0)),将其添加到该值中,并将结果弹出。因此,它仍然不是独立的,因为我们必须等待st(0)被释放。

我的老师说错了吗?或者有没有一种方法可以使它们独立,从而在我测量时给出明显不同的执行时间?


1
现今的微处理器具备寄存器重命名功能,其中一个体系结构寄存器名称可以映射到物理寄存器文件中的多个寄存器。因此,如果st(0)被写入、读取,然后由独立计算再次写入,硬件将在不可见的情况下重命名st(0),以避免独立计算中的WAR(写后读)数据危险。 - Iwillnotexist Idonotexist
1
无论如何,现在大多数浮点数学都不是在传统的x87单元上完成的,而是使用矢量SSE单元,使用标量指令,例如movss xmm0,-somvevalue(%rbp)addss xmm0,xmm1或它们的矢量表兄弟movaps xmm0,-somvevalue(%rbp)addps xmm0,xmm1。矢量寄存器也可以重命名,并且它们比x87单元中的硬件浮点堆栈更容易使用。特别是它们更好地暴露了并行性。 - Iwillnotexist Idonotexist
1个回答

18
按照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开头的助记符,包括FADDFMULFDIVFLDFSTP等。这些指令不能成对*出现,因此永远无法真正独立执行。
只有一个特殊例外,即FXCH指令(浮点交换)不遵循x87 FPU指令不能成对出现的规则。当FXCH作为一对指令中的第二条指令出现时,它可以与第一条指令配对,前提是该对指令中的第一条指令是FLDFADDFSUBFMULFDIVFCOMFCHSFABS,且紧随FXCHG之后的下一条指令也是浮点指令。因此,这确实涵盖了您最常使用FXCHG的情况。正如Iwillnotexist Idonotexist在评论中所暗示的那样,这个神奇的功能是通过寄存器重命名在内部实现的: FXCH指令实际上并不交换两个寄存器的内容,它只交换寄存器的名称。在奔腾处理器和更高版本的处理器上,寄存器可以在使用过程中进行重命名,甚至可以每个时钟周期重命名多次,而不会产生任何停顿。这个特性实际上非常重要,可以保持x87代码的最佳性能。为什么呢?因为x87具有基于堆栈的接口,其“寄存器”(st0st7)被实现为一个堆栈,并且几个浮点指令仅对堆栈顶部的值(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]   ; 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 计算,然后返回到a计算。每第三个FADD指令返回到原始计算,遵循一个正常的模式。在此期间,FXCH指令用于使堆栈顶部(st(0))包含属于适当计算的值。可以编写等效的代码来使用FSUBFMULFILD,因为所有三个都需要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 不支持此类配对。

哇,这太棒了。谢谢,我会尽力修正我的代码,也许尝试一下来自Fogs书中的想法。至于这个AT&T语法——他们就是强迫我用它,所以我真的不能换成英特尔的。 - minecraftplayer1234

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接