为什么新的展示位置比直接作业更快?

时间:2015-08-26 09:53:56

标签: c++ performance c++11 gcc x86-64

我最近发现使用新的展示位置比执行16次作业要快:
考虑以下代码(c ++ 11):

class Matrix
{
public:
    double data[16];

    Matrix() : data{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }
    {
    };

    void Identity1()
    {
        new (this) Matrix();
    };

    void Identity2()
    {
        data[0]  = 1.0; data[1]  = 0.0; data[2]  = 0.0; data[3]  = 0.0;
        data[4]  = 0.0; data[5]  = 1.0; data[6]  = 0.0; data[7]  = 0.0;
        data[8]  = 0.0; data[9]  = 0.0; data[10] = 1.0; data[11] = 0.0;
        data[12] = 0.0; data[13] = 0.0; data[14] = 0.0; data[15] = 1.0;
    };
};

用法:

Matrix m;
//modify m.data

m.Identity1(); //~25 times faster
m.Identity2();

在我的机器上Identity1()比第二个功能快约25倍。现在我很好奇为什么会有这么大的差异呢?

我还尝试了第三个:

void Identity3()
{
    memset(data, 0, sizeof(double) * 16);
    data[0] = 1.0;
    data[5] = 1.0;
    data[10] = 1.0;
    data[15] = 1.0;
};

但这比Identity2()慢,我无法想象为什么。

分析信息

我已经完成了几项分析测试,看它是否是与分析相关的问题,因此有一个默认的' for循环'测试以及外部剖析测试:

分析方法1 :(众所周知的循环测试)

struct timespec ts1;
struct timespec ts2;

clock_gettime(CLOCK_MONOTONIC, &ts1);

for (volatile int i = 0; i < 10000000; i++)
    m.Identity(); //use 1 or 2 here

clock_gettime(CLOCK_MONOTONIC, &ts2);

int64_t start = (int64_t)ts1.tv_sec * 1000000000 + (int64_t)ts1.tv_nsec;
int64_t elapsed = ((int64_t)ts2.tv_sec * 1000000000 + (int64_t)ts2.tv_nsec) - start;

if (elapsed < 0)
    elapsed += (int64_t)0x100000 * 1000000000;

printf("elapsed nanos: %ld\n", elapsed);

方法2:

$ valgrind --tool=callgrind ./testcase

$ # for better overview:
$ python2 gprof2dot.py -f callgrind.out.22028 -e 0.0 -n 0.0 | dot -Tpng -o tree.png

装配信息

作为用户T.C.在评论中说明,这可能会有所帮助:

http://goo.gl/LC0RdG

编译和机器信息

  

编译:{{1​​}}

     

g++ --std=c++11 -O3 -g -pg -Wall不是问题。在不使用此标志的情况下,在测量方法1中获得相同的时间差。

-pg

2 个答案:

答案 0 :(得分:2)

无论你测量的时差是多少25倍,实际上两个Identity()实现之间并没有区别。

使用您的计时代码,两个版本编译为完全相同的asm:一个空循环。您发布的代码从不使用m,因此它会被优化掉。所有这些都是循环计数器的加载/存储。 (这是因为你使用volatile int告诉gcc变量存储在内存映射的I / O空间中,所以它出现在源中的所有读/写必须实际出现在asm中.MSVC有一个volatile关键字的含义不同,超出了标准所说的范围。)

看看at the asm on godbolt。这是你的代码,它变成了asm:

for (volatile int i = 0; i < 10000000; i++)
    m.Identity1();
// same output for gcc 4.8.2 through gcc 5.2.0, with -O3

# some setup before this loop:  mov $0, 8(%rsp)  then test if it reads back as 0
.L16:
    movl    8(%rsp), %eax
    addl    $1, %eax
    movl    %eax, 8(%rsp)
    movl    8(%rsp), %eax
    cmpl    $9999999, %eax
    jle .L16
  for (volatile int i = 0; i < 10000000; i++)
    m.Identity2();

# some setup before this loop:  mov $0, 12(%rsp)  then test if it reads back as 0
.L15:
    movl    12(%rsp), %eax
    addl    $1, %eax
    movl    %eax, 12(%rsp)
    movl    12(%rsp), %eax
    cmpl    $9999999, %eax
    jle .L15

如您所见,两个人都没有调用Identity()函数的任何版本。

有趣的是,在asm中Identity1看到它使用整数movq来分配零,而Identity2仅使用标量FP移动。这可能与使用0.0与0相关,或者可能是由于就地new与简单分配相关。

无论哪种方式,gcc 5.2.0都不会对Identity函数进行矢量化,除非您使用-march=native。 (在这种情况下,它使用AVX 32B加载/存储从4x 32B数据进行复制。没有什么比将字节移位寄存器以将1.0移动到不同位置更聪明:/)

如果gcc更聪明,它会做一个两个零的16B存储,而不是两个movsd。也许它假定未对齐,并且未对齐商店的高速缓存行或页面行拆分的缺点比保存商店insn的好处要差得多。

所以无论你用这个代码计时,它都不是你的功能。除非他们中的一个做Identity,而另一个没做。无论哪种方式,从你的循环计数器中丢失volatile,这完全是愚蠢的。只需查看空循环中的额外加载/存储即可。

答案 1 :(得分:1)

我敢打赌,如果手动memcopy一个const-expr数组,你会获得相同的性能:

static constexpr double identity_data[16] = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };

void Identity3()
{
    std::copy(std::begin(identity_data), std::end(identity_data), data);
}