Почему этот тип хвостового вызова Фибоначчи работает быстрее, чем рекурсия чистого дерева в Haskell? - PullRequest
2 голосов
/ 26 февраля 2020

Я пытаюсь понять рекурсии хвостового вызова. Я конвертирую чистую функцию Фибоначчи с древовидной рекурсией:

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 обрабатывает такие хвостовые вызовы внутри GH C? Спасибо!

1 Ответ

1 голос
/ 05 марта 2020

Производительность кода, тестируемого в интерактивном приглашении GHCi, может вводить в заблуждение, поэтому при тестировании кода GH C рекомендуется протестировать его в отдельном исполняемом файле, скомпилированном с ghc -O2. Также полезно добавить явные сигнатуры типов и убедиться, что -Wall не сообщает о каких-либо предупреждениях о «дефолтных» типах. В противном случае GH C может выбрать число по умолчанию для чисел c типов, которые вы не намеревались. Наконец, это также хорошая идея использовать библиотеку 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
  ]

, скомпилированный с GH C 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' примерно на 3-5% быстрее, чем версия fib.

На данный момент, возможно, стоит указать, что мои сигнатуры типов использовали Integer. Это также значение по умолчанию, которое GH C выбрал бы без явных сигнатур типов. Замена на 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)

. Поэтому я рекомендую включать явные подписи типов и следить за тем, чтобы не было предупреждений о типах по умолчанию. В противном случае, вы можете потратить много времени в погоне за крошечными улучшениями, когда реальной проблемой является индекс oop, который использует Integer, когда он мог бы использовать Int. В этом примере, конечно, есть еще одна проблема, заключающаяся в том, что алгоритм неверен, поскольку алгоритм имеет квадратичное значение c, и возможна линейная реализация, как и в обычном решении "умный 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 для остальной части этого ответа ...

Компилятор GH C создает промежуточную форму программы под названием STG (без позвоночника, без метки, G-машина). Это представление самого высокого уровня, которое точно показывает, как программа будет работать на самом деле. Лучшая документация по STG и тому, как она на самом деле переводится в выделение кучи и кадры стека, - это статья Создание быстрого карри: push / enter против eval / apply для языков высшего порядка . При чтении этого документа рисунок 1 является языком STG (хотя синтаксис отличается от того, что GH C создает с -ddump-stg), а первая и третья панели рисунка 2 показывают, как STG оценивается с использованием подхода eval / apply (который соответствует текущему GH C -генерированный код). Есть также более старая статья Реализация ленивых функциональных языков на стандартном оборудовании: G-машина Spineless Tagless G , которая предоставляет намного больше деталей (возможно, слишком много), но немного устарела.

В любом случае, чтобы увидеть разницу между 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

Здесь анализ строгости уже сделал весь расчет строгим. Здесь нет созданных громов. (В STG только блоки let создают thunks, и в этой STG нет блоков let.) Таким образом, (минимальная) разница в производительности между этими двумя реализациями не имеет ничего общего со строгим и ленивым.

Игнорируя дополнительный аргумент fib', обратите внимание, что эти две реализации по существу структурно идентичны, за исключением операции сложения в строке (6) в fib' и оператора case с операцией сложения в строке ( 14) в fib.

Чтобы понять разницу между этими двумя реализациями, сначала нужно понять, что вызов функции f a b компилируется в псевдокод:

lbl_f:  load args a,b
        jump to f_entry

Примечание что все вызовы функций, независимо от того, являются ли они хвостовыми вызовами, компилируются в подобные переходы. Когда код в f_entry завершится, он перейдет к любому кадру продолжения на вершине стека, поэтому, если вызывающий объект хочет что-то сделать с результатом вызова функции, он должен сделать sh кадр продолжения до jump.

Например, блок кода:

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

хочет что-то сделать с возвращаемым значением f a b, поэтому он компилируется в следующий (неоптимизированный) псевдокод:

        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 y пропущены накладные расходы при проверке размера стека.

В версии, использующей Int, все добавления и сравнения представляют собой отдельные инструкции, а разница между двумя сборками - по моим подсчетам - пять инструкций из примерно 30 инструкций в общей сложности. Из-за жесткого l oop этого достаточно, чтобы учесть разницу в производительности на 33%.

Итак, суть в том, что нет фундаментальной структурной причины, по которой fib' быстрее, чем fib, и небольшое улучшение производительности сводится к микрооптимизации в порядке набора инструкций, которые позволяет хвостовой вызов.

В других ситуациях реорганизация функции для введения хвостового вызова, как это, может или не может улучшить производительность , Эта ситуация, вероятно, была необычной в том смысле, что реорганизация функции имела очень ограниченное влияние на STG, и поэтому улучшение net некоторых инструкций не было подавлено другими факторами.

...