Как уменьшить использование памяти в приложении Haskell?

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

Идея алгоритма действительно проста, чтобы прояснить ее в императивных терминах: он принимает `массив 'и к каждой внутренней точке добавляет значение, которое вычисляется как комбинация значений в самой точке и в ее соседи. Граничные точки - это особые случаи.

Итак, это мой модуль Euler1D.hs:

module Euler1D
( stepEuler
, makeu0
) where

-- impose zero flux condition
zeroflux :: (Floating a) => a -> [a] -> [a]
zeroflux mu (boundary:inner:xs) = [boundary+mu*2*(inner-boundary)]

-- one step of integration
stepEuler :: (Floating a) => a -> [a] -> [a]
stepEuler mu u@(x:xs) = (applyBC . (diffused mu)) u
    where
          diffused mu (left:x:[]) = []    -- ignore outer points
          diffused mu (left:x:right:xs) = -- integrate inner points
                   (x+mu*(left+right-2*x)) : diffused mu (x:right:xs)
          applyBC inner = (lbc u') ++ inner ++ (rbc u') -- boundary conditions
               where u' = [head u] ++ inner ++ [last u]
                     lbc = zeroflux mu             -- left boundary
                     rbc = (zeroflux mu) . reverse -- right boundary

-- initial condition
makeu0 :: Int -> [Double]
makeu0 n = [ ((^2) . sin . (pi*) . xi) x | x <- [0..n]]
    where xi x = fromIntegral x / fromIntegral n

И простой Main.hs:

module Main where

import System ( getArgs )
import Euler1D

main = do
      args <- getArgs
      let n = read $ head args :: Int
      let u0 = makeu0 n
      let un = stepEuler 0.5 u0
      putStrLn $ show $ sum un

Для сравнения я также написал чистую реализацию C.

Теперь, если я попытаюсь запустить реализацию Haskell для достаточно большого размера массива n, у меня будет:

$ time ./eulerhs 200000
100000.00000000112

real    0m3.552s
user    0m3.304s
sys     0m0.128s

Для сравнения, версия C быстрее почти на два порядка:

$ time ./eulerc 200000
100000

real    0m0.088s
user    0m0.048s
sys     0m0.008s

EDIT: это сравнение не совсем справедливо, потому что версия Haskell скомпилирована с флагами профилирования, а C - нет. Если я скомпилирую обе программы с -O2 и обе без флагов профилирования, я могу увеличить n. В этом случае time ./eulerhs 1000000 занимает 0m2,236s, а time ./eulerc 1000000 - только 0m0,293s. Таким образом, проблема остается со всеми оптимизациями и без профилирования, она только компенсируется.

Я также хотел бы отметить, что распределение памяти программы Haskell, похоже, растет линейно с n. Наверное, это нормально.

Но хуже всего - требования к памяти. Моя версия Haskell требует более 100 МБ (моя оценка минимума в C составляет 4 МБ). Думаю, это может быть источником проблемы. Согласно отчету о профилировании программа проводит 85% времени в GC, и

        total time  =        0.36 secs   (18 ticks @ 20 ms)
        total alloc = 116,835,180 bytes  (excludes profiling overheads)

COST CENTRE                    MODULE               %time %alloc

makeu0                         Euler1D               61.1   34.9
stepEuler                      Euler1D               33.3   59.6
CAF:sum                        Main                   5.6    5.5

Я был удивлен, увидев, что makeu0 так дорого. Я решил, что это связано с его ленивой оценкой (если его преобразователи остаются в памяти до конца stepEuler).

Я пробовал это изменение в Main.hs:

   let un = u0 `seq` stepEuler 0.5 u0

но разницы не заметил. Понятия не имею, как уменьшить использование памяти в stepEuler. Итак, мои вопросы:

  1. Есть ли способ в Haskell строить списки / выполнять их строгое понимание? В этом случае нет смысла ленить его.
  2. Как в этом случае уменьшить общее использование памяти? Полагаю, надо сделать что-то строгое, но не вижу что. Другими словами, если мне нужно нанести seq челку, где и зачем?
  3. И, наконец, самое главное, как лучше всего определять такие дорогостоящие конструкции?

Я прочитал главу о профилировании и оптимизации в Real World Haskell, но остается неясным, как именно я могу решить, что должно быть строгим, а что нет.

Прошу простить меня за такой длинный пост.

EDIT2: как было предложено А. Рексом в комментариях, я попытался запустить обе программы в valgrind. И это то, что я заметил. Для программы Haskell (n = 200000) обнаружено:

malloc / free: 33 аллока, 30 освобождений, выделено 84 109 байт. ... проверил 55 712 980 байт.

И для программы C (после небольшого исправления):

malloc / free: 2 аллока, 2 освобождения, выделено 3 200 000 байт.

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

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

comment
Как бы то ни было, версия для Haskell у меня работала всего в 4 раза медленнее, когда и gcc, и ghc имели переключатели -O2.   -  person A. Rex    schedule 20.01.2009
comment
И, как ни странно, valgrind сообщает, что Haskell использует значительно меньше памяти, чем версия C. = / Но я все же думаю, что это хороший вопрос, потому что анализировать производительность памяти в Haskell сложно. Я надеюсь, что у кого-то есть лучший ответ, чем мои немного неосведомленные сотрудники.   -  person A. Rex    schedule 20.01.2009
comment
А. Рекс, вы можете поместить эту ссылку на настройки компилятора в своем собственном ответе, я думаю, это полезно.   -  person Svante    schedule 20.01.2009
comment
Если бы он работал всего в 4 раза медленнее, я был бы счастлив. Я только что попробовал перекомпилировать без профилирования, и, конечно, стало намного лучше, так что я смог увеличить n. time ./eulerhs 1000000 выполняется за 2,236 с, а time ./eulerc 1000000 - за 0,293 с. Отредактирую вопрос. Спасибо за комментарии!   -  person sastanin    schedule 20.01.2009
comment
А. Рексу: valgrind сообщает, что Haskell использует значительно меньше памяти, чем C-версия. Я не освободил выделенный массив при выходе из программы на C, поэтому valgrind жалуется. Когда исправлено, valgrind сообщает, что программа на Haskell выполняет гораздо больше мелких распределений ... Но это дает представление!   -  person sastanin    schedule 20.01.2009


Ответы (7)


  1. Списки - не лучшая структура данных для этого типа кода (с большим количеством (++) и (последний)). Вы теряете много времени, составляя и разбирая списки. Я бы использовал Data.Sequence или массивы, как в версиях C.

  2. Преобразователи makeu0 не могут быть обработаны сборщиком мусора, так как вам нужно сохранить их все (ну, все результаты "диффузного", если быть точным) до конца вычислений, чтобы умеет делать "обратное" в applyBC. Что очень дорого, учитывая, что вам нужно всего два пункта из хвоста списка для вашего "zeroflux".

Вот быстрый взлом вашего кода, который пытается добиться лучшего слияния списков и делает меньше (де) построение списков:

module Euler1D
( stepEuler
) where

-- impose zero flux condition
zeroflux mu (boundary:inner:xs) = boundary+mu*2*(inner-boundary)

-- one step of integration
stepEuler mu n = (applyBC . (diffused mu)) $ makeu0 n
    where
          diffused mu (left:x:[]) = []    -- ignore outer points
          diffused mu (left:x:right:xs) = -- integrate inner points
                   let y = (x+mu*(left+right-2*x))
                       in y `seq` y : diffused mu (x:right:xs)
          applyBC inner = lbc + sum inner + rbc -- boundary conditions
               where
                     lbc = zeroflux mu ((f 0 n):inner)             -- left boundary
                     rbc = zeroflux mu ((f n n):(take 2 $ reverse inner)) -- right boundary

-- initial condition
makeu0 n = [ f x n | x <- [0..n]]

f x n = ((^2) . sin . (pi*) . xi) x
    where xi x = fromIntegral x / fromIntegral n

Для 200000 баллов он завершается за 0,8 секунды против 3,8 секунды для начальной версии.

person ADEpt    schedule 22.01.2009
comment
Большое спасибо! Это именно тот ответ, который я искал. Я не знал о Data.Sequence и массивах, но подозревал, что можно избежать множества операций со списками. P.S. y seq y:… в диффузном - это красивый узор… также обратите внимание на applyBC… Спасибо! - person sastanin; 22.01.2009
comment
Однако суммирование в stepEuler изменяет исходную семантику функции. Я восстановил исходную семантику (pastebin.com/f5ca77a7f), и она по-прежнему работает достаточно быстро (0,5 секунды для 2e5 баллов ). Сейчас я рассмотрю возможность использования Data.Sequence. - person sastanin; 22.01.2009

В моей 32-разрядной системе x86 ваша программа использует всего около 40 МБ памяти.

Возможно, вы путаете строку «total alloc = 116 835 180 байт» из выходных данных профилирования с тем, сколько памяти фактически используется программой в любой момент времени? Общий объем памяти - это объем памяти, выделенный для всего выполнения программы; большая часть этого освобождается сборщиком мусора по мере вашего продвижения. Вы можете ожидать, что это число станет очень большим в программе на Haskell; У меня есть программы, которые выделяют много террабайт памяти в течение всего своего выполнения, хотя на самом деле у них максимальный размер виртуальной памяти составляет около ста мегабайт.

Я бы не стал слишком беспокоиться о больших общих выделениях памяти в ходе выполнения программы; это природа чистого языка, и среда выполнения GHC имеет очень хороший сборщик мусора, который помогает это компенсировать.

person cjs    schedule 06.06.2009
comment
Да, точно! Я понимал, что общее выделение памяти похоже на максимальное выделение памяти. Теперь мне стало понятнее. Спасибо! - person sastanin; 09.06.2009

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

Вы также можете найти освещение этого отличное сообщение в блоге Дона Стюарта о понимании строгости, ее взаимодействии со сборкой мусора, а также о том, как диагностировать и устранять проблемы.

person daf    schedule 31.01.2009
comment
Спасибо за ссылки, даф! - person sastanin; 31.01.2009
comment
Сообщение Дона Стюарта действительно очень полезно. Большое спасибо! - person sastanin; 02.02.2009
comment
Обе ссылки сейчас мертвы :( - person 4castle; 06.07.2017

Принуждает "лень" с помощью $! помощь? согласно этому ответу.

person John Mulder    schedule 20.01.2009
comment
Спасибо! Я попытался оценить строго u, аргумент stepEuler и все аргументы в applyBC (см. pastebin.com/f56a2079d), но эффект обратный: программа выделяет почти на 50% больше (согласно valgrind) и работает почти на 50% дольше. - person sastanin; 20.01.2009

По запросу Харлекина: Вы пробовали устанавливать флаги оптимизации? Например, с ghc вы можете использовать add «-O2» так же, как и с gcc. (Хотя я не уверен, какие уровни оптимизации существуют в ghc; справочная страница точно не говорит ...)

По моему прошлому опыту установка этого флага имела огромную разницу. Насколько я могу судить, runhugs и неоптимизированный ghc используют самую простую и очевидную реализацию Haskell; к сожалению, иногда это не очень эффективно.

Но это только предположение. Как я сказал в своем комментарии, я надеюсь, что кто-то хорошо ответит на ваш вопрос. У меня часто возникают проблемы с анализом и исправлением использования памяти Haskell.

person A. Rex    schedule 20.01.2009
comment
Да, я компилировал с флагами оптимизации: ghc -O2 -c -prof -auto-all -caf-all -fforce-recomp Euler1D.hs ; ghc -O2 -o eulerhs Main.hs Euler1D.o -prof -auto-all -caf-all -fforce-recomp - person sastanin; 20.01.2009

Также используйте переключатель -fvia-C.

person Hynek -Pichi- Vychodil    schedule 20.01.2009

Сейчас мне бросилось в глаза то, что вывод Haskell - это число с плавающей запятой, а вывод C - целочисленный. Я еще не разобрался с кодом Haskell, но, может быть, есть место, где у вас есть арифметика с плавающей запятой в Haskell, а C использует целые числа?

person Svante    schedule 20.01.2009
comment
Нет, вывод C не является целым числом. Просто printf пытается красиво представить результат при печати. C арифметика полностью двойная. - person sastanin; 20.01.2009