【问题标题】:What is the ideal growth rate for a dynamically allocated array?动态分配数组的理想增长率是多少?
【发布时间】:2023-02-10 15:18:59
【问题描述】:

C++ 有 std::vector,Java 有 ArrayList,许多其他语言都有自己的动态分配数组形式。当动态数组空间不足时,它会重新分配到更大的区域,并将旧值复制到新数组中。这种阵列性能的一个核心问题是阵列大小增长的速度有多快。如果你总是只增长到足以适应当前的推动,你最终每次都会重新分配。因此,将数组大小加倍或乘以 1.5 倍是有意义的。

有理想的生长因子吗? 2倍? 1.5倍?我所说的理想是指数学上合理的、最佳平衡性能和浪费的内存。我意识到,理论上,鉴于您的应用程序可能具有任何潜在的推送分布,这在某种程度上取决于应用程序。但我很想知道是否存在“通常”最好的值,或者在某些严格约束下被认为是最好的值。

我听说某处有一篇关于此的论文,但我一直找不到。

【问题讨论】:

  • hyperskill.org 把我带到了这里!

标签: arrays math vector arraylist dynamic-arrays


【解决方案1】:

我记得很多年前读过为什么 1.5 优于 2,至少适用于 C++(这可能不适用于托管语言,其中运行时系统可以随意重新定位对象)。

道理是这样的:

  1. 假设您从 16 字节分配开始。
  2. 当您需要更多时,分配 32 个字节,然后释放 16 个字节。这会在内存中留下一个 16 字节的空洞。
  3. 当您需要更多时,您分配 64 个字节,释放 32 个字节。这会留下一个 48 字节的空洞(如果 16 和 32 相邻)。
  4. 当您需要更多时,您分配 128 个字节,释放 64 个字节。这留下了一个 112 字节的空洞(假设所有先前的分配都是相邻的)。
  5. 等等等等。

    这个想法是,通过 2 倍的扩展,没有时间点可以使产生的空洞大到足以重新用于下一次分配。使用 1.5x 分配,我们有这个:

    1. 以 16 个字节开始。
    2. 当您需要更多时,分配 24 个字节,然后释放 16 个,留下 16 个字节的空洞。
    3. 当您需要更多时,分配 36 个字节,然后释放 24 个,留下 40 个字节的空洞。
    4. 当您需要更多时,分配 54 个字节,然后释放 36 个,留下 76 个字节的空洞。
    5. 当您需要更多时,分配 81 个字节,然后释放 54 个,留下 130 个字节的空洞。
    6. 当您需要更多时,使用 130 字节洞中的 122 字节(向上舍入)。

【讨论】:

  • 我发现的随机论坛帖子 (objectmix.com/c/…) 也有类似的原因。海报声称 (1+sqrt(5))/2 是重用的上限。
  • 如果这个说法是正确的,那么 phi (== (1 + sqrt(5)) / 2) 确实是最适合使用的数字。
  • 我喜欢这个答案,因为它揭示了 1.5 倍与 2 倍的基本原理,但乔恩的答案在技术上最符合我的陈述方式。我应该问为什么过去推荐 1.5 :p
  • Facebook 在其 FBVector 实现中使用 1.5,article here 解释了为什么 1.5 对于 FBVector 是最佳的。
  • @jackmott 对,正如我的回答所指出的:“这可能不适用于运行时系统可以随意重新定位对象的托管语言”。
【解决方案2】:

在极限如n→ ∞, it would be the golden ratio: ϕ = 1.618...

对于有限n,你想要接近的东西,比如 1.5。

原因是您希望能够重用较旧的内存块,以利用缓存并避免不断让操作系统为您提供更多内存页面。您要解决的方程式以确保后续分配可以重复使用全部先前的块减少到Xn− 1- 1 =Xn+ 1Xn,其解决方案接近X= φ 大n.在实践中n是有限的,您将希望能够在每几次分配后重用最后几个块,因此 1.5 非常适合确保这一点。
(有关更详细的说明,请参阅链接。)

