什么时候函数调用成本在现代编译器中仍然很重要?


95

我是一个虔诚的人,并努力不犯罪。这就是为什么我倾向于编写较小的函数(于此值以重写Robert C. Martin)以符合Clean Code圣经所定的几条诫命。但是在检查一些东西的同时,我登陆了这篇文章,在下面我读到这个评论:

请记住,方法调用的成本可能很高,具体取决于语言。在编写可读代码和编写性能代码之间几乎总是要权衡取舍。

考虑到高性能现代编译器行业的丰富性,如今在什么条件下该引用语句仍然有效?

那是我唯一的问题。这与我应该编写长函数还是小函数无关。我只是强调,您的反馈可能会或不会有助于改变我的态度,使我无法抗拒亵渎神灵的诱惑。


11
编写可读且可维护的代码。只有当您遇到堆栈溢出问题时,您才可以重新考虑问题
Fabio

33
这里的一般答案是不可能的。有太多不同的编译器,实现了太多不同的语言规范。然后是JIT编译的语言,动态解释的语言等等。不过,只要您使用现代的编译器编译本机C或C ++代码,就不必担心函数调用的开销了。优化器会在适当的时候内联这些。作为微优化的爱好者,我很少看到编译器做出内联的决策,而这些决策是我或我的基准测试所不同意的。
科迪·格雷

6
从个人经验上讲,我用一种专有的语言编写了代码,这种专有的语言在功能方面相当现代,但是函数调用却非常昂贵,以至于甚至对于典型的for循环都必须对其速度进行优化:for(Integer index = 0, size = someList.size(); index < size; index++)而不是简单地for(Integer index = 0; index < someList.size(); index++)。仅仅因为您的编译器是最近几年制作的,并不一定意味着您可以放弃分析。
phyrfox

5
@phyrfox很有道理,在循环外获取someList.size()的值,而不是每次循环都调用它。如果在同步过程中读者和编写者可能会尝试在冲突期间发生冲突,那么这种情况尤其如此,在这种情况下,您还希望保护列表免受迭代期间的任何更改。
Craig

8
注意不要将小功能带到太远,它可能像单片宏功能一样有效地混淆代码。如果您不相信我,请查看ioccc.org的一些获奖者:有些将全部代码编码为一个main(),另一些将全部代码拆分为约50个微小的函数,而这些都是完全不可读的。诀窍是一如既往地保持良好的平衡
cmaster

Answers:


148

这取决于您的域。

如果要为低功耗微控制器编写代码,则方法调用成本可能会很大。但是,如果您要创建普通的网站或应用程序,则与其余代码相比,方法调用成本可以忽略不计。在这种情况下,将始终更需要关注正确的算法和数据结构,而不是像方法调用这样的微观优化。

还有编译器为您内联方法的问题。大多数编译器足够智能,可以在可能的地方内联函数。

最后,性能是黄金法则:始终优先考虑。不要基于假设编写“优化”代码。如果您不满意,请写两种情况,看看哪一种更好。


13
和EG的热点编译performes 投机内联,这在某种意义上是内联,即使它是不是可能。
约尔格W¯¯米塔格

49
实际上,在Web应用程序中,相对于数据库访问和网络流量而言整个代码可能无关紧要……
AnoE

72
我实际上是通过一个非常老的编译器进入嵌入式和超低功耗的,这个编译器几乎不知道优化的含义,并且即使函数调用很重要,也仍然相信我,这绝不是寻找优化的第一位。即使在这种特殊领域,在这种情况下,代码质量也是第一位。
蒂姆(Tim)

2
@Mehrdad即使在这种情况下,如果代码中没有其他与优化相关的内容,我也会感到惊讶。在对代码进行性能分析时,我发现事情比函数调用要重得多,这就是寻找优化的地方。一些开发人员为一两个未优化的LOC感到疯狂,但是当您对SW进行概要分析时,您意识到设计的意义远不止于此,至少对于代码的最大部分而言。当您发现瓶颈时,您可以尝试对其进行优化,它会比低级任意优化(例如编写大函数以避免调用开销)产生更大的影响。
蒂姆(Tim)

8
好答案!您的最后一点应该是第一位:在确定优化位置之前,始终进行概要分析
CJ丹尼斯

56

函数调用开销完全取决于语言以及所优化的级别。

