【问题标题】:Loop unrolling to achieve maximum throughput with Ivy Bridge and Haswell使用 Ivy Bridge 和 Haswell 循环展开以实现最大吞吐量
【发布时间】:2014-02-01 04:11:00
【问题描述】:

我正在使用 AVX 一次计算八个点积。在我当前的代码中,我做了这样的事情(在展开之前):

常春藤桥/沙桥

__m256 areg0 = _mm256_set1_ps(a[m]);
for(int i=0; i<n; i++) {        
    __m256 breg0 = _mm256_load_ps(&b[8*i]);
    tmp0 = _mm256_add_ps(_mm256_mul_ps(arge0,breg0), tmp0); 
}

哈斯韦尔

__m256 areg0 = _mm256_set1_ps(a[m]);
for(int i=0; i<n; i++) {      
    __m256 breg0 = _mm256_load_ps(&b[8*i]);
    tmp0 = _mm256_fmadd_ps(arge0, breg0, tmp0);
}

我需要为每个案例展开多少次循环以确保最大吞吐量?

对于使用 FMA3 的 Haswell,我认为答案在这里 FLOPS per cycle for sandy-bridge and haswell SSE2/AVX/AVX2。我需要展开循环 10 次。

对于 Ivy Bridge,我认为是 8。这是我的逻辑。 AVX 加法的延迟为 3,乘法的延迟为 5。Ivy Bridge 可以使用不同的端口同时进行一次 AVX 乘法和一次 AVX 加法。使用符号 m 表示乘法,a 表示加法,x 表示无操作以及一个数字来表示部分和(例如 m5 表示与第 5 个部分和相乘)我可以这样写:

port0:  m1  m2  m3  m4  m5  m6  m7  m8  m1  m2  m3  m4  m5  ... 
port1:   x   x   x   x   x  a1  a2  a3  a4  a5  a6  a7  a8  ...

因此,通过在 9 个时钟周期后使用 8 个部分和(四个来自负载,五个来自乘法),我可以在每个时钟周期提交一个 AVX 负载、一个 AVX 加法和一个 AVX 乘法。

我猜这意味着不可能在 Ivy Bridge 和 Haswell 的 32 位模式下实现此任务的最大吞吐量,因为 32 位模式只有 8 个 AVX 寄存器?

编辑:关于赏金。我的主要问题仍然存在。我想获得上述 Ivy Bridge 或 Haswell 函数的最大吞吐量,n 可以是大于或等于 64 的任何值。我认为这只能通过展开来完成(Ivy Bridge 为 8 次,Ivy Bridge 为 10 次)哈斯韦尔)。如果你认为这可以用另一种方法来完成,那么让我们看看吧。在某种意义上,这是How do I achieve the theoretical maximum of 4 FLOPs per cycle? 的变体。但不是只有乘法和加法,我正在寻找一个 256 位负载(或两个 128 位负载)、一个 AVX 乘法和一个 AVX 加法,每个时钟周期使用 Ivy Bridge 或两个 256 位负载和两个 FMA3 指令每个时钟周期。

我还想知道需要多少个寄存器。对于 Ivy Bridge,我认为是 10。一个用于广播,一个用于负载(由于寄存器重命名,只有一个),八个用于八个部分总和。所以我不认为这可以在 32 位模式下完成(事实上,当我在 32 位模式下运行时,性能会显着下降)。

我应该指出,编译器可能会给出误导性结果Difference in performance between MSVC and GCC for highly optimized matrix multplication code

我正在为 Ivy Bridge 使用的当前功能如下。这基本上将一行 64x64 矩阵 a 与所有 64x64 矩阵 b 相乘(我在 a 的每一行上运行此函数 64 次,以获得矩阵 c 中的完整矩阵乘法)。

