【问题标题】:CPU Cache disadvantages of using linked lists in C在 C 中使用链表的 CPU Cache 缺点
【发布时间】:2017-02-25 13:35:46
【问题描述】:

我想知道与 C 中的连续数组相比,链表的优点和缺点是什么。因此我阅读了一篇关于链表的维基百科文章。 https://en.wikipedia.org/wiki/Linked_list#Disadvantages

根据这篇文章,缺点如下:

  • 由于指针使用的存储空间,它们比数组使用更多的内存。
  • 链表中的节点必须从头开始按顺序读取,因为链表本质上是顺序访问。
  • 当涉及到反向遍历时,链表会出现困难。例如,单链表向后导航很麻烦,而双链表在某种程度上更易于阅读,但分配时会浪费内存。

  • 节点不连续存储,大大增加了访问列表中各个元素所需的时间,尤其是使用 CPU 缓存时。

我理解前 3 点,但我很难理解最后一个:

节点不连续存储,大大增加了访问列表中单个元素所需的时间,尤其是使用 CPU 缓存时。

关于 CPU Cache 的文章没有提到任何关于非连续内存数组的内容。据我所知,CPU 缓存只是缓存经常使用的地址,总共 10^-6 缓存未命中。

因此,我不明白为什么在涉及非连续内存阵列时 CPU 缓存的效率应该较低。

【问题讨论】:

标签: c caching optimization linked-list cpu-cache


【解决方案1】:

CPU 缓存实际上做了两件事。

你提到的是缓存最近使用的内存。

然而,另一个是预测在不久的将来将使用哪些内存。该算法通常非常简单——它假设程序处理大量数据,并且每当它访问一些内存时,它会预取更多的字节。

这不适用于链表,因为节点是随机放置在内存中的。

此外,CPU 会加载更大的内存块(64、128 字节)。同样,对于具有单次读取的 int64 数组,它具有用于处理 8 或 16 个元素的数据。对于链表,它读取一个块,其余的可能会被浪费,因为下一个节点可能在完全不同的内存块中。

最后但并非最不重要的一点是,与上一节相关 - 链表需要更多内存来管理它,最简单的版本将至少需要额外的 sizeof(pointer) 字节来指向下一个节点的指针。但它不再是 CPU 缓存了。

【讨论】:

  • 最大的区别不是预测,而是粒度。也就是说,高速缓存由单独的高速缓存行组成,每个高速缓存行都存储一个小的连续内存块。因此,对于不可预测或冷访问,如果每个元素是 16 字节,缓存行是 128 字节,那么对于线性数组扫描,只有每八次访问是一次未命中,而链表最终可能会浪费 7/8 的内存带宽。
  • @doynax,现在两者兼而有之。但你说得对,答案可能是更好地讨论粒度(空间局部性)和预测作为单独的概念。
【解决方案2】:

这篇文章只是触及了皮毛,并且出现了一些错误(或至少是有问题的),但总体结果通常大致相同:链表要慢得多。

需要注意的一点是,“节点存储不连续 [原文如此]”是一个过于强烈的主张。确实,通常由 malloc 返回的节点可能会分布在内存中,特别是如果节点是在不同时间或从不同线程分配的。然而,在实践中,许多节点通常同时分配在同一个线程上,并且这些节点通常会在内存中非常连续,因为好的 malloc 实现非常好!此外,当性能受到关注时,您可能经常基于每个对象使用特殊分配器,它从一个或多个连续的内存块中分配固定大小的音符,这将提供很好的空间局部性。

因此,您可以假设,至少在某些情况下,链表会为您提供合理到良好的空间局部性。这在很大程度上取决于您是一次添加大部分列表元素(链表可以),还是在较长时间内不断添加元素(链表的空间局部性较差)。

现在,在列表变慢的一边,链表掩盖的主要问题之一是与数组变体相关的某些操作相关的大常数因子。每个人都知道在给定索引的情况下访问一个元素在链表中是O(n),在数组中是O(1),所以如果你要通过索引进行大量访问,就不要使用链表。同样,大家都知道在链表中添加元素需要O(1)时间,在数组中需要O(n)时间,所以前者胜出。

他们没有解决的是,即使是具有相同算法复杂度的操作,在一种实现中实际上也可能慢得多...

让我们遍历列表中的所有元素(也许是寻找一个特定的值)。无论您使用链接表示还是数组表示,这都是 O(n) 操作。所以这是平局,对吧?

没那么快!实际表现可能会有很大差异! Here is what 典型的 find() 实现在 x86 gcc 中以 -O2 优化级别编译时看起来像,这要归功于 Godbolt,它使这变得简单。

数组

C 代码

int find_array(int val, int *array, unsigned int size) {
    for (unsigned int i=0; i < size; i++) {
      if (array[i] == val)
        return i;
    }

    return -1;
}

组装(仅循环)1

.L6:
        add     rsi, 4
        cmp     DWORD PTR [rsi-4], edi
        je      .done
        add     eax, 1
        cmp     edx, eax
        jne     .notfound

链表

C 代码

struct Node {
  struct Node *next;
  int item;
};

Node * find_list(int val, Node *listptr) {
    while (listptr) {
      if (listptr->item == val)
        return listptr;
      listptr = listptr->next;
    }
    return 0;
}

组装(仅循环)

.L20:
        cmp     DWORD PTR [rax+8], edi
        je      .done
        mov     rax, QWORD PTR [rax]
        test    rax, rax
        jne     .notfound

只关注 C 代码,两种方法看起来都具有竞争力。数组方法将增加i,进行几次比较,以及一次内存访问以从数组中读取值。链表版本如果有几个(相邻的)内存访问来读取Node.valNode.next 成员,以及几个比较。

程序集似乎证明了这一点:链表版本有 5 条指令,数组版本2 有 6 条。所有指令都是简单指令,每个周期的吞吐量为 1 条或更多在现代硬件上。

如果您测试它 - 两个列表都完全驻留在 L1 中,您会发现数组版本每次迭代执行大约 1.5 个循环,而链表版本大约需要 4 个!这是因为链表版本受限于它对listptr 的循环依赖。一行listptr = listptr-&gt;next 归结为 on 指令,但一条指令每 4 个周期执行一次不会超过一次,因为每次执行都取决于前一条的完成(您需要阅读完 listptr-&gt;next 才能计算listptr-&gt;next-&gt;next)。尽管现代 CPU 每个周期可以执行 2 个加载周期,但这些加载需要大约 4 个周期才能完成,因此您会遇到串行瓶颈。

数组版本也有加载,但地址不依赖于之前的加载:

add     rsi, 4
cmp     DWORD PTR [rsi-4], edi

它只取决于rsi,它只是通过每次迭代加4来计算的。 add 在现代硬件上具有一个周期的延迟,因此这不会造成瓶颈(除非您低于 1 个周期/迭代)。因此数组循环能够利用 CPU 的全部功能,并行执行许多指令。链表版本不是。

这不是“查找”所独有的 - 任何需要迭代许多元素的链接操作都将具有这种 指针追逐 行为,这在现代硬件上天生就很慢。


1我省略了每个汇编函数的结尾和序言,因为它确实没有做任何有趣的事情。两个版本实际上都没有尾声,而且两个版本的序言都非常相似,剥离了第一次迭代并跳到了循环的中间。无论如何,完整的代码是available for inspection

2值得注意的是,gcc 并没有真正做到这一点,因为它同时维护了 rsi 作为指向数组的指针,以及 eax 作为索引i。这意味着两个单独的cmp 指令和两个增量。最好只在循环中维护指针rsi,并将(array + 4*size) 作为“未找到”条件进行比较。那将消除一个增量。此外,您可以消除一个cmp,方法是让rsi-4*size 运行到零,并使用[rdi + rsi] 对数组进行索引,其中rdi 是array + 4*size。表明即使在今天,优化编译器也并非一切都正确!

