为什么这种fibonacci尾部调用比Haskell中的纯树递归运行得更快?

为什么这种fibonacci尾部调用比Haskell中的纯树递归运行得更快?,haskell,recursion,programming-languages,fibonacci,tail-call-optimization,Haskell,Recursion,Programming Languages,Fibonacci,Tail Call Optimization,我正在尝试不理解尾部调用递归。我转换纯树递归斐波那契函数: fib 0 = 0 fib 1 = 1 fib n = fib (n-1) + fib (n-2) 要更改尾部调用版本,请执行以下操作: fib' 0 a = a fib' 1 a = 1 + a fib' n a = fib' (n-1) (fib' (n-2) a) 当我尝试这两个版本时,第二个版本似乎比第一个树递归更快,尽管我尝试使用seq在第二个版本中强制执行严格的评估 Haskell如何处理GHC内部的此类尾部呼叫?谢谢

我正在尝试不理解尾部调用递归。我转换纯树递归斐波那契函数:

fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)
要更改尾部调用版本,请执行以下操作:

fib' 0 a = a
fib' 1 a = 1 + a
fib' n a = fib' (n-1) (fib' (n-2) a)
当我尝试这两个版本时,第二个版本似乎比第一个树递归更快,尽管我尝试使用
seq
在第二个版本中强制执行严格的评估


Haskell如何处理GHC内部的此类尾部呼叫?谢谢

在GHCi交互提示下测试的代码性能可能会产生误导,因此在对GHC代码进行基准测试时,最好在使用
GHC-O2
编译的独立可执行文件中进行测试。添加显式类型签名并确保
-Wall
不会报告任何关于“默认”类型的警告也很有帮助。否则,GHC可能会选择您不想要的默认数字类型。最后,使用
标准
基准测试库也是一个好主意,因为它可以很好地生成可靠且可重复的计时结果

通过该程序以这种方式对两个
fib
版本进行基准测试:

import Criterion.Main

fib :: Integer -> Integer
fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)

