簡體   English   中英

為什么這種斐波那契的尾調用比 Haskell 中的純樹遞歸運行得更快?

[英]Why does this kind of tail call of fibonacci run faster than pure tree recursion in Haskell?

我正在嘗試理解尾調用遞歸。 我轉換純樹遞歸斐波那契函數:

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強制嚴格評估,但似乎第二個版本比第一個 tree-recusion 更快!

Haskell 如何處理 GHC 中的此類尾調用? 謝謝!

在 GHCi 交互式提示符下測試的代碼的性能可能會產生誤導,因此在對 GHC 代碼進行基准測試時,最好在使用ghc -O2編譯的獨立可執行文件中對其進行測試。 添加顯式類型簽名並確保-Wall不報告有關“默認”類型的任何警告也很有幫助。 否則,GHC 可能會選擇您不想要的默認數字類型。 最后,使用criterion基准測試庫也是一個好主意,因為它可以很好地生成可靠且可重復的計時結果。

使用程序以這種方式對您的兩個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 -O2 -Wall Fib2.hs用 GHC 8.6.5 編譯,我得到:

$ ./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)

無論如何,讓我們在這個答案的其余部分使用Integer切換回fibfib' ...

GHC 編譯器生成稱為 STG(無骨干、無標簽、G 機器)的程序的中間形式。 它是忠實地表示程序實際運行方式的最高級別表示。 關於 STG 以及它如何實際轉換為堆分配和堆棧幀的最佳文檔是論文制作快速咖喱:push/enter vs eval/apply for high-order languages 閱讀本文時,圖 1 是 STG 語言(盡管語法與 GHC 使用-ddump-stg生成的不同),圖 2 的第一和第三個面板顯示了如何使用 eval/apply 方法評估 STG(與當前的 GHC-生成的代碼)。 還有一篇較舊的論文在庫存硬件上實現惰性功能語言:Spineless Tagless G-machine提供了更多細節(可能太多),但它有點過時了。

無論如何,要查看fibfib'之間的區別,我們可以使用以下方法查看生成的 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

在這里,嚴格性分析已經使整個計算變得嚴格。 這里沒有創建 thunk。 (在 STG 中,只let塊創建 thunk,而在這個 STG 中沒有let塊。)因此,這兩種實現之間的(最小)性能差異與嚴格與惰性無關。

忽略了額外的參數來fib'注意,這兩個實施方式中基本上是除了在管線(6)中的加法操作結構上相同的fib'在和管線(14)的加法運算的情況下聲明fib

要理解這兩種實現的區別,首先需要了解一個函數調用fab被編譯成偽代碼:

lbl_f:  load args a,b
        jump to f_entry

請注意,所有函數調用,無論它們是否是尾調用,都被編譯為這樣的跳轉。 f_entry的代碼完成時,它將跳轉到堆棧頂部的任何延續幀,因此如果調用者想要對函數調用的結果做一些事情,它應該在跳轉之前推送一個延續幀。

例如,代碼塊:

case f a b of
    True -> body1
    _    -> body2

想要對fab的返回值做一些事情,所以它編譯為以下(未優化的)偽代碼:

        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

知道了這一點,兩個實現的第(6)行的區別就是偽代碼:

-- 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 "+"

一旦優化,它們之間實際上幾乎沒有任何性能差異。 為這些塊生成的匯編代碼是:

-- True -> 1                              -- True -> (+) 1 a
                                          movq 16(%rbp),%rsi
movl $lvl_r83q_closure+1,%ebx             movl $lvl_r83q_closure+1,%r14d
addq $16,%rbp                             addq $24,%rbp
jmp *(%rbp)                               jmp plusInteger_info

-- case fib n_minus_1 of ...              -- fib' n_minus_1 y
movq $block_c89A_info,(%rbp)              movq 8(%rbp),%rax
movq %rbx,%r14                            addq $16,%rbp
jmp fib_info                              movq %rax,%rsi
movq 8(%rbp),%rsi                         movq %rbx,%r14
movq %rbx,%r14                            // fall through to start of fib'
addq $16,%rbp
jmp plusInteger_info

這里的區別是一些說明。 由於fib' n_minus_1 yfib' n_minus_1 y跳過了堆棧大小檢查的開銷,因此節省了更多指令。

在使用Int的版本中,加法和比較都是單個指令,兩個程序集之間的區別是——據我統計——總共大約 30 條指令中有 5 條指令。 由於緊密循環,這足以解釋 33% 的性能差異。

因此,底線是fib'fib快沒有根本的結構原因,並且小的性能改進歸結為尾調用允許的少量指令順序的微優化。

在其他情況下,重新組織函數以引入像這樣的尾調用可能會也可能不會提高性能。 這種情況可能是不尋常的,因為函數的重組對 STG 的影響非常有限,因此一些指令的凈改進沒有被其他因素淹沒。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM