非连续数组性能好吗?


12

在C#中,当用户创建一个List<byte>并向其中添加字节时,它有可能用完空间并需要分配更多空间。它分配前一个数组的大小的两倍(或其他倍数),复制字节并丢弃对旧数组的引用。我知道该列表成倍增长,因为每次分配都很昂贵,这将其限制在O(log n)分配范围内,10每次仅添加额外的项就会导致O(n)分配。

但是,对于大型阵列,可能会浪费大量空间,可能几乎是阵列的一半。为了减少内存,我写了一个类似的类NonContiguousArrayListList<byte>如果列表中的内存少于4MB,它将用作后备存储,然后它会NonContiguousArrayList随着大小的增加分配额外的4MB字节数组。

List<byte>这些阵列不同,它们是非连续的,因此周围没有数据复制,只有额外的4M分配。查找项目时,将索引除以4M以获得包含该项目的数组的索引,然后对4M取模以获取数组内的索引。

您能指出这种方法的问题吗?这是我的清单:

  • 不连续的数组没有缓存局部性,这会导致性能下降。但是,在4M的块大小下,似乎将有足够的位置进行良好的缓存。
  • 访问一个项目不是那么简单,存在一个间接的附加级别。这会优化吗?会导致缓存问题吗?
  • 由于达到4M限制后线性增长,因此您可以分配比平时更多的分配(例如,对于1GB内存,最多可以分配250个分配)。4M之后不会复制任何额外的内存,但是我不确定额外的分配是否比复制大块内存更昂贵。

8
您已经用尽了理论(考虑了缓存,讨论了渐进复杂性),剩下的就是插入参数(这里,每个子列表4M项),并且可能进行了微优化。现在是进行基准测试的时候了,因为没有固定硬件和实现,就没有太多数据可以进一步讨论性能了。

3
如果您在一个集合中使用超过400万个元素,那么我希望容器微优化是您对性能最少的关注。
Telastyn

2
您所描述的内容类似于展开的链表(具有非常大的节点)。您关于它们没有缓存局部性的断言有些错误。单个缓存行中只能容纳这么多的数组。假设64个字节。因此,每64个字节将有一个高速缓存未命中。现在考虑一个展开的链表,该链表的节点正好是64个字节的某个倍数(包括用于垃圾回收的对象标头)。每64个字节仍然只有一个高速缓存未命中,并且节点在内存中不相邻甚至无关紧要。
2015年

@Doval并不是真正的展开链表,因为4M块本身存储在数组中,因此访问任何元素都是O(1)而不是O(n / B),其中B是块大小。

2
@ user2313838如果有1000MB内存和350MB阵列,则增长阵列所需的内存将为1050MB,大于可用内存,这是主要问题,您的有效限制是总空间的1/3。TrimExcess仅当列表已创建时才有帮助,即使这样列表仍然需要足够的空间用于复制。
noisecapella 2015年

Answers:


5

在您提到的范围内,所关注的问题与您所提到的完全不同。

缓存位置

  • 有两个相关的概念:
    1. 局部性,最近访问过的同一缓存行(空间局部性)上数据的重用(时间局部性)
    2. 自动缓存预取(流)。
  • 在您提到的规模上(数百MB到千兆字节,以4MB块为单位),这两个因素与数据元素访问模式的关系比与内存布局的影响更大。
  • 我(毫无头绪)的预测是,从统计上讲,与巨大的连续内存分配相比,性能可能不会有太大差异。没有收获就没有损失。

数据元素访问模式

  • 本文直观地说明了内存访问模式将如何影响性能。
  • 简而言之,请记住,如果您的算法已经受到内存带宽的限制,那么提高性能的唯一方法就是对已经加载到缓存中的数据进行更多有用的工作。
  • 换句话说,即使YourList[k]并且YourList[k+1]很有可能连续(四百万分之一的可能性),如果您完全随机地访问列表,或者步履蹒跚,例如大步前进,该事实也无济于事。while { index += random.Next(1024); DoStuff(YourList[index]); }

