Инструменты для анализа производительности программы Haskell

104

Решая некоторые задачи Project Euler для изучения Haskell (так что сейчас я совсем новичок), я столкнулся с проблемой 12 . Я написал это (наивное) решение:

--Get Number of Divisors of n
numDivs :: Integer -> Integer
numDivs n = toInteger $ length [ x | x<-[2.. ((n `quot` 2)+1)], n `rem` x == 0] + 2

--Generate a List of Triangular Values
triaList :: [Integer]
triaList =  [foldr (+) 0 [1..n] | n <- [1..]]

--The same recursive
triaList2 = go 0 1
  where go cs n = (cs+n):go (cs+n) (n+1)

--Finds the first triangular Value with more than n Divisors
sol :: Integer -> Integer
sol n = head $ filter (\x -> numDivs(x)>n) triaList2

Это решение n=500 (sol 500)очень медленное (работает уже более 2 часов), поэтому я подумал, как узнать, почему это решение такое медленное. Существуют ли какие-либо команды, которые говорят мне, на что уходит большая часть времени вычислений, чтобы я знал, какая часть моей программы haskell работает медленно? Что-то вроде простого профилировщика.

Для того, чтобы понять, я не прошу для более быстрого решения , но и для пути , чтобы найти это решение. С чего бы вы начали, если бы у вас не было знаний о haskell?

Я попытался написать две triaListфункции, но не нашел способа проверить, какая из них быстрее, поэтому здесь начинаются мои проблемы.

Спасибо

Theomega
источник

Ответы:

187

как узнать, почему это решение такое медленное. Есть ли какие-либо команды, которые говорят мне, на что тратится большая часть времени вычислений, чтобы я знал, какая часть моей программы haskell работает медленно?

Точно! GHC предоставляет множество отличных инструментов, в том числе:

Учебное пособие по использованию профилирования времени и пространства является частью Real World Haskell .

Статистика GC

Во-первых, убедитесь, что вы компилируете с ghc -O2. И вы можете убедиться, что это современный GHC (например, GHC 6.12.x)

Первое, что мы можем сделать, это проверить, не проблема ли сборка мусора. Запустите вашу программу с помощью + RTS -s

