boring — fast, portable CBOR serialization for Clojure and ClojureScript
Clojure's serialization story is split three ways. ⚡ <https://github.com/taoensso/nippy%7Cnippy> and <https://github.com/mpenet/hako%7Chako> are fast and JVM-only. <https://github.com/clojure/data.fressian%7Cfressian> is portable across Clojure but speaks to no other language. <https://github.com/cognitect/transit-clj%7Ctransit> speaks to other languages, but its own README says what it's for: "Transit is intended primarily as a wire protocol for transferring data between applications. If storing Transit data durably, readers and writers are expected to use the same version of Transit…" The libraries are at 1.x; the spec is at 0.8. Reasonable for a wire protocol, poor for an archive — and an archive is what <https://github.com/replikativ/datahike%7Cdatahike> needed.
boring takes the reach: it's <https://www.rfc-editor.org/rfc/rfc8949%7CCBOR>, an IETF standard with implementations in 26 languages. On the JVM it gives up nothing for it.
Measured on nippy's own benchmark — their stress-data, their filter, their timing loop:
codec round µs bytes
boring 595 15 326
nippy/fast 932 17 105
nippy (LZ4) 1 102 8 518
boring + zstd 1 102 4 900
fressian 2 860 12 222
pr-str/read 5 369 15 880
Raw, boring is 1.6× nippy/fast. Compressed — which is what a storage layer actually writes — boring+zstd matches nippy's default round-trip time at 1.7× smaller.
Everything Clojure has round-trips without a schema and without registering anything: records, sets, metadata, bignums, BigDecimal with its scale, ratios, sorted maps, queues, instants, UUIDs, regexes, primitive arrays as RFC 8746 typed arrays. The measure of "everything" is someone else's — 46 of the 49 keys in nippy's stress-data, asserted in CI against the live nippy dependency. The same .cljc conformance suite runs on both platforms, and Python (cbor2) and Rust (ciborium) reference readers run in CI against the same fixtures, so "other languages can read it" is a test rather than a claim.
Where it loses: hako is faster on deeply nested maps and integer vectors, and on ClojureScript transit-cljs is 1.5–2.7× faster on generic data (it writes JSON text, so JSON.parse does its structural walk in native C++) — though boring is smaller on the wire on every payload, and beats it on arrays of same-shaped maps. Full tables, methodology and profiles are in the docs.
The library is beta. The format is not — there's a written compatibility policy and a golden cross-version corpus behind it.
Apache-2.0. Feedback and bug reports very welcome.
github <https://github.com/replikativ/boring>@mpenet I would be happy to cover your hako use cases as well if this is interesting for you. I couldn't quite identify the benefits of the native memory mapped buffers though, it was not obvious from the code and did not provide speed benefits. In general avoiding allocations got me into hako's ballpark.
@alekcz360 would be cool if you could stress test boring a bit then I would be comfortable to replace clj-cbor for the migration code.
FFM: It's not only avoiding allocations but also relieving the jvm gc from having to do work related to encoding/decoding. It's reallyabout memory lifecycle ownership and control.
The gc can get busy on other tasks. So while it doesn't show necessarily on a benchmark of hako, it might be beneficial on other parts where it's used by relieving gc pressure
You can also go off heap with bytebuffers but the api is meh and it's missing features arenas give you for free, like thread confinment
FFM is the future for this kind of work on the jvm
It will only keep improving, I am not sure the same is true for the older alternatives
Ultimately all this libs might just converge on performance, we're all applying the same kind of tricks. Personally I wanted deep integration with arenas/mem-segments. If the places where you use it speak this language the benefits grow
I am happy to avoid allocations and I also explore different strategies to manage memory more directly, e.g. escape analysis in the Raster compiler (which allows me to compile Clojure to GPU). I am mostly bothered that Clojure serializes into formats that are super niche and not readable by anyone else really. For me this was a proof that CBOR is good enough. If this is true for you then I am happy to add explorative support for off-heap memory operations to boring, too. But maybe hako has other advantages. CBOR is not perfect, but I think people underestimate how much buy-in comes from custom serialization formats.
Hako is highly optimized for maps, given how prevalent they are in clj that made sense
I dont think cbor can achieve the same level of packing without compressing, which adds overhead and also something I want to keep out of the picture for hako.
Some bench shows similar results as nippy fast but orders of magnitude less alloc. I think people focus too much on raw speed. I am aiming at near 0 alloc on encode path, I am already there for a few datatype (the 40B is just the arena slice overhead, which can be avoided with encode-into-bb
Happy to discuss this on a call when you have time
I am on a long drive. Feel free to call! That would keep me awake
In my benchmarks CBOR (which mind you can be extended inside out, which do optionally for boring's own packed format), is size-wise and allocation-wise very close to hako and sometimes beats it. I think if you care about sizes then compression is almost always worth it, and no current serialization format has a fused compression integrated that can beat any decent off the shelf general compressor, which also compresses inside your strings, arrays, etc. So I think the picture of memory usage advantages is not that clear to me. I am happy to be challenged with adversarial examples 🙂.
I can see off heap advantages e.g. operating maybe on mmap'ed files, but I think that is easy to do with boring as is already.
I am not saying you should use CBOR, but see it as a challenge whether we can make it work 🙂. I genuinely think that giving Clojure a portable format in its representations that works out of the box for edn, many JVM types and defrecords would make it much easier to offer bindings to other languages more or less naturally. I can see though how for a cloud provider's internals it might still be silly to use CBOR, but maybe not...
I didn't work on hako for exoscale, it's a personal endeavor
I am somewhat awake again btw. Boring cost me quite a bit of sleep ...
I am not religious about the format, I want deep integration with FFM api tho
Just drove a gazillion hours, my turn to checkout :) . Happy to discuss this later
I just want deploying Clojure solutions to be nice to other languages. Inside the JVM we care a lot about hosting, but I think we lost the plot a bit with the serialization formats. To be fair back then only msgpack was portable (and it wasn't really, that is why CBOR exists). Maybe it is also a bit of a German/European standardization urge coming through 😉
I am all for standards. My knowledge of cbor might be outdated (I did write a lib for it for another language but it was ages ago).
Fyi hako/encode is here for convenience for people porting to hako, but the canonical/idiomatic usage is via encode-into
Encode and decode allocate an arena for the time of the call, which is not very efficient. Into is much faster/more-efficient. encode/decode are still very fast but that's not what folks should build upon.
There's a notion of arena/segment re-use, if you leverage that you can push it quite far
Ok, maybe I benchmarked wrong... will recheck.
Basically check hako reader/writer