Skip to content

Hash RedisValue with seeded XxHash3 rather than Marvin - #3229

Closed
mgravell wants to merge 1 commit into
marc/redisvalue-equality-allocfrom
marc/redisvalue-hash-sampling
Closed

mgravell wants to merge 1 commit into
marc/redisvalue-equality-allocfrom
marc/redisvalue-hash-sampling

Conversation

@mgravell

Copy link
Copy Markdown
Collaborator

Stacked on #3228take it or leave it independently; the equality work does not depend on this.

Base is marc/redisvalue-equality-alloc, so the diff shows only the hashing change. Retarget to main if
#3228 lands first.

Why

Benchmarking hashing while working on #3228 turned up something larger than the allocation that PR removes.
HashBlob and HashString sit within ~4% of each other at every size, which means the UTF8 decode is not
the cost — the hash function is, at roughly 2 GB/s. At 64KB that is ~31µs to produce a single int.

The change

XxHash3 over the decoded characters, seeded once per process. No new dependency: System.IO.Hashing is
already referenced by the main library and XxHash3 is already used by ValueCondition. It also removes the
#if NET split, since the down-level targets no longer need to fall back to building a string.

Size HashBlob HashString
16 30.4 → 27.3 ns (1.1×) 33.7 → 29.7 ns (1.1×)
1024 503.7 → 85.4 ns (5.9×) 489.6 → 69.1 ns (7.1×)
65536 31,026 → 2,970 ns (10.4×) 29,885 → 1,762 ns (17.0×)

Long strings benefit more than blobs, so this is not a blob-specific fix.

What is given up — the part that needs a decision

The whole content is still hashed. Nothing is sampled, so equal values land in the same bucket by
construction, whatever representation they arrived in, and the hash contract is safe.

What changes is the kind of hash. Marvin is keyed: collisions cannot be constructed without the key.
xxHash's seed is not a key — it gives per-process unpredictability, not resistance, and published
collision-finding techniques for xxHash do not depend on the seed. So this trades hash-flooding resistance for
speed, on the basis that a RedisValue is rarely a dictionary key and practically never an attacker-chosen
one.

That is worth weighing against the June discussion on #3116, where the leaning was the other way — towards
bringing Marvin to RedisKey, which today uses the hand-rolled 728271210-seeded hash and has no Marvin at
all. This PR moves RedisValue toward RedisKey rather than the reverse. Defensible, but a deliberate choice
rather than a drift.

Alternatives considered and rejected:

  • Sample the two ends and keep Marvin — ~1000× at 64KB, but it abandons the unread middle entirely, and
    Redis data routinely shares long prefixes, so common-prefix collisions become free. Strictly worse on the
    security axis than this.
  • A keyed-but-faster hash (SipHash-1-3) — keeps genuine resistance, but only ~2–3×, and means carrying an
    implementation.
  • Hash the UTF8 bytes directly — would remove the remaining decode, but reintroduces the string/blob
    divergence that Compare strings against blobs without decoding them into a string #3228 exists to avoid.

Note for reviewers

((RedisValue)"abc").GetHashCode() previously equalled "abc".GetHashCode(), because the string case
forwarded straight to it. It no longer does. Hash codes have always been per-process and must never be
persisted, so nothing may legitimately depend on the value — but the coincidence is gone, and a test somewhere
could be asserting it.

The decode is now the visible remainder: it was under 4% of the old cost, and at 64KB it is ~40% of the new
one (2,970 ns blob against 1,762 ns string). I would stop here rather than chase it.

Testing

Full suite: 6365 passed, 0 failed — including EqualStringAndBlobShareHashCodes from #3228, which is the
invariant that matters. All six TFMs build with 0 warnings.

Hashing a RedisValue ran at roughly 2 GB/s, so any large value spent almost all
of its hash time inside the hash function itself - the UTF8 decode that sits
alongside it for byte-backed values accounted for under 4% of the total. At 64KB
that is ~31us to produce a single int.

