哪个更快:堆栈分配或者堆分配

时间:2020-03-06 15:00:42  来源:igfitidea点击:

这个问题听起来很基本,但这是我与与我合作的另一位开发人员进行的辩论。

我很小心地在可能的地方堆栈分配东西,而不是堆分配它们。他在跟我说话,看着我的肩膀,并评论说这是不必要的,因为它们是相同的表现。

我一直给人的印象是,增加堆栈的时间是恒定的,并且堆分配的性能取决于堆当前的分配(查找适当大小的孔)和取消分配(折叠孔以减少碎片)的复杂性如果我没有记错的话,许多标准库的实现在删除过程中都需要花费一些时间。

这让我感到震惊,因为它可能非常依赖于编译器。特别是对于这个项目,我正在使用Metrowerks编译器用于PPC体系结构。对这种组合有深刻的了解将是最有帮助的,但是总的来说,对于GCC和MSVC ++,情况如何?堆分配的性能不如堆栈分配高吗?没有区别吗?还是差异如此之细,以至于毫无意义的微优化。

解决方案

我们可以为性能非常好的特定大小的对象编写一个特殊的堆分配器。但是,一般堆分配器的性能不是特别好。

我也同意Torbj?rn Gyllebring关于对象的预期寿命。好点子!

堆栈分配要快得多,因为它真正要做的只是移动堆栈指针。
使用内存池,我们可以从堆分配中获得可比的性能,但这会带来一点点复杂性和麻烦。

此外,堆栈与堆不仅是性能方面的考虑,而且还需要考虑堆栈的大小。它还告诉我们很多有关对象的预期寿命的信息。

我不认为堆栈分配和堆分配通常是可互换的。我也希望它们两者的性能足以通用。

我强烈建议我们购买小件物品,无论哪种物品都更适合分配范围。对于大型项目,可能需要堆。

在具有多个线程的32位操作系统上,堆栈通常受到相当大的限制(尽管通常至少为几个mb),因为需要划分地址空间,并且一个或者多个线程迟早会遇到另一个线程。在单线程系统(无论如何还是Linux glibc单线程)上,该限制要少得多,因为堆栈可以不断增长。

在64位操作系统上,有足够的地址空间来使线程堆栈很大。

堆栈要快得多。实际上,在大多数情况下,例如大多数情况下,它仅使用一条指令。在x86上:

sub esp, 0x10

(这会将堆栈指针向下移动0x10字节,从而"分配"这些字节以供变量使用。)

当然,堆栈的大小非常非常有限,因为我们会很快发现是否过度使用了堆栈分配或者尝试进行递归:-)

另外,没有必要优化不需要验证的代码的性能,例如通过性能分析证明。 "过早的优化"通常会引起更多的问题,而不是值得的。

我的经验法则:如果我知道在编译时需要一些数据,并且它的大小不足几百个字节,则可以对其进行堆栈分配。否则,我会对其进行堆分配。

通常,堆栈分配仅包括从堆栈指针寄存器中减去。这比搜索堆快了很多吨。

有时堆栈分配需要添加虚拟内存页面。添加零内存的新页面不需要从磁盘读取页面,因此通常这仍比搜索堆快很多(特别是如果部分堆也被页面调出)。在极少数情况下,我们可以构造一个示例,堆中恰好有足够的空间可用,而该堆已经在RAM中,但是为堆栈分配新页必须等待其他页被写入到磁盘。在那种罕见的情况下,堆会更快。

我认为生命周期至关重要,是否必须以复杂的方式构造要分配的事物。例如,在事务驱动的建模中,通常必须填写事务结构并将其传递给具有一系列字段的事务函数。以OSCI SystemC TLM-​​2.0标准为例。

由于构造昂贵,将这些分配在靠近操作调用的堆栈上往往会导致巨大的开销。好的方法是在池中进行分配并通过池化或者简单的策略(例如"此模块仅需要一个事务对象")重用事务对象。

这比在每个操作调用上分配对象快许多倍。

原因很简单,因为该对象具有昂贵的构造和相当长的使用寿命。

