现代编译器优化如何将递归转换为返回常量?

时间:2019-02-14 08:50:18

标签: c++ recursion gcc g++ compiler-optimization

当我使用g ++编译以下简单的递归代码时,汇编代码只会返回i,就好像g ++可以像人类一样做一些代数技巧。

int Identity(int i) {
  if (i == 1)
    return 1;
  else
    return Identity(i-1)+1;
}

我不认为此优化是关于尾递归的, 显然,g ++至少必须做以下两件事:

  1. 如果我们传递一个负值,那么此代码将陷入无限循环,因此g ++消除此 bug 是否有效?
  2. 虽然可以枚举从1到INT_MAX的所有值,然后g ++可以告诉该函数应返回i,但显然g ++使用了更智能的检测方法,因为编译过程非常快。因此,我的问题是,编译器优化如何做到这一点?

如何复制

% g++ -v
gcc version 8.2.1 20181127 (GCC)

% g++ a.cpp -c -O2 && objdump -d a.o
Disassembly of section .text:
0000000000000000 <_Z8Identityi>:
   0:   89 f8                   mov    %edi,%eax
   2:   c3

更新: 感谢许多人回答了这个问题。我在这里收集了一些讨论和更新。

  1. 编译器使用某种方法来知道传递负值会导致UB。也许编译器使用相同的方法进行代数技巧。
  2. 关于尾递归:根据Wikipedia,我以前的代码不是尾递归形式。我尝试了尾递归版本,gcc生成正确的while循环。但是,它不能像我以前的代码那样仅返回i。
  3. 有人指出编译器可能会尝试“证明” f(x)= x,但我仍然不知道所使用的实际优化技术的名称。我对这种优化的确切名称感兴趣,例如通用子表达式消除(CSE)或它们的某种组合,等等。

已更新+回答: 感谢下面的答案(我将其标记为有用的,并且还检查了manlio的答案),我想我知道编译器如何以一种简单的方式做到这一点。请参见下面的示例。 首先,现代gcc可以完成比尾递归更强大的功能, 因此代码被转换为如下形式:

// Equivalent to return i
int Identity_v2(int i) {
  int ans = 0;
  for (int i = x; i != 0; i--, ans++) {}
  return ans;
}
// Equivalent to return i >= 0 ? i : 0
int Identity_v3(int x) {
  int ans = 0;
  for (int i = x; i >= 0; i--, ans++) {}
  return ans;
}

(我猜想)编译器可以知道ans和我共享相同的 delta ,并且在退出循环时也知道i = 0。因此,编译器知道它应该返回i。 在v3中,我使用>=运算符,因此编译器还会为我检查输入的符号。 这可能比我猜想的要简单得多。

3 个答案:

答案 0 :(得分:7)

GCC的优化以GIMPLE格式对代码的中间表示进行传递。

使用-fdump-*选项,您可以要求GCC输出树的中间状态并发现有关执行的优化的许多细节。

在这种情况下,有趣的文件是(数字可能会有所不同,具体取决于GCC版本)

.004t.gimple

这是起点:

int Identity(int) (int i)
{
  int D.2330;
  int D.2331;
  int D.2332;

  if (i == 1) goto <D.2328>; else goto <D.2329>;
  <D.2328>:
  D.2330 = 1;
  return D.2330;
  <D.2329>:
  D.2331 = i + -1;
  D.2332 = Identity (D.2331);
  D.2330 = D.2332 + 1;
  return D.2330;
}

.038t.eipa_sra

最后一个提供递归的优化源:

int Identity(int) (int i)
{
  int _1;
  int _6;
  int _8;
  int _10;

  <bb 2>:
  if (i_3(D) == 1)
    goto <bb 4>;
  else
    goto <bb 3>;

  <bb 3>:
  _6 = i_3(D) + -1;
  _8 = Identity (_6);
  _10 = _8 + 1;

  <bb 4>:
  # _1 = PHI <1(2), _10(3)>
  return _1;
}

