โ† back to journal

Roaring bitmaps pay 16 bits where 6 would do

2026-10-13 ยท Kit Wickham

Roaring bitmaps are the default compressed integer set in a lot of software: search engines, analytics databases, anything that intersects lists of row IDs. They are built to be fast, not as small as possible. I wanted to know how far from the smallest possible they are, and where. So I measured their real serialized size across densities from 0.001% to 98%, on random sets and on clustered ones, and compared it with the information-theoretic floor and with Elias-Fano, the usual compact encoding for sorted integers.

The short answer: on random sets just under 6.25% dense, Roaring spends 16 bits per element where the floor is 5.6 and Elias-Fano needs 6.1. That's 2.9 times the floor. On clustered sets it's the other way around: Roaring is about two to two and a half times the floor, and Elias-Fano, which can't see runs, is dozens of times over once they get long.

How Roaring stores a set

Roaring splits the 32-bit range into chunks of 65,536 values. Each non-empty chunk gets one container, chosen by what's smallest:

The serialized format adds 8 bytes per container (key, count and offset; see the format spec). Notice what's missing: an array spends 16 bits on every element no matter how far apart the elements are. If a chunk holds 4,000 values, the gap between neighbours is about 16, which takes about 4 bits to write down plus some change. Roaring writes 16.

The setup

A universe of 224 values (256 chunks). For each of 41 densities from 0.001% to 98%, five seeds of:

I built each set with pyroaring (CRoaring underneath), serialized it before and after run_optimize, and divided the bytes by the element count. The floor for random sets is log2 of the number of possible sets of that size, per element. For clustered sets it's the entropy rate of the two-state process that generated them, which is lower: it's the floor for that kind of data, not for any set. For Elias-Fano I used its exact size formula (nยทl low bits plus n + 224โˆ’l high bits, l = โŒŠlog2(U/n)โŒ‹) rather than an implementation; it lands within 2 + log2(U/n) bits per element.

To check the measurement, I also wrote a closed-form model of the random case: chunk counts are binomial, each container costs 8 bytes plus min(2c, 8192) bytes. It matches the real serialized sizes within 0.06% for densities of 0.1% and up, and within 1.7% below that, where sets have only a few hundred elements.

Two panels of bits per element against density, both on log scales. Left, random sets: Roaring sits between 16 and 18 bits per element from 0.05% to 6.25% density, then falls steeply, crosses Elias-Fano near 25% and meets the information floor around 40 to 55%; Elias-Fano stays within about 12% of the floor up to 10% density; the floor falls from about 15 bits to 5.6 at 6%. Right, clustered sets with runs of about 16: Roaring without run_optimize is 16 bits up to 6%; with run_optimize it is about 2 bits from 1% up; Elias-Fano is the same as for random sets, 15 down to 3 bits; the floor is about 1 to 1.5 bits.

Median of five seeds per point. Above 0.1% density the seeds differ by under 0.5% for random sets and under 8% for clustered ones.

Random sets: worst just below 6.25%

densityRoaringElias-Fanofloor
0.01%25.715.214.7
0.1%17.012.011.4
1%16.18.68.1
5.5%16.06.15.6
9.8%10.25.34.7
23%4.34.13.4
55%1.82.81.8

Bits per element. run_optimize changes nothing here until 98%, since random sets have no long runs.

Below 6.25% every chunk is an array, so Roaring sits at 16 bits per element while the floor keeps falling as the set gets denser. The gap is widest right before the switch: at 5.5%, 16.0 against 5.6. Past 6.25% chunks turn into bitmaps, whose cost per element is 65,536 divided by the count, so Roaring drops fast. It crosses Elias-Fano between 23% and 31% and is within 3% of the floor from about 40% to 55%. Denser than that, a bitmap's one bit per value is again more than the floor, but only by fractions of a bit (1.36 against 1.13 at 73%).

At the sparse end the 8-byte container header shows up: at 0.01% a chunk holds about 6.5 elements, so the header alone adds about 10 bits each. Roaring is still never closer than 1.45 times the floor on random data below 6%; that best case is around 0.05%.

Clustered sets: the run container does the work

mean runRoaringElias-Fanofloor
48.28.63.3
162.18.61.1
640.638.60.34
2560.248.60.10

Bits per element at 1% density, Roaring after run_optimize. Without it, all four rows are 16.1.

A run container costs 32 bits per run, so per element it's 32 divided by the mean run length: 8, 2, 0.5, 0.125. The measured numbers sit just above those, the extra being headers and runs cut at chunk edges. That puts Roaring at two to two and a half times the floor for clustered data. Elias-Fano doesn't see runs at all: it charges the same 8.6 bits per element whether the values are scattered or packed in blocks of 256. With short runs of 4 that's about a tie with Roaring; with runs of 16 it's 4 times bigger, with runs of 256 it's 36 times.

The catch is that run containers are opt-in. Without run_optimize, the same clustered sets cost 16 bits per element below 6% density, exactly like random ones. (pyroaring runs it for you when you build a set from an iterable; I turned that off to see the difference. Other bindings may not do it by default.)

What I'd take from it

Limits. Synthetic data with one universe size; real ID sets are neither fully random nor cleanly clustered. Elias-Fano sizes are from its formula, not a library, and I didn't try partitioned Elias-Fano, which adapts to clusters. I measured size only, not speed: Roaring's array and bitmap containers are built for fast intersections, which is a large part of why people pick it.


โ€” Kit ๐ŸฆŠ