#include <immintrin.h>
extern "C" void row_m64x64(const float *a, const float *b, float *c) {      
    const int vec_size = 8;
    const int n = 64;
    __m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
    tmp0 = _mm256_loadu_ps(&c[0*vec_size]);
    tmp1 = _mm256_loadu_ps(&c[1*vec_size]);
    tmp2 = _mm256_loadu_ps(&c[2*vec_size]);
    tmp3 = _mm256_loadu_ps(&c[3*vec_size]);
    tmp4 = _mm256_loadu_ps(&c[4*vec_size]);
    tmp5 = _mm256_loadu_ps(&c[5*vec_size]);
    tmp6 = _mm256_loadu_ps(&c[6*vec_size]);
    tmp7 = _mm256_loadu_ps(&c[7*vec_size]);

    for(int i=0; i<n; i++) {
        __m256 areg0 = _mm256_set1_ps(a[i]);

        __m256 breg0 = _mm256_loadu_ps(&b[vec_size*(8*i + 0)]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(areg0,breg0), tmp0);    
        __m256 breg1 = _mm256_loadu_ps(&b[vec_size*(8*i + 1)]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(areg0,breg1), tmp1);
        __m256 breg2 = _mm256_loadu_ps(&b[vec_size*(8*i + 2)]);
        tmp2 = _mm256_add_ps(_mm256_mul_ps(areg0,breg2), tmp2);    
        __m256 breg3 = _mm256_loadu_ps(&b[vec_size*(8*i + 3)]);
        tmp3 = _mm256_add_ps(_mm256_mul_ps(areg0,breg3), tmp3);   
        __m256 breg4 = _mm256_loadu_ps(&b[vec_size*(8*i + 4)]);
        tmp4 = _mm256_add_ps(_mm256_mul_ps(areg0,breg4), tmp4);    
        __m256 breg5 = _mm256_loadu_ps(&b[vec_size*(8*i + 5)]);
        tmp5 = _mm256_add_ps(_mm256_mul_ps(areg0,breg5), tmp5);    
        __m256 breg6 = _mm256_loadu_ps(&b[vec_size*(8*i + 6)]);
        tmp6 = _mm256_add_ps(_mm256_mul_ps(areg0,breg6), tmp6);    
        __m256 breg7 = _mm256_loadu_ps(&b[vec_size*(8*i + 7)]);
        tmp7 = _mm256_add_ps(_mm256_mul_ps(areg0,breg7), tmp7);    
    }
    _mm256_storeu_ps(&c[0*vec_size], tmp0);
    _mm256_storeu_ps(&c[1*vec_size], tmp1);
    _mm256_storeu_ps(&c[2*vec_size], tmp2);
    _mm256_storeu_ps(&c[3*vec_size], tmp3);
    _mm256_storeu_ps(&c[4*vec_size], tmp4);
    _mm256_storeu_ps(&c[5*vec_size], tmp5);
    _mm256_storeu_ps(&c[6*vec_size], tmp6);
    _mm256_storeu_ps(&c[7*vec_size], tmp7);
}

【问题讨论】:

  • 请注意,展开小循环会对 Core i7 及更高版本的性能产生负面影响。循环流检测器只能在 Nehalem 上缓存 28 µops - 我不确定 Ivy Bridge/Haswell 中该大小是否增加了。
  • 您通过展开的目的究竟是什么,避免容易预测的分支? OOO 能够跨多次迭代执行,您仍然应该只受限于 tmp0 依赖项
  • 他不是在说 uop 缓存,循环流检测器要小得多(在带宽方面更有效)
  • 我也在质疑展开像 FMA 这样的长延迟操作的依赖链的必要性。静态准备好索引的好处可以忽略不计,您的性能由向量单位带宽/延迟控制 - 计算索引(并预测一些分支)可以并行完成而没有影响。不过,只有分析才能判断谁是对的。
  • 这仅适用于桑迪/常春藤桥。 Haswell 可以执行两个 256 位加载/周期。我指的是您的原始循环,其中(我认为)您对每个 FMA 都有广播和负载。

标签: c++ x86 intel sse avx


【解决方案1】:

对于 Sandy/Ivy Bridge,您需要在 3 点之前展开:

  • 只有 FP Add 依赖于循环的上一次迭代
  • FP Add 可以在每个周期发出
  • FP 添加需要三个周期才能完成
  • 因此展开 3/1 = 3 完全隐藏了延迟
  • FP Mul 和 FP Load 不依赖于先前的迭代,您可以依靠 OoO 内核以接近最佳的顺序发出它们。这些指令只有在降低 FP Add 的吞吐量时才会影响展开因子(这里不是这种情况,FP Load + FP Add + FP Mul 可以在每个周期发出)。

对于 Haswell,您需要在 10 点之前展开:

  • 只有 FMA 依赖于循环的上一次迭代
  • FMA 可以在每个周期双发(即平均独立指令需要 0.5 个周期)
  • FMA 的延迟为 5
  • 因此展开 5/0.5 = 10 完全隐藏了 FMA 延迟
  • 两个 FP Load 微操作不依赖于之前的迭代,并且可以与 2x FMA 共同发出,因此它们不会影响展开因子。

