multithreading haskell memory ghc

multithreading - Haskell/GHC por costes de memoria de hilo



memory (1)

Estoy tratando de entender lo caro que es un hilo (verde) en Haskell (GHC 7.10.1 en OS X 10.10.5) realmente. Soy consciente de que es súper barato en comparación con un subproceso de sistema operativo real, tanto para el uso de la memoria como para la CPU.

Correcto, entonces comencé a escribir un programa súper simple con subprocesos n (verdes) (usando la excelente biblioteca async ) y luego duerme cada subproceso durante m segundos.

Bueno, eso es bastante fácil:

$ 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]

y antes que nada, vamos a compilarlo y ejecutarlo:

$ 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

eso debería incluir 100k hilos y esperar 10 segundos en cada uno y luego imprimirnos algo de información:

$ 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

Tomó bastante tiempo (1m33.757s) dado que se supone que cada subproceso solo espera 10s, pero lo hemos construido sin hilos, lo suficientemente justo por ahora. En total, usamos 350 MB, eso no es tan malo, eso es 3.5 KB por hilo. Dado que el tamaño de pila inicial ( -ki es 1 KB ).

Correcto, pero ahora compilemos está en modo de subprocesos y veamos si podemos obtener algo más rápido:

$ 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

Wow, mucho más rápido, solo 12s ahora, mucho mejor. Desde el Monitor de actividad vi que usaba aproximadamente 4 subprocesos del sistema operativo para los 100k subprocesos verdes, lo que tiene sentido.

Sin embargo, 3657 MB de memoria total ! Eso es 10 veces más que la versión sin hilos utilizada ...

Hasta ahora, no hice ningún perfil usando -prof o -hy o así. Para investigar un poco más, hice algunos perfiles de pila ( -hy ) en ejecuciones separadas . El uso de la memoria no cambió en ninguno de los casos, los gráficos de perfiles del montón parecen interesantes (izquierda: sin hilos, derecha: hilos) pero no puedo encontrar la razón de la diferencia de 10 veces.

Al diferenciar la salida del perfil (archivos .prof ) tampoco puedo encontrar ninguna diferencia real.

Por lo tanto, mi pregunta: ¿De dónde proviene la diferencia de 10 veces en el uso de la memoria?

EDITAR : Solo para mencionarlo: La misma diferencia se aplica cuando el programa ni siquiera está compilado con soporte de perfiles. Entonces, el time ./PerTheadMem 100000 10 +RTS -sstderr ejecución time ./PerTheadMem 100000 10 +RTS -sstderr con ghc -rtsopts -threaded -fforce-recomp PerTheadMem.hs es de 3559 MB. Y con ghc -rtsopts -fforce-recomp PerTheadMem.hs es de 395 MB.

EDIT 2 : en Linux ( GHC 7.10.2 en Linux 3.13.0-32-generic #57-Ubuntu SMP, x86_64 ) sucede lo mismo: 460 MB sin subprocesos en 1: 28.538 y con hilos: 3483 MB es 12.604s. /usr/bin/time -v ... informa Maximum resident set size (kbytes): 413684 y Maximum resident set size (kbytes): 413684 Maximum resident set size (kbytes): 1645384 respectivamente.

EDIT 3 : también cambió el programa para usar forkIO directamente:

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]

Y no cambia nada: sin hilos: 152 MB, con hilos: 3308 MB.


En mi humilde opinión, el culpable es threadDelay . * threadDelay ** usa mucha memoria. Aquí hay un programa equivalente al tuyo que se comporta mejor con la memoria. Asegura que todos los subprocesos se ejecuten simultáneamente al tener un cálculo de larga ejecución.

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"

Y aquí están las estadísticas de tiempo:

$ 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

La residencia máxima es de alrededor de 1.5 kb por hilo. Jugué un poco con el número de subprocesos y la longitud de ejecución del cálculo. Dado que los hilos comienzan a hacer cosas inmediatamente después de forkIO, la creación de 100000 hilos en realidad lleva mucho tiempo. Pero los resultados se mantuvieron para 1000 hilos.

Aquí hay otro programa en el que threadDelay ha sido "factorizado", este no usa ninguna CPU y se puede ejecutar fácilmente con 100000 subprocesos:

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"

Y los resultados:

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

En mi i5, toma menos de un segundo crear los 100000 subprocesos y poner el mvar de "inicio". La residencia máxima es de alrededor de 778 bytes por subproceso, ¡no está nada mal!

Verificando la implementación de threadDelay, vemos que es efectivamente diferente para el caso de hilos y sin hilos:

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

Entonces aquí: https://hackage.haskell.org/package/base-4.8.1.0/docs/src/GHC.Event.TimerManager.html

que parece bastante inocente. Pero una versión anterior de base tiene una ortografía arcana de (memoria) doom para aquellos que invocan threadDelay:

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

Si todavía hay un problema o no, es difícil decirlo. Sin embargo, siempre se puede esperar que un programa concurrente "en la vida real" no tenga que tener demasiados hilos esperando en threadDelay al mismo tiempo. Por un lado voy a vigilar mi uso de threadDelay a partir de ahora.