Efficient logging of string data in Haskell's ST Monad
Asked Answered
P

1

6

I have a Haskell program that generates ~280M of logging text data during a run inside the ST monad. This is where virtually all memory consumption goes (with logging disabled the program allocates a grand total of 3MB real memory).

Problem is, I run out of memory. While the program runs memory consumption exceeds 1.5GB, and it finally runs out when it tries to write the log string to a file.

The log function takes a String and accumulates the log data into a string builder stored in an STRef in the environment:

import qualified Data.ByteString.Lazy.Builder as BB
...
myLogFunction s = do
    ...
    lift $ modifySTRef myStringBuilderRef (<> BB.stringUtf8 s)

I tried introducing strictness using bang patterns and modifySTRef', but this made memory consumption even worse.

I write the log string as recommended by the hPutBuilder documentation, like this:

    hSetBinaryMode h True
    hSetBuffering  h $ BlockBuffering Nothing
    BB.hPutBuilder h trace

This consumes several additional GBs of memory. I tried different buffering settings and converting to a lazy ByteString first (slightly better).

Qs:

  • How can I minimize memory consumption while the program runs? I'd expect given a tight ByteString representation and the appropriate amount of strictness I'd need little more memory than the ~280M of actual log data I'm storing.

  • How can I write the result to a file without allocating memory? I don't understand why Haskell needs GBs of memory to just stream some resident data into a file.

Edit:

Here's the memory profile for a small run (~42MB of log data). The total memory use is 3MB with logging disabled.

    15,632,058,700 bytes allocated in the heap
     4,168,127,708 bytes copied during GC
       343,530,916 bytes maximum residency (42 sample(s))
         7,149,352 bytes maximum slop
               931 MB total memory in use (0 MB lost due to fragmentation)

                                      Tot time (elapsed)  Avg pause  Max pause
    Gen  0     29975 colls,     0 par    5.96s    6.15s     0.0002s    0.0104s
    Gen  1        42 colls,     0 par    6.01s    7.16s     0.1705s    1.5604s

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

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

    INIT    time    0.00s  (  0.00s elapsed)
    MUT     time   32.38s  ( 33.87s elapsed)
    GC      time   11.97s  ( 13.31s elapsed)
    RP      time    0.00s  (  0.00s elapsed)
    PROF    time    0.00s  (  0.00s elapsed)
    EXIT    time    0.00s  (  0.00s elapsed)
    Total   time   44.35s  ( 47.18s elapsed)

    Alloc rate    482,749,347 bytes per MUT second

    Productivity  73.0% of total user, 68.6% of total elapsed

Edit:

I ran a memory profile with a small log run as asked:

profile http://imageshack.us/a/img14/9778/6a5o.png

I tried adding bang patterns, $!, deepseq/$!!, force and such in the relevant places, but it doesn't seem to make any difference. How do I force Haskell to actually take my string / printf expression etc. and put it in a tight ByteString instead of keeping all those [Char] lists and unevaluated thunks around?

Edit:

Here's the actual full trace function

trace s = do
     enable <- asks envTraceEnable
     when (enable) $ do
        envtrace <- asks envTrace
        let b = B8.pack s
        lift $ b `seq` modifySTRef' envtrace (<> BB.byteString b)

Is this 'strict' enough? Do I need to watch out for anything if I call this typeclass function inside my ReaderT/ST monad? Just so that it is actually called and not deferred in any way.

do
    trace $ printf "%i" myint

is fine?

Thanks!

