静态数组与动态数组在C/C++性能方面的比较

25
当应用程序的性能至关重要时,是否应该考虑将数组声明在堆栈上还是堆上?让我概述一下为什么会有这个问题。
由于C/C ++中的数组不是对象并且会退化为指针,编译器使用提供的索引执行指针算术来访问元素。据我了解,当超过第一个维度时,这个过程与静态声明的数组与动态声明的数组之间存在差异
如果我要在堆栈上声明一个数组,如下所示;
  int array[2][3] = { 0, 1, 2, 3, 4, 5 }
  //In memory        { row1 } { row2 }

这个数组会以行主序格式存储在内存中,因为它被存储在一个连续的内存块中。这意味着当我尝试访问数组中的元素时,编译器必须执行一些加法和乘法运算来确定正确的位置。

因此,如果我要执行以下操作:

  int x = array[1][2]; // x = 5

编译器将使用以下公式:

i = 行索引 j = 列索引 n = 单行大小(此处 n = 2)
array = 指向第一个元素的指针

  *(array + (i*n) + j)
  *(array + (1*2) + 2)  

这意味着如果我循环访问该数组中的每个元素,每次按索引访问都会执行额外的乘法步骤。
现在,在堆上声明的数组范式不同,需要多个阶段的解决方案。注意:我也可以在这里使用C++的new运算符,但我认为数据的表示方式没有区别。
  int ** array;
  int rowSize = 2;
  // Create a 2 by 3 2d array on the heap
  array = malloc(2 * sizeof(int*));
  for (int i = 0; i < 2; i++) {
      array[i] = malloc(3 * sizeof(int));
  }

  // Populating the array
  int number = 0;
  for (int i = 0; i < 2; i++) {
      for (int j = 0l j < 3; j++) {
          array[i][j] = number++;
      }
  }

由于数组现在是动态的,其表示形式是一个一维数组的一维数组。我将尝试绘制一个ASCII图片...

              int *        int int int
int ** array-> [0]          0   1   2
               [1]          3   4   5

这意味着不再涉及乘法,对吗?如果我按照以下方式操作:
int x = array[1][1];

这将对array[1]执行间接/指针算术运算,以访问指向第二行的指针,然后再次执行此操作以访问第二个元素。我这样说是正确的吗?
现在有了一些背景,回到问题。如果我正在为需要快速性能的应用程序编写代码,比如游戏每帧大约需要0.016秒来渲染,那么我是否应该三思而后行,考虑使用堆栈上的数组还是堆上的数组?现在我意识到使用malloc或new运算符会有一次性的成本,但在某个点上(就像Big O分析一样),当数据集变得很大时,通过动态数组迭代避免行主要索引是否更好呢?

3
你可以使用继续内存分配2D进行比较。 - Grijesh Chauhan
无论你做什么,你仍然在使用行优先(而不是列优先)。尝试测量它。 - doctorlove
堆栈与堆的区别在于分配大小及其已知程度,而不是数据布局。正如 Grijesh Chauhan 所说,您可以在堆上使用与多维静态数组相同的布局,您只需不使用语法糖即可。您还可以拥有指向数组的指针的静态数组(有时候很有意义,尽管所指向的数组通常是动态分配的)。 - user395760
另外,你对性能的担忧似乎是误导性的:指针数组可能会节省一个整数乘法,但这只是 CPU 可以执行的较便宜的操作之一,并且作为回报,您需要进行额外的内存引用——即使它命中 L1 缓存,这也几乎不比乘法更便宜。无论哪种方法更快,都有可能不是瓶颈之一。如果是,您的分析器会告诉您。仔细考虑低级优化可能是有用的,但这不是您应主动关注的东西,即使在性能关键代码中也是如此。 - user395760
2
如果你只是在数组上进行迭代,一种标准的优化技术叫做“强度降低”,可以将乘法转换为加法。这个技术已经存在了大约40年左右。虽然现在乘法不再是一个主要问题;缓存未命中更糟糕。 - Alan Stokes
1
另一个有趣的问题是,在堆栈上实现静态数组与在数据段中实现静态数组的性能比较。 - Z boson
5个回答

32

这些只适用于“纯粹”的C语言(不包括C ++)。

首先,我们要澄清一些术语

“static”是C语言中的一个关键字,如果应用于函数内声明的变量,它将大大改变变量的分配/访问方式。