fib' :: Integer -> Integer -> Integer
fib' 0 a = a
fib' 1 a = 1 + a
fib' n a = fib' (n-1) (fib' (n-2) a)

main :: IO ()
main = defaultMain
  [ bench "fib" $ whnf fib 30
  , bench "fib'" $ whnf (fib' 30) 0
  ]
使用GHC 8.6.5编译,使用
GHC-O2-Wall Fib2.hs
,我得到:

$ ./Fib2
benchmarking fib
time                 40.22 ms   (39.91 ms .. 40.45 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 39.91 ms   (39.51 ms .. 40.11 ms)
std dev              581.2 μs   (319.5 μs .. 906.9 μs)

benchmarking fib'
time                 38.88 ms   (38.69 ms .. 39.06 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 38.57 ms   (38.49 ms .. 38.67 ms)
std dev              188.7 μs   (139.6 μs .. 268.3 μs)
这里的差异非常小,但可以持续复制。
fib'
版本比
fib
版本快约3-5%

在这一点上,也许值得指出的是,我的类型签名使用了
Integer
。这也是GHC在没有显式类型签名的情况下选择的默认值。用
Int
替换这些选项将大大提高性能:

benchmarking fib
time                 4.877 ms   (4.850 ms .. 4.908 ms)
                     0.999 R²   (0.999 R² .. 1.000 R²)
mean                 4.766 ms   (4.730 ms .. 4.808 ms)
std dev              122.2 μs   (98.16 μs .. 162.4 μs)

benchmarking fib'
time                 3.295 ms   (3.260 ms .. 3.332 ms)
                     0.999 R²   (0.998 R² .. 1.000 R²)
mean                 3.218 ms   (3.202 ms .. 3.240 ms)
std dev              62.51 μs   (44.57 μs .. 88.39 μs)
这就是为什么我建议包含显式类型签名,并确保没有关于默认类型的警告。否则,如果真正的问题是循环索引使用了
Integer
,而它本可以使用
Int
,那么您可能会花费大量时间追求微小的改进。当然,在这个例子中,还有一个额外的问题,即该算法是完全错误的,因为该算法是二次的,并且线性实现是可能的,就像通常的“聪明的Haskell”解决方案:

-- fib'' 30 runs about 100 times faster than fib 30
fib'' :: Int -> Int
fib'' n = fibs !! n
  where fibs = scanl (+) 0 (1:fibs)
不管怎样,让我们切换回
fib
fib'
使用
Integer
来了解这个答案的其余部分

GHC编译器生成一种中间形式的程序,称为STG(无脊椎、无标记、G机器)。它是忠实地表示程序实际运行方式的最高级别表示。STG的最佳文档以及它如何实际转化为堆分配和堆栈帧就是本文。阅读本文时,图1是STG语言(尽管语法不同于GHC使用
-ddump STG
生成的语法),图2的第一个和第三个面板显示了如何使用eval/apply方法(与当前GHC生成的代码相匹配)评估STG。还有一篇较旧的论文提供了更多的细节(可能太多),但有点过时了

无论如何,要查看
fib
fib'
之间的差异,我们可以使用以下方法查看生成的STG:

ghc -O2 -ddump-stg -dsuppress-all -fforce-recomp Fib2.hs
获取STG输出并对其进行实质性清理,使其看起来更像“常规Haskell”,我得到以下定义:

fib = \n ->                          fib' = \n a ->
  case (==) n 0 of                     case (==) n 0 of
    True -> 0                            True -> a;
    _ ->                                 _ ->
      case (==) n 1 of                     case (==) n 1 of
        True -> 1                            True -> (+) 1 a;                 -- (6)
        _ ->                                 _ ->
          case (-) n 2 of                      case (-) n 2 of
            n_minus_2 ->                         n_minus_2 ->
              case fib n_minus_2 of                case fib' n_minus_2 a of
                y ->                                 y ->
                  case (-) n 1 of                      case (-) n 1 of
                    n_minus_1 ->                         n_minus_1 ->
                      case fib n_minus_1 of                fib' n_minus_1 y   -- (14)
                        x -> (+) x y
在这里,严格性分析已经使整个计算变得严格。这里没有创建thunks。(在STG中,只有
let
块创建thunk,而在这个STG中没有
let
块。)因此,这两个实现之间的(最小)性能差异与严格与懒惰无关

忽略
fib'
的额外参数,请注意这两个实现在结构上基本相同,除了
fib'
中第(6)行的加法操作和
fib
中第(14)行带有加法操作的case语句

要了解这两种实现之间的差异,首先需要了解函数调用
fab
被编译为伪代码:

lbl_f:  load args a,b
        jump to f_entry
        push 16-byte case continuation frame <lbl0,copy_of_arg1> onto the stack
lbl_f:  -- code block for f a b, as above:
        load args a,b
        jump to f_entry   -- f_entry will jump to lbl0 when done
lbl0:   restore copy_of_arg1, pop case continuation frame
        if return_value == True jump to lbl2 else lbl1
lbl1:   block for body1
lbl2:   block for body2
-- True -> 1                              -- True -> (+) 1 a
load 1 as return value                    load args 1,a
jump to next continuation                 jump to "+"
                                          -- Note: "+" will jump to next contination
请注意,所有函数调用,不管它们是否是尾部调用,都被编译为如下所示的跳转。当
f_entry
中的代码完成时,它将跳转到堆栈顶部的任何延续帧,因此如果调用方希望对函数调用的结果执行某些操作,它应该在跳转之前推动延续帧

例如,代码块:

case f a b of
    True -> body1
    _    -> body2
想要对fab的返回值执行一些操作,因此它编译为以下(未优化)伪代码:

lbl_f:  load args a,b
        jump to f_entry
        push 16-byte case continuation frame <lbl0,copy_of_arg1> onto the stack
lbl_f:  -- code block for f a b, as above:
        load args a,b
        jump to f_entry   -- f_entry will jump to lbl0 when done
lbl0:   restore copy_of_arg1, pop case continuation frame
        if return_value == True jump to lbl2 else lbl1
lbl1:   block for body1
lbl2:   block for body2
-- True -> 1                              -- True -> (+) 1 a
load 1 as return value                    load args 1,a
jump to next continuation                 jump to "+"
                                          -- Note: "+" will jump to next contination
第(14)行中两个实现之间的区别是:

-- case fib n_minus_1 of ...              -- fib' n_minus_1 y
        push case continuation <lbl_a>    load args n_minus_1,y
        load arg n_minus_1                jump to fib'
        jump to fib
lbl_a:  pop case continuation
        load args returned_val,y
        jump to "+"
这里的区别在于一些说明。由于在
fib'n_减1 y
中的失败跳过了堆栈大小检查的开销,因此保存了更多的指令

在使用
Int
的版本中,添加和比较都是单个指令,两个程序集之间的差异是——据我统计——总共30条指令中有5条指令。由于环路很紧,这足以解释33%的性能差异

因此,归根结底,
fib'
fib
快并没有根本的结构性原因,而性能的微小改进归结为对尾部调用允许的少量指令的微优化


在其他情况下,重新组织函数以引入这样的尾部调用可能会提高性能,也可能不会。这种情况可能是不寻常的,因为函数的重组对STG的影响非常有限,因此一些指令的净改进没有被其他因素所淹没。

我在GHCi中得到了两个完全相同的运行时间,分别是
fib 30
fib'30 0
。因此,请为您的索赔添加一些支持证据。显示如何准确地调用这两者。另外,
fib'n