在 C++ 中调用函数有多少开销?

How much overhead is there in calling a function in C++?

提问人:Obediah Stane 提问时间:9/28/2008 更新时间:9/23/2017 访问量:54580

问:

许多文献都在讨论使用内联函数来“避免函数调用的开销”。但是,我还没有看到可量化的数据。函数调用的实际开销是多少,即我们通过内联函数实现什么样的性能提升?

C++ 函数 开销

评论

0赞 Sebastian Mach 2/9/2016
投票决定以“过于宽泛”为由关闭。这不能真正得到明智的回答。它取决于编译器、热点检测、CPU(缓存、推理执行、分支目标缓冲)以及更多因素

答:

5赞 Don Neufeld 9/28/2008 #1

对于非常小的函数,内联是有意义的,因为函数调用的(小)成本相对于函数体的(非常小的)成本是显著的。对于几行以上的大多数功能来说,这并不是一个很大的胜利。

51赞 Eclipse 9/28/2008 #2

在大多数架构上,成本包括将所有(或部分或无)寄存器保存到堆栈中,将函数参数推送到堆栈(或将它们放入寄存器中),递增堆栈指针并跳转到新代码的开头。然后,当功能完成后,您必须从堆栈中恢复寄存器。此网页对各种调用约定中涉及的内容进行了说明。

大多数 C++ 编译器现在都足够智能,可以为您内联函数。inline 关键字只是对编译器的提示。有些人甚至会在他们认为有帮助的翻译单元之间进行内联。

评论

10赞 Evan Teran 9/28/2008
在 x86(和许多其他架构)上,并非所有寄存器都需要备份,因为它们应该通过函数调用来更改。x86 上的 C 调用约定通常不保留 eax、ecx 和 edx。
1赞 Martin York 9/28/2008
将所有函数参数推送到堆栈是 C ABI。C++ 没有指定特定的 ABI 作为标准的一部分(与 C 不同)。因此,允许每个编译器根据需要进行优化。因此,大多数 C++ 编译器不会将所有参数推送到堆栈。
9赞 wnoise 9/28/2008
@Martin York:C 的 ABI 不是标准的一部分——它不可能是,标准与架构无关,而 ABI 依赖于架构。C 的标准化 ABI,允许它用作基本交换和胶水语言,由操作系统或芯片制造商完成。BeOS 有一个 C++ ABI。
1赞 MSalters 9/29/2008
是的,英特尔为安腾提供了C++ ABI。
0赞 Ash 9/28/2008 #3

每个新函数都需要创建一个新的本地堆栈。但是,只有当您在循环的每次迭代中调用函数时,才会在非常多的迭代中调用函数,这才会引起注意。

评论

3赞 Andrew Edgecombe 9/28/2008
你的意思是“新堆栈框架”,而不是“新堆栈”,是吗?
0赞 dicroce 9/28/2008 #4

对于大多数函数,在 C++ 与 C 中调用它们没有额外的开销(除非您将“this”指针视为每个函数的不必要参数。你必须以某种方式将状态传递给函数)...

对于虚函数,还有一个额外的间接级别(相当于通过 C 中的指针调用函数)......但实际上,在今天的硬件上,这是微不足道的。

评论

1赞 Mark Ransom 9/28/2008
编译器几乎不会内联虚拟函数,所以你提出了一个有争议的问题。唯一的例外是,当对象类型在编译时已知时,可以跳过间接操作。
0赞 freakish 8/20/2016
在现代 CPU 上,直接调用和通过指针调用之间没有明显的区别。虚拟函数的开销来自 vtable 查找。这很重要。
8赞 Mark Ransom 9/28/2008 #5

开销量将取决于编译器、CPU 等。开销百分比将取决于要内联的代码。知道的唯一方法是以两种方式获取您的代码并对其进行分析 - 这就是为什么没有明确的答案。

1赞 Uri 9/28/2008 #6

这里有几个问题。

  • 如果你有一个足够聪明的编译器,即使你没有指定内联,它也会为你做一些自动内联。另一方面,有很多东西是不能内联的。

  • 如果函数是虚拟的,那么你当然要付出它不能内联的代价,因为目标是在运行时确定的。相反,在 Java 中,除非您指出该方法是最终的,否则您可能需要支付此价格。

  • 根据代码在内存中的组织方式,由于代码位于其他位置,因此可能需要支付缓存未命中甚至页面未命中的成本。这最终可能会对某些应用程序产生巨大影响。

评论