【讨论】:

  • (不确定为什么你删除了我们所有的 cmet,但我想为遇到此问题的任何人添加一些中立的说明。)为了澄清,n在这个答案中不是数组的大小,它是在您能够重用内存之前重新分配的最小数量。所以n→ ∞ 并不意味着“随着数组增长到无穷大”,这意味着您对浪费内存的容忍度越高,您希望增长因子越接近黄金比例。请注意,此计算仅对小的实际意义n和增长率进一步远离 ϕ,因为
  • 大而有限n,随着增长率接近 ϕ,这意味着您只能在多次重新分配后才能重用较旧的内存块;如果您的用例对浪费的内存如此不敏感,则 2 倍的增长率会比接近 ϕ 的增长率表现更好。
【解决方案3】:

这将完全取决于用例。您更关心复制数据(和重新分配数组)所浪费的时间还是额外的内存?阵列将持续多长时间?如果它不会存在很长时间,那么使用更大的缓冲区可能是个好主意——惩罚是短暂的。如果它要徘徊(例如在 Java 中,进入老一代和老一代),那显然是一种惩罚。

没有所谓的“理想生长因子”。这不仅仅是理论上取决于应用程序,它是确实应用程序依赖。

2 是一个非常常见的增长因子 - 我很确定这就是 .NET 中的 ArrayListList<T> 使用的。 Java 中的ArrayList<T> 使用 1.5。

编辑:正如 Erich 指出的那样,.NET 中的 Dictionary<,> 使用“将大小加倍然后增加到下一个质数”,以便哈希值可以在桶之间合理分配。 (我确定我最近看到的文档表明素数实际上并不适合分发哈希桶,但这是另一个答案的论据。)

