Skip to content

Commit eab8a35

Browse files
authored
Optimize RedisValue / reader logic by using canonical parser and new ShortBlob storage kind (#3114)
* spike better RedisValue parsing logic * Introduce new storage kind for small payloads (<=8 bytes) - avoids array alloc * fix CI * CI: tweak failing test * tidying (support ulong as canonical)
1 parent 596d3a5 commit eab8a35

7 files changed

Lines changed: 661 additions & 92 deletions

File tree

src/StackExchange.Redis/MessageWriter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ internal static void WriteBulkString(in RedisValue value, IBufferWriter<byte> wr
9999
case RedisValue.StorageType.String:
100100
WriteUnifiedPrefixedString(writer, null, value.RawString());
101101
break;
102-
case RedisValue.StorageType.MemoryManager or RedisValue.StorageType.ByteArray:
103-
WriteUnifiedSpan(writer, value.RawSpan());
102+
case RedisValue.StorageType.MemoryManager or RedisValue.StorageType.ByteArray or RedisValue.StorageType.ShortBlob:
103+
WriteUnifiedSpan(writer, value.UnsafeRawSpan(out _));
104104
break;
105105
case RedisValue.StorageType.Sequence:
106106
WriteUnifiedSequenceIterator(writer, value.RawSequenceIterator());

src/StackExchange.Redis/ReadOnlySequenceExtensions.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,33 @@ public static int SequenceCompareTo(this in ReadOnlySequence<byte> first, in Rea
184184
// everything in the overlap matched, so the longer sequence sorts after the shorter
185185
return first.Length.CompareTo(other.Length);
186186
}
187+
188+
/// <summary>
189+
/// Lexicographically compares a sequence against a contiguous span (same semantics as the
190+
/// sequence-vs-sequence overload).
191+
/// </summary>
192+
public static int SequenceCompareTo(this in ReadOnlySequence<byte> first, ReadOnlySpan<byte> other)
193+
{
194+
if (first.IsSingleSegment) return first.FirstSpan.SequenceCompareTo(other);
195+
196+
long firstLength = first.Length;
197+
int otherLength = other.Length;
198+
var firstPos = first.Start;
199+
ReadOnlySpan<byte> a = default;
200+
while (true)
201+
{
202+
while (a.IsEmpty && first.TryGet(ref firstPos, out var aNext)) a = aNext.Span;
203+
if (a.IsEmpty || other.IsEmpty) break;
204+
205+
var shared = Math.Min(a.Length, other.Length);
206+
var cmp = a.Slice(0, shared).SequenceCompareTo(other.Slice(0, shared));
207+
if (cmp != 0) return cmp;
208+
209+
a = a.Slice(shared);
210+
other = other.Slice(shared);
211+
}
212+
213+
// overlap matched, so the longer input sorts after the shorter
214+
return firstLength.CompareTo((long)otherLength);
215+
}
187216
}

src/StackExchange.Redis/RedisValue.cs

Lines changed: 179 additions & 75 deletions
Large diffs are not rendered by default.

src/StackExchange.Redis/RespReaderExtensions.cs

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Buffers;
33
using System.Diagnostics;
4+
using System.Runtime.CompilerServices;
45
using System.Threading.Tasks;
56
using RESPite.Messages;
67

@@ -15,12 +16,43 @@ public RedisValue ReadRedisValue()
1516
reader.DemandScalar();
1617
if (reader.IsNull) return RedisValue.Null;
1718

18-
return reader.Prefix switch
19+
switch (reader.Prefix)
1920
{
20-
RespPrefix.Boolean => reader.ReadBoolean(),
21-
RespPrefix.Integer => reader.ReadInt64(),
22-
_ => reader.ReadByteArray(),
23-
};
21+
case RespPrefix.Boolean:
22+
return reader.ReadBoolean();
23+
case RespPrefix.Integer:
24+
return reader.ReadInt64();
25+
}
26+
27+
// bulk/simple/verbatim string. Only inline (non-streaming) scalars get the compact storage
28+
// kinds; streaming scalars fall through to ReadByteArray.
29+
if (reader.IsInlineScalar)
30+
{
31+
var length = reader.ScalarLength();
32+
33+
// Short payloads (<= 8 bytes) pack inline as a short-blob: allocation-free, and with *no*
34+
// eager numeric parse - any later (long)/(double)/etc. is deferred to the caller (Simplify
35+
// on demand), which is cheaper for the common case of values never interpreted as numbers.
36+
// Contiguous data (the common case) is taken straight from TryGetSpan - no stackalloc. Only a
37+
// scalar that straddles segments needs linearizing into the 8-byte stack buffer; the length
38+
// guard is what makes that fixed buffer safe, since Buffer() silently truncates an over-long
39+
// discontiguous payload.
40+
if (length <= RedisValue.MaxInlineBytes)
41+
{
42+
return RedisValue.FromRaw(reader.TryGetSpan(out var buffer) ?
43+
buffer : reader.Buffer(stackalloc byte[RedisValue.MaxInlineBytes]));
44+
}
45+
46+
// Longer payloads: prefer a compact numeric storage kind when the text is the *canonical*
47+
// representation of that number, so every projection (ToString, (byte[]), equality, hash)
48+
// still round-trips byte-for-byte; this also avoids the byte[] alloc. Canonical parsing needs
49+
// a contiguous span, so a discontiguous payload falls through to ReadByteArray.
50+
if (reader.TryGetSpan(out var span) && TryReadCanonicalNumber(span, out var number))
51+
{
52+
return number;
53+
}
54+
}
55+
return reader.ReadByteArray();
2456
}
2557

