Free strings during BGSAVE/BGAOFRW to reduce copy-on-write#905
Conversation
Lazily allocate 8KiB buffer to use for compression. This buffer should be large enough for most cases. When a larger buffer is required, rdbSaveLzfStringObject falls back to the ad hoc allocation. This change goes together with the further change that introduces freeing string objects during BGSAVE. It greatly decreases the number of COW events. Otherwise compression allocations reuses the memory freed by string objects. Which leads to COW. Signed-off-by: Vadym Khoptynets <[email protected]>
Instead of dismissing with madvise(MADV_DONTNEED) we free it via the allocator. This has advantage that it allows the allocator to accumulate free buffers to free whole pages. While madvise is nop if the buffer is less than a page. The allocator overhead is minimal, because raw strings (sds) use the fast path. Signed-off-by: Vadym Khoptynets <[email protected]>
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## unstable #905 +/- ##
============================================
+ Coverage 70.59% 70.74% +0.14%
============================================
Files 117 118 +1
Lines 63324 63386 +62
============================================
+ Hits 44704 44841 +137
+ Misses 18620 18545 -75
|
|
I guess a general concern I have is that all of these benchmarks are run on systems that are being stressed. What if the write rate is very low, would we be increasing the CoW usage in the vast majority of cases? |
@madolson if the system is not stressed, is that a very big issue if the CoW is increased? lets try and define the extra tests we need in order to gain more confidence?
also IMO @poiuj provided some supporting data, but I think we probably need to improve the results visualization |
|
COW numbers for idle system with 3M keys, depending on value size: The overhead seems to be in range of 1-3% approximately. |
| comprlen = lzf_compress(s, len, out, outlen); | ||
| if (comprlen == 0) { | ||
| zfree(out); | ||
| if (outlen >= LZF_STATIC_BUFFER_SIZE) zfree(out); |
There was a problem hiding this comment.
Consider: if (out != buffer) zfree(out); it is more clear.
And maybe to avoid the code duplication:
comprlen = lzf_compress(s, len, out, outlen);
ssize_t nwritten = comprlen > 0 ? rdbSaveLzfBlob(rdb, out, comprlen, len) :0;
if (out != buffer) zfree(out);
return nwritten;
| #define RDB_LOAD_ERR_OTHER 2 /* Any other errors */ | ||
|
|
||
| /* Size of the static buffer used for rdbcompression */ | ||
| #define LZF_STATIC_BUFFER_SIZE (8 * 1024) |
There was a problem hiding this comment.
consider moving LZF_STATIC_BUFFER_SIZE to rdb.c since it's only used internally
Signed-off-by: Vadym Khoptynets <[email protected]>
ranshid
left a comment
There was a problem hiding this comment.
I like the improvement.
I think that:
- we should make sure to test AOF as well
- can we bold out the save times in the data collected. they mostly look the same, but I want to make sure we do not sacrifice save time for the memory peak.
|
@valkey-io/core-team LGTM but maybe someone has any other concerns? |
enjoy-binbin
left a comment
There was a problem hiding this comment.
LGTM, the top commet does not seem to mention the static buffer in lzf
|
@poiuj can you please check how on earth did the git misspelled your name on the signoff? |
@poiuj lets bring that up in the top comment |
This will capture more cases. E.g. hashes. Signed-off-by: Vadym Khoptynets <[email protected]>
|
The compression change is mentioned in the top comment:
@enjoy-binbin @ranshid can you suggest how to clarify the message here? |
Maybe just mention that you use a constant 8K buffer to hold the comressed size. |
|
@poiuj one thought I just realized is that we are targetting this as a use when JEMalloc is the allocator right? in case we use libc malloc will this introduce a degradation? |
Let's test it with libc malloc before we decide to use conditional code. Maybe it's good for other allocators too..? Btw, libc malloc is different on linux vs freebsd vs macos. (Freebsd's libc malloc is actually jemalloc.) |
Yes. I know about the difference platforms and I agree we better test for it. But I also do not feel very strongly about not offering this only for jemalloc in order to reduce the blast. We often provide many changes which are jemalloc biased since this is practically the production level used allocator. |
While I agree about code complications, as @zuiderkwast correctly mentioned it might be different impact on different platforms. I only think that if we are not going to test it on all common platforms we should atleast state that on the release notes. @zuiderkwast WDYT? |
|
note - taking off the run-extra-tests flag, we already have extended tests coverage |
| if (o->encoding == OBJ_ENCODING_RAW) { | ||
| dismissSds(o->ptr); | ||
| } | ||
| freeStringObject(o); |
There was a problem hiding this comment.
BTW - why do we need to keep the freeStringObject here?
There was a problem hiding this comment.
| freeStringObject(o); | |
| if (o->encoding == OBJ_ENCODING_RAW) { | |
| dismissSds(o->ptr); | |
| } |
There was a problem hiding this comment.
We still use freeStringObject in decrRefCount. So this decision is extra call vs repeating code. I prefer the extra call as compiler can optimize that, while we avoid repeating the code.
There was a problem hiding this comment.
I am not sure what repeating code or extra call in this context. I just thought to keep this function as it was before since the change can only be done in dismissSds.
I do not see any issue with this change so I approved it. probably not worth the debate :)
There was a problem hiding this comment.
Apparently I misunderstood you. I took the change.
|
The failing test passes for me locally. Talked to @ranshid about it. It's related to another issue. |
@poiuj That's great.
@ranshid I'm fine with just mentioning this change in release notes. We don't test all possible impacts on all possible platforms. |
|
@poiuj Can you run a fully daily CI in your repo, we just want to ensure the test can be passed in every platform (This is only concern from my side). |
@hwware We already had the daily runs running on this PR: https://github.com/valkey-io/valkey/actions/runs/12067940597/job/33651977697 |
Co-authored-by: Viktor Söderqvist <[email protected]> Signed-off-by: ranshid <[email protected]>
Signed-off-by: Vadym Khoptynets <[email protected]>


Motivation
Copy-on-write (COW) amplification refers to the issue where writing to a small object leads to the entire page being cloned, resulting in inefficient memory usage. This issue arises during the BGSAVE process, which can be particularly problematic on instances with limited memory. If the BGSAVE process could release unneeded memory, it could reduce memory consumption. To address this, the BGSAVE process calls the
madvisefunction to signal the operating system to reclaim the buffer. However, this approach does not work for buffers smaller than a page (usually 4KiB). Even after multiple such calls, where a full page may be free, the operating system will not reclaim it.To solve this issue, we can call
zfreedirectly. This allows the allocator (jemalloc) to handle the bookkeeping and release pages when buffers are no longer needed. This approach reduces copy-on-write events.Benchmarks
To understand how usage of
zfreeaffects BGSAVE and the memory consumption I ran 45 benchmarks that compares my clonewith the vanilla version. The benchmark has the following steps:I played the following parameters to understand their influence:
I'm attaching a graph of BGSAVE process memory consumption. Instead of all benchmarks, I show the most representative runs IMO.
For 2000 bytes values peak memory usage is ~53% compared to vanilla. The peak happens at 57% BGSAVE progress.
For 500 bytes values the peak is ~80% compared to vanilla. And happens at ~80% progress.
For 120 bytes the difference is under 5%, and the patched version could even use more memory.
For 12M keys, the peak is ~85% of the vanilla’s. Happens at ~70% mark.
For 6M keys, the peak is ~87% of the vanilla’s. Happens at ~77% mark.
For 3M keys, the peak is ~87% of the vanilla’s Happens at ~80% mark.
Changes
The PR contains 2 changes:
Static buffer for RDB comrpession.
RDB compression leads to COW events even without any write load if we use
zfree. It happens because the compression functions allocates a new buffer for each object. Together with freeing objects withzfreeit leads to reusing of the memory shared with the main process.To deal with this problem, we use a pre-allocated constant 8K buffer for compression. If the object size is too big for this buffer, than we fall back to the ad hoc allocation behavior.
Freeing string objects instead of dismissing them
Call to
zfreeis more expensive than direct call tomadvise. But with Add zfree_with_size to optimize sdsfree since we can get zmalloc_size from the header #453 strings use the fast path –zfree_with_size. As a possible next step we can optimizezfreefor other data types as well.