Liking cljdoc? Tell your friends :D

Performance

Numbers, methodology, and the cases where boring loses.

Methodology

Everything below was taken with:

  • A quiet machine. Load average ≈ 4. An earlier round of numbers was taken on a loaded box and had to be withdrawn: run-to-run variance under load exceeded 60%, which is larger than most of the differences being measured.

  • Fresh objects per call, for every library. boring through boring/decode, hako through hako/decode, nippy through nippy/fast-thaw — each allocating its own reader per call. An earlier version of our benchmark compared boring's reused, warm-cache reader against hako's fresh one, which flattered boring and was not a fair comparison.

  • criterium quick-benchmark, mean, for the published tables. The interleaved A/B harness (bench/ab.clj) reports a minimum of 8 rounds instead, because it exists to compare two boring variants under load, where the minimum approximates the uncontended cost and the mean measures the machine. Do not mix figures from the two.

  • Allocation measured with getThreadAllocatedBytes, which is deterministic and immune to load, wherever a claim can be made about allocation instead of time.

  • A warm process, not a warm cell. Per-cell warmup is not enough: hako's small-map encode measured 2.52 / 1.30 / 1.15 / 1.08 µs across four consecutive runs of the same cell, so whichever cell runs first is penalised and the first block of any table is fiction. The suite warms every payload through every codec before it measures anything. Two byte-identical boring variants once timed 76% apart without this.

Reproduce with:

clojure -M:bench -m published        # exactly the tables on this page
clojure -M:bench -m published size   # the deterministic sections only; seconds
clojure -M:nippy-bench               # nippy's own benchmark, unmodified
bin/bench                            # the wider suite; several minutes

Every table below is emitted verbatim by one of the first three commands. They used to be hand-maintained, and had drifted: the wire-size table quoted 6 951 bytes for a datom-maps-200 that now measures 9 952, because the payload definition it was taken from was never committed. The payloads live in bench/published.clj now.

Output lands in target/bench/<timestamp>.txt with the machine description at the top, because a benchmark number without its hardware is a rumour. Sizes run first on purpose — they are deterministic, so they are the one section worth keeping from a run that turns out to be too noisy to trust. bench/README.md covers the individual harnesses.

JVM, µs/op

Lower is better. Bold is the winner.

payloadopboringboring :shapeshakonippy
small-mapencode0.140.220.200.24
small-mapdecode0.310.310.250.29
mixedencode0.130.210.180.27
mixeddecode0.210.210.180.22
nested-map-50encode5.295.604.087.01
nested-map-50decode7.087.114.348.06
datom-maps-200encode26.9115.4815.0545.02
datom-maps-200decode21.0111.4111.5449.97
long-vec-1kencode6.746.766.6510.91
long-vec-1kdecode11.0511.074.6510.23
str-maps-200encode23.1725.4823.3136.50
str-maps-200decode22.6313.7723.2038.86

Wire size, bytes:

payloadboringboring :shapeshakonippy
small-map56564341
mixed63635553
nested-map-501 5611 5611 0791 381
datom-maps-2009 9524 9825 16510 665
long-vec-1k2 7262 7262 7402 874
str-maps-2007 5504 5709 74111 465

boring beats nippy on all twelve timing cells, and on size for the two payloads where the shape machinery applies. Against hako — an experimental codec built for speed — it wins the small-payload encodes, ties datom-maps-200, wins str-maps-200 decode by 1.7× at 2.1× smaller on the wire, and loses the nested-map and integer-vector rows (see below).

The tier this table uses matters, and it flatters boring on the small rows. Every codec here allocates fresh per call, which is matched — but hako/encode builds a Writer and a confined Arena each time, and hako's intended path is the reused encode-into!. Reuse both sides and the small-payload encode wins reverse: small-map goes from boring 0.50 / hako 1.08 µs to boring 0.86 / hako 0.59. clojure -M:bench -m hako-ab is the tier-matched table — time and allocation, T1/T2/T3 — and it is the one to quote when comparing the two libraries as each is meant to be called. Summarised: hako wins map-heavy encode (1.1–1.8×) and map-heavy decode (~2×); boring wins vector-heavy decode (~1.3×) and allocates less on every payload measured.

The four small cells are close enough that the ordering is not stable across machines; treat 0.13 vs 0.18 µs as "the same". The gaps worth reading are the ones with a stated cause.

