From fb9110c752edddaf8077fd4531923c97eaaa470f Mon Sep 17 00:00:00 2001 From: mgravell Date: Wed, 16 Sep 2026 20:17:57 +0100 Subject: [PATCH] Hash RedisValue with seeded XxHash3 rather than Marvin 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. --- src/StackExchange.Redis/RedisValue.cs | 48 ++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 0b3820f61..ed7236263 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.IO.Hashing; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -655,7 +656,7 @@ private static int GetHashCode(RedisValue x) case StorageType.Int64 or StorageType.UInt64: return x._valueInt64.GetHashCode(); case StorageType.String: - return x.RawString().GetHashCode(); + return HashChars(x.RawString().AsSpan()); } // Everything else - byte/memory/sequence buffers - compares to each other (and to strings) "as @@ -663,21 +664,50 @@ private static int GetHashCode(RedisValue x) // numeric was already reduced to Int64/Double by Simplify() above, so the equality-consistent // hash for what remains is the hash of the string form. (We must NOT hash raw bytes: that would // give byte buffers a different hash from the equal string.) -#if NET - // hash the decoded UTF8 chars directly, which avoids allocating a transient string; this matches - // string.GetHashCode() for the equivalent text + // hash the decoded UTF8 chars directly, which avoids allocating a transient string const int StackLimit = 256; var maxChars = x.GetMaxCharCount(); char[]? leased = null; Span chars = maxChars <= StackLimit ? stackalloc char[StackLimit] : (leased = ArrayPool.Shared.Rent(maxChars)); var written = x.CopyTo(chars); - var hashCode = string.GetHashCode(chars.Slice(0, written)); + var hashCode = HashChars(chars.Slice(0, written)); if (leased is not null) ArrayPool.Shared.Return(leased); return hashCode; -#else - // no string.GetHashCode(ReadOnlySpan) on these targets, so fall back to the string form - return ((string)x!).GetHashCode(); -#endif + } + + /// + /// Per-process entropy, so that hash codes are not predictable between runs. + /// + /// + /// This is what gets from Marvin, and the reason hash codes must + /// never be persisted or sent between processes - which was already true. + /// + private static readonly long HashSeed = BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 0); + + /// + /// Hashes text, over its whole content. + /// + /// + /// + /// Equality here is defined by the decoded characters (see operator ==), so the hash has to be + /// taken over those same characters: every representation of a value - string, byte[], sequence - + /// then lands in the same bucket. + /// + /// + /// rather than the framework's Marvin, because hashing was measured at roughly + /// 2 GB/s and dominated the cost of hashing any large value - the UTF8 decode alongside it accounted + /// for under 4%. The seed supplies per-process entropy, but note what it does not do: Marvin is a + /// *keyed* hash, built so that collisions cannot be constructed without the key, whereas xxHash's + /// seed offers no such guarantee. That is a deliberate trade of hash-flooding resistance for speed, + /// on the basis that a is rarely a dictionary key and practically never an + /// attacker-chosen one. + /// + /// + private static int HashChars(scoped ReadOnlySpan chars) + { + if (chars.IsEmpty) return 0; + var hash = XxHash3.HashToUInt64(MemoryMarshal.AsBytes(chars), HashSeed); + return unchecked((int)hash ^ (int)(hash >> 32)); } ///