我的老师声称处理器有时可以并行执行 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)
被释放。
我的老师是说错了什么,还是有没有办法让他们独立,在我测量时给出明显不同的执行时间?
在PolitiFact的风格中,我会将你老师所说的"处理器有时可以并行执行FPU操作"评为"半真半假"。从某种意义上说,在某些条件下,这是完全正确的;从其他意义上说,这根本不是真的。因此,笼统的陈述是非常误导的,很可能被误解。
现在,最有可能的是,你的老师在一个非常具体的上下文中说了这句话,对他之前已经告诉你的事情做出了一些假设,而你没有在问题中包括所有这些,所以我不会责怪他们故意误导。相反,我将试图澄清这种一般性主张,指出它在某些方面是正确的,而在其他方面它是错误的。
最大的症结正是"FPU操作"的含义。传统上,x86 处理器在单独的浮点协处理器(称为浮点单元或 FPU)x87 上执行 FPU 操作。在80486处理器之前,这是一个安装在主板上的单独芯片。从 80486DX 开始,x87 FPU 直接集成到与主处理器相同的芯片上,因此可用于所有系统,而不仅仅是那些安装了专用 x87 FPU 的系统。今天仍然如此——所有 x86 处理器都有一个内置的兼容 x87 的 FPU,这通常是人们在 x86 微架构中说"FPU"时所指的。
但是,x87 FPU 很少再用于浮点运算。尽管它仍然存在,但它已被 SIMD 单元有效地取代,该单元既易于编程,又(通常)更高效。
AMD 率先在 K6-2 微处理器(大约 1998 年)中引入了这种专门的矢量单元,其 3DNow! 技术。由于各种技术和营销原因,除了在某些游戏和其他专业应用程序中,它并没有真正被使用,并且从未在行业中流行过(AMD已经在现代处理器上逐步淘汰了它),但它确实支持对打包的单精度浮点值进行算术运算。
当英特尔发布带有奔腾 III 处理器的 SSE 扩展时,SIMD 才真正开始流行起来。SSE 与 3DNow! 类似,因为它支持对单精度浮点值的向量运算,但与它不兼容,并且支持稍大的操作范围。AMD也迅速为其处理器添加了SSE支持。与3DNow相比,SSE的真正好处是它使用了一组完全独立的寄存器,这使得编程变得更加容易。在奔腾 4 中,英特尔发布了 SSE2,它是 SSE 的扩展,增加了对双精度浮点值的支持。支持 64 位长模式扩展 (AMD64) 的所有处理器都支持SSE2,AMD64 是目前制造的所有处理器,因此 64 位代码实际上总是使用 SSE2 指令来操作浮点值,而不是 x87 指令。即使在 32 位代码中,SSE2 指令在今天也很常用,因为自奔腾 4 以来的所有处理器都支持它们。
除了支持传统处理器之外,今天使用 x87 指令实际上只有一个原因,那就是 x87 FPU 支持特殊的"长双精度"格式,精度为 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
、FSUB
、FMUL
、FDIV
、FCOM
、FCHS
或FABS
,并且FXCHG
后面的下一条指令也是浮点指令。因此,这确实涵盖了您将使用FXCHG
的最常见情况。正如 Iwillnotexists Idonotexists 在评论中提到的,这个魔术是通过寄存器重命名在内部实现的:FXCH
指令实际上并没有像你想象的那样交换两个寄存器的内容;它只交换寄存器的名称。在奔腾及更高版本的处理器上,寄存器可以在使用时重命名,甚至可以在每个时钟上重命名多次,而不会引起任何停顿。此功能实际上对于在 x87 代码中保持最佳性能非常重要。为什么?好吧,x87 的不寻常之处在于它具有基于堆栈的界面。它的"寄存器"(st0
到st7
)是作为一个堆栈实现的,几个浮点指令只对堆栈顶部的值(st0
)进行操作。但是,允许您以合理有效的方式使用 FPU 基于堆栈的接口的功能几乎不算作"独立"执行。
但是,许多 x87 FPU 操作确实可以重叠。这就像任何其他类型的指令一样:自奔腾以来,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
。如您所见,只需 4 个时钟周期即可执行多达 6 个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)
)包含属于适当计算的值。等效的代码可以为FSUB
、FMUL
和FILD
编写,因为这三者都需要3个时钟周期并且能够重叠。(好吧,除了这一点,至少在奔腾上 - 我不确定这是否适用于后来的处理器,因为我不再使用x87 -FMUL
指令不是完美的流水线,所以你不能在一个又一个FMUL
一个时钟周期中启动一个FMUL
。你要么有一个摊位,要么你必须在中间抛出另一个指令。
我想这种事情就是你老师的想法。然而,在实践中,即使有FXCHG
指令的魔力,也很难编写真正实现显著并行性的代码。您需要有多个可以交错的独立计算,但在许多情况下,您只是在计算一个大的公式。有时有一些方法可以独立地并行计算公式的各个部分,然后在最后将它们组合在一起,但不可避免地会出现停滞,从而降低整体性能,并且并非所有浮点指令都可以重叠。正如您可能想象的那样,这很难实现,以至于编译器很少这样做(在任何重要程度上)。它需要具有决心和毅力的人手动优化代码,手动调度和交错指令。
更常见的一件事是交错浮点和整数指令。像FDIV
这样的指令很慢(奔腾上~39个周期),并且与其他浮点指令不能很好地重叠;但是,除了第一个时钟周期外,它可以与整数指令重叠。(总是有警告,这也不例外:浮点除法不能与整数除法重叠,因为它们在几乎所有处理器上都由同一个执行单元处理。类似的事情可以用FSQRT
完成.编译器更有可能执行这些类型的优化,假设您编写的代码中整数运算散布在浮点运算周围(内联对此有很大帮助),但是,在许多情况下,当您进行扩展浮点计算时,您几乎没有需要完成的整数工作。
更好地了解了实现真正"独立"浮点运算的复杂性,以及为什么您编写的FADD
+FMUL
代码实际上并不重叠或执行速度更快,让我简要介绍一下您在尝试查看编译器输出时遇到的问题。
(顺便说一下,这是一个很好的策略,也是我学习如何编写和优化汇编代码的主要方法之一。当我想手动优化特定的代码片段时,构建编译器的输出仍然是我的开始方式。
正如我上面提到的,现代编译器不会生成 x87 FPU 指令。它们从不适用于 64 位版本,因此您必须从在 32 位模式下编译开始。然后,通常必须指定一个编译器开关,指示它不使用 SSE 指令。在 MSVC 中,这是/arch:IA32
.在 Gnu 风格的编译器中,如 GCC 和 Clang,这是-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
.在 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语法。
*从奔腾处理器(大约 1993 年)开始,在处理器主要部分执行的整数指令可以"配对"。这是通过处理器实际上具有两个几乎独立的执行单元来实现的,称为"U"管道和"V"管道。这种配对自然有一些警告 — "V"管道比"U"管道更受其执行指令的限制,因此某些指令和某些指令组合是不可配对的 — 但总的来说,这种配对的可能性使奔腾的有效带宽翻了一番,使其在相应编写的代码上比其前身(486)快得多。我在这里要说的是,与处理器的主要整数侧相比,x87 FPU不支持这种类型的配对。