为啥内联函数的效率低于内置函数?

Posted

技术标签:

【中文标题】为啥内联函数的效率低于内置函数?【英文标题】:Why does an inline function have lower efficiency than an in-built function?为什么内联函数的效率低于内置函数? 【发布时间】:2017-07-09 09:01:58 【问题描述】:

我在InterviewBit 中尝试了一个关于数组的问题。在这个问题中,我创建了一个返回整数绝对值的内联函数。但有人告诉我,我的算法在提交时效率不高。但是当我改用 C++ 库中的abs() 时,它给出了一个正确答案的结论。

这是我的函数得到了一个低效判决 -

inline int abs(int x)return x>0 ? x : -x;

int Solution::coverPoints(vector<int> &X, vector<int> &Y) 
    int l = X.size();
    int i = 0;
    int ans = 0;
    while (i<l-1)
        ans = ans + max(abs(X[i]-X[i+1]), abs(Y[i]-Y[i+1]));
        i++;
    
    return ans;

这是得到正确答案的那个 -

int Solution::coverPoints(vector<int> &X, vector<int> &Y) 
    int l = X.size();
    int i = 0;
    int ans = 0;
    while (i<l-1)
        ans = ans + max(abs(X[i]-X[i+1]), abs(Y[i]-Y[i+1]));
        i++;
    
    return ans;

为什么会发生这种情况,因为我认为内联函数是最快的,因为没有调用?还是网站有错误?如果站点是正确的,C++ abs() 使用什么比inline abs() 更快?

【问题讨论】:

您可能想通过电子邮件发送到该 InterviewBit 网站 (hello@interviewbit.com) 并报告错误。向他们发送此问题的链接,以便他们改进网站,或者(如果这不是错误)向我们发送一些调试数据(例如代码失败的测试用例)。 您对for 循环过敏吗?当您可以写出更惯用的for (int i=0; i &lt; l-1; i++) 时,为什么还要写while? (您也可以将 l-1 计算提升到循环条件之外。)另外,ans += max(...) 将是一种不错的风格。 是的,我在使用 for 循环时会出疹子,你是怎么知道的?:p 除了for 循环和+=,您应该养成使用++i 而不是后缀的习惯(尽管在这里无关紧要),并注意Stroustrup 教导您将type 而不是 variable 的指针修饰符:char* p。这实际上是他向公众介绍 C++ 的原著中的第一件事。哦,不要命名l。即使在语言中用作后缀时,也会选择大写字母以避免看起来模棱两可的字母。 您的 X 和 Y 参数不是 const。你的意思是说你可以在工作时摧毁它们? 【参考方案1】:

我不同意他们的判决。他们显然错误

在当前的优化编译器上,两种解决方案都会产生完全相同的输出。甚至,如果他们不产生完全相同,他们会产生与库代码一样高效的代码(一切都匹配可能有点令人惊讶:算法,使用的寄存器。也许是因为实际的库实现是否与 OP 的相同?)。

正如其他答案所暗示的那样,没有理智的优化编译器会在您的 abs() 代码中创建分支(如果可以在没有分支的情况下完成)。如果编译器没有优化,那么它可能没有内联库abs(),所以它也不会很快。

优化abs() 是编译器最容易做的事情之一(只需在窥视孔优化器中为其添加一个条目,然后完成)。

此外,我过去曾见过库实现,其中 abs() 被实现为非内联库函数(不过,这是很久以前的事了)。

两种实现方式相同的证明:

海合会:

myabs:
    mov     edx, edi    ; argument passed in EDI by System V AMD64 calling convention
    mov     eax, edi
    sar     edx, 31
    xor     eax, edx
    sub     eax, edx
    ret

libabs:
    mov     edx, edi    ; argument passed in EDI by System V AMD64 calling convention
    mov     eax, edi
    sar     edx, 31
    xor     eax, edx
    sub     eax, edx
    ret

叮当声:

myabs:
    mov     eax, edi    ; argument passed in EDI by System V AMD64 calling convention
    neg     eax
    cmovl   eax, edi
    ret

libabs:
    mov     eax, edi    ; argument passed in EDI by System V AMD64 calling convention
    neg     eax
    cmovl   eax, edi
    ret

Visual Studio (MSVC):

libabs:
    mov      eax, ecx    ; argument passed in ECX by Windows 64-bit calling convention 
    cdq
    xor      eax, edx
    sub      eax, edx
    ret      0

myabs:
    mov      eax, ecx    ; argument passed in ECX by Windows 64-bit calling convention 
    cdq
    xor      eax, edx
    sub      eax, edx
    ret      0

国际商会:

myabs:
    mov       eax, edi    ; argument passed in EDI by System V AMD64 calling convention 
    cdq
    xor       edi, edx
    sub       edi, edx
    mov       eax, edi
    ret      

libabs:
    mov       eax, edi    ; argument passed in EDI by System V AMD64 calling convention 
    cdq
    xor       edi, edx
    sub       edi, edx
    mov       eax, edi
    ret      

See for yourself 在 Godbolt 编译器资源管理器上,您可以在其中检查各种编译器生成的机器代码。 (链接由 Peter Cordes 友情提供。)

【讨论】:

即使您已经仔细挑选了一些编译器为内置abs() 和自定义abs 发出相同代码的情况,但无论如何我的陈述并没有错。我可以很容易地提供一些相反的例子:gcc 4.8.5 and clang 4.0 emit different code。如果您尝试编写更复杂的库函数的自定义实现,那么让编译器发出相同的代码将是一项相当重要的任务。另外我还在用 tcc,它作为一个嵌入式编译器工作得很好。 @VTT:精心挑选?问题是关于abs():“c++ abs() 使用什么比内联 abs() 更快?” @VTT:您链接的内容完全错误。在这里,您以错误的方式使用库。实际上,使用这种方式库比手写方式慢很多。提示:有 int->float->int 转换...请改用 stdlib.h 中的abs() @geze 是的,当编译器为 c++ abs() 和内联 abs() 生成相同代码时,您似乎仔细挑选了案例(来自第一篇文章)。此外,您甚至没有发布任何 Godblot 链接来验证它。而且我正在正确使用库。有一个 ::std::abs 重载采用整数类型,尽管它确实在内部处理浮点数(至少在 gcc 上它只是调用 __builtin_fabs)。 geza:Matt Godbolt 几个月前添加了 MSVC 的 CL19 x86 和 x86-64 :) 查看所有 4 个编译器,其中 @VTT 缺少 std::abs 整数重载的 #include &lt;cstdlib&gt;,在godbolt.org/g/uwtXTK。全部使用 Intel 语法; IDK 为什么您不在桌面上使用 -masm=intel 来为您的答案生成一致的 asm。【参考方案2】:

您的abs 根据条件执行分支。虽然内置变体只是从整数中删除符号位,但很可能只使用几条指令。可能的汇编示例(取自here):

cdq
xor eax, edx
sub eax, edx

cdq 将寄存器 eax 的符号复制到寄存器 edx。例如,如果它是一个正数,edx 将为零,否则,edx 将是 0xFFFFFF,表示 -1。如果它是一个正数(任何数字 xor 0 都不会改变),则与原始数字的 xor 操作不会改变。但是,当 eax 为负数时,eax xor 0xFFFFFF 产生(不是 eax)。最后一步是从 eax 中减去 edx。同样,如果 eax 为正,则 edx 为零,最终值仍然相同。对于负值,(~ eax) – (-1) = –eax 就是想要的值。

如您所见,这种方法只使用了三个简单的算术指令,根本没有条件分支。

编辑:经过一些研究,发现许多内置的 abs 实现都使用相同的方法,return __x &gt;= 0 ? __x : -__x;,这种模式显然是编译器优化的目标,以避免不必要的分支.

