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

How can modern compiler optimization convert recursion into returning a constant?

本文关键字:转换 递归 返回 常数 编译器 优化      更新时间:2023-10-16

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

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

我认为这种优化不是关于尾部递归,显然,G 至少必须做这两件事:

  1. 如果我们传递一个负值,则此代码将属于无限循环,因此对于G 消除此 bug
  2. ,它有效
  3. 虽然可以将所有值从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在循环时生成了正确的。但是,它不能像我以前的代码一样返回我。
  3. 有人指出,编译器可能会尝试"证明" F(x)= X,但我仍然不知道所使用的实际优化技术的名称。我对这样的优化的确切名称感兴趣,例如常见的子表达消除(CSE)或某种组合或其他组合。

更新 回答:多亏了下面的答案(我标记了它有用,还检查了Manlio的答案),我想我知道编译器如何以一种简单的方式来执行此操作。请参阅下面的示例。首先,现代海湾合作委员会可以做比尾随更强大的事情,因此,代码被转换为类似的东西:

// 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中,我使用>=操作员,因此编译器还为我检查输入的符号。这可能比我猜到的要简单得多。

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)>

_1获得1的值或_10的值,具体取决于我们是通过Block 2还是块3到达此处。

.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) 当我们到达返回X语句时,我们应该返回a_acc + x * m_acc 反而。它们最初分别为01初始初始化 因此,显然保留了该功能的语义。如果是 确保累加器的价值永远不会改变,我们 省略累加器。

有三种情况该功能如何退出。第一个是 在awad_return_value中处理,其他两个在awad_accumulator_values中 (第二种情况实际上是第三种情况,我们 仅仅为了清楚起见):

  1. 只需返回x,其中x不在其余的特殊形状中。 我们将其重写为gimple等效于返回m_acc * x + a_acc
  2. 返回f (...),其中f是当前功能,在一个 经典的尾声消除方式,分配参数 并跳到功能的开始。累加器的值 不变。
  3. 返回a + m * f(...),其中am不依赖于f的呼叫。 为了保留在我们希望重写之前所描述的语义 以我们终于返回的方式 a_acc + (a + m * f(...)) * m_acc = (a_acc + a * m_acc) + (m * m_acc) * f(...)。 IE。我们通过a * m_acc增加a_acc,将m_acc乘以m和 消除对f的尾巴调用。当价值仅为特殊情况 通过设置a = 0m = 1添加或仅乘以乘以。

gcc即使在非尾巴回收调用的情况下,也能够对递归进行优化。我想搜索了许多常见的递归模式,然后翻译成其迭代或封闭形式。

您可以阅读有关(不)有关GCC的好旧页面。

如果我们传递一个负值,则原始代码将属于无限循环,因此对于G 消除此错误是否有效?

增加/减少签名的整数可能会导致溢出/不确定的行为(与无符号整数不同)。编译器只是假设UB在这里没有发生(即编译器始终假设签名的整数不会溢出/Undeflow,除非您使用-fwrapv)。如果确实如此,那是一个编程错误。