与GC系统的相互作用

  • 我认为,这是您应该重点关注的地方。
  • 至少要了解您的设计将如何与以下对象交互:
  • 我对这些主题不了解,因此我将让其他人做出贡献。

地址偏移量计算的开销

  • 典型的C#代码已经在进行大量的地址偏移量计算,因此,方案产生的额外开销不会比在单个数组上工作的典型C#代码更糟。
    • 记住,C#代码也可以进行数组范围检查。这个事实并不能阻止C#达到与C ++代码相当的数组处理性能。
    • 原因是性能主要受到内存带宽的限制。
    • 从内存带宽最大化实用程序的技巧是使用SIMD指令进行内存读/写操作。典型的C#和典型的C ++都不这样做。您必须求助于图书馆或语言附加组件。

为了说明原因:

  • 做地址计算
  • (在OP的情况下,加载块基地址(已经在缓存中),然后执行更多地址计算)
  • 读取/写入元素地址

最后一步仍需花费大量时间。

个人建议

  • 您可以提供一个CopyRange功能,其功能类似于Array.Copy功能,但可以在您的两个实例NonContiguousByteArray之间,或者在一个实例与另一个Normal之间运行byte[]。这些函数可以利用SIMD代码(C ++或C#)来最大程度地利用内存带宽,然后您的C#代码就可以在复制的范围内运行,而无需进行多次取消引用或地址计算的开销。

可用性和互操作性问题

  • 显然,你不能用这个NonContiguousByteArray与任何C#,C ++或外国语言库预计连续字节数组,或可固定字节数组。
  • 但是,如果您编写自己的C ++加速库(使用P / Invoke或C ++ / CLI),则可以将几个4MB块的基地址列表传递到基础代码中。
    • 例如,如果您需要授予从开始到(3 * 1024 * 1024)结束于的元素(5 * 1024 * 1024 - 1)的访问权限,这意味着访问权限将跨越chunk[0]chunk[1]。然后,您可以构造一个字节数组(大小为4M)的数组(大小为2),固定这些块地址并将其传递给基础代码。
  • 另一个可用性问题是您将无法IList<byte>高效地实现该接口:Insert并且Remove处理时间太长,因为它们需要O(N)时间。
    • 实际上,您似乎无法实现以外的任何其他功能IEnumerable<byte>,即可以按顺序进行扫描,仅此而已。

2
您似乎错过了数据结构的主要优点,那就是它允许您创建非常大的列表而不会耗尽内存。扩展List <T>时,它需要一个新数组,其大小是旧数组的两倍,并且两个数组必须同时存在于内存中。
Frank Hileman 2015年

6

值得注意的是,C ++已具有Standard的等效结构std::deque。当前,建议将其作为需要随机访问的东西序列的默认选择。

现实情况是,一旦数据超过一定大小,连续内存几乎完全没有必要-高速缓存行仅为64字节,页面大小仅为4-8KB(当前为典型值)。一旦您开始谈论几个MB,它就会成为您真正关心的问题。分配成本也是如此。无论如何,处理所有这些数据(甚至只是读取数据)的价格都使分配的价格相形见war。

担心它的唯一其他原因是与C API的接口。但是无论如何您都无法获得指向List缓冲区的指针,因此这里无需担心。


有趣的是,我不知道deque有类似的实现方式
noisecapella 2015年

谁目前在推荐std :: deque?您可以提供来源吗?我一直以为std :: vector是推荐的默认选择。
Teimpz

std::deque实际上,我们强烈建议不要这样做,部分原因是MS标准库的实现是如此糟糕。
塞巴斯蒂安·雷德尔

3

当在不同的时间点分配内存块时,例如在数据结构中的子数组中,它们可以在内存中彼此远离。这是否是一个问题,取决于CPU,很难再预测了。您必须进行测试。

这是一个绝妙的主意,这是我过去使用过的一个主意。当然,对于子阵列大小,您应该仅使用2的幂,而对于除法运算则应使用移位(可能是优化的一部分)。我发现这种类型的结构要慢一些,因为编译器可以更轻松地优化单个数组间接寻址。您必须进行测试,因为这些类型的优化一直在变化。

主要优点是,只要您始终使用这些类型的结构,就可以在接近系统内存上限的情况下运行。只要您使数据结构更大而不产生垃圾,就可以避免普通List会发生额外的垃圾收集。对于庞大的列表,它可能会产生巨大的变化:继续运行与内存不足之间的区别。

仅当子阵列块很小时,额外的分配才是问题,因为每个阵列分配中都有内存开销。

我为字典(哈希表)创建了类似的结构。.net框架提供的Dictionary与List具有相同的问题。字典比较难,因为您还需要避免重新哈希。


压缩收集器可以压缩彼此相邻的块。
DeadMG

@DeadMG我指的是这种情况不会发生的情况:它们之间还有其他大块,不是垃圾。使用List <T>,可以确保数组的连续内存。对于分块列表,除非您提到了幸运的压缩情况,否则内存仅在一个块内是连续的。但是压缩还可能需要移动大量数据,并且大型阵列会进入大型对象堆。这很复杂。
Frank Hileman

2

块大小为4M时,即使在物理内存中也不保证单个块是连续的。它大于典型的VM页面大小。在那种规模上,本地性没有意义。

您将不得不担心堆碎片:如果分配发生时,导致块在堆中基本上是不连续的,那么当它们被GC回收时,您将最终得到一个堆,该堆可能碎片太多而无法容纳一个堆。后续分配。通常情况更糟,因为故障将发生在无关的地方,并可能迫使应用程序重新启动。


压缩GC是无碎片的。
DeadMG

的确如此,但是如果我没记错的话,LOH压缩仅在.NET 4.5和更高版本中可用。
user2313838

堆压缩还可能比标准的按重新分配复制行为产生更多的开销List
user2313838

无论如何,足够大且适当大小的对象实际上是无碎片的。
DeadMG

2
@DeadMG:与GC压缩(使用此4MB方案)有关的真正问题是,它可能花了很多时间浪费在这些4MB的牛肉饼周围。因此,它可能导致较大的GC暂停。因此,在使用这种4MB方案时,重要的是监视重要的GC统计信息以查看其工作状况并采取纠正措施。
rwong 2015年

1

尽管您使用的是较小的连续块(​​更像是4千字节而不是4兆字节),但我围绕您描述的数据结构的类型介绍了代码库(ECS引擎)中最核心的部分。

在此处输入图片说明

它使用双重空闲列表来实现恒定时间的插入和删除,其中一个空闲列表用于准备插入的空闲块(不完整的块),而块内的子空闲列表用于该块中的索引。准备插入时回收。

我将介绍这种结构的优缺点。让我们从一些缺点开始,因为它们有很多:

缺点

  1. 向此结构中插入数亿个元素所需的时间比std::vector(一个连续的结构)要长约4倍。我在微优化方面相当不错,但是从概念上讲,还有更多工作要做,因为通常情况下,首先要检查块空闲列表顶部的空闲块,然后访问该块并从该块的弹出弹出索引空闲列表,将元素写入空闲位置,然后检查块是否已满,如果是,则从块空闲列表中弹出该块。它仍然是一个固定时间的操作,但是常数要比推回更大std::vector
  2. 给定额外的索引编制算法和额外的间接层,使用随机访问模式访问元素所需的时间大约是原来的两倍。
  3. 顺序访问不能有效地映射到迭代器设计,因为迭代器每次递增时都必须执行其他分支。
  4. 它有一点内存开销,通常每个元素大约1位。每个元素1位听起来可能并不多,但是如果使用它存储一百万个16位整数,则比完全紧凑的数组多使用6.25%的内存。但是,在实践中,这往往比使用较少的内存要少得多,std::vector除非您压缩vector来消除它保留的多余容量。另外,我通常不使用它来存储此类小元素。

优点

  1. 使用for_each函数的顺序访问需要对块中的元素进行回调处理,该函数几乎可以与顺序访问的速度相媲美std::vector(仅相差10%),因此在对性能要求最为严格的用例中,它的效率不会降低很多(在ECS引擎中花费的大部分时间是在顺序访问中)。
  2. 当块完全变空时,它可以通过结构分配块从中间进行恒定时间的删除。因此,通常可以确保数据结构不会使用过多的内存。
  3. 它不会使未直接从容器中删除的元素的索引无效,因为它只是使用自由列表方法在随后的插入操作中留下了空位,从而留下了空位。
  4. 即使这种结构包含大量的元素,您也不必担心内存不足,因为它只需要小的连续块,这对于操作系统查找大量连续未使用的块不会构成挑战。页面。
  5. 由于操作通常局限于单个块,因此它很适合并发性和线程安全性,而无需锁定整个结构。

现在,对我而言,最大的优点之一就是制作这种数据结构的不可变版本变得微不足道,如下所示:

在此处输入图片说明

从那时起,这就打开了各种各样的门,可以编写更多没有副作用的函数,这使得实现异常安全,线程安全等变得更加容易。不变性是我发现可以轻松实现的一种东西事后看来,这种数据结构是偶然的,但可以说它最终具有的最大好处之一是,它使维护代码库变得更加容易。

不连续的数组没有缓存局部性,这会导致性能下降。但是,在4M的块大小下,似乎将有足够的位置进行良好的缓存。

在这种大小的块上,您不必担心参考位置,更不用说4 KB的块了。缓存行通常只有64个字节。如果要减少高速缓存未命中,则只需集中精力正确对齐这些块,并在可能的情况下采用更多顺序访问模式即可。

将随机存取存储器模式转换为顺序模式的一种非常快速的方法是使用位集。假设您有大量的索引,并且它们的顺序是随机的。您可以浏览它们并在位集中标记位。然后,您可以遍历位集并检查哪些字节为非零字节,一次检查64位。一旦遇到一组至少设置了一位的64位,就可以使用FFS指令快速确定设置了哪些位。这些位告诉您应该访问哪些索引,除了现在您可以按顺序对索引进行排序。

这有一些开销,但在某些情况下可能是值得的交换,尤其是如果您要遍历这些索引很多次时。

访问一个项目不是那么简单,存在一个间接的附加级别。这会优化吗?会导致缓存问题吗?

不,无法对其进行优化。至少,使用这种结构,随机访问将始终花费更多。尽管由于倾向于使用指向块的指针的数组来获得较高的时间局部性,所以它通常不会增加高速缓存的丢失率,尤其是在常见情况下执行路径使用顺序访问模式的情况下。

由于达到4M限制后线性增长,因此您可以分配比平时更多的分配(例如,对于1GB内存,最多可以分配250个分配)。4M之后不会复制任何额外的内存,但是我不确定额外的分配是否比复制大块内存更昂贵。

在实践中,由于这种情况很少见,因此复制通常会更快,只会发生诸如log(N)/log(2)总时间之类的事情,而同时简化了这种非常便宜的常见情况,在这种情况下,您只需在数组变满之前需要多次写入元素,然后需要重新分配它。因此,通常情况下,使用这种类型的结构不会获得更快的插入速度,因为即使不需要处理重新分配大型数组的昂贵情况,普通情况下的工作也会更加昂贵。

尽管有很多弊端,但这种结构对我的主要吸引力在于减少了内存使用,不必担心OOM,能够存储不会失效的索引和指针,并发性和不变性。拥有一个数据结构很高兴,您可以在其中不断地插入和删除内容,同时它可以为您自己清理,并且不会使结构中的指针和索引无效。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.