我会说:尝试两种方法,看看哪种方法最适合我们,因为它实际上取决于代码的行为。

与堆分配相比,堆分配的最大问题可能是,在一般情况下堆分配是无限制的操作,因此,在计时很重要的情况下,我们不能使用它。

对于其他时间无关紧要的应用程序,这可能没什么大不了的,但是如果我们堆很多,这会影响执行速度。始终尝试将堆栈用于寿命短且经常分配的内存(例如在循环中),并在应用程序启动期间尽可能长地分配堆。

堆栈的容量有限,而堆栈没有。进程或者线程的典型堆栈约为8K。分配大小后就无法更改大小。

堆栈变量遵循作用域规则,而堆变量则不遵循。如果指令指针超出函数范围,则与该函数关联的所有新变量都将消失。

最重要的是,我们无法预先预测整个函数调用链。因此,仅分配200个字节可能会引起堆栈溢出。如果我们要编写的是库而不是应用程序,那么这一点尤其重要。

老实说,编写一个程序来比较性能很简单:

#include <ctime>
#include <iostream>

namespace {
    class empty { }; // even empty classes take up 1 byte of space, minimum
}

int main()
{
    std::clock_t start = std::clock();
    for (int i = 0; i < 100000; ++i)
        empty e;
    std::clock_t duration = std::clock() - start;
    std::cout << "stack allocation took " << duration << " clock ticks\n";
    start = std::clock();
    for (int i = 0; i < 100000; ++i) {
        empty* e = new empty;
        delete e;
    };
    duration = std::clock() - start;
    std::cout << "heap allocation took " << duration << " clock ticks\n";
}

有人说愚蠢的一致性是小头脑的妖精。显然,优化编译器是许多程序员的专栏作家。该讨论曾经是答案的底部,但是显然人们不愿意花那么多时间来阅读它,因此,我将其移至此处以避免收到已经回答的问题。

优化的编译器可能会注意到此代码不执行任何操作,并且可能会对其进行优化。做这样的事情是优化器的工作,而与优化器抗争是傻瓜的事。

我建议在关闭优化的情况下编译此代码,因为没有很好的方法欺骗当前正在使用或者将来将要使用的每个优化器。

任何打开优化器然后抱怨与优化器抗争的人都应该受到公众的嘲笑。

如果我关心纳秒级的精度,我将不会使用std :: clock()。如果我想将结果发表为博士学位论文,那么我会做更多的事情,并且我可能会比较GCC,Tendra / Ten15,LLVM,Watcom,Borland,Visual C ++,Digital Mars,ICC和其他编译器。实际上,堆分配比栈分配要花费数百倍的时间,而且我认为进一步研究这个问题没有任何用处。

优化器的任务是摆脱我正在测试的代码。我看不出有任何理由告诉优化器运行,然后尝试使优化器欺骗而不进行实际优化。但是,如果我认为这样做有价值,那么我将执行以下一项或者多项操作:

  • 将一个数据成员添加到"空",并在循环中访问该数据成员;但是,如果我只从数据成员中读取数据,则优化器可以进行不断折叠并删除循环;如果我只写过数据成员,则优化器可能会跳过循环的最后一个迭代,而不是最后一个迭代。另外,问题不是"堆栈分配和数据访问与堆分配和数据访问"。
  • 声明e易失性,但是易失性经常被错误地编译(PDF)。
  • 在循环内获取" e"的地址(可能将其分配给声明为" extern"并在另一个文件中定义的变量)。但是即使在这种情况下,编译器可能会注意到-至少在堆栈上-总是将'e'分配在相同的内存地址,然后像上面的(1)一样进行常量折叠。我得到了循环的所有迭代,但是该对象从未真正分配过。

除了显而易见的以外,该测试还存在缺陷,因为它可以同时测量分配和释放,并且最初的问题并未询问释放。当然,在堆栈上分配的变量会在其作用域的末尾自动解除分配,因此不调用delete'将会(1)使数字倾斜(关于堆栈分配的数字中包括了堆栈的解除分配,因此,公平地衡量堆的解除分配)和(2)会导致非常严重的内存泄漏,除非我们保留对新指针的引用并在进行时间测量后调用delete`。