2658
public string DebugReadTruncatedString(int maxChars)
@@ -184,7 +216,7 @@ public static RespPrefix GetRespPrefix(ReadOnlySpan<byte> frame)
184216
RespPrefix.SimpleError => ResultType.Error,
185217
RespPrefix.Null => ResultType.Null,
186218
RespPrefix.VerbatimString => ResultType.VerbatimString,
187-
RespPrefix.Push=> ResultType.Push,
219+
RespPrefix.Push => ResultType.Push,
188220
_ => throw new ArgumentOutOfRangeException(nameof(prefix), prefix, null),
189221
};
190222
}
@@ -208,4 +240,92 @@ internal bool AnyNull()
208240
public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion;
209241
}
210242
#endif
243+
244+
private static readonly int MaxCanonicalLength = Math.Max(Format.MaxInt64TextLen, Format.MaxDoubleTextLen);
245+
246+
// Recognizes the canonical decimal text of an integer (signed Int64 or, for non-negative values up to
247+
// ulong.MaxValue, UInt64) or a finite double, returning a numeric-backed RedisValue only when re-formatting
248+
// the parsed value reproduces the exact input bytes. This keeps the optimization invisible to callers:
249+
// non-canonical spellings ("01234", "+5", "1.50", "1e3"), values beyond the ulong/double range, and the
250+
// special inf/nan tokens all return false and are kept as a byte[] payload by the caller.
251+
private static bool TryReadCanonicalNumber(ReadOnlySpan<byte> span, out RedisValue value)
252+
{
253+
static bool Failure(out RedisValue value)
254+
{
255+
value = default;
256+
return false;
257+
}
258+
// integer: canonical exactly when the round-trip text length matches (rules out leading zeros, a
259+
// leading '+', "-0", trailing junk, etc.) - so no need to re-emit and compare bytes
260+
if (span.IsEmpty | span.Length > MaxCanonicalLength) return Failure(out value);
261+
262+
// restrict to *just* basic number tokens, tracking the two facts that let us pick a single parse:
263+
// whether a '-' appeared (so we know whether the integer is signed), and whether a '.'/'e'/'E'
264+
// appeared (which rules out an integer entirely - only a double can be canonical)
265+
bool seenNegative = false, seenDotOrExp = false;
266+
foreach (var b in span)
267+
{
268+
switch (b)
269+
{
270+
case (byte)'0':
271+
case (byte)'1':
272+
case (byte)'2':
273+
case (byte)'3':
274+
case (byte)'4':
275+
case (byte)'5':
276+
case (byte)'6':
277+
case (byte)'7':
278+
case (byte)'8':
279+
case (byte)'9':
280+
break;
281+
case (byte)'-':
282+
seenNegative = true;
283+
break;
284+
case (byte)'.':
285+
case (byte)'E':
286+
case (byte)'e':
287+
seenDotOrExp = true;
288+
break;
289+
default:
290+
return Failure(out value);
291+
}
292+
}
293+
294+
if (!seenDotOrExp)
295+
{
296+
// pure integer text. For a non-negative value parse as *unsigned* so the full ulong range is
297+
// covered; the RedisValue(ulong) ctor demotes to Int64 storage when the value fits, so smaller
298+
// values still land as Int64 exactly as before. A negative value can only be Int64.
299+
if (seenNegative)
300+
{
301+
if (Format.TryParseInt64(span, out var i64) && Format.MeasureInt64(i64) == span.Length)
302+
{
303+
value = i64;
304+
return true;
305+
}
306+
}
307+
else if (Format.TryParseUInt64(span, out var u64) && Format.MeasureUInt64(u64) == span.Length)
308+
{
309+
value = u64;
310+
return true;
311+
}
312+
313+
// note that all-digit text which isn't a canonical integer (oversize, leading zero, etc.) can't
314+
// be a canonical double either - any such value re-formats with an exponent - so we simply fall
315+
// through to the failure at the bottom rather than attempting a (futile) double parse
316+
}
317+
else if (Format.TryParseDouble(span, out var dbl))
318+
{
319+
if (dbl == 0.0) dbl = Math.Abs(dbl); // prevent problems with -0 formatting
320+
Span<byte> formatted = stackalloc byte[Format.MaxDoubleTextLen];
321+
var len = Format.FormatDouble(dbl, formatted);
322+
if (formatted.Slice(0, len).SequenceEqual(span))
323+
{
324+
value = dbl;
325+
return true;
326+
}
327+
}
328+
329+
return Failure(out value);
330+
}
211331
}

