编译器能否进行数学推导或证明?

16

我开始学习函数式编程语言,例如 HaskellML,大部分练习都会展示以下内容:

   foldr (+) 0 [ 1 ..10]

相当于

   sum = 0
     for( i in [1..10] ) 
         sum += i

这让我想到,为什么编译器不能知道这是等差数列,并使用O(1)公式进行计算呢? 特别是对于没有副作用的纯FP语言而言? 同样适用于

  sum reverse list == sum list

假设a + b = b + a成立且定义了“反转”,编译器/语言能否自动证明它?


3
FP语言是λ演算的扩展,就像C等过程性语言是图灵机的扩展一样。在λ演算中,我们有λ项、λ抽象和λ应用。通过评估λ应用,我们只需进行β规约即可。类似地,像Haskell这样的FP语言也使用规约来评估应用程序(Hugs解释器会显示规约数量)。因此,FP语言基于“λ演算的代数”,而不是你在学校里学习的代数。这就是为什么FP编译器不证明数学定理和优化的原因。 - Aadit M Shah
1
我相信Coq是一种用于证明数学定理的函数式编程语言。然而它不是图灵完备的。不用说它也不基于λ演算。 - Aadit M Shah
4
没有解释的话,你的说法让人感到困惑。Lambda立方体有几个顶点,Haskell只是其中之一;Coq和Agda是其他一些顶点。此外,请不要被图灵完备性迷惑 - 拥有图灵完备语言并不是目标。目标是拥有能够编写有用程序的语言。编写悖论程序是没有用处的(除了学术用途)。 - Sassa NF
1
你可以在一些函数式语言中证明这些陈述,但你需要比Haskell更强的类型系统(以便编译器推断计算规律)。然后a+b=b+a不再是“给定”的,它可以从(+)和Nat的定义中证明。类似地,可以证明原始列表的任何排列产生相同的总和。然后可以在需要a+b但只有b+a可用的地方使用这个事实。 - Sassa NF
1
这些是非常特殊的情况,在实际编码中并不经常出现。实际代码往往对更复杂的序列进行折叠,其中不存在闭合形式,或者肯定没有在编译时已知的形式。因此,编译器实现者不值得花时间编写这样的转换。 - Tom Ellis
显示剩余3条评论
6个回答