【讨论】:

  • 我明白了。我不清楚 OoO 逻辑是如何处理这个问题的,但我知道在代码中要做什么。我必须阅读英特尔手册才能了解更多信息。由于我如何安排内存和 64x64 矩阵,我展开 8 次这一事实可能会产生最佳结果。 32 位较慢,因为我需要八个以上的寄存器。但这仍然意味着 Haswell 不可能在 32 位模式下获得最大吞吐量。
  • 我刚刚意识到我实际上并没有展开我的问题中的循环。我正在做八个 8 宽点积(64 个点积)。我正在使用八个 AVX 累加器读取/写入一行 64 边矩阵。但这并没有真正改变问题。它只是成为最大吞吐量需要多少个累加器,答案是一样的。
  • Why does mulss take only 3 cycles on Haswell, different from Agner's instruction tables? (Unrolling FP loops with multiple accumulators) 表明使用比最低限度更多的累加器会有所帮助。显然调度并不总是完美的,和/或吸收内存延迟变化对更多的累加器效果更好。
【解决方案2】:

我只是在这里回答我自己的问题来补充信息。

我继续分析 Ivy Bridge 代码。当我第一次在 MSVC2012 中测试这个时,展开两个以上并没有多大帮助。但是,根据我在Difference in performance between MSVC and GCC for highly optimized matrix multplication code 的观察,我怀疑 MSVC 没有以最佳方式实现内在函数。因此,我使用g++ -c -mavx -O3 -mabi=ms 在 GCC 中编译了内核,将对象转换为 COFF64 并将其放入 MSVC,现在我将其展开为 3 给出了确认 Marat Dunkhan 答案的最佳结果。

这里是以秒为单位的时间,Xeon E5 1620 @3.6GHz MSVC2012

unroll    time default            time with GCC kernel
     1    3.7                     3.2
     2    1.8 (2.0x faster)       1.6 (2.0x faster)
     3    1.6 (2.3x faster)       1.2 (2.7x faster)
     4    1.6 (2.3x faster)       1.2 (2.7x faster)

这是 i5-4250U 在 Linux 中使用 fma 和 GCC 的时间 (g++ -mavx -mfma -fopenmp -O3 main.cpp kernel_fma.cpp -o sum_fma)

unroll    time
     1    20.3
     2    10.2 (2.0x faster)
     3     6.7 (3.0x faster) 
     4     5.2 (4.0x faster)
     8     2.9 (7.0x faster)
    10     2.6 (7.8x faster)

以下代码适用于 Sandy-Bridge/Ivy Bridge。对于 Haswell 使用,例如tmp0 = _mm256_fmadd_ps(a8,b8_1,tmp0) 代替。

内核.cpp

#include <immintrin.h>

extern "C" void foo_unroll1(const int n, const float *b, float *c) {      
    __m256 tmp0 = _mm256_set1_ps(0.0f);
    __m256 a8 = _mm256_set1_ps(1.0f);
    for(int i=0; i<n; i+=8) {
        __m256 b8 = _mm256_loadu_ps(&b[i + 0]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(a8,b8), tmp0);
    }
    _mm256_storeu_ps(c, tmp0);
}

extern "C" void foo_unroll2(const int n, const float *b, float *c) {
    __m256 tmp0 = _mm256_set1_ps(0.0f);
    __m256 tmp1 = _mm256_set1_ps(0.0f);
    __m256 a8 = _mm256_set1_ps(1.0f);
    for(int i=0; i<n; i+=16) {
        __m256 b8_1 = _mm256_loadu_ps(&b[i + 0]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(a8,b8_1), tmp0);
        __m256 b8_2 = _mm256_loadu_ps(&b[i + 8]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(a8,b8_2), tmp1);
    }
    tmp0 = _mm256_add_ps(tmp0,tmp1);
    _mm256_storeu_ps(c, tmp0);
}

extern "C" void foo_unroll3(const int n, const float *b, float *c) { 
    __m256 tmp0 = _mm256_set1_ps(0.0f);
    __m256 tmp1 = _mm256_set1_ps(0.0f);
    __m256 tmp2 = _mm256_set1_ps(0.0f);
    __m256 a8 = _mm256_set1_ps(1.0f);
    for(int i=0; i<n; i+=24) {
        __m256 b8_1 = _mm256_loadu_ps(&b[i + 0]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(a8,b8_1), tmp0);
        __m256 b8_2 = _mm256_loadu_ps(&b[i + 8]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(a8,b8_2), tmp1);
        __m256 b8_3 = _mm256_loadu_ps(&b[i + 16]);
        tmp2 = _mm256_add_ps(_mm256_mul_ps(a8,b8_3), tmp2);
    }
    tmp0 = _mm256_add_ps(tmp0,_mm256_add_ps(tmp1,tmp2));
    _mm256_storeu_ps(c, tmp0);
}