在我的机器上,在Windows上使用g ++ 3.4.4,对于小于100000分配的任何内容,对于堆栈和堆分配,我都会获得" 0时钟滴答",即使如此,对于堆栈分配和" 15时钟滴答"也将获得" 0时钟滴答"。 "进行堆分配。当我测量10,000,000个分配时,堆栈分配需要31个时钟滴答,堆分配需要1562个时钟滴答。

是的,优化编译器可能会忽略创建空对象。如果我理解正确,它甚至可能会漏掉整个第一个循环。当我将迭代次数增加到10,000,000时,堆栈分配花费了31个时钟周期,堆分配花费了1562个时钟周期。我认为可以肯定地说,在没有告诉g ++优化可执行文件的情况下,g ++并没有忽略构造函数。

自从我写这篇文章以来,多年来,Stack Overflow一直偏向于发布经过优化的内部版本的性能。总的来说,我认为这是正确的。但是,我仍然认为在我们实际上不希望优化代码时要求编译器优化代码是很愚蠢的。我觉得这与为代客停车支付额外费用非常相似,但拒绝交出钥匙。在这种情况下,我不希望优化程序运行。

使用经过稍微修改的基准测试版本(以解决原始程序每次都没有通过循环在栈上分配某些东西的有效点),并在不进行优化的情况下进行编译,而是链接到发行版库(以解决我们不希望使用的有效点不想包括由于链接到调试库而导致的任何速度下降):

#include <cstdio>
#include <chrono>

namespace {
    void on_stack()
    {
        int i;
    }

    void on_heap()
    {
        int* i = new int;
        delete i;
    }
}

int main()
{
    auto begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_stack();
    auto end = std::chrono::system_clock::now();

    std::printf("on_stack took %f seconds\n", std::chrono::duration<double>(end - begin).count());

    begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_heap();
    end = std::chrono::system_clock::now();

    std::printf("on_heap took %f seconds\n", std::chrono::duration<double>(end - begin).count());
    return 0;
}

显示:

on_stack took 2.070003 seconds
on_heap took 57.980081 seconds

在我的系统上,使用命令行cl foo.cc / Od / MT / EHsc进行编译时。

我们可能不同意我获取未优化构建的方法。很好:随时根据需要随意修改基准。启用优化后,我得到:

on_stack took 0.000000 seconds
on_heap took 51.608723 seconds

并不是因为堆栈分配实际上是瞬时的,而是因为任何半个体面的编译器都可以注意到on_stack并没有做任何有用的事情并且可以对其进行优化。我的Linux笔记本电脑上的GCC还注意到on_heap并没有做任何有用的事情,并对其进行了优化:

on_stack took 0.000003 seconds
on_heap took 0.000002 seconds

不是jsut堆栈分配更快。使用堆栈变量也可以赢得很多好处。它们具有更好的参考位置。最后,重新分配也便宜很多。

关于这种优化有一个普遍的观点。

我们获得的优化与程序计数器实际在该代码中的时间成正比。

如果对程序计数器进行采样,则会发现它在哪里花费时间,而这通常只是代码的一小部分,而且通常在库例程中我们无法控制。

只有发现它在对象的堆分配中花费了很多时间,才可以明显更快地对其进行堆栈分配。

切勿过早假设,因为其他应用程序代码和用法会影响功能。因此,将功能视为隔离是没有用的。

如果我们对应用程序很认真,请使用VTune或者使用任何类似的性能分析工具查看热点。

吉坦

之前已经提到过,堆栈分配只是在移动堆栈指针,即在大多数体系结构上只有一条指令。将其与堆分配情况下通常发生的情况进行比较。

操作系统将空闲内存的部分保持为链接列表,并带有有效载荷数据,该有效载荷数据包括指向空闲部分的起始地址的指针和空闲部分的大小。要分配X个字节的内存,将遍历链接列表,并按顺序访问每个注释,以检查其大小是否至少为X。当找到大小P> = X的部分时,P分为两部分,尺寸X和PX。链接列表将更新,并返回指向第一部分的指针。

