benchmark/Resp.benchmark/README.md
End-to-end throughput / latency benchmark for the Garnet RESP server. Drives GET/SET/INCR/MGET/... workloads against any RESP server (Garnet, Redis, KeyDB, Dragonfly) over TCP, or against an in-process embedded Garnet server. It is the top of the stack: Resp ≤ KV ≤ Device ≤ fio.
Two modes:
--op X): pre-built request batches of size -b, N threads loop
Send → CompletePending. Reports throughput (ops/sec). Use to saturate.--online): one in-flight op per thread (--itp K for more),
per-op latency in an HdrHistogram printed every 2 s. Use for latency curves.Clients (--client): LightClient (default offline, zero-alloc pipeliner),
GarnetClientSession (default online, async pipelined, --itp), GarnetClient,
SERedis (for apples-to-apples vs Redis), InProc (embedded, no TCP — server CPU only).
dotnet build benchmark/Resp.benchmark/Resp.benchmark.csproj -c Release -f net10.0
dotnet build main/GarnetServer/GarnetServer.csproj -c Release -f net10.0
RB=benchmark/Resp.benchmark/bin/Release/net10.0/Resp.benchmark.dll
GS=main/GarnetServer/bin/Release/net10.0/GarnetServer.dll
dotnet $GS --port 6379 & # start server
dotnet $RB --op GET --dbsize 1000000 -t 8 -b 512 --runtime 15 # offline throughput
Always measure on a Release build. dotnet $RB --help lists all flags.
| Flag | Default | Controls |
|---|---|---|
--op | GET | Op to benchmark (offline): GET, MGET, INCR, SET, ZADD, ... |
--dbsize | 1024 | Distinct keys (pre-loaded unless -s). |
--valuelength | 8 | Value bytes (use 100 for KV.benchmark parity). |
-t | 1,2,4,8,16,32 | Thread-count sweep (offline). |
-b | 4096 | Requests per pipeline (offline; dominant throughput knob, 1024 is a good default). Online forces 1. |
--runtime | 15 | Seconds per cell. 0 = load only (no run). |
-s | false | Skip load — run against a pre-loaded server. |
--itp | 1 | Online: in-flight ops per thread. |
--zipf | false | Skew keys (θ=0.99) instead of uniform. |
Three server setups distinguished by where reads land, swept over threads for
offline throughput. Scatter-gather GET (--sg-get, on by default) batches contiguous
pending GETs into one vectored IO — essential for the device-backed scenarios.
Default server; the dataset fits in the in-memory log, so reads never touch a device.
dotnet $GS --port 6379 &
dotnet $RB --op GET --dbsize 16777216 --valuelength 100 --runtime 0 # load 16 M × 100 B
dotnet $RB -s --op GET --dbsize 16777216 --valuelength 100 -t 1,2,4,8,16,32 -b 1024
Tier the store with a tiny memory log so ~99.9% of a 100 M dataset is on NVMe and every GET is a 4 KB random fetch. Use 100 M × 100 B (smaller datasets touch few NAND dies and understate device IOPS).
DATA=/mnt/nvme/garnet; mkdir -p $DATA
numactl --cpunodebind=0 --membind=0 dotnet $GS --port 6379 \
--memory 16m --page 4m --segment 1g --index 4g --storage-tier --logdir $DATA \
--device-type Native --device-io-backend libaio --device-throttle-limit 512 \
--logger-level Warning &
numactl --cpunodebind=0 --membind=0 dotnet $RB --op MSET --dbsize 100000000 --valuelength 100 -t 8 -b 1024 --runtime 0
numactl --cpunodebind=0 --membind=0 dotnet $RB -s --op GET --dbsize 100000000 --valuelength 100 -t 1,2,4,8,16,32 -b 1024 --runtime 15
--index 4g for 100 M keys (default 128 m → 3–4× slowdown from hash chains).libaio is fastest on Linux (uring to compare; Default → RandomAccess, slower).
--device-throttle-limit 512 is safe on fast NVMe; lower to 128 on SATA.Same tiered server, but the syscall-free LocalMemory device: the full RESP +
pending path with zero disk latency (the software ceiling; matches the
KV
and Device
LocalMemory runs). Replace the device flags in (2) with:
... --device-type LocalMemory --device-completion-threads 4 --device-throttle-limit 512 ...
Reference (10 M × 100 B, t=16): ~2.7 M ops/sec at -b 1024, ~3.7 M at -b 256.
dotnet $RB --op MGET --dbsize 16777216 --valuelength 8 -t 16 -b 512 # MGET (scatter-gather)
dotnet $RB --op GET --dbsize 1000000 -v 100 -t 16 -b 1024 --client InProc # server CPU only, no TCP
dotnet $RB --op GET --dbsize 1000000 -v 100 -t 16 -b 256 --client SERedis # apples-to-apples vs Redis
dotnet $RB --op GET --dbsize 16777216 -v 100 -t 16 -b 1024 --zipf # skewed keys (θ=0.99)
# Single-client GET latency (cleanest reading)
dotnet $RB --online --op-workload GET --op-percent 100 --dbsize 1000000 -t 1 -b 1 --runtime 30
# 50/50 GET/SET tail latency, 16 connections
dotnet $RB --online --op-workload GET,SET --op-percent 50,50 --dbsize 1000000 -t 16 --runtime 60 --client GarnetClientSession
# Fixed offered load: 8 conns × 64 in-flight
dotnet $RB --online --op-workload GET --op-percent 100 --dbsize 1000000 -t 8 --itp 64 --client GarnetClientSession
--runtime -1 runs until interrupted; 0 is invalid for online.
--op GET --runtime 0 (seeds the keyspace, no run phase). Then
-s for read phases.redis-cli INFO store — Log.TailAddress should
match the dataset size and Log.HeadAddress ≈ TailAddress (data evicted from the
small memory region to the device).O_DIRECT, so reads bypass it.
During the read phase iostat -x 1 should show the NVMe at r/s ≈ ops/sec and
aqu-sz ≈ --device-throttle-limit. On a shared box, read per-process
/proc/<GarnetServer pid>/io read_bytes instead — it excludes other tenants' IO.GarnetServer.dll PID — stopping the dotnet launcher leaves the runtime
child alive, and leaked spinning servers cause large variance.[Total time]: <ms> for <ops> and [Throughput]: <ops/sec>
(= ops_done × batch / runtime).min; 5th; median; avg; 95th; 99th; 99.9th; total_ops; iter_tops; tpt(Kops/s)
every 2 s (µs; per-thread HdrHistogram).| Symptom | Fix |
|---|---|
Skipload not supported with --online | drop -s or use offline |
-b N>1 warning in online | tool forces -b 1; use --itp for concurrency |
--pool with LightClient unsupported | use GarnetClientSession / GarnetClient / SERedis |
| Disk-bound throughput far below KV.benchmark | server-side bottleneck — profile with dotnet-trace |
--dbsize % loadThreads != 0 | round --dbsize to a multiple of the loader thread count |