访问堆成员和堆栈对象之间的性能差异

Performance difference between accessing the member of a heap and a stack object?

本文关键字:性能 之间 对象 成员 堆栈 访问      更新时间:2023-10-16

当前我正在使用'->'运算符来取消引用类内的成员。我的问题是它是否比普通会员访问更快。例如:

Class* myClsPtr = new Class();
myClsPtr->foo(bar);

Vs。

Class myCls;
myCls.foo(bar);

可以同时使用两种方式而不存在性能差异吗?

首先,

Class myCls = new Class();

是无效代码。。。假设你指的是

Class myCls;

几乎没有明显的差异,但您可以通过在一个循环中迭代一百万次来自己进行基准测试,并在计时两个执行时间的同时调用任一变体。

我刚刚在我的笔记本电脑上做了一个快速而肮脏的基准测试,迭代了一亿次,如下所示:

Stack对象

struct MyStruct
{
    int i;
};
int main()
{
    MyStruct stackObject;
    for (int i = 0; i < 100000000; ++i)
        stackObject.i = 0;
    return 0;
}

然后我运行:

g++ main.cpp && time ./a.out

结果是:

sreal   0m0.301s
user    0m0.303s
sys 0m0.000s

堆对象

struct MyStruct
{
    int i;
};
int main()
{
    MyStruct *heapObject = new MyStruct();
    for (int i = 0; i < 100000000; ++i)
        heapObject->i = 5;
    return 0;
}

然后我运行:

g++ main.cpp && time ./a.out

结果是:

real    0m0.253s
user    0m0.250s
sys 0m0.000s

正如您所看到的,对于100数百万次迭代,堆对象在我的机器上稍微快一些。即使在我的机器上,这对于明显更少的项目来说也不会引起注意。有一点很突出,尽管后续运行的结果略有不同,但堆对象版本在我的笔记本电脑上总是表现得更好。但是,不要把它当作一种保证。

与许多性能问题一样,答案复杂多变。使用堆的速度慢的潜在来源是:

  • 分配和解除分配对象的时间
  • 对象不在缓存中的可能性

这两种情况都意味着堆上的对象一开始可能很慢。但是,如果您在一个紧密的循环中多次使用该对象,这并没有多大关系:很快,无论该对象是在堆中还是在堆栈中,它都将最终出现在CPU缓存中。

一个相关的问题是,包含其他对象的对象应该使用指针还是副本。如果速度是唯一的问题,那么最好存储副本,因为每次新的指针查找都有可能导致缓存未命中。

由于a->b等价于(*a).b(这确实是编译器必须创建的,至少在逻辑上是这样)->可能比.慢。,如果有的话。在实践中,编译器可能会将a的地址存储在寄存器中,并立即添加偏移量b,跳过(*a)部分,并在内部有效地将其减少为a.b。

顺便说一句,使用-O3 gcc 4.8.2消除了整个环路。如果我们从main返回最后一个MyStruct::i,它甚至会做到这一点——循环是无副作用的,并且最终值是可计算的。只是另一句板凳话。

然后,这不是关于堆上的对象,而是关于使用地址与立即使用对象。对于相同的对象,逻辑是相同的:

MyStruct m;
mp = &m;

然后运行两个循环,分别使用m或mp。对象的位置(就其所在的内存页而言)可能比直接访问或通过指针访问更重要,因为局部性在现代体系结构(具有缓存和并行性)中往往很重要。如果一些内存已经在缓存的内存位置(堆栈很可能被缓存),那么访问速度要比必须首先加载到缓存中的某个位置(某个任意堆位置)快得多。在任何一个循环中,对象所在的内存都可能保持缓存状态,因为那里不会发生太多其他事情,但在更现实的场景中(在向量中迭代指针:指针指向哪里?分散或连续的内存?),这些考虑因素将远远超过廉价的去引用。

我发现结果令人费解,所以我进一步调查了一下。首先,我使用chrono增强了示例prog,并添加了一个通过指针访问本地变量(而不是堆上的内存)的测试。这确保了时间差异不是由对象的位置引起的,而是由访问方法引起的。

