英特尔 FMA 指令提供零性能优势

Intel FMA Instructions Offer Zero Performance Advantage

使用 Haswell 的 FMA 指令考虑以下指令序列:

  __m256 r1 = _mm256_xor_ps (r1, r1);
  r1 = _mm256_fmadd_ps (rp1, m6, r1);
  r1 = _mm256_fmadd_ps (rp2, m7, r1);
  r1 = _mm256_fmadd_ps (rp3, m8, r1);

  __m256 r2 = _mm256_xor_ps (r2, r2);
  r2 = _mm256_fmadd_ps (rp1, m3, r2);
  r2 = _mm256_fmadd_ps (rp2, m4, r2);
  r2 = _mm256_fmadd_ps (rp3, m5, r2);

  __m256 r3 = _mm256_xor_ps (r3, r3);
  r3 = _mm256_fmadd_ps (rp1, m0, r3);
  r3 = _mm256_fmadd_ps (rp2, m1, r3);
  r3 = _mm256_fmadd_ps (rp3, m2, r3);

同样的计算可以使用非 FMA 指令表示如下:

  __m256 i1 = _mm256_mul_ps (rp1, m6);
  __m256 i2 = _mm256_mul_ps (rp2, m7);
  __m256 i3 = _mm256_mul_ps (rp3, m8);
  __m256 r1 = _mm256_xor_ps (r1, r1);
  r1 = _mm256_add_ps (i1, i2);
  r1 = _mm256_add_ps (r1, i3);

  i1 = _mm256_mul_ps (rp1, m3);
  i2 = _mm256_mul_ps (rp2, m4);
  i3 = _mm256_mul_ps (rp3, m5);
  __m256 r2 = _mm256_xor_ps (r2, r2);
  r2 = _mm256_add_ps (i1, i2);
  r2 = _mm256_add_ps (r2, i3);

  i1 = _mm256_mul_ps (rp1, m0);
  i2 = _mm256_mul_ps (rp2, m1);
  i3 = _mm256_mul_ps (rp3, m2);
  __m256 r3 = _mm256_xor_ps (r3, r3);
  r3 = _mm256_add_ps (i1, i2);
  r3 = _mm256_add_ps (r3, i3);

人们希望 FMA 版本比非 FMA 版本提供一些性能优势。

但不幸的是,在这种情况下,性能改进为零 (0)。

谁能帮我理解为什么?

我在基于核心 i7-4790 的机器上测量了这两种方法。

更新:

所以我分析了生成的机器代码并确定 MSFT VS2013 C++ 编译器生成机器代码使得 r1 和 r2 的依赖链可以并行调度,因为 Haswell 有 2 个 FMA 管道。

r3 必须在 r1 之后调度,因此在这种情况下,第二个 FMA 管道处于空闲状态。

我认为如果我展开循环来执行 6 组 FMA 而不是 3 组,那么我可以让所有 FMA 管道在每次迭代时都处于忙碌状态。

不幸的是,当我在这种情况下检查程序集转储时,MSFT 编译器没有选择允许我正在寻找的并行调度类型的寄存器分配,并且我验证了我没有得到性能增加我一直在寻找的。

有没有一种方法可以更改我的 C 代码(使用内部函数)以使编译器生成更好的代码?

您没有提供包含周围循环的完整代码示例(大概周围循环),所以很难给出明确的答案,但主要问题我看到你的 FMA 代码的依赖链的延迟比你的乘法 + 加法代码长得多。

您的 FMA 代码中的三个块中的每一个都在执行相同的独立操作:

TOTAL += A1 * B1;
TOTAL += A2 * B2;
TOTAL += A3 * B3;

因为它是结构化的,每个操作都取决于之前的到期时间,因为每个操作的读写总数。所以这串操作的延迟是 3 ops x 5 cycles/FMA = 15 cycles.

在没有 FMA 的重写版本中,TOTAL 上的依赖链现已断开,因为您已完成:

TOTAL_1 = A1 * B1;  # 1
TOTAL_2 = A2 * B2;  # 2
TOTAL_3 = A3 * B3;  # 3

TOTAL_1_2 = TOTAL_1 + TOTAL2;  # 5, depends on 1,2
TOTAL = TOTAL_1_2 + TOTAL3;    # 6, depends on 3,5

前三个 MUL 指令可以独立执行,因为它们没有任何依赖关系。两条加法指令连续依赖于乘法。因此,该序列的延迟为 5 + 3 + 3 = 11。

所以第二种方法的延迟较低,即使它使用更多 CPU 资源(总共发出 5 条指令)。那么,根据整个循环的结构,较低的延迟肯定有可能抵消 FMA 对该代码的吞吐量优势——如果它至少部分受延迟限制。

为了更全面的静态分析,我强烈推荐 Intel's IACA - 它可以像上面那样进行循环迭代,并准确告诉你瓶颈是什么,至少在最好的情况下是这样。它可以识别循环中的关键路径,您是否受延迟限制等。

另一种可能性是您受内存限制(延迟或吞吐量),其中您还会看到 FMA 与 MUL + ADD 的类似行为。

回复:您的编辑:您的代码具有三个依赖链(r1、r2 和 r3),因此它可以同时保持三个 FMA 运行。 Haswell 上的 FMA 是 5c 延迟,每 0.5c 吞吐量一个,因此机器可以在飞行中维持 10 个 FMA。

如果您的代码处于循环中,并且一次迭代的输入不是由前一次迭代生成的,那么您可能会以这种方式获得 10 个 FMA。 (即没有涉及 FMA 的循环携带依赖链)。但是由于您看不到性能增益,可能是一个 dep 链导致吞吐量受延迟限制。


您没有 post 从 MSVC 获得的 ASM,但您声明了一些关于寄存器分配的内容。 xorps same,same 开始一个新的依赖链,就像使用寄存器作为只写操作数(例如,非 FMA AVX 指令的目的地。)

代码很可能是正确的,但仍然包含 r3 对 r1 的依赖性。确保您了解使用寄存器重命名的乱序执行允许不同的依赖链使用相同的寄存器。


顺便说一句,您应该使用 __m256 r1 = _mm256_setzero_ps(); 而不是 __m256 r1 = _mm256_xor_ps (r1, r1);。您应该避免使用您在其自己的初始化程序中声明的变量!当您使用未初始化的向量时,编译器有时会生成愚蠢的代码,例如从堆栈内存加载垃圾,或做额外的 xorps.

更好的是:

__m256 r1 = _mm256_mul_ps (rp1, m6);
r1 = _mm256_fmadd_ps (rp2, m7, r1);
r1 = _mm256_fmadd_ps (rp3, m8, r1);

这避免了需要 xorps 将累加器的寄存器归零。

在 Broadwell 上,mulps 的延迟低于 FMA。

在Skylake上,FMA/mul/add都是4c延迟,每0.5c吞吐量一个。他们从端口 1 中删除了单独的加法器,并在 FMA 单元上执行。他们缩短了 FMA 单元的延迟周期。