Почему добавление тильды перед совпадением шаблонов замедляет мою функцию?

У меня есть две функции, определенные в куске кода haskell:

lengthwtilde [] = 0
lengthwtilde ~(_:xs) = 1 + lengthwtilde xs

lengthwotilde [] = 0
lengthwotilde (_:xs) = 1 + lengthwotilde xs

Когда я тестирую их оба в ghci (используя :set +s), я обнаружил, что lengthwtilde (тот, у которого тильда перед совпадением шаблона) выполняет значительно медленнее, чем lengthwotilde примерно на три секунды.

*Main> lengthwtilde [1..10000000]
10000000
(19.40 secs, 1731107132 bytes)
*Main> lengthwotilde [1..10000000]
10000000
(16.45 secs, 1531241716 bytes)

Почему это?

Ответы

Ответ 1

Добавление ~ перед совпадением шаблона делает совпадение неопровержимым. Вы можете думать об этом как добавление дополнительной лени к шаблону, так что он никогда не будет соответствовать, если этот матч абсолютно необходим для оценки. Вот простой пример:

Prelude> (\ (_:_) -> "non-empty") []
"*** Exception: <interactive>:2:2-23: Non-exhaustive patterns in lambda

Prelude> (\ ~(_:_) -> "oops") []
"oops"

С неопровержимым совпадением шаблонов, даже если совпадение шаблона терпит неудачу в пустом списке, поскольку никакие связанные переменные не оцениваются, нет ошибки. По сути, неопровержимое совпадение шаблонов преобразует функцию в:

\ xs -> let (_:_) = xs in "oops"

Это лишняя упаковка лени, которая замедляет вашу функцию длины. Если вы применяете одно и то же преобразование let-binding к lengthwtilde, вы получаете

lengthwtilde [] = 0
lengthwtilde xs' = let (_:xs) = xs' in 1 + lengthwtilde xs

Подумайте, как это оценивается. На верхнем уровне вы получаете 1+lengthwtilde xs. Но xs даже не оценивается, так как это переменная let-bound. Поэтому на следующем шаге сначала оценивается xs, чтобы определить его соответствие второму случаю lengthwtilde, и процесс повторяется.

Сравните это с lengthwotilde. В этой функции акт сопоставления во втором случае функции заставляет аргумент также оцениваться. Конечный результат один и тот же, но он более эффективен, чтобы иметь возможность разворачивать его раньше, чем оставлять еще один thunk, который должен быть принудительно.

Технически lengthwtilde немного сложнее: аргумент уже оценивается во второй ветки, так как мы определяем, в какую ветвь мы находимся, однако он переустанавливается при передаче в рекурсивный вызов.

Это полезно, чтобы иметь возможность видеть произведенное ядро. Здесь вывод lengthwotilde (полученный из ghc -O0:

Foo.lengthwotilde =
  \ (@ t_afD)
    (@ a_afE)
    ($dNum_afF :: GHC.Num.Num a_afE)
    (eta_B1 :: [t_afD]) ->
    letrec {
      lengthwotilde1_af2 [Occ=LoopBreaker] :: [t_afD] -> a_afE
      [LclId, Arity=1]
      lengthwotilde1_af2 =
        \ (ds_dgd :: [t_afD]) ->
          case ds_dgd of _ {
            [] -> GHC.Num.fromInteger @ a_afE $dNum_afF (__integer 0);
            : ds1_dge xs_af1 ->
              GHC.Num.+
                @ a_afE
                $dNum_afF
                (GHC.Num.fromInteger @ a_afE $dNum_afF (__integer 1))
                (lengthwotilde1_af2 xs_af1)
          }; } in
    lengthwotilde1_af2 eta_B1

Обратите внимание, что функция lengthwotilde1_af2 сразу выполняет case в аргументе ds_dgd (это список ввода), а затем рекурсирует внутри случая, формируя thunk (с некоторыми расширениями):

1 + len [2..]
1 + (1 + len [3..])
1 + (1 + (1 + len [4..])

что в конечном итоге требует оценки   1 + (1 + (1 + (1 +..)))

Здесь lengthwtilde

Foo.lengthwtilde =
  \ (@ t_afW)
    (@ a_afX)
    ($dNum_afY :: GHC.Num.Num a_afX)
    (eta_B1 :: [t_afW]) ->
    letrec {
      lengthwtilde1_afM [Occ=LoopBreaker] :: [t_afW] -> a_afX
      [LclId, Arity=1]
      lengthwtilde1_afM =
        \ (ds_dgh :: [t_afW]) ->
          case ds_dgh of wild_X9 {
            [] -> GHC.Num.fromInteger @ a_afX $dNum_afY (__integer 0);
            : ipv_sgv ipv1_sgw ->
              GHC.Num.+
                @ a_afX
                $dNum_afY
                (GHC.Num.fromInteger @ a_afX $dNum_afY (__integer 1))
                (lengthwtilde1_afM
                   (case wild_X9 of _ {
                      [] ->
                        (Control.Exception.Base.irrefutPatError
                           @ () "foo.hs:(3,1)-(4,42)|(_ : xs)")
                        `cast` (UnsafeCo () [t_afW] :: () ~# [t_afW]);
                      : ds1_dgk xs_aeH -> xs_aeH
                    }))
          }; } in
    lengthwtilde1_afM eta_B1

Оценка этого формы отличается от другого:

len [1..]
1 + (len (if null [1..] then error else [2..]))
1 + (len [2..])
1 + (1 + len (if null [2..] then error else [3..]))

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

Теперь, если вы выполняли скомпилированный код с любыми оптимизациями, ghc почти наверняка обнаружил бы, что аргументы не могут быть пустыми, так как они уже оцениваются и, как известно, используют конструктор (:) в этот момент. И когда я скомпилирую код с помощью ghc -O2 и запустим его, обе функции выполняются за один и тот же промежуток времени. Они оба довольно плохи, потому что в любом случае получается цепочка трюков. Стандартное определение length намного лучше, так же как хорошее определение foldl'.