就C语言而言,有三个位置可以存放变量(包括数组):

  • 堆栈:没有static的函数本地变量。
  • 数据段:程序启动时为其分配空间。这些是任何全局变量(无论是否static,该关键字与可见性相关),以及任何声明为static的函数本地变量。
  • 堆:由指针引用的动态分配的内存(malloc()free())。您仅通过指针访问此数据。

现在让我们看看如何访问一维数组

如果您使用常量索引(可以是#define,但在纯C中不能是const),则编译器可以计算出此索引。 如果在数据段中有真正的数组,则可以无需间接访问即可访问它。 如果有指针()或堆栈上的数组,则始终需要间接访问。因此,具有此类型访问方式的数据段中的数组可能会快一点。 但这不是一个可以改变世界的非常有用的事情。

如果您使用索引变量访问数组,则由于索引可能会更改(例如在for循环中增加),它基本上总是会衰减为指针。 生成的代码对所有类型来说可能非常相似甚至相同。

增加更多维度

如果您声明了一个二维或多维数组,并且通过常量部分或全部访问它,则聪明的编译器可能会将这些常量优化出来,如上所述。

如果通过索引访问,需要注意内存是线性的。如果真实数组的后维度不是2的倍数,则编译器将需要生成乘法。例如,在数组 int arr [4] [12];中,第二维是12。如果您现在像arr[i][j]这样访问它,其中ij是索引变量,则必须将线性内存作为12 * i + j进行索引。因此,编译器必须在此处生成与常量相乘的代码。复杂度取决于常量距离2的幂的距离有多远。在这里,生成的代码可能看起来有点像计算(i<<3) + (i<<2) + j以访问数组中的元素。
如果您使用指针组建立二维“数组”,则维度的大小无关紧要,因为您的结构中有引用指针。在这里,如果您可以写出arr[i][j],那就意味着您将其声明为例如int* arr[4],然后从中malloc()分配了四个大小为12个int的内存块。请注意,您的四个指针(编译器现在可以用作基础)也会消耗未使用的内存,如果它是真实数组,则不会占用该内存。还要注意,这里生成的代码将包含双重间接引用:首先,代码通过arr i 加载一个指针,然后通过 j 从该指针中加载一个 int
如果长度距离2的幂很远(因此需要生成复杂的“与常数相乘”的代码才能访问元素),那么使用指针可能会生成更快的访问代码。
正如 James Kanze 在他的答案中提到的,在某些情况下,编译器可以优化真正的多维数组访问。这种优化对于由指针组成的数组来说是不可能的,因为在这种情况下,“数组”实际上不是线性的内存块。

本地性很重要

如果您正在开发通常的桌面/移动体系结构(Intel / ARM 32/64位处理器),那么本地性也很重要。这是可能存储在缓存中的变量。如果由于某种原因您的变量已经在缓存中,则它们将被更快地访问。

在本地性方面,堆栈始终是最优选择,因为堆栈非常频繁地使用,所以它很可能总是存储在缓存中。因此,小型数组最好放在其中。

使用真正的多维数组而不是从指针组成一个数组也可以在这方面有所帮助,因为真正的数组始终是线性内存块,因此通常可能需要加载较少的缓存块。相反,零散的指针组合(即如果使用单独的malloc()分配的块)可能需要更多的缓存块,并且可能会出现缓存行冲突,具体取决于堆上的块如何物理结束。


1
有很多对我的问题的好答案,但这个最让我觉得有道理。真希望我能接受不止一个答案。谢谢大家!通过这个讨论,我学到了很多东西。 - Paul Renton
我认为数据段中至少还有一种变量类型,那就是线程本地存储(例如,使用__thread int x)。 - Z boson
@RestlessC0bra,节的大小取决于架构和编译器,如果你真的很在意,你很可能会在链接脚本中找到它们。通常情况下,你不需要太关心它,因为数据的分配是在编译时确定的(以适应所有全局变量),微控制器上剩余的空间用于堆栈和堆(在桌面上通常不需要触及它们)。所以大多数情况下,默认值应该是正确的。 - Jubatian

4
关于哪种选择提供更好的性能,答案很大程度上取决于您的具体情况。要知道哪种方式更好或它们是否大致相等的唯一方法是测量应用程序的性能。
一些因素包括:执行频率、数组/数据的实际大小、系统内存量以及系统管理内存的效率。
如果您有选择的余地,则必须意味着已经确定了大小。那么,您不需要像您所示的多重分配方案。您可以对您的2D数组执行单个动态分配。在C中:
int (*array)[COLUMNS];
array = malloc(ROWS * sizeof(*array));

在C++中:

std::vector<std::array<int, COLUMNS>> array(ROWS);

只要 COLUMNS 固定下来,你就可以执行单个分配来获取你的2D数组。如果两者都没有固定,那么你无论如何都不能使用静态数组。

你提出的C和C++解决方案在内存分配方面有根本性的不同。通常情况下,在C++中,为了解决这种问题,你需要定义一个“Matrix”类,它可以使用“std::vector<std::array<int, COLUMNS>>”或“std::vector<int>”(并在索引运算符中进行乘法运算),具体取决于哪个在你的机器上更快。(通常情况下,如果我的经验是正确的话,应该是后者更快。) - James Kanze
1
在进一步考虑后,它们并不像我想的那样不同。我是在考虑 std::vector<std::vector<>> - James Kanze

4

在C++中实现二维数组的常规方式是将其封装在一个类中,使用std::vector<int>,并具有计算索引的类访问器。 然而:

任何关于优化的问题只能通过测量来回答,即使这样,它们也仅适用于您正在使用的编译器和在其上进行测量的机器。

如果您编写:

int array[2][3] = { ... };

然后大概是这样的:

for ( int i = 0; i != 2; ++ i ) {
    for ( int j = 0; j != 3; ++ j ) {
        //  do something with array[i][j]...
    }
}

很难想象一个编译器实际上并不生成以下内容的代码: ```` ````
for ( int* p = array, p != array + whatever; ++ p ) {
    //  do something with *p
}

这是最根本的优化之一,至少已经有30年的历史。

如果按照您提出的动态分配方式,编译器将无法应用此优化。即使对于单个访问: 矩阵的局部性较差,并且需要更多的内存访问,因此可能会降低性能。

如果您使用C ++,通常会编写一个Matrix类,使用std :: vector<int>作为内存,使用乘法明确计算索引。(改进的局部性可能会导致更好的性能,尽管需要乘法。)这可能会使编译器难以执行上述优化,但如果发现存在问题,您可以始终为处理此特殊情况提供专门的迭代器。在几乎不损失性能的情况下,您将获得更易读和更灵活的代码(例如,维数不必是常量)。


那种基本优化有一个名称吗?也许我可以检查一下我的编译器是否执行了这个优化。 - Paul Renton
for循环优化是可行的,但在许多情况下可能无法执行。例如,如果索引在代码中使用,或者甚至循环本身可能具有额外的早期终止点。通常使用多维数组,因为它更清晰地表达了某些算法,该算法显然会以某种方式使用索引。嗯,我对编译器不太熟悉(我通常使用8位嵌入式CPU,其编译器更简单),也许可以比我想象的更广泛地应用。 - Jubatian
@PaulRenton 我不知道它的名字,但这是20世纪70年代的一种常见优化方式,所以我会很惊讶如果今天的任何编译器都没有做到它。 - James Kanze

1
通常在内存占用和速度之间需要进行权衡。根据经验,我发现在堆栈上创建数组比在堆上分配更快。随着数组大小的增加,这一点变得更加明显。
您始终可以减少内存消耗。例如,您可以使用short或char代替int等。
随着数组大小的增加,特别是使用realloc时,可能会有更多的页面替换(上下),以维护项目连续位置。
您还应考虑可以存储在堆栈中的东西的最低限制,对于堆而言,这个限制更高,但是会牺牲性能。

1
另一方面,您可以在堆栈上分配的最大大小通常非常有限;如果数组足够大以至于性能成为问题,那么您可能别无选择。 - James Kanze
1
性能上并不一定有任何区别。这取决于你在做什么以及编译器如何处理它。 - James Kanze
你说得对,关于数组的堆栈限制,James。我正在相应地编辑我的答案。 - sgun

-4

与堆相比,栈式内存分配可以更快地访问数据。如果CPU没有找到地址,它会在缓存中查找,如果在缓存中没有找到地址,则会在主内存中查找。在缓存之后,栈是首选位置。


3
"Stalk memory allocation"是什么?我猜你的意思是堆栈(stack)?请问我的理解是否正确? - Paul Renton

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