Non-GC'd, contiguous storage for immutable data structures.
This package provides user-facing APIs for working with "compact regions", which hold a fully evaluated Haskell object graph. These regions maintain the invariant that no pointers live inside the struct that point outside it, which ensures efficient garbage collection without ever reading the structure contents (effectively, it works as a manually managed "oldest generation" which is never freed until the whole is released). This package is currently highly experimental, but we hope it may be useful to some people. It is GHC 8.2 and later only. The bare-bones library that ships with GHC is ghc-compact
.
compact
Non-GC'd, contiguous storage for immutable data structures.
This package provides user-facing APIs for working with "compact regions", which hold a fully evaluated Haskell object graph. These regions maintain the invariant that no pointers live inside the struct that point outside it, which ensures efficient garbage collection without ever reading the structure contents (effectively, it works as a manually managed "oldest generation" which is never freed until the whole is released).
When would you want to use a compact region? The simplest use case is this: you have some extremely large, long-lived, pointer data structure which GHC has uselessly been tracing when you have a major collection. If you place this structure in a compact region, after the initial cost of copying the data into the region, you should see a speedup in your major GC runs.
This package is currently highly experimental, but we hope it may be useful to some people. It is GHC 8.2 and later only. The bare-bones library that ships with GHC is ghc-compact
.
Quick start
Import
Data.Compact
Put some data in a compact region with
compact :: a -> IO (Compact a)
, e.g.,cr <- compact someBigDataStructure
, fully evaluating it in the process.Use
getCompact :: Compact a -> a
to get a pointer inside the region, e.g.,operateOnDataStructure (getCompact cr)
. The data pointed to by these pointers will not participate in GC.Import
Data.Compact.Serialize
to write and read compact regions from files.
Tutorial
Garbage collection savings. It's a little difficult to construct a compelling, small example showing the benefit, but here is a very simple case from the nofib
test suite, the spellcheck
program. spellcheck
is a very simple program which reads a dictionary into a set, and then tests an input word-by-word to see if it is in the set or not (yes, it is a very simple spell checker):
import System.Environment (getArgs)
import qualified Data.Set as Set
import System.IO
main = do
[file1,file2] <- getArgs
dict <- readFileLatin1 file1
input <- readFileLatin1 file2
let set = Set.fromList (words dict)
let tocheck = words input
print (filter (`Set.notMember` set) tocheck)
readFileLatin1 f = do
h <- openFile f ReadMode
hSetEncoding h latin1
hGetContents h
Converting this program to use a compact region on the dictionary is very simple: add import Data.Compact
, and convert let set = Set.fromList (words dict)
to read set <- fmap getCompact (compact (Set.fromList (words dict)))
:
import System.Environment (getArgs)
import qualified Data.Set as Set
import System.IO
import Data.Compact -- **
main = do
[file1,file2] <- getArgs
dict <- readFileLatin1 file1
input <- readFileLatin1 file2
set <- fmap getCompact (compact (Set.fromList (words dict))) -- ***
let tocheck = words input
print (filter (`Set.notMember` set) tocheck)
readFileLatin1 f = do
h <- openFile f ReadMode
hSetEncoding h latin1
hGetContents h
Breaking down the new line: compact
takes an argument a
which must be pure and immutable and then copies it into a compact region. This function returns a Compact a
pointer, which is simultaneously a handle to the compact region as well as the data you copied into it. You get back the actual a
data that lives in the region using getCompact
.
Using the sample nofib
input (words and input), we can take a look at our GC stats before and after the change. To make the effect more pronounced, I've reduced the allocation area size to 256K, so that we do more major collections. Here are the stats with the original:
1,606,462,200 bytes allocated in the heap
727,499,032 bytes copied during GC
24,050,160 bytes maximum residency (21 sample(s))
107,144 bytes maximum slop
71 MB total memory in use (0 MB lost due to fragmentation)
Tot time (elapsed) Avg pause Max pause
Gen 0 6119 colls, 0 par 0.743s 0.754s 0.0001s 0.0023s
Gen 1 21 colls, 0 par 0.608s 0.611s 0.0291s 0.0582s
INIT time 0.000s ( 0.000s elapsed)
MUT time 2.012s ( 2.024s elapsed)
GC time 1.350s ( 1.365s elapsed)
EXIT time 0.000s ( 0.000s elapsed)
Total time 3.363s ( 3.389s elapsed)
%GC time 40.2% (40.3% elapsed)
Alloc rate 798,416,807 bytes per MUT second
Productivity 59.8% of total user, 59.7% of total elapsed
Here are the stats with compact regions:
1,630,448,408 bytes allocated in the heap
488,392,976 bytes copied during GC
24,104,152 bytes maximum residency (21 sample(s))
76,144 bytes maximum slop
55 MB total memory in use (0 MB lost due to fragmentation)
Tot time (elapsed) Avg pause Max pause
Gen 0 6119 colls, 0 par 0.755s 0.770s 0.0001s 0.0017s
Gen 1 21 colls, 0 par 0.147s 0.147s 0.0070s 0.0462s
INIT time 0.000s ( 0.000s elapsed)
MUT time 1.999s ( 2.054s elapsed)
GC time 0.902s ( 0.918s elapsed)
EXIT time 0.000s ( 0.000s elapsed)
Total time 2.901s ( 2.972s elapsed)
%GC time 31.1% (30.9% elapsed)
Alloc rate 815,689,434 bytes per MUT second
Productivity 68.9% of total user, 69.1% of total elapsed
You can see that while the version of the program with compact regions allocates slightly more (since it performs a copy on the set), it copies nearly half as much data during GC, reducing the time spent in major GCs by a factor of three. On this particular example, you don't actually save that much time overall (since the bulk of execution is spent in the mutator)--a reminder that one should always measure before one optimizes.
Serializing to disk. You can take the data in a compact region and save it to disk, so that you can load it up at a later point in time. This functionality is provided by Data.Compact.Serialized
: writeCompact
and unsafeReadCompact
let you write a compact to a file, and read it back again:
{-# LANGUAGE TypeApplications #-}
import Data.Compact
import Data.Compact.Serialize
main = do
orig_c <- compact ("I want to serialize this", True)
writeCompact @(String, Bool) "somefile" orig_c
res <- unsafeReadCompact @(String, Bool) "somefile"
case res of
Left err -> fail err
Right c -> print (getCompact c)
Compact regions written to handles this way are subject to some restrictions:
Our binary representation contains direct pointers to the info tables of objects in the region. This means that the info tables of the receiving process must be laid out in exactly the same way as from the original process; in practice, this means using static linking, using the exact same binary and turning off ASLR. This API does NOT do any safety checking and will probably segfault if you get it wrong. DO NOT run
unsafeReadCompact
on untrusted input.You must read out the value at the correct type. We will check this for you and raise an error if the types do not match. To tell
unsafeReadCompact
what type it should read out with, theTypeApplications
extension may come in handy (this extension is guaranteed to be available, since compact only supports GHC 8.2 or later!)