Use XxHash3 over the decoded characters instead, seeded once per process.
System.IO.Hashing is already a dependency and XxHash3 is already used by
ValueCondition, so this costs nothing in packaging, and it lets the down-level
targets take the same path as the rest rather than falling back to building a
string.

  64KB blob    31,026ns -> 2,970ns  (10.4x)
  64KB string  29,885ns -> 1,762ns  (17.0x)
  1KB blob        504ns ->    85ns  (5.9x)
  1KB string      490ns ->    69ns  (7.1x)
  16B             ~30ns ->   ~28ns

The whole content is still hashed - no sampling - so equal values continue to
land in the same bucket by construction, whatever representation they arrived
in. What is given up is that Marvin is a *keyed* hash, designed so collisions
cannot be constructed without the key, while xxHash's seed carries no such
guarantee: it provides per-process unpredictability, not resistance. That is a
deliberate trade on the basis that a RedisValue is rarely a dictionary key and
practically never an attacker-chosen one.

Note that RedisValue.GetHashCode() no longer agrees with String.GetHashCode()
for the same text; it did before, because the string case forwarded straight to
it. Hash codes have always been per-process and must never be persisted, so
nothing may depend on the value, but the coincidence is gone.
@mgravell

Copy link
Copy Markdown
Collaborator Author

Closing — this is a breaking change to shipped behaviour, and it does not belong in a minor.

The specific trap, verified rather than theorised. RedisValue.Equals(object) accepts a string (via
TryParse), and RedisValue.GetHashCode() currently agrees with string.GetHashCode() for the same text
because the string case forwards straight to it. Those two facts together make a real interop path work today:

                                          main          this PR
string.GetHashCode()                  22895958      -291348134
RedisValue.GetHashCode()              22895958      1353807393
same hash                                 True           False
redisValue.Equals(string)                 True            True    <-- unchanged
Hashtable keyed by RedisValue,
  probed with a string            value-via-...          (null)   <-- silently lost

So after this change redisValue.Equals(someString) still reports true while their hash codes differ: a
hash-contract violation across the object boundary, which is precisely what I objected to in #3116, just at
a different seam. Anyone with a Hashtable/Dictionary<object,…> keyed by RedisValue and probed by string
loses lookups silently, with no compile error and no exception.

The measured win was real (10-17x, and more for long strings than blobs), so the finding stands even though
the change does not: hashing a RedisValue runs at roughly 2 GB/s and the UTF8 decode alongside it is under
4% of the cost. It is just not worth a silent break outside a major.

Superseded by the opt-in approach: a RedisValue.EqualityComparer abstract base with Default and Binary,
so callers who want byte-domain speed ask for it and the shared type keeps its behaviour. That can go further
than this PR anyway - Binary never decodes at all, so it reaches ~900ns for a 64KB comparison against the
5,397ns in #3228, and hashes raw bytes.

#3228 is unaffected and still stands on its own: it changes only how operator == computes the same
relation, leaves GetHashCode alone, and the interop above still works on that branch (verified: same hash = True).

@mgravell mgravell closed this Sep 16, 2026
mgravell added a commit that referenced this pull request Sep 17, 2026
Comparing and hashing a RedisValue works on the text a value decodes to, which
is what lets a blob equal the string it spells. For callers slinging large
payloads that reading costs more than they want, and for them the bytes would do
just as well.

Rather than change what the type itself means - see #3229, where doing so turned
out to break a Hashtable keyed by RedisValue and probed by string, silently -
offer the byte reading as something a caller asks for:

    new Dictionary<RedisValue, T>(RedisValue.EqualityComparer.Binary)

An abstract base with Default and Binary, implementing both the typed and the
untyped comparer interfaces; the untyped side accepts anything RedisValue itself
accepts from object - string, byte[], the numerics - via the same forgiving
TryParse that Equals(object) uses. Derivation is closed via a private protected
constructor: nobody needs to derive from this to write their own rules, and
leaving it open would fix the shape forever.

Deliberately equality only. A total order would have to choose between the
numeric, textual and raw-byte readings of a value, and those disagree; Redis
does not define one either, since sorted sets order by score and ZRANGEBYLEX
orders member bytes.

