是否更快地访问静态或动态分配的内存?

在C中有两种分配全局数组的方法:

  1. 静态

    char data[65536]; 
  2. 动态

     char *data; … data = (char*)malloc(65536); /* or whatever size */ 

问题是,哪种方法有更好的表现? 多少钱?

理解它,第一种方法应该更快。

因为使用第二种方法,要访问数组,每次访问时都必须取消引用元素的地址,如下所示:

  1. 读取包含指向数组开头的指针的变量data
  2. 计算特定元素的偏移量
  3. 访问元素

使用第一种方法,编译器将data变量的地址硬编码到代码中,跳过第一步,因此我们有:

  1. 从编译时定义的固定地址计算特定元素的偏移量
  2. 访问数组的元素

每次内存访问相当于大约40个CPU时钟周期,因此,使用动态分配,特别是对于不频繁读取,与静态分配相比可能会显着降低性能,因为data变量可能会被一些更频繁访问的变量从缓存中清除。 相反,解除引用静态分配的全局变量的成本是0,因为它的地址已经在代码中进行了硬编码。

它是否正确?

人们应该始终确定基准。 但是,暂时忽略缓存的影响,效率可能取决于您偶尔如何访问缓存。 在这里,考虑char data_s[65536]char *data_p = malloc(65536)


如果访问是偶发的,则静态/全局将稍微加快:

 // slower because we must fetch data_p and then store void datasetp(int idx,char val) { data_p[idx] = val; } // faster because we can store directly void datasets(int idx,char val) { data_s[idx] = val; } 

现在,如果我们考虑缓存, datasetsdatasets将在[第一次访问之后]大致相同,因为data_p的提取将从缓存中实现[无法保证,但很可能],因此时间差异要小得多。