如我们所见,堆分配取决于可能的因素,例如请求的内存量,内存的碎片程度等等。

通常,栈的分配要比堆的分配快,正如上面几乎每个答案所提到的那样。堆栈推入或者弹出操作为O(1),而从堆进行分配或者释放则可能需要遍历先前的分配。但是,我们通常不应该在性能密集的紧凑循环中进行分配,因此选择通常取决于其他因素。

进行这种区分可能会很好:我们可以在堆上使用"堆栈分配器"。严格来说,我将堆栈分配理解为实际的分配方法,而不是分配的位置。如果我们在实际的程序堆栈上分配了很多东西,由于各种原因,这可能是不好的。另一方面,在可能的情况下,使用堆栈方法在堆上分配是分配方法的最佳选择。

由于我们提到了Metrowerks和PPC,所以我猜我们是说Wii。在这种情况下,内存是非常宝贵的,并且在可能的情况下使用堆栈分配方法可以确保我们不会在片段上浪费内存。当然,与"常规"堆分配方法相比,这样做需要更多的注意。评估每种情况的权衡是明智的。

我对Xbox 360 Xenon处理​​器上的堆栈与堆分配了解到的一件有趣的事情(也可能适用于其他多核系统)是,在堆上进行分配会导致输入关键部分以暂停所有其他内核,因此分配不会不冲突。因此,在一个紧密的循环中,堆栈分配是固定大小阵列的一种选择,因为它可以防止停顿。

如果我们正在为多核/多进程进行编码,这可能是要考虑的另一种提速方法,因为堆栈分配只能由运行作用域函数的内核查看,而不会影响任何其他内核/ CPU。

除了在数量级上优于堆分配的性能优势外,对于长时间运行的服务器应用程序,堆栈分配更可取。甚至最好的托管堆最终也会变得如此分散,以至于应用程序性能下降。

堆栈分配几乎总是与堆分配一样快或者更快,尽管堆分配器当然可以简单地使用基于堆栈的分配技术。

但是,在处理基于堆栈的分配与基于堆的分配的总体性能时(或者更好地说,本地分配与外部分配),存在更大的问题。通常,堆(外部)分配很慢,因为它处理许多不同种类的分配和分配模式。减小正在使用的分配器的范围(使其在算法/代码局部)将易于提高性能,而无需进行重大更改。为分配模式添加更好的结构,例如,通过以更简单,更结构化的方式使用分配器,对分配和解除分配对强制执行LIFO排序也可以提高分配器的性能。或者,我们可以使用或者编写针对特定分配模式进行了调整的分配器;大多数程序会频繁分配一些离散的大小,因此基于一些固定(最好是已知的)大小的后备缓冲区的堆将表现得非常好。 Windows正是出于这个原因使用了低碎片整理堆。

另一方面,如果线程过多,则在32位内存范围内基于堆栈的分配也会带来危险。堆栈需要连续的内存范围,因此拥有的线程越多,运行它们所需的虚拟地址空间就越多,而不会导致堆栈溢出。 (现在)使用64位将不会有问题,但是它肯定会在具有大量线程的长时间运行的程序中造成严重破坏。由于碎片而导致虚拟地址空间用尽总是很难解决的。

堆栈分配是一对指令,而我所知最快的rtos堆分配器(TLSF)平均使用150条指令。另外,堆栈分配不需要锁,因为它们使用线程本地存储,这是另一个巨大的性能优势。因此,堆栈分配的速度可以提高2-3个数量级,具体取决于环境中多线程的使用量。

通常,如果我们在乎性能,那么堆分配是最后选择。可行的中间选项可以是固定池分配器,该分配器也只有几个指令,并且每个分配的开销非常小,因此对于固定大小的小型对象非常有用。不利的一面是,它仅适用于固定大小的对象,本质上不是线程安全的,并且存在块碎片问题。

堆栈分配要快得多。