Prentiss answered 15/8, 2013 at 8:55 Comment(12)
Logging is not about state and hence I would suggest you use Writer monad for itWingate
Even if convert from using a reader monad with an STRef to a writer I have the same situation. In the end it's a monoid of type Builder. I'd rather not add a WriterT in the transformer stack for no good reason.Prentiss
We need more data. Can you show us a heap profile? How is your log generated? If you use, for example, stringUtf8, then my suspicion is that the resulting Builder holds a large number of references to String, and that's where the memory goes.Unconstitutional
Try using byteString instead of stringUtf8 (i.e. doing String -> ByteString conversion before mappending to a Builder).Unconstitutional
Even if I use '(<> (BB.byteString $ Data.ByteString.Char8.pack s))' and use strictness everywhere memory consumption quick exceeds 1GB. I basically run a stateful computation and write ~100 characters to a log string every once in a while. But Haskell eats way more memory during the ST part and then needs GBs of additional memory to later write it to a file.Prentiss
Ok - build a heap profile graph with hp2ps and generated with the -hy flag. You'll almost certainly find that you're not releasing any memory at all until the end - your builder is holding on to entire object graphs, not just the ByteStrings. But as @MikhailGlushenkov pointed out, we need more data - and what you've provided isn't it.Weathering
The problem is almost certainly excessive laziness and not understanding the details of the Builder representation, by the way. Forcing a Builder does nothing. Forcing the ByteString being added to it is vital if the ByteString has an object graph in its construction.Weathering
@AN1 I tried reproducing this, but my version only consumes ~200MB to generate a 100 MB file, which looks reasonable: gist.github.com/23Skidoo/6242475Unconstitutional
Thanks, very helpful! I don't have Data.ByteString.Builder, only Data.ByteString.Lazy.Builder and my Data.STRef already defines modifySTRef', but even after those changes your program needs only 200MB for the 100MB logfile, which still seems somewhat excessive, but better. Applying all this to my code, consumption seems a little better (can now gen. a 410MB logfile with ~3GB RAM ), but still way off. I have no idea if having an actual trace function in a typeclass, using lift to get into ST or something like causes the additional laziness. Hm.Prentiss
@AN1 Try upgrading to a newer version of bytestring library. Data.ByteString.Lazy.Builder was renamed to Data.ByteString.Builder in bytestring-0.10.2.0.Unconstitutional
@AN1 Your heap profile shows large quantities of memory being consumed by plain Strings, as I suspected. I agree with @Weathering that this looks like excessive laziness. BTW, you can generate color output with hp2ps -c.Unconstitutional
As a Haskell newbie I'm a bit afraid to deviate from the Haskell Platform defaults, I managed to get myself into a bit of a cabal hell a few times ;-( Is there anything I can do to absolutely force full evaluation, deepseq etc.? I don't understand why all those Strings etc. are kept around. I'll update the main post with the actual full function.Prentiss
C
3

Since the log messages take that much memory, it would be more efficient to write them to file as soon as they are produced. This seems impossible because we are inside the ST monad, and you can't perform IO while in the ST monad.

But there is a way out: use some kind of coroutine monad transformer like those of the "pipes" package. Here is an example using pipes-3.3.0:

{-# LANGUAGE ExplicitForAll #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE LiberalTypeSynonyms #-}

import Control.Monad
import Control.Monad.ST
import Control.Monad.ST (stToIO) -- Transforms ST computations into IO computations
import Control.Monad.Trans
import Control.Monad.Morph (hoist) -- Changes the base monad of a monad transformer
import Control.Proxy.Prelude (stdoutD) -- Consumer that prints to stdout
import Control.Proxy.Core
import Control.Proxy.Core.Correct

import Data.STRef

simpleST :: ST s Bool
simpleST= do
    ref <- newSTRef True
    writeSTRef ref False
    readSTRef ref

-- Like simpleST, but emits log messages during the computation
loggingST :: Producer ProxyCorrect String (ST s) Bool
loggingST = do
    ref <- lift $ newSTRef True
    respond "Before writing"
    lift $ writeSTRef ref False
    respond "After writing"
    lift $ readSTRef ref

adapt :: (forall s . Producer ProxyCorrect String (ST s) a) ->
         Producer ProxyCorrect String IO a
adapt x = hoist stToIO x

main :: IO ()
main = do
    result <- runProxy $ (\_ -> adapt loggingST) >-> stdoutD
    putStrLn . show $ result

It prints the log to stdout. When run, it outputs the following:

Before writing
After writing
False

It works as follows: you emit the log messages in the producer using respond while still residing in the ST monad. That way you can log and still be sure that your computation doesn't perform some weird IO stuff. It forces you to pepper your code with lifts, though.

Once you have constructed your ST computation, you transform the base monad of the producer from ST to IO using hoist. hoist is a useful function that lets you change the tablecloth while the dishes are still on the table.

Now we are in IO-land! The only thing left to do is to connect the producer with a consumer that actually writes the messages (here they are printed to stdout, but you can just as easily connect to a consumer which writes to file.)

Countervail answered 15/8, 2013 at 12:16 Comment(1)
I have to admit this is a bit over my head, but I always wanted to check out the pipes package! But just to be clear, there's absolutely no problem with the size of the log messages. 280MB is totally fine! The problem is Haskell wasting GBs on 'Haskell stuff'. If I implemented my logging using a mutable unboxed vector with 500MB of Word8s and just wrote that to a file, everything would be fine. I'm just asking if somebody can tell me why the ByteString library needs like 5x more storage than expected and then allocates GBs of temporary memory just to write that data to a file.Prentiss

© 2022 - 2024 — McMap. All rights reserved.