在C++中调用函数会产生多少开销?

75
很多文献都谈论使用内联函数来“避免函数调用的开销”,但我没有看到可量化的数据。函数调用的实际开销是多少,也就是我们通过内联函数能获得多少性能提升?

投票选择“过于宽泛”来关闭。这个问题实际上无法合理地回答。这取决于编译器、热点检测、CPU(缓存、推测执行、分支目标缓冲等)还有许多其他因素。 - Sebastian Mach
15个回答

51

在大多数架构中,函数调用的成本包括将所有寄存器保存到堆栈中(或者部分保存,或者不保存),将函数参数推入堆栈(或者放入寄存器中),增加堆栈指针并跳转到新代码的开头。然后当函数完成时,您必须从堆栈中恢复寄存器。这个网页描述了各种调用约定所涉及的内容。

现在,大多数C++编译器都足够聪明,可以为您内联函数。inline关键字只是对编译器的提示。有些编译器甚至会跨翻译单位进行内联,如果他们认为这有帮助。


10
在x86架构中(以及许多其他架构中),不需要备份所有寄存器,因为它们预计会被函数调用更改。在x86上,C调用约定通常不保留eax、ecx和edx寄存器。 - Evan Teran
1
将所有函数参数推入堆栈是C ABI。C ++作为标准的一部分不指定特定的ABI(不像C)。因此,允许每个编译器根据需要进行优化。因此,大多数C ++编译器不会将所有参数推入堆栈。 - Martin York
9
C的ABI不是标准的一部分,因为标准是与架构无关的,而ABI取决于架构。 C的标准化ABI由操作系统或芯片制造商完成,使其可以用作基础交换和粘合语言。 BeOS有一个C ++ ABI。 - wnoise
1
是的,对于Itanium,英特尔提供了C++ ABI。 - MSalters

37

我对一个简单的自增函数进行了简单的基准测试:

inc.c:

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

main.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 上运行10亿次迭代,我得到了以下结果:

  • 使用内联版本时,用时1.4秒
  • 使用正常链接版本时,用时4.4秒

(看起来可能会波动多达0.2秒,但我懒得计算正确的标准偏差,也不关心它们)

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

我测量的最快速度大约为0.3ns,这意味着一个函数调用的成本约为9个原始操作,非常简单地说。

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


有趣。我想知道当你使用函数时是否由于缓存未命中导致的。 - Bawenang Rukmoko Pardian Putra

12

有技术层面和实际层面两种回答。实际上,这个问题永远不会成为问题,在极少数情况下,唯一的方法是通过实际的分析测试。

你所提到的技术层面的答案通常并不相关,因为编译器进行了优化。但如果你仍然感兴趣,可以参考Josh的详细描述。

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


