【问题标题】:Why is iterating a list of objects slower than iterating a list of object pointers?为什么迭代对象列表比迭代对象指针列表慢?
【发布时间】:2013-08-15 16:46:54
【问题描述】:

在阅读了这篇关于列表缓存不友好的博文后: http://www.baptiste-wicht.com/2012/11/cpp-benchmark-vector-vs-list/

...我试图通过将实际对象放入每个节点(从而删除一个间接操作)来使指向对象的指针的 std::list 对缓存更友好,希望当当前节点被缓存时,对象也会。但是,性能实际上下降了。这是我使用的代码:

来源和二进制文件: http://wilcobrouwer.nl/bestanden/ListTest%202013-8-15%20%233.7z

#include <list>
using std::list;

list<Object*> case1;
list<Object> case2;

class Object {
    public:
        Object(char i);
        ~Object();

        char dump[256];
};

// Should not notice much of a difference here, equal amounts of memory are 
// allocated
void Insertion(Test* test) {

    // create object, copy pointer
    float start1 = clock->GetTimeSec();
    for(int i = 0;i < test->size;i++) {
        case1.push_back(new Object(i)); 
    }
    test->insertion1 = clock->GetTimeSec()-start1;

    // create object in place, no temps on stack
    float start2 = clock->GetTimeSec();
    for(int i = 0;i < test->size;i++) {
        case2.emplace_back(i); 
    }
    test->insertion2 = clock->GetTimeSec()-start2;
}

// Case 2 removes one extra layer of derefence, so it should be more cache 
// friendly, because when the list node is found in cache, the object should be
// there too
void Iteration(Test* test) {

    // faster than case2 for some reason
    float start1 = clock->GetTimeSec();
    int tmp1 = 0;
    for(list<Object*>::iterator i = case1.begin();i != case1.end();i++) {
        tmp1 += (**i).dump[128]; 
    }
    test->iteration1 = clock->GetTimeSec()-start1;

    // why the hell is this slower? I removed a dereference
    float start2 = clock->GetTimeSec();
    int tmp2 = 0;
    for(list<Object>::iterator i = case2.begin();i != case2.end();i++) {
        tmp2 += (*i).dump[128]; // is equal to tmp1, so no mistakes...
    }
    test->iteration2 = clock->GetTimeSec()-start2;
}

// Case 2 removes one extra layer of derefence, so it should be more cache 
// friendly, because when the list node is found in cache, the object should be
// there too
void Deletion(Test* test) {

    // again, faster than case2 for some reason
    float start1 = clock->GetTimeSec();
    int size1 = case1.size();
    for(list<Object*>::iterator i = case1.begin();i != case1.end();i++) {
        delete *i;
    }
    case1.clear();
    test->deletion1 = clock->GetTimeSec()-start1;

    // as before: why is this slower? I removed a dereference
    float start2 = clock->GetTimeSec();
    int size2 = case2.size();
    case2.clear();
    test->deletion2 = clock->GetTimeSec()-start2;
}

这些函数针对从 1 到 100000 线性变化的 test->size 值运行,并且时钟->GetTimeSec() 之间的时间差在 计算完成后保存到磁盘。可以在这里找到我的结果图:

http://wilcobrouwer.nl/bestanden/ListTestFix.png
如您所见,案例 2 在插入和删除时快了大约 10%,但在迭代时慢了大约 10%,这意味着迭代案例 1 所需的额外取消引用使其更快!

我在这里错过了什么?

编辑 1: 我的 CPU 是 Phenom II X4 @ 3.5GHz(恒定频率),具有 64K/1MB/6MB 缓存,我正在以这种方式编译(请注意 -m64 是暗示,这意味着通过 -mfpmath=ssse 禁止 x87):

Compiler: TDM-GCC 4.7.1 64-bit Release
rm -f obj/Clock.o obj/main.o obj/Object.o ListTest.exe
g++.exe -c Clock.cpp -o obj/Clock.o -std=gnu++11
g++.exe -c main.cpp -o obj/main.o -std=gnu++11
g++.exe -c Objecst.cpp -o obj/Object.o -std=gnu++11
g++.exe obj/Clock.o obj/main.o obj/Object.o -o ListTest.exe -static-libgcc

编辑 2:Dale Wilson 的回答:使用列表我的意思是 std::list。对 Mats Petersson 的回答:图片中添加了摘要。优化检查正在进行中。回答询问更大数据集的人:抱歉,我只有 4GiB 的 RAM,而且从当前最大值到填充的图非常无聊。