但是,当在紧密循环中访问数据时,它们将大致相同,因为编译器[optimizer]将在循环开始时预取data_p并将其放入寄存器:

 void datasetalls(char val) { int idx; for (idx = 0; idx < 65536; ++idx) data_s[idx] = val; } void datasetallp(char val) { int idx; for (idx = 0; idx < 65536; ++idx) data_p[idx] = val; } void datasetallp_optimized(char val) { int idx; register char *reg; // the optimizer will generate the equivalent code to this reg = data_p; for (idx = 0; idx < 65536; ++idx) reg[idx] = val; } 

如果访问是如此偶然, data_p被从缓存中逐出,则性能差异并不重要,因为对[或者]数组的访问并不常见。 因此, 不是代码调整的目标。

如果发生这样的驱逐,实际数据arrays也很可能被驱逐。

一个更大的数组可能会产生更多的影响(例如,如果代替65536 ,我们有100000000 ,那么仅仅遍历将驱逐data_p ,当我们到达数组的末尾时,最左边的条目已经被驱逐。

但是,在这种情况下, data_p的额外提取将是0.000001%的开销。

因此,它有助于对特定用例/访问模式进行基准测试(或建模)。


更新:

基于一些进一步的实验[由Peter的评论引发],由于严格的别名考虑, datasetallp函数不会针对某些条件优化到datasetallp_optimized的等效项。

因为数组是char [或unsigned char ],所以编译器会在每次循环迭代时生成data_p fetch。 请注意,如果数组不是 char (例如int ),那么优化确实会发生, data_p只被提取一次,因为char可以别名,但int更有限。

如果我们将char *data_p更改为char *restrict data_p我们将获得优化的行为。 添加restrict告诉编译器data_p 不会为任何[甚至是其自身]添加别名,因此优化获取是安全的。


个人提示:虽然我理解这一点,但对我来说,似乎愚蠢的是, 如果没有 restrict ,编译器必须假设data_p可以别名返回自身 。 虽然我确定还有其他[同样做作]的例子,我能想到的唯一一个是data_p指向自身或者data_p是结构的一部分:

 // simplest char *data_p = malloc(65536); data_p = (void *) &data_p; // closer to real world struct mystruct { ... char *data_p; ... }; struct mystruct mystruct; mystruct.data_p = (void *) &mystruct; 

这些是获取优化错误的情况。 但是,IMO,这些与我们一直在处理的简单案例有所区别。 至少,结构版本。 并且,如果程序员应该做第一个,IMO,他们得到他们应得的[并且编译器应该允许获取优化]。


对于我自己,我总是手工编写相当于datasetallp_optimized [sans register ]的代码,所以我通常不会看到多重“问题”[如果你愿意]太多。 我总是相信“给编译器一个有用的提示”,因此我只是公理化地做这件事。 它告诉编译器另一个程序员意图是“只获取一次data_p ”。


此外,当使用data_p进行输入时不会发生data_p问题[因为我们没有修改任何东西,别名不是考虑因素]:

 // does fetch of data_p once at loop start int datasumallp(void) { int idx; int sum; sum = 0; for (idx = 0; idx < 65536; ++idx) sum += data_p[idx]; return sum; } 

但是,虽然它可能相当普遍,但“硬连线”原始数组操作函数与显式数组[ data_sdata_p ]通常没有比将数组地址作为参数传递data_p用。

旁注: clang会使用data_s优化一些函数到memset调用中,因此,在实验过程中,我稍微修改了示例代码以防止这种情况。

 void dataincallx(array_t *data,int val) { int idx; for (idx = 0; idx < 65536; ++idx) data[idx] = val + idx; } 

这不会受到多重捕获问题的影响。 也就是说, dataincallx(data_s,17)dataincallx(data_p,37)工作方式大致相同[使用初始的额外data_p fetch]。 这更可能是人们通常可以使用的[更好的代码重用等]。


因此, data_sdata_p之间的区别变得有点没有实际意义。 加上明智地使用restrict [或使用除char之外的类型], data_p获取开销可以最小化到不太重要的程度。

现在,它更多地涉及选择固定大小arrays或动态分配arrays的架构/设计选择。 其他人指出了权衡。

这是用例依赖的。

如果我们有一个有限数量的数组函数,但是有大量不同的数组,那么将数组地址传递给函数是一个明显的赢家。

但是,如果我们有大量的数组操作函数和[比较]一个数组(例如[2D]数组是游戏板或网格),那么每个函数直接引用全局[ data_sdata_p ]可能会更好。

计算偏移量不是一个很大的性能问题。 您必须考虑如何在代码中实际使用该数组。 你最有可能写一些像data[i] = x; 然后无论data存储在何处,程序都必须加载基址并计算偏移量。

编译器可以在静态分配数组的情况下硬编码地址的情况仅在您编写类似data[55] = x; 这可能是一个不太可能的用例。

无论如何,我们在这里和那里谈论几个CPU滴答。 这不是你应该通过尝试手动优化来追逐的东西。

每次存储器访问相当于大约40个CPU时钟周期

什么!? 这是什么CPU? 1960年以前的一些古代电脑?

关于高速缓冲存储器,这些问题可能更有效。 静态分配的内存有可能更好地利用数据缓存,但这只是猜测,你必须考虑到非常具体的CPU才能进行讨论。


然而,静态和动态分配之间存在显着的性能差异,即分配本身。 对于每次调用malloc ,都会调用OS API,然后运行搜索function,通过堆运行并寻找一个空闲段。 该库还需要在内部跟踪该段的地址,以便在调用free()它知道要释放多少内存。 此外,您调用malloc / free越多,堆的分段就越多。

我认为数据局部性比计算arrays的基地址更具问题。 (我可以想象访问指针内容非常快的情况,因为它在寄存器中,而堆栈指针或文本段的偏移量是编译时间常数;访问寄存器可能更快。)

但真正的问题将是数据局部性,这通常是在性能关键紧密循环中小心动态内存的一个原因。 如果您有更多动态分配的数据恰好接近您的arrays,则内存可能会保留在缓存中。 如果数据分散在不同时间分配的RAM中,则可能有许多缓存未命中访问它们。 在这种情况下,如果可能的话,最好将它们静态地(或在堆栈上)彼此相邻地分配。

这里有一个小的影响。 它不太可能是重要的,但它是真实的。 它通常需要一个额外的指令来解析全局指针到缓冲区而不是全局数组的额外间接层。 对于大多数用途,其他考虑因素将更为重要(例如重用相同的临时空间,相比之下,为每个函数提供自己的临时缓冲区)。 另外:避免编译时大小限制!

仅当您直接引用全局而不是将地址作为函数参数传递时,才会出现此效果。 内联/整个程序链接时优化可以一直看到全局最初用作函数arg的位置,并且能够在更多代码中利用它。


让我们比较简单的测试函数, 由clang 3.7编译为x86-64(SystemV ABI,所以第一个arg在rdi )。 其他架构的结果基本相同:

 int data_s[65536]; int *data_p; void store_s(int val) { data_s[val] = val; } movsxd rax, edi ; sign-extend mov dword ptr [4*rax + data_s], eax ret void store_p(int val) { data_p[val] = val; } movsxd rax, edi mov rcx, qword ptr [rip + data_p] ; the extra level of indirection mov dword ptr [rcx + 4*rax], eax ret 

好的,所以有一个额外负载的开销。 ( mov r64, [rel data_p] )。 取决于data_p存储在其附近的其他静态/全局对象,即使我们不经常使用它,它也可能在高速缓存中保持热量。 如果它位于没有其他频繁访问数据的缓存行中,那么它会浪费大部分缓存行。

然而,即使存在循环,每次函数调用也只需支付一次开销。 (除非数组是一个指针数组,因为C别名规则要求编译器假定任何指针可能指向data_p ,除非它可以certificate不是。 这是使用全局指针到指针时的主要性能危险 。)

如果不使用restrict ,编译器仍然必须假设int *data_p1int *data_p2指向的缓冲区可能会重叠,这会干扰自动向int *data_p2 ,循环展开和许多其他优化。 静态缓冲区不能与其他静态缓冲区重叠,但在同一循环中使用静态缓冲区和指针时仍需要restrict

无论如何,让我们来看看非常简单的memset风格循环的代码:

 void loop_s(int val) { for (int i=0; i<65536; ++i) data_s[i] = val; } mov rax, -262144 ; loop counter, counting up towards zero .LBB3_1: # =>This Inner Loop Header: Depth=1 mov dword ptr [rax + data_s+262144], edi add rax, 4 jne .LBB3_1 ret 

请注意,clang在这里使用非RIP相对有效的data_s地址,因为它可以。

 void loop_p(int val) { for (int i=0; i<65536; ++i) data_p[i] = val; } mov rax, qword ptr [rip + data_p] xor ecx, ecx .LBB4_1: # =>This Inner Loop Header: Depth=1 mov dword ptr [rax + 4*rcx], edi add rcx, 1 cmp rcx, 65536 jne .LBB4_1 ret 

注意mov rax, qword [rip + data_p]mov rax, qword [rip + data_p]和效率较低的循环结构,因为它使用循环计数器作为数组索引。

gcc 5.3在两个循环之间的差异要小得多 :它将起始地址放入寄存器并递增,并与结束地址进行比较。 因此它为存储使用单寄存器寻址模式 ,这可能在Intel CPU上表现更好。 gcc的循环结构/开销的唯一区别是静态缓冲区版本将初始指针获取到具有mov r32, imm32的寄存器,而不是来自内存的加载。 (因此地址是嵌入在指令流中的直接常量。)


共享库代码和OS X上,所有可执行文件必须与位置无关 ,gcc的方式是唯一的选择。 而不是mov r32, imm32来获取地址到寄存器,它将使用lea r64, [RIP + displacement] 。 当您需要将地址偏移可变量(例如,数组索引)时,通过将绝对地址嵌入到其他指令中来保存指令的机会就消失了。 如果数组索引是编译时常量,则可以将其折叠到RIP相对加载或存储而不是LEA的位移。 对于数组上的循环,这只能在完全展开时发生,因此不太可能。

仍然,额外的间接级别仍然存在指向动态分配内存的指针。 在执行加载而不是LEA时,仍有可能出现缓存或TLB未命中。

请注意,全局数据(与static相反)在全局偏移表中具有额外的间接级别,但这是在动态分配的间接或缺乏的情况下。 用gcc 5.3 -fPIC编译 。

 int global_data_s[65536]; int access_global_s(int i){return global_data_s[i];} mov rax, QWORD PTR global_data_s@GOTPCREL[rip] ; load from a RIP-relative address, instead of an LEA movsx rdi, edi mov eax, DWORD PTR [rax+rdi*4] ; load, indexing the array ret int *global_data_p; int access_global_p(int i){return global_data_p[i];} mov rax, QWORD PTR global_data_p@GOTPCREL[rip] ; extra layer of indirection through the GOT movsx rdi, edi mov rax, QWORD PTR [rax] ; load the pointer (the usual layer of indirection) mov eax, DWORD PTR [rax+rdi*4] ; load, indexing the array ret 

如果我理解正确,编译器不会假定当前编译单元中全局符号的符号定义是在链接时实际使用的定义。 因此RIP相对偏移量不是编译时常量。 由于运行时动态链接,它也不是链接时间常量, 因此使用了通过GOT的额外间接级别。 这很不幸,我希望OS X上的编译器对全局变量没有太大的开销。 使用-O0 -fwhole-program上的-O0 -fwhole-program ,我可以看到即使是全局变量也只是通过RIP相对寻址来访问,而不是通过GOT访问,因此在制作与位置无关的可执行文件时,这个选项可能比平时更有价值。 希望链接时间优化也可以,因为在制作共享库时可以使用它。


正如许多其他答案所指出的那样,还有其他重要因素,例如内存位置,以及实际执行分配/释放的开销。 对于在程序启动时分配一次的大缓冲区(多页),这些并不重要。 请参阅Peter A. Schneider的回答。

但是,动态分配可以带来显着的好处,如果您最终使用相同的内存作为多个不同的事物的暂存空间,那么它在缓存中保持热门。 如果不需要同时为每个函数提供自己的临时空间静态缓冲区通常是一个糟糕的举动:脏内存必须在不再需要时写回主内存,并且永远是程序占用空间的一部分。

在没有malloc (或new )开销的情况下获得小型临时缓冲区的好方法是在堆栈上创建它们(例如,作为本地数组变量)。 C99允许可变大小的本地数组,如foo(int n) { int buf[n]; ...; } foo(int n) { int buf[n]; ...; } 注意不要过度使用并耗尽堆栈空间,但当前的堆栈页面将在TLB中变热。 我的godbolt链接中的_local函数在堆栈上分配一个可变大小的数组,这有一些开销,用于在添加可变大小后将堆栈重新对齐到16B边界。 看起来clang注意掩盖了标志位,但是如果n是负数,那么gcc的输出看起来就会以有趣和激动的方式打破。 (在godbolt中,使用“二进制”按钮来获取反汇编输出,而不是编译器的asm输出,因为反汇编使用hex作为立即常量。例如clang的movabs rcx, 343597383520x7fffffff0 )。 虽然它需要一些指令,但它比malloc便宜得多。 使用malloc中型到大型分配(如64kiB)通常会进行mmap系统调用。 但这是分配的成本,而不是分配后的访问成本。

将缓冲区放在堆栈上意味着堆栈指针本身就是索引它的基址。 这意味着它不需要额外的寄存器来保存该指针,也不必从任何地方加载它。


如果在静态(或全局)中将任何元素静态初始化为非零,则整个数组或结构将位于可执行文件中,如果程序启动时大多数条目应为零,则会浪费大量空间。 (或者,如果可以快速计算数据。)

在某些系统上,只要你甚至从未读过你不需要的部分,拥有一个巨大的数组零初始化静态数组就不会花费任何成本。 内存的延迟映射意味着操作系统将巨型arrays的所有页面映射到同一个归零内存页面,并进行写入时复制。 利用这个将是一个丑陋的性能黑客只有在你确定你真的想要它时才能使用,并确保你的代码永远不会在你的char data[1<<30]真正使用那么多内存的系统上运行马上。


每次存储器访问相当于大约40个CPU时钟周期。

这是无稽之谈。 延迟可以是3或4个周期(L1缓存命中)到数百个周期(主存储器),甚至是需要磁盘访问的页面错误。 除了页面错误之外,大部分延迟都可能与其他工作重叠,因此对吞吐量的影响可能会低得多。 来自常量地址的加载可以在指令发出到无序核心时立即开始,因为它是新依赖关系链的开始。 无序窗口的大小是有限的(英特尔Skylake核心的重新排序缓冲区为224微秒,一次可以有72个负载)。 完全缓存未命中(或者更糟糕的是,TLB未命中后跟缓存未命中)通常会使无序执行失效。 请参阅http://agner.org/optimize/和x86 wiki中的其他链接。 另请参阅此博客文章,了解ROB大小对可以同时传输多少缓存未命中的影响。

其他架构(如ARM和PPC)的无序内核类似,但有序内核更多地受到缓存未命中的影响,因为它们在等待时无法执行任何其他操作。 (像英特尔和AMD的主流微架构(不是低功耗的Silvermont或Jaguar微架构)这样的大型x86内核比其他设计拥有更多的无序执行资源.AFAIK,大多数ARM内核具有明显更小的缓冲区,可以尽早启动独立负载和/或隐藏缓存未命中延迟。)

我会说你真的应该对它进行描述。 从理论上讲,你是对的但是你必须记住一些基本的东西。

语言C是一种高级语言,就像现在存在的许多语言一样,你告诉机器该做什么。 越来越接近机器代码将考虑ASM或类似。 如果您通过编译和链接或其他方式构建代码,编译器将尽力正确运行您的需求并对其进行优化(除非您不希望这样)。 请记住,还存在Just-In-Time编译(JIT)等概念。

所以我认为很难回答你的问题。 首先,你可以肯定。 静态数组很可能更快,特别是大小为65536,因为编译器有更多的优化机会。 这可能取决于您定义的大小。 对于GCC 65536字节似乎是堆栈和缓存的常见,不确定。 有些编译器甚至可能会告诉你数组太大,因为它们试图将它保存在其他内存层次结构中,比如高速缓存也比随机存取内存更快。

最后但并非最不重要的是记住,现代操作系统也使用虚拟内存进行内存管理。

静态存储器可以存储在数据段中,并且很可能在程序执行时加载,但请记住,这也是您必须考虑的时间。 程序启动时是由OS分配内存还是在运行时执行? 这真的取决于你的应用程序。

所以我认为你真的应该对你的结果进行基准测试,看看它的速度有多快。 但我倾向于说你的静态数组会编译一个运行得更快的代码。