Skip to content

perf(storage): ZADD reaches its listpack encoding — small zsets stop being skiplists - #793

Open
TinDang97 wants to merge 1 commit into
mainfrom
perf/zset-listpack-encoding
Open

perf(storage): ZADD reaches its listpack encoding — small zsets stop being skiplists#793
TinDang97 wants to merge 1 commit into
mainfrom
perf/zset-listpack-encoding

Conversation

@TinDang97

Copy link
Copy Markdown
Collaborator

Root cause

SortedSetListpack was wired end to end — classify_hot answered it, every _readonly
twin already had an entries_sorted() fallback for it — but nothing in the tree ever
constructed one that survived
, so every sorted set was a skiplist from its first
member. Redis keeps a zset in a listpack until it exceeds zset-max-listpack-entries
(128) or zset-max-listpack-value (64).

ZADD z 1 a 2 b 3 c   ->  moon: skiplist   redis: listpack

Same shape as #787 (SADD): the listpack guard does not live in OwnedKind::upgrade, it
lives in a per-type accessor the command layer calls instead of the owned accessor, and
get_or_create_zset_listpack did not exist.

The zset case had a second, sharper edge. SortedSetKind::upgrade did not upgrade a
listpack — a comment said its upgrade "lives in the zset command layer", a layer that does
not exist — and project_mut only matches SortedSetBPTree. So a listpack zset reaching
any owned accessor answered WRONGTYPE, not merely "promoted early". The accessor and
the upgrade arm therefore had to land together. Mutation-testing proved it: with the arm
disabled, ZREM on a ZADD-created zset returns WRONGTYPE instead of 1.

Why this one matters most

zset is the single largest per-key memory gap in the campaign — 19.4x vs redis 7.4.2
(4,721 B/key vs 244), the only dimension where moon loses by more than an order of
magnitude.

Score round-trip: verified, not assumed

Scores are stored as format_score_bytes output (Rust's {} for f64 is the shortest
round-trip-exact rendering), and ListpackEntry::as_score is its single inverse. The
listpack re-encodes integral renderings as Integer, so the i64 boundary is the risk.
Probed on a live server, comparing the new listpack path against the existing skiplist
path on identical input
(skiplist forced with a 65-byte member) — 14/14 agree:

score in listpack skiplist
9007199254740993 9007199254740992 9007199254740992
9223372036854775807 9223372036854776000 9223372036854776000
9223372036854775808 9223372036854776000 9223372036854776000
1e30 / -1e30 full expansion identical
1.0000000000000002 exact exact
4.9e-324 (min subnormal) exact exact
inf / -inf / -0 / 0.1 / 3.0 match match

Values above i64::MAX fall to the String arm (parse::<i64>() returns None on
overflow) and parse back at full precision.

Tests

Red/green: 6 of 10 new tests failed before the fix with left: "skiplist", right: "listpack". Lib suite 5118 / 0. Three guards mutation-tested (broken → red →
restored → green) — necessary because both threshold tests pass vacuously before the
fix, everything already being a skiplist.

test_object_encoding_sorted_set asserted the bug (a one-member zset must be
skiplist); corrected, with a new test keeping the skiplist case covered.

Verified against a live redis 8.6.1 oracle: 17 new script rows pass, including the
128/129-member and 64/65-byte threshold boundaries, NX/XX/GT/LT/CH, DUMP/RESTORE, and
kill-and-restart.

Known limitations — stated, not hidden

  • ZADD only. ZREM, ZINCRBY, ZPOPMIN/ZPOPMAX, ZRANGESTORE, ZUNIONSTORE,
    ZINTERSTORE still promote to skiplist (measured: after ZREM moon: skiplist redis: listpack). No test codifies the divergence and no consistency row asserts the reverting
    cases.
  • Reads do not promote — the five read commands are served by dispatch_read's
    _readonly twins and leave the key a listpack. The mutable twins would promote and are
    reachable via shard routing, so this is a property of routing, not a guarantee.
  • Two pre-existing ZADD gaps found and deliberately untouched (ZADD k XX 1 a on a missing
    key leaves an empty zset; ZADD k 1 a nan b applies a then errors where Redis
    validates all scores first).
  • ZADD CH miscounts sub-epsilon score changes — ZADD CH miscounts sub-epsilon score changes: absolute f64::EPSILON compare where Redis compares exactly #792, pre-existing in the B+tree
    path; the listpack path deliberately mirrors it so the two encodings agree.