Binary agrees with Default wherever a value's UTF8 form round-trips - all
well-formed text - and differs where it does not, in both directions: a string
holding an unpaired surrogate encodes as U+FFFD does, and a blob that is not
canonical UTF8 decodes to U+FFFD without re-encoding to itself. Both its
equality and its hashing read the same bytes, so it stays self-consistent; that
is what makes the byte reading safe here and not on the type itself, whose
GetHashCode hashes decoded text.

Measured against Default on the same matrix (net10.0, string vs byte[]):

  16B    equal            56.3ns -> 10.8ns
  1KB    equal           131.9ns -> 41.1ns
  1KB    differs first    54.9ns -> 15.2ns
  64KB   equal          5,397ns  -> 2,155ns
  64KB   differs first     52.4ns -> 15.5ns
  hash 64KB blob        31,026ns ->   884ns

Two things the benchmarks caught that the tests could not: comparing a string
against a blob has to encode a chunk at a time onto the blob's own bytes, or a
mismatch in the first byte still costs a full pass over both sides; and the
length precheck has to come after that, because measuring a string's UTF8 length
walks all of it and dominates everything else.
mgravell added a commit that referenced this pull request Sep 17, 2026
Comparing and hashing a RedisValue works on the text a value decodes to, which
is what lets a blob equal the string it spells. For callers slinging large
payloads that reading costs more than they want, and for them the bytes would do
just as well.

Rather than change what the type itself means - see #3229, where doing so turned
out to break a Hashtable keyed by RedisValue and probed by string, silently -
offer the byte reading as something a caller asks for:

    new Dictionary<RedisValue, T>(RedisValue.EqualityComparer.Binary)

An abstract base with Default and Binary, implementing both the typed and the
untyped comparer interfaces; the untyped side accepts anything RedisValue itself
accepts from object - string, byte[], the numerics - via the same forgiving
TryParse that Equals(object) uses. Derivation is closed via a private protected
constructor: nobody needs to derive from this to write their own rules, and
leaving it open would fix the shape forever.

Deliberately equality only. A total order would have to choose between the
numeric, textual and raw-byte readings of a value, and those disagree; Redis
does not define one either, since sorted sets order by score and ZRANGEBYLEX
orders member bytes.

Binary agrees with Default wherever a value's UTF8 form round-trips - all
well-formed text - and differs where it does not, in both directions: a string
holding an unpaired surrogate encodes as U+FFFD does, and a blob that is not
canonical UTF8 decodes to U+FFFD without re-encoding to itself. Both its
equality and its hashing read the same bytes, so it stays self-consistent; that
is what makes the byte reading safe here and not on the type itself, whose
GetHashCode hashes decoded text.

Measured against Default on the same matrix (net10.0, string vs byte[]):

  16B    equal            56.3ns -> 10.8ns
  1KB    equal           131.9ns -> 41.1ns
  1KB    differs first    54.9ns -> 15.2ns
  64KB   equal          5,397ns  -> 2,155ns
  64KB   differs first     52.4ns -> 15.5ns
  hash 64KB blob        31,026ns ->   884ns

Two things the benchmarks caught that the tests could not: comparing a string
against a blob has to encode a chunk at a time onto the blob's own bytes, or a
mismatch in the first byte still costs a full pass over both sides; and the
length precheck has to come after that, because measuring a string's UTF8 length
walks all of it and dominates everything else.
mgravell added a commit that referenced this pull request Sep 17, 2026
* Add RedisValue.EqualityComparer, with an opt-in binary reading

Comparing and hashing a RedisValue works on the text a value decodes to, which
is what lets a blob equal the string it spells. For callers slinging large
payloads that reading costs more than they want, and for them the bytes would do
just as well.

Rather than change what the type itself means - see #3229, where doing so turned
out to break a Hashtable keyed by RedisValue and probed by string, silently -
offer the byte reading as something a caller asks for:

    new Dictionary<RedisValue, T>(RedisValue.EqualityComparer.Binary)

