【问题标题】:Cache-friendliness std::list vs std::vector缓存友好性 std::list 与 std::vector
【发布时间】:2017-03-22 05:35:24
【问题描述】:

随着 CPU 缓存越来越好,std::vector 的性能通常优于std::list,即使在测试std::list 的优势时也是如此。出于这个原因,即使在我需要在容器中间删除/插入的情况下,我通常也会选择std::vector,但我意识到我从未测试过这个以确保假设是正确的。所以我设置了一些测试代码:

#include <iostream>
#include <chrono>
#include <list>
#include <vector>
#include <random>

void TraversedDeletion()
{
    std::random_device dv;
    std::mt19937 mt{ dv() };
    std::uniform_int_distribution<> dis(0, 100000000);

    std::vector<int> vec;
    for (int i = 0; i < 100000; ++i)
    {
        vec.emplace_back(dis(mt));
    }

    std::list<int> lis;
    for (int i = 0; i < 100000; ++i)
    {
        lis.emplace_back(dis(mt));
    }

    {
        std::cout << "Traversed deletion...\n";
        std::cout << "Starting vector measurement...\n";

        auto now = std::chrono::system_clock::now();
        auto index = vec.size() / 2;
        auto itr = vec.begin() + index;
        for (int i = 0; i < 10000; ++i)
        {
            itr = vec.erase(itr);
        }

        std::cout << "Took " << std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now() - now).count() << " μs\n";
    }

    {
        std::cout << "Starting list measurement...\n";

        auto now = std::chrono::system_clock::now();
        auto index = lis.size() / 2;
        auto itr = lis.begin();
        std::advance(itr, index);
        for (int i = 0; i < 10000; ++i)
        {
            auto it = itr;
            std::advance(itr, 1);
            lis.erase(it);
        }

        std::cout << "Took " << std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now() - now).count() << " μs\n";
    }

}

void RandomAccessDeletion()
{
    std::random_device dv;
    std::mt19937 mt{ dv() };
    std::uniform_int_distribution<> dis(0, 100000000);

    std::vector<int> vec;
    for (int i = 0; i < 100000; ++i)
    {
        vec.emplace_back(dis(mt));
    }

    std::list<int> lis;
    for (int i = 0; i < 100000; ++i)
    {
        lis.emplace_back(dis(mt));
    }

    std::cout << "Random access deletion...\n";
    std::cout << "Starting vector measurement...\n";
    std::uniform_int_distribution<> vect_dist(0, vec.size() - 10000);

    auto now = std::chrono::system_clock::now();

    for (int i = 0; i < 10000; ++i)
    {
        auto rand_index = vect_dist(mt);
        auto itr = vec.begin();
        std::advance(itr, rand_index);
        vec.erase(itr);
    }

    std::cout << "Took " << std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now() - now).count() << " μs\n";

    std::cout << "Starting list measurement...\n";

    now = std::chrono::system_clock::now();

    for (int i = 0; i < 10000; ++i)
    {
        auto rand_index = vect_dist(mt);
        auto itr = lis.begin();
        std::advance(itr, rand_index);
        lis.erase(itr);
    }

    std::cout << "Took " << std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now() - now).count() << " μs\n";
}

int main()
{
    RandomAccessDeletion();
    TraversedDeletion();
    std::cin.get();
}

所有结果都用/02 (Maximize speed)编译。

第一个,RandomAccessDeletion(),生成一个随机索引并将该索引擦除 10.000 次。我的假设是正确的,向量确实比列表快很多:

随机访问删除...

开始矢量测量...

耗时 240299 微秒

开始列表测量...

耗时 1368205 μs

向量比列表快 5.6 倍。我们很可能要感谢缓存霸主的这种性能优势,即使我们需要在每次删除时移动向量中的元素,它的影响小于列表的查找时间,正如我们在基准测试中看到的那样。


然后我添加了另一个测试,在TraversedDeletion() 中看到。它不使用随机位置来删除,而是在容器中间选择一个索引并将其用作基本迭代器,然后遍历容器以擦除 10.000 次。

我的假设是该列表的性能仅略胜于向量或与向量一样快。

相同执行的结果:

遍历删除...

开始矢量测量....

耗时 195477 微秒

开始列表测量...

耗时 581 微秒

哇。该列表的速度大约是 336 倍。这与我的预期相差甚远。因此,在列表中出现一些缓存未命中似乎根本不重要,因为减少列表的查找时间会更重要。


因此,当涉及到角落/不寻常案例的性能时,该列表显然仍然具有非常强大的地位,或者我的测试用例在某些方面存在缺陷?

这是否意味着现在的列表只是遍历时在容器中间进行大量插入/删除的合理选择,还是有其他情况?

有没有办法可以更改TraversedDeletion() 中的矢量访问和擦除,使其与列表相比至少更具竞争力?


回应@BoPersson 的评论:

vec.erase(it, it+10000) 的性能会比 10000 好很多 单独删除。

变化:

for (int i = 0; i < 10000; ++i)
{
    itr = vec.erase(itr);
}

收件人:

vec.erase(itr, itr + 10000);

给我:

开始矢量测量...

耗时 19 微秒

这已经是一个重大的改进了。

【问题讨论】:

  • 矢量擦除测试表现出未定义的行为。 vec.erase(it) 使 itr 无效。你想要itr = vec.erase(itr);
  • 取决于“擦除”对向量的作用。它很可能将内存复制到新的内存位置(删除的元素除外),因此您在矢量擦除中没有缓存友好性。还有很多“抄袭”
  • @NathanOliver 不,他没有。我不确定你在说什么。
  • @IgorTandetnik 哎呀。我看错了。 vec.erase(it) 确实 ivalidate itr 并且他在每次后续迭代中都使用它。我在看RandomAccessDeletion 而不是TraversedDeletion
  • 擦除范围意味着元素只需要移动一次,因此大约需要 40,000 个副本。一个一个地擦除意味着相同的 40,000-50,000 个元素每个需要移动 10,000 次,总共大约 450,000,000 个副本。