Gates

macOS host: fmt, clippy default, clippy tokio, release lib suite — 4/4. No RSS
number is claimed
: every memory figure must come from Linux, and this branch has not
been measured there yet. The 19.4x above is main's measured gap, not a post-fix claim.

Refs #787
author: Tin Dang

…being skiplists

`SortedSetListpack` was wired end to end but nothing ever produced one, so every
sorted set was a `skiplist` from its first member. Redis keeps a zset in a
listpack until it exceeds zset-max-listpack-entries (128) or
zset-max-listpack-value (64). Verified against a redis 8.6.1 oracle:

    ZADD z 1 a 2 b 3 c   ->  moon: skiplist   redis: listpack

This is the largest per-key memory loss in the campaign: 4722.1 B/key against
redis 7.0.15's 229.9 (20.5x) and 7.4.2's 244.0 (19.4x), the only dimension where
moon loses by more than an order of magnitude.

## Root cause

Identical in shape to #787 (SADD). The listpack guard does not live in
`OwnedKind::upgrade`; it lives one level up, in a per-type accessor the command
layer calls INSTEAD of the owned accessor:

    get_or_create_intset            EXISTS   <- SADD, integer members
    get_or_create_hash_listpack     EXISTS   <- HSET
    get_or_create_list_listpack     EXISTS   <- RPUSH
    get_or_create_zset_listpack     MISSING

Without it, ZADD went straight to `get_or_create_sorted_set`. The zset case had
a second, sharper edge: `SortedSetKind::upgrade` did NOT upgrade a listpack (a
comment said its upgrade "lives in the zset command layer" — a layer that did
not exist), and `project_mut` only matches `SortedSetBPTree`. So a listpack zset
reaching ANY owned accessor answered WRONGTYPE, not merely "promoted early".
That is why the accessor and the upgrade arm have to land together.

## Change

- `get_or_create_zset_listpack` / `upgrade_zset_listpack_to_bptree`, modelled on
  the hash pair including the cold-tier rule (cold storage never persists a
  compact encoding, so a promoted value decodes as `SortedSetBPTree` and lands
  in the `Ok(None)` fall-through arm rather than being fabricated over).
- `SortedSetKind::upgrade` gains the `SortedSetListpack` arm — the safety net
  that keeps every non-ZADD command correct on a key ZADD created compact.
  Promotion is one-way, matching Redis.
- ZADD routes through the listpack below both thresholds, honouring
  NX/XX/GT/LT/CH in listpack form, and promotes past either with the same
  one-time cost-model handoff `hset` and the intset path already perform.
- Scores are stored as `format_score_bytes` output, not the raw argument, so
  `ZADD z 3.0 m` answers `ZSCORE` with `3` exactly as the B+tree form does;
  Rust's `{}` for f64 is the shortest round-trip-exact rendering, so
  `ListpackEntry::as_score` recovers the identical f64 (including inf/-inf).
  `as_score` is the single inverse of `format_score_bytes`.
- `parse_zadd_score` is shared by both ZADD paths so the two encodings cannot
  diverge on which score arguments they accept.

Reads needed nothing: `SortedSetKind::classify_hot` already answers
`SortedSetRef::Listpack`, and every `_readonly` twin already had an
`entries_sorted()` fallback for it — paths that until now were unreachable.

## Tests

Red/green. Ten new unit tests; the RED test asserts through the real
`OBJECT ENCODING` handler rather than a private field, and 6 of them failed
before the fix with `left: "skiplist", right: "listpack"`.

