为什么这种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