Подтвердить что ты не робот

Расходы Haskell/GHC на каждую память

Я пытаюсь понять, насколько дорогой (зеленый) поток в Haskell (GHC 7.10.1 на OS X 10.10.5) действительно. Я знаю, что это супер дешево по сравнению с реальным потоком ОС, как для использования памяти, так и для процессора.

Итак, я начал писать суперпростую программу с вилками n (зеленых) потоков (используя отличную библиотеку async) а затем просто спит каждый поток за m секунды.

Ну, это достаточно легко:

$ cat PerTheadMem.hs 
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (mapConcurrently)
import System.Environment (getArgs)

main = do
    args <- getArgs
    let (numThreads, sleep) = case args of
                                numS:sleepS:[] -> (read numS :: Int, read sleepS :: Int)
                                _ -> error "wrong args"
    mapConcurrently (\_ -> threadDelay (sleep*1000*1000)) [1..numThreads]

и прежде всего, скомпилируйте и запустите его:

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.10.1
$ ghc -rtsopts -O3 -prof -auto-all -caf-all PerTheadMem.hs
$ time ./PerTheadMem 100000 10 +RTS -sstderr

который должен использовать потоки 100k и ждать 10 секунд в каждом, а затем распечатать нам некоторую информацию:

$ time ./PerTheadMem 100000 10 +RTS -sstderr
340,942,368 bytes allocated in the heap
880,767,000 bytes copied during GC
164,702,328 bytes maximum residency (11 sample(s))
21,736,080 bytes maximum slop
350 MB total memory in use (0 MB lost due to fragmentation)

Tot time (elapsed)  Avg pause  Max pause
Gen  0       648 colls,     0 par    0.373s   0.415s     0.0006s    0.0223s
Gen  1        11 colls,     0 par    0.298s   0.431s     0.0392s    0.1535s

INIT    time    0.000s  (  0.000s elapsed)
MUT     time   79.062s  ( 92.803s elapsed)
GC      time    0.670s  (  0.846s elapsed)
RP      time    0.000s  (  0.000s elapsed)
PROF    time    0.000s  (  0.000s elapsed)
EXIT    time    0.065s  (  0.091s elapsed)
Total   time   79.798s  ( 93.740s elapsed)

%GC     time       0.8%  (0.9% elapsed)

Alloc rate    4,312,344 bytes per MUT second

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


real    1m33.757s
user    1m19.799s
sys 0m2.260s

Потребовалось довольно много времени (1m33.757s), учитывая, что каждый поток должен только ждать 10 секунд, но сейчас мы построили его без потоковой передачи. В общем, мы использовали 350 МБ, что не так уж плохо, что 3,5 КБ на поток. Учитывая, что размер начального стека (-ki равен 1 KB).

Правильно, но теперь пусть компиляция находится в поточном режиме и посмотрим, получим ли мы быстрее:

$ ghc -rtsopts -O3 -prof -auto-all -caf-all -threaded PerTheadMem.hs
$ time ./PerTheadMem 100000 10 +RTS -sstderr
3,996,165,664 bytes allocated in the heap
2,294,502,968 bytes copied during GC
3,443,038,400 bytes maximum residency (20 sample(s))
14,842,600 bytes maximum slop
3657 MB total memory in use (0 MB lost due to fragmentation)

Tot time (elapsed)  Avg pause  Max pause
Gen  0      6435 colls,     0 par    0.860s   1.022s     0.0002s    0.0028s
Gen  1        20 colls,     0 par    2.206s   2.740s     0.1370s    0.3874s

TASKS: 4 (1 bound, 3 peak workers (3 total), using -N1)

