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

Cache-friendliness std::list vs std::vector

本文关键字:std vs vector list 缓存      更新时间:2023-10-16

随着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() << " μsn";
    }
    {
        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() << " μsn";
    }
}
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() << " μsn";
    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() << " μsn";
}
int main()
{
    RandomAccessDeletion();
    TraversedDeletion();
    std::cin.get();
}

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

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

随机存取删除…

开始矢量测量…

耗时240299 μs

起始列表测量…

耗时1368205 μs

vector 比list 快5.6倍。我们很可能要感谢我们的缓存霸主带来的性能优势,尽管我们需要在每次删除时移动vector中的元素,但它的影响小于我们在基准测试中看到的列表查找时间。


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

我的假设是,列表的性能只会略优于向量,或者与向量一样快。

相同执行的结果:

遍历删除…

起始向量测量....

占用195477 μs

起始列表测量…

耗时581 μs

哇。列表大约快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 μs

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

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

另一方面,在RandomAccessDeletion中,您不断更改删除点。这意味着需要O(N)次操作来遍历列表以找到要删除的节点,并且需要O(1)次操作来删除节点,而需要O(1)次遍历来查找元素,并且需要O(N)次操作来向前复制向量中的元素。这两者不一样的原因是,从一个节点到另一个节点的遍历成本比复制vector中的元素要高。

RandomDeletionlist的持续时间较长是由于从列表开始移动到随机选择的元素所花费的时间,这是一个O(N)操作。

TraverseDeletion只是对一个0(1)操作的迭代器加1。

关于vector的"快速"部分是"到达"需要访问的元素(遍历)。实际上,在删除操作中,你不会在vector上遍历太多,而只会访问第一个元素。(我想说一步一步的进步对测量没有多大帮助)

删除需要相当多的时间(O(n)),所以当删除每个元素时,它是O(n²)),因为改变了内存中的元素。因为删除会改变被删除元素后位置的内存,所以你也无法从预取中获益,而预取也是使向量更快的一件事。

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

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

在第二个测试中,列表遍历一次,然后重复删除。所花的时间还在路上;删除的代价很低。只是现在我们不需要重复遍历了

对于向量,遍历是自由的。删除需要时间。随机删除一个元素所花费的时间小于列表遍历到该随机元素所花费的时间,因此在第一种情况下vector占上风。

在第二种情况下,vector所做的艰苦工作比list所做的艰苦工作要多得多。

但是,问题是你不应该这样对一个向量进行遍历和删除。对于列表,这是一种可接受的方法。

对于一个向量,你可以这样写: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());

list几乎比vector快的唯一情况是,当您将一个迭代器存储到list中,并且在该迭代器处或附近定期擦除,同时仍然在这些擦除操作之间遍历列表。

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

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


每次擦除vector中的一个元素时,它将vector的"尾部"移动到1上方。

如果擦除10,000个元素,则一步将vector的"尾部"移动到10,000以上。

如果你使用remove_if,它会有效地移除尾部,给你剩余的"浪费",然后你可以从向量中删除浪费。

我想指出这个问题中还没有提到的一些事情:

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

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

最好使用静态C数组作为向量元素,然后使用不同数组大小进行测量。

所以,你可以交换这一行代码:
std::vector<int> vec;

,例如:

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

,用不同的size值进行检验。