21
编译器通常不会尝试自动证明这种类型的事情,因为它很难实现。除了将逻辑添加到编译器中以将一个代码片段转换为另一个代码片段之外,您还必须非常小心,只有在确实安全时才尝试执行 - 例如,通常存在许多“副条件”需要担心。例如,在上面的示例中,某人可能已经编写了类型类Num(因此是+运算符)的实例,其中a + b不是b + a。然而,GHC确实具有rewrite rules,您可以将其添加到自己的源代码中,并可用于涵盖您上面列出的一些相对简单的情况,特别是如果您不太关心副条件。例如,我没有测试过,您可能会使用以下规则之一来处理上面的一个示例:
{-# RULES
  "sum/reverse"    forall list .  sum (reverse list) = sum list
    #-}

请注意在reverse list周围的括号 - 你在问题中写的实际上是(sum reverse) list,这会导致类型检查失败。
编辑:
由于你正在寻找官方来源和研究指针,我列出了一些。显然,证明否定是很困难的,但没有人给出一个通常会这样做的通用编译器的例子,这本身可能是相当有力的证据。
  • 正如其他人所指出的,即使是简单的算术优化在浮点数上也是非常危险的,编译器通常有标志来关闭它们 - 例如Visual C++gcc。即使是整数运算也不总是明确的,人们偶尔会就如何处理溢出等问题发生大争论

  • 正如Joachim所指出的,循环中的整数变量是应用稍微复杂的优化的一个地方,因为实际上可以获得显着的收益。Muchnick的书可能是这个主题的最佳通用来源,但它不便宜。维基百科关于强度降低的页面可能是任何一种标准优化的介绍,同时还提供了相关文献的参考。

  • FFTW是一个例子,它内部执行各种数学优化。其中一些代码是作者专门为此编写的定制编译器生成的。这是值得的,因为作者具有特定领域的优化知识,而且在库的特定上下文中既值得努力又安全。

  • 人们有时使用模板元编程编写“自我优化的库”,这些库可能依赖于算术恒等式,例如Blitz++Todd Veldhuizen的博士论文有一个很好的概述。

  • 如果你深入到玩具和学术编译器的领域,那么各种各样的事情都会发生。例如我自己的博士论文是关于编写低效函数程序以及解释如何优化它们的小脚本。其中许多例子(参见第6章)依赖于应用算术规则来证明底层优化。

此外,值得强调的是,最近的几个例子都是针对代码的特定部分(例如对特定库的调用)应用的专业优化,这些优化被认为是值得的。正如其他答案所指出的那样,编译器要在整个程序中搜索所有可能应用优化的地方是太昂贵了。我上面提到的 GHC 重写规则是编译器暴露给各个库使用的通用机制的一个很好的例子,以最适合它们的方式使用。

11

答案

不,编译器不会做那种事情。

一个原因

对于你的例子来说,这样做甚至是错误的:由于没有给出类型注释,Haskell编译器将推断出最通用的类型,这将是

foldr (+) 0 [ 1 ..10]  :: Num a => a

和相似的

(\list -> sum (reverse list)) :: Num a => [a] -> a

而且正在使用的类型的 Num 实例可能无法满足您建议的转换所需的数学规律。编译器首先应避免改变程序的含义(即语义)。

更加实际的是:编译器很少能检测到这种大规模的转换,因此实现它们不值得。

一个例外

请注意,线性转换在循环中是非常明显的例外。大多数编译器会重新编写

for (int i = 0; i < n; i++) {
   ... 200 + 4 * i ...
}

for (int i = 0, j = 200; i < n; i++, j += 4) {
   ... j ...
}

或类似的东西,因为这种模式在处理数组的代码中经常出现。


5
你心中所想的优化,即使有单态类型的存在也可能不会被实现,因为这涉及到太多可能性和大量的知识。例如,在这个例子中:
sum list == sum (reverse list)

编译器需要了解或考虑以下事实:
  • sum = foldl (+) 0
  • (+) 是可交换的
  • 反转列表是列表的置换
  • 当x是可交换的常数c时,foldl x c l会为所有的l的置换产生相同的结果。
这些似乎都是微不足道的。当然,编译器可以查找sum的定义并将其内联。它可能要求(+)是可交换的,但请记住,对于编译器来说,+只是另一个没有附加含义的符号。第三点需要编译器证明一些关于reverse的非平凡性质。
但重点是:
  1. You don't want to perform the compiler to do those calculations with each and every expression. Remember, to make this really useful, you'd have to heap up a lot of knowledge about many, many standard functions and operators.
  2. You still can't replace the expression above with True unless you can rule out the possibility that list or some list element is bottom. Usually, one cannot do this. You can't even do the following "trivial" optimization of f x == f x in all cases

     f x `seq` True
    

考虑到,

f x = (undefined :: Bool, x)

那么。
f x `seq` True    ==> True
f x == f x        ==> undefined

说到你的第一个例子,稍微修改一下针对单态性:
 f n = n * foldl (+) 0 [1..10] :: Int

可以想象,通过将表达式移出其上下文并用常量名称替换它,可以优化程序,就像这样:

const1 = foldl (+) 0 [1..10] :: Int
f n = n * const1

这是因为编译器可以看出这个表达式必须是常量。

我相信简单的流程分析应该能够做得更多。由于列表是使用常量变量创建的,因此它可以简单地分配一个列表而不创建它。另外,由于fold中的所有参数都是常量,所以它应该能够在编译时运行foldl而不是在运行时运行它。 - Loïc Faure-Lacroix
@LoïcFaure-Lacroix 这需要编译器具有比它实际拥有的更多关于 foldl+enumFromThenTo 的知识。例如,它必须知道计算 foldl (+) 0 [1..10] 是可以的,但计算 foldl (+) 0 [1..maxBound] 则不行。 - Ingo

3
正如其他人所指出的那样,即使在Haskell中,您的简化方式也不清楚。例如,我可以定义以下内容:
newtype NInt = N Int
instance Num NInt where
  N a + _ = N a
  N b * _ = N b
  ... -- etc

现在 sum . reverse :: Num [a] -> a 不等于 sum :: Num [a] -> a,因为我可以将它们专门化为 [NInt] -> NInt,其中显然不满足 sum . reverse == sum

这是围绕优化“复杂”操作存在的一般性张力之一——要成功证明某些东西可以进行优化,您实际上需要相当多的信息。这就是为什么在语法级别上存在的编译器优化通常是单态的并与程序的结构相关——通常是这样一个简化的领域,以至于优化“不可能”出错。即使如此,由于领域永远不会完全如此简化和为编译器所知,这也经常是不安全的。

以流融合为例,这是一种非常流行的“高级”语法优化。在这种情况下,编译器提供了足够的信息来表明流融合可以发生,并且基本上是安全的,但即使在这个典型的例子中,我们也需要避开非终止的概念。

那么,需要什么才能将 \x -> sum [0..x] 替换为 \x -> x*(x + 1)/2?编译器需要内置数字和代数的理论知识。这在 Haskell 或 ML 中不可能,但在依赖类型语言(如 Coq、Agda 或 Idris)中变得可能。在那里,您可以指定像这样的东西:

revCommute :: (_+_ :: a -> a -> a) 
            -> Commutative _+_ 
            -> foldr _+_ z (reverse as) == foldr _+_ z as

然后,理论上讲,告诉编译器按照revCommute重新编写。这仍然很困难和棘手,但至少我们有足够的信息。需要明确的是,我在上面写了一些非常奇怪的内容,一个依赖类型。该类型不仅取决于内联引入类型和参数名称的能力,还取决于您语言中的整个语法结构“在类型层面上”的存在。

然而,我刚才所写的与您在Haskell中所做的事情之间存在很多差异。首先,为了形成一个基础,使得这样的承诺可以被认真对待,我们必须放弃一般递归(因此我们已经不必担心像流融合那样的非终止问题)。我们还必须有足够的结构来创建像承诺Commutative _+_这样的东西——这可能取决于在语言的标准库中建立运算符和数学的整个理论,否则您将需要自己创建。最后,即使要表达这些理论的丰富类型系统也会给整个系统增加很多复杂性,并且抛弃了您今天所知道的类型推断。

但是,鉴于所有这些结构,我将永远无法为在NInt上定义的_+_创建一个承诺Commutative _+_,因此我们可以确定foldr (+) 0 . reverse == foldr (+) 0实际上确实成立。

但现在我们需要告诉编译器如何执行该优化。对于流融合来说,编译器规则只有在我们编写了某些完全正确的句法形式时才会启动,以便“清晰地”成为优化redex。同样的限制也适用于我们的sum . reverse规则。实际上,我们已经失败了,因为

foldr (+) 0 . reverse
foldr (+) 0 (reverse as)

不匹配。由于我们可以证明一些关于 (.) 的规则,它们“显然”是相同的,但这意味着现在编译器必须调用两个内置规则来执行我们的优化。
最终,为了实现你所说的自动优化,你需要一个非常智能的优化搜索,以便在已知法律集合上进行搜索。
因此,我们不仅增加了整个系统的复杂性,需要大量基础工作来构建一些有用的代数理论,并失去了图灵完备性(这可能不是最糟糕的事情),而且只有一个棘手的承诺,除非我们在编译期间执行指数级痛苦的搜索,否则我们的规则甚至无法触发。
噫。
今天存在的妥协往往是,有时候我们对正在编写的内容具有足够的控制,以大多数情况下确信可以执行某个显然的优化。这就是流融合的体制,它需要许多隐藏类型、精心编写的证明、利用参数性和挥手之前的手势,才能成为社区信任并运行其代码的东西。
而且它甚至不总是触发。为了解决这个问题,看看Vector的源代码,其中包含所有指定Vector流融合优化应启动的常见情况的规则pragma。
所有这些都不是对编译器优化或依赖类型理论的批评。两者都非常令人难以置信。相反,这只是增强引入这种优化所涉及的权衡的放大。这不是轻率地做出的决定。

3
你所描述的看起来像是超级编译。在你的情况下,如果表达式具有单态类型,比如Int(而不是多态的Num a => a),编译器可以推断出表达式foldr (+) 0 [1 ..10]没有外部依赖,因此它可以在编译时进行评估并被55替换。然而,据我所知,目前没有主流编译器执行这种优化。
(在函数式编程中,“证明”通常与不同的东西相关联。在具有依赖类型的语言中,类型足够强大,可以表达复杂的命题,然后通过Curry-Howard对应程序成为这些命题的证明。)

也许没有函数式语言编译器。gcc和MSVC都将OP帖子中的求和循环折叠为常量。当然,任何矢量化编译器都会进行严格的代码转换以寻找矢量化机会。虽然曾经这些是高价超级计算机的异类编译器的专属领域,但今天它们已经司空见惯了。 - Gene

1
有趣的事实:给定两个任意的公式,它们是否在相同的输入下产生相同的输出?这个简单问题的答案是不可计算的!换句话说,数学上不可能编写一个计算机程序,在有限的时间内总是能够给出正确答案。
鉴于这一事实,也许并不令人惊讶,没有人拥有一个编译器,能够神奇地将每个可能的计算转化为其最有效的形式。
此外,这不是程序员的工作吗?如果你经常需要算术序列的总和,以至于它是性能瓶颈,为什么不自己编写更有效的代码呢?同样地,如果你真的想要斐波那契数列(为什么?),使用O(1)算法。

确实!而且,如果你想知道一个列表的总和是否与它的反转相同,再想一想。 - Ingo
@Ingo 这将取决于您正在求和的数据类型是否实现了可交换和结合的加法运算符。 - MathematicalOrchid

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接