但是,这并不能证明使用自定义 abs 实现是合理的,因为它违反了 DRY 原则,并且没有人可以保证您的实现对于更复杂的场景和/或不寻常的平台同样适用。通常,只有在存在明确的性能问题或在现有实现中检测到某些其他缺陷时,才应考虑重写某些库函数。

Edit2:仅从 int 切换到 float 就会显示出相当大的性能下降:

float libfoo(float x)

    return ::std::fabs(x);


andps   xmm0, xmmword ptr [rip + .LCPI0_0]

还有一个自定义版本:

inline float my_fabs(float x)

    return x>0.0f?x:-x;


float myfoo(float x)

    return my_fabs(x);


movaps  xmm1, xmmword ptr [rip + .LCPI1_0] # xmm1 = [-0.000000e+00,-0.000000e+00,-0.000000e+00,-0.000000e+00]
xorps   xmm1, xmm0
xorps   xmm2, xmm2
cmpltss xmm2, xmm0
andps   xmm0, xmm2
andnps  xmm2, xmm1
orps    xmm0, xmm2

online compiler

【讨论】:

最重要的是,内置的随机数据不会出现分支预测失败,从而产生指令缓存友好的代码。 @StoryTeller:没有条件移动的分支预测失败。 没有优化编译器会为 OP 的 abs() 发出一个分支。 @VTT - 有点偏离轨道,但我会以其他方式添加评论....我是 DRY 的忠实粉丝,但当我们考虑不能打破或弯曲的规则时,我们通常会以同样糟糕的发展质量状况。 DRY 做耦合代码,也是我们需要考虑的一个平衡。 关于浮点版本:这是因为编译器使用严格的浮点运算,所以它无法优化该代码。如果您添加-ffast-math,您将获得同样好的解决方案。【参考方案3】:

如果您使用标准库版本,您的解决方案可以说是教科书上的“更干净”,但我认为评估是错误的。您的代码被拒绝并没有真正好的、正当的理由。

这是其中一种情况,有人正式正确(根据教科书),但坚持以完全愚蠢的方式知道唯一正确的解决方案,而不是接受替代解决方案并说 “...但这是最佳实践,你知道的”

从技术上讲,“使用标准库,这就是它的用途,它可能会尽可能地优化”,这是一种正确、实用的方法。尽管“尽可能优化”部分在某些情况下很可能是错误的,因为标准对某些算法和/或容器施加了一些限制。

现在,把意见、最佳实践和宗教放在一边。事实上,如果你比较这两种方法......

int main(int argc, char**)

  40f360:       53                      push   %rbx
  40f361:       48 83 ec 20             sub    $0x20,%rsp
  40f365:       89 cb                   mov    %ecx,%ebx
  40f367:       e8 a4 be ff ff          callq  40b210 <__main>
return std::abs(argc);
  40f36c:       89 da                   mov    %ebx,%edx
  40f36e:       89 d8                   mov    %ebx,%eax
  40f370:       c1 fa 1f                sar    $0x1f,%edx
  40f373:       31 d0                   xor    %edx,%eax
  40f375:       29 d0                   sub    %edx,%eax
//

int main(int argc, char**)

  40f360:       53                      push   %rbx
  40f361:       48 83 ec 20             sub    $0x20,%rsp
  40f365:       89 cb                   mov    %ecx,%ebx
  40f367:       e8 a4 be ff ff          callq  40b210 <__main>
return (argc > 0) ? argc : -argc;
  40f36c:       89 da                   mov    %ebx,%edx
  40f36e:       89 d8                   mov    %ebx,%eax
  40f370:       c1 fa 1f                sar    $0x1f,%edx
  40f373:       31 d0                   xor    %edx,%eax
  40f375:       29 d0                   sub    %edx,%eax
//

...它们会产生完全相同相同、完全相同的指令。

但即使编译器确实使用了一个比较后跟一个条件移动(它可能会在更复杂的“分支分配”中这样做,并且它会在例如min的情况下这样做/max),这可能比 bit hack 慢一个 CPU 周期左右,所以除非你这样做几百万次,否则“效率不高”的说法无论如何都是值得怀疑的。 一次缓存未命中,您将受到条件移动的一百倍的惩罚。

支持和反对这两种方法都有有效的论据,我不会详细讨论。我的观点是,因为如此琐碎、不重要的细节而拒绝 OP 的解决方案为“完全错误”,这是相当狭隘的。

编辑:

(有趣的琐事)

我只是在我的 Linux Mint 机器上尝试了,只是为了好玩,没有任何利润,它使用了一个稍旧版本的 GCC(5.4 与上面的 7.1 相比)。

由于我没有太多考虑就包括了&lt;cmath&gt;(嘿,像abs 这样的函数非常清楚属于数学,不是吗!)而不是&lt;cstdlib&gt;整数重载,结果是……令人惊讶。调用库函数远逊于单表达式包装器。

现在,为了保护标准库,如果您包含 &lt;cstdlib&gt;,那么在任何一种情况下,生成的输出都是完全相同的。

作为参考,测试代码如下:

#ifdef DRY
  #include <cmath>
  int main(int argc, char**)
  
     return std::abs(argc);
  
#else
  int abs(int v) noexcept  return (v >= 0) ? v : -v; 
  int main(int argc, char**)
  
     return abs(argc);
  
#endif

...导致

4004f0: 89 fa                   mov    %edi,%edx
4004f2: 89 f8                   mov    %edi,%eax
4004f4: c1 fa 1f                sar    $0x1f,%edx
4004f7: 31 d0                   xor    %edx,%eax
4004f9: 29 d0                   sub    %edx,%eax
4004fb: c3                      retq 

现在,显然很容易陷入无意中使用错误的标准库函数的陷阱(我自己证明了它是多么容易!)。所有这些都没有来自编译器的任何警告,例如“嘿,你知道,你在整数值上使用了double 重载(嗯,显然没有警告,这是一个有效的转换)。

考虑到这一点,可能还有另一个“理由”,为什么提供他自己的单线的 OP 并不是那么糟糕和错误。毕竟,他可能犯了同样的错误。

【讨论】:

所以你使用的库实现有一个天真的abs。所以呢?你不保证总是得到一个超级优化的版本,但你可以。这会产生巨大的差异,就像 OP 的代码一样。 问题不在于分支的额外循环。问题在于分支的额外周期+分支预测失败的额外周期+指令缓存变脏。这可能会施加一些严厉的惩罚,具体取决于数据的分布。 “bit-hack”不会。 @StoryTeller 正好相反。自定义朴素 abs 被编译器检测为等同于 abs 并替换为为库 abs 生成的相同指令。 你已经和 VTT 陷入了同样的陷阱。使用 cstdlib 代替 cmath。 标量 double 是一个常数,除了符号位之外的所有位都已设置。 bitwise-AND to clear the sign bit is how you implement absolute-value for an float/double in an SSE register。一行上的00 本身就是换行,因为movsd 很长。使用objdump -dw 禁用包装长指令。 (我使用alias disas='objdump -drwC -Mintel')。正如 geza 所说,这只是因为您忘记了 #include &lt;cstdlib&gt; 来获取 std::abs() 的整数重载。愚蠢的 C++。

以上是关于为啥内联函数的效率低于内置函数?的主要内容,如果未能解决你的问题,请参考以下文章

内联函数真的可以提高程序执行效率吗

实用技能分享,充分利用内联函数,内联汇编,内部函数和嵌入式汇编提升代码执行效率和便捷性(2021-12-17)

为啥 -O3 GCC Optimization 没有内联这个函数?

内联函数和函数重载

纯虚函数可能没有内联定义。为啥?

为啥我不能在我的类中内联函数? [复制]