标签: c++ list c++11 vector visual-studio-2015


【解决方案1】:

RandomDeletionlist 的长时间持续时间是由于从列表的开头前进到随机选择的元素所需的时间,这是一个 O(N) 操作。

TraverseDeletion 只是增加一个迭代器,一个 O(1) 操作。

【讨论】:

  • TraversedDeletion 还将std::advance(itr, index); 包含在测量中。我会很好奇这部分时间与实际删除相比。
  • @IgorTandetnik 遍历删除前进 1。随机删除前进 N,其中 N 介于 0 和 10,000 之间(平均 5,000)。要在列表中前进,您需要遍历列表元素 N 次,因此随机删除需要做更多的工作才能到达要删除的元素。
  • 它在大多数情况下前进 1 - 但是有一个一次性设置调用将迭代器移动到列表的中间,并且它包含在测量中。
  • @IgorTandetnik 但那是一次,而不是一万次。
  • 但它需要遍历 50,000 个节点,而不是 10,000 个节点。 std::advance 内置了一个循环
【解决方案2】:

关于向量的“快速”部分是“到达”需要访问(遍历)的元素。您实际上并没有在删除中遍历向量,而只访问第一个元素。 (我会说提前一个并没有多少测量明智)

然后删除需要相当多的时间( O(n) ,因此当单独删除每个时,它是 O(n²) ),因为更改了内存中的元素。因为删除会更改已删除元素之后位置上的内存,所以您也无法从预取中受益,这也是使向量变得如此之快的原因。

我不确定删除也会使缓存失效多少,因为迭代器之外的内存已经改变,但这也会对性能产生很大影响。

【讨论】:

    【解决方案3】:

    TraversedDeletion 中,您实际上是在做pop_front,但不是在前面,而是在中间做。对于链表,这不是问题。删除节点是一个 O(1) 操作。不幸的是,当您在向量中执行此操作时,它是一个 O(N) 操作,其中 Nvec.end() - itr。这是因为它必须将每个元素从删除点向前复制一个元素。这就是为什么它在矢量情况下要贵得多。

    另一方面,在RandomAccessDeletion 中,您不断更改删除点。这意味着您有一个 O(N) 操作来遍历列表以到达要删除的节点,以及一个 O(1) 来删除节点,而 O(1) 遍历来查找元素和一个 O(N) 操作向前复制向量中的元素。这不一样的原因是从一个节点到另一个节点的遍历成本比复制向量中的元素所花费的常数更高。

    【讨论】:

      【解决方案4】:

      在第一个测试中,列表必须遍历到删除点,然后删除条目。列表花费的时间是遍历每次删除

      在第二次测试中,列表遍历一次,然后反复删除。花费的时间还在遍历中;删除很便宜。除了现在我们不会重复遍历。

      对于向量,遍历是免费的。删除需要时间。随机删除一个元素所花费的时间少于列表遍历到该随机元素所花费的时间,因此向量在第一种情况下胜出。

      在第二种情况下,向量的工作量比列表的工作量多得多。

      但是,问题在于您不应该如何从向量中遍历和删除。对于列表,这是一种可接受的方式。

      向量的写法是std::remove_if,然后是erase。或者只擦除一次:

        auto index = vec.size() / 2;
        auto itr = vec.begin() + index;
        vec.erase(itr, itr+10000);
      

      或者,模拟一个涉及擦除元素的更复杂的决策过程:

        auto index = vec.size() / 2;
        auto itr = vec.begin() + index;
        int count = 10000;
        auto last = std::remove_if( itr, vec.end(),
          [&count](auto&&){
            if (count <= 0) return false;
            --count;
            return true;
          }
        );
        vec.erase(last, vec.end());
      

      几乎唯一一种listvector 快得多的情况是,您将一个迭代器存储到list 中,并且您在该迭代器处或附近定期擦除,同时仍在遍历列出此类擦除操作。

      根据我的经验,几乎所有其他用例的 vector 使用模式都匹配或超过 list 性能。

      代码不能总是逐行翻译,正如您所演示的那样。


      每次擦除向量中的元素时,都会将向量的“尾部”移动超过 1。

      如果你删除 10,000 个元素,它会将向量的“尾部”一步移动超过 10000 个。

      如果你remove_if,它会有效地去除尾部,给你剩余的“浪费”,然后你可以从向量中去除浪费。

      【讨论】:

        【解决方案5】:

        我希望 po 指出这个问题中仍未提及的内容:

        在 std::vector 中,当您删除中间的元素时,由于新的移动语义,元素会被移动。这是第一个测试采用这种速度的原因之一,因为您甚至没有在删除的迭代器之后复制元素。您可以使用不可复制类型的向量和列表重现该实验,并查看列表的性能(比较)如何更好。

        【讨论】:

        • 1.这是vector&lt;int&gt;int(或任何原始数据类型)的移动和复制操作完全相同,没有通过移动语义加速。 2. 一个向量是连续的,如果你在中间删除一个元素,所有元素必须被移动/复制以填补空白,类型必须是可复制的。不可复制在这里没有意义。
        猜你喜欢
        • 2010-09-19
        • 2012-05-07
        • 1970-01-01
        • 2018-06-06
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多