【问题标题】:Performance difference between accessing the member of a heap and a stack object?访问堆成员和堆栈对象之间的性能差异?
【发布时间】:2014-05-27 04:51:06
【问题描述】:

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

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

对比

Class myCls;
myCls.foo(bar);

可以在没有性能差异的情况下同时使用这两种方式吗?

【问题讨论】:

  • Class myCls = new Class(); 是无效代码。
  • 在结构中是否有自动填充来对齐数据以 2 的幂为大小? 填充根据自己的大小对齐数据,如 here 所述跨度>

标签: c++ class pointers member


【解决方案1】:

我发现结果令人费解,所以我进一步调查了一下。首先,我通过使用 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;
}

这是一个典型的运行。 heap 和 local 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 的偏移量,因为是 dummy。

                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 在开启优化时会将赋值移出循环。因此,对于关心性能的人来说,这在某种程度上是一个幻影问题。此外,这些微小的差异将被循环中发生的任何“真实”事件所淹没。但还是出乎意料。

【讨论】:

    【解决方案2】:

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

    顺便说一句,使用 -O3 gcc 4.8.2 消除了整个循环。如果我们从 main 返回最后一个 MyStruct::i ,它甚至会这样做——循环没有副作用,并且最终值可以简单地计算。只是另一个基准测试。

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

    MyStruct m;
    mp = &m;
    

    然后分别使用 m 或 mp 运行两个循环。一个对象的位置(就它所在的内存页而言)可能比你直接访问它还是通过指针访问它更重要,因为locality往往是在现代架构中很重要(具有缓存和并行性)。如果某些内存已经在缓存的内存位置(堆栈很可能被缓存),则访问比必须首先加载到缓存中的某个位置(某个任意堆位置)要快得多。在任一循环中,对象所在的内存可能会保持缓存,因为那里没有发生太多其他事情,但在更现实的情况下(迭代向量中的指针:指针指向哪里?分散的或连续的内存?)这些考虑将远胜过廉价的取消引用。

    【讨论】:

      【解决方案3】:

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

      • 是时候分配和解除分配对象了。
      • 对象不在缓存中的可能性。

      这两个都意味着堆上的对象起初可能很慢。但是,如果您在一个紧密的循环中多次使用该对象,这并不重要:很快该对象将在 CPU 缓存中结束,无论它是在堆中还是在堆栈中。

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

      【讨论】:

      • 实际上,与您的“较慢”含义相反,堆在我的机器上更快。有关详细信息,请参阅我的答案中的基准。我并不是说这是对任何事情的保证,但它很有趣。
      • @LaszloPapp,您的两个程序在我的机器上花费的时间完全相同。速度很复杂。
      • 当然,否则我不会声称它。只是出于好奇。
      【解决方案4】:

      首先,

      Class myCls = new Class();
      

      是无效代码...假设您的意思是

      Class myCls;
      

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

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

      堆栈对象

      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 百万次迭代,堆对象在我的机器上稍快一些。即使在我的机器上,这对于少得多的项目也不会引起注意。突出的一件事是,尽管后续运行的结果略有不同,但堆对象版本在我的笔记本电脑上总是表现更好。但是,不要将其作为保证。

      【讨论】:

      • 您是否多次运行测试?总是一样吗?
      • 哦 :-) 这令人惊讶。任何想法为什么?
      • 非常感谢您的基准测试。这些信息确实很有帮助。
      • @PeterSchneider:嗯,我没有删除堆对象,如果应用程序长时间运行会泄漏内存,但即使添加了删除,也没有太大区别,所以差异不是因为堆栈展开。一种不那么快速和肮脏的方法是仅使用 std::chrono 测量循环。
      • 我已经在我的机器上运行了@LaszloPapp 代码。使用不带任何-O 标志的g++,我得到的程序集看起来 对于“堆栈”应该更快(“堆”包含一个额外的内存-> 注册副本)。但是它们仍然需要完全相同的时间来运行:196+-3ms。单次变化约10ms。
      猜你喜欢
      • 2015-07-04
      • 1970-01-01
      • 2018-01-06
      • 2018-04-02
      • 1970-01-01
      • 2012-01-29
      • 2020-04-20
      • 2011-06-30
      • 2023-03-31
      相关资源
      最近更新 更多