Compressed

The row that actually matters for a storage or wire codec, since konserve, kabel and every HTTP transport compress. zstd level 3, and nippy's own built-in LZ4 for the nippy/freeze column:

payloadboring+zstdboring :shapes+zstdhako+zstdfressian+zstdnippy (own LZ4)
small-map6565526045
mixed7272646957
nested-map-503543543883451 385
datom-maps-2001 1211 2371 1681 0142 488
long-vec-1k1 8511 8511 8611 5032 878
str-maps-2001 0621 1681 1189822 493

Two things worth noting:

  • Compression erases most of the uncompressed size differences. boring's 9 952-byte datom-maps-200 and nippy/fast's 10 665 both land near 1.1 KB. Only nippy's own LZ4 is meaningfully worse, and that is LZ4 versus zstd, not a format property. Choosing a codec on uncompressed size is choosing on a number your storage layer deletes.
  • :shapes is a small loss under compression — 1 237 against 1 121 on datom-maps-200. It removes exactly the repetition zstd is best at, and replaces it with a header zstd cannot exploit. :shapes is a win when you do not compress (2× smaller, and 1.8× faster to decode) and roughly a 10% cost when you do. The same reasoning applies to stringref, which is why it is on by default but not load-bearing.

fressian+zstd is 5–20% ahead of boring+zstd on four of six payloads. That is a real, if small, loss, and it is the entire remaining size argument for fressian.

On nippy's benchmark

clojure -M:nippy-bench reruns nippy's own benchmark — nippy's stress-data, nippy's reader+fressian filter, nippy's timing loop — across all six codecs nippy reports, plus boring and the compressed tiers:

codecfreeze µsthaw µsround µsbytes
boring28531059515 326
boring :shapes28831560315 326
nippy/fast39953393217 105
nippy (LZ4)5275751 1028 518
boring + zstd6374651 1024 900
boring :shapes + zstd6414601 1014 900
nippy/encrypted5466181 1648 546
fressian1 6471 2132 86012 222
fressian + zstd2 0071 1173 1244 600
pr-str + read-string2 2203 1495 36915 880
nippy/lzma26 3362 7439 0793 888

Raw against raw, boring is 1.6× nippy/fast and 4.8× fressian on round-trip.

The size column needs the compressed rows to be read fairly: nippy/freeze compresses above a size threshold, so its 8 518 is a codec plus a compressor against boring's raw 15 326. Put both behind a compressor and boring+zstd lands at 4 900 bytes in the same 1 102 µs nippy takes for 8 518 — 1.7× smaller at equal time. nippy/lzma2 is smaller still at 3 888, for 8× the round-trip. fressian+zstd is 6% smaller than boring+zstd and 2.8× slower.

Reading: byte[], FFM, and navigation

hako builds on java.lang.foreign — a confined Arena owning a native MemorySegment. boring's reader does not, and the reason is measured rather than assumed, because the first attempt got it wrong.

The microbenchmark lied

A microbenchmark of the access pattern a CBOR codec has — a header byte then an unaligned scalar — reports a native segment at parity with byte[], provided you never use withOrder(BIG_ENDIAN). That layout costs 4.1× on stock HotSpot by declining to intrinsify; access in native order and Long.reverseBytes (a bswap intrinsic, byte-identical output) and the penalty vanishes. byteArrayViewVarHandle, by contrast, is endian-neutral. That endian rule still holds and SegmentSource depends on it — it is the one result from that probe that survived.

So a segment-based reader was built. It cost 14–50% on decode and ~2.5× the stack per recursive level — enough that maxDepth's 1024 default rose above the real stack limit and the depth cap silently stopped being a cap.

clj-async-profiler said why: ~25% of samples in checkValidStateRaw, checkIndex, checkSegment, checkBounds — per-access bounds and liveness checks. A tight loop over a constant layout lets the JIT hoist all of it, which is exactly what the microbenchmark measured. A recursive, branchy decoder does not. A microbenchmark of an access pattern is not a benchmark of a decoder built on it.

The probe itself is not in the repo. Its headline number was withdrawn, and a probe that reproduces a withdrawn conclusion is worse than no probe: someone runs it, sees parity, and re-opens a settled question. What survived is written down here and in SegmentSource, which is where it is load-bearing.