【讨论】:

  • 隐含的假设是链表主要用于您真正需要 O(1) 插入的地方,因此随着时间的推移几乎总是会变得混乱和不连续。否则你会选择一些部分链接的解决方案(也许是一组链接的向量)。另外 - 你错过了缓存预取数组的好处。
  • 这是您的假设或 OP 的假设,还是每个人的假设?当然,对于列表数据结构的单一特定用途,具有众所周知且可预测的访问模式,您可能有幸选择完全正确的列表类型。在现实世界中,您经常会比较数组和链表以及其他类型以用于各种可能没有明显选择的场景(例如选择语言或应用程序的默认列表类型等)。
  • 另外,如果你正在执行大量插入操作,链接数据结构的 O(1) 行为将破坏基于数组的 O(n) 行为,除非列表很短。因此,在这种情况下,您不需要所有关于系数的微妙参数 - 您只需使用对您的常见操作没有可怕行为的结构。我没有在我的答案中涵盖预取或任何其他原因,因为现有答案很好地涵盖了它们。我想添加一些新东西:)
  • @BeeOnRope 如果我将链表实现为数组,是否会更好地利用缓存行,尤其是当插入数是 64 的倍数时?
  • 这取决于“将链表实现为数组”的含义。基本上,您所做的任何使节点紧密存储在一起并且紧密包装的操作都会有所帮助。插入的数量是 64 的倍数并不重要。 @gansub
【解决方案3】:

CPU缓存通常会接收一个特定大小的页面,例如(常见的)4096字节4kB,并从那里访问所需的信息。要获取一个页面,需要消耗大量时间,比如说 1000 个周期。如果说我们有一个连续的 4096 字节数组,我们将从缓存内存中获取一个 4096 字节的页面,并且可能大部分数据都在那里。如果不是,我们可能需要获取另一个页面来获取其余数据。

示例:我们有 0-8191 的 2 个页面,数组在 2048 和 6244 之间,然后我们将从 0-4095 获取页面#1 以获取所需的元素,然后获取页面#2从 4096-8191 获取我们想要的所有数组元素。这导致从内存中获取 2 个页面到我们的缓存中以获取我们的数据。

但列表中会发生什么?在列表中,数据是不连续的,这意味着元素不在内存中的连续位置,因此它们可能分散在各个页面中。这意味着 CPU 必须从内存中获取大量页面到缓存中才能获得所需的数据。

示例: Node#1 mem_address = 1000,Node#2 mem_address = 5000,Node#3 mem_address = 18000。如果 CPU 能够看到 4k 页面大小,那么它必须获取 3从内存中找到不同的页面来找到它想要的数据。

此外,内存使用 prefetch 技术在需要之前获取内存页面,因此如果链表很小,比如说 A -> B -> C,那么第一个周期会很慢因为预取器无法预测下一个要提取的块。但是,在下一个循环中,我们说预取器已经预热,它可以开始预测链表的路径并按时获取正确的块。

汇总数组很容易被硬件预测并且位于一个位置,因此它们很容易获取,而链表是不可预测的并且分散在整个内存中,这使得预测器和 CPU 的寿命变得更加困难。

【讨论】:

    【解决方案4】:

    BeeOnRope 的回答很好,并强调了遍历链表与遍历数组的循环计数开销,但正如他明确表示的那样,这是假设“两个列表都完全驻留在 L1 中”。但是,数组在 L1 中比链表更适合的可能性要大得多,并且在您开始破坏缓存的那一刻,性能差异变得巨大。 RAM 可能比 L1 慢 100 倍以上,而 L2 和 L3(如果您的 CPU 有的话)则慢 3 到 14 倍。

    在 64 位架构上,每个指针占用 8 个字节,双向链表需要其中两个或 16 个字节的开销。如果每个条目只需要一个 4 字节的 uint32,这意味着 dlist 需要的存储空间是数组所需的 5 倍。数组保证了局部性,尽管如果你按照正确的顺序将东西分配在一起,malloc 可以在局部性上做得很好,但你通常不能。让我们通过说它占用 2 倍的空间来近似较差的局部性,因此 dlist 使用的“局部性空间”是数组的 10 倍。这足以让你从适合 L1 到溢出到 L3,甚至更糟的是从 L2 到 RAM。

    【讨论】:

      猜你喜欢
      • 2018-04-13
      • 2015-05-17
      • 1970-01-01
      • 2012-05-15
      • 2011-12-17
      • 1970-01-01
      • 2011-01-27
      • 2010-09-25
      • 2014-10-30
      相关资源
      最近更新 更多