haskell / haskell/aeson

`Text` (and `Key`) sharing in `Object`, and `String`

Open
#960 8 comments 1 reaction 0 assignees View on GitHub
Dominant language
Haskell
Stars
1.3k
Forks
336
Avg merge
3d 7h
Merged PRs (30d)
4

Description

# Introduction

I would like to discuss a change in `aeson` to introduce an explicit sharing in `Key`, and actually, all `Text` values used during `decode`.

My motivation comes from the fact that I have important json files containing list of items, which are them-self list of other items. To give you a rough idea, I'm serializing the following:

```haskell
data Patient = Patient {
patientId :: Text,
patientAttributes :: Vector Attribute
}

data Attribute = Attribute {
attributeName :: Text,
attributeValue :: Double,
attributeFlag = Flag
}

data Flag = FlagA | FlagB | FlagC
```

In the current context, I do have 50000 patients, each containing 1000 attributes. `patientId` is different for each patient, however the values for `attributeName` are the same for all patients.

At the end of the decoding process, I do have 1000 unique `Text` values for my `attributeName`, but I actually ends up with 50 millions independent text values. Sharing would definitely help. I can recover sharing after using a post processing, but my memory already peaked during the decoding phase.

Moreover, the decoding process first decode to `Value` before decoding to my final types. During the decoding, many more `Text` representations are kept: the one for the different `Key` (i.e. `patientId`, `patientAttributes`, `attributeName`, `attributeValue`, `attributeFlag`) as well as the `Text` value for `Flag` (which will be either a `Text` representing `FlagA`, `FlagB`, or `FlagC`, or even worse, an object with `tag` and `content` fields, as well as the associated values).

This also represents a lot of duplication of value which may be shared instead.

# Proposal

I propose to extend the internal parser to store a state as `Map Text Text`. This map will be used in `jstring_` parser so instead of returning the just parsed text, it will look in the `Map` for the same text and return that value instead (effectively sharing it), or add the value to the cache. The cache will be propagated by the `Parser` monad.

Pseudo implementation / use case:

```haskell
jstring_ :: Parser Text
{-# INLINE jstring_ #-}
jstring_ = cacheText $ do
s <- A.takeWhile (\w -> w /= W8_DOUBLE_QUOTE && w /= W8_BACKSLASH && w >= 0x20 && w < 0x80)
mw <- A.peekWord8
case mw of
Nothing -> fail "string without end"
Just W8_DOUBLE_QUOTE -> A.anyWord8 $> unsafeDecodeASCII s
Just w | w < 0x20 -> fail "unescaped control character"
_ -> jstringSlow s

cacheText :: Parser Text -> Parser Text
cacheText p = do
t <- p
cache <- getCache
case Map.lookup t cache of
Just t' -> pure t'
Nothing -> do
saveCache t
pure t
```

Where `saveCache` and `getCache` reads and write the parser internal `Map`

Note that the `Map` is created when starting the `Parser` and hence destructed at the end of the parsing.

# Impacts

This should not change the public API, everything will be hidden inside the opaque `Parser` type, so I'm not anticipating any breakage.

It should have a minor negative impact on debugging performance, considering that we will have to do a lookup in the map for each key.

Regarding memory usage, the `Map` size will grow with the number of unique `Text` value in the input json file. However, the storage for `Text` will be shared between the map key, value, and usage in the final `Value` datastructure, so we will only pay for the `Map` structure overhead, which is negligible compared to the size of `Text`.

In summary, if we decode a value with no sharing at all (i.e. no duplicated keys), it will be a negligible memory overhead. But as soon as sharing will appear, huge memory win will appear too. It may also improve decoding performances by removing pressure on the GC.

It's unclear right now for me, but the `Map` may even be `Map ByteString Text` and the caching may happen in `jstring_` (and `jstringSlow`) before the conversion to `Text`, which may actually save time, at the cost of keeping in memory the `ByteString` chunks. Great care should be taken to ensure that it does not keep the whole input `ByteString` in memory, but even there, most of the time the input `ByteString` weights nothing compared to the `Text` overhead due to duplication.

# Discussion

What are your thoughts about this?

I think it could be a great win for performance and memory usage of decoding. I can focus only on "decoding" now, but encoding (using `toJSON`, no `toEncoding`) may also benefit from generating a structure with improved sharing. However, it will be more difficult to implement in `encode` because the state cannot be hidden inside an already existing opaque structure.

Also, please note that my proposal do not do any distinction between `Text` as used in the opaque `Key` structure, and `Text` used in the `String` json constructor. This can be discussed and perhaps sharing may be implemented only for one, or for both, but using separate strategies, ...

If approved, I'll do the implementations / benchmarks / ...

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.