缓存友好性:std::list vs std::vector

14

随着CPU缓存越来越好,即使在测试std::list的强度时,std::vector通常表现得更好。因此,即使在需要在容器中间删除/插入的情况下,我通常会选择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(最大化速度)编译。

首先,RandomAccessDeletion()生成一个随机索引并删除该索引10,000次。我的假设是正确的,向量确实比列表快得多:

随机访问删除...

开始测量向量...

花费240299微秒

开始测量列表...

花费1368205微秒

向量比列表快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微秒

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


4
向量删除测试表现为未定义行为。“vec.erase(it)”使“itr”无效。您需要使用“itr = vec.erase(itr);”。 - Igor Tandetnik
取决于向量上的“erase”操作,它很可能会将内存复制到一个新的内存位置(除了被删除的元素),因此在向量擦除时缺乏缓存友好性。而且还有很多“复制”。 - Hayt
@IgorTandetnik 哎呀,我看错了。vec.erase(it) 会使 itr 失效,而他在每个后续迭代中都使用了它。我看的是 RandomAccessDeletion 而不是 TraversedDeletion - NathanOliver
@Hayt:在被删除的迭代器之前的所有迭代器都不会失效 - 分配的内存区域是相同的,元素只是“向后移动”以占据被删除元素的位置。 - Vittorio Romeo
1
擦除一个范围意味着元素只需要移动一次,因此大约需要40,000个副本。逐个擦除意味着相同的40,000-50,000个元素需要每个移动10,000次,总共约为4.5亿个副本。 - Igor Tandetnik
显示剩余8条评论
6个回答

5
TraversedDeletion中,你实际上是在中间执行一个pop_front操作。对于链表来说,这并不是一个问题。删除节点是一个O(1)的操作。但是,在向量中执行此操作时,复杂度为O(N),其中Nvec.end() - itr。这是因为它必须将删除点后面的每个元素都复制一遍。这就是为什么在向量情况下它要显得更加昂贵。
另一方面,在RandomAccessDeletion中,你不断地改变删除点。这意味着你需要一个O(N)的操作来遍历列表以获取要删除的节点,并且一个O(1)的操作来删除节点,而不是一个O(1)的遍历来查找元素和一个O(N)的操作来复制向量中的元素。然而,这不同的原因在于从节点到节点遍历的成本比复制向量中的元素所需的成本更高。

4

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

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


“TraversedDeletion” 还将 std::advance(itr, index); 纳入了测量范围。我很好奇这部分时间与实际删除相比,哪个更占用时间。 - Igor Tandetnik
@IgorTandetnik 遍历删除每次前进1个。随机删除每次前进N,其中N在0到10,000之间(平均为5,000)。要在列表中前进,您需要遍历列表元素N次,因此随机删除需要做更多的工作才能到达要删除的元素。 - 1201ProgramAlarm
大多数情况下,它会递增1 - 但是有一个一次性的设置调用来将迭代器移动到列表的中间,并且它包含在测量中。 - Igor Tandetnik
@IgorTandetnik 但那只是一次,不是一万次。 - 1201ProgramAlarm
但是需要遍历50000个节点,而不是10000个节点。 std :: advance 中内置了一个循环。 - Igor Tandetnik
显示剩余3条评论

2
关于向量的“快速”部分,是“到达”需要访问的元素(遍历)的过程。在删除中,您实际上不会在向量上进行太多遍历,而只是访问第一个元素。(我认为逐个删除没有太大的测量意义)
然后,由于更改内存中的元素,删除需要相当长的时间(O(n),因此当每个元素单独进行删除时,其复杂度为O(n²))。由于删除更改了删除元素后位置的存储器位置,所以您也无法从预取中受益,这也使得该向量变得快速。
我不确定删除还会使高速缓存失效多少,因为迭代器之后的内存已更改,但这也可能对性能产生很大的影响。

2

在第一次测试中,列表必须遍历到删除点,然后删除该条目。列表所需的时间是在每次删除时进行遍历。

在第二次测试中,列表遍历了一次,然后重复删除。花费的时间仍在遍历中,删除操作很便宜。除此之外,我们不再重复遍历。

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

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

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

对于向量,您应该使用std::remove_if,然后使用erase。或者只使用一个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 快得多。
在我的经验中,几乎每种其他用途都有一个 vector 使用模式,其性能与 list 相匹配或超越。
代码并不总是可以逐行翻译,正如您所展示的那样。
每次您在向量中删除元素时,它将“尾部”向右移动1个位置。
如果您删除了10,000个元素,则它会一步移动向量的“尾部”10000个位置。
如果您使用 remove_if,它会有效地移除剩余项,然后您可以从向量中删除浪费的内容。

0
我想指出这个问题中还没有提到的一些内容:
在std :: vector中,当您删除中间的元素时,由于新的移动语义,元素被移动。这是第一个测试速度快的原因之一,因为您甚至不需要复制删除迭代器后的元素。您可以使用非可复制类型的向量和列表重现实验,并查看列表(相比之下)的性能更好。

  1. 这是一个 vector<int>,对于 int(或任何原始数据类型),移动和复制操作完全相同,没有通过移动语义加速的效果。
  2. 向量是连续的,如果您在中间删除一个元素,则其后的所有元素都必须被移动/复制以填补空缺,类型必须是可复制的。不可复制在这里没有意义。
- AliciaBytes

0

我建议使用更复杂的数据类型在std::vector中运行相同的测试:不要使用int,而是使用结构体。

更好的方法是将静态C数组用作向量元素,然后使用不同的数组大小进行测量。

因此,您可以交换代码中的这一行:

std::vector<int> vec;

使用类似以下的方式:

const size_t size = 256;
struct TestType { int a[size]; };
std::vector<TestType> vec;

并且使用不同的 size 值进行测试。行为可能取决于此参数。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接