9
您的问题是没有绝对真相的问题之一。一个普通函数调用的开销取决于三个因素:
1. CPU。x86、PPC和ARM CPU的开销差异很大,即使您只使用一个架构,Intel Pentium 4、Intel Core 2 Duo和Intel Core i7之间的开销也会有相当大的差异。即使在相同时钟速度下,即使两者运行相同的缓存大小、缓存算法、内存访问模式和调用操作码的实际硬件实现等因素也可能明显影响开销。
2. ABI(应用二进制接口)。即使使用相同的CPU,通常也存在不同的ABI,它们指定了函数调用如何传递参数(通过寄存器、通过堆栈或通过两者的组合)以及堆栈帧初始化和清除发生的位置和方式。所有这些都对开销产生影响。不同的操作系统可能为相同的CPU使用不同的ABI;例如,Linux、Windows和Solaris可能都使用不同的ABI来支持相同的CPU。
3. 编译器。严格遵循ABI仅在函数在独立代码单元之间调用时才很重要,例如,如果应用程序调用系统库的函数或用户库调用另一个用户库的函数。只要函数是“私有的”,不可见于某个库或二进制文件之外,编译器可以“欺骗”。它可能不严格遵循ABI,而是使用导致更快的函数调用的快捷方式。例如,它可能通过寄存器传递参数,而不是使用堆栈,或者如果不是真正必要,它可能完全跳过堆栈帧设置和清除。
如果您想了解上述三个因素的特定组合(例如,在Linux上使用GCC的Intel Core i5),您唯一的方法是基准测试两个实现之间的差异,一个使用函数调用,另一个将代码直接复制到调用者中;这样你可以强制进行内联,因为inline语句只是一个提示,不总是导致内联。
然而,真正的问题在于:确切的开销是否真的很重要?有一件事是确定的:函数调用始终具有开销。它可能很小,也可能很大,但肯定存在。无论它多么小,如果在性能关键部分经常调用某个函数,则开销都会在某种程度上起作用。内联很少使您的代码变慢,除非您过度使用它;它会使代码变得更大。今天的编译器非常擅长自行决定何时进行内联,因此您几乎永远不必为此费心。
个人而言,我在开发过程中完全忽略内联,直到我有一个更或多或少可用的产品,我可以对其进行分析,只有在分析告诉我某个函数真的经常被调用并且也在应用程序的性能关键部分中被调用时,我才会考虑强制内联此函数。
到目前为止,我的答案非常通用,它适用于C、C++和Objective-C。最后,让我谈谈特别是关于C++的一些事情:虚方法是双重间接函数调用,这意味着它们具有比普通函数调用更高的函数调用开销,而且它们不能被内联。非虚方法可能会被编译器内联或不内联,但即使它们没有被内联,它们仍然比虚方法快得多,因此除非你真的打算覆盖它们或让它们被覆盖,否则你不应该使方法成为虚方法。

8

开销的大小取决于编译器、CPU 等因素。内联时百分比的开销取决于你要内联的代码。唯一的方法是对你自己的代码进行两种方式的分析 - 这就是为什么没有一个明确的答案。


5

对于非常小的函数,内联是有意义的,因为函数调用的(小)成本相对于函数体的(非常小)成本来说是显著的。但对于大多数超过几行的函数,内联并不能带来很大的优势。


5
值得指出的是,内联函数会增加调用函数的大小,任何增加函数大小的因素都可能对缓存产生负面影响。如果你正处于边界上,“再来一个薄荷巧克力”内联代码可能会极大地影响性能。
如果你正在阅读警告“函数调用成本”的文献,我建议它可能是过时的材料,不反映现代处理器的情况。除非你处于嵌入式世界中,C作为“可移植汇编语言”的时代已经基本过去了。过去十年(比如)芯片设计师的许多创意都涉及各种低级复杂性,可能与“过去”的工作方式截然不同。

2
有一个很棒的概念叫做“寄存器阴影”,它允许通过寄存器(在CPU上)传递(最多6个?)值,而不是通过堆栈(内存)。此外,根据所使用的函数和变量,编译器可能会决定不需要框架管理代码!
此外,即使是C++编译器也可以进行“尾递归优化”,即如果A()调用B(),并且在调用B()之后,A只返回,则编译器将重用堆栈帧!
当然,这一切都可以做到,只要程序遵守标准的语义(请参见指针别名及其对优化的影响)。

1
你描述的优化不是“尾递归优化”,而是真正的优化... 但是尾递归优化是当递归函数发生在函数末尾或“尾部”时,可以将递归函数转换为循环函数的优化。 - Evan Teran
3
两者实际上都是“尾递归优化”:尾递归只是进行自身调用的特殊情况下的尾调用。 - Simon Buchan
@EvanTeran 这绝对是尾调用优化。编译器正在优化函数末尾的调用 - Brennan Vincent

2
现代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

在小型(可内联)函数或类中,开销非常小。

以下示例有三个不同的测试,每个测试都会运行多次并计时。结果始终等于几千分之一单位时间的顺序。

#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次迭代(包括简单、六个函数调用和三个对象调用)时,使用这种半复杂的工作负载时输出如下:
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

除了速度快几个数量级之外,每种情况都会产生相同的结果。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接