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

时间:2016-01-18 05:10:20

标签: c arrays performance

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

  1. 静态

    char data[65536];
    
  2. 动态

    char *data;
    …
    data = (char*)malloc(65536);  /* or whatever size */
    
  3. 问题是,哪种方法有更好的表现?多少钱?

    据了解,第一种方法应该更快。

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

    1. 读取变量data,其中包含指向数组开头的指针
    2. 计算特定元素的偏移量
    3. 访问元素
    4. 使用第一种方法,编译器将data变量的地址硬编码到代码中,跳过第一步,所以我们有:

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

        这是对的吗?

5 个答案:

答案 0 :(得分:17)

应始终确定基准。但是,暂时忽略缓存的影响,效率可能取决于你偶尔如何访问缓存。在此,请考虑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;
}

现在,如果我们考虑缓存,datasetpdatasets将在第一次访问后大致相同,因为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从缓存中逐出,那么,性能差异并不重要,因为访问[或者]数组是罕见。因此,不是代码调整的目标。

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

一个更大的数组可能会产生更多的影响(例如,如果不是65536,我们有100000000,那么仅仅遍历就会驱逐data_p并且当我们到达终点时在数组中,最左边的条目已经被逐出。

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

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

<强>更新

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

因为数组是char [或unsigned char],所以编译器会在每次循环迭代中生成data_p次提取。请注意,如果数组 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]的代码,所以我通常不会看到多重问题&#34;问题&#34; [如果你愿意]太多了。我一直相信&#34;给编译器一个有用的提示&#34;至于我的意图,所以我只是公理地做到这一点。它告诉编译器另一个程序员意图是&#34;只获取data_p一次&#34;。

此外,当使用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;
}

但是,虽然它可能相当普遍,但#34;硬连线&#34;具有显式数组[ data_sdata_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提取]。这更可能是人们通常可以使用的[更好的代码重用等]。

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

现在更多的是选择固定大小的阵列或动态分配阵列的架构/设计选择。其他人指出了权衡。

这是与用例有关的。

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

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

答案 1 :(得分:5)

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

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

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

  

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

什么!这是什么CPU?一些1960年以前的古代计算机?

关于缓存,这些问题可能更有效。静态分配的内存有可能更好地利用数据缓存,但这只是推测,并且您必须考虑到非常具体的CPU来进行讨论。

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

答案 2 :(得分:4)

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

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

答案 3 :(得分:3)

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

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

让我们比较x86-64的简单测试函数compiled by clang 3.7(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别名规则要求编译器为assume that any pointer might be pointing to data_p,除非它能证明不是这样。这是使用全局指针指向时的主要性能危险。)

如果您不使用restrict,编译器仍然必须假设int *data_p1int *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在此使用data_s的非RIP相对有效地址,因为它可以。

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]中的loop_p和效率较低的循环结构,因为它使用循环计数器作为数组索引。

gcc 5.3 has much less difference between the two loops:它将起始地址放入寄存器并递增,并与结束地址进行比较。因此它使用a one-register addressing mode用于商店,这可能在英特尔CPU上表现更好。 gcc的循环结构/开销的唯一区别是静态缓冲区版本将初始指针获取到具有mov r32, imm32的寄存器,而不是来自内存的负载。 (因此地址是嵌入在指令流中的直接常量。)

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

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

请注意,全局数据(与static相对)通过全局偏移表具有额外的间接级别,但这是间接的,或者是动态分配的缺省。 compiling with 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相对偏移量不是编译时常量。由于运行时动态链接,它也不是链接时间常量,so an extra level of indirection through the GOT is used.这很不幸,我希望OS X上的编译器不会为全局变量带来这么多开销。使用godbolt上的-O0 -fwhole-program,我可以看到即使是全局变量也只是通过RIP相对寻址来访问,而不是通过GOT访问,因此在制作与位置无关的可执行文件时,这个选项可能比平时更有价值。希望链接时间优化也可以,因为在制作共享库时可以使用它。

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

动态分配可以带来显着的好处,但是,如果最终使用相同的内存作为临时空间来处理多个不同的事情,那么它在缓存中保持热点。如果不需要同时为每个函数提供自己的临时空间静态缓冲区通常是一个不好的举动:脏内存必须在不再需要时写回主内存,并且是其中的一部分。该计划的足迹将永远存在。

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

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

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

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

  

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

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

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

答案 4 :(得分:-2)

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

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

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

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

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

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