extern "C" void foo_unroll4(const int n, const float *b, float *c) {      
    __m256 tmp0 = _mm256_set1_ps(0.0f);
    __m256 tmp1 = _mm256_set1_ps(0.0f);
    __m256 tmp2 = _mm256_set1_ps(0.0f);
    __m256 tmp3 = _mm256_set1_ps(0.0f);
    __m256 a8 = _mm256_set1_ps(1.0f);
    for(int i=0; i<n; i+=32) {
        __m256 b8_1 = _mm256_loadu_ps(&b[i + 0]);
        tmp0 = _mm256_add_ps(_mm256_mul_ps(a8,b8_1), tmp0);
        __m256 b8_2 = _mm256_loadu_ps(&b[i + 8]);
        tmp1 = _mm256_add_ps(_mm256_mul_ps(a8,b8_2), tmp1);
        __m256 b8_3 = _mm256_loadu_ps(&b[i + 16]);
        tmp2 = _mm256_add_ps(_mm256_mul_ps(a8,b8_3), tmp2);
        __m256 b8_4 = _mm256_loadu_ps(&b[i + 24]);
        tmp3 = _mm256_add_ps(_mm256_mul_ps(a8,b8_4), tmp3);
    }
    tmp0 = _mm256_add_ps(_mm256_add_ps(tmp0,tmp1),_mm256_add_ps(tmp2,tmp3));
    _mm256_storeu_ps(c, tmp0);
}

main.cpp

#include <stdio.h>
#include <omp.h>
#include <immintrin.h>

extern "C" void foo_unroll1(const int n, const float *b, float *c);
extern "C" void foo_unroll2(const int n, const float *b, float *c);
extern "C" void foo_unroll3(const int n, const float *b, float *c);
extern "C" void foo_unroll4(const int n, const float *b, float *c);

int main() {
    const int n = 3*1<<10;
    const int r = 10000000;
    double dtime;
    float *b = (float*)_mm_malloc(sizeof(float)*n, 64);
    float *c = (float*)_mm_malloc(8, 64);
    for(int i=0; i<n; i++) b[i] = 1.0f;

    __m256 out;
    dtime = omp_get_wtime();    
    for(int i=0; i<r; i++) foo_unroll1(n, b, c);
    dtime = omp_get_wtime() - dtime;
    printf("%f, ", dtime); for(int i=0; i<8; i++) printf("%f ", c[i]); printf("\n");

    dtime = omp_get_wtime();    
    for(int i=0; i<r; i++) foo_unroll2(n, b, c);
    dtime = omp_get_wtime() - dtime;
    printf("%f, ", dtime); for(int i=0; i<8; i++) printf("%f ", c[i]); printf("\n");

    dtime = omp_get_wtime();    
    for(int i=0; i<r; i++) foo_unroll3(n, b, c);
    dtime = omp_get_wtime() - dtime;
    printf("%f, ", dtime); for(int i=0; i<8; i++) printf("%f ", c[i]); printf("\n");

    dtime = omp_get_wtime();    
    for(int i=0; i<r; i++) foo_unroll4(n, b, c);
    dtime = omp_get_wtime() - dtime;
    printf("%f, ", dtime); for(int i=0; i<8; i++) printf("%f ", c[i]); printf("\n");
}

【讨论】:

  • 我不确定它是否有帮助,但您可以在参数 b 和 c 中添加 __restrict。
  • 1f 的乘法是什么?
  • 为什么有用于 b8_1...b8_4 的变量,因为这些表达式只使用一次?
  • 在分析时,您如何确保 cpu “涡轮速度”功能会影响执行时间,相对于调制时钟频率?
  • @Z boson 仅供参考,代码没有完全按预期执行。因为 A8 设置为全 1,所以 GCC 优化了 _mm256_mul_ps 操作。需要将 A8 更改为其他值,例如 5.0f。对于 unroll1,缺少的 MULPS 步骤不会影响总时间,但会对 unroll4 产生显着影响。
猜你喜欢
  • 2016-11-25
  • 2012-05-16
  • 2012-04-25
  • 1970-01-01
  • 1970-01-01
  • 2023-03-17
  • 2011-03-05
  • 2015-07-14
相关资源
最近更新 更多