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