在极低的级别上,如果函数调用导致分支预测错误或CPU缓存未命中,则函数调用甚至更多,因此虚拟方法调用可能会非常昂贵。如果您已经编写了汇编程序,那么您还将知道需要一些额外的说明来保存和恢复调用周围的寄存器。“足够聪明”的编译器能够内联正确的函数以避免这种开销是不正确的,因为编译器受语言语义的限制(尤其是围绕诸如接口方法分派或动态加载的库之类的功能)。

从较高的角度来看,Perl,Python,Ruby之类的语言在每个函数调用中都会做大量记账工作,这使它们的成本相对较高。通过元编程,情况变得更糟。我曾经只是通过在非常热的循环中提升函数调用来加快Python 3x的速度。在对性能至关重要的代码中,内联帮助函数可以产生明显的效果。

但是绝大多数软件对性能的要求不是那么严格,以至于您会注意到函数调用的开销。在任何情况下,编写简洁,简单的代码都会有回报:

  • 如果您的代码不是性能至关重要的代码,则这将使维护更加容易。即使在性能至关重要的软件中,大多数代码也不会成为“热点”。

  • 如果您的代码对性能至关重要,那么简单的代码将使您更容易理解代码并发现优化机会。通常,最大的胜利不是来自内联函数之类的微优化,而是来自算法的改进。或用不同的措辞:不要更快地完成相同的事情。找到一种减少工作的方法。

注意,“简单代码”并不意味着“分解为一千个小函数”。每个函数还引入了一些认知上的开销– 推理更多抽象代码更加困难。在某些时候,这些微小的功能可能做得很少,以至于不使用它们会简化您的代码。


16
一个非常聪明的DBA曾经告诉我“规范化直到感到痛苦,然后对规范化直到没有伤害为止”。在我看来,它可以改写为“提取方法,直到遇到麻烦为止,然后再进行内联直到没有麻烦为止”。
RubberDuck

1
除认知开销外,调试器信息中还存在符号性开销,通常最终二进制文件中的开销是不可避免的。
Frank Hileman '17

关于智能编译器-他们可以这样做,但并非总是如此。例如,jvm可以基于运行时配置文件以非常便宜/免费的陷阱来内联事物,以实现不常见的路径或内联多态函数,对于这些内联多态函数只有给定方法/接口的一种实现,然后在动态加载新的子类时将其调用优化为适当的多态运行。但是,是的,在许多语言中,这些事情是不可能实现的,即使在jvm中,多数情况下它也不合算,或者在一般情况下是不可能的。
Artur Biesiadowski

19

关于性能调整代码的几乎所有格言都是阿姆达尔定律的特例。阿姆达尔定律的简短幽默的陈述是

如果程序的一部分占用了5%的运行时,而您对该程序进行了优化,以使其现在占用了%的运行时,则整个程序的运行速度只会提高5%。

