我最近看到了这条推文:
为了方便以后阅读,我仅将 C++ 代码粘贴在此处:
inline int square( int n )
{
int k = 0;
while ( true )
{
if( k == n*n )
return k;
k++;
}
};
int main()
{
return square(10);
}
这被编译为
main:
mov eax, 100
ret
不知何故,clang 能够评估这个square
函数,其中包括while (true)
。
这让我非常惊讶,因为while (true)
应该立即发出危险信号,表明循环可能无法终止。
因此,这似乎意味着,要么 clang 已经解决了停机问题,要么 clang 可能会陷入无限循环并且永远无法完成编译。
那么,内部到底发生了什么?
\endgroup
7
6 个回答
6
因为这是 Clang 所做的优化,所以您可以使用逐个优化过程来探索它。我已打开“优化管道查看器”,将“选项”设置为包含“转储完整模块”,并将“过滤器”更改为“隐藏无关紧要的通道”(因此它会隐藏所有不会更改所考虑的 IR 的通道)。我不会描述获得相关结果所涉及的所有通道,只会描述对代码进行重大更改的通道。
第一个有趣的过程是“LICMPass on while.cond”;这会检测到n * n
在循环的每次迭代中都是相同的,并将计算移到循环之前。这使得 LLVM IR 类似于(请注意,由于 C 没有 phi 节点,因此语义无法清晰映射):
inline int square( int n )
{
int k = 0;
int mul = n * n;
while ( true )
{
if( k == mul )
return k;
k++;
}
};
下一个有趣的过程是“LoopRotatePass on while.cond”。这会进一步重新排列循环,看起来有点像:
inline int square( int n )
{
int k = 0;
int mul = n * n;
while ( k != mul )
{
k++;
}
return k;
};
因为确定退出循环的唯一方法是为k == mul
真,然后它立即返回k
。
然后,经过一些较小的传递来准备 LLVM IR 以触发此优化之后,“IndVarSimplifyPass on while.cond”会删除循环,因为它可以看到这k
是一个感应变量,并将代码更改为如下所示:
inline int square( int n )
{
while ( false )
{
}
int mul = n * n;
return mul;
};
从这里开始,更多的传递被清理square
为inline int square( int n ) { return n * n; }
;然后“InlinerPass on (main)”用替换对square
in 的main
调用(10 * 10)
,并进行常量折叠以将其转换为常量100
。其余的重要传递只是从 LLVM IR 转换为 LLVM 的 x86-64 机器代码的内部表示,准备输出。
\endgroup
1
-
\begingroup
每个人的答案都很棒,但是这个答案通过逐步完成 LLVM 优化过程最贴切地回答了我的问题(我特别询问了 clang 内部发生了什么)。
\endgroup
–
|
造成这种情况的原因主要有两个:停机问题是半可判定的(即,有时我们可以解决它),而c++ 标准允许 llvm 作弊并假装它解决了停机问题,即使它没有
如何解决停机问题
(注意:本节内容有些牵强;我稍后可能会使其更加严格,但我认为它本身就提供了很好的直觉)
停机问题表明我们永远无法制作一个程序,它接受另一个程序并说“是,这停止”或“不,它不停止”。但我们实际上并不需要这样的东西。相反,我们想要的是一个程序,它接受另一个程序并说“是,这停止”或“不,它不停止”或“这太复杂了;我不知道”。这很容易实现(您可以一直说“不知道”),但我们希望有一些策略来提供更有用的结果。以下是一种行之有效的特定策略
如果我们可以确定一个归纳数据类型的实例,其值在每次循环迭代中都会减少,那么我们就可以确保循环终止。为什么?因为最终,归纳数据类型将“触底”。在那时,没有办法进一步减少它。但为了继续进行下一次循环迭代,我们必须减少它。这意味着我们不能继续进行下一次循环迭代;无论是在那时还是在那之前,我们都必须终止循环
这里的减少量在哪里?首先将“int”换成“unsigned”;整数可以归纳定义,但顺序会变得混乱,这会让事情变得更加混乱,而且这里本来就没有负数。自然数更容易处理,数值顺序可以满足我们的需要。现在,每次迭代减少的数量是 n*n – k。在任何给定的迭代中,它要么是零,要么是非零。如果 n*n – k == 0,则 n*n == k,因此循环终止。否则,我们继续进行下一次循环迭代,k 增加 1,因此 n*n – k 减少 1
值得再次强调归纳类型在这里所起的作用。在不可变语言中,链表是归纳的,因此对链表的迭代总会终止。另一方面,在可变语言(如 c)中,对链表的迭代不一定会终止,因为列表中可能有循环
如何欺骗标准化人员为您解决停机问题
请参阅中的 intro.progress 部分。这实际上表明,编译器可以简单地假设你所展示的循环总是会终止,如果你编写了一个不会终止的循环,那是你自己的错
\endgroup
5
-
2\begingroup
“在不可变语言中,链表是归纳的,因此对链表的迭代总会终止。” – 这并不一定如此 – Haskell 是一种不可变语言,但对列表的迭代并不总是终止。
\endgroup
– -
\begingroup
此外,虽然这不是 clang 的做法,但部分解决停机问题的另一种方法是运行循环,比如说一个小时。如果它停止了,你就得到了结果。如果它没有停止,或者它做了任何有副作用的事情,那就说你不知道(在这种情况下,内联代码而不是结果)。标准中没有任何内容说编译器必须很快。
\endgroup
–
-
\begingroup
我所说的“运行循环”并不是指将未优化的代码编译到目标架构并在那里运行。原则上,编译器可以在某种沙箱中执行代码,可能类似于它评估静态常量表达式的方式,和/或它计算模板的方式(众所周知,它可以做循环可以做的任何事情,直到达到实现限制)。中断不停止的程序的能力对于我们这些有时编写有错误的代码的人来说至关重要 😉
\endgroup
–
-
\begingroup
@psmears 没错,但是一个遍历无限列表的程序很可能会因为懒惰而终止。当你懒惰的时候,整个计算都是不同的
\endgroup
– -
\begingroup
是的,这不是问题 – 只是指出即使使用不可变列表也可能会得到循环(原则上,即使在非惰性语言中,这也是可能的,尽管我不知道有任何语言支持它……)
\endgroup
–
|
Clang 既没有解决停机问题,也没有永远评估循环。它只是在编译过程中优化了一次循环。
让我们像优化编译器一样编译代码
这里混合了各种优化,因此请尽量使其变得非常简单。
首先,square(int n)
用 调用n = 10
。这很重要,因为10
是一个常量,所以编译器现在可以为这个特定的调用创建此函数的副本。这称为,也是 鼓励的inline
。特化函数现在是:
inline int square_10() // Const parameter removed
{
int k = 0;
while ( true )
{
if( k == 10*10 ) // Const value replaced
return k;
k++;
}
};
这里,10*10
被简单优化为100
。剩下的代码不多了(这在后面很重要)。
有一个k
初始化为 0 的局部变量,还有一个while(true)
循环,只有两个语句。一个是if
,另一个是 的局部增量k
。从后者开始,编译器可以将 的范围推断为k
所有正数(直到达到未定义的行为)。
内部编译的代码现在在内存中大致表示为:
inline int square_10()
{
// k = $ALL_POSITIVE_INT (compiler internal state)
while ( true )
if( k == 100 )
return k;
};
这也可以通过两个步骤轻松优化。首先,if
最终将为真,因为100
在范围内k
,因此return
可以达到。当return
达到时,k
被限制为100
。
没有其他指令会改变结果,因此该代码唯一可能的效果被优化为:
inline int square_10()
{
return 100;
}
没有对 的调用square(int n)
,因此永远不会生成“正常”函数。代码现在简化为返回常量的内联函数。它被内联为:
int main()
{
return 100;
}
好吧,这很好,但是让我们测试一下上面的一些假设。
常数参数和乘法消元
您可以尝试用原始示例中的 ,您将看到编译器不再将代码优化为常量。10
getchar()
它将消除循环,但保留mul
,因为现在函数结果实际上取决于函数参数:返回仅对于常量参数是常量。
范围检查和循环消除
您可以进一步 k++
,k = getchar()
您将看到编译器不会消除循环(如跳转、je
和 所观察到的jne
)。
它还将保持在imul
左右,因为编译器不能假设任何k
超出初始零的值。
优化编译器只关心会产生影响的代码
putchar(0);
在 内的任何位置插入while
都会禁用循环消除。putchar(0);
在 之前插入 会while
产生一些额外的汇编,而putchar(0);
在 之后插入 则while
不会产生任何效果,因为它被上述相同的优化检测为死代码。
TL:DR;
函数特化、常量传播、常量折叠、范围检查、代码不变分析和死代码消除的混合可以生成这些结果。无需评估。
\endgroup
6
-
3\begingroup
在您的getchar()
示例中,编译器仍然会执行此处感兴趣的优化,即用单个乘法指令替换循环。
\endgroup
– -
2\begingroup
‘int k = 0 .. INT_MAX’ ‘由于 k 的范围,if 始终为真,因此始终可以返回。’这完全是胡说八道
\endgroup
– -
2\begingroup
由于无限循环是 UB,并且循环中只有一个出口,因此编译器可以假设该出口是可到达的,并推断条件为真。
\endgroup
– -
1\begingroup
@Falco 不确定为什么这么多答案都错了。没有必要假设任何关于无限循环的事情(事实上,它们在 C 中不是 UB,并且适用相同的优化)。这很简单 – 编译器可以假设k
在某个点总是达到 100,因为它单调增加(并且整数溢出是 UB,但即使k
是无符号的,也可以由于环绕而做出相同的推论)。
\endgroup
– -
\begingroup
@DanM. c 中的一些无限循环是“ub”(但不是这个);请参阅标准第 6.8.5p6
\endgroup
–
|
这里有很多关于归纳类型和内联的讨论,但是代码中的优化可以通过更简单的方法实现。
让我们看看这个函数
inline int square( int n )
{
int k = 0;
while ( true )
{
if( k == n*n )
return k;
k++;
}
};
它只有一种返回方式 – 如果k == n*n
。如果为真,它将返回k
,因此编译器可以将代码转换为返回n*n
– 这是一个更简单的表达式,因为它在循环期间是恒定的(与 不同k
)
inline int square( int n )
{
int k = 0;
while ( true )
{
if( k == n*n )
return n*n; // <-- !!
k++;
}
};
现在,编译器处于不同的场景中。它知道它将返回什么值,但不知道它是否会返回。幸运的是,它可以假设这一点!当有一个“简单”*循环时,它永远运行是未定义的行为。现在编译器有两个关键事实:
- 循环返回
n*n
,从函数开始就保持不变,n
从未改变 - 循环必须返回**
因此,可以简单地将函数改为
inline int square( int n )
{
return n*n;
}
现在,如果无限循环不被视为未定义,它必须证明循环总是终止。它可以使用之前讨论过的归纳逻辑相当简单地做到这一点:
n*n
必须始终为正,并且处于int
k
从零开始并增加到 int 的整个正值范围- 因此,k在某一点必须相等
n*n
- 通过这种稍微复杂的逻辑,它可以进行与之前相同的转换
获取程序集的最后一步是最终内联此函数并使用提供的常量值,但这并不重要:)
* “简单”意味着不做任何外界可见的事情,比如 IO 或易失性访问。
** 出于清晰的考虑,C 编译器通常不会以这种方式优化循环,因为如果无限循环消失,就会造成混淆。它的真实逻辑可能更接近于第二个循环优化,但以第一种方式进行优化仍然完全合法
\endgroup
3
-
\begingroup
k++
“未定义行为”的说法是错误的。请注意,按照这种逻辑,即使没有该行,编译器也会以完全相同的方式优化代码,而这显然不是它所做的。
\endgroup
– -
1\begingroup
@leftaroundabout:我相信有些编译器确实会尝试识别没有循环变量或循环变量为常量的情况,并且出于对程序员的尊重,可能会决定不优化此类循环。但除非循环终止,否则编写此类循环仍然是 UB,并且编译器无论如何都有权应用优化。
\endgroup
–
-
\begingroup
@leftaroundabout 它的意思是“这些是编译器可以遵循的优化规则”,但可能不太清楚。我添加了澄清
\endgroup
–
|
这不是问题的答案,因为它与 Clang 无关,但我们还在,并且还支持断言过程始终终止的指令。
除了编译时求值之外,它对于目标重新排序也很有用。在纯声明性语言中,这种转换几乎总是正确的:
p(), q() -> q(), p()
(即“调用p
然后调用q
”可以重新排序)。唯一不正确的情况是如果调用q
可以无限循环。
当然,还有其他情况需要考虑;比如,也许p
和q
都可能引发异常。但如果您正在设计语言,您可以简单地认为在这种情况下引发任一异常都是有效的语义。
\endgroup
|
语言规范可以通过两种方式让编译器避免解决停机问题,从而实现原本简单而有用的优化:
-
语言规范可能允许将操作推迟到可以观察到其副作用为止,并且如果永远不会观察到其副作用,则将操作永远推迟(实际上完全跳过),并且指定代码段内的分支具有单个出口,该出口可从该段内的所有代码静态到达,而不必将其本身视为副作用。
-
语言规范可能会指定,如果程序收到的输入会导致其陷入无副作用的无限循环,则实现可能会以任意方式运行。
前一种方法将允许后者实现的大多数有用的优化,但允许人类或自动代码验证器推断程序不变量。后一种方法如果不证明程序将对所有可能的输入终止,则无法证明任何不变量,但允许编译器同时应用两种优化,从而避免做出艰难的决定,即是否省略循环或利用在循环存在时建立的后置条件。
恕我直言,第二种方法对于大多数类型的任务来说都是适得其反的,因为编译器能够提高正确代码性能的唯一情况是:
-
程序员可以证明程序会在所有可能的输入下终止,但编译器却不能。
-
不需要程序维护内存安全或任何其他可能导致程序陷入无限循环的输入不变量。
如果程序包含任何有副作用的循环,而这些循环有可能无法终止,那么确保不变量得到遵守的唯一方法就是在循环中添加虚拟副作用或添加人为的退出条件,这两种情况都意味着,即使循环是无用的,编译器也不允许省略循环,或者编译器省略循环的权利不取决于无限循环是否具有定义的行为。
因此,虽然第一种语义方法下允许的优化可以在正确的程序中有效地使用,但更广泛的语义下允许的优化主要在处理错误程序时有效。
\endgroup
|
之所以将其保留为注释,是因为我没有源代码,但编译器可能会假设没有副作用的无限循环,因为那将是 C 中的 UB。此外,
if (a == b) return a;
和if (a == b) return b;
显然是等价的。编译器可以假设循环退出,并且 return 语句是退出的唯一方式,因此编译器将循环优化为仅 return 语句。\endgroup
–
它可能做的最简单的事情是评估循环直到某个固定的迭代次数,并且如果迭代次数过多则停止尝试优化。另一种可能性是它根本不评估循环,而是注意到
return
函数中的唯一值是k
条件保护的值k == n * n
。因此,如果函数返回任何内容,那么它必须返回n * n
,并且据我所知,C++ 编译器可以假设无副作用循环终止,即使它们无法证明这一点。\endgroup
–
“解决停机问题”需要一种算法,该算法可以完美准确地确定每个可能的循环是否终止。能够检测某些循环是否终止的算法并非不可能。在 clang 的情况下,绝大多数可能的循环代码无法像这样优化,因此停机问题完好无损。
\endgroup
–
我的意思是,你当然可以坐下来写一个证明来证明这个循环终止,尽管它被写成
while(true)
。你可以在有限的时间内做到这一点,而不必真正模拟循环。没有什么可以阻止编译器应用与你相同的逻辑。但你们都没有解决停机问题。\endgroup
–
实际上,您可以使用来查看 LLVM 是如何逐个执行的。这样您就可以准确地看到每个优化器过程从原始未优化的 LLVM IR 到略显奇怪的机器指令表示所进行的转换。
\endgroup
–
|