编辑 3:我启用了 -O3(-O2 产生类似的结果),这只会使事情变得更糟:

http://wilcobrouwer.nl/bestanden/ListTestO3Fix.png
这一次,案例 2 在插入和删除时快了大约 20%,但这次在迭代时慢了大约 1~5 倍(在更大的测试规模下变得更糟)。同样的结论。

编辑 4:Maxim Yegorushkin 的回答:CPU 频率缩放恰好被禁用(忘了提),我的 CPU 始终运行在 3.5GHz。此外,基本上也可以从更多测试中选择平均值或最佳结果,因为 x 轴上有足够多的样本点。也启用了优化:-O3、-m64 和 mfpmath=sse 都已设置。将相同的测试一个接一个地添加到 std::vector 测试(检查源代码)并没有显着改变。

修改5:修复了一些错别字(删除结果没有显示,但迭代结果显示了两次。这已经清除了删除问题,但迭代问题仍然存在。

【问题讨论】:

  • 我们假设使用标准吗?所以 list 实际上是 std::list?
  • 你记得启用编译器优化吗?
  • 添加 -O2 或 -O3 并再次检查....
  • 您对缓存的假设有点错误。包含大对象的节点对缓存不太友好,因为它们不适合单个缓存行。指针列表可能适合同一缓存行中的两个或多个节点,这可能会加速迭代 - 可能超过额外间接的成本。
  • @Smac89 为什么迭代会涉及任何内存分配?

标签: c++ list pointers


【解决方案1】:

有点离题,但这种基准测试方法不会产生正确和可重复的结果,因为它忽略了缓存效果、CPU 频率扩展和进程调度程序。

要正确测量时间,它需要运行每个微基准测试(即每个循环)几次(例如至少 3 次)并选择最佳时间。最佳时间是当 CPU 缓存、TLB 和分支预测器很热时可实现的最佳时间。您需要最好的时间,因为最坏的时间没有上限,因此无法进行有意义的比较。

进行基准测试时,您还需要禁用 CPU 频率缩放,这样它就不会在您的基准测试过程中切换频率。它还应该以实时优先级运行,以减少因其他进程抢占您的基准而导致的调度噪音。

别忘了用优化编译它。

接下来,让我们回顾一下您的基准测试:

  • 插入:它主要测量两次内存分配 (list&lt;Object*&gt;) 与一次内存分配 (list&lt;Object&gt;) 的时间。
  • 删除:同上,将allocation替换为deallocation
  • 迭代:您的对象大小为 256 字节,即 4x64 字节缓存行。与列表节点大小相比,这样的对象大小太大了,因此当它从 256 字节对象中读取一个字节时,您可能会测量缓存未命中的时间。

您真正想要衡量的是在读取对象的所有字节时(例如,对对象的所有字节求和)时列表的迭代与数组上的迭代。您的假设是,当对象排列在数组中并按顺序访问时,CPU 会将下一个对象预加载到缓存中,这样当您访问它时,就不会发生缓存未命中。而当对象存储在其节点在内存中不连续的列表中时,缓存预读不会提高速度,因为下一个对象在内存中与当前对象不相邻,因此当它追逐列表的指针时会导致缓存未命中。

【讨论】:

    【解决方案2】:

    我在您的构建命令中没有看到任何优化设置,因此您可能正在获得未优化的构建。完全可以相信,在这样的构建中,额外的间接级别(和/或列表节点更小的事实)实际上通过机会/库实现提高了性能。

    尝试在至少启用-O2 的情况下进行编译,看看会发生什么。

    【讨论】:

      【解决方案3】:

      在插入方面,情况 1 速度较慢,因为它分配了两次内存(一次用于对象,另一次用于指向指向列表中对象的指针的指针)。由于 case 2 每次插入只分配一次内存,所以会更快。

      列表容器通常对缓存不友好。不能保证顺序节点将位于顺序内存块中,因此在遍历它时,指针列表会更快,因为它更有可能位于顺序块中而不是对象列表中。删除整个列表也是如此(因为它再次迭代列表)。

      如果您想对缓存更友好,请使用向量(但中间的插入和删除会更昂贵)。

      【讨论】:

      • 我不明白为什么指向已分配对象(分配非连续)的指针列表(可能是连续的)比对象列表(分配非连续)更快!
      • 当你迭代时,如果你有一个块已经有你的 next 指针在内存中,你不必加载它(因此,你只是在实际访问数据时加载分配指针指向)。如果您总是访问它所指向的数据,那么您几乎永远不会同时将它们放在缓存中。
      • 但是如果不是“如果您总是访问它所指向的数据,那么您几乎永远不会同时将它们放在缓存中”,那么迭代的意义何在。不过,您的观点对于 std::advance 或类似内容是正确的。
      • 换句话说,当你迭代一个对象列表时,CPU 必须发送一个新内存块的请求(几乎总是),因为它们可能没有对齐或不连续。当您迭代指针列表时,CPU 只需在您访问数据时发出请求。如果你注意到他更新的图表,他的删除比他的迭代快得多。这是因为他在迭代时没有读取数据,所以它很可能是在连续的内存块中敲击。不过,指针迭代的低寻道时间很奇怪。
      【解决方案4】:

      通常,当您分配时

      Object left = right;
      

      相当于:

      • left分配内存(如果是局部变量,通常在堆栈上)
      • 调用复制构造函数Object::Object(Object&amp; right)。如果未声明,则复制构造函数由编译器隐式生成。

      因此执行的代码比以下代码之一多一点:

      Object& left = right;
      const Object& left = right; 
      Object* pLeft = &right;
      

      任何一个构造都只会创建一个指针,而不是一个新对象。

      但是,在您的情况下,您使用list&lt;Object&gt;::iterator我认为是一个指针,所以这并不能解释速度差异。

      【讨论】:

      • 没错,迭代器基本上就是一个指针(你可以通过向GDB询问它)。
      【解决方案5】:

      我的测试表明存储对象比存储指针快一点。如果对象/指针的数量过多,内存管理就会遇到麻烦(交换)。

      我使用的来源:

      #include <algorithm>
      #include <chrono>
      #include <iostream>
      #include <list>
      using std::list;
      using namespace std::chrono;
      
      struct Test {
          int size = 1000000;
          duration<double> insertion1;
          duration<double> insertion2;
          duration<double> iteration1;
          duration<double> iteration2;
          duration<double> deletion1;
          duration<double> deletion2;
      };
      
      class Object {
          public:
          Object(char i);
          ~Object();
      
          char dump[256];
      };
      
      Object::Object(char i) { std::fill_n(dump, 256, i); }
      Object::~Object() {}
      
      list<Object*> case1;
      list<Object>  case2;
      
      // Should not notice much of a difference here, equal amounts of memory are
      // allocated
      void Insertion(Test& test, int order) {
      
          for(int n = 0; n < 2; ++n) {
              // create object, copy pointer
              if((n == 0 && order == 0) || (n == 1 && order == 1))
              {
                  high_resolution_clock::time_point start1 = high_resolution_clock::now();
                  for(int i = 0;i < test.size;i++) {
                      case1.push_back(new Object(i));
                  }
                  test.insertion1 = duration_cast<duration<double>>(high_resolution_clock::now() - start1);
              }
      
              // create object in place, no temps on stack
              if((n == 0 && order != 0) || (n == 1 && order != 1))
              {
                  high_resolution_clock::time_point start2 = high_resolution_clock::now();
                  for(int i = 0;i < test.size;i++) {
                      case2.emplace_back(i);
                  }
                  test.insertion2 = duration_cast<duration<double>>(high_resolution_clock::now() - start2);
              }
          }
      }
      
      // Case 2 removes one extra layer of derefence, so it should be more cache
      // friendly, because when the list node is found in cache, the object should be
      // there too
      void Iteration(Test& test, int order) {
      
          for(int n = 0; n < 2; ++n) {
              // faster than case2 for some reason
              if((n == 0 && order == 0) || (n == 1 && order == 1))
              {
                  high_resolution_clock::time_point start1 = high_resolution_clock::now();
                  int tmp1 = 0;
                  for(list<Object*>::iterator i = case1.begin();i != case1.end();i++) {
                      tmp1 += (**i).dump[128];
                  }
                  test.iteration1 = duration_cast<duration<double>>(high_resolution_clock::now() - start1);
              }
      
              // why the hell is this slower? I removed a dereference
              if((n == 0 && order != 0) || (n == 1 && order != 1))
              {
                  high_resolution_clock::time_point start2 = high_resolution_clock::now();
                  int tmp2 = 0;
                  for(list<Object>::iterator i = case2.begin();i != case2.end();i++) {
                      tmp2 += (*i).dump[128]; // is equal to tmp1, so no mistakes...
                  }
                  test.iteration2 = duration_cast<duration<double>>(high_resolution_clock::now() - start2);
              }
          }
      }
      
      // Case 2 removes one extra layer of derefence, so it should be more cache
      // friendly, because when the list node is found in cache, the object should be
      // there too
      void Deletion(Test& test, int order) {
      
          for(int n = 0; n < 2; ++n) {
              // again, faster than case2 for some reason
              if((n == 0 && order == 0) || (n == 1 && order == 1))
              {
                  high_resolution_clock::time_point start1 = high_resolution_clock::now();
                  int size1 = case1.size();
                  for(list<Object*>::iterator i = case1.begin();i != case1.end();i++) {
                      delete *i;
                  }
                  case1.clear();
                  test.deletion1 = duration_cast<duration<double>>(high_resolution_clock::now() - start1);
              }
      
              // as before: why is this slower? I removed a dereference
              if((n == 0 && order != 0) || (n == 1 && order != 1))
              {
                  high_resolution_clock::time_point start2 = high_resolution_clock::now();
                  int size2 = case2.size();
                  case2.clear();
                  test.deletion2 = duration_cast<duration<double>>(high_resolution_clock::now() - start2);
              }
          }
      }
      
      int main() {
          Test test;
          std::cout
              << "First Test:\n"
                 "==========" << std::endl;
          Insertion(test, 0);
          std::cout
              <<   "Insertion [Ptr] " << test.insertion1.count()
              << "\n          [Obj] " << test.insertion2.count() << std::endl;
          Iteration(test, 0);
          std::cout
              <<   "Iteration [Ptr] " << test.iteration1.count()
              << "\n          [Obj] " << test.iteration2.count() << std::endl;
          Deletion(test, 0);
          std::cout
              <<   "Deletion  [Ptr] " << test.deletion1.count()
              << "\n          [Obj] " << test.deletion2.count() << std::endl;
      
          std::cout
              << "Second Test:\n"
                 "===========" << std::endl;
          Insertion(test, 1);
          std::cout
              <<   "Insertion [Ptr] " << test.insertion1.count()
              << "\n          [Obj] " << test.insertion2.count() << std::endl;
          Iteration(test, 1);
          std::cout
              <<   "Iteration [Ptr] " << test.iteration1.count()
              << "\n          [Obj] " << test.iteration2.count() << std::endl;
          Deletion(test, 1);
          std::cout
              <<   "Deletion  [Ptr] " << test.deletion1.count()
              << "\n          [Obj] " << test.deletion2.count() << std::endl;
          return 0;
      }
      

      输出:

      First Test:
      ==========
      Insertion [Ptr] 0.298454
                [Obj] 0.253187
      Iteration [Ptr] 0.041983
                [Obj] 0.038143
      Deletion  [Ptr] 0.154887
                [Obj] 0.187797
      Second Test:
      ===========
      Insertion [Ptr] 0.291386
                [Obj] 0.268011
      Iteration [Ptr] 0.039379
                [Obj] 0.039853
      Deletion  [Ptr] 0.150818
                [Obj] 0.105357
      

      请注意在删除时,第一个删除的列表比第二个要快。似乎问题出在内存管理上。

      【讨论】:

      • 哎呀,我注意到删除结果没有显示,但迭代结果显示了两次。迭代问题仍然存在,但 Howland 很好地解释了这一点。
      【解决方案6】:

      纯属猜测:对象列表实际上可能对缓存不太友好。内存分配器可能必须将节点+对象结构放入一个 512 字节的插槽中,其中大部分为空,因为它是 256 字节加上存在的任何列表节点开销。相比之下,指针列表能够将对象放在连续的 256 字节槽中,而节点则放在(例如)连续 16 字节的槽中 - 内存的 2 个独立部分,但都密集地打包。

      测试用例 - 尝试将该数组的大小减小到 220。

      【讨论】:

      • 不,改变 Object 的大小只会改变绝对值(y 轴),而不是情况一和二之间的比率。
      • 感谢您的尝试!对于获取有关理论问题的真实数据总是有用的。
      猜你喜欢
      • 1970-01-01
      • 2019-07-30
      • 1970-01-01
      • 1970-01-01
      • 2013-09-02
      • 2015-12-12
      • 1970-01-01
      • 1970-01-01
      • 2014-04-26
      相关资源
      最近更新 更多