Запоминаемая последовательность Коллатца

Я разместил тот же вопрос в CodeReview, но не получил ответа. поэтому я попытаю счастья здесь, в SO.

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

{-# LANGUAGE BangPatterns #-}
import Data.Functor
import Data.Array (Array)
import qualified Data.Array as Arr
import Control.DeepSeq

genColtzArr n = collatzArr
    where collatzArr = Arr.array (1, n) $ take n $ map (\v -> (v, collatz v 0)) [1..] 
          collatz 1 !acc  = 1 + acc
          collatz !m !acc
              | even m    = go (m `div` 2) acc
              | otherwise = go (3 * m + 1) acc
              where go !l !acc
                      | l <= n    = let !v = collatzArr Arr.! l in 1 + acc + v
                      | otherwise = collatz l $ 1 + acc

collatz здесь означает этого парня. Эта функция должна получить число n, а затем вернуть массив с индексацией от 1 до n, и в котором каждая ячейка содержит длину ссылки от индекса до 1 по формуле Коллатца.

Но использование памяти этим методом настолько велико. Вот результат профилировщика (опция ghc -prof -fprof-auto -rtsopts, опция времени выполнения +RTS -p, n == 500000):

total alloc = 730,636,136 bytes  (excludes profiling overheads)

COST CENTRE              MODULE  %time %alloc

genColtzArr.collatz      Main     40.4   34.7
genColtzArr.collatz.go   Main     25.5   14.4


COST CENTRE                      MODULE                    no.     entries  %time %alloc   %time %alloc     

      genColtzArr                Main                      105           1    0.0    0.0    74.7   72.1
       genColtzArr.collatzArr    Main                      106           1    8.0   20.8    74.7   72.1
        genColtzArr.collatzArr.\ Main                      107      500000    0.9    2.2    66.8   51.3
         genColtzArr.collatz     Main                      109     1182582   40.4   34.7    65.9   49.1
          genColtzArr.collatz.go Main                      110     1182581   25.5   14.4    25.5   14.4

Обратите внимание, что -O2 не является желаемым ответом. Я хочу выяснить, в чем проблема в этой программе и вообще, как мне определить неэффективность времени и памяти в коде Haskell. В частности, я понятия не имею, почему этот код с хвостовой рекурсией и шаблоном взрыва может потреблять так много памяти.

UPDATE1:

тот же код с -s дает это:

   1,347,869,264 bytes allocated in the heap
     595,901,528 bytes copied during GC
     172,105,056 bytes maximum residency (7 sample(s))
         897,704 bytes maximum slop
             315 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      2408 colls,     0 par    0.412s   0.427s     0.0002s    0.0075s
  Gen  1         7 colls,     0 par    0.440s   0.531s     0.0759s    0.1835s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.828s  (  0.816s elapsed)
  GC      time    0.852s  (  0.958s elapsed)
  RP      time    0.000s  (  0.000s elapsed)
  PROF    time    0.000s  (  0.000s elapsed)
  EXIT    time    0.004s  (  0.017s elapsed)
  Total   time    1.684s  (  1.791s elapsed)

  %GC     time      50.6%  (53.5% elapsed)

  Alloc rate    1,627,861,429 bytes per MUT second

  Productivity  49.4% of total user, 46.4% of total elapsed

так что занимает 300 мегабайт. это все еще слишком велико.

Update2

полный код

{-# LANGUAGE BangPatterns #-}
import Data.Functor
import Data.Array (Array)
import qualified Data.Array as Arr
import Control.DeepSeq

genColtzArr n = collatzArr
    where collatzArr = Arr.array (1, n) $ take n $ map (\v -> (v, collatz v 0)) [1..] 
          collatz 1 !acc  = 1 + acc
          collatz !m !acc
              | even m    = go (m `div` 2) acc
              | otherwise = go (3 * m + 1) acc
              where go !l !acc
                      | l <= n    = let !v = collatzArr Arr.! l in 1 + acc + v
                      | otherwise = collatz l $ 1 + acc


genLongestArr n = Arr.array (1, n) llist
    where colatz = genColtzArr n
          llist  = (1, 1):zipWith (\(n1, a1) l2 -> 
                                    let l1 = colatz Arr.! a1
                                     in (n1 + 1, if l2 < l1 then a1 else n1 + 1)) 
                                  llist (tail $ Arr.elems colatz)


main :: IO ()
main = getLine >> do
    ns <- map read <$> lines <$> getContents
    let m          = maximum ns
    let lar        = genLongestArr m
    let iter []    = return ()
        iter (h:t) = (putStrLn $ show $ lar Arr.! h) >> iter t
    iter ns

person Jason Hu    schedule 09.03.2016    source источник
comment
Обратите внимание, что -O2 не является желаемым ответом. - вы говорите, что не компилируете с оптимизацией? Если вы ожидаете, что ваш код на Haskell будет хоть сколько-нибудь быстрым, вы должны скомпилировать его с оптимизацией. Каждый раз, когда код -O0 работает быстро, это, по сути, случайность. Следующее, что вы можете сделать, это посмотреть на оптимизированное ядро.   -  person user2407038    schedule 09.03.2016
comment
@user2407038 user2407038 я показал свой аргумент ghc. Мне нужно понять, что происходит в моей программе. это поможет и другим моим программам. Я не ожидаю, что каждый раз, когда я попытаюсь улучшить свою программу, мне придется приходить сюда и задавать вопросы. поэтому для haskell людям очень легко сказать try -O2, нет, я хочу сосредоточиться на улучшении самой программы, а не полагаться на некоторую оптимизацию.   -  person Jason Hu    schedule 09.03.2016
comment
Я ничего не знаю об операционной семантике программы -O0 Haskell — компилятор делает все, что хочет, поэтому невероятно непредсказуемо, какие проходы ядра сработают. Единственный выход — посмотреть на результат -v3 и окончательное сгенерированное ядро. Если код выглядит до смешного плохо... может быть потому, что вам действительно нужно использовать -O2 с GHC. Никакого заговора нет, -O2 не является жульничеством - ваша программа может иметь никаких проблем с ней, и компилятор генерирует плохую программу, потому что -O0 означает делать это быстро, а не делать это хорошо.   -  person user2407038    schedule 09.03.2016
comment
В вашей программе есть все виды утечек пространства: взаимная рекурсия между collatzArr и collatz, большой длинный ленивый массив, который нельзя собрать на лету, и т. д., но что у вас за main? Какое действие IO было оценено?   -  person zakyggaps    schedule 09.03.2016
comment
@zakyggaps нет, это не просочилось. Я хочу получить все длины ссылок от 1 до n. Это уже не секрет, я думаю, это проект Эйлера 14. Мне нужно будет найти самую длинную ссылку в пределах n. Но я думаю, что самый длинный поиск будет не слишком интересен для оптимизации.   -  person Jason Hu    schedule 09.03.2016
comment
Пока вы не предоставите свои main, невозможно воспроизвести ваше поведение таким образом, чтобы помочь другим. Например, без изменения вашего кода я получаю 70% производительности по сравнению с менее чем 50%, если я использую main = print $ maximum $ genColtzArr 500000 с общим использованием памяти 128 МБ.   -  person Zeta    schedule 09.03.2016
comment
@Zeta, теперь у тебя есть мой полный код. Я надеюсь, что вы можете определить что-то. но пока я думаю, что 128 МБ слишком много. это не должно занимать более 10 МБ.   -  person Jason Hu    schedule 10.03.2016


Ответы (1)


Как подсказывает другой ответ на CodeReview, массив из 500000 элементов в штучной упаковке может потреблять ~ 20 МБ памяти, однако это не только массив, но и многое другое вместе:

collatz 500000 +RTS -hr -L50

Хотя вы повсюду размещаете шаблоны взрыва, сама инициализация массива представляет собой ленивую папку:

-- from GHC.Arr
array (l,u) ies
    = let n = safeRangeSize (l,u)
      in unsafeArray' (l,u) n
                      [(safeIndex (l,u) n i, e) | (i, e) <- ies]

unsafeArray' :: Ix i => (i,i) -> Int -> [(Int, e)] -> Array i e
unsafeArray' (l,u) n@(I# n#) ies = runST (ST $ \s1# ->
    case newArray# n# arrEleBottom s1# of
        (# s2#, marr# #) ->
            foldr (fill marr#) (done l u n marr#) ies s2#)

Поэтому, если вы не оценили последний бит массива, он содержит ссылку на список, используемый при инициализации. Обычно список может быть GC'd на лету, пока вы оцениваете массив, но в вашем случае взаимные ссылки и ссылки на себя нарушили общий шаблон GC.

  • llist ссылается на себя для создания каждого отдельного элемента, поэтому он не будет GC'd, пока вы не оцените последний его элемент.
  • он также содержит ссылку на genColtzArr, поэтому genColtzArr не будет GC, пока llist не будет полностью оценен
  • вы можете подумать, что collatz является хвостовой рекурсией, но это не так, это взаимная рекурсия с collatzArr, поэтому снова они оба не будут GC, пока не будут полностью оценены

Все вместе, ваша программа будет хранить в памяти три структуры, подобные спискам из 500000 элементов, и в результате пиковый размер кучи составит ~ 80 МБ.


Решение

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

genLongestArr :: Int -> Array Int Int
genLongestArr n =
  let collatz = genColtzArr n
  -- deepseq genColtzArr before mapping over it
  -- this is equivalent to your recursive definition
  in collatz `deepseq` (Arr.listArray (1,n) $ fmap fst $ scanl' (maxWith snd) (0, 0) $ Arr.assocs collatz)

maxWith :: Ord a => (b -> a) -> b -> b -> b
maxWith f b b' = case compare (f b) (f b') of
  LT -> b'
  _  -> b

И в main:

-- deepseq lar before mapping over it
-- this is equivalent to your iter loop
lar `deepseq` mapM_ (print . (lar Arr.!)) ns

С genColtzArr ничего сделать нельзя, он сам себя использует для запоминания, так что взаимная рекурсия как бы необходима.

Теперь график кучи достигает пика ~ 20 МБ, как и должно быть:

collatz2 500000 +RTS -hr -L50

(Отказ от ответственности: все программы в этом ответе были скомпилированы с помощью -O0)

person zakyggaps    schedule 11.03.2016