Skip to content

reduce bucket tail latency with 2 map rotation - #101

Open
liusy182 wants to merge 1 commit into
VictoriaMetrics:masterfrom
liusy182:2-map-bench
Open

reduce bucket tail latency with 2 map rotation#101
liusy182 wants to merge 1 commit into
VictoriaMetrics:masterfrom
liusy182:2-map-bench

Conversation

@liusy182

Copy link
Copy Markdown

Summary

Replace synchronous hash-map cleanup during bucket generation update (when chunks are full) with 2 rotating maps to improve the tail latency.

Previously, Set called cleanLocked whenever a bucket’s chunks are full. cleanLocked scanned the bucket index, allocated a replacement map, and copied the live entries while holding the write lock. The resulting pause scaled with the no. of entries in the bucket. It blocked both reads and writes to that bucket. This caused bad tail latency for read requests, especially when the no. of entries is high.

The proposed implementation maintains 2 maps:

  • m stores entries written during the current generation.
  • mPrev retains entries from the previous generation that may still be live.

When the chunk ring wraps at gen+1, the maps rotate in O(1):

  • m becomes mPrev,
  • the old mPrev is discarded,
  • and a new empty m is created.

This removes the full-map scan and reconstruction from the Set hot path (in other words, Set need not to call cleanLocked anymore, we only need cleanLocked before Save). In the benchmark below, average new-gen write latency decreases from 3ms ms to 258 ns - this is over 10,000x lower.

The trade-off is that Get may need to check two maps - m then mPrev - when an entry is not found in m. This may result in slightly higher read latency. Based on the benchmark below, which shows the write latency of the same generation, we can proxy that the read-latency degradation is less than 15%.

Benchmark

Apple M4 Max
Go 1.24.13.

Benchmark run prior to this change

> go test -run '^$' -bench '^BenchmarkBucketSetAtNextGen$' -benchtime=5000000x -count=5 -benchmem
goos: darwin
goarch: arm64
pkg: github.com/VictoriaMetrics/fastcache
cpu: Apple M4 Max
BenchmarkBucketSetAtNextGen-16    	 5000000	       115.2 ns/op	   4999943 cur-gen/count	        47.14 cur-gen/ns/avg	    108125 cur-gen/ns/max	        57.00 next-gen/count	   3175191 next-gen/ns/avg	   3946000 next-gen/ns/max	      80 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	       116.6 ns/op	   4999943 cur-gen/count	        48.47 cur-gen/ns/avg	    127000 cur-gen/ns/max	        57.00 next-gen/count	   3202607 next-gen/ns/avg	   4006500 next-gen/ns/max	      80 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	       114.3 ns/op	   4999943 cur-gen/count	        47.01 cur-gen/ns/avg	    123625 cur-gen/ns/max	        57.00 next-gen/count	   3153882 next-gen/ns/avg	   3365667 next-gen/ns/max	      80 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	       115.3 ns/op	   4999943 cur-gen/count	        47.80 cur-gen/ns/avg	    132625 cur-gen/ns/max	        57.00 next-gen/count	   3185975 next-gen/ns/avg	   3460541 next-gen/ns/max	      80 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	       114.5 ns/op	   4999943 cur-gen/count	        47.19 cur-gen/ns/avg	    126584 cur-gen/ns/max	        57.00 next-gen/count	   3163214 next-gen/ns/avg	   3297459 next-gen/ns/max	      80 B/op	       0 allocs/op
PASS
ok  	github.com/VictoriaMetrics/fastcache	3.505s

Benchmark run with this change

>go test -run '^$' -bench '^BenchmarkBucketSetAtNextGen$' -benchtime=5000000x -count=5 -benchmem
goos: darwin
goarch: arm64
pkg: github.com/VictoriaMetrics/fastcache
cpu: Apple M4 Max
BenchmarkBucketSetAtNextGen-16    	 5000000	        85.80 ns/op	   4999943 cur-gen/count	        55.94 cur-gen/ns/avg	    138291 cur-gen/ns/max	        57.00 next-gen/count	       258.0 next-gen/ns/avg	       709.0 next-gen/ns/max	      54 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	        83.65 ns/op	   4999943 cur-gen/count	        54.69 cur-gen/ns/avg	    122000 cur-gen/ns/max	        57.00 next-gen/count	       233.2 next-gen/ns/avg	       666.0 next-gen/ns/max	      54 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	        83.99 ns/op	   4999943 cur-gen/count	        54.84 cur-gen/ns/avg	     91042 cur-gen/ns/max	        57.00 next-gen/count	       571.6 next-gen/ns/avg	     19291 next-gen/ns/max	      54 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	        83.30 ns/op	   4999943 cur-gen/count	        54.48 cur-gen/ns/avg	    145334 cur-gen/ns/max	        57.00 next-gen/count	       209.1 next-gen/ns/avg	       333.0 next-gen/ns/max	      54 B/op	       0 allocs/op
BenchmarkBucketSetAtNextGen-16    	 5000000	        83.58 ns/op	   4999943 cur-gen/count	        54.68 cur-gen/ns/avg	     97625 cur-gen/ns/max	        57.00 next-gen/count	       511.0 next-gen/ns/avg	     16167 next-gen/ns/max	      54 B/op	       0 allocs/op
PASS
ok  	github.com/VictoriaMetrics/fastcache	2.697s

Comment thread fastcache.go
b.mu.RLock()
s.EntriesCount += uint64(len(b.m))
entriesCount := len(b.m) + len(b.mPrev) - b.mPrevEntriesMask
s.EntriesCount += uint64(entriesCount)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change, len(b.m) + len(b.mPrev) - b.mPrevEntriesMask becomes more like a "proxy" to the actual entry count with some level of over estimation. This is because len(b.mPrev) may contain entries that have been overwritten by the entries in the b.m, but the keys in the b.mPrev may not be removed.

The alternative is to iterate through the map to get an accurate count, but that will be detrimental to the performance when UpdateStats is called.

Open to feedback on this.

Comment thread fastcache.go
b.gen++
}
needClean = true
b.mPrev = b.m

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the main change to address the tail latency:

in the Set(), we don't mark needClean and call cleanLocked() later on - because cleanLocked() is heavy. Instead, we swap the map and we are done. This means during the Get() calls we need to check both maps for a key's presence.

Comment thread fastcache.go
if (gen+1 == bGen || gen == maxGen && bGen == 1) && idx >= bIdx || gen == bGen && idx < bIdx {
bmNew[k] = v
}
bmPrev := b.mPrev

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a summary of the change in the cleanLocked()

  1. iterate through b.m and b.mPrev, find out the total no. of live items.
  2. create a new map with capacity equals to #1.
  3. iterate through b.m and b.mPrev, fill up the map created in #2 with live items items from these 2 maps.
  4. set b.m to the new map.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant