diff --git a/README.md b/README.md index 2abd2d7..ac9f8dd 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,74 @@ Reads walk 50 SSTables. Each index fits in CPU cache so map lookups are fast and Fewer SSTables so reads are faster. Writes are slower since each flush is much larger. Index maps no longer fit in CPU cache, so bloom's compact bitset beats a plain map lookup on misses. +## go test benchmarks + +Run on Apple M4, `-benchtime=5s`. Numbers are per-operation. + +### Sequential writes + +| Config | ns/op | B/op | +|--------|------:|-----:| +| bloom=true, lock=true | 58,165 | 28,180 | +| bloom=true, lock=false | 73,167 | 36,454 | +| bloom=false, lock=true | 70,526 | 35,228 | +| bloom=false, lock=false | 80,792 | 39,588 | + +### Sequential reads — hits (10k keys pre-loaded) + +| Config | ns/op | +|--------|------:| +| bloom=true, lock=true | 5,445 | +| bloom=true, lock=false | 5,364 | +| bloom=false, lock=true | 5,351 | +| bloom=false, lock=false | 5,333 | + +### Sequential reads — misses (10k keys pre-loaded, reading non-existent keys) + +| Config | ns/op | +|--------|------:| +| bloom=true, lock=true | 65.4 | +| bloom=true, lock=false | 65.1 | +| bloom=false, lock=true | 67.5 | +| bloom=false, lock=false | 67.4 | + +### Reads — large dataset (100k keys, hits vs misses) + +| Config | ns/op | +|--------|------:| +| hit, bloom=true | 5,418 | +| hit, bloom=false | 5,378 | +| miss, bloom=true | 97.5 | +| miss, bloom=false | 110.4 | + +### Concurrent writes (`b.RunParallel`, 10 goroutines) + +| Config | ns/op | +|--------|------:| +| bloom=true, lock=true | 18,639 | +| bloom=false, lock=true | 18,889 | + +### Concurrent reads (`b.RunParallel`, 10 goroutines) + +| Config | ns/op | +|--------|------:| +| bloom=true, lock=true | 4,696 | +| bloom=false, lock=true | 4,754 | + +### What the numbers tell us + +**Bloom filter only helps misses.** On a hit, the bloom filter says "maybe" and you still have to read the index — so you pay the hash cost for nothing. On a miss, bloom can reject a key outright without touching the index at all, which is where the speedup comes from. + +**The bloom advantage scales with index size.** With 10k keys (small SSTables, index fits in cache), bloom saves ~3% on misses (65ns vs 67ns). With 100k keys (larger indices after compaction), the saving grows to ~12% (97ns vs 110ns). The index maps are too large for CPU cache at that point, so each lookup becomes a RAM access; bloom's compact bitset stays cache-hot and wins. + +**Bloom never helps read hits.** The hit numbers across small and large datasets are nearly identical with and without bloom (~5.3–5.4µs). The SSTable data file read dominates, and bloom adds a small hash overhead before it. + +**An uncontended lock is basically free.** For sequential writes, `lock=true` is actually faster than `lock=false` (58µs vs 73µs). There's no contention so the mutex costs nothing, and the two code paths end up with different allocation patterns — `lock=false` allocates about 30% more memory per op, which is where the slowdown comes from. + +**Concurrent writes scale to about 3x, not 10x.** Sequential puts cost ~58µs; parallel drops to ~18µs across 10 goroutines. The ceiling is the memtable write lock — WAL appends and B-tree inserts both serialize, so adding more goroutines doesn't help past a point. + +**Concurrent reads barely move the needle** (~5.4µs → 4.7µs). Reads take a shared `RLock` so they can technically run in parallel, but the bottleneck is SSTable file I/O which doesn't fan out well on a single drive. + ## Running ```bash diff --git a/db/db.go b/db/db.go index cd2ef3f..d3d0888 100644 --- a/db/db.go +++ b/db/db.go @@ -7,6 +7,7 @@ import ( "lorem-lsm/sstable" "lorem-lsm/wal" "os" + "sync" "time" ) @@ -16,10 +17,12 @@ type LoremDB struct { ssTables []*sstable.SSTable ssTablePath string useBloom bool + useLock bool ssTableLimit int + lock sync.RWMutex } -func NewLoremDB(useBloom bool) *LoremDB { +func NewLoremDB(useBloom bool, supportConcurrency bool) *LoremDB { os.MkdirAll("sstable", 0755) writeAheadLog, _ := wal.NewWal("wal.log") @@ -43,11 +46,17 @@ func NewLoremDB(useBloom bool) *LoremDB { ssTablePath: ssTablePath, useBloom: useBloom, ssTableLimit: 5, + useLock: supportConcurrency, } } func (db *LoremDB) Put(key string, value string) error { // write wal first to maximize data recovery chances + if db.useLock { + db.lock.Lock() + defer db.lock.Unlock() + } + db.wal.Append(wal.WalRow{ Key: key, Value: value, @@ -79,6 +88,12 @@ func (db *LoremDB) Put(key string, value string) error { } func (db *LoremDB) Delete(key string) error { + + if db.useLock { + db.lock.Lock() + defer db.lock.Unlock() + } + // write wal first to maximize data recovery chances db.wal.Append(wal.WalRow{ Key: key, @@ -106,6 +121,12 @@ func (db *LoremDB) Delete(key string) error { } func (db *LoremDB) Get(key string) (string, bool) { + + if db.useLock { + db.lock.RLock() + defer db.lock.RUnlock() + } + // check in memtable val, ok := db.memTable.Get(key) if ok { diff --git a/db/db_bench_test.go b/db/db_bench_test.go new file mode 100644 index 0000000..344fd7d --- /dev/null +++ b/db/db_bench_test.go @@ -0,0 +1,176 @@ +package db + +import ( + "fmt" + "os" + "testing" +) + +func setupDB(b *testing.B, useBloom bool, useLock bool) *LoremDB { + os.RemoveAll("sstable") + os.Remove("wal.log") + os.MkdirAll("sstable", 0755) + return NewLoremDB(useBloom, useLock) +} + +func BenchmarkPut(b *testing.B) { + configs := []struct { + useBloom bool + useLock bool + }{ + {true, true}, + {true, false}, + {false, true}, + {false, false}, + } + for _, cfg := range configs { + name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock) + b.Run(name, func(b *testing.B) { + store := setupDB(b, cfg.useBloom, cfg.useLock) + b.ResetTimer() + for i := 0; i < b.N; i++ { + store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) + } + }) + } +} + +func BenchmarkGet(b *testing.B) { + configs := []struct { + useBloom bool + useLock bool + }{ + {true, true}, + {true, false}, + {false, true}, + {false, false}, + } + for _, cfg := range configs { + name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock) + b.Run(name, func(b *testing.B) { + store := setupDB(b, cfg.useBloom, cfg.useLock) + for i := 0; i < 10000; i++ { + store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + store.Get(fmt.Sprintf("key-%d", i%10000)) + } + }) + } +} + +// BenchmarkGetMiss reads keys that don't exist. Bloom filter can short-circuit +// every SSTable lookup (no index access needed), so the gap vs no-bloom is largest here. +func BenchmarkGetMiss(b *testing.B) { + configs := []struct { + useBloom bool + useLock bool + }{ + {true, true}, + {true, false}, + {false, true}, + {false, false}, + } + for _, cfg := range configs { + name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock) + b.Run(name, func(b *testing.B) { + store := setupDB(b, cfg.useBloom, cfg.useLock) + for i := 0; i < 10000; i++ { + store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + store.Get(fmt.Sprintf("miss-%d", i)) + } + }) + } +} + +// BenchmarkGetLargeDataset loads enough keys to trigger compaction and produce +// large SSTables whose index maps no longer fit in CPU cache. At this scale +// bloom's compact bitset should beat plain map lookups for both hits and misses. +func BenchmarkGetLargeDataset(b *testing.B) { + const preload = 100000 + configs := []struct { + useBloom bool + label string + }{ + {true, "hit,bloom=true"}, + {false, "hit,bloom=false"}, + {true, "miss,bloom=true"}, + {false, "miss,bloom=false"}, + } + _ = configs + for _, hit := range []bool{true, false} { + for _, useBloom := range []bool{true, false} { + hit, useBloom := hit, useBloom + label := fmt.Sprintf("hit=%v,bloom=%v", hit, useBloom) + b.Run(label, func(b *testing.B) { + store := setupDB(b, useBloom, false) + for i := 0; i < preload; i++ { + store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if hit { + store.Get(fmt.Sprintf("key-%d", i%preload)) + } else { + store.Get(fmt.Sprintf("miss-%d", i)) + } + } + }) + } + } +} + +func BenchmarkPutConcurrent(b *testing.B) { + configs := []struct { + useBloom bool + useLock bool + }{ + {true, true}, + {false, true}, + } + for _, cfg := range configs { + name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock) + b.Run(name, func(b *testing.B) { + store := setupDB(b, cfg.useBloom, cfg.useLock) + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) + i++ + } + }) + }) + } +} + +func BenchmarkGetConcurrent(b *testing.B) { + configs := []struct { + useBloom bool + useLock bool + }{ + {true, true}, + {false, true}, + } + for _, cfg := range configs { + name := fmt.Sprintf("bloom=%v,lock=%v", cfg.useBloom, cfg.useLock) + b.Run(name, func(b *testing.B) { + store := setupDB(b, cfg.useBloom, cfg.useLock) + for i := 0; i < 10000; i++ { + store.Put(fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + store.Get(fmt.Sprintf("key-%d", i%10000)) + i++ + } + }) + }) + } +} diff --git a/db/db_test.go b/db/db_test.go index 03e5052..e6abc10 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -11,12 +11,12 @@ func TestCrashRecovery(t *testing.T) { os.MkdirAll("sstable", 0755) // write some keys - store := NewLoremDB(true) + store := NewLoremDB(true, true) store.Put("name", "sakar") store.Put("lang", "go") // simulate crash — don't flush, just create a new instance - store2 := NewLoremDB(true) + store2 := NewLoremDB(true, true) val, ok := store2.Get("name") if !ok || val != "sakar" { diff --git a/go.mod b/go.mod index 8e214e0..926fca4 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module lorem-lsm go 1.26.2 -require github.com/google/btree v1.1.3 // indirect +require github.com/google/btree v1.1.3 diff --git a/sstable/sstable.go b/sstable/sstable.go index 296d289..9eb226e 100644 --- a/sstable/sstable.go +++ b/sstable/sstable.go @@ -24,7 +24,6 @@ func (table *SSTable) Close() { table.indexFile.Close() table.dataFile.Close() os.RemoveAll(table.BasePath) - println("Cleaned") } func CreateSSTable(basePath string, useBloom bool) (*SSTable, error) { @@ -120,14 +119,17 @@ func (ssTable *SSTable) Get(key string) (string, bool) { if !yes { return "", false } + handle, err := os.Open(ssTable.dataFile.Name()) - _, err := ssTable.dataFile.Seek(seekPoint, 0) + defer handle.Close() - if err != nil { + _, err1 := handle.Seek(seekPoint, 0) + + if err1 != nil || err != nil { return "", false } - reader := bufio.NewReader(ssTable.dataFile) + reader := bufio.NewReader(handle) row, err := reader.ReadString('\n') row = strings.TrimSuffix(row, "\n")