多次指针解引用与引用之间的性能差异

8

这是我几个月前接受的一道面试题:

以下哪个函数会执行得更快,Foo1 还是 Foo2

void Foo(SomeObjectArray** array, unsigned int size)
{
    for (int i = 0; i < size; i++)
    {
        if (((*array) + i) != NULL)
        {
            ((*array) + i)->Operation1();
            ((*array) + i)->Operation2();
            ((*array) + i)->Operation3();
            ((*array) + i)->Operation4();
            ((*array) + i)->Operation5();
            ((*array) + i)->Operation6();
    }
}

void Foo(SomeObjectArray** array, unsigned int size)
{
    for (int i = 0; i < size; i++)
    {
        if (*((*array) + i) != NULL)
        {
            Object& obj = *((*array) + i);
            obj.Operation1();
            obj.Operation2();
            obj.Operation3();
            obj.Operation4();
            obj.Operation5();
            obj.Operation6();
        }
    }
}

请注意,由于这是我从记忆中翻译的,所以我不记得代码确切的内容,但是基本思路是相同的。一个函数使用指针,而另一个函数使用引用(它可能像上面的代码一样有一个指向数组的指针,但我不太确定)。我说过“我不确定,需要分析代码才能找出答案,但如果我必须猜测,那么 Foo2 '可能' 会更快。”他们并不印象深刻...
当我遇到类似这样的代码(或编写它)时,这让我几次感到不安,并且会想知道在这种情况下我该怎么办。
我知道...
1. 这是微观优化。 2. 编译器很可能会进行优化。
编辑:我稍微修改了代码,现在它检查空指针。

即使编译器优化后生成相同的汇编代码,为了清晰起见,我仍然会写第二种代码。 - Xeo
8个回答

6
我觉得这是一个非常有趣的问题,我看到了很多关于编译器可能会做什么的猜测,但我想更仔细地看一下并确定。所以我拿了e.James的程序,并通过GCC将其转换成汇编代码。我应该说我不太懂汇编语言,所以如果我错了,请有人纠正我,但我认为我们可以合理地推断出正在发生的事情。 :)
使用-O0(无优化)进行编译
对于Foo1,我们看到在每个函数调用之前计算数组偏移量:
movl    8(%ebp), %eax
movl    (%eax), %edx
movl    -4(%ebp), %eax
leal    (%edx,%eax), %eax
movl    %eax, (%esp)
call    __ZN10SomeObject10Operation1Ev

那是针对六个不同方法调用的,只是使用了不同的方法名称。 Foo2 有一些设置代码来获取引用。
movl    8(%ebp), %eax
movl    (%eax), %edx
movl    -4(%ebp), %eax
leal    (%edx,%eax), %eax
movl    %eax, -8(%ebp)

接着是六个这样的操作,看起来只是一个堆栈指针推送和一个函数调用:

movl    -8(%ebp), %eax
movl    %eax, (%esp)
call    __ZN10SomeObject10Operation1Ev

基本上,这正是我们没有进行任何优化时所预期的结果。输出如下:
Foo1: 18472
Foo2: 17684

使用-O1编译(最小优化)

Foo1稍微更有效率一些,但仍需要每次添加数组偏移量:

movl    %esi, %eax
addl    (%ebx), %eax
movl    %eax, (%esp)
call    __ZN10SomeObject10Operation1Ev

Foo2保存了ebx的值(addl (%edi), %ebx),然后进行以下调用:

movl    %ebx, (%esp)
call    __ZN10SomeObject10Operation1Ev

这里的时间是:
Foo1: 4979
Foo2: 4977

使用-O2(适度优化)进行编译

当使用-O2进行编译时,GCC会将整个操作都优化掉,每次调用Foo1Foo2都只会导致在dummy中加上594(99次增量*6次调用=594次增量):

imull   $594, %eax, %eax
addl    %eax, _dummy

虽然代码中保留了对象的方法,但没有对这些方法进行调用。正如我们所预料的那样,这里的时间是

Foo1: 1
Foo2: 0

我认为这告诉我们,在没有优化的情况下,Foo2 要快一点,但实际上这是无关紧要的,因为一旦开始优化,编译器只是在栈和寄存器之间移动几个长整型。


1
@wallyk,这是针对此版本的gcc的确定性答案。你不能根据一个例子推广到所有编译器!尽管如此,这是一个很好的例子,说明你必须对自己的编译器进行分析。加入一些计时会更完美。 - Mark Ransom
@Mark,我猜你是指e.James代码中内置的时间?我已经添加了该输出。 - Steve Blackwell

4
严格来说,如果没有任何优化,我会说Foo2更快,因为Foo1每次都要计算间接指针,但这种情况不太可能发生。
我认为编译器会对其进行优化并保持不变。看起来编译器有足够的空间,每次迭代中iarray不会改变,所以它可以将指针放入寄存器中进行优化,这与引用完全相同。

有没有我不知道的情况,会导致编译器不执行这个操作呢?如果在将其分配给引用并使用之前检查 null 呢?上面的代码也是一样吗?编辑:我已经稍微改了一下代码来进行 NULL 检查。不知道这是否有所不同 - Samaursa
我会说同样的话,你可以将 NULL 赋给引用,编译器会进行相同的优化,但是你的程序会崩溃。 - Arkaitz Jimenez
一个非常快速的实验证实,应用 -O2 使得两者之间的差距可以忽略不计,而没有优化时 Foo2 稍微快一些(Operation1..6 只是在递增一个类成员)。 - t.dubrownik
1
编译器可能不知道Operation()函数是否会改变*array,因此我认为它在那里无法进行优化。*array + i处的值可能会被调用更改。 - sth
@Samaursa,如果涉及的其中一个项目被声明为volatileatomic,编译器可能会被迫在每个语句中对指针进行加法运算。在这样简单的代码中,优化器通常会执行相同的操作。然而,如果代码变得更加复杂,优化器可能无法看到第二种形式并执行第一种形式。因此,第二种形式是首选。 - edA-qa mort-ora-y

3

很可能编译器会进行优化,使得每行上的共同子表达式相同。但并不保证。

用现今的编译器和处理器,通过"椅子推理"无法得出任何合理的结论。唯一的方法是尝试并计时。如果有人在面试答案中没有明确说明这一点,那么我会立刻给予失败的评价。


2
如果面试官不知道这个,他就不能雇佣我了。 :-) - Bo Persson

2

以防有人怀疑编译器是否会优化到相同的结果,这里有一个快速而简单的测试程序:

#include <iostream>
#include <time.h>

using namespace std;
size_t dummy;

class SomeObject
{
    public:
        void Operation1();
        void Operation2();
        void Operation3();
        void Operation4();
        void Operation5();
        void Operation6();
};

void SomeObject::Operation1() { for (int i = 1; i < 100; i++) { dummy++; } }
void SomeObject::Operation2() { for (int i = 1; i < 100; i++) { dummy++; } }
void SomeObject::Operation3() { for (int i = 1; i < 100; i++) { dummy++; } }
void SomeObject::Operation4() { for (int i = 1; i < 100; i++) { dummy++; } }
void SomeObject::Operation5() { for (int i = 1; i < 100; i++) { dummy++; } }
void SomeObject::Operation6() { for (int i = 1; i < 100; i++) { dummy++; } }

void Foo1(SomeObject** array, unsigned int size)
{
    for (int i = 0; i < size; i++)
    {
        ((*array) + i)->Operation1();
        ((*array) + i)->Operation2();
        ((*array) + i)->Operation3();
        ((*array) + i)->Operation4();
        ((*array) + i)->Operation5();
        ((*array) + i)->Operation6();
    }
}

void Foo2(SomeObject** array, unsigned int size)
{
    for (int i = 0; i < size; i++)
    {
        SomeObject& obj = *((*array) + i);
        obj.Operation1();
        obj.Operation2();
        obj.Operation3();
        obj.Operation4();
        obj.Operation5();
        obj.Operation6();
    }
}


int main(int argc, char * argv[])
{
    clock_t timer;

    SomeObject * array[100];
    for (int i = 0; i < 100; i++)
    {
        array[i] = new SomeObject();
    }

    timer = clock();
    for (int i = 0; i < 100000; i++) { Foo1(array, 100); }
    cout << "Foo1: " << clock() - timer << endl;

    timer = clock();
    for (int i = 0; i < 100000; i++) { Foo2(array, 100); }
    cout << "Foo2: " << clock() - timer << endl;

    for (int i = 0; i < 100; i++)
    {
        delete array[i];
    }

    return 0;
}

结果始终相差不到几毫秒:

Foo1: 15437
Foo2: 15484


2
任何一个合理的编译器都会将它们视为等同的。 这里不是在深入模板元编程中讨论 NRVO,而是通过常见子表达式消除进行基本和简单的优化,这非常常见且相对基础。发帖的代码具有极小的复杂性,因此编译器极有可能进行此类优化。请注意保留HTML标签。

1

在我看来,哪个版本更快的问题是无关紧要的。在一个对象上依次调用6种不同的方法是面向对象设计中的一种坏味道。该对象可能应该提供一个方法来完成所有这些操作。


0

Foo2 有额外的对象创建,但除此之外,编译后它们应该差不多


2
这是一个引用赋值,而不是对象创建。 - Arkaitz Jimenez

0

我喜欢这个问题,忍不住要回答,尽管我知道我可能完全错了。不过,我猜Foo1会更快。

我的愚蠢的理由是什么呢? 嗯,我看到Foo2创建了一个对象引用,获取了“array”的地址,然后调用它的方法。

但在Foo1中,它直接使用地址,解引用它,进入对象内存并直接调用函数。Foo1中没有像Foo2那样创建不必要的对象引用。我们也不知道数组的继承深度以及有多少基类构造函数需要被调用才能获得对象引用,这需要额外的时间。所以我猜Foo1稍微更快一些。请纠正我,因为我确信我是错的。干杯!


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接