ByteBuffer is the JDK-9-compatible alternative and is worse: it ties byte[] on sequential scans but runs 2.29× on the data-dependent walk a head parser actually performs, against MemorySegment's 1.22×.

What shipped: one parser, two accessors

The structural logic is single-source — a second head parser is what drifts, silently — but the loads branch on whether the source is a heap array. That recovers the loss in full (datom-maps-200 decode: 53.39 µs before, 72.06 all-segment, 53.06 with the branch; allocation identical).

Because the byte[] path then touches no FFM, the FFM types moved out of Reader behind a JDK-9-named ByteSource. src/java compiles at --release 9, src/java22 holds the one MemorySegment implementation, and one jar carries both since the JVM rejects a class only when it loads it. The full suite passes on JDK 21, which cannot load FFM at all; 22+ adds mmap.

Off-heap decode costs 1.35× heap decode, and only that path pays it (shared/global arena 1.35×, confined 1.46×). It also means: to realise a whole subtree from a mapping, stage its byte span into a scratch array and decode through the array path (67.5 µs) rather than in place (75.4 µs).

Navigation

boring.nav is a read-only cursor — ILookup (so clojure.core/get-in works), Indexed, Counted, Seqable, IReduceInit, and a clojure.zip zipper. clojure -M:bench -m nav:

68 KB, 200 recordsnavdecode + get-inratio
get-in one leaf (heap)5.9 µs124 µs21×
count the top-level map0.08 µs121 µs1400×
reduce over all 200, one field each57 µs125 µs2.2×
get-in one leaf (mmap'ed)6.2 µs131 µs21×
locate a 1 MiB blob vs materialise it0.6 µs185 µs290×

count is O(1) — the element count is in the head. The reduce row is only 2.2× because it visits every record. Skipping is 3–11× cheaper than decoding for structure and 18× for a bytestring, which is length-prefixed, so ignoring one is a jump whose cost does not scale with its size.

A log is a CBOR sequence, walked by nav/items:

5 000 events, 360 KBnavdecode-seqratio
scan for matching events1 542 µs5 330 µs3.5×
first event only (early exit)3.9 µs2.2 µs0.6×

That second row is where nav loses, and it is the useful one. decode-seq is already lazy, so stopping at the first item decodes only that item — and for one small item a cursor plus a key probe costs more than decoding it. Navigation wins by what it skips.

Write such a file with the options on the writer, not per call:

(let [w (boring/writer 65536 {:stringref false})]
  (with-open [out (BufferedOutputStream. (FileOutputStream. f) 262144)]
    (doseq [e events] (boring/write-to! w e out))))

resolve-opts merges the caller's map over the profile defaults on every encode, which costs ~250 heap bytes per event — and it bites hardest here, because a navigable file needs :stringref false and so cannot use the nil-opts fast path. Resolved once on the writer, a log event costs 301 → 15 bytes through encode-buffered! and 248 → 0 through write-to!.

Two constraints are enforced, not documented-and-hoped: :stringref documents are refused (a cursor holding only an offset cannot resolve an index into a table built from preceding strings), and indefinite-length containers cannot be descended (their count is not on the wire, so Counted would lie — boring never writes them). Tags are opaque: get realises through the ordinary reader and delegates, because a tag's reader is an arbitrary function and structure does not imply semantics.

mmap: good for reading, not for writing

clojure -M:bench -m mmap. Selective decode over a mapping beats a pread per item 2.3×, and costs 3–17% over a no-copy floor.

Writing is the opposite. Appending 200 000 items: BufferedOutputStream 130 ms, mmap 171 ms, encode-only floor 105 ms. A mapping faults per 4 KiB page while write(2) hands the kernel one prepared buffer. The floor matters more than the ranking — I/O is 19% of the job — so a writer that encoded straight into a mapping would compete for that 19% against an overhead larger than the copy it removes. The actionable finding is that an unbuffered FileOutputStream is 2.9× slower than wrapping it.

Compression: chunk at page size

Compression and mmap'ed selective access pull against each other: mmap pages at 4 KiB, a compressed block only decodes whole. zstd level 3, random lookups:

chunkcompressedrations/lookupvs raw
uncompressed15.4 MB1.00×1 4981.0×
4 KB1.59 MB9.7×5 4003.6×
64 KB1.22 MB12.7×55 98737×
256 KB1.21 MB12.8×201 755135×

Lookup cost scales with chunk size; ratio saturates almost immediately. 4 KB reaches 77% of whole-file ratio and aligns with the page granularity mmap gives you anyway. This is the argument against filesystem compression here: btrfs compresses 128 KiB extents and ZFS a 128 KiB recordsize, landing at the bottom of that table with no knob. Compression also forecloses zero-copy — a chunk must be decompressed to the heap — so it and the blob win are alternatives for the same bytes.

Where boring loses

Deeply nested maps — 1.4× bigger, 1.6× slower

nested-map-50 is 1 561 bytes against hako's 1 079, and decodes in 7.08 µs against 4.34.

The cause is understood: 50 maps share a key set, but they are nested rather than collected into an array, so shaped arrays do not fire — 1 561 bytes either way. Stringref deduplicates the key strings, but every occurrence still pays a tag-39 identifier wrapper, a stringref reference and a map header.

Under compression the size half of this loss inverts: 354 bytes against hako's 388.

This is the case SHAPES.md specifies tag 39650 for. Measured on a comparable payload, define-then-reference takes a map-of-maps from 7 126 to 3 946 bytes (−44.6%). It is specified and deliberately not shipped in the first release.

A plain vector of integers — 2.4× slower

long-vec-1k decodes in 11.05 µs against hako's 4.65. This is the largest remaining gap in the table, and it is a decode-side gap only — encode is a tie at 6.74 against 6.65.

Handing boring a primitive array instead of a vector changes the picture entirely, because it becomes an RFC 8746 typed array — a raw little-endian memory image:

representationbytesdecode
vector of 1000 ints2 72611.10 µs
long[] (tag 79)8 0081.25 µs
int[] (tag 78)4 0080.69 µs
short[] (tag 77)2 0080.34 µs
hako vector2 7404.61 µs

short[] decodes 33× faster than the vector and is smaller on the wire. On the JVM that is a bulk VarHandle read; in JavaScript it is a TypedArray view over the buffer, which is genuinely zero-copy.

Unusually, the fastest path is also the most portable one: RFC 8746 is a registered standard that other CBOR libraries read natively.

Automatically narrowing a homogeneous integer vector to a typed array would close this gap — measured at 5 144 bytes and 1.65 µs against 7 133 and 37.11 µs for a 512-row four-column payload, i.e. smaller and 22× faster. It needs narrowest-fit selection per column to avoid long[]'s 2.3× size penalty, and is specified in SHAPES.md as the columnar extension.

ClojureScript

node v23.11, ns/op, reused writer and reader on both sides (transit's tw/tr are created once, so boring's are too). Regenerate with:

clojure -M:cljs-compare -m cljs.main -co '{:language-in :ecmascript-next}' \
  -O advanced -t node -o target/cljs-compare.js -c cljsbench.compare
node target/cljs-compare.js

An earlier version of this page claimed boring "beats transit-cljs on every axis — 1.6x encode, 2.6x decode, 2.9x smaller". That is wrong as a general claim. It holds for one payload shape, with :shapes enabled, and the reverse holds elsewhere.

Decode, ns/op

payloadboringboring :shapestransitJSON.parse
small-map2 2532 2441 055224
mixed1 4701 475627172
string-10034935013546
nested-map-5043 20743 19224 9976 991
datom-maps-200199 11152 82091 04233 727
datom-vec-1k242 767240 819152 31397 903
long-vec-1k10 03010 09412 7335 609

Encode, ns/op

payloadboringboring :shapestransitJSON.stringify
small-map1 7021 825895123
nested-map-5041 87041 65225 2633 557
datom-maps-200194 045124 680128 43520 144
datom-vec-1k705 819729 565449 12371 121
long-vec-1k40 96141 06139 0418 427

Size, bytes

boring is smaller on every payload:

payloadboringboring :shapestransitJSON
small-map56567548
nested-map-501 5611 5612 1761 621
datom-maps-2009 9524 98214 30713 091
datom-vec-1k25 74825 74839 00040 991
long-vec-1k2 7262 7263 8913 891

Why transit wins on JS, and where it does not

transit-cljs :json writes JSON text, so JSON.parse does the entire byte-to-structure walk in native C++ and transit only pays for the JS-level walk that builds Clojure values. boring parses binary in JavaScript. Profiling datom-maps-200 decode (node --cpu-prof) puts boring's time at:

share
byte scan / dispatch31.8%
string decode (TextDecoder + UTF-8 validation)25.4%
collection building (transients)16.8%
keyword interning13.5%
other, GC12.5%

The first two — 57% of decode — are what V8 does natively for transit. That is a structural disadvantage of binary-in-JS, not a defect, and it is why the fastest JS CBOR codecs generate their decoders with new Function/eval (which a strict Content Security Policy forbids, and which boring does not do).

boring wins where the format advantage beats native text parsing:

  • :shapes on an array of same-shaped maps. Stripping the repeated keys removes most of the scan and all of the repeated keyword interning: datom-maps-200 decodes in 53 µs against transit's 91 µs — 1.7× faster, at 2.9× smaller — and encode is now ahead too, 125 µs against 128. This is the datom shape boring exists for. It does nothing for nested-map-50 (maps nested, not collected) or datom-vec-1k (vectors, not maps), which is why those rows are flat.
  • Dense numeric data. long-vec-1k decodes in 9.8 µs against 12.6 — CBOR integers are 1–3 binary bytes where JSON must parse decimal text. A typed array widens this to more than an order of magnitude.

Everywhere else — small maps, plain strings, nested maps — transit is 1.5–2.7× faster and boring is 1.3–1.4× smaller. If you are CPU-bound in a browser on generic data, transit is the faster choice today; boring's case on JS is wire size, cross-language reach, and the shaped-array path.

The JSON column is not the bar it looks like

JSON.parse returns plain JS objects with string keys. boring and transit return ClojureScript persistent maps with keyword keys. Those are different jobs, and the difference is most of the apparent gap. On datom-maps-200:

ns
JSON.parse → plain JS objects34 348
JSON.parse + a hand-written CLJS build that knows the 5 keys43 420
boring :shapes45 127
boring, generic170 279
JSON.parse + js->clj :keywordize-keys197 026
CLJS construction alone, nothing parsed4 297

With :shapes, boring is within 4% of JSON.parse doing the same job — 45.1 µs against 43.4, where the JSON side has been hand-specialised to the same five keys boring's shape header carries. That is about as close to the native parser as anything returning Clojure values gets.

Against what a CLJS app actually writes, boring wins outright. Nobody hand-unrolls their keys; they call js->clj, and that costs 197 µs — 4.4× slower than boring :shapes, and slower than boring's generic path too. For reading into ClojureScript data, boring is the faster option today.

The last row decides what is left to optimise: building the result — 200 PersistentArrayMaps and a vector — costs 4.3 µs, under 10% of the shaped decode. What remains is parsing, not construction.

A WASM decoder: measured, ~6–10%

An optional WASM module with a JS fallback is the obvious way to buy native-speed scanning. bench/wasm/ is the experiment: a CBOR skeleton walker in C that counts items and constructs nothing — the most a WASM module could take off the JS decoder, since strings and Clojure values have to be built on the JS side regardless.

JSWASM
skeleton scan, generic (9 952 B)21 317 ns10 471 ns2.04×
skeleton scan, :shapes (4 982 B)9 806 ns5 242 ns1.87×

WASM is genuinely ~2× at the scan, and the buffer copy is not the obstacle — 10 KB costs ~120 ns. The problem is the share:

scan share of decodesaving if ALL of it moved
generic (170 279 ns)12.5%6.4%
:shapes (45 127 ns)21.7%10.1%

That is a ceiling, not an estimate: it assumes JS consumes whatever index WASM writes for free, and a CBOR header is already one byte with the major type in its top three bits, so reading an index entry is not obviously cheaper than reading the header it replaces.

Two things pin the ceiling there. Strings cannot move — a JS string cannot be a view into WASM memory, so every one is copied and transcoded UTF-8 → UTF-16; this is the wall a Rust JSON parser in WASM hits at ~8× slower than native JSON.parse, and boring's string time is already inside TextDecoder either way. Clojure values cannot moveKeyword, PersistentArrayMap and PersistentVector are JS objects WASM cannot allocate.

Against that, :shapes takes the same payload from 170 µs to 45 — 3.8× — with no second implementation to keep conformant. Widening where shapes fire (tag 39650, SHAPES.md) is worth more than a WASM scanner by a wide margin, and nested-map-50 — where boring is furthest behind transit — is exactly that case.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close