-
Notifications
You must be signed in to change notification settings - Fork 91
Description
I've been debugging an issue in a server application that stores a large (multi-gigabyte) state in memory and occasionally serialises it to disk using serialise. In principle this serialisation should be able to happen in constant space. However, when it does so, memory usage grows significantly and forces major GCs (which we want to avoid for performance reasons, as we're using the copying collector).
The following is an attempt to reproduce the issue (using only cborg). The script constructs some fully-evaluated data in memory, then serialises it to disk. During the serialisation stage (between the second and third vertical bars in the first graph) we see that the live bytes remains constant (i.e. unnecessary data is not being retained on the heap), but the total blocks size grows significantly. By looking at the +RTS -Dg output it appears that the growth is due to blocks going straight into generation 1, and hence not being freed until a major GC. I would expect instead that blocks allocated during serialisation rapidly become garbage, and hence stay in the nursery or generation 0 to be freed in a minor GC. Perhaps eager promotion might be kicking in somewhere?
This reproduction applies to GHC 9.6.1, cborg 0.2.8.0 and bytestring 0.11.4.0, although I've also seen it on earlier versions. It's not obvious to me whether this is an issue in one of the libraries or GHC itself, but I found a related issue in serialise (#317) so this seemed like a reasonable place to report it.
Curiously this seems to depend on the structure of the state type. The problematic behaviour occurs if the state is a pair of lists of unit, but if the state is a single list of unit (using the definitions of mkState and encode guarded by #ifdef GOOD) then we get the expected behaviour, where the blocks size remains constant, as shown in the second graph. Exactly which state types work seems to vary somewhat with GHC/library versions and other details of the surrounding code.
Thanks to @bgamari and @mpickering for helping me get this far. Any further insights would be very welcome. 😁
Unexpected behaviour
Expected behaviour
With -DGOOD:
Reproducer
import Control.DeepSeq
import Debug.Trace
import System.Environment
import System.Mem
import qualified Codec.CBOR.Encoding as CBOR
import qualified Codec.CBOR.Write as CBOR
import qualified Data.ByteString.Builder as Builder
main :: IO ()
main = do
[n] <- getArgs
let st = mkState (read n)
print (rnf st)
traceMarkerIO "End rnf"
performMajorGC
traceMarkerIO "Start serialise"
Builder.writeFile "test.cbor" $ CBOR.toBuilder $ encode st
traceMarkerIO "End serialise"
performMajorGC
traceMarkerIO "Start second rnf"
print (rnf st)
#ifdef GOOD
mkState :: Int -> [()]
mkState n = replicate (2*n) ()
encode :: [()] -> CBOR.Encoding
encode = encode'
#else
mkState :: Int -> ([()], [()])
mkState n = (replicate n (), replicate n ())
encode :: ([()], [()]) -> CBOR.Encoding
encode (xs, ys) = CBOR.encodeListLen 2 <> encode' xs <> encode' ys
#endif
encode' :: [()] -> CBOR.Encoding
encode' [] = CBOR.encodeListLen 0
encode' xs = CBOR.encodeListLenIndef <> Prelude.foldr (\_x r -> CBOR.encodeNull <> r) CBOR.encodeBreak xscabal-version: 2.4
name: cbortest
version: 0.1.0.0
executable cbortest
main-is: CBORTest.hs
build-depends: base, cborg, deepseq, bytestring
default-language: Haskell2010
ghc-options: -debug -threaded -rtsopts -Wall
if flag(good)
cpp-options: -DGOOD
Flag good
Description: Use the good version
Default: False
Manual: True
cabal run cbortest -- 3000000 +RTS -s -l -N1 && eventlog2html cbortest.eventlog

