Skip to content

Compare strings against blobs without decoding them into a string - #3228

Open
mgravell wants to merge 1 commit into
mainfrom
marc/redisvalue-equality-alloc
Open

mgravell wants to merge 1 commit into
mainfrom
marc/redisvalue-equality-alloc

Conversation

@mgravell

Copy link
Copy Markdown
Collaborator

Picks up the intent of #3116 — mixed string/blob equality allocates — but implements it so the relation, and
therefore the hash contract, is untouched. Supersedes that PR's Equals hunk; its other two parts are already
moot (the author reverted the GetHashCode change, and the StartsWith non-ASCII bug was fixed in 0531cd54).

The problem, measured

operator == falls through to (string?)x == (string?)y when one side is a string and the other byte-backed,
which materialises the blob as a transient string. RedisValueEqualityBenchmarks (new here, net10.0):

Size Form Mean Allocated BlobVsBlob control
16 Equal 58.9 ns 56 B 34.6 ns / 0 B
1024 Equal 133.9 ns 2,072 B 45.3 ns / 0 B
65536 Equal 43,121 ns 131,110 B 899.8 ns / 0 B
65536 DiffAtStart 57,286 ns 131,110 B 38.7 ns / 0 B

Two things stand out. The allocation is proportional, so at 64KB it is a large-object-heap allocation on
every comparison — the Gen0/Gen1/Gen2 columns all read 41.6260. And a value differing in its first byte
costs the same as one that matches, because the whole payload is decoded before anything is compared.

The change

Decode a chunk at a time into a 128-char stack buffer and compare as we go, stopping at the chunk containing a
mismatch. An O(1) bounds check runs first: UTF-8 yields at most one char per byte and never more than three
bytes per char, so a mismatched length is rejected without touching content. Sequence-backed values carry up to
four bytes across a segment boundary, because a sequence split mid-character must be resumed rather than
treated as invalid data.

Size Form Before After
65536 Equal 43,121 ns / 131,110 B 5,397 ns / 0 B (8×)
65536 DiffAtStart 57,286 ns / 131,110 B 52 ns / 0 B (1092×)
65536 DiffAtEnd 40,776 ns / 131,110 B 5,793 ns / 0 B (7×)
1024 DiffAtStart 119 ns / 2,072 B 55 ns / 0 B (2.2×)
16 Equal 59 ns / 56 B 56 ns / 0 B

Allocation is gone at every size. Small values are otherwise a wash, and two cases are marginally slower —
1KB differing at the last byte goes 138 ns → 153 ns — where per-chunk overhead lands on a path that has to
decode everything regardless.

Decoding still costs ~6× a raw memcmp (5,397 ns against the 899 ns BlobVsBlob floor). That gap is the price
of not changing the relation, which is the next section.

Why not compare in the byte domain

Encoding the string and matching raw bytes would reach the memcmp floor, and it is what #3116 does. It is not
the same relation. UTF-8 does not round-trip strings holding unpaired surrogates:

input=\uD800   utf8=EF-BF-BD   round-trips=False (decoded=�)
  current Equals = False
  byte-domain    = True
  hash(string)=241405939  hash(blob)=-38304690   <- equal values, different hash codes

GetHashCode hashes the decoded form, and says so in a comment. Making Equals byte-based without changing
it in the same breath puts equal values in different buckets, which silently breaks Dictionary and HashSet.
Keeping the decode-domain relation means that question never arises.

Down-level targets keep the previous implementation — System.Text.Unicode.Utf8 does not exist there.

Testing

The new tests in RedisValueEquivalencyUnitTests compare against the old behaviour directly, asserting
(asString == asBlob) == (s == (string?)asBlob) over text that does not survive a UTF-8 round trip: truncated
2/3/4-byte sequences, overlong encodings, surrogates encoded as UTF-8, invalid bytes mid-payload, emoji, and
strings holding lone surrogates. For sequence-backed values this runs at every possible split point, which
is what covers the carry logic. A third test asserts the hash contract directly.

Cases are built in code rather than as InlineData because a lone surrogate survives neither a UTF-8 source
file nor xunit's argument serialization — and those are the interesting inputs.

Sabotaging the bounds guard fails the segmented test, so it is not passing vacuously. Full suite: 6365 passed,
0 failed. All six TFMs build with 0 warnings.

Follow-up, deliberately not here

Benchmarking hashing while I was in here turned up something bigger: HashBlob and HashString are within 4%
of each other at every size (~31µs vs ~30µs at 64KB), so the decode is not the cost — Marvin is, at roughly
2 GB/s. Hashing a bounded prefix (and suffix, to keep common-prefix collisions expensive) would be worth
~1000× at that size, and would benefit long strings just as much as blobs. That is a separate change with a
hash-flooding tradeoff to weigh, so it is not in this PR.

Equality between a string and a byte-backed RedisValue fell through to
(string?)x == (string?)y, which materialises the blob as a transient string
before comparing. That allocation is proportional to the payload: at 64KB it is
a 131KB string, over the large-object-heap threshold, on every comparison. And
because the whole payload is decoded before anything is compared, a value that
differs in its first byte costs exactly as much as one that matches.

Answer the same question without building the string: decode a chunk at a time
into a small stack buffer and compare as we go, stopping at the chunk that
contains a mismatch. A bounds check first - UTF-8 yields at most one char per
byte, and never more than three bytes per char - rejects mismatched lengths
before touching any content. Sequence-backed values carry up to four bytes
across a segment boundary, since a sequence split mid-character has to be
resumed rather than treated as invalid data.

The relation is deliberately unchanged: still "the blob, read as UTF-8 text,
equals the string". Comparing the other way - encoding the string and matching
raw bytes - looks equivalent but is not, because UTF-8 does not round-trip
strings holding unpaired surrogates: "\uD800" encodes to the same bytes as
"�", so the two would compare equal while GetHashCode, which hashes the
decoded form, keeps them in different buckets. Equal values with different hash
codes silently break Dictionary and HashSet.

Measured against the local six-node figures in
RedisValueEqualityBenchmarks (net10.0, string vs byte[]):

  64KB, equal          43,121ns / 131,110B  ->  5,397ns / 0B   (8x)
  64KB, differs first  57,286ns / 131,110B  ->     52ns / 0B   (1092x)
  1KB,  differs first     119ns /   2,072B  ->     55ns / 0B   (2.2x)
  16B,  equal              59ns /      56B  ->     56ns / 0B

Allocation is gone at every size. Small values are otherwise a wash, and two
cases are marginally slower - 1KB differing at the last byte goes 138ns ->
153ns - where the per-chunk overhead lands on a path that has to decode
everything anyway.

Down-level targets keep the previous implementation: System.Text.Unicode.Utf8
does not exist there.

The tests compare against the old behaviour directly, over text that does not
survive a UTF-8 round trip (truncated, overlong and invalid sequences, surrogate
pairs, lone surrogates) and, for sequences, at every possible split point.
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