SSA一样,GCC在必要的基本块开始处插入称为PHI的伪函数,以合并变量的多个可能值。

这里:

# _1 = PHI <1(2), _10(3)>

其中_11还是_10的值,这取决于我们是通过块2还是块3到达这里。 / p>

.039t.tailr1

这是第一个将递归转换为循环的转储:

int Identity(int) (int i)
{
  int _1;
  int add_acc_4;
  int _6;
  int acc_tmp_8;
  int add_acc_10;

  <bb 2>:
  # i_3 = PHI <i_9(D)(0), _6(3)>
  # add_acc_4 = PHI <0(0), add_acc_10(3)>
  if (i_3 == 1)
    goto <bb 4>;
  else
    goto <bb 3>;

  <bb 3>:
  _6 = i_3 + -1;
  add_acc_10 = add_acc_4 + 1;
  goto <bb 2>;

  <bb 4>:
  # _1 = PHI <1(2)>
  acc_tmp_8 = add_acc_4 + _1;
  return acc_tmp_8;
}

处理尾部调用的同一优化还处理了通过创建累加器使尾部递归的琐碎情况。

https://github.com/gcc-mirror/gcc/blob/master/gcc/tree-tailcall.c文件的开始注释中有一个非常相似的示例:


  

该文件实现了尾递归消除。它也用来   通常分析尾调用,将结果传递到rtl级别   它们用于sibcall优化的地方。

     

除了标准的尾部递归消除外,我们还能处理最多   通过创建累加器使调用尾递归的简单案例。

     

例如以下功能

int sum (int n)
{
  if (n > 0)
    return n + sum (n - 1);
  else
    return 0;
}
  

被转化为

int sum (int n)
{
  int acc = 0;
  while (n > 0)
    acc += n--;
  return acc;
}
  

为此,我们维护了两个累加器(a_accm_acc),它们指示   当我们到达return x语句时,我们应该返回a_acc + x * m_acc   代替。它们最初分别初始化为01,   因此该函数的语义显然得以保留。如果我们是   保证累加器的值永远不变,我们   省略累加器。

     

在三种情况下函数可能如何退出。第一个是   在adjust_return_value中处理,其他两个在adjust_accumulator_values中处理   (第二种情况实际上是第三种情况的特例,我们   只是为了清楚起见,分开展示):

     
      
  1. 仅返回x,其中x不会保留任何其他特殊形状。   我们将其重写为等效于return m_acc * x + a_acc的gimple。
  2.   
  3. return f (...)(其中f是当前函数)被重写为   经典的尾递归消除方法,用于参数分配   并跳转到该功能的开头。累加器的价值   不变。
  4.   
  5. 返回a + m * f(...),其中am不依赖于对f的调用。   为了保留描述的语义,我们希望将其重写之前   这样我们终于回来了   a_acc + (a + m * f(...)) * m_acc = (a_acc + a * m_acc) + (m * m_acc) * f(...)。   即我们将a_acc增加a * m_acc,将m_acc乘以m,然后   消除对f的尾部调用。特殊情况下,值只是   通过设置a = 0m = 1获得加或乘的结果。
  6.   

答案 1 :(得分:0)

即使在无尾递归调用的情况下,

gcc仍可以对递归进行优化。我想要搜索很多常见的递归模式,然后将其转换为迭代或封闭形式的对应模式。

您可能会读this good old short page关于(不是)众所周知的gcc优化事实。

答案 2 :(得分:0)

  

如果我们传递一个负值,那么原始代码将陷入无限循环,那么对于g ++消除此错误是否有效?

递增/递减有符号整数会导致溢出/流出,这是未定义的行为(与无符号整数不同)。编译器只是简单地假设UB不会在这里发生(即,除非您使用-fwrapv,否则编译器始终假设有符号整数不会溢出/溢出)。如果是这样,那就是编程错误。