DEV Community

Remdore
Remdore

Posted on AI-assisted

Valkey 9.1's memory saving stops at 97 bytes, not the 128 in the release notes

Valkey 9.1 shipped in May with a line in the announcement that caught my eye: "Internal pointer optimizations bring up to a 20% memory reduction for strings under 128 bytes". I run a cache full of short values, so that is the kind of sentence I want to be true.

It is mostly true. The saving is real and it is large. But the boundary is not 128 bytes, and on my data it would have been 97. The gap between those two numbers is the whole reason I wrote this up, because if you size your values against the documented figure you can land just past the cliff and get nothing at all.

Everything below came from building 9.0.6 and 9.1.2 from their git tags and loading identical data into each. Same jemalloc, 5.3.0, in both binaries, so the allocator is not doing anything different underneath.

The saving is real

Five million keys, 64-byte values, 16-byte keys, no TTL, persistence off:

9.0.6 9.1.2 saved
used_memory 674.6 MB 554.6 MB 17.8%
RSS 699.8 MB 573.4 MB 18.1%

That is 120 MB off a 675 MB dataset for a version bump. I ran it twice and got 674.6 and 554.6 MB both times, to the byte on the megabyte scale. On a 4-vCPU cloud box with 2 million keys the same test gave 275.0 MB against 227.0 MB, 17.5%.

So "up to 20%" is a fair headline, if slightly generous. I never saw 20 in any configuration. The best I measured was 17.9%.

The boundary is 97 bytes

I swept value sizes to find where the effect fades, expecting a gentle taper towards 128. Instead it falls off a cliff:

value bytes 9.0.6 B/key 9.1.2 B/key saved
64 133.7 109.7 17.9%
96 165.7 141.7 14.5%
97 165.7 141.7 14.5%
98 165.7 165.7 0.0%
128 213.7 213.7 0.0%

One byte. At 97 bytes of value you save 14.5%, and at 98 you save nothing whatsoever. This reproduced on a completely different machine, so it is not an artefact of my laptop.

The reason is in object.c. The function that decides whether to embed a string does this:

size_t size = sizeof(robj) - sizeof(void *);
if (key) {
    size_t key_len = sdslen(key);
    size += sdsReqSize(key_len, sdsReqType(key_len)) + 1;
}
size += (expire != EXPIRY_NONE) * sizeof(long long);
size += sdsReqSize(val_len, SDS_TYPE_8);
return size <= 128;
Enter fullscreen mode Exit fullscreen mode

The 128 is real, and it is the number in the announcement. But it is not a budget for the value. It is a budget for the object header, the key, the expiry and the value added together. The value only gets what is left over.

With my 16-byte keys that leaves 97 bytes. Which means the limit moves depending on what your keys look like, and nothing in the announcement suggests that.

Predicting where the cliff moves

Since the rule is arithmetic, it should be possible to predict the boundary for other shapes and then go and check. Two predictions.

A 32-byte key eats more of the budget, and crosses an sds header boundary on the way, which should pull the cutoff down to 79 bytes:

   val      9.0.6      9.1.2
    79        165        141   saving
    80        165        165   none
Enter fullscreen mode Exit fullscreen mode

Adding a TTL costs eight bytes of budget, so with the original 16-byte key the cutoff should drop from 97 to 89:

   val      9.0.6      9.1.2
    89        171        155   saving
    90        171        171   none
Enter fullscreen mode Exit fullscreen mode

Both landed exactly where the arithmetic said they would.

So 97 is not a fact about Valkey. It is a fact about my keys. Somebody using user:session: prefixes and 36-character UUIDs is working with a budget in the sixties, and if they read 128 anywhere they are going to be disappointed by a wide margin. Add an expiry to those and it drops another eight.

Work out your own by counting: start at 128, subtract 8 for the object header, subtract your key plus its sds header and one byte, subtract 8 more if the key carries a TTL, and subtract 4 for the value's own header. What is left is how long a value can be before the saving stops.

The throughput claim did not survive

The same announcement promises "up to 17%" more throughput from a redesigned I/O threading model. This one I could not reproduce, and the shape of the failure is more interesting than the failure.

