当数组大小均匀时,为什么这段代码会变慢?

时间:2017-03-01 15:38:49

标签: c performance gcc optimization icc

警告:实际上它不是由2的幂而是奇偶校验。请参阅编辑部分。

我找到了一个显示相当奇怪行为的代码。

代码使用2D数组(大小x大小)。 当大小为2的幂时,代码的速度在10%到40%之间,最小的是size = 32。

我用英特尔编译器获得了这些结果。如果我使用gcc 5.4编译,2问题的能力消失但代码慢3倍。在不同的CPU上进行测试后,我认为它应该具有足够的可重现性。

代码:

#define N 10000000

unsigned int tab1[TS][TS];

void simul1() {

  for(int i=0; i<TS; i++)
  for(int j=0; j<TS; j++) {

    if(i > 0)
      tab1[i][j] += tab1[i-1][j];

    if(j > 0)
      tab1[i][j] += tab1[i][j-1];
  }
}

int main() {

  for(int i=0; i<TS; i++)
  for(int j=0; j<TS; j++)
    tab1[j][i] = 0;


  for(int i=0; i<N; i++) {
    tab1[0][0] = 1;
    simul1();
  }

  return tab1[10][10];
}

汇编:

icc:
        icc main.c -O3 -std=c99 -Wall -DTS=31 -o i31
        icc main.c -O3 -std=c99 -Wall -DTS=32 -o i32
        icc main.c -O3 -std=c99 -Wall -DTS=33 -o i33

gcc:
        gcc main.c -O3 -std=c99 -Wall -DTS=31 -o g31
        gcc main.c -O3 -std=c99 -Wall -DTS=32 -o g32
        gcc main.c -O3 -std=c99 -Wall -DTS=33 -o g33

Xeon E5的结果:

time ./icc31
4.549s

time ./icc32
6.557s

time ./icc33
5.188s


time ./gcc31
13.335s

time ./gcc32
13.669s

time ./gcc33
14.399s

我的问题:

  • 当数组的大小正好为32 时,为什么会出现性能下降?
  • (可选:为什么icc 比gcc快3倍?)

编辑:实际上这是由于奇偶校验,并且仅适用于尺寸&gt; = 32.偶数和奇数之间的性能差异是一致的,并且当尺寸变大时会减小。这是一个更详细的基准:

enter image description here

(y轴是每秒数百万的元素数,以TS²* N / size / 1000000获得)

我的CPU有32KB L1缓存和256 KB L2

2 个答案:

答案 0 :(得分:7)

  

为什么icc比gcc快3倍?

GCC无法对内循环进行矢量化,因为它报告说,数据引用之间存在依赖关系。英特尔的编译器非常智能,可以将内部循环分成两个独立的部分:

for (int j = 1; j < TS; j++)
    tab1[i][j] += tab1[i-1][j];  // this one is vectorized

for (int j = 1; j < TS; j++)
    tab1[i][j] += tab1[i][j-1];

尝试1:循环重组

通过将simul1重写为:

,您可能会在GCC中获得更好的表现
void simul1(void)
{
    for (int j = 1; j < TS; j++)
        tab1[0][j] += tab1[0][j-1];

    for (int i = 1; i < TS; i++) {
        for (int j = 0; j < TS; j++)
            tab1[i][j] += tab1[i-1][j];
        for (int j = 1; j < TS; j++)
            tab1[i][j] += tab1[i][j-1];
    }
}

我的结果在GCC 6.3.0下,-O3 -march-nativeTS = 32由英特尔酷睿i5 5200U提供支持:

原始版本:

real    0m21.110s
user    0m21.004s
sys 0m0.000s

修改:

real    0m8.588s
user    0m8.536s
sys 0m0.000s

尝试2:前缀和的矢量化

经过一些研究,我发现有可能通过向量加法和移位来对第二个内环进行向量化。提出了算法here

版本A:128位宽矢量

#include "emmintrin.h"

void simul1(void)
{
    for (int j = 1; j < TS; j++)
        tab1[0][j] += tab1[0][j-1];

    for (int i = 1; i < TS; i++) {
        for (int j = 0; j < TS; j++)
            tab1[i][j] += tab1[i-1][j];

        for (int stride = 0; stride < TS; stride += 4) {
            __m128i v;
            v = _mm_loadu_si128((__m128i*) (tab1[i] + stride));
            v = _mm_add_epi32(v, _mm_slli_si128(v, sizeof(int)));
            v = _mm_add_epi32(v, _mm_slli_si128(v, 2*sizeof(int)));
            _mm_storeu_si128((__m128i*) (tab1[i] + stride), v);
        }

        for (int stride = 4; stride < TS; stride += 4)
            for (int j = 0; j < 4; j++)
                tab1[i][stride+j] += tab1[i][stride-1];
    }
}

结果:

real    0m7.541s
user    0m7.496s
sys 0m0.004s

版本B:256位宽矢量

这个更复杂。考虑int s的八元素向量:

V = (a, b, c, d, e, f, g, h)

我们可以将它视为两个打包的载体:

(a, b, c, d), (e, f, g, h)

首先,算法执行两个独立的求和:

(a, b, c, d), (e, f, g, h)
+
(0, a, b, c), (0, e, f, g)
=
(a, a+b, b+c, c+d), (e, e+f, f+g, g+h)
+
(0, 0, a, a+b), (0, 0, e, e+f)
=
(a, a+b, a+b+c, a+b+c+d), (e, e+f, e+f+g, e+f+g+h)

然后它将第一个向量的最后一个元素传播到第二个向量的每个元素中,因此最终得到:

(a, a+b, a+b+c, a+b+c+d), (a+b+c+d+e, a+b+c+d+e+f, a+b+c+d+e+f+g, a+b+c+d+e+f+g+h)

我怀疑,这些内在函数可以写得更好,因此有可能有所改善。

#include "immintrin.h"

void simul1(void)
{
    for (int j = 1; j < TS; j++)
        tab1[0][j] += tab1[0][j-1];

    for (int i = 1; i < TS; i++) {
        for (int j = 0; j < TS; j++)
            tab1[i][j] += tab1[i-1][j];

        for (int stride = 0; stride < TS; stride += 8) {
            __m256i v;
            v = _mm256_loadu_si256((__m256i*) (tab1[i] + stride));
            v = _mm256_add_epi32(v, _mm256_slli_si256(v, sizeof(int)));
            v = _mm256_add_epi32(v, _mm256_slli_si256(v, 2*sizeof(int)));

            __m256i t = _mm256_setzero_si256();
            t = _mm256_insertf128_si256(t, 
                _mm_shuffle_epi32(_mm256_castsi256_si128(v), 0xFF), 1);

            v = _mm256_add_epi32(v, t);
            _mm256_storeu_si256((__m256i*) (tab1[i] + stride), v);
        }

        for (int stride = 8; stride < TS; stride += 8)
            for (int j = 0; j < 8; j++)
                tab1[i][stride+j] += tab1[i][stride-1];
    }
}

结果(Clang 3.8):

real    0m5.644s
user    0m5.364s
sys 0m0.004s

答案 1 :(得分:5)

看起来像是缓存争用的经典案例。编写代码使得相邻矩阵行和列上有操作。当矩阵行与高速缓存行对齐时,这可能会很痛苦,并且将存储在同一高速缓存行中。

但是没有太多数据。如果一条线路被快速L1缓存踢出,它可能仍然适合相当快的L2缓存。对于GCC发出的代码来说,L2显然足够快,但L2无法跟上ICC的(矢量化)代码。