为什么迭代对象列表比迭代对象指针列表慢

Why is iterating a list of objects slower than iterating a list of object pointers?

本文关键字:对象 列表 迭代 指针 为什么      更新时间:2023-10-16

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

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

源代码和二进制文件: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线性变化的测试->大小值运行,clock->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::列表。回答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:修复了一些拼写错误(删除结果没有显示,但迭代结果显示了两次。这已经解决了删除问题,但迭代问题仍然存在。

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

为了正确地测量时间,它需要运行每个微基准(即每个和每个循环)几次(比如至少3次),并选择最佳时间。该最佳时间是当CPU缓存、TLB和分支预测器处于热状态时可实现的最佳时间。你需要最好的时光,因为最糟糕的时光没有上限,所以无法进行有意义的比较。

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

别忘了用优化编译它。

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

  • 插入:它基本上测量两个内存分配(list<Object*>)与一个内存分配的时间(list<Object>
  • 删除:同上,将分配替换为解除分配
  • 迭代:对象大小为256字节,即4x64字节的缓存行。与列表节点的大小相比,这样的对象大小太大了,所以当缓存从256字节的对象中读取一个字节时,您可能会测量缓存未命中的时间

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

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

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

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

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

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

通常,当您分配时

Object left = right;

相当于:

  • left分配内存(如果是局部变量,则通常在堆栈上)
  • 调用复制构造函数CCD_ 5。若并没有声明,复制构造函数将由编译器隐式生成

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

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

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

然而,在您的情况下,您使用list<Object>::iterator我认为是一个指针,因此这不能解释速度差异。

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

我使用的来源:

#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

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

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

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