Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
*.json
*.rdb
temp/
temp/
**/target/
34 changes: 20 additions & 14 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,28 @@ FlashDB is a Redis-compatible in-memory key-value store written in Rust. It spea

---

## Benchmark Results (6-core machine, Intel i5-11400H, 12 threads)
## Benchmark Results

Peak observed on a warmed 6-core Intel i5-11400H (12 hardware threads) using
loopback TCP, 100 clients, and three complete benchmark runs. Figures are
workload-specific measurements, not latency or throughput guarantees.

| Metric | FlashDB (6 cores) | Redis Cluster (6 nodes) | vs Cluster |
| ---------------- | ----------------- | ----------------------- | ---------- |
| Sequential SET | ~15.4M ops/sec | ~3.5M ops/sec | 4.4x |
| Pipelined SET | ~15.9M ops/sec | ~7.9M ops/sec | 2.0x |
| Pipelined GET | ~19.6M ops/sec | ~8.3M ops/sec | 2.4x |
| Pub/Sub delivery | ~25.66M msg/sec | ~7.3M msg/sec | 3.5x |
| Pipeline-64 SET | ~14.7M ops/sec | ~3.5M ops/sec | 4.2x |
| Pipeline-100 SET | ~14.9M ops/sec | ~7.9M ops/sec | 1.9x |
| Pipeline-100 GET | ~19.3M ops/sec | ~8.3M ops/sec | 2.3x |
| Pub/Sub delivery | ~25.6M msg/sec | ~7.3M msg/sec | 3.5x |

### Internal Store Throughput (no TCP overhead)
### Resource Usage

| Operation | Throughput |
| ------------- | ------------- |
| SET (new key) | 24.6M ops/sec |
| SET (update) | 29.8M ops/sec |
| GET | 42.3M ops/sec |
| Measurement | Result |
| ----------------------- | ------- |
| Idle RSS (no keys) | ~55 MB |
| Average RSS under load | ~215 MB |
| Peak RSS during a run | ~235 MB |
| Average CPU under load | ~50% |
| Peak CPU during a run | ~60% |

---

Expand Down Expand Up @@ -147,8 +153,8 @@ When shard reaches 70% occupancy:
4. Copy all live Entry pointers from old table to new table
(Entry objects are shared — same heap allocation, just referenced from new position)
5. shard.table.store(new_ptr, Release) — readers instantly see new table
6. Old SlotTable (just the pointer array) is leaked
(safe: readers may still be probing it; entries are alive in new table)
6. Retire the old SlotTable through EBR
(freed after readers that may still be probing it leave their epoch)
7. grow_lock.unlock()

After grow:
Expand All @@ -157,7 +163,7 @@ After grow:
- Writers retrying: see new threshold, insert into new table

Memory lifecycle:
- SlotTable arrays: leaked on grow (8 bytes × old_capacity, ~few KB each)
- SlotTable arrays: reclaimed through EBR after each grow grace period
- Entry objects: live forever once inserted (key stays for probing)
- ValueBox: swapped atomically, recycled via EBR pool
- String data inside values: freed when ValueBox is reclaimed
Expand Down
43 changes: 20 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,42 @@ A Redis-compatible in-memory key-value store written in Rust. Speaks the RESP pr

## Performance

Benchmarked on a 6-core machine (Intel i5-11400H, 12 threads) with 100 clients, 1M ops, pipeline size 100.
Peak observed on a 6-core Intel i5-11400H (12 hardware threads), loopback TCP,
100 clients, 1M operations, and a warmed server. Each figure is the best of
three complete runs; sustained throughput will vary with CPU scheduling, cache
state, key cardinality, and subscriber fan-out.