SPARKS: 0 (0 converted, 0 overflowed, 0 dud, 0 GC'd, 0 fizzled)

INIT    time    0.000s  (  0.001s elapsed)
MUT     time    0.879s  (  8.534s elapsed)
GC      time    3.066s  (  3.762s elapsed)
RP      time    0.000s  (  0.000s elapsed)
PROF    time    0.000s  (  0.000s elapsed)
EXIT    time    0.074s  (  0.247s elapsed)
Total   time    4.021s  ( 12.545s elapsed)

Alloc rate    4,544,893,364 bytes per MUT second

Productivity  23.7% of total user, 7.6% of total elapsed

gc_alloc_block_sync: 0
whitehole_spin: 0
gen[0].sync: 0
gen[1].sync: 0

real    0m12.565s
user    0m4.021s
sys 0m1.154s

Ничего себе, гораздо быстрее, всего 12 секунд, лучше. Из Activity Monitor я увидел, что он примерно использовал 4 потока ОС для зеленых потоков 100 тыс., Что имеет смысл.

Однако 3657 МБ общей памяти! Это в 10 раз больше, чем не-резьбовая версия...

До сих пор я не делал профилирования с использованием -prof или -hy или так. Чтобы исследовать немного больше, я сделал некоторое профилирование кучи (-hy) в отдельных прогонах. Использование памяти в обоих случаях не изменилось, графики профилирования кучи выглядят интересным образом (слева: не-threaded, right: threaded), но я не могу найти причину разницы в 10 раз. профиль кучи разницы

Разграничение вывода профилирования (.prof файлов) Я также не могу найти никакой реальной разницы. prof diffs

Поэтому мой вопрос: откуда происходит 10-кратное различие в использовании памяти?

РЕДАКТИРОВАТЬ: просто упомянуть об этом: та же разница применяется, когда программа даже не скомпилирована с поддержкой профилирования. Таким образом, запуск time ./PerTheadMem 100000 10 +RTS -sstderr с ghc -rtsopts -threaded -fforce-recomp PerTheadMem.hs равен 3559 МБ. И с ghc -rtsopts -fforce-recomp PerTheadMem.hs он 395 МБ.

РЕДАКТИРОВАТЬ 2. В Linux (GHC 7.10.2 on Linux 3.13.0-32-generic #57-Ubuntu SMP, x86_64) происходит одно и то же: Non-threaded 460 МБ в 1m28.538s и threaded составляет 3483 МБ - 12,604s. /usr/bin/time -v ... сообщает Maximum resident set size (kbytes): 413684 и Maximum resident set size (kbytes): 1645384 соответственно.

РЕДАКТИРОВАТЬ 3. Также была изменена программа для непосредственного использования forkIO:

import Control.Concurrent (threadDelay, forkIO)
import Control.Concurrent.MVar
import Control.Monad (mapM_)
import System.Environment (getArgs)

main = do
    args <- getArgs
    let (numThreads, sleep) = case args of
                                numS:sleepS:[] -> (read numS :: Int, read sleepS :: Int)
                                _ -> error "wrong args"
    mvar <- newEmptyMVar
    mapM_ (\_ -> forkIO $ threadDelay (sleep*1000*1000) >> putMVar mvar ())
          [1..numThreads]
    mapM_ (\_ -> takeMVar mvar) [1..numThreads]

И это ничего не меняет: non-threaded: 152 MB, threaded: 3308 MB.

4b9b3361

Ответ 1

ИМХО, виновник threadDelay. * threadDelay ** использует много памяти. Вот программа, эквивалентная вашей, которая лучше работает с памятью. Это гарантирует, что все потоки будут выполняться одновременно, имея длительное вычисление.

uBound = 38
lBound = 34

doSomething :: Integer -> Integer
doSomething 0 = 1
doSomething 1 = 1
doSomething n | n < uBound && n > 0 = let
                  a = doSomething (n-1) 
                  b = doSomething (n-2) 
                in a `seq` b `seq` (a + b)
              | otherwise = doSomething (n `mod` uBound )

e :: Chan Integer -> Int -> IO ()
e mvar i = 
    do
        let y = doSomething . fromIntegral $ lBound + (fromIntegral i `mod` (uBound - lBound) ) 
        y `seq` writeChan mvar y

main = 
    do
        args <- getArgs
        let (numThreads, sleep) = case args of
                                    numS:sleepS:[] -> (read numS :: Int, read sleepS :: Int)
                                    _ -> error "wrong args"
            dld = (sleep*1000*1000) 
        chan <- newChan
        mapM_ (\i -> forkIO $ e chan i) [1..numThreads]
        putStrLn "All threads created"
        mapM_ (\_ -> readChan chan >>= putStrLn . show ) [1..numThreads]
        putStrLn "All read"

И вот статистика времени:

 $ ghc -rtsopts -O -threaded  test.hs
 $ ./test 200 10 +RTS -sstderr -N4

 133,541,985,480 bytes allocated in the heap
     176,531,576 bytes copied during GC
         356,384 bytes maximum residency (16 sample(s))
          94,256 bytes maximum slop
               4 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     64246 colls, 64246 par    1.185s   0.901s     0.0000s    0.0274s
  Gen  1        16 colls,    15 par    0.004s   0.002s     0.0001s    0.0002s

  Parallel GC work balance: 65.96% (serial 0%, perfect 100%)

  TASKS: 10 (1 bound, 9 peak workers (9 total), using -N4)

  SPARKS: 0 (0 converted, 0 overflowed, 0 dud, 0 GC'd, 0 fizzled)

  INIT    time    0.000s  (  0.003s elapsed)
  MUT     time   63.747s  ( 16.333s elapsed)
  GC      time    1.189s  (  0.903s elapsed)
  EXIT    time    0.001s  (  0.000s elapsed)
  Total   time   64.938s  ( 17.239s elapsed)

  Alloc rate    2,094,861,384 bytes per MUT second

  Productivity  98.2% of total user, 369.8% of total elapsed

gc_alloc_block_sync: 98548
whitehole_spin: 0
gen[0].sync: 0
gen[1].sync: 2

Максимальное место жительства составляет около 1,5 кб за нить. Я немного поиграл с количеством потоков и продолжительностью вычислений. Поскольку потоки начинают делать вещи сразу после forkIO, создание 100000 потоков на самом деле занимает очень много времени. Но результаты проведены для 1000 потоков.

Вот еще одна программа, в которой threadDelay был "factored out", этот не использует никакого процессора и может быть легко выполнен с 100 000 потоками:

e :: MVar () -> MVar () -> IO ()
e start end = 
    do
        takeMVar start
        putMVar end ()

main = 
    do
        args <- getArgs
        let (numThreads, sleep) = case args of
                                    numS:sleepS:[] -> (read numS :: Int, read sleepS :: Int)
                                    _ -> error "wrong args"
        starts <- mapM (const newEmptyMVar ) [1..numThreads]
        ends <- mapM (const newEmptyMVar ) [1..numThreads]
        mapM_ (\ (start,end) -> forkIO $ e start end) (zip starts ends)
        mapM_ (\ start -> putMVar start () ) starts
        putStrLn "All threads created"
        threadDelay (sleep * 1000 * 1000)
        mapM_ (\ end -> takeMVar end ) ends
        putStrLn "All done"

И результаты:

     129,270,632 bytes allocated in the heap
     404,154,872 bytes copied during GC
      77,844,160 bytes maximum residency (10 sample(s))
      10,929,688 bytes maximum slop
             165 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0       128 colls,   128 par    0.178s   0.079s     0.0006s    0.0152s
  Gen  1        10 colls,     9 par    0.367s   0.137s     0.0137s    0.0325s

  Parallel GC work balance: 50.09% (serial 0%, perfect 100%)

  TASKS: 10 (1 bound, 9 peak workers (9 total), using -N4)

  SPARKS: 0 (0 converted, 0 overflowed, 0 dud, 0 GC'd, 0 fizzled)

  INIT    time    0.000s  (  0.001s elapsed)
  MUT     time    0.189s  ( 10.094s elapsed)
  GC      time    0.545s  (  0.217s elapsed)
  EXIT    time    0.001s  (  0.002s elapsed)
  Total   time    0.735s  ( 10.313s elapsed)

  Alloc rate    685,509,460 bytes per MUT second

  Productivity  25.9% of total user, 1.8% of total elapsed

На моем i5 требуется меньше одной секунды, чтобы создать потоки 100000 и поставить "start" mvar. Пиковая резидентность составляет около 778 байт на поток, совсем не плохо!


Проверяя реализацию threadDelay, мы видим, что он действительно отличается для случая с резьбой и без резьбы:

https://hackage.haskell.org/package/base-4.8.1.0/docs/src/GHC.Conc.IO.html#threadDelay

Затем здесь: https://hackage.haskell.org/package/base-4.8.1.0/docs/src/GHC.Event.TimerManager.html

который выглядит невинно. Но более старая версия базы имеет тайное написание (памяти) doom для тех, которые вызывают threadDelay:

https://hackage.haskell.org/package/base-4.4.0.0/docs/src/GHC-Event-Manager.html#line-121

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