其次,我在结构中添加了一个伪成员,因为我注意到直接成员目的地使用了堆栈指针的偏移量,我怀疑这可能是罪魁祸首;指针版本通过没有偏移的寄存器访问存储器。假人在那里平整了场地。但这并没有什么不同。

对于堆和本地对象来说,通过指针进行访问要快得多。来源:

#include<chrono>
#include<iostream>
using namespace std;
using namespace std::chrono;
struct MyStruct { /* offset for i */ int dummy; int i; };
int main()
{
    MyStruct *heapPtr = new MyStruct;
    MyStruct localObj;
    MyStruct *localPtr = &localObj;
    ///////////// ptr to heap /////////////////////
    auto t1 = high_resolution_clock::now();
    for (int i = 0; i < 100000000; ++i)
    {
        heapPtr->i = i;
    }
    auto t2 = high_resolution_clock::now();
    cout << "heap ptr: " 
        << duration_cast<milliseconds>(t2-t1).count() 
        << " ms" << endl;
    ////////////////// local obj ///////////////////////
    t1 = high_resolution_clock::now();
    for (int i = 0; i < 100000000; ++i)
    {
        localObj.i = i;
    }
    t2 = high_resolution_clock::now();
    cout << "local: " 
        << duration_cast<milliseconds>(t2-t1).count() 
        << " ms" << endl;
    ////////////// ptr to local /////////////////
    t1 = high_resolution_clock::now();
    for (int i = 0; i < 100000000; ++i)
    {
        localPtr->i = i;
    }
    t2 = high_resolution_clock::now();
    cout << "ptr to local: " 
        << duration_cast<milliseconds>(t2-t1).count() 
        << " ms" << endl;
    /////////// have a side effect ///////////////
    return heapPtr->i + localObj.i;
}

这是一次典型的跑步。堆和本地ptr之间的差异在两个方向上都是随机的。

heap ptr: 217 ms
local: 236 ms
ptr to local: 206 ms

以下是指针的反汇编和直接访问。我假设heapPtr的堆栈偏移量为0x38,因此第一个mov将其内容(即它所指向的堆上对象的地址)移动到%rax。这用作在第三次移动中将值移动到的地址(由于前面的伪成员,偏移量为4字节)。

第二步将i的值(i显然位于堆栈偏移量4C处,如果计算所有插入的定义,则该偏移量对齐)放入%edx(因为最后一个mov最多可以有一个内存操作数,即对象,所以i中的值必须进入寄存器)。

最后一个mov将i的值(在寄存器%edx中)获取到对象的地址(现在在%rax中),再加上由于虚设而产生的偏移量4。

                heapPtr->i = i;
  3e:   48 8b 45 38             mov    0x38(%rbp),%rax
  42:   8b 55 4c                mov    0x4c(%rbp),%edx
  45:   89 50 04                mov    %edx,0x4(%rax)

正如预期的那样,直接访问时间更短。变量的值(不同的本地i,这次在堆栈偏移量0x48)加载在寄存器%eax中,然后在堆栈偏移0x60写入加法器(我不知道为什么一些本地对象存储在正偏移量,而另一些存储在负偏移量)。底线是,这是一条比指针访问短的指令;基本上,指针访问的第一条指令(将指针的值加载到地址寄存器)丢失了。这正是我们所期望的——这就是取消引用尽管如此,直接访问需要更多的时间我不知道为什么。由于我排除了大多数可能性,我必须假设要么使用%rbp比使用%rax慢(不太可能),要么负偏移会减慢访问速度。是这样吗?

                localObj.i = i;
  d6:   8b 45 48                mov    0x48(%rbp),%eax
  d9:   89 45 a0                mov    %eax,-0x60(%rbp)

需要注意的是,当优化开启时,gcc会将分配移出循环。因此,对于关心性能的人来说,这在某种程度上是一个虚幻的问题。此外,这些微小的差异将被循环中发生的任何"真实"的事情所淹没。但这仍然出乎意料。