| Metric | FlashDB (6 cores) | Redis Cluster (6 nodes) | vs Cluster |
| ---------------- | ----------------- | ----------------------- | ---------- |
| Sequential SET | ~15.4M ops/sec | ~3.5M ops/sec | 4.4x |
| Pipelined SET | ~15.9M ops/sec | ~7.9M ops/sec | 2.0x |
| Pipelined GET | ~19.6M ops/sec | ~8.3M ops/sec | 2.4x |
| Pub/Sub delivery | ~25.66M msg/sec | ~7.3M msg/sec | 3.5x |
| Pipeline-64 SET | ~14.7M ops/sec | ~3.5M ops/sec | 4.2x |
| Pipeline-100 SET | ~14.9M ops/sec | ~7.9M ops/sec | 1.9x |
| Pipeline-100 GET | ~19.3M ops/sec | ~8.3M ops/sec | 2.3x |
| Pub/Sub delivery | ~25.6M msg/sec | ~7.3M msg/sec | 3.5x |

> A single FlashDB node outperforms a 6-node Redis Cluster. Redis is single-threaded per node; FlashDB scales linearly with cores.

### Resource Usage

| State | RSS Memory | CPU Usage |
| -------------- | ---------- | ----------- |
| Idle (no keys) | ~30 MB | 0% |
| Under load | ~260 MB | ~42% avg |
| Peak | ~280 MB | ~65% peak |
| Measurement | Result |
| ----------------------- | ------- |
| Idle RSS (no keys) | ~55 MB |
| Average RSS under load | ~215 MB |
| Peak RSS during a run | ~235 MB |
| Average CPU under load | ~50% |
| Peak CPU during a run | ~60% |

### Resource Comparison (FlashDB vs Redis Cluster during benchmark)

| | FlashDB (1 node) | Redis Cluster (6 nodes) |
| -------- | ---------------- | ----------------------- |
| Idle RSS | ~30 MB | ~75 MB (total) |
| Peak RSS | ~280 MB | ~320 MB (total) |
| Avg RSS | ~260 MB | ~240 MB (total) |
| Peak CPU | ~65% peak | ~99% |
| Avg CPU | ~42% avg | ~29% |
| Idle RSS | ~55 MB | ~75 MB (total) |
| Peak RSS | ~235 MB | ~154 MB (total) |
| Avg RSS | ~215 MB | ~126 MB (total) |
| Peak CPU | ~60% | ~96% |
| Avg CPU | ~50% | ~25% |

> FlashDB uses more memory (pre-allocated lock-free hash table slots) but delivers 2–4x the throughput of a 6-node cluster on less CPU. The memory cost is the trade-off for zero-lock, zero-contention data access.

### Internal Store Throughput (no TCP overhead)

| Operation | Throughput |
| ------------- | ------------- |
| SET (new key) | 24.6M ops/sec |
| SET (update) | 29.8M ops/sec |
| GET | 42.3M ops/sec |

## Quick Start