(将运行时间降低到零百分比是完全可行的:当您坐下来优化大型复杂程序时,您很可能会发现它至少将部分运行时花费在了根本不需要做的事情上

这就是为什么人们通常不担心函数调用成本的原因:无论它们有多昂贵,通常整个程序在调用开销上只花费其运行时的一小部分,因此加速它们并没有太大帮助。

但是,如果有一个技巧可以使所有函数调用更快,那么这个技巧可能是值得的。编译器开发人员花费大量时间来优化函数“序言”和“结语”,因为这样做会使使用该编译器编译的所有程序受益,即使每个程序只有一点点。

而且,如果您有理由相信某个程序在进行函数调用时花费大量的运行时间,那么您应该开始考虑其中某些函数调用是否不必要。以下是一些经验法则,可帮助您了解何时应该执行此操作:

  • 如果一个函数的每次调用运行时少于一毫秒,但是该函数被调用了数十万次,则应该内联它。

  • 如果程序的概要文件显示成千上万个功能,而它们中的任何一个都不花费超过运行时的0.1%左右,则函数调用的开销合计可能很大。

  • 如果您有“ 千层面代码 ”,其中有很多抽象层,除了分发到下一层几乎没有任何工作,而所有这些层都是通过虚拟方法调用实现的,那么很有可能CPU浪费了间接分支管道停顿的时间很多。不幸的是,唯一的解决方法是摆脱一些层,这通常很难。


7
只是要提防在嵌套循环深处完成的昂贵工作。我优化了一个功能,并获得了运行速度快10倍的代码。那是在探查者指出罪魁祸首之后。(一次
又一次地

“不幸的是,解决此问题的唯一方法是摆脱某些层,这通常很难。” -这在很大程度上取决于您的语言编译器和/或虚拟机技术。如果您可以修改代码以使编译器更容易内联(例如,通过使用final适用于Java的类和方法,或者virtual使用C#或C ++中的非方法),则可以通过编译器/运行时消除间接寻址,您可以如果不进行大规模重组,我们将看到收获。正如@JorgWMittag指出的那样,在无法证明优化是...的情况下,JVM甚至可以内联...
Jules

...有效,因此尽管存在分层,但很可能是在您的代码中进行的。
Jules

@Jules虽然确实JIT编译器可以执行推测性优化,但这并不意味着此类优化统一应用的。特别是关于Java,我的经验是,开发人员文化偏爱堆积在层之上的层,从而导致极深的调用堆栈。有趣的是,这导致了许多Java应用程序的呆滞,肿胀的感觉。这种高度分层的体系结构不利于JIT运行时,无论这些层在技术上是否可内联。JIT并不是可以自动解决结构问题的灵丹妙药。
阿蒙(Amon)

@amon我对“ lasagna代码”的经验来自于大型C ++应用程序,其代码可追溯到1990年代,当时深嵌套的对象层次结构和COM成为时尚。C ++编译器做出了极大的努力,以消除此类程序中的抽象惩罚,仍然可能会看到,他们在间接分支流水线停顿上花费了大量的挂钟运行时(以及I缓存未命中的另一个重要块) 。
zwol

17

我将对此报价提出质疑:

在编写可读代码和编写性能代码之间几乎总是要权衡取舍。

这是一个令人误解的陈述,并且可能是一种危险的态度。在某些特定情况下,您必须进行权衡,但通常这两个因素是独立的。

一个必要的权衡的例子是当您拥有简单的算法而不是更复杂但性能更高的算法。哈希表实现显然比链表实现更复杂,但是查找会更慢,因此您可能不得不为了性能而牺牲简单性(这是可读性的一个因素)。

关于函数调用开销,根据算法和语言的不同,将递归算法转换为迭代可能会具有显着的优势。但这又是非常特定的情况,通常,函数调用的开销可以忽略不计或被优化。

(某些动态语言(如Python)确实存在大量的方法调用开销。但是,如果性能成为问题,则可能一开始就不应该使用Python。)

可读代码的大多数原理-一致的格式,有意义的标识符名称,适当且有用的注释等对性能没有影响。还有一些(例如使用枚举而不是字符串)也具有性能优势。


5

在大多数情况下,函数调用开销并不重要。

但是,内联代码的最大好处是在内联之后优化新代码

例如,如果您使用常量参数调用函数,那么优化器现在可以在内联调用之前将参数折叠到无法折叠的位置。如果参数是函数指针(或lambda),则优化器现在也可以内联对该lambda的调用。

这是为什么虚函数和函数指针没有吸引力的一个重要原因,因为除非实际函数指针一直一直折叠到调用位置,否则您根本无法内联它们。


5

假设性能对于您的程序确实很重要,并且确实有很多调用,那么根据调用类型的不同,成本仍然可能无关紧要。

如果被调用的函数很小,并且编译器能够内联它,那么代价实际上将为零。现代的编译器/语言实现具有JIT,链接时间优化和/或模块系统,旨在在有益时最大程度地内联函数。

OTOH,函数调用没有明显的代价:函数调用的单纯存在可能会抑制调用之前和之后的编译器优化。

如果编译器无法推断被调用函数的功能(例如,其虚拟/动态调度或动态库中的函数),则可能不得不悲观地假设该函数可能有任何副作用-引发异常,修改全局状态,或更改通过指针看到的任何内存。编译器可能必须将临时值保存到后台存储器,并在调用后重新读取它们。它无法在调用周围重新排序指令,因此它可能无法向量化循环或将多余的计算提升到循环外。

例如,如果您在每次循环迭代中不必要地调用函数:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

编译器可能知道它是一个纯函数,然后将其移出循环(在此示例这样的糟糕情况下,甚至将偶然的O(n ^ 2)算法固定为O(n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

然后甚至可以使用Wide / SIMD指令重写循环以一次处理4/8/16个元素。

但是,如果您在循环中添加了对一些不透明代码的调用,即使该调用不执行任何操作且本身非常便宜,则编译器也必须假设最坏的情况-该调用将访问一个指向与s更改相同的内存的全局变量它的内容(即使它const在您的函数中,也可以不在const其他任何地方),从而使优化无法实现:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

这份旧报纸可能会回答您的问题:

小盖伊·刘易斯·斯蒂尔。麻省理工学院AI实验室 AI实验室备忘录AIM-443。1977年10月。

抽象:

Folklore指出,GOTO语句“便宜”,而过程调用则“昂贵”。这个神话很大程度上是由于语言实现设计不当造成的。考虑了这个神话的历史增长。讨论了理论思想和现有的实现方式,这颠覆了这个神话。结果表明,过程调用的无限制使用允许很大的时尚自由度。特别是,任何流程图都可以编写为“结构化”程序,而无需引入额外的变量。GOTO语句和过程调用的困难在于抽象编程概念和具体语言构造之间的冲突。


12
我非常怀疑old会回答“函数调用成本在现代编译器中是否仍然重要”这一问题。
科迪·格雷

6
@CodyGray我认为编译器技术自1977年以来应该已经发展起来。因此,如果可以在1977年使函数调用变得便宜,那么我们现在应该能够做到。所以答案是否定的。当然,这假设您使用的是体面的语言实现,可以执行函数内联之类的事情。
Alex Vong

4
@AlexVong依靠1977年的编译器优化就像依赖石器时代的商品价格趋势。一切都改变了太多。例如,乘法曾经被便宜的操作所取代。目前,它的价格要昂贵得多。虚拟方法调用比以前要昂贵得多(内存访问和分支错误预测),但是通常可以对其进行优化,甚至可以内联虚拟方法调用(Java一直在这样做),因此成本是正好为零。1977
。– maaartinus

3
正如其他人指出的,不仅仅是编译器技术的变化使过去的研究无效。如果编译器在微体系结构基本保持不变的情况下继续改进,那么本文的结论将仍然有效。但是那没有发生。如果有的话,微体系结构的变化远不止编译器。相对来说,以前快的东西现在慢了。
科迪·格雷

2
@AlexVong要更精确地描述使该纸张过时的CPU更改:早在1977年,主内存访问是一个CPU周期。如今,即使是对L1(!)高速缓存的简单访问也具有3至4个周期的延迟。现在,函数调用在内存访问中非常繁琐(创建堆栈帧,保存返回地址,保存用于局部变量的寄存器),这很容易将单个函数调用的成本提高到20个或更多周期。如果您的函数仅重新排列其参数,并且可能添加另一个常量参数以传递给直通,那么这几乎是100%的开销。
cmaster

3
  • 在C ++中,请注意设计用于复制参数的函数调用,默认值为“按值传递”。由于保存寄存器和其他与堆栈框架有关的东西而导致的函数调用开销可能会因对象的意外复制(可能非常昂贵)而无法承受。

  • 在放弃高度分解的代码之前,应研究与堆栈框架相关的优化。

  • 大多数时候,当我不得不处理一个缓慢的程序时,我发现进行算法更改比内联函数调用产生的速度要快得多。例如:另一位工程师重做了一个解析器,该解析器填充了map-of-maps结构。作为其一部分,他从一个映射中删除了一个缓存索引到一个逻辑关联的索引。这是一个很好的代码健壮性,但是由于对所有将来的访问进行了哈希查找,而不是使用存储的索引,导致程序变慢了100倍,从而使程序无法使用。分析表明,大部分时间都花在了散列函数上。


4
第一条建议有些陈旧。从C ++ 11开始,移动成为可能。特别是,对于需要在内部修改其参数的函数,按值获取参数并就地对其进行修改可能是最有效的选择。
MSalters

@MSalters:我认为您特别误解了“进一步”或其他内容。传递副本或引用的决定是在C ++ 11之前做出的(据我所知您知道)。
phresnel

@phresnel:我认为我做对了。我所指的特殊情况是您在调用方中创建一个临时项,其移至一个参数,然后在被调用方中对其进行修改。这是不可能的前C ++ 11,如C ++ 03不能/不会结合一个非const引用到一个临时..
MSalters

@MSalters:那我在第一次阅读它时误解了你的评论。在我看来,您的意思是,在C ++ 11之前,如果要修改所传递的值,则按值传递不是一件事。
phresnel

“移动”的到来最有助于返回对象,这些对象在函数中比在外部构造起来更方便,并且可以通过引用传递。在此之前,从函数返回对象会调用副本,这通常是昂贵的举动。那不涉及函数参数。我仔细地在注释中加上了“设计”一词,因为必须明确给予编译器许可以“移入”函数自变量(&&语法)。我已经习惯了“删除”副本构造函数,以找出这样做有价值的地方。
user2543191

3

就像其他人所说的那样,您应该首先衡量程序的性能,并且在实践中可能不会发现任何不同。

不过,从概念上讲,我认为我将清除一些与您的问题混为一谈的内容。首先,您问:

函数调用成本在现代编译器中是否仍然重要?

注意关键字“功能”和“编译器”。您的报价与众不同:

请记住,方法调用的成本可能很高,具体取决于语言。

这是在面向对象的意义上谈论方法

尽管“功能”和“方法”通常可以互换使用,但是它们的成本(您要询问的)和编译时(即您所提供的上下文)的成本是有所不同的。

特别是,我们需要了解静态调度动态调度。我暂时将忽略优化。

在像C这样的语言中,我们通常使用static dispatch调用函数。例如:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

当编译器看到该调用时foo(y),它知道该foo名称指的是哪个函数,因此输出程序可以直接跳转到该foo函数,这非常便宜。这就是静态调度的意思。

另一种选择是动态调度,其中编译器知道正在调用哪个函数。作为示例,下面是一些Haskell代码(因为等效的C语言很杂乱!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

在这里,bar函数正在调用其参数f,该参数可以是任意值。因此,编译器不能只编译bar为快速跳转指令,因为它不知道跳转到哪里。相反,我们为其生成的代码bar将取消引用f以找出其指向的功能,然后跳转到该功能。这就是动态调度的意思。

这两个例子都是针对函数的。您提到了method,可以将其视为动态调度函数的一种特殊样式。例如,下面是一些Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

y.foo()调用使用动态调度,因为它fooy对象中查找属性的值,并调用找到的所有内容;它不知道y会有class A,或者A该类包含一个foo方法,所以我们不能直接跳转到它。

好,那是基本思想。需要注意的是静态调度比动态调度快,无论我们是否编译或解释; 其他所有条件都一样。无论哪种方式,取消引用都会产生额外的费用。

那么,这如何影响现代的,优化的编译器?

首先要注意的是,静态分配可以得到更大程度的优化:当我们知道我们要跳转到哪个函数时,可以执行内联之类的事情。使用动态调度,我们不知道要等到运行时才跳,所以我们可以做的优化不多。

其次,在某些语言中,可以推断一些动态调度将结束跳转到的位置,从而将它们优化为静态调度。这使我们可以执行其他优化,例如内联等。

在上面的Python示例中,这种推论是完全没有希望的,因为Python允许其他代码覆盖类和属性,因此很难推论在所有情况下都适用的内容。

如果我们的语言允许我们施加更多限制,例如通过使用注释限制yA,那么我们可以使用该信息来推断目标函数。在具有子类化的语言(几乎是所有具有类的语言!)中,这实际上是不够的,因为y实际上可能具有不同的(子)类,因此我们需要Java final注释之类的额外信息才能确切知道将调用哪个函数。

Haskell不是OO语言,但是我们可以f通过内联bar静态分派)到main,代替foo来推断的值y。由于fooin 的目标main是静态已知的,因此该调用将被静态分派,并且可能会内联并完全被优化(由于这些函数很小,编译器更可能内联它们;尽管我们通常不能指望它们) )。

因此,成本下降为:

  • 语言是静态还是动态调度您的呼叫?
  • 如果是后者,那么该语言是否允许实现使用其他信息(例如类型,类,注释,内联等)来推断目标?
  • 静态调度(推断或其他方式)的优化程度如何?

如果您使用的是“非常动态”的语言,并且具有很多动态分配,并且编译器无法使用任何保证,那么每次调用都会产生成本。如果您使用的是“非常静态”的语言,那么成熟的编译器将产生非常快速的代码。如果介于两者之间,则可能取决于您的编码样式以及实现的聪明程度。


我不同意调用闭包(或某些函数指针)(如您的Haskell示例)是动态调度。动态调度涉及一些计算(例如,使用某些vtable)来获得该关闭,因此比间接调用的开销更大。否则,很好的答案。
巴西尔·斯塔林凯维奇

2

是的,错过分支预测在现代硬件上的成本要比几十年前高,但是编译器在优化此功能方面变得更加聪明。

例如,考虑一下Java。乍一看,函数调用开销在这种语言中应该特别占优势:

  • JavaBean约定使微小的功能得到广泛应用
  • 函数默认为虚拟,通常是
  • 编译单位是类;运行时支持随时加载新类,包括覆盖先前单态方法的子类

受到这些做法的惊吓,普通的C程序员会预测Java必须比C慢至少一个数量级。而20年前,他是对的。但是,现代基准测试将惯用的Java代码放在等效C代码的百分之几之内。那怎么可能?

原因之一是现代JVM内联函数调用是理所当然的事情。它使用推测性内联来做到这一点:

  1. 新加载的代码无需优化即可执行。在此阶段中,对于每个调用站点,JVM都会跟踪实际调用了哪些方法。
  2. 一旦代码被确定为性能热点,运行时将使用这些统计信息来识别最可能的执行路径,并内联该代码,并在不进行推测性优化的情况下以条件分支为前缀。

也就是说,代码:

int x = point.getX();

被重写为

if (point.class != Point) GOTO interpreter;
x = point.x;

当然,运行时足够聪明,只要未分配点,就可以进行此类型检查;如果调用代码知道该类型,则可以取消它。

总而言之,即使Java都管理自动方法内联,也没有内在的理由解释为什么编译器不支持自动内联,并且没有任何理由这样做,因为内联在现代处理器上非常有用。因此,我几乎无法想象有任何现代主流编译器不了解这种最基本的优化策略,并且除非有其他证明,否则将假定有能力做到这一点的编译器。


4
“没有编译器不支持自动内联的内在原因” –存在。您已经谈到了JIT编译,它相当于自我修改的代码(由于安全性,操作系统可能会阻止该代码)以及执行自动配置文件引导的完整程序优化的能力。用于允许动态链接的语言的AOT编译器了解不足,无法虚拟化和内联任何调用。OTOH:AOT编译器有时间去优化它可以做的一切,JIT编译器只有时间去关注热点上的廉价优化。在大多数情况下,这使JIT处于劣势。
阿蒙(Amon)

2
告诉我一个操作系统,因为“安全性”而阻止运行Google Chrome(V8在运行时将JavaScript编译为本机代码)。同样,内联AOT并不是一个内在的原因(它不是由语言决定的,而是由您为编译器选择的体系结构确定的),并且尽管动态链接会禁止AOT跨编译单元内联,但它并不禁止编译内进行联大部分通话发生的单位。实际上,在使用动态链接的语言比Java少使用的语言中,有用的内联可以说容易得多。
meriton

4
值得注意的是,iOS阻止了非特权应用程序的JIT。Chrome或Firefox必须使用Apple提供的网络视图,而不是自己的引擎。好的一点是,AOT与JIT是实现级别的选择,而不是语言级别的选择。
阿蒙(Amon)

@meriton Windows 10 S和视频游戏机操作系统也倾向于阻止第三方JIT引擎。
Damian Yerrick

2

请记住,方法调用的成本可能很高,具体取决于语言。在编写可读代码和编写性能代码之间几乎总是要权衡取舍。

不幸的是,这高度依赖于:

  • 编译器工具链,包括JIT(如果有),
  • 域。

首先,性能优化的第一定律是配置文件优先。在许多领域中,软件部分的性能与整个堆栈的性能都不相关:数据库调用,网络操作,OS操作,...

这确实意味着软件的性能是完全无关紧要的,即使它不能改善延迟,优化软件也可以节省能源和硬件(或节省移动应用程序的电池),这很重要。

但是,这些通常通常不会被视而不见,并且通常算法的改进会大大超越微观优化。

因此,在进行优化之前,您需要了解要进行的优化...以及是否值得。


现在,关于纯软件性能,工具链之间的差异很大。

函数调用有两个成本:

  • 运行时间成本
  • 编译时间成本。

运行时间成本相当明显;为了执行功能调用,必须进行一定量的工作。例如,在x86上使用C,函数调用将需要(1)将寄存器溢出到堆栈中,(2)将参数推入寄存器,执行调用,然后(3)从堆栈中恢复寄存器。请参阅此调用约定摘要以了解所涉及的工作

此寄存器溢出/恢复需要很短的时间(数十个CPU周期)。

通常预计,与执行该功能的实际成本相比,此成本是微不足道的,但是某些模式在这里适得其反:吸气剂,受简单条件保护的功能等。

因此,除了解释器外,程序员还希望他们的编译器JIT将优化不必要的函数调用。尽管这种希望有时可能不会见效。因为优化器不是魔术。

优化器可以检测到函数调用是微不足道的,并内联该调用:本质上是在调用站点上复制/粘贴函数的主体。这并不总是一个很好的优化(可能会引起膨胀),但总的来说是值得的,因为内联公开了context,并且context支持更多优化。

一个典型的例子是:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

如果func是内联的,那么优化器将意识到该分支永远不会被采用,并优化callvoid call() {}

从这个意义上讲,通过从优化器隐藏信息(如果尚未内联),函数调用可能会抑制某些优化。虚函数调用对此尤其内,,因为去虚拟化(证明最终在运行时调用哪个函数)并不总是那么容易。


总而言之,我的建议是首先写清楚,避免过早的算法悲观化(立方复杂度或更快的咬伤),然后仅优化需要优化的内容。


1

“请记住,根据语言的不同,方法调用的成本可能会很高。在编写可读代码和编写性能代码之间几乎总是要取舍。”

考虑到高性能现代编译器行业的丰富性,如今在什么条件下该引用语句仍然有效?

我只是要说永远不要。我相信报价单会随便扔掉。

当然,我并不是在说完整的事实,但我不太在乎诚实。就像在那部Matrix电影中,我忘了它是1还是2或3-我认为那是性感的意大利女演员和大瓜的那一部(除了第一个,我真的不喜欢)。甲骨文女士对基努·里维斯说:“我只是告诉你你需要听的东西,”或者类似的意思,这就是我现在想要做的。

程序员不需要听这个。如果他们有使用分析器的经验,并且引用在某种程度上适用于他们的编译器,那么他们将已经知道这一点,并且将通过了解它们的分析输出,以及通过测量来了解为什么某些叶子调用是热点的正确方法,来学习此方法。如果他们没有经验并且从未分析过他们的代码,这是他们需要听到的最后一件事,那就是他们应该开始迷信地破坏他们的代码编写方式,直到发现热点之前都将它们内联,以期希望它能够变得更有表现。

无论如何,为了获得更准确的响应,这取决于。某些条件条件已经在一些很好的答案中列出了。仅选择一种语言的可能条件本身已经非常庞大,例如C ++,必须在虚拟调用中进入动态调度,何时可以对其进行优化,以及在何种条件下可以使用编译器甚至是链接器,并且已经保证了详细的响应,更不用说尝试了。用每种可能的语言解决条件,并在那里编译。但我要在最上面加上“谁在乎?” 因为即使在光线跟踪等性能至关重要的领域中工作,我也要开始做的最后一件事是在进行任何测量之前手动插入方法。

我确实相信有些人对建议您在测量之前绝对不要进行任何微优化感到过分热情。如果将参考计数的局部性优化视为微优化,那么我经常确实会从一开始就以面向数据的设计心态在某些我认为对性能至关重要的领域(例如,光线跟踪代码)开始应用此类优化,因为否则,我知道在这些领域工作多年后,我将不得不立即重写大部分内容。除非我们像二次时间一样线性讨论,否则针对缓存命中而优化数据表示通常可以具有与算法改进相同的性能改进。

但是我从来没有见过在测量之前就开始进行内联的充分理由,尤其是因为探查器很擅长揭示可能从内联中受益的内容,而不是在揭示不被内联中可能受益的方面(并且如果没有内联函数调用是一种罕见的情况,它提高了icache对热代码的引用的局部性,有时甚至允许优化器为常见的执行路径做得更好。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.