Three guards mutation-tested — each broken deliberately, shown red, restored:

    lp.len()/2 > LISTPACK_MAX_ENTRIES   -> usize::MAX
        zadd_promotes_past_the_entry_threshold  FAILED (listpack != skiplist)
    m.len() > LISTPACK_MAX_ELEMENT_SIZE -> usize::MAX
        zadd_promotes_on_an_oversized_member    FAILED (listpack != skiplist)
    SortedSetKind::upgrade listpack arm -> disabled
        listpack_zset_upgrades_transparently_on_a_non_zadd_write
        FAILED (ZREM answered WRONGTYPE, not 1)

Both threshold tests pass VACUOUSLY before the fix — everything is already a
skiplist — which is exactly why they were mutation-tested.

`test_object_encoding_sorted_set` ASSERTED THE BUG (a one-member zset must be
`skiplist`) and is corrected to expect `listpack`; a new
`test_object_encoding_sorted_set_skiplist_past_threshold` keeps the skiplist
encoding covered. That assertion is why the divergence survived, so 13 zset
OBJECT ENCODING / score-rendering rows are added to scripts/test-consistency.sh
and 6 to scripts/test-commands.sh — no row in either probed zset encoding.

Full lib suite 5118 passed / 0 failed (baseline 5108 + 10 new). clippy clean on
both runtimes with --all-targets; cargo fmt --check clean.

All 17 new rows verified green against a live redis 8.6.1 oracle, plus a
command-by-command A/B: thresholds (128 listpack / 129 skiplist, 64-byte member
listpack / 65-byte skiplist), score normalisation (3.0->3, 1e3->1000,
3.5000->3.5, inf, -inf, 1.0000000000000002), all five ZADD flags, duplicate
in-place update, WRONGTYPE, DUMP/RESTORE round trip, and a kill-and-restart
(the zset comes back as a listpack because the WAL replays ZADD).

## Known limitation — ZADD only

`ZREM`, `ZINCRBY`, `ZPOPMIN`/`ZPOPMAX`, `ZRANGESTORE`, `ZUNIONSTORE` and
`ZINTERSTORE` still reach the value through `get_or_create_sorted_set`, so they
promote a listpack zset to a skiplist. Measured against the oracle:

    after ZREM      moon: skiplist   redis: listpack
    after ZINCRBY   moon: skiplist   redis: listpack
    after ZPOPMIN   moon: skiplist   redis: listpack
    after ZADD dup  moon: listpack   redis: listpack

READS do not promote: ZSCORE, ZCARD, ZRANK, ZRANGE and ZRANGEBYSCORE are served
by `dispatch_read`'s `_readonly` twins, which classify every encoding. Measured
on a live server — all five leave the key a listpack.

So a zset that is only ever added to and read from stays compact — the common
case, and the one the RSS harness measures — while a zset touched by a removal
reverts. That is a strict improvement over "always a skiplist", but it is NOT
full parity, and the encoding now depends on command history.

No consistency row is added for the reverting cases: a row asserting the current
divergent answer would codify the bug, which is precisely the mistake
`test_object_encoding_sorted_set` made and this commit corrects.

Two further gaps found while probing, both PRE-EXISTING at HEAD (confirmed by
reading `git show HEAD:src/command/sorted_set/sorted_set_write.rs` — the same
`get_or_create_sorted_set`-before-validation shape) and deliberately NOT changed
here: `ZADD k XX 1 a` on a missing key leaves an empty zset (`EXISTS` 1 vs
Redis's 0), and a bad score mid-list is applied partially (`ZADD k 1 a nan b`
leaves `a` where Redis validates every score first and writes nothing).

No RSS number is claimed: every published figure must come from a Linux host,
and this was developed on macOS.

Refs #787
author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@TinDang97

Copy link
Copy Markdown
Collaborator Author

Directional memory read — macOS, NOT a publishable number

The PR text says no RSS number is claimed, and that still holds: every published figure
must come from Linux, and the GCE host is currently held by another measurement. This is a
directional check to de-risk that run, not a result.

Same release-fast profile on both sides (the change's parent d63ffcd8 built fresh, so
the A/B is not confounded by build profile). 40,000 five-member zsets, --shards 1,
--appendonly no --disk-offload disable, 2 reps:

binary encoding used_memory B/key RSS B/key (rep 1 / rep 2)
parent d63ffcd8 skiplist 764.0 4422.0 / 4419.6
this branch listpack 218.0 295.7 / 297.8

~14.9x less memory per zset key, reps agreeing to within 0.7%.

Two independent cross-checks say the harness is measuring the right thing:

  1. The parent's macOS RSS (4,421 B/key) lands within 6.4% of the independently
    Linux-measured 4,721 B/key for the same shape of workload — two different platforms,
    two different harnesses, same answer for the unchanged binary.
  2. The used_memory vs RSS ratio on the parent is 4421/764 = 5.79x, reproducing the
    5.75x container ledger gap documented in used_memory under-reports container types up to 5.75x on Linux — --maxmemory cannot bind (quantifies #475) #788 almost exactly. That gap is itself the
    reason RSS, not used_memory, is the number that matters here.

If the Linux run reproduces this, zset moves from 19.4x vs redis 7.4.2 (244 B/key) to
roughly parity. That is a projection across platforms and is exactly the claim the GCE
measurement has to confirm or refute before it goes anywhere public.

An earlier attempt at this measurement read allocator_allocated from INFO memory --
a field moon does not expose -- and produced empty cells rather than an error. The
encodings were still confirmed on that run; the byte figures above come from the corrected
harness.

@TinDang97

Copy link
Copy Markdown
Collaborator Author

Linux measurement — confirmed. zset 24.50x → 1.58x vs Redis 7.4.2

Supersedes the directional macOS comment above. GCE moon-bench-x86 (c3-standard-8, Xeon 8481C, 8 vCPU, Ubuntu 24.04), --shards 1, fresh server per row, 200,000 keys x 5 members, VmRSS loaded−idle, 3 interleaved reps, 12/12 rows valid.

engine B/key encoding
moon main (d27fb0c5) 4,431.3 skiplist
moon + this PR (06005a3a) 286.4 listpack
redis 7.0.15 151.6 listpack
redis 7.4.2 180.9 listpack

15.47x less memory per zset key. Against the two oracles, leading with the newer:

vs before after
redis 7.4.2 24.50x 1.58x
redis 7.0.15 29.24x 1.89x

Reps agree to ±0.05% (286.2 / 286.4 / 286.5). This closes the single largest per-key memory gap in the campaign — the only dimension where moon lost by more than an order of magnitude.

Two provenance notes, because the first attempt at this was wrong

The first Linux run was void and reported enc=skiplist, reduction 1.00x. Cause: the GCE repo's origin is a local bundle, not GitHub, so git fetch failed and it built 5c76a656d — the set-listpack branch. The binary was never this PR. My script's provenance guard fired but only printed a warning and let the build continue, which is the whole failure: a guard that cannot fail the run is not a guard.

The run above was rebuilt through a bundle with a hard guard that exit 3s on SHA mismatch and greps the tree for get_or_create_zset_listpack before compiling, plus a per-row encoding pre-flight that refuses to record a row whose encoding is not what that binary should produce. The two binaries have different sha256 (06005a3a vs the wrong 1baa10a8), confirming the swap.

Member shape: 5 alphabetic members (alpha bravo charlie delta echo), deliberately non-numeric so #795 (leading-zero integer-encoding corruption) cannot make moon and Redis store different bytes. This is a like-for-like comparison.

Note this workload is harder than the campaign's earlier zset row (4,431 vs 4,721 B/key baseline, 24.5x vs 19.4x) because 5-member zsets are exactly the shape Redis keeps in a listpack and moon did not.

Merge order unchanged

#794 (restart preserves compact encodings) must land with or before this, or the win reverts on the first restart.

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