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

时间:2013-08-15 16:46:55

标签: c++ list pointers

阅读此博客文章后,列表对缓存的不友好程度如何: 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-&gt; GetTimeSec()之间的时间差保存到磁盘。我的结果图可以在这里找到:

http://wilcobrouwer.nl/bestanden/ListTestFix.png
正如您所看到的,案例2在插入和删除时的速度提高了约10%,但在迭代时速度提高了约10%,这意味着迭代案例1所需的额外解引用会使其更快!

我在这里缺少什么?

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

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:回答戴尔威尔逊:列表我的意思是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:修复了一些拼写错误(删除结果未显示,但迭代结果显示两次。这已清除删除问题,但迭代问题仍然存在。

6 个答案:

答案 0 :(得分:3)

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

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

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

不要忘记用优化编译它。

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

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

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

答案 1 :(得分:2)

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

尝试使用至少-O2启用编译,看看会发生什么。

答案 2 :(得分:1)

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

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

如果你想要更加缓存,请使用向量(但是中间的插入和删除会更加昂贵)。

答案 3 :(得分:0)

通常,当您指定

Object left = right;

相当于:

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

所以要执行的代码多于以下其中一项:

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

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

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

答案 4 :(得分:0)

我的测试显示存储对象比存储指针快一点。如果对象/指针的数量太大,则内存管理会遇到麻烦(交换)。

我使用的来源:

#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

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

答案 5 :(得分:0)

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

测试用例 - 尝试将该数组减少到220个。