I ran it on a 4-vCPU DigitalOcean droplet in Frankfurt rather than my laptop, because benchmarking a threading change on a 16-core machine with the client competing for the same cores tells you very little. Five runs of valkey-benchmark -t set -n 800000 -c 50 -P 8 -d 64, medians:

io-threads 9.0.6 9.1.2
1 232,086 247,219
2 368,664 321,802
4 218,460 360,685

The best number either version produced was 394,867 for 9.0.6 at two threads, against 380,590 for 9.1.2 at four. If you tune both carefully, 9.1.2 came out 4% slower here, not 17% faster.

But look at what happens along the row for 9.0.6. Setting io-threads to the machine's core count made it slower than turning threading off entirely, 218,460 against 232,086. That is a genuine trap, and it is the setting most people would reach for first. On 9.1.2 the same obvious choice is the fastest configuration it has, 46% up on threading off.

So the redesign did not make peak throughput higher on this hardware. It made the naive configuration stop being a mistake. I would rather have that, but it is not what the announcement says.

Two caveats I cannot wave away. My laptop gave the opposite result, with 9.0.6 ahead at four threads, which tells you how much the answer depends on the machine. And the benchmark client shared those four vCPUs with the server, which is not how anyone runs this in production.

What I got wrong on the way

My throughput harness printed nothing for six runs, then printed zero for six more.

valkey-benchmark redraws a progress counter with carriage returns rather than newlines, so the progress text and the final summary arrive on the same physical line. My first parser anchored on ^SET and matched nothing, because the summary has a leading space. I fixed the anchor, and then it matched the line and read field two, which is the progress counter reading rps=0.0, not the result. Piping through tr '\r' '\n' first fixed it properly.

The failure mode I want to point at is the second one. It did not error. It printed a tidy table of zeros, and a tidy table of zeros is exactly what a real regression would look like.

I also nearly published a claim about RSS from far too little data. At 200,000 keys, repeated runs of the same binary on the same workload varied by up to 53 MB of RSS, while used_memory varied by a few kilobytes. Those early RSS numbers showed 9.1.2 using more memory at some sizes and 26% less at others, and all of it was noise. Only at five million keys did RSS settle down enough to confirm what the accounting had been saying all along.

Run it yourself

git clone https://github.com/valkey-io/valkey.git
cd valkey
git worktree add ../v906 9.0.6 && (cd ../v906 && make -j8)
git worktree add ../v912 9.1.2 && (cd ../v912 && make -j8)
Enter fullscreen mode Exit fullscreen mode

Then load a fixed number of keys into each and read used_memory before and after:

$BIN --port 7799 --dir "$DIR" --save '' --appendonly no --activedefrag no &
valkey-cli -p 7799 info memory | awk -F: '/^used_memory:/{print $2}'
python3 -c "
import sys
for i in range(200000):
    k, v = 'key:%012d' % i, 'v'*97
    sys.stdout.write('*3\r\n\$3\r\nSET\r\n\$%d\r\n%s\r\n\$%d\r\n%s\r\n' % (len(k), k, len(v), v))
" | valkey-cli -p 7799 --pipe
Enter fullscreen mode Exit fullscreen mode

Run that at 97 bytes and again at 98 and watch the difference appear and vanish. Pin activedefrag no and save '' on both sides or you will be measuring something else.

What to do about it

Go and look at your own key length before you plan anything around this. valkey-cli --memkeys, or a sample of a few thousand STRLEN calls, will tell you where your values actually sit. The question worth asking is not whether they are under 128. It is how many of them sit within a few bytes of your real cutoff, because those are the ones that move.

That is also the cheapest win available here. Shortening a key prefix from application:cache:user: to something terse buys back budget one byte at a time, and every byte moves more values under the line. It is a strange optimisation to be doing in 2026 and it works.

For the threading, my honest reading is that 9.1 has not raised the ceiling, it has removed a hole in the floor. If you had io-threads set to your core count on 9.0 you were probably paying for it. Check what yours is set to before you decide the upgrade did nothing for you.

Top comments (0)