【问题标题】:Stack concern : Local variables vs Arithmetics堆栈问题:局部变量与算术
【发布时间】:2014-09-21 20:39:44
【问题描述】:

我有一个关于保存算术计算以限制堆栈使用是否好的问题。

假设我有一个像这样的递归函数:

void foo (unsigned char x, unsigned char z) {
    if (!x || !z)
        return;
    // Do something
    for (unsigned char i = 0; i < 100; ++i) {
        foo(x - 1, z);
        foo(x, z - 1);
    }
}

这里主要看到的是 x - 1z - 1 在循环中每次评估。
为了提高性能,我会这样做:

const unsigned char minus_x = x - 1;
const unsigned char minus_z = z - 1;
for (unsigned char i = 0; i < 100; ++i) {
    foo(minus_x, z);
    foo(x, minus_z);
}

但是这样做意味着在每次调用时,minus_xminus_z 都会保存在堆栈中。递归函数可能被调用数千次,这意味着堆栈中使用了数千个字节。此外,所涉及的数学并不像-1 那样简单。

这是个好主意吗?
编辑:它实际上没用,因为它是编译器的标准优化:Loop-invariant code motion(参见 HansPassant 的评论)

使用包含以下计算的静态数组会更好:

static const char minuses[256] = {/* 0 for x = 0; x - 1 for x = 1 to 255 */}

然后做:

foo(minuses[x], z);
foo(x, minuses[z]);

这种方法极大地限制了所需的实际数学运算,但在每次调用时,它必须获取数组中的单元格,而不是从寄存器中读取它。

我正在尝试尽可能多地进行基准测试以找到最佳解决方案,但如果有最佳实践或我在这里遗漏了什么,请告诉我。

【问题讨论】:

  • 操作x - 1 无论如何都会在堆栈上创建一个值(即使您没有将它存储在局部变量中)。
  • @barakmanos 当然,但是对于局部变量,您在保存局部变量期间进行一次推送,在复制下一个函数调用的参数时进行一次推送。至少我是这么认为的。
  • 您可以简单地尝试这两个选项,运行到深度 2 或 3,然后检查 SP 寄存器的值。
  • @barakmanos 现在就做。
  • 您是否看到此代码中的实际性能受到影响?现代编译器足够聪明,可以自动优化这些条件。如果有真正的问题,您可以比较生成的机器代码。

标签: c performance recursion stack


【解决方案1】:

FWIW,我用 gcc 尝试了这个,两个函数 foo_1()(没有额外的变量)和 foo_2()(额外的变量)。

使用 -03 gcc 展开 for 循环 (!),这两个函数的大小完全相同,但代码不完全相同。很遗憾我没有时间弄清楚它们的不同之处和原因。

使用-02 gcc 为foo_1foo_2 生成完全相同的代码。正如人们所期望的那样,它为xzx-1z-1i 分配了一个寄存器,并推送/弹出这些寄存器以保留父级的值——使用 6 x 8(64 位机器) 每次调用的堆栈字节数(包括返回地址)。

您报告使用了 24 字节的堆栈...那是 32 位机器吗?

使用 -O0,情况有所不同,foo_1 每次都在循环中执行 x-1z-1,并且在这两种情况下,变量都保存在内存中。 foo_1 稍微短一些,我怀疑减法在现代处理器上没有任何区别!在这种情况下,foo_1foo_2 使用了相同数量的堆栈。这是因为foo 中的所有变量都是unsigned char,并且额外的minus_xminus_zi 打包在一起,使用空间,否则会填充。如果将minus_xminus_z 更改为unsigned long long,就会有所不同。奇怪的是,foo_1 也使用了 6 x 8 字节的堆栈。堆栈帧中有 16 个未使用的字节,所以即使考虑到将 RSP 和 RBP 对齐到 16 个字节边界,它似乎使用的比它需要的多……我不知道为什么。

我快速浏览了x - 1 的静态数组。对于 -O0,它对堆栈的使用没有影响(出于与以前相同的原因)。对于 -O2,它查看了 foo(x, minuses[z]); 并将 minuses[z] 提升到了循环之外!哪一个应该预料到...并且堆栈使用保持不变(6 x 8)。

更一般地说,正如其他地方所指出的那样,任何有效的优化量都会尽可能地将计算提升到循环之外。正在发生的另一件事是大量使用寄存器来保存变量——真实变量(你已经命名的那些)和“伪”变量(保存被提升的东西的预先计算的结果)。这些寄存器需要在子程序的调用中保存——无论是由调用者还是被调用者。 x86 push/pop 对整个寄存器进行操作,因此寄存器中保存的 unsigned char 将需要完整的 8 或 4(64 位或 32 位模式)字节的堆栈。但是,嘿,这就是您为优化付出的代价!

我并不完全清楚您最关心的是运行时还是堆栈使用。无论哪种方式,消息都是由编译器决定,当且仅当事情太慢时才担心,然后只担心分析显示的位有问题!

【讨论】:

  • 感谢您抽出宝贵时间深入研究。在过去的两周里,我进行了一些高级别的优化,现在我正在挖掘非常低级的问题以进一步提升它。要回答你的问题,我必须考虑这两个问题。所涉及的递归算法的性质(与 Trie 的 damereau-levenstein 距离)以及它在堆栈大小为 8M 的线程中运行的事实意味着堆栈大小可能是一个问题。另一方面,这个项目的目标是尽可能快的算法。因此,困难和这类问题。
  • 有了你的回答和之前的评论,我将坚持我当前的版本(看起来像我描述的第一个版本),但会尝试打包我的变量以节省空间。我认为这是最好的解决方案。
  • 好吧,如果你想从你的机器中挤出最大值,你可以考虑用显式堆栈替换递归......所以你向下迭代,边走边推东西,然后弹出东西回去的路。这样您就可以完全控制使用的空间,并且您可以利用仅需要几个字节的优势,而每个推送的寄存器需要 4 或 8 个字节。请注意,您必须非常深入地用完 8MB!
  • 对于某些树操作,完成后甚至不需要弹出所有内容,可以简单地丢弃堆栈。对于某些事情(例如,二叉树的按顺序遍历),可以使用显式堆栈来跟踪您的位置,以便可以为“下一个节点请求”函数提供遍历的最后一个已知状态,它会更新。不利的一面是您需要分配显式堆栈......这意味着知道树的深度,或者(为了最大的简单性)最大深度......这有多容易取决于所讨论的树。
  • 嗯,这里的树保证是固定大小的,所以应该很容易。
猜你喜欢
  • 2013-02-17
  • 1970-01-01
  • 2015-01-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-03-04
  • 1970-01-01
  • 2013-06-27
相关资源
最近更新 更多