1赞 MSalters 9/29/2008
对不起,虚拟位是错误的方式。如果对象位于堆栈上,则在该函数中,编译器将在编译时知道类型。因此,它可以立即解析调用,并且不需要进行 vtable 查找
0赞 Uri 10/13/2008
我不确定我是否明白这个评论的意义。显然,在没有动态调度的情况下,这不会成为问题。但是,在许多情况下,您将使用指针和潜在的多态代码,并且代码向上转换或向下转换堆栈对象的情况并不少见。
2赞 vrdhn 9/28/2008 #7

有一个很好的概念叫做“寄存器影子”,它允许通过寄存器(在 CPU 上)而不是堆栈(内存)传递(最多 6 个?)值。此外,根据其中使用的函数和变量,编译器可能只是决定不需要帧管理代码!

此外,即使是 C++ 编译器也可以执行“尾递归优化”,即如果 A() 调用 B(),并且在调用 B() 后,A 只是返回,编译器将重用堆栈帧!

当然,这一切都可以做到,只有当程序坚持标准的语义时(参见指针别名及其对优化的影响)

评论

1赞 Evan Teran 9/28/2008
你描述的优化不是“尾递归优化”,你描述的是真正的优化......但是尾递归优化是指递归函数可以更改为循环函数,因为递归发生在函数的末尾或“尾部”。
3赞 Simon Buchan 9/28/2008
两者实际上都是“尾调用优化”:尾部递归只是尾部调用的特例。
0赞 Brennan Vincent 11/1/2016
@EvanTeran这绝对是尾部调用优化。编译器正在优化函数部的调用
12赞 nedruod 9/28/2008 #8

有技术和实际的答案。实际的答案是它永远不会重要,在极少数情况下,它确实如此,您知道的唯一方法是通过实际的分析测试。

由于编译器优化,您的文献所引用的技术答案通常不相关。但如果你仍然感兴趣,乔希描述得很好。

至于“百分比”,你必须知道函数本身有多贵。除了被调用函数的成本之外,没有百分比,因为您正在与零成本操作进行比较。对于内联代码,没有成本,处理器只需移动到下一条指令。inling的缺点是代码大小较大,其成本表现方式与堆栈构建/拆卸成本不同。

5赞 Larry OBrien 10/31/2008 #9

值得指出的是,内联函数会增加调用函数的大小,任何增加函数大小的内容都可能对缓存产生负面影响。如果你正好处于一个边界,那么“再多一个薄薄的薄荷”内联代码可能会对性能产生巨大的负面影响。


如果你正在阅读关于“函数调用成本”的文献,我建议它可能是没有反映现代处理器的旧材料。除非你身处嵌入式世界,否则C语言是“可移植汇编语言”的时代基本上已经过去了。在过去十年中,芯片设计人员的大量独创性已经投入到各种低级的复杂性中,这些复杂性可能与“过去”的工作方式截然不同。

9赞 Mecki 3/4/2012 #10

你的问题是一个问题,没有答案可以称之为“绝对真理”。正常函数调用的开销取决于三个因素:

  1. CPU。x86、PPC 和 ARM CPU 的开销差异很大,即使您只使用一种架构,英特尔奔腾 4、英特尔酷睿 2 双核和英特尔酷睿 i7 之间的开销也相差很大。即使 Intel 和 AMD CPU 以相同的时钟速度运行,开销甚至可能明显不同,因为缓存大小、缓存算法、内存访问模式和调用操作码本身的实际硬件实现等因素可能会对开销产生巨大影响。

  2. ABI(应用程序二进制接口)。即使使用相同的 CPU,也经常存在不同的 ABI,用于指定函数调用如何传递参数(通过寄存器、堆栈或两者的组合)以及堆栈帧初始化和清理的位置和方式。所有这些都对开销有影响。不同的操作系统可能对同一个 CPU 使用不同的 ABI;例如,Linux、Windows 和 Solaris 可能都对同一个 CPU 使用不同的 ABI。

  3. 编译器。只有当函数在独立代码单元之间调用时,严格遵循 ABI 才有意义,例如,如果一个应用程序调用一个系统库的函数或一个用户库调用另一个用户库的函数。只要函数是“私有的”,在某个库或二进制文件之外不可见,编译器就可能“作弊”。它可能不严格遵循 ABI,而是使用导致更快函数调用的快捷方式。例如,它可能会在寄存器中传递参数而不是使用堆栈,或者如果不是真的有必要,它可能会完全跳过堆栈帧的设置和清理。

如果您想知道上述三个因素的特定组合的开销,例如,对于使用 GCC 的 Linux 上的 Intel Core i5,获取此信息的唯一方法是对两种实现之间的差异进行基准测试,一种使用函数调用,另一种将代码直接复制到调用者中;这样,您肯定会强制内联,因为内联语句只是一个提示,并不总是导致内联。