tests/StackExchange.Redis.Tests/Issues/Issue1103Tests.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ namespace StackExchange.Redis.Tests.Issues;
88
public class Issue1103Tests(ITestOutputHelper output) : TestBase(output)
99
{
1010
[Theory]
11-
[InlineData(142205255210238005UL, (int)StorageType.Int64)]
12-
[InlineData(ulong.MaxValue, (int)StorageType.UInt64)]
13-
[InlineData(ulong.MinValue, (int)StorageType.Int64)]
14-
[InlineData(0x8000000000000000UL, (int)StorageType.UInt64)]
15-
[InlineData(0x8000000000000001UL, (int)StorageType.UInt64)]
16-
[InlineData(0x7FFFFFFFFFFFFFFFUL, (int)StorageType.Int64)]
17-
public async Task LargeUInt64StoredCorrectly(ulong value, int storageType)
11+
[InlineData(142205255210238005UL, (int)StorageType.Int64, (int)StorageType.Int64)]
12+
[InlineData(ulong.MaxValue, (int)StorageType.UInt64, (int)StorageType.UInt64)] // 20-byte canonical uint => UInt64 on read
13+
[InlineData(ulong.MinValue, (int)StorageType.Int64, (int)StorageType.ShortBlob)]
14+
[InlineData(0x8000000000000000UL, (int)StorageType.UInt64, (int)StorageType.UInt64)] // long.MaxValue+1: 19-byte canonical uint => UInt64 on read
15+
[InlineData(0x8000000000000001UL, (int)StorageType.UInt64, (int)StorageType.UInt64)]
16+
[InlineData(0x7FFFFFFFFFFFFFFFUL, (int)StorageType.Int64, (int)StorageType.Int64)] // long.MaxValue: 19-byte canonical int => Int64 on read
17+
public async Task LargeUInt64StoredCorrectly(ulong value, int storageType, int fromRedisType)
1818
{
1919
await using var conn = Create();
2020

@@ -29,13 +29,13 @@ public async Task LargeUInt64StoredCorrectly(ulong value, int storageType)
2929
var fromRedis = db.StringGet(key);
3030

3131
Log($"{fromRedis.Type}: {fromRedis}");
32-
Assert.Equal(StorageType.ByteArray, fromRedis.Type);
32+
Assert.Equal((StorageType)fromRedisType, fromRedis.Type);
3333
Assert.Equal(value, (ulong)fromRedis);
3434
Assert.Equal(value.ToString(CultureInfo.InvariantCulture), fromRedis.ToString());
3535

3636
var simplified = fromRedis.Simplify();
3737
Log($"{simplified.Type}: {simplified}");
38-
Assert.Equal((StorageType)storageType, typed.Type);
38+
Assert.Equal((StorageType)storageType, simplified.Type);
3939
Assert.Equal(value, (ulong)simplified);
4040
Assert.Equal(value.ToString(CultureInfo.InvariantCulture), fromRedis.ToString());
4141
}

0 commit comments

Comments
 (0)