【问题标题】:Does stack space required by a function affect inlining decisions in C/C++?函数所需的堆栈空间是否会影响 C/C++ 中的内联决策?
【发布时间】:2019-02-14 15:56:27
【问题描述】:

函数所需的大量堆栈空间会阻止它被内联吗?比如如果我在堆栈上有一个 10k 的自动缓冲区,那会不会降低函数被内联的可能性?

int inlineme(int args) {
  char svar[10000];

  return stringyfunc(args, svar);
}

我更关心 gcc,但 icc 和 llvm 也很高兴知道。

我知道这并不理想,但我很好奇。该代码可能在缓存上也很糟糕。

【问题讨论】:

  • 我不明白为什么这会成为内联的问题。您能否举例说明需要大量堆栈的函数如何内联?如果您多次调用inlineme,则每个内联实例都可以拥有自己的块作用域,因此一次只需要一个这样的数组驻留在堆栈上。而且无论函数是否内联,该数组都需要入栈。
  • 如果它是一个常用函数(例如帮助输出缓冲区),并且调用堆栈上可能有很多函数使用它,堆栈可能会迅速增长。我无法为 gcc 内联决策/如何在这方面帮助优化器提供太多信息。
  • fna() { inlineme(); fnb(); } 然后fnb() { inlineme(); fnc(); } 以及对inlineme() 的每个内联调用都需要自己的缓冲区。
  • 阻塞作用域是否释放空间(即调整堆栈指针)?我认为 func 的所有空间总是在入口处分配,虽然可能会调用析构函数或变量失去作用域,但堆栈并没有缩小。
  • @FrançoisAndrieux:把这些 cmets 变成了答案。它没有直接回答问题,但讨论编译器如何分配堆栈肯定是相关的。

标签: c++ c gcc inline micro-optimization


【解决方案1】:

是的,是否内联的决定取决于函数的复杂性、堆栈和寄存器的使用情况以及进行调用的上下文。这些规则取决于编译器和目标平台。当性能很重要时,请始终检查生成的程序集。

比较 this version 与 10000 字符数组 内联(GCC 8.2、x64、-O2):

inline int inlineme(int args) {
  char svar[10000];

  return stringyfunc(args, svar);
}

int test(int x) {
    return inlineme(x);
}

生成的程序集:

inlineme(int):
        sub     rsp, 10008
        mov     rsi, rsp
        call    stringyfunc(int, char*)
        add     rsp, 10008
        ret
test(int):
        jmp     inlineme(int)

this one 带有一个小得多的 10 字符数组,内联的:

inline int inlineme(int args) {
  char svar[10];

  return stringyfunc(args, svar);
}

int test(int x) {
    return inlineme(x);
}

生成的程序集:

test(int):
        sub     rsp, 24
        lea     rsi, [rsp+6]
        call    stringyfunc(int, char*)
        add     rsp, 24
        ret

【讨论】:

  • 1- 谢谢。 2- Godbolt 如何使用.text 和 `\\` 复选框对所有内容进行解码并漂亮地打印?
  • @JasonN 因为 Godbolt 的 Compiler Explorer 是杰作。但更严重的是,您可以随时查看source code 或查看his blog
  • visual studio 内联最多 8128 字节
【解决方案2】:

如果我在堆栈上有一个 10k 的自动缓冲区,那会不会降低函数被内联的可能性?

一般来说不一定。事实上,内联扩展有时可以减少堆栈空间的使用,因为不必为函数参数设置空间。

将“宽”调用扩展为调用其他“宽”函数的单个框架可能是一个问题,除非优化器单独防止这种情况,否则它可能不得不避免扩展“宽”函数。

在递归的情况下:很可能是。

LLVM source 的一个例子:

if (IsCallerRecursive &&
         AllocatedSize > InlineConstants::TotalAllocaSizeRecursiveCaller) {
       InlineResult IR = "recursive and allocates too much stack space";

来自GCC source

对于堆栈增长限制,我们始终基于堆栈使用量的增长 的来电者。我们希望防止应用程序出现段错误 当具有巨大堆栈帧的函数获取堆栈溢出时 内联。

控制限制,来自GCC manual

--参数名称=值

大功能增长

  • 以百分比指定由内联引起的大型函数的最大增长。例如,参数值 100 将大型函数的增长限制为原始大小的 2.0 倍。

大堆栈帧

  • 指定大堆栈帧的限制。虽然内联算法试图不超过这个限制太多。

大堆栈帧增长

  • 以百分比指定由内联引起的大型堆栈帧的最大增长。例如,参数值 1000 将大型堆栈帧的增长限制为原始大小的 11 倍。

【讨论】:

  • 我担心的调用不是递归的,而是出现在调用栈的很多函数中。我不担心会炸毁堆栈,因为这个函数没有被内联,因为很快我们就会谈论 100k 和更大的堆栈帧。我喜欢 LLVM 源代码。一些我见过的最干净的。
  • 感谢 gcc 手册参考。我看了一眼,但我不知道它是指堆栈还是代码大小。我假设代码大小。
  • @JasonN insns 限制用于指令,growth 用于堆栈。你可以在source看到用法
【解决方案3】:

是的,部分原因是编译器在序言/尾声中为整个函数分配了一次堆栈,而不是在堆栈指针进入/离开块范围时移动堆栈指针。


每个对 inlineme() 的内联调用都需要自己的缓冲区。

不,我很确定编译器足够聪明,可以为同一函数的不同实例重用相同的堆栈空间,因为该 C 变量的一个实例一次只能在作用域内。强>

内联后的优化可以将内联函数的一些操作合并到调用代码中,但我认为编译器很少会最终得到它想要同时保留的数组的两个版本。

我不明白为什么这会成为内联的问题。您能否举例说明需要大量堆栈的函数如何内联?

它可能产生的问题的真实示例(编译器启发式通常会避免):

if (rare_special_case) use_much_stack() 内联到递归函数中,否则不会使用太多堆栈,这将是一个明显的性能问题(更多缓存和 TLB 未命中),如果递归足够深,甚至正确性实际上溢出了堆栈。

(特别是在 Linux 内核堆栈等受限环境中,通常每个线程 8kiB 或 16kiB,而旧版 Linux 的 32 位平台上为 4k。https://elinux.org/Kernel_Small_Stacks 有一些关于试图摆脱 4k 的信息和历史引述堆栈,因此内核不必为每个任务查找 2 个连续的物理页)。

编译器通常会让函数预先分配它们需要的所有堆栈空间(VLA 和alloca 除外)。内联错误处理或特殊情况处理函数,而不是在极少数情况下调用它会在主要的序幕/尾声,它也会影响快速路径。特别是如果快速路径没有进行任何其他函数调用。

如果您不内联处理程序,那么如果没有错误(或特殊情况没有发生),则永远不会使用该堆栈空间。因此,快速路径可以更快,使用更少的推送/弹出指令,并且在继续调用另一个函数之前不分配任何大缓冲区。 (即使函数本身实际上不是递归的,在深度调用树中的多个函数中发生这种情况也可能会浪费大量堆栈。)

我读到 Linux 内核确实在几个关键位置手动执行此优化,在这些地方 gcc 的内联启发式会做出不想要的内联决定:将函数分解为快速路径调用慢速路径,并在较大的慢速路径函数上使用__attribute__((noinline)) 以确保它不会内联。


在某些情况下,不在条件块内进行单独分配会错过优化,但更多的堆栈指针操作会使堆栈展开元数据以支持异常(和回溯)更加臃肿(尤其是保存/恢复堆栈展开异常的调用保留寄存器必须恢复)。

如果您在运行某些通过任一方式到达的通用代码之前在条件块内进行保存和/或分配(使用另一个分支来决定在结语中恢复哪些寄存器),那么将无法异常处理程序机器知道是仅加载 R12 还是 R13 以及(例如)从该函数保存它们的位置,而不需要某种异常复杂的元数据格式,可以指示寄存器或内存位置以针对某些条件进行测试。 ELF 可执行文件/库中的.eh_frame 部分已经足够臃肿了! (顺便说一句,它是非可选的。x86-64 System V ABI(例如)即使在不支持异常的代码或 C 中也需要它。在某些方面这很好,因为这意味着回溯通常可以工作,甚至通过通过函数备份的异常会导致损坏。)

不过,您绝对可以在条件块内调整堆栈指针。为 32 位 x86 编译的代码(带有糟糕的堆栈参数调用约定)可以并且确实使用 push 甚至在条件分支中。所以只要你在离开分配空间的块之前清理堆栈,它是可行的。那不是保存/恢复寄存器,只是移动堆栈指针。 (在没有帧指针构建的函数中,展开元数据必须记录所有这些变化,因为堆栈指针是查找保存的寄存器和返回地址的唯一参考。)

我不确定为什么编译器不能/不想更智能地仅在使用它的块内分配大量额外堆栈空间的详细信息。问题的一个重要部分可能是它们的内部结构没有设置为甚至能够寻找这种优化。


相关:Raymond Chen posted a blog 关于 PowerPC 调用约定,以及如何对使堆栈展开工作的函数序言/尾声有特定要求。 (并且规则暗示/要求在堆栈指针下方存在一个红色区域,该区域对异步破坏是安全的。其他一些调用约定使用红色区域,例如 x86-64 System V,但 Windows x64 没有。Raymond 发布 @987654323 @)

【讨论】:

  • “goto”进出块的合法性倾向于在整个函数中将堆栈指针保持在同一位置。否则,每次跳转都可能需要调整堆栈指针。这对条件分支来说是一个真正的痛苦。而不是“如果相等则分支到label2”,您必须执行“如果不相等则分支到temp;调整堆栈指针;跳转到label2;temp:”。 (也就是说,有一种称为“收缩包装”的技术可以在函数的生命周期内动态调整堆栈。)
  • @RaymondChen:是的,我只是在想象为具有单个入口和出口点的块执行块范围的 alloc/dealloc。 (例如,一个没有与其他东西合并的 if() 主体。)这可能超过 1 个 basic 块,但编译器知道发生了什么,理论上可以寻找一个地方alloc 和一个可以避免问题的释放位置。感谢关于 Shrink Wrap 名称的提醒,用于延迟序幕的优化。 reviews.llvm.org/D9210 展示了一个带有 AArch64 示例的收缩包装优化过程。
猜你喜欢
  • 2015-01-05
  • 2011-05-12
  • 1970-01-01
  • 2016-06-19
  • 2013-05-12
  • 1970-01-01
  • 2016-10-24
  • 2019-01-05
  • 2010-09-15
相关资源
最近更新 更多