Почему `(карта digitToInt). шоу так быстро? - PullRequest
12 голосов
/ 30 января 2011

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

import Data.Char

digits :: Integer -> [Int]
digits = (map digitToInt) . show

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

То, что я до сих пор пробовал:

Базовая линия:

digits :: Int -> [Int]
digits = (map digitToInt) . show

Получил этот вопрос из другого вопроса о StackOverflow:

digits2 :: Int -> [Int]
digits2 = map (`mod` 10) . reverse . takeWhile (> 0) . iterate (`div` 10)

Попытка свернуть мою собственную:

digits3 :: Int -> [Int]
digits3 = reverse . revDigits3

revDigits3 :: Int -> [Int]
revDigits3 n = case divMod n 10 of
               (0, digit) -> [digit]
               (rest, digit) -> digit:(revDigits3 rest)

Этот был вдохновлен showInt в Numeric:

digits4 n0 = go n0 [] where
    go n cs
        | n < 10    =  n:cs
        | otherwise =  go q (r:cs)
        where
        (q,r) = n `quotRem` 10

Теперь эталон. Примечание: я заставляю оценку использовать filter.

λ>:set +s
λ>length $ filter (>5) $ concat $ map (digits) [1..1000000]
2400000
(1.58 secs, 771212628 bytes)

Это ссылка. Теперь для digits2:

λ>length $ filter (>5) $ concat $ map (digits2) [1..1000000]
2400000
(5.47 secs, 1256170448 bytes)

Это 3,46 раза дольше.

λ>length $ filter (>5) $ concat $ map (digits3) [1..1000000]
2400000
(7.74 secs, 1365486528 bytes)

digits3 медленнее 4,89 . Ради интереса, я пытался использовать только revDigits3 и избегать reverse.

λ>length $ filter (>5) $ concat $ map (revDigits3) [1..1000000]
2400000
(8.28 secs, 1277538760 bytes)

Странно, это еще медленнее, 5,24 раз медленнее.

И последний:

λ>length $ filter (>5) $ concat $ map (digits4) [1..1000000]
2400000
(16.48 secs, 1779445968 bytes)

Это 10.43 медленнее.

У меня сложилось впечатление, что только использование арифметики и минусов превзойдет все, что связано с преобразованием строк. Видимо, есть кое-что, чего я не могу понять.

Так в чем же подвох? Почему digits так быстро?

Я использую GHC 6.12.3.

Ответы [ 2 ]

30 голосов
/ 30 января 2011

Поскольку я пока не могу добавлять комментарии, я сделаю немного больше работы и просто проанализирую их все. Я ставлю анализ наверху; Тем не менее, соответствующие данные ниже. (Примечание: все это также сделано в 6.12.3 - магии GHC 7 пока нет.)


Анализ:

Версия 1: Шоу довольно неплохо для новичков, особенно таких коротких, как мы. Создание строк на самом деле имеет тенденцию быть приличным в GHC; однако чтение строк и запись больших строк в файлы (или stdout, хотя вы бы не хотели этого делать) - это то место, где ваш код может абсолютно сканировать. Я подозреваю, что многие детали, объясняющие, почему это так быстро, происходят из-за хитрой оптимизации в шоу для Ints.

Версия 2: Этот файл был самым медленным из всех при компиляции. Некоторые проблемы: обратное строгое в своем аргументе. Это означает, что вы не можете выполнять вычисления в первой части списка во время вычисления следующих элементов; Вы должны вычислить их все, перевернуть их, а затем выполнить свои вычисления (а именно (`mod` 10)) для элементов списка. Хотя это может показаться небольшим, это может привести к большему использованию памяти (обратите внимание, что здесь также выделено 5 ГБ памяти кучи) и более медленным вычислениям. (Короче говоря: не используйте реверс).

Версия 3: Помните, как я только что сказал, не используйте реверс? Оказывается, если вы возьмете его, это сокращает общее время выполнения до 1,79 с - чуть медленнее, чем базовая линия. Единственная проблема здесь в том, что когда вы углубляетесь в число, вы строите корешок списка в неправильном направлении (по сути, вы вводите «в» список с помощью рекурсии, а не «обращаетесь»). список).

Версия 4: Это очень умная реализация. Вы получаете выгоду от нескольких приятных вещей: во-первых, quotRem должен использовать евклидов алгоритм, логарифмический по большему аргументу. (Может быть, это быстрее, но я не верю, что есть что-то, что больше, чем постоянный фактор, быстрее, чем Евклид.) Кроме того, вы попадаете в список, как обсуждалось в прошлый раз, так что вам не нужно разрешать какие-либо списки по мере того, как вы go - список уже полностью создан, когда вы возвращаетесь, чтобы разобрать его. Как видите, от этого выигрывает производительность.

Этот код, вероятно, был самым медленным в GHCi, потому что многие оптимизации, выполняемые с флагом -O3 в GHC, связаны с ускорением создания списков, в то время как GHCi этого не делает.

Уроки: выбирает правильный путь в список, следите за промежуточной строгостью, которая может замедлить вычисления, и поработайте над просмотром детальной статистики производительности вашего кода. Также скомпилируйте с флагами -O3: всякий раз, когда вы этого не сделаете, все те люди, которые тратят много времени на то, чтобы сделать GHC сверхбыстрым, смотрят вам в глаза.


Данные:

Я просто взял все четыре функции, вставил их в один файл .hs, а затем изменил при необходимости, чтобы отразить используемую функцию. Кроме того, я увеличил ваш лимит до 5e6, потому что в некоторых случаях скомпилированный код будет выполняться менее чем за полсекунды на 1e6, и это может вызвать проблемы с гранулярностью при измерениях, которые мы проводим.

Параметры компилятора: используйте ghc --make -O3 [имя файла] .hs , чтобы GHC провел некоторую оптимизацию. Мы сбросим статистику в стандартную ошибку, используя цифры + RTS -sstderr .

Вывод в -sstderr дает нам вывод, который выглядит следующим образом, в случае цифр 1:

digits1 +RTS -sstderr
12000000
   2,885,827,628 bytes allocated in the heap
         446,080 bytes copied during GC
           3,224 bytes maximum residency (1 sample(s))
          12,100 bytes maximum slop
               1 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0:  5504 collections,     0 parallel,  0.06s,  0.03s elapsed
  Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    1.61s  (  1.66s elapsed)
  GC    time    0.06s  (  0.03s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    1.67s  (  1.69s elapsed)

  %GC time       3.7%  (1.5% elapsed)

  Alloc rate    1,795,998,050 bytes per MUT second

  Productivity  96.3% of total user, 95.2% of total elapsed

Здесь есть три ключевых статистики:

  1. Общее количество используемой памяти: только 1 МБ означает, что эта версия очень компактна.
  2. Общее время: 1,61 с сейчас ничего не значит, но посмотрим, как оно выглядит на фоне других реализаций.
  3. Производительность: это всего лишь 100% минус сборщик мусора; поскольку у нас 96,3%, это означает, что мы не создаем много объектов, которые оставляем в памяти.

Хорошо, давайте перейдем к версии 2.

digits2 +RTS -sstderr
12000000
   5,512,869,824 bytes allocated in the heap
       1,312,416 bytes copied during GC
           3,336 bytes maximum residency (1 sample(s))
          13,048 bytes maximum slop
               1 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0: 10515 collections,     0 parallel,  0.06s,  0.04s elapsed
  Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    3.20s  (  3.25s elapsed)
  GC    time    0.06s  (  0.04s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    3.26s  (  3.29s elapsed)

  %GC time       1.9%  (1.2% elapsed)

  Alloc rate    1,723,838,984 bytes per MUT second

  Productivity  98.1% of total user, 97.1% of total elapsed

Хорошо, мы видим интересный паттерн.

  1. Тот же объем используемой памяти. Это означает, что это довольно хорошая реализация, хотя это может означать, что нам нужно протестировать более высокие входные данные выборки, чтобы увидеть, сможем ли мы найти разницу.
  2. Это занимает вдвое больше времени. Мы вернемся к некоторым предположениям о том, почему это позже.
  3. На самом деле это немного более продуктивно, но, учитывая, что GC не является огромной частью любой из этих программ, это не поможет нам ничего значительного.

Версия 3:

digits3 +RTS -sstderr
12000000
   3,231,154,752 bytes allocated in the heap
         832,724 bytes copied during GC
           3,292 bytes maximum residency (1 sample(s))
          12,100 bytes maximum slop
               1 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0:  6163 collections,     0 parallel,  0.02s,  0.02s elapsed
  Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    2.09s  (  2.08s elapsed)
  GC    time    0.02s  (  0.02s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    2.11s  (  2.10s elapsed)

  %GC time       0.7%  (1.0% elapsed)

  Alloc rate    1,545,701,615 bytes per MUT second

  Productivity  99.3% of total user, 99.3% of total elapsed

Хорошо, поэтому мы видим странные паттерны.

  1. Мы все еще используем 1 МБ общей памяти. Таким образом, мы не столкнулись с проблемой неэффективной памяти, что хорошо.
  2. Мы не совсем разбираемся с цифрами1, но у нас довольно легко бьют цифры2.
  3. Очень маленький GC. (Имейте в виду, что производительность более 95% очень хорошая, поэтому мы не имеем дело с чем-то слишком значительным.)

И, наконец, версия 4:

digits4 +RTS -sstderr
12000000
   1,347,856,636 bytes allocated in the heap
         270,692 bytes copied during GC
           3,180 bytes maximum residency (1 sample(s))
          12,100 bytes maximum slop
               1 MB total memory in use (0 MB lost due to fragmentation)

  Generation 0:  2570 collections,     0 parallel,  0.00s,  0.01s elapsed
  Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time    1.09s  (  1.08s elapsed)
  GC    time    0.00s  (  0.01s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time    1.09s  (  1.09s elapsed)

  %GC time       0.0%  (0.8% elapsed)

  Alloc rate    1,234,293,036 bytes per MUT second

  Productivity 100.0% of total user, 100.5% of total elapsed

Wowza! Давайте разберемся с этим:

  1. Мы все еще на 1МБ. Это почти наверняка особенность этих реализаций, так как они остаются на 1 МБ на входах 5e5 и 5e7. Завещание лени, если хотите.
  2. Мы сократили около 32% нашего первоначального времени, что довольно впечатляюще.
  3. Я подозреваю, что проценты здесь отражают гранулярность в мониторинге -sstderr, а не какие-либо вычисления для сверхсветовых частиц.
12 голосов
/ 31 января 2011

Отвечая на вопрос "почему rem вместо мода?" в комментариях. При работе с положительными значениями rem x y === mod x y единственное замечание - это производительность:

> import Test.QuickCheck
> quickCheck (\x y -> x > 0 && y > 0 ==> x `rem` y == x `mod` y)

Так что такое производительность? Если у вас нет веских причин не делать этого (а быть ленивым - не веская причина и не знать Критерий), тогда используйте хороший инструмент для тестирования, я использовал Критерий:

$ cat useRem.hs 
import Criterion
import Criterion.Main

list :: [Integer]
list = [1..10000]

main = defaultMain
        [ bench "mod" (nf (map (`mod` 7)) list)
        , bench "rem" (nf (map (`rem` 7)) list)
        ]

Запуск показывает, что rem заметно лучше, чем mod (скомпилировано с -O2):

$ ./useRem 
...
benchmarking mod
...
mean: 590.4692 us, lb 589.2473 us, ub 592.1766 us, ci 0.950

benchmarking rem
...
mean: 394.1580 us, lb 393.2415 us, ub 395.4184 us, ci 0.950
...