x86可以独立或并行执行FPU操作吗?

min*_*234 9 floating-point optimization x86 assembly 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;
Run Code Online (Sandbox Code Playgroud)

所以,正如我所听到的,上面的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;
Run Code Online (Sandbox Code Playgroud)

因为处理器必须等到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
Run Code Online (Sandbox Code Playgroud)

那些不是独立的.现在,我正在尝试写独立的.但问题是,无论我实际做什么,值总是保存到ST(0)(无论我使用哪个指令),可选择它可以弹出,但这仍然意味着我们必须等到计算.

我查看了编译器(gcc -S)生成的代码.它只是在st寄存器上不像这样运行.对于每个数字,它确实:

flds number
fstps -some_value(%ebp)
Run Code Online (Sandbox Code Playgroud)

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

flds    -4(%ebp)
fadds   -8(%ebp) # i = a + b
fstps   -32(%ebp)
Run Code Online (Sandbox Code Playgroud)

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

我的老师说错了什么,或者有没有办法使他们独立,当我测量它时会产生明显不同的执行时间?

Cod*_*ray 11

PolitiFact的风格中,我会评价你的老师的声明"处理器有时可以并行执行FPU操作"为"半真".在某些意义上和在某些条件下,它是完全正确的; 在其他方面,它根本不是真的.因此,使一般性陈述非常具有误导性,很可能被误解.

现在,很有可能,你的老师在一个非常具体的背景下说这个,对他之前已经告诉你的内容做了一些假设,你没有在问题中包含所有这些,所以我不会责怪他们因为故意误导.相反,我会尝试澄清这个一般性的主张,指出它是真实的一些方式以及其他错误的方式.

最重要的一点就是"FPU操作"的含义.传统上,x86处理器在单独的浮点协处理器(称为浮点单元或FPU)x87上完成了FPU操作.直到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代码的最后一个主要版本.在所有更高版本中,除非您专门关闭它们,否则会生成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.正如Iwillnotexist Idonotexist在评论中提到的,这种魔法是通过寄存器重命名在内部实现的:FXCH指令实际上并没有像你想象的那样交换两个寄存器的内容.它只交换寄存器的名称.在Pentium和更高版本的处理器上,寄存器可以在使用时重命名,甚至可以每个时钟重命名一次,而不会产生任何停顿.此功能对于在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
Run Code Online (Sandbox Code Playgroud)

FADD指令需要3个时钟周期才能执行,但我们可以FADD在每个时钟周期启动一个新的时钟周期.如您所见,可以FADD在6个时钟周期内完成最多4次操作,这比非流水线型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)
Run Code Online (Sandbox Code Playgroud)

在此代码中,交错了三个独立的计算:(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个时钟周期并且能够重叠.(好吧,除了那个,至少在Pentium上 - 我不确定这是否适用于后来的处理器,因为我不再使用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)
Run Code Online (Sandbox Code Playgroud)

它在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"管更受限制,因此某些指令和指令的某些组合是不可配对的 - 但一般来说,这个配对的可能性使Pentium的有效带宽翻了一番,使得它在相应编写的代码上明显快于其前身(486).我在这里说的是,与处理器的主整数方面相比,x87 FPU 支持这种类型的配对.