```bash
Expand Down
66 changes: 39 additions & 27 deletions bench/kv.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,31 @@ func runKV() {
go func(id int, conn *net.TCPConn) {
defer wg.Done()

w := bufio.NewWriterSize(conn, 256<<10)
r := bufio.NewReaderSize(conn, 128<<10)

var kb [32]byte
requests := make([]byte, 0, seqBatch*40)
base := id * OPS_CLIENT
sent := 0
for sent < OPS_CLIENT {
batch := seqBatch
if OPS_CLIENT-sent < batch {
batch = OPS_CLIENT - sent
}
requests = requests[:0]
for j := 0; j < batch; j++ {
kn := strconv.AppendInt(kb[:0], int64(base+sent+j), 10)
writeSetBytes(w, kn)
requests = appendSetBytes(requests, kn)
}
w.Flush()
writeFull(conn, requests)
discardN(r, batch*5)
sent += batch
}
}(i, seqConns[i])
}
wg.Wait()
seqElapsed := time.Since(seqStart)
printResult("Sequential SET", totalOps, seqElapsed)
printResult("Pipeline-64 SET", totalOps, seqElapsed)

pipeSetStart := time.Now()

Expand All @@ -69,22 +70,23 @@ func runKV() {
go func(id int, conn *net.TCPConn) {
defer wg.Done()

w := bufio.NewWriterSize(conn, 256<<10)
r := bufio.NewReaderSize(conn, 128<<10)

var kb [32]byte
requests := make([]byte, 0, PIPE_SIZE*40)
base := id * OPS_CLIENT
sent := 0
for sent < OPS_CLIENT {
batch := PIPE_SIZE
if OPS_CLIENT-sent < batch {
batch = OPS_CLIENT - sent
}
requests = requests[:0]
for j := 0; j < batch; j++ {
kn := strconv.AppendInt(kb[:0], int64(base+sent+j), 10)
writeSetBytes(w, kn)
requests = appendSetBytes(requests, kn)
}
w.Flush()
writeFull(conn, requests)
discardN(r, batch*5)
sent += batch
}
Expand All @@ -101,22 +103,23 @@ func runKV() {
go func(id int, conn *net.TCPConn) {
defer wg.Done()

w := bufio.NewWriterSize(conn, 256<<10)
r := bufio.NewReaderSize(conn, 256<<10)

var kb [32]byte
requests := make([]byte, 0, PIPE_SIZE*32)
base := id * OPS_CLIENT
sent := 0
for sent < OPS_CLIENT {
batch := PIPE_SIZE
if OPS_CLIENT-sent < batch {
batch = OPS_CLIENT - sent
}
requests = requests[:0]
for j := 0; j < batch; j++ {
kn := strconv.AppendInt(kb[:0], int64(base+sent+j), 10)
writeGetBytes(w, kn)
requests = appendGetBytes(requests, kn)
}
w.Flush()
writeFull(conn, requests)
skipGetReplies(r, batch)
sent += batch
}
Expand All @@ -131,7 +134,7 @@ func runKV() {
getRate := rate(totalOps, pipeGetElapsed)

fmt.Println("\n── KV Summary ──────────────────────────────────")
fmt.Printf("sequential SET: %s\n", fmtRate(seqRate))
fmt.Printf("pipeline-64 SET: %s\n", fmtRate(seqRate))
fmt.Printf("pipelined SET: %s\n", fmtRate(setRate))
fmt.Printf("pipelined GET: %s\n", fmtRate(getRate))
fmt.Printf("pipeline speedup: %.1fx\n", setRate/seqRate)
Expand Down Expand Up @@ -178,26 +181,25 @@ var (
crlfB = []byte("\r\n")
)

func writeSetBytes(w *bufio.Writer, key []byte) {
w.Write(setHdr)
writeLen(w, len(key))
w.Write(crlfB)
w.Write(key)
w.Write(valPart)
func appendSetBytes(out, key []byte) []byte {
out = append(out, setHdr...)
out = appendLen(out, len(key))
out = append(out, crlfB...)
out = append(out, key...)
return append(out, valPart...)
}

func writeGetBytes(w *bufio.Writer, key []byte) {
w.Write(getHdr)
writeLen(w, len(key))
w.Write(crlfB)
w.Write(key)
w.Write(crlfB)
func appendGetBytes(out, key []byte) []byte {
out = append(out, getHdr...)
out = appendLen(out, len(key))
out = append(out, crlfB...)
out = append(out, key...)
return append(out, crlfB...)
}

func writeLen(w *bufio.Writer, n int) {
func appendLen(out []byte, n int) []byte {
if n < 10 {
w.WriteByte(byte('0' + n))
return
return append(out, byte('0'+n))
}
var buf [5]byte
pos := len(buf)
Expand All @@ -206,7 +208,17 @@ func writeLen(w *bufio.Writer, n int) {
buf[pos] = byte('0' + n%10)
n /= 10
}
w.Write(buf[pos:])
return append(out, buf[pos:]...)
}

func writeFull(conn *net.TCPConn, p []byte) {
for len(p) != 0 {
n, err := conn.Write(p)
if err != nil {
panic(err)
}
p = p[n:]
}
}

func discardN(r *bufio.Reader, n int) {
Expand Down
Loading
Loading