编译器可以在数学上推断/证明吗?

时间:2014-01-04 10:53:33

标签: haskell functional-programming ml

我开始学习像HaskellML这样的函数式编程语言,大多数练习都会展示如下内容:

   foldr (+) 0 [ 1 ..10]

相当于

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

这让我想到为什么编译器不能知道这是Arithmetic Progression并使用O(1)公式来计算? 特别是对于没有副作用的纯FP种语言? 这同样适用于

  sum reverse list == sum list

给定a + b = b + a 反向的定义,编译器/语言可以自动证明吗?

6 个答案:

答案 0 :(得分: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。即使是整数运算并不总是很明确,人们偶尔会有big arguments关于如何处理溢出等问题。

  • 正如Joachim所指出的,循环中的整数变量是一个应用稍微复杂的优化的地方,因为实际上有很多胜利。 Muchnick's book可能是该主题的最佳一般来源,但并不便宜。 The wikipedia page on strength reduction可能与此类标准优化中的任何一个一样好,并且对相关文献有一些参考。

  • FFTW是一个在内部进行各种数学优化的库的示例。它的一些代码是generated by a customised compiler作者专门为此目的编写的。这是值得的,因为作者具有特定领域的优化知识,这些知识在图书馆的特定环境中既值得又安全

  • 人们有时会使用模板元编程来编写“自我优化库”,这些库可能又依赖于算术身份,例如参见Blitz++Todd Veldhuizen's PhD dissertation有一个很好的概述。

  • 如果你进入玩具和学术编纂领域的各种各样的事情。例如my own PhD dissertation是关于编写低效的函数程序以及解释如何优化它们的小脚本。许多示例(参见第6章)依赖于应用算术规则来证明基础优化的合理性。

此外,值得强调的是,最后几个示例是专门的优化,仅适用于代码的某些部分(例如,调用特定的库),预计这些部分是值得的。正如其他答案所指出的那样,编译器搜索可能适用优化的整个程序中的所有可能位置实在太昂贵了。我上面提到的GHC重写规则是编译器的一个很好的例子,它为各个库提供了一种通用机制,以最适合它们的方式使用。

答案 1 :(得分: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 ...
}

或类似的东西,因为该模式经常出现在使用数组的代码中。

答案 2 :(得分:5)

即使在存在单形类型的情况下,您所考虑的优化也可能无法完成,因为有太多的可能性和所需的知识。例如,在此示例中:

sum list == sum (reverse list)

编制者需要了解或考虑以下事实:

  • sum = foldl(+)0
  • (+)是可交换的
  • 反向列表是列表的排列
  • foldl x c l,其中x是可交换的,c是常量,对l的所有排列产生相同的结果。

这一切似乎微不足道。当然,编译器很可能会查找sum的定义并将其内联。可能要求(+)是可交换的,但请记住+只是另一个符号而没有附加到编译器的含义。第三点要求编译器证明关于reverse的一些非平凡的属性。

但重点是:

  1. 您不希望执行编译器对每个表达式执行这些计算。请记住,为了使这个非常有用,你必须积累大量关于许多标准函数和运算符的知识。
  2. 您仍然无法用True替换上面的表达式,除非您可以排除列表或某个列表元素位于底部的可能性。通常,人们不能这样做。在所有情况下,您甚至无法对f x == f x进行以下“琐碎”优化

     f x `seq` True
    
  3. 对于,考虑

    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
    

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

答案 3 :(得分:3)

您所描述的内容如super-compilation。在您的情况下,如果表达式具有monomorphic类型,如Int(而不是多态Num a => a),则编译器可以推断表达式foldr (+) 0 [1 ..10]没有外部依赖关系,因此可以在编译时对其进行评估,并替换为55。但是,AFAIK目前还没有主流编译器进行这种优化。

(在函数式编程中,“证明”通常与不同的东西相关联。在dependent types类型的语言中,强大到足以表达复杂命题,然后通过Curry-Howard correspondence程序成为这些命题的证明。)

答案 4 :(得分: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取代revCommute :: (_+_ :: a -> a -> a) -> Commutative _+_ -> foldr _+_ z (reverse as) == foldr _+_ z as 需要什么?编译器需要内置数字和代数的理论。这在Haskell或ML中是不可能的,但在诸如Coq,Agda或Idris之类的依赖类型语言中变得可能。在那里你可以指定像

这样的东西
revCommute

然后,理论上,告诉编译器根据Commutative _+_重写。这仍然是困难和挑剔,但至少我们有足够的信息。要清楚,我正在写一些非常奇怪的东西,一个依赖类型。该类型不仅取决于为内联参数引入类型和名称的能力,还取决于“在类型级别”是否存在语言的整个语法。

尽管如此,我刚写的内容和你在Haskell中的内容之间存在很多差异。首先,为了形成可以认真对待这些承诺的基础,我们必须抛弃一般的递归(因此我们已经不必担心像流融合一样的非终止问题)。我们还必须有足够的结构来创建像promise Commutative _+_这样的东西---这可能取决于在语言的标准库中内置了完整的运算符和数学理论,否则你需要自己创建。最后,甚至表达这些理论所需的类型系统的丰富性为整个系统增加了很多复杂性,并且如你今天所知的那样抛弃了类型推断。

但是,鉴于所有这些结构,我永远无法为定义为_+_的{​​{1}}创建义务NInt,因此我们可以确定{ {1}}确实存在。

但现在我们需要告诉编译器如何实际执行该优化。对于流融合,编译器规则只有在我们以完全正确的语法形式编写某些内容时才会启动,以“明确”为优化重新索引。相同类型的限制适用于我们的foldr (+) 0 . reverse == foldr (+) 0规则。事实上,我们已经沉没了,因为

sum . reverse

不匹配。由于我们可以证明foldr (+) 0 . reverse foldr (+) 0 (reverse as) 的一些规则,它们“显然”相同,但这意味着现在编译器必须调用两个内置规则才能执行优化。


在一天结束时,您需要对已知法律集进行非常智能的优化搜索,以实现您所讨论的各种自动优化。

因此,我们不仅要为整个系统增加很多复杂性,需要大量基础工作来构建一些有用的代数理论,并且失去图灵完整性(这可能不是最糟糕的事情),我们也只是除非我们在编译过程中进行指数级的痛苦搜索,否则我们的规则甚至会被激怒。

布莱什。


今天存在的妥协往往是有时我们对所写的内容有足够的控制权主要确定明显的可以执行优化。这是流融合的制度,它需要很多隐藏的类型,精心编写的证据,参数化的开发,以及在社区信任到足以运行其代码之前挥手的东西。

它甚至不总是开火。有关解决该问题的示例,请查看所有RULES编译指示的(.)的来源,其中指定了Vector的流融合优化应该启动的所有常见情况。


所有这些都不是对编译器优化或依赖类型理论的批判。两者都非常不可思议。相反,它只是放大引入这种优化所涉及的权衡。不能轻易做到。

答案 5 :(得分:1)

有趣的事实:给定两个任意公式,它们是否为相同的输入提供相同的输出?这个微不足道的问题的答案是不可计算!换句话说,在数学上不可能编写一个总是在有限时间内给出正确答案的计算机程序。

鉴于这一事实,没有人能拥有能够将每个可能的计算神奇地转换为最有效的形式的编译器也就不足为奇了。

另外,这不是程序员的工作吗?如果你想要一个算术序列的总和,这通常是一个性能瓶颈,为什么不自己编写一些更有效的代码呢?同样,如果你真的想要Fibonacci数(为什么?),请使用O(1)算法。