Есть ли способ быстрее сделать программу подсчета слов без использования нечистых трюков?

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

import qualified Data.Map as Map
import Data.List.Split
import Data.List
import Data.Ord

-- Count words
count = Map.toList . foldl' increment Map.empty
    where
        increment dict k = Map.insert k (1 + Map.findWithDefault 0 k dict) dict

-- Sort the counts
countAndSort = sortBy (flip $ comparing snd) . count

-- Pretty printing
pp :: Show a => [(String,a)] -> IO()
pp = putStrLn . foldl' format "" where
    format text (x,y) = text ++ "\n" ++ x ++ "\t" ++ show y

main = readFile  "pg13951.txt" >>= pp . take 50 .countAndSort . splitOn " "

Проблема в том, что он в 16 раз медленнее, чем моя реализация python с изменяемым dict:

def increment(dic,word):
    dic[word] = dic.get(word,0) + 1
    return dic

print sorted(reduce(increment,open("pg13951.txt").read().split(),{}).items(),key=lambda e:-e[1])[:50]

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

$ ghc -rtsopts count.hs
$ ./count +RTS -sstderr

de      7682
et      4423
la      4238
<snip>
d'Artagnan      511
M.      502
c'est   443
d'Artagnan,     443

     705,888,048 bytes allocated in the heap
     655,511,720 bytes copied during GC
     139,823,800 bytes maximum residency (10 sample(s))
       1,049,416 bytes maximum slop
             287 MB total memory in use (0 MB lost due to fragmentation)

                                    Tot time (elapsed)  Avg pause  Max pause
  Gen  0      1366 colls,     0 par    2.16s    2.26s     0.0017s    0.0072s
  Gen  1        10 colls,     0 par    2.86s    3.09s     0.3093s    1.5055s

  INIT    time    0.00s  (  0.00s elapsed)
  MUT     time    3.18s  (  3.36s elapsed)
  GC      time    5.02s  (  5.36s elapsed)
  EXIT    time    0.00s  (  0.00s elapsed)
  Total   time    8.20s  (  8.72s elapsed)

  %GC     time      61.2%  (61.4% elapsed)

  Alloc rate    221,831,366 bytes per MUT second

  Productivity  38.8% of total user, 36.5% of total elapsed

Мой вопрос: есть ли способ сделать эту программу более эффективной, не прибегая к грязным трюкам, таким как работа в монаде IO, использование изменяемых структур данных и т.д.?

PS: файл данных доступен по следующему URL-адресу: http://www.gutenberg.org/cache/epub/13951/pg13951.txt

Ответ 1

Вот несколько быстрых и простых оптимизаций, которые я пробовал.

Оригинальная версия на моей машине:

real    0m1.539s
user    0m1.452s
sys 0m0.076s
  • Вместо использования insert и foldl' вы можете использовать fromListWith для подсчета слова.

    count = Map.toList . Map.fromListWith (+) . flip zip (repeat 1)
    

    Это более чем вдвое быстрее.

    real    0m0.687s
    user    0m0.648s
    sys 0m0.032s
    
  • Тип String - это связанный список символов, который позволяет манипулировать струны довольно изящные, но неэффективные. Мы можем использовать тип Text, чтобы получить больше эффективная обработка строк. Я также переписал вашу функцию pp для использования unlines вместо foldl' и используйте words вместо splitOn для исходного раскола.

    {-# LANGUAGE OverloadedStrings #-}
    
    import Data.Monoid
    import Data.Text (Text)
    import qualified Data.Text as T
    import qualified Data.Text.IO as T
    
    pp :: Show a => [(Text,a)] -> IO()
    pp = T.putStrLn . T.unlines . map format where
        format (x,y) = x <> "\t" <> (T.pack $ show y)
    
    main = T.readFile  "pg13951.txt" >>= pp . take 50 .countAndSort . T.words
    

    Опять же, в два раза быстрее, чем предыдущий шаг.

    real    0m0.330s
    user    0m0.316s
    sys 0m0.008s
    
  • Используйте строгую версию Map

    import qualified Data.Map.Strict as Map
    

    Увеличение скорости на 20%

    real    0m0.265s
    user    0m0.252s
    sys 0m0.008s