然而,这里真正的问题是:确切的开销真的重要吗?有一件事是肯定的:函数调用总是有开销的。它可能很小,也可能很大,但它肯定是存在的。而且,如果在性能关键部分中调用函数的频率足够高,那么无论它有多小,开销都会在一定程度上受到影响。内联很少会让你的代码变慢,除非你做得非常过分;不过,它将使代码更大。今天的编译器非常善于自己决定何时内联,何时不内联,因此您几乎不必为此绞尽脑汁。

就我个人而言,我在开发过程中完全忽略了内联,直到我有一个或多或少可用的产品可以进行分析,并且只有当分析告诉我,某个函数经常被调用并且也在应用程序的性能关键部分内,那么我会考虑“强制内联”这个函数。

到目前为止,我的答案非常通用,它适用于 C 和适用于 C++ 和 Objective-C。作为结束语,让我特别谈谈 C++:虚拟方法是双重间接函数调用,这意味着它们的函数调用开销高于普通函数调用,并且它们不能内联。非虚拟方法可能由编译器内联,也可能不内联,但即使它们没有被内联,它们仍然比虚拟方法快得多,因此您不应该使方法虚拟,除非您真的计划重写它们或重写它们。

2赞 doug65536 1/15/2013 #11

现代 CPU 速度非常快(显然!几乎所有涉及调用和参数传递的操作都是全速指令(间接调用可能稍微贵一些,主要是第一次通过循环)。

函数调用开销非常小,只有调用函数的循环才能使调用开销相关。

因此,当我们今天谈论(和度量)函数调用开销时,我们通常实际上是在谈论无法将常见子表达式从循环中提升出来的开销。如果一个函数在每次被调用时都必须做一堆(相同的)工作,那么编译器将能够将其“提升”出循环,如果它是内联的,则执行一次。当未内联时,代码可能会继续重复工作,你告诉它!

内联函数看起来快得不可思议,不是因为调用和参数开销,而是因为可以从函数中提取出的常见子表达式。

例:

Foo::result_type MakeMeFaster()
{
  Foo t = 0;
  for (auto i = 0; i < 1000; ++i)
    t += CheckOverhead(SomethingUnpredictible());
  return t.result();
}

Foo CheckOverhead(int i)
{
  auto n = CalculatePi_1000_digits();
  return i * n;
}

优化器可以看穿这种愚蠢行为,并执行以下操作:

Foo::result_type MakeMeFaster()
{
  Foo t;
  auto _hidden_optimizer_tmp = CalculatePi_1000_digits();
  for (auto i = 0; i < 1000; ++i)
    t += SomethingUnpredictible() * _hidden_optimizer_tmp;
  return t.result();
}

似乎调用开销不可能减少,因为它确实将函数的很大一部分从循环中提升出来(CalculatePi_1000_digits调用)。编译器需要能够证明CalculatePi_1000_digits总是返回相同的结果,但好的优化器可以做到这一点。

1赞 teeks99 2/10/2015 #12

根本没有太多的开销,特别是对于小型(可内联)函数甚至类。

以下示例有三个不同的测试,每个测试都运行多次并定时。结果总是等于一个时间单位的 1000 分之一的量级。

#include <boost/timer/timer.hpp>
#include <iostream>
#include <cmath>

double sum;
double a = 42, b = 53;

//#define ITERATIONS 1000000 // 1 million - for testing
//#define ITERATIONS 10000000000 // 10 billion ~ 10s per run
//#define WORK_UNIT sum += a + b
/* output
8.609619s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.0%)
8.604478s wall, 8.611255s user + 0.000000s system = 8.611255s CPU(100.1%)
8.610679s wall, 8.595655s user + 0.000000s system = 8.595655s CPU(99.8%)
9.5e+011 9.5e+011 9.5e+011
*/

#define ITERATIONS 100000000 // 100 million ~ 10s per run
#define WORK_UNIT sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)
/* output
8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015
*/


// ------------------------------
double simple()
{
   sum = 0;
   boost::timer::auto_cpu_timer t;
   for (unsigned long long i = 0; i < ITERATIONS; i++)
   {
      WORK_UNIT;
   }
   return sum;
}

// ------------------------------
void call6()
{
   WORK_UNIT;
}
void call5(){ call6(); }
void call4(){ call5(); }
void call3(){ call4(); }
void call2(){ call3(); }
void call1(){ call2(); }

double calls()
{
   sum = 0;
   boost::timer::auto_cpu_timer t;

   for (unsigned long long i = 0; i < ITERATIONS; i++)
   {
      call1();
   }
   return sum;
}

// ------------------------------
class Obj3{
public:
   void runIt(){
      WORK_UNIT;
   }
};

class Obj2{
public:
   Obj2(){it = new Obj3();}
   ~Obj2(){delete it;}
   void runIt(){it->runIt();}
   Obj3* it;
};

class Obj1{
public:
   void runIt(){it.runIt();}
   Obj2 it;
};

double objects()
{
   sum = 0;
   Obj1 obj;

   boost::timer::auto_cpu_timer t;
   for (unsigned long long i = 0; i < ITERATIONS; i++)
   {
      obj.runIt();
   }
   return sum;
}
// ------------------------------


int main(int argc, char** argv)
{
   double ssum = 0;
   double csum = 0;
   double osum = 0;

   ssum = simple();
   csum = calls();
   osum = objects();

   std::cout << ssum << " " << csum << " " << osum << std::endl;
}

运行 10,000,000 次迭代(每种类型:简单、6 次函数调用、3 次对象调用)的输出是使用以下半卷织工作负载:

sum += std::sqrt(a*a + b*b + sum) + std::sin(sum) + std::cos(sum)

如下:

8.485689s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (100.0%)
8.494153s wall, 8.486454s user + 0.000000s system = 8.486454s CPU (99.9%)
8.467291s wall, 8.470854s user + 0.000000s system = 8.470854s CPU (100.0%)
2.50001e+015 2.50001e+015 2.50001e+015

使用简单的工作负载

sum += a + b

给出相同的结果,只是每种情况都快几个数量级。

0赞 user377178 2/9/2016 #13

根据您构建代码的方式,将代码划分为模块和库等单元,在某些情况下可能具有深远的意义。

  1. 使用带有外部链接的动态库功能,大多数时候会强加全栈帧处理。
    这就是为什么当比较操作像整数比较一样简单时,使用 stdc 库中的 qsort 比使用 stl 代码慢一个数量级(10 倍)。
  2. 在模块之间传递函数指针也会受到影响。
  3. 同样的惩罚很可能会影响C++的虚函数以及其他函数的使用,这些函数的代码在单独的模块中定义。

  4. 好消息是,整个程序优化可能会解决静态库和模块之间的依赖关系问题。

37赞 Petr Skocik 8/7/2016 #14

我针对一个简单的增量函数做了一个简单的基准测试:

Inc.C:

typedef unsigned long ulong;
ulong inc(ulong x){
    return x+1;
}

主.c

#include <stdio.h>
#include <stdlib.h>

typedef unsigned long ulong;

#ifdef EXTERN 
ulong inc(ulong);
#else
static inline ulong inc(ulong x){
    return x+1;
}
#endif

int main(int argc, char** argv){
    if (argc < 1+1)
        return 1;
    ulong i, sum = 0, cnt;
    cnt = atoi(argv[1]);
    for(i=0;i<cnt;i++){
        sum+=inc(i);
    }
    printf("%lu\n", sum);
    return 0;
}

在我的 Intel(R) Core(TM) i5 CPU M 430 @ 2.27GHz 上运行它,给了我:

  • 1.4 秒内联版本)
  • 常规链接版本为 4.4 秒

(它似乎波动高达 0.2,但我懒得计算适当的标准差,也不关心它们)

这表明此计算机上函数调用的开销约为 3 纳秒

我测量的最快速度约为 0.3ns,因此简而言之,这表明函数调用的成本约为 9 个原始运算

对于通过 PLT(共享库中的函数)调用的函数,每次调用的开销会增加约 2ns(总时间调用时间约为 6ns)。

评论

0赞 Bawenang Rukmoko Pardian Putra 3/6/2023
有趣。我想知道是不是因为使用该功能时缓存未命中。
1赞 Jesse 9/23/2017 #15

正如其他人所说,你真的不必太担心开销,除非你追求终极性能或类似的东西。创建函数时,编译器必须将代码写入:

  • 将函数参数保存到堆栈
  • 将返回地址保存到堆栈
  • 跳转到函数的起始地址
  • 为函数的局部变量(堆栈)分配空间
  • 运行函数的主体
  • 保存返回值(堆栈)
  • 局部变量(又名垃圾回收)的可用空间
  • 跳回已保存的退货地址
  • 释放参数的保存 等。。。

但是,您必须考虑降低代码的可读性,以及它将如何影响您的测试策略、维护计划和 src 文件的整体大小影响。