【讨论】:

    【解决方案4】:

    回答此类问题的一种方法是“作弊”,看看流行的库做了什么,假设一个广泛使用的库至少不会做一些可怕的事情。

    因此,只需快速检查一下,Ruby (1.9.1-p129) 似乎在附加到数组时使用 1.5x,而 Python (2.6.2) 使用 1.125x 加上一个常量(在 Objects/listobject.c 中):

    /* This over-allocates proportional to the list size, making room
     * for additional growth.  The over-allocation is mild, but is
     * enough to give linear-time amortized behavior over a long
     * sequence of appends() in the presence of a poorly-performing
     * system realloc().
     * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
     */
    new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);
    
    /* check for integer overflow */
    if (new_allocated > PY_SIZE_MAX - newsize) {
        PyErr_NoMemory();
        return -1;
    } else {
        new_allocated += newsize;
    }
    

    上面的newsize是数组的元素个数。请注意,newsize 添加到 new_allocated,因此带有位移和三元运算符的表达式实际上只是在计算过度分配。

    【讨论】:

    • 因此它将数组从 n 增长到 n + (n/8 + (n<9?3:6)),这意味着增长因子,在问题的术语中,是 1.25x(加上一个常数)。
    • 不会是 1.125x 加上一个常数吗?
    【解决方案5】:

    假设您将数组大小增加了x。因此,假设您从大小 T 开始。下次增长数组时,它的大小将为 T*x。然后它将是T*x^2等等。

    如果您的目标是能够重用之前创建的内存,那么您要确保您分配的新内存小于您释放的先前内存的总和。因此,我们有这个不等式:

    T*x^n <= T + T*x + T*x^2 + ... + T*x^(n-2)
    

    我们可以从两侧删除 T。所以我们得到这个:

    x^n <= 1 + x + x^2 + ... + x^(n-2)
    

    通俗地说,我们所说的是在nth 分配时,我们希望所有先前释放的内存大于或等于第 n 次分配时的内存需求,以便我们可以重用先前释放的内存。

    例如,如果我们希望能够在第 3 步执行此操作(即n=3),那么我们有

    x^3 <= 1 + x 
    

    这个等式对所有满足0 &lt; x &lt;= 1.3(大致)的 x 都是正确的

    看看下面不同的 n 得到的 x 是什么:

    n  maximum-x (roughly)
    
    3  1.3
    
    4  1.4
    
    5  1.53
    
    6  1.57
    
    7  1.59
    
    22 1.61
    

    请注意,增长因子必须小于2,因为x^n &gt; x^(n-2) + ... + x^2 + x + 1 for all x&gt;=2

    【讨论】:

    • 您似乎声称您已经可以在第二次分配时以 1.5 的系数重用先前释放的内存。这是不正确的(见上文)。如果我误解了你,请告诉我。
    • 在第二次分配中,您分配的是 1.5*1.5*T = 2.25*T,而在此之前您将进行的总解除分配是 T + 1.5*T = 2.5*T。所以 2.5 大于 2.25。
    • 啊,我应该仔细看书;你所说的是总释放内存将超过第 n 次分配时分配的内存,不是您可以在第 n 次分配时重用它。
    【解决方案6】:

    另外两美分

    • 大多数计算机都有虚拟内存!在物理内存中,您可以在任何地方都有随机页面,这些页面在程序的虚拟内存中显示为单个连续空间。间接解析由硬件完成。虚拟内存耗尽在 32 位系统上是个问题,但现在真的不是问题了。所以填充不再是问题(特殊环境除外)。自 Windows 7 以来,Microsoft 也毫不费力地支持 64 位。 @ 2011
    • O(1) 达到任何r> 1 个因素。同样的数学证明不仅适用于 2 作为参数。
    • r= 1.5 可以用old*3/2计算,所以不需要浮点运算。 (我说 /2 是因为编译器会在认为合适的情况下用生成的汇编代码中的位移位替换它。)
    • MSVC 去了r= 1.5,所以至少有一个主要编译器不使用 2 作为比率。

    正如某人所说,2 感觉比 8 好。而且 2 感觉也比 1.1 好。

    我的感觉是 1.5 是一个很好的默认值。除此之外,它取决于具体情况。

    【讨论】:

    • 最好使用n + n/2来延迟溢出。使用n*3/2 会将您可能的容量减少一半。
    • @owacoder 是的。但是当 n*3 不适合但 n*1.5 适合时,我们正在谈论大量内存。如果 n 是 32 位 unsigend 那么当 n 是 4G/3 时 n*3 溢出,也就是 1.333G 大约。这是一个巨大的数字。在一次分配中有很多内存。如果元素不是 1 个字节而是例如每个 4 个字节,则更多。想知道用例...
    • 的确,这可能是一种边缘情况,但边缘情况通常会产生影响。养成寻找可能的溢出或其他可能暗示更好设计的行为的习惯从来都不是一个坏主意,即使这在目前看来可能有些牵强。以 32 位地址为例。现在我们需要 64...
    【解决方案7】:

    这真的取决于。有些人分析常见的用例以找到最佳数量。

    我见过 1.5x 2.0x phi x,以前用过 2 的幂。

    【讨论】:

    • 披!这是一个很好的数字。我应该从现在开始使用它。谢谢! +1
    • 我不明白......为什么phi?它有什么特性使其适合于此?
    • @Jason:phi 构成斐波那契数列,因此下一个分配大小是当前大小和先前大小的总和。这允许适度的增长率,比 1.5 快但不是 2(请参阅我的帖子,了解为什么 >= 2 不是一个好主意,至少对于非托管语言而言)。
    • @Jason:另外,根据我帖子的一位评论者的说法,任何大于 phi 的数字实际上都是一个坏主意。我自己还没有做过数学来证实这一点,所以请持保留态度。
    • @ChrisJester-Young 需要明确的是,如果您的目标是重用内存,任何接近 phi (≈ 1.618) 的增长率都是不好的。任何 ≥ phi 的增长率,包括 2x,将永远无法重用内存,而仅略低于 phi 的增长率将浪费大量内存才能重用任何内存。为了更快地重用内存和减少浪费,你想要比 phi 少得多,但这必须与更频繁的重新分配和复制相平衡:stackoverflow.com/a/67648650/362030
    【解决方案8】:

    如果你有一个数组长度的分布,并且你有一个效用函数来说明你喜欢浪费空间还是浪费时间,那么你绝对可以选择一个最佳的调整大小(和初始大小)策略。

    使用简单常量倍数的原因显然是每个追加都有摊销的常量时间。但这并不意味着您不能对小尺寸使用不同(更大)的比例。

    在 Scala 中,您可以使用查看当前大小的函数覆盖标准库哈希表的 loadFactor。奇怪的是,可调整大小的数组只是加倍,这是大多数人在实践中所做的。

    我不知道有任何加倍(或 1.5*ing)数组实际上会捕获内存不足错误并在这种情况下增长得更少。似乎如果你有一个巨大的单一阵列,你就会想要这样做。

    我还要补充一点,如果您将可调整大小的数组保持足够长的时间,并且随着时间的推移您更喜欢空间,那么最初大幅过度分配(对于大多数情况)然后在您需要时重新分配到正确的大小可能是有意义的完毕。

    【讨论】:

      【解决方案9】:

      最高投票和接受的答案都很好,但都没有回答问题中要求“数学上合理的”“理想增长率”、“最佳平衡性能和浪费的内存”的部分。 (第二高投票的答案确实试图回答问题的这一部分,但其推理很混乱。)

      这个问题完美地确定了必须平衡的 2 个注意事项,性能和浪费的内存。如果您选择的增长率太低,性能会受到影响,因为您会很快用完额外空间并且不得不过于频繁地重新分配。如果你选择的增长率太高,比如 2x,你就会浪费内存,因为你永远无法重用旧的内存块。

      特别是,如果你do the math1个你会发现增长率的上限是黄金比例φ= 1.618…。增长率大于φ(比如 2x)意味着你永远无法重用旧的内存块。增长率仅略低于φ意味着在多次重新分配之前您将无法重用旧内存块,在此期间您将浪费内存。所以你想尽可能地低于φ在不牺牲太多性能的情况下可以获得。

      因此,我建议这些候选者用于“数学上合理”、“理想增长率”、“最佳平衡性能和浪费的内存”:

      • ≈1.466x(解X4个=1+X+X2个) 允许在仅 3 次重新分配后重用内存,一次比 1.5x 允许的更快,而重新分配的频率仅稍高
      • ≈1.534x(解X5个=1+X+X2个+X3个) 允许在 4 次重新分配后重用内存,与 1.5x 相同,同时重新分配的频率略低以提高性能
      • ≈1.570x(解X6个=1+X+X2个+X3个+X4个) 只允许在 5 次重新分配后重用内存,但为了进一步提高性能,重新分配的频率会更低(勉强)

      显然那里有一些收益递减,所以我认为全球最优可能就是其中之一。另外,请注意 1.5x 是全局最优值的一个很好的近似值,并且具有非常简单的优点。

      1个感谢@user541686 提供此优秀资源。

      【讨论】:

        【解决方案10】:

        我最近对我在事物的浪费内存方面获得的实验数据着迷。下图显示了“开销因子”,计算为开销空间量除以有用空间,x 轴显示增长因子。我还没有找到一个很好的解释/模型来解释它所揭示的内容。

        仿真SN-P:https://gist.github.com/gubenkoved/7cd3f0cb36da56c219ff049e4518a4bd

        模拟显示的形状和绝对值都不是我所期望的。

        更高分辨率的图表显示了对最大有用数据大小的依赖性:https://i.stack.imgur.com/Ld2yJ.png

        更新。经过深思熟虑,我终于想出了正确的模型来解释模拟数据,希望它能很好地匹配实验数据。只需查看我们需要包含的给定数量的元素所需的数组大小,就可以很容易地推断出该公式。

        前面提到的 GitHub gist 已更新为包括使用 scipy.integrate 进行数值积分的计算,这允许创建下面的图,它可以很好地验证实验数据。

        更新 2。但是应该记住,我们在那里建模/模拟的内容主要与虚拟内存有关,这意味着过度分配的开销可以完全留在虚拟内存区域,因为物理内存占用仅在我们第一次访问页面时发生的虚拟内存,因此可以malloc一大块内存,但在我们第一次访问页面之前,我们所做的只是保留虚拟地址空间。我已经用 CPP 程序更新了 GitHub gist,它有一个非常基本的动态数组实现,允许更改增长因子和多次运行它以收集“真实”数据的 Python sn-p。请看下面的最终图表。

        结论可能是,对于虚拟地址空间不是限制因素的 x64 环境,不同增长因素之间的物理内存占用量可能几乎没有差异。此外,就虚拟内存而言,上面的模型似乎做出了很好的预测!

        Simulation sn-p 是在 Windows 10(build 19043)上用 g++.exe simulator.cpp -o simulator.exe 构建的,g++ 版本如下。

        g++.exe (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0
        

        附言。请注意,最终结果是特定于实现的。根据实现细节,动态数组可能会或可能不会访问“有用”边界之外的内存。一些实现会使用 memset 对整个容量的 POD 元素进行零初始化——这将导致虚拟内存页面转换为物理内存页面。但是,上述编译器上的 std::vector 实现似乎并没有这样做,因此表现得像 sn-p 中的模拟动态数组一样——这意味着虚拟内存端会产生开销,而物理内存端的开销可以忽略不计。

        【讨论】:

        • 你能详细说明你是如何推导出这个公式的吗?它的输入和输出是否直接对应于 x 和 y 轴?
        • 公式推导如下——中心部分是 alpha^ceil(log(n, alpha))——这是包含具有给定增长率 (alpha) 的 n 个项目所需的动态数组容量。然后得到开销因子 (beta) 作为开销与有用大小 (n) 的比率是微不足道的,所以它给了我们 alpha^ceil(log(n, alpha)) - n / n。最后一步是找到一个平均情况(数学期望),我们在范围 [min, max] 除以宽度这样的区间上对 n 进行积分。输入/输出(即 alpha/beta 或增长率/开销因子)确实对应于 x 和 y 轴。
        【解决方案11】:

        我同意 Jon Skeet 的观点,甚至我的 theorycrafter 朋友也坚持认为,当将因子设置为 2x 时,这可以被证明是 O(1)。

        cpu 时间和内存之间的比率在每台机器上都不同,因此该因素也会有很大差异。如果你有一台 RAM 为 GB 的机器和一个慢速 CPU,将元素复制到一个新数组比在一台速度快的机器上要昂贵得多,而速度快的机器反过来可能有更少的内存。这是一个理论上可以回答的问题,对于一台统一的计算机,在实际场景中对你根本没有帮助。

        【讨论】:

        • 详细来说,将数组大小加倍意味着你得到摊销O(1) 插入。这个想法是,每次插入一个元素时,您也会从旧数组中复制一个元素。假设你有一个大小数组, 和其中的元素。添加元素时m+1,没有空间,所以你分配一个新的数组大小2米.而不是复制所有的第一个元素,每次插入一个新元素时都要复制一个。这样可以最大限度地减少方差(除了内存分配),并且一旦插入 2m 个元素,就会从旧数组中复制所有元素。
        • @hvidgaard 究竟如何与随机访问一起工作......?我不知道如何在没有分支的情况下做到这一点,似乎复制总体上会更快,这是假设您根本需要复制。
        【解决方案12】:

        我知道这是一个老问题,但有几件事似乎每个人都遗漏了。

        首先,这是乘以 2:大小 << 1。这是乘以任何事物在 1 和 2 之间:int(float(size) * x),其中 x 是数字,* 是浮点数学,处理器必须运行额外的指令以在 float 和 int 之间进行转换。换句话说,在机器级别,加倍需要一条非常快的指令来找到新的大小。乘以 1 到 2 之间的数需要至少一条将 size 转换为浮点数的指令,一条乘法指令(这是浮点数乘法,因此它可能至少需要两倍的周期,如果不是 4 倍甚至 8 倍的话),一条指令转换回 int,并且假设您的平台可以在通用寄存器上执行浮点运算,而不需要使用特殊寄存器。简而言之,您应该期望每次分配的数学运算时间至少是简单左移的 10 倍。但是,如果您在重新分配期间复制了大量数据,这可能不会产生太大影响。

        其次,可能是最大的问题:每个人似乎都认为正在释放的内存与其自身是连续的,并且与新分配的内存也是连续的。除非您自己预先分配所有内存,然后将其用作池,否则几乎可以肯定不是这种情况。操作系统可能偶尔最终这样做,但大多数时候,将会有足够的可用空间碎片,任何半像样的内存管理系统都能够找到一个小洞来适合你的内存。一旦你开始真正地分块,你更有可能以连续的部分结束,但到那时,你的分配已经足够大,以至于你没有足够频繁地进行它们,以至于它不再重要了。简而言之,想象使用一些理想的数字将允许最有效地使用可用内存空间是很有趣的,但实际上,除非您的程序在裸机上运行(例如,没有操作系统),否则它不会发生在它下面做出所有决定)。

        我对问题的回答?不,没有理想的数字。它是如此特定于应用程序,以至于没有人真正尝试过。如果您的目标是理想的内存使用率,那您就很不走运了。对于性能而言,分配频率越低越好,但如果我们只这样做,我们可以乘以 4 甚至 8!当然,当 Firefox 一口气从使用 1GB 跃升至 8GB 时,人们会抱怨,所以这甚至没有意义。以下是我会遵循的一些经验法则:

        如果您不能优化内存使用,至少不要浪费处理器周期。乘以 2 至少比进行浮点运算快一个数量级。它可能不会产生很大的不同,但至少会产生一些不同(尤其是在早期,在更频繁和更小的分配期间)。

        不要想太多。如果你只是花了 4 个小时试图弄清楚如何做一些已经完成的事情,那你就是在浪费时间。老实说,如果有比 *2 更好的选择,几十年前它就会在 C++ 向量类(以及许多其他地方)中完成。

        最后,如果你真的想要优化,不要为小事操心。如今,没有人关心 4KB 的内存被浪费了,除非他们在嵌入式系统上工作。当您达到 1GB 的对象,每个对象在 1MB 到 10MB 之间时,加倍可能太多了(我的意思是,在 100 到 1,000 个对象之间)。如果你可以估计预期的扩张率,你可以在某一点将其拉平到线性增长率。如果您期望每分钟大约 10 个对象,那么每步增加 5 到 10 个对象大小(每 30 秒到一分钟一次)可能就可以了。

        归根结底,不要想太多,优化你能做的,如果必须的话,根据你的应用程序(和平台)进行定制。

        【讨论】:

        • 当然n + n &gt;&gt; 11.5 * n是一样的。对于您能想到的每个实际增长因素,想出类似的技巧是相当容易的。
        • 这是个好的观点。但是请注意,在 ARM 之外,这至少使指令数量翻倍。 (许多 ARM 指令,包括 add 指令,可以对其中一个参数进行可选的移位,从而允许您的示例在单个指令中工作。但是大多数体系结构不能这样做。)不,在大多数情况下,将数字加倍从一个指令到两个指令的数量不是一个重要问题,但是对于数学更复杂的更复杂的增长因素,它可能会对敏感程序产生性能差异。
        • @Rybec - 虽然可能有一些程序对一两个指令的时间变化很敏感,但任何使用动态重新分配的程序都不太可能会担心这一点。如果它需要精细地控制时间,它可能会改用静态分配的存储。
        • 我做游戏,其中一两个指令可以在错误的地方产生显着的性能差异。也就是说,如果内存分配处理得当,它不应该频繁发生,几条指令就不会产生影响。
        • 我认为在动态数组的上下文中谈论整数算术与浮点数的性能在很大程度上是无关紧要的,因为与需要进行的其他过程相比,每次重新分配的这种单一计算完全可以忽略不计。
        猜你喜欢
        • 1970-01-01
        • 2021-03-27
        • 2013-01-28
        • 2013-08-13
        • 2019-11-07
        • 2011-02-24
        • 2011-07-11
        • 2014-01-01
        • 1970-01-01
        相关资源
        最近更新 更多