Skip to content

Free strings during BGSAVE/BGAOFRW to reduce copy-on-write#905

Merged
ranshid merged 7 commits into
valkey-io:unstablefrom
poiuj:unstable
Dec 1, 2024
Merged

Free strings during BGSAVE/BGAOFRW to reduce copy-on-write#905
ranshid merged 7 commits into
valkey-io:unstablefrom
poiuj:unstable

Conversation

@poiuj

@poiuj poiuj commented Aug 13, 2024

Copy link
Copy Markdown
Contributor

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 madvise function 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 zfree directly. 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 zfree affects BGSAVE and the memory consumption I ran 45 benchmarks that compares my clonewith the vanilla version. The benchmark has the following steps:

  1. Start a new Valkey process
  2. Fill the DB with data sequentially
  3. Run a warmup to randomize the memory layout
  4. Introduce fragmentation by deleting part of the keys
  5. In parallel:
    1. Trigger BGSAVE
    2. Start 80/20 get/set load

I played the following parameters to understand their influence:

  1. Number of keys: 3M, 6M, and 12M.
  2. Data size. While key themselves are of fixed length ~30 bytes, the value size is 120, 250, 500, 1000, and 2000 bytes.
  3. Fragmentation. I delete 5%, 10%, and 15% of the original key range.

I'm attaching a graph of BGSAVE process memory consumption. Instead of all benchmarks, I show the most representative runs IMO.

3m-fixed

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.

500b-fixed

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:

  1. 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 with zfree it 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.

  2. Freeing string objects instead of dismissing them
    Call to zfree is more expensive than direct call to madvise. 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 optimize zfree for other data types as well.

poiuj added 2 commits August 12, 2024 18:25
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

codecov Bot commented Aug 13, 2024

Copy link
Copy Markdown

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 70.74%. Comparing base (a939cb8) to head (b4446ec).
Report is 6 commits behind head on unstable.

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     
Files with missing lines Coverage Δ
src/object.c 79.15% <100.00%> (ø)
src/rdb.c 76.57% <100.00%> (+0.32%) ⬆️

... and 19 files with indirect coverage changes

Comment thread src/rdb.c
@madolson

madolson commented Oct 7, 2024

Copy link
Copy Markdown
Member

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?

@ranshid

ranshid commented Oct 9, 2024

Copy link
Copy Markdown
Member

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?
like :

  1. use different value sizes (very small, medium, large)
  2. Test the impact on CoW on idle system
  3. Test performance impact (?)

also IMO @poiuj provided some supporting data, but I think we probably need to improve the results visualization

@poiuj

poiuj commented Oct 10, 2024

Copy link
Copy Markdown
Contributor Author

COW numbers for idle system with 3M keys, depending on value size:
3 bytes values – 0 MiB
45 bytes values – 4 MiB (~1.1% of the memory usage reported by valkey)
100 bytes values – 5 MiB (~0.9%)
250 bytes values – 27 MiB (~2.9%)
500 bytes values – 50 MiB (~3%)
1000 bytes values – 100 MiB (~3.2%)

The overhead seems to be in range of 1-3% approximately.

@madolson madolson requested a review from ranshid November 22, 2024 03:28
Comment thread src/rdb.c Outdated
comprlen = lzf_compress(s, len, out, outlen);
if (comprlen == 0) {
zfree(out);
if (outlen >= LZF_STATIC_BUFFER_SIZE) zfree(out);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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;

Comment thread src/rdb.h Outdated
#define RDB_LOAD_ERR_OTHER 2 /* Any other errors */

/* Size of the static buffer used for rdbcompression */
#define LZF_STATIC_BUFFER_SIZE (8 * 1024)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

consider moving LZF_STATIC_BUFFER_SIZE to rdb.c since it's only used internally

@ranshid ranshid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like the improvement.
I think that:

  1. we should make sure to test AOF as well
  2. 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.

Comment thread src/object.c Outdated

@ranshid ranshid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@ranshid

ranshid commented Nov 27, 2024

Copy link
Copy Markdown
Member

@valkey-io/core-team LGTM but maybe someone has any other concerns?

@enjoy-binbin enjoy-binbin added the run-extra-tests Run extra tests on this PR (Runs all tests from daily except valgrind and RESP) label Nov 27, 2024

@enjoy-binbin enjoy-binbin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, the top commet does not seem to mention the static buffer in lzf

@ranshid

ranshid commented Nov 27, 2024

Copy link
Copy Markdown
Member

@poiuj can you please check how on earth did the git misspelled your name on the signoff?
the last commiter name is spelled: Vadym Khoptyents
Please ammend the commit in order to satisfy DCO

@enjoy-binbin enjoy-binbin changed the title Free strings during BGSAVE Free strings during BGSAVE/BGAOFRW to reduce copy-on-write Nov 27, 2024
@ranshid

ranshid commented Nov 27, 2024

Copy link
Copy Markdown
Member

LGTM, the top commet does not seem to mention the static buffer in lzf

@poiuj lets bring that up in the top comment

Comment thread src/object.c
This will capture more cases. E.g. hashes.

Signed-off-by: Vadym Khoptynets <[email protected]>
@poiuj

poiuj commented Nov 27, 2024

Copy link
Copy Markdown
Contributor Author

The compression change is mentioned in the top comment:

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 with zfree it leads to reusing of the memory shared with the main process.
To deal with this problem, we use a static buffer for compression. If the object size is too big for the static buffer, than it falls back to the ad hoc allocation behavior.

@enjoy-binbin @ranshid can you suggest how to clarify the message here?

@ranshid

ranshid commented Nov 27, 2024

Copy link
Copy Markdown
Member

The compression change is mentioned in the top comment:

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 with zfree it leads to reusing of the memory shared with the main process.
To deal with this problem, we use a static buffer for compression. If the object size is too big for the static buffer, than it falls back to the ad hoc allocation behavior.

@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.

@ranshid

ranshid commented Nov 28, 2024

Copy link
Copy Markdown
Member

@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?
Noyt sure HOW important it is to avoid degradation since IMO for any operational installation of valkey jemalloc will probably be used, but maybe we can limit this optimization to JEMAlloc case?

@zuiderkwast

Copy link
Copy Markdown
Contributor

in case we use libc malloc will this introduce a degradation?
Noyt sure HOW important it is to avoid degradation since IMO for any operational installation of valkey jemalloc will probably be used, but maybe we can limit this optimization to JEMAlloc case?

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.)

@ranshid

ranshid commented Nov 28, 2024

Copy link
Copy Markdown
Member

in case we use libc malloc will this introduce a degradation?
Noyt sure HOW important it is to avoid degradation since IMO for any operational installation of valkey jemalloc will probably be used, but maybe we can limit this optimization to JEMAlloc case?

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.

@poiuj

poiuj commented Nov 28, 2024

Copy link
Copy Markdown
Contributor Author

I've run the benchmark with libc and there is no effect. It doesn't worth to complicate the code with turning off for other allocators.

3m-fixed-libc

@ranshid ranshid added the release-notes This issue should get a line item in the release notes label Nov 28, 2024
@ranshid

ranshid commented Nov 28, 2024

Copy link
Copy Markdown
Member

I've run the benchmark with libc and there is no effect. It doesn't worth to complicate the code with turning off for other allocators.

3m-fixed-libc

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?

@ranshid ranshid removed the run-extra-tests Run extra tests on this PR (Runs all tests from daily except valgrind and RESP) label Nov 28, 2024
@ranshid

ranshid commented Nov 28, 2024

Copy link
Copy Markdown
Member

note - taking off the run-extra-tests flag, we already have extended tests coverage

Comment thread src/object.c Outdated
if (o->encoding == OBJ_ENCODING_RAW) {
dismissSds(o->ptr);
}
freeStringObject(o);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

BTW - why do we need to keep the freeStringObject here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
freeStringObject(o);
if (o->encoding == OBJ_ENCODING_RAW) {
dismissSds(o->ptr);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Apparently I misunderstood you. I took the change.

@poiuj

poiuj commented Nov 28, 2024

Copy link
Copy Markdown
Contributor Author

The failing test passes for me locally. Talked to @ranshid about it. It's related to another issue.

@zuiderkwast

Copy link
Copy Markdown
Contributor

I've run the benchmark with libc and there is no effect. It doesn't worth to complicate the code with turning off for other allocators.

@poiuj That's great.

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?

@ranshid I'm fine with just mentioning this change in release notes. We don't test all possible impacts on all possible platforms.

Comment thread src/object.c Outdated
@hwware

hwware commented Nov 28, 2024

Copy link
Copy Markdown
Contributor

@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).

@ranshid

ranshid commented Dec 1, 2024

Copy link
Copy Markdown
Member

@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
The only failed test was related to aknown issue related to the last defrag changes and not new to this PR. I removed the tag in order to save unneeded excutions. I spoke with @zuiderkwast offline and we both agree this is fine to be merged now, given we add a note in the release notes about the fact that the change is optimizing JEMalloc builds and although not tested on all platforms we did not notice any degradation using other allocators.

ranshid and others added 2 commits December 1, 2024 15:45
Co-authored-by: Viktor Söderqvist <[email protected]>
Signed-off-by: ranshid <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-notes This issue should get a line item in the release notes

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

8 participants