为什么用循环或 switch 语句内联函数不划算?

Why is it not cost effective to inline functions with loops or switch statements?

我注意到 Google's C++ style guide 关于使用循环或 switch 语句内联函数的警告:

Another useful rule of thumb: it's typically not cost effective to inline functions with loops or switch statements (unless, in the common case, the loop or switch statement is never executed).

Other comments 在 Whosebug 上重申了这一观点。

为什么带有循环或 switch 语句(或 gotos)的函数不适合或不兼容内联。这是否适用于包含任何类型跳转的函数?它是否适用于具有 if 语句的函数?另外(这可能有点不相关),为什么不鼓励 return 值的内联函数?

我对这个问题特别感兴趣,因为我正在处理一段对性能敏感的代码。我注意到在内联包含一系列 if 语句的函数后,性能会显着下降。如果相关的话,我正在使用 GNU Make 3.81。

带有条件分支的内联函数使得 CPU 更难以准确预测分支语句,因为分支的每个实例都是独立的。

如果有多个分支语句,成功的分支预测比调用函数的成本节省了很多周期。

类似的逻辑适用于使用 switch 语句展开循环。


参考的 Google 指南没有提及任何关于函数返回值的内容,因此我假设参考在其他地方,并且需要一个不同的问题并明确引用。

虽然在您的情况下,性能下降似乎是由分支预测错误引起的,但我认为这不是 Google 风格指南提倡反对包含循环或 switch 语句的内联函数的原因。在某些用例中,分支预测器可以从内联中获益。

一个循环往往要执行几百次,所以循环的执行时间比内联节省的时间要大得多。所以性能优势可以忽略不计(参见阿姆达尔定律)。 OTOH,内联函数导致代码大小增加,这对指令缓存有负面影响。

对于switch语句,我只能猜测。理由可能是跳转表可能相当大,在代码段中浪费的内存比显而易见的要多得多。

我认为这里的关键词是成本效益。花费大量周期或内存的函数通常不值得内联。

编码风格指南的目的是告诉您,如果您正在阅读它,您不太可能在真正的编译器中添加了优化,更不可能添加了有用的优化(由其他人在CPUs 范围内的现实程序),因此不太可能猜出猜中的人。至少,不要误导他们,例如,将 volatile 关键字放在所有变量的前面。

编译器中的内联决策与 'Making a Simple Branch Predictor Happy' 关系不大。或者不那么困惑。

首先,目标 CPU 甚至可能没有分支预测。

二、具体例子:

想象一个除了内联之外没有其他优化(打开)的编译器。那么内联函数的唯一积极影响是消除了与函数调用相关的簿记(保存寄存器、设置局部变量、保存 return 地址以及跳转和跳转)。代价是在调用函数的每个位置复制代码。

在真实的编译器中,完成了许多其他简单的优化,内联决策的希望是这些优化将很好地交互(或级联)。这是一个非常简单的例子:

int f(int s)
{
 ...;
 switch (s) {
   case 1: ...; break;
   case 2: ...; break;
   case 42: ...; return ...;
 }
 return ...;
}

void g(...)
{
  int x=f(42);
  ...
}

当编译器决定内联 f 时,它会将赋值的 RHS 替换为 f 的主体。它用实际参数 42 代替形式参数 s,突然发现开关处于恒定值...所以它丢弃所有其他分支,希望已知值将允许进一​​步简化(即它们级联)。

如果你真的很幸运,所有对该函数的调用都将被内联(除非 f 在外部可见)原始 f 将从你的代码中完全消失。所以你的编译器消除了所有的簿记,并在编译时使你的代码更小。并使代码在运行时更加本地化。

如果运气不好,代码大小会增加,运行时的局部性会降低,代码运行速度也会变慢。

当它对内联循环有益时,给出一个很好的例子就比较棘手了,因为必须假设其他优化以及它们之间的相互作用。

关键是,即使您知道允许编译器更改它的所有方式,也很难预测一段代码会发生什么。我不记得是谁说的,但应该无法识别优化编译器生成的可执行代码。

我认为扩展@user1666959 提供的示例可能是值得的。我会回答提供更清晰的示例代码。 让我们考虑这样的场景。

/// Counts odd numbers in range [0;number]
size_t countOdd(size_t number)
{
    size_t result = 0;
    for (size_t i = 0; i <= number; ++i)
    {
        result += (i % 2);
    }
    return result;
}

int main()
{
    return countOdd(5);
}

如果函数不是内联的而是使用外部链接,它会执行整个循环。想象一下当你内联它时会发生什么。

int main()
{
    size_t result = 0;
    for (size_t i = 0; i <= 5; ++i)
    {
        result += (i % 2);
    }
    return result;
}

现在让我们启用循环展开优化。这里我们知道它从 0 迭代到 5,所以它可以很容易地展开,删除代码中不需要的条件。

int main()
{
    size_t result = 0;
    // iteration 0
    size_t i = 0
    result += (i % 2);
    // iteration 1
    ++i
    result += (i % 2);
    // iteration 2
    ++i
    result += (i % 2);
    // iteration 3
    ++i
    result += (i % 2);
    // iteration 4
    ++i
    result += (i % 2);
    // iteration 5
    ++i
    result += (i % 2);
    return result;
}

没有条件,它已经更快了,但这还不是全部。 i的值我们都知道了,为什么不直接传过去呢?

int main()
{
    size_t result = 0;
    // iteration 0
    result += (0 % 2);
    // iteration 1
    result += (1 % 2);
    // iteration 2
    result += (2 % 2);
    // iteration 3
    result += (3 % 2);
    // iteration 4
    result += (4 % 2);
    // iteration 5
    result += (5 % 2);
    return result;
}

更简单,但是,这些操作是constexpr,我们可以在编译时计算它们。

int main()
{
    size_t result = 0;
    // iteration 0
    result += 0;
    // iteration 1
    result += 1;
    // iteration 2
    result += 0;
    // iteration 3
    result += 1;
    // iteration 4
    result += 0;
    // iteration 5
    result += 1;
    return result;
}

所以现在编译器发现其中一些操作没有任何效果,只留下那些改变值的操作。之后,它会删除不必要的临时变量并执行尽可能多的计算,因为它可以在编译期间完成,您的代码最终为:

int main()
{
    return 3;
}