$ time ./A +RTS -s
./A +RTS -s 
749700
   9,961,432,992 bytes allocated in the heap
       2,463,072 bytes copied during GC
          29,200 bytes maximum residency (1 sample(s))
         187,336 bytes maximum slop
               **2 MB** total memory in use (0 MB lost due to fragmentation)

  Generation 0: 19002 collections,     0 parallel,  0.11s,  0.15s elapsed
  Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time   13.15s  ( 13.32s elapsed)
  GC    time    0.11s  (  0.15s elapsed)
  RP    time    0.00s  (  0.00s elapsed)
  PROF  time    0.00s  (  0.00s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time   13.26s  ( 13.47s elapsed)

  %GC time       **0.8%**  (1.1% elapsed)

  Alloc rate    757,764,753 bytes per MUT second

  Productivity  99.2% of total user, 97.6% of total elapsed

./A +RTS -s  13.26s user 0.05s system 98% cpu 13.479 total

Что уже дает нам много информации: у вас есть только куча 2M, а сборщик мусора занимает 0,8% времени. Так что не нужно беспокоиться, что проблема заключается в распределении.

Профили времени

Получить временной профиль для вашей программы просто: скомпилируйте с -prof -auto-all

 $ ghc -O2 --make A.hs -prof -auto-all
 [1 of 1] Compiling Main             ( A.hs, A.o )
 Linking A ...

И для N = 200:

$ time ./A +RTS -p                   
749700
./A +RTS -p  13.23s user 0.06s system 98% cpu 13.547 total

который создает файл A.prof, содержащий:

    Sun Jul 18 10:08 2010 Time and Allocation Profiling Report  (Final)

       A +RTS -p -RTS

    total time  =     13.18 secs   (659 ticks @ 20 ms)
    total alloc = 4,904,116,696 bytes  (excludes profiling overheads)

COST CENTRE          MODULE         %time %alloc

numDivs            Main         100.0  100.0

Это означает, что все ваше время тратится на numDivs, а также является источником всех ваших выделений.

Профили кучи

Вы также можете получить разбивку этих распределений, запустив + RTS -p -hy, который создает A.hp, который вы можете просмотреть, преобразовав его в файл postscript (hp2ps -c A.hp), генерируя:

альтернативный текст

что говорит нам, что с использованием вашей памяти все в порядке: она распределяется в постоянном пространстве.

Итак, ваша проблема - алгоритмическая сложность numDivs:

toInteger $ length [ x | x<-[2.. ((n `quot` 2)+1)], n `rem` x == 0] + 2

Исправьте то, что составляет 100% вашего рабочего времени, а все остальное легко.

Оптимизация

Это выражение является хорошим кандидатом для оптимизации объединения потоков , поэтому я перепишу его для использования Data.Vector , например:

numDivs n = fromIntegral $
    2 + (U.length $
        U.filter (\x -> fromIntegral n `rem` x == 0) $
        (U.enumFromN 2 ((fromIntegral n `div` 2) + 1) :: U.Vector Int))

Которая должна слиться в единый цикл без ненужных выделений кучи. То есть она будет иметь лучшую сложность (с постоянными коэффициентами), чем версия со списком. Вы можете использовать инструмент ghc-core (для опытных пользователей), чтобы проверить промежуточный код после оптимизации.

Проверяя это, ghc -O2 --make Z.hs

$ time ./Z     
749700
./Z  3.73s user 0.01s system 99% cpu 3.753 total

Таким образом, время выполнения для N = 150 сократилось в 3,5 раза, без изменения самого алгоритма.

Вывод

Ваша проблема - numDivs. Это 100% вашего рабочего времени, и он ужасно сложен. Подумайте о numDivs и о том, как, например, для каждого N вы генерируете [2 .. n div2 + 1] N раз. Попробуйте запомнить это, поскольку значения не меняются.

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


Дополнения

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

Мы также можем переписать trialList и полагаться на fusion, чтобы превратить его в цикл, который вы пишете вручную в trialList2, который является функцией "сканирования префикса" (также известной как scanl):

triaList = U.scanl (+) 0 (U.enumFrom 1 top)
    where
       top = 10^6

Аналогично для sol:

sol :: Int -> Int
sol n = U.head $ U.filter (\x -> numDivs x > n) triaList

С таким же общим временем работы, но с немного более чистым кодом.

Дон Стюарт
источник
Просто замечание для других идиотов вроде меня: timeутилита, которую Дон упомянул в Time Profiles, - это просто программа Linux time. Это недоступно в Windows. Так что для профилирования времени в Windows (на самом деле где угодно) см. Этот вопрос.
Джон Ред
1
Для будущих пользователей -auto-allне рекомендуется в пользу -fprof-auto.
Б. Мехта
60

Ответ Дона великолепен, но он не является спойлером, так как дает прямое решение проблемы.
Здесь я хочу предложить небольшой инструмент, который я написал недавно. Это экономит ваше время на написание аннотаций SCC вручную, если вам нужен более подробный профиль, чем профиль по умолчанию ghc -prof -auto-all. Кроме того, это красочно!

Вот пример кода, который вы указали (*), зеленый - нормально, красный - медленный: альтернативный текст

Все время идет на создание списка делителей. Это предлагает несколько вещей, которые вы можете сделать:
1. Сделать фильтрацию n rem x == 0быстрее, но, поскольку это встроенная функция, вероятно, она и так быстрая.
2. Составьте более короткий список. Вы уже сделали что-то в этом направлении, проверив только до n quot 2.
3. Полностью откажитесь от генерации списков и используйте математику, чтобы получить более быстрое решение. Это обычный способ решения задач Эйлера проекта.

(*) Я получил это, поместив ваш код в файл с именем eu13.hs, добавив основную функцию main = print $ sol 90. Потом бегом visual-prof -px eu13.hs eu13и результат на месте eu13.hs.html.

Даниэль
источник
3

Замечание, относящееся к Haskell: triaList2конечно, быстрее, чем triaListпотому, что последний выполняет много ненужных вычислений. Для вычисления n первых элементов потребуется квадратичное время triaList, но линейно для triaList2. Есть еще один элегантный (и эффективный) способ определить бесконечный ленивый список чисел треугольника:

triaList = 1 : zipWith (+) triaList [2..]

Примечание по математике: нет необходимости проверять все делители до n / 2, достаточно проверить до sqrt (n).

Рхайров
источник
2
Также учтите: scanl (+) 1 [2 ..]
Дон Стюарт
1

Вы можете запустить свою программу с флагами, чтобы включить профилирование времени. Что-то вроде этого:

./program +RTS -P -sprogram.stats -RTS

Это должно запустить программу и создать файл с именем program.stats, в котором будет указано, сколько времени было потрачено на каждую функцию. Дополнительную информацию о профилировании с помощью GHC можно найти в руководстве пользователя GHC . Для бенчмаркинга есть библиотека Criterion. Я обнаружил, что это сообщение в блоге содержит полезное введение.

user394827
источник
1
Но сначала скомпилируйте это сghc -prof -auto-all -fforce-recomp --make -O2 program.hs
Daniel