5

在阅读了这篇关于缓存列表有多不友好的博客文章后: 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 值运行,并且在计算完成后将clock->GetTimeSec() 之间的时间差保存到磁盘。我的结果图可以在这里找到:

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

我在这里想念什么?

编辑 1:我的 CPU 是具有 64K/1MB/6MB 缓存的 Phenom II X4 @ 3.5GHz(恒定频率),我正在以这种方式编译(请注意 -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:对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:修复了一些错别字(删除结果没有显示,但迭代结果显示了两次。这已经清除了删除问题,但迭代问题仍然存在。

4

6 回答 6

3

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

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

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

并且不要忘记通过优化对其进行编译。

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

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

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

于 2013-08-15T19:04:10.797 回答
2

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

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

于 2013-08-15T17:17:45.013 回答
1

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

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

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

于 2013-08-15T18:09:38.337 回答
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

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

于 2013-08-15T19:47:20.503 回答
0

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

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

于 2013-08-15T20:41:27.513 回答
0

通常,当您分配

Object left = right;

这相当于:

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

所以这比以下之一要执行的代码多一点:

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

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

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

于 2013-08-15T19:29:13.457 回答