Performance analysis¶
In this article, is presented a quick study around HedgeDB performance in terms of operation throughput and latency on different real-world workloads.
To provide a well-known reference to compare against, RocksDB has been chosen because it’s embeddable and LSM-tree based aswell.
RocksDB has been configured with Universal Compaction (Size Tiered) and other, and the same data is submitted. For further details, check the rocksdb/benchtool.cc script on GitHub.
Setup¶
CPU |
13th Gen Intel i7-13700H (14 cores / 20 threads) |
RAM |
32 GB DDR5 |
Storage |
Samsung 980 Pro 1TB NVMe |
Records |
100M, 24-byte keys, 100-byte values (~12 GB raw) |
Key space |
uniformly-distributed random |
Both RocksDB and HedgeDB have been tested with O_DIRECT I/O mode, with 12 threads plus 8 background threads (for flush and compaction), reflecting the test CPU architecture (6 P-cores with SMT and 4+4 E-cores).
In the HedgeDB benchmarks, the operations are submitted through the TooManyCooks coroutine-based threadpool; in the RocksDB the operations are submitted just via std::thread.
RocksDB has been tested with Universal Compaction (size-tiered). RocksDB has been provided with 1GB worth of cache and pin_l0_filter_and_index_blocks_in_cache was enabled.
RocksDB was configured in an attempt to match HedgeDB’s features. For the specific configurations check src/benchtool/utils.cc and rocksdb/benchtool.cc.
Throughput¶
Workload |
HedgeDB |
RocksDB |
HedgeDB / RocksDB |
|---|---|---|---|
Load (100M puts) |
3.97M ops/s |
1.14M ops/s |
3.5× |
Load + compactions drained |
3.59M ops/s |
1.13M ops/s |
3.2× |
Read (100M random gets) |
1.03M ops/s |
194K ops/s |
5.3× |
Mixed 50/50 read-write |
1.33M ops/s |
262K ops/s |
5.1× |
Latency¶
Read (read-only workload)¶
HedgeDB’s per-request latency is higher than RocksDB’s despite its 5.3× throughput advantage. This is the expected tradeoff of the batching model: each thread runs its own io_uring ring at QD16, keeping multiple I/O requests in flight simultaneously. More requests in flight means higher aggregate throughput, but each individual request spends more time waiting in the queue. See the Queue-depth effect section below for a direct QD8 vs QD16 comparison.
Percentile |
HedgeDB |
RocksDB |
|---|---|---|
avg |
185 µs |
60 µs |
p50 |
155 µs |
61 µs |
p90 |
298 µs |
112 µs |
p99 |
632 µs |
198 µs |
p99.9 |
1.05 ms |
295 µs |
Write (memtable insert+WAL append)¶
Percentile |
HedgeDB |
RocksDB |
|---|---|---|
avg |
2.73 µs |
10.28 µs |
p50 |
2.0 µs |
9.5 µs |
p99 |
6.0 µs |
17.0 µs |
p99.9 |
23.5 µs |
25.5 µs |
Read latency under the mixed workload¶
Percentile |
HedgeDB |
RocksDB |
|---|---|---|
avg |
285 µs |
84 µs |
p50 |
237 µs |
72 µs |
p90 |
430 µs |
136 µs |
p99 |
1.09 ms |
281 µs |
Range scans¶
Range size |
Metric |
HedgeDB |
RocksDB |
HedgeDB / RocksDB |
|---|---|---|---|---|
Small (1-100) |
scans/s |
87.5K |
26.3K |
3.3× |
Small (1-100) |
keys/s |
4.38M |
1.32M |
3.3× |
Medium (512-1024) |
scans/s |
24.9K |
6.7K |
3.7× |
Medium (512-1024) |
keys/s |
19.2M |
5.12M |
3.7× |
Large (114K-131K) |
scans/s |
240 |
192 |
1.25× |
Large (114K-131K) |
keys/s |
29.5M |
23.7M |
1.25× |
Small and medium scans favor HedgeDB by ~3.3-3.7×. Very large scans converge: at that range size both engines are bottlenecked by sequential SSD bandwidth, not the index structure.
Memory (peak RSS)¶
Workload |
HedgeDB |
RocksDB |
|---|---|---|
Load (100M puts) |
1.53 GB |
1.03 GB |
Read (100M gets) |
455 MB |
1.30 GB |
Range scans |
633 MB |
1.30 GB |
Mixed 50/50 read-write |
1.82 GB |
1.89 GB |
HedgeDB uses more memory during load, since the memtable holds pending writes
before they flush to SSTs. On the read path it is significantly lighter:
the SST index cache is demand-filled and shares nothing with the OS page
cache (all reads go through O_DIRECT), so memory usage tracks actual
working set rather than page-cache accumulation.
io_uring Queue-depth effect on read latencies¶
The tests shown above were executed with the thread-local io_uring instance configured with queue-depth 16.
For very latency-sensitive workloads, the io_uring queue depth can be tuned while still maintaining high bandwidth utilization.
Let’s see what happens if we reduce the QD to 8 instead:
Measurement |
HedgeDB QD8 |
HedgeDB QD16 |
RocksDB |
|---|---|---|---|
Throughput (reads/s) |
881K |
1.03M |
193K |
avg |
108 µs |
185 µs |
60 µs |
p50 |
99 µs |
155 µs |
61 µs |
p90 |
153.5 µs |
298 µs |
112 µs |
p99 |
237.5 µs |
632 µs |
198 µs |
p99.9 |
331.5 µs |
1025 µs |
295 µs |
With this configuration, despite not being able to maximize the device bandwidth (14.5% lower than the peak), we gain substantial improvements on the measured latencies (62.5% decrease). HedgeDB now behaves much closer to RocksDB, proving that it can be adapted even to latency-sensitive scenarios.
Q: Did you try RocksDB’s MultiGet? It even supports io_uring!
A: I did try it, but I did not register any meaningful throughput gain, only higher latencies.
Performance update on Zen 5 + Gen5 NVMe¶
Recently, I upgraded my workstation to some latest-generation hardware: the build has a 9900X3D Zen5 CPU equipped with 12 cores (24 threads with SMT), 32 GB of DDR5 RAM and a Gen5 NVMe: a Crucial T710 2TB! I chose this model because it’s better at sustained writes (source) compared to other alternatives.
I personally tested it against a Samsung 9100 Pro 2TB and can confirm those results hold for HedgeDB too: the Samsung really fell over on a benchmark with larger values (512 bytes). During this large-value test, the write amplification caused by compaction was noticeably high. That put the entire burden on the NVMe, which delivered very poor write throughput once its SLC cache filled up, so I decided to get the Crucial instead.
The test setup is almost the same as before (HedgeDB received some updates and small improvements, though). Once again the workload was adapted to the CPU architecture: the foreground tasks (writes, reads and range scans) run on a threadpool pinned to the first CCX; background tasks (flush and compactions) run on the second CCX.
A small note: I’m aware that in a real-world use case, server CPUs are different from desktop ones: they usually have more cores running at a lower frequency (Zen5 runs at ~5GHz!). Industrial NVMe SSDs are also built differently from consumer hardware, since they are made to perform consistently over sustained workloads, while my benchmarks last for no longer than tens of seconds.
With these caveats in mind, it is still quite interesting to explore how HedgeDB scales when provided with more power.
Let’s look at the results.
Throughput¶
Workload |
HedgeDB |
RocksDB |
HedgeDB / RocksDB |
HedgeDB (laptop) |
RocksDB (laptop) |
|---|---|---|---|---|---|
Load (100M puts) |
8.07M ops/s |
1.34M ops/s |
6.0× |
3.97M ops/s |
1.14M ops/s |
Load + compactions drained |
7.94M ops/s |
1.33M ops/s |
5.9× |
3.59M ops/s |
1.13M ops/s |
Read (100M random gets) |
2.23M ops/s |
296K ops/s |
7.5× |
1.03M ops/s |
194K ops/s |
Mixed 50/50 read-write |
2.57M ops/s |
423K ops/s |
6.1× |
1.33M ops/s |
262K ops/s |
From the table above, you can see how both HedgeDB and RocksDB behave on my desktop workstation against the laptop I ran the first comparison on.
It’s clear that HedgeDB, thanks to its parallel design, is capable of crunching even more write operations and widening the gap with RocksDB.
I should point out that, judging from internal tests, the bottleneck here seems to be the Concurrent SkipList rather than the storage device itself, hinting that the device saturation point might not be reached yet.
When running RocksDB, it is easy to see that the write workload is software-constrained, since it does not show the same gains from the new hardware that HedgeDB does.
About the Read 100M tests, HedgeDB still saturates the drive at 2.2M random lookups/s,
the T710’s nominal random-read IOPS. The story here is still the same as before: batched I/O operations
(submitted via io_uring) are mandatory for saturating the SSD controller submission queue (more on this in
async model).
Latency¶
Read (point-lookup workload)¶
Percentile |
HedgeDB |
RocksDB |
|---|---|---|
avg |
172 µs |
40 µs |
p50 |
155 µs |
48 µs |
p90 |
259 µs |
59 µs |
p99 |
414 µs |
105 µs |
p99.9 |
578 µs |
142 µs |
The same tradeoff as before. HedgeDB keeps 32 I/O requests in flight per thread (the earlier test ran at QD16), so each individual read waits longer while the device stays busy.
Write (load phase)¶
Percentile |
HedgeDB |
RocksDB |
|---|---|---|
avg |
1.48 µs |
8.83 µs |
p50 |
1.31 µs |
8.50 µs |
p99 |
5.12 µs |
12.00 µs |
p99.9 |
7.95 µs |
14.50 µs |
Range scans¶
Range size |
Metric |
HedgeDB |
RocksDB |
HedgeDB / RocksDB |
|---|---|---|---|---|
Small (1-100) |
scans/s |
134K |
42.7K |
3.1× |
Small (1-100) |
keys/s |
6.68M |
2.14M |
3.1× |
Medium (512-1024) |
scans/s |
39.6K |
14.5K |
2.7× |
Medium (512-1024) |
keys/s |
30.4M |
11.1M |
2.7× |
Large (114K-131K) |
scans/s |
431 |
396 |
1.09× |
Large (114K-131K) |
keys/s |
53.1M |
48.7M |
1.09× |
The shape is the same as on the laptop: a clear win on short and medium scans, near-parity on the large ones where both engines just stream from the device.
Memory (peak RSS)¶
Workload |
HedgeDB |
RocksDB |
|---|---|---|
Load (100M puts) |
1.14 GB |
1.00 GB |
Read (100M gets) |
475 MB |
1.26 GB |
Range scans |
910 MB |
1.31 GB |
Mixed 50/50 read-write |
1.78 GB |
2.01 GB |
Note that RocksDB always had HyperClockCache enabled, with 1 GiB allocated for caching filters and index blocks.
Conclusions¶
From the results, we can deduce that HedgeDB’s multi-core, NVMe-aware architecture delivers what it was designed for.
HedgeDB can accommodate a wider flow of requests, showing a higher throughput compared to RocksDB (and lower latencies), thanks to the high degree of parallelism, fast synchronization structures and the per-thread WAL.
Random reads can finally saturate the NVMe bandwidth thanks to the
io_uringintegration. Although maximum throughput comes at the cost of higher latency, the user can tune the submission queue depth based on the needs of the use-case.Short and medium range scan workloads are IOPS-bound, and here the HedgeDB’s I/O batching shines the most.
Long range scans are bandwidth-intensive rather than IOPS-intensive, so the concurrent model based on
io_uringmodel is less of a differentiator.
Reproducing¶
The benchtool and rocksdb_benchtool binaries that produced these
numbers live in src/benchtool* in the repo. See
Getting started for the build steps and CLI flags.