An abstract base with Default and Binary, implementing both the typed and the
untyped comparer interfaces; the untyped side accepts anything RedisValue itself
accepts from object - string, byte[], the numerics - via the same forgiving
TryParse that Equals(object) uses. Derivation is closed via a private protected
constructor: nobody needs to derive from this to write their own rules, and
leaving it open would fix the shape forever.

Deliberately equality only. A total order would have to choose between the
numeric, textual and raw-byte readings of a value, and those disagree; Redis
does not define one either, since sorted sets order by score and ZRANGEBYLEX
orders member bytes.

Binary agrees with Default wherever a value's UTF8 form round-trips - all
well-formed text - and differs where it does not, in both directions: a string
holding an unpaired surrogate encodes as U+FFFD does, and a blob that is not
canonical UTF8 decodes to U+FFFD without re-encoding to itself. Both its
equality and its hashing read the same bytes, so it stays self-consistent; that
is what makes the byte reading safe here and not on the type itself, whose
GetHashCode hashes decoded text.

Measured against Default on the same matrix (net10.0, string vs byte[]):

  16B    equal            56.3ns -> 10.8ns
  1KB    equal           131.9ns -> 41.1ns
  1KB    differs first    54.9ns -> 15.2ns
  64KB   equal          5,397ns  -> 2,155ns
  64KB   differs first     52.4ns -> 15.5ns
  hash 64KB blob        31,026ns ->   884ns

Two things the benchmarks caught that the tests could not: comparing a string
against a blob has to encode a chunk at a time onto the blob's own bytes, or a
mismatch in the first byte still costs a full pass over both sides; and the
length precheck has to come after that, because measuring a string's UTF8 length
walks all of it and dominates everything else.

* Review: fix the chunk-boundary surrogate guard, and pin numeric divergence

The guard meant to stop a surrogate pair being split across chunks extended the
chunk by a character. That was wrong twice over:

- it could overrun the encode buffer, which is sized for ChunkChars * 3 bytes;
  511 three-byte characters followed by a straddling pair need 1537 of 1536, and
  Encoding.UTF8.GetBytes throws ArgumentException rather than returning a wrong
  answer;
- where the character at the seam was itself a lone high surrogate, the extra
  character was the *following* pair's high half, so the split simply moved onto
  a real pair - each half encoding to U+FFFD, and an equal value comparing
  unequal.

Step back instead. A chunk then never ends on a high surrogate and never exceeds
ChunkChars characters, so it cannot overrun; and it is only reachable when take
== ChunkChars, so it cannot reach zero. Fuzzing a 500-530 character corpus over
the awkward alphabet gave 247 wrong answers in 20,000 before and 0 after, with
no exceptions.

The tests could not have caught this: the longest value in the corpus was 300
characters, so the chunking loop never ran past its first pass. Added cases that
put a straddling pair, a lone high surrogate before a real pair, and a pair
approached from the other side at exactly the 512-character seam, plus a plain
2000-character value for the multi-chunk path.

Also documented and pinned a divergence that was missing: operator == runs
Simplify() first, so "1.0", "1.00" and "1" are all equal under the default rules,
as are "0" and "-0.0", while Binary compares the bytes and says otherwise. That
is the right answer for a byte reading - it is how the server identifies members
- but it belonged in the remarks, which claimed agreement for "all well-formed
text".

Two smaller things from the same review: seed the hash from RandomNumberGenerator
rather than Guid.NewGuid, whose version and variant bits are fixed; and take the
cheap exit when two strings are ordinally equal, since identical text encodes
identically. The unequal case still goes the byte route, because two different
strings can share a UTF-8 form.

* Drop the superseded surrogate-guard comment left above its replacement

* Pin that Binary does not depend on how a value is stored

The default rules can simplify a string and the bytes of that same string
differently (#3233), so the same text is not always equal to itself. Binary
reads the UTF8 form of both sides and so is not exposed to that; asserting it
keeps the property rather than leaving it as a happy accident.
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