01 2,000,000 ids each, 2,000 chunk keys each
Each segment holds 2,000,000 ids over 2,000 chunk keys — 1,000 ids per key. A's keys run 0–1,999; B shares the first hundred and then diverges to 2,100–3,999. Nothing has been read yet.
Replay of a measured run
Two segments of 2,000,000 ids each. Intersecting them reads 403,200 bytes and returns 100,000 ids in 25.3 ms — because keys are compared before any bytes move, and 3,800 chunk reads simply never happen.
Every number on this page comes from one recorded run of pnpm bench:scale, checked into the repository. Nothing here is simulated, and nothing is computed live — this is a replay, and it says so because the distinction is the point.
A plain bitset over the 32-bit id space is 512 MiB, allocated whether you store five ids or five million. Roaring cuts every id in two and never allocates a chunk nobody has written to.
1_234_567_890 = 0x499602D2
Which chunk. One of 65,536 — and the only thing compared when two segments are intersected.
Which bit inside that chunk. One of 65,536 — and never looked at unless the chunk is fetched.
That asymmetry is the entire trick. Keys are small and comparable; remainders are the payload. The replay below compares 4,000 keys and fetches 100 payloads.
A chunk holds up to 65,536 remainders, and how it stores them depends on how many there are. All three shapes answer the same question — is this id present? — so nothing above the container ever knows which one it got.
A sorted list of 16-bit remainders. Two bytes per id and nothing wasted, so it wins while a chunk stays sparse.
2 bytes × ids presentOne bit per possible remainder, 65,536 of them. Flat cost whatever the count, which is what makes it the cheaper shape once a chunk fills up.
8 KiB, alwaysStart-and-length pairs. A contiguous stretch of ids becomes a couple of numbers instead of one entry each — the shape that beats both others on ranges.
4 bytes × runsThe two costs cross at 4,096 ids, and that is arithmetic rather than a tuning choice: 4,096 × 2 bytes is 8 KiB, exactly what a bitset costs. Below it an array is smaller; above it a bitset is. The flavors page walks all three shapes and the conversions between them.
The whole argument is in beat two: the keys are compared first, and at that moment zero bytes have been read. Everything after it is the consequence.
Chunk-key space · 4,000 keys · both segments
in both · 100 keys in one segment only · 1,900 each in neither · 100
Keys 80–119, one cell per key
The shared prefix ends at key 99. From 100 on, a key exists in A alone — nothing to intersect with, so nothing to fetch.
01 2,000,000 ids each, 2,000 chunk keys each
Each segment holds 2,000,000 ids over 2,000 chunk keys — 1,000 ids per key. A's keys run 0–1,999; B shares the first hundred and then diverges to 2,100–3,999. Nothing has been read yet.
02 100 of 2,000 keys align
Keys are compared before any bytes move. One hundred appear in both segments; 1,900 in each segment appear in only one, and a key that exists in one segment alone cannot contribute to an intersection. Bytes read at this point: still zero.
03 403,200 bytes, 100 chunks
Only the hundred aligned chunks are requested, 4,032 bytes each. The other 3,800 chunk reads never happen — not deferred or cached, never issued. On object storage that is 3,800 GET requests you are not charged for.
04 100,000 ids in 25.3 ms
One hundred shared keys times 1,000 ids per key is 100,000 ids — and 100,000 is what the run reported. The result is derivable from the setup as well as observed, which is the only reason to trust a number on a page like this one.
Two kinds of number appear above and they are not equally strong. Both panels are cross-checked against each other by a build gate, so neither can quietly stop being true.
| Quantity | Value | Reported by |
|---|---|---|
| Chunk keys per segment | 2,000 | benchmark parameter |
| Ids per segment | 2,000,000 | benchmark parameter |
| Chunks fetched | 100 | metrics sink |
| Chunk reads skipped | 3,800 | metrics sink |
| Cold bytes read | 403,200 | metrics sink |
| Wall time | 25.3 ms | timer around the drained stream |
| Ids returned | 100,000 | counted while draining |
Source: bench/scale-results.json
| Quantity | Value | From |
|---|---|---|
| Ids per chunk key | 1,000 | 2,000,000 ids ÷ 2,000 keys |
| Bytes per fetched chunk | 4,032 | 403,200 bytes ÷ 100 chunks |
| Key-space extent | 4,000 | B’s tail is offset past A’s range |
| Keys in neither segment | 100 | the offset leaves a gap of that width |
A derived number is only as good as the construction behind it — bench/scale.cjs is 40 lines, and worth reading.
The check is a build gate rather than a promise: scripts/site-replay.cjs fails if the reads the benchmark reported stop matching what its construction implies, fails if this page drifts from either, and fails if the page states any figure the benchmark cannot account for at all.
It is not running here
No bitmap engine is loaded in your browser and no network request is made. Making it live would mean either real credentials on a public page, or the in-memory drivers — in which case nothing crosses a network and the demo would be testing a claim it cannot reach.
One workload, not a guarantee
5% key overlap on 2,000-chunk segments. The skip rate is a property of your overlap: two segments sharing most of their keys fetch most of their chunks, and the win shrinks accordingly.
Memory drivers, not S3
25.3 ms is compute, not cloud latency. It is the right number for how many requests happen and the wrong one for how long they take — the benchmarks carry real-backend timings, and the rate past which a flat tier is cheaper.
Everything above ran against the in-memory drivers, which ship inside the package and need no configuration at all. So the quickstart runs as-is, and you can see the shape before you point anything at a bucket.
npm i @cloudbitmaps/roaring
# the memory drivers need nothing else
# add an SDK only for a real backend:
npm i @aws-sdk/client-s3
Or reproduce the run above
git clone https://github.com/cloudbitmaps/cloudbitmaps
pnpm install
pnpm bench:scale # writes scale-results.json