Skip to content

Commit 0f4f469

Browse files
authored
Improve double formatting performance on net8+ and fix equality incorrectness re special doubles (#2928)
* Improve `double` formatting performance on net8+ * release notes * - set max len - use in physicalconnection * radical idea: let's actually copy the data * simplify inf/nan parsing - massively reduce ToLower overhead * NaN rules * avoid problem with literal "nan" / "inf" having inconsistent equality behaviour * update/clarify nan/inf rules in test
1 parent b995ebc commit 0f4f469

File tree

6 files changed

+253
-64
lines changed

6 files changed

+253
-64
lines changed

docs/ReleaseNotes.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Current package versions:
1212
- Fix key-prefix omission in `SetIntersectionLength` and `SortedSet{Combine[WithScores]|IntersectionLength}` ([#2863 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2863))
1313
- Add `Condition.SortedSet[Not]ContainsStarting` condition for transactions ([#2638 by ArnoKoll](https://github.com/StackExchange/StackExchange.Redis/pull/2638))
1414
- Add support for XPENDING Idle time filter ([#2822 by david-brink-talogy](https://github.com/StackExchange/StackExchange.Redis/pull/2822))
15-
-
15+
- Improve `double` formatting performance on net8+ ([#2928 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2928))
16+
1617
## 2.8.58
1718

1819
- Fix [#2679](https://github.com/StackExchange/StackExchange.Redis/issues/2679) - blocking call in long-running connects ([#2680 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2680))

src/StackExchange.Redis/Format.cs

Lines changed: 84 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Diagnostics.CodeAnalysis;
55
using System.Globalization;
66
using System.Net;
7+
using System.Runtime.CompilerServices;
78
using System.Text;
89

910
#if UNIX_SOCKET
@@ -168,24 +169,41 @@ internal static bool TryParseDouble(string? s, out double value)
168169
value = s[0] - '0';
169170
return true;
170171
// RESP3 spec demands inf/nan handling
171-
case 3 when CaseInsensitiveASCIIEqual("inf", s):
172-
value = double.PositiveInfinity;
173-
return true;
174-
case 3 when CaseInsensitiveASCIIEqual("nan", s):
175-
value = double.NaN;
176-
return true;
177-
case 4 when CaseInsensitiveASCIIEqual("+inf", s):
178-
value = double.PositiveInfinity;
179-
return true;
180-
case 4 when CaseInsensitiveASCIIEqual("-inf", s):
181-
value = double.NegativeInfinity;
182-
return true;
183-
case 4 when CaseInsensitiveASCIIEqual("+nan", s):
184-
case 4 when CaseInsensitiveASCIIEqual("-nan", s):
185-
value = double.NaN;
172+
case 3 when TryParseInfNaN(s.AsSpan(), true, out value):
173+
case 4 when s[0] == '+' && TryParseInfNaN(s.AsSpan(1), true, out value):
174+
case 4 when s[0] == '-' && TryParseInfNaN(s.AsSpan(1), false, out value):
186175
return true;
187176
}
188177
return double.TryParse(s, NumberStyles.Any, NumberFormatInfo.InvariantInfo, out value);
178+
179+
static bool TryParseInfNaN(ReadOnlySpan<char> s, bool positive, out double value)
180+
{
181+
switch (s[0])
182+
{
183+
case 'i':
184+
case 'I':
185+
if (s[1] is 'n' or 'N' && s[2] is 'f' or 'F')
186+
{
187+
value = positive ? double.PositiveInfinity : double.NegativeInfinity;
188+
return true;
189+
}
190+
break;
191+
case 'n':
192+
case 'N':
193+
if (s[1] is 'a' or 'A' && s[2] is 'n' or 'N')
194+
{
195+
value = double.NaN;
196+
return true;
197+
}
198+
break;
199+
}
200+
#if NET6_0_OR_GREATER
201+
Unsafe.SkipInit(out value);
202+
#else
203+
value = 0;
204+
#endif
205+
return false;
206+
}
189207
}
190208

191209
internal static bool TryParseUInt64(string s, out ulong value) =>
@@ -235,37 +253,41 @@ internal static bool TryParseDouble(ReadOnlySpan<byte> s, out double value)
235253
value = s[0] - '0';
236254
return true;
237255
// RESP3 spec demands inf/nan handling
238-
case 3 when CaseInsensitiveASCIIEqual("inf", s):
239-
value = double.PositiveInfinity;
240-
return true;
241-
case 3 when CaseInsensitiveASCIIEqual("nan", s):
242-
value = double.NaN;
243-
return true;
244-
case 4 when CaseInsensitiveASCIIEqual("+inf", s):
245-
value = double.PositiveInfinity;
246-
return true;
247-
case 4 when CaseInsensitiveASCIIEqual("-inf", s):
248-
value = double.NegativeInfinity;
249-
return true;
250-
case 4 when CaseInsensitiveASCIIEqual("+nan", s):
251-
case 4 when CaseInsensitiveASCIIEqual("-nan", s):
252-
value = double.NaN;
256+
case 3 when TryParseInfNaN(s, true, out value):
257+
case 4 when s[0] == '+' && TryParseInfNaN(s.Slice(1), true, out value):
258+
case 4 when s[0] == '-' && TryParseInfNaN(s.Slice(1), false, out value):
253259
return true;
254260
}
255261
return Utf8Parser.TryParse(s, out value, out int bytes) & bytes == s.Length;
256-
}
257-
258-
private static bool CaseInsensitiveASCIIEqual(string xLowerCase, string y)
259-
=> string.Equals(xLowerCase, y, StringComparison.OrdinalIgnoreCase);
260262

261-
private static bool CaseInsensitiveASCIIEqual(string xLowerCase, ReadOnlySpan<byte> y)
262-
{
263-
if (y.Length != xLowerCase.Length) return false;
264-
for (int i = 0; i < y.Length; i++)
263+
static bool TryParseInfNaN(ReadOnlySpan<byte> s, bool positive, out double value)
265264
{
266-
if (char.ToLower((char)y[i]) != xLowerCase[i]) return false;
265+
switch (s[0])
266+
{
267+
case (byte)'i':
268+
case (byte)'I':
269+
if (s[1] is (byte)'n' or (byte)'N' && s[2] is (byte)'f' or (byte)'F')
270+
{
271+
value = positive ? double.PositiveInfinity : double.NegativeInfinity;
272+
return true;
273+
}
274+
break;
275+
case (byte)'n':
276+
case (byte)'N':
277+
if (s[1] is (byte)'a' or (byte)'A' && s[2] is (byte)'n' or (byte)'N')
278+
{
279+
value = double.NaN;
280+
return true;
281+
}
282+
break;
283+
}
284+
#if NET6_0_OR_GREATER
285+
Unsafe.SkipInit(out value);
286+
#else
287+
value = 0;
288+
#endif
289+
return false;
267290
}
268-
return true;
269291
}
270292

271293
/// <summary>
@@ -399,11 +421,21 @@ internal static unsafe string GetString(ReadOnlySpan<byte> span)
399421

400422
internal const int
401423
MaxInt32TextLen = 11, // -2,147,483,648 (not including the commas)
402-
MaxInt64TextLen = 20; // -9,223,372,036,854,775,808 (not including the commas)
424+
MaxInt64TextLen = 20, // -9,223,372,036,854,775,808 (not including the commas),
425+
MaxDoubleTextLen = 40; // we use G17, allow for sign/E/and allow plenty of panic room
403426

404427
internal static int MeasureDouble(double value)
405428
{
406429
if (double.IsInfinity(value)) return 4; // +inf / -inf
430+
431+
#if NET8_0_OR_GREATER // can use IUtf8Formattable
432+
Span<byte> buffer = stackalloc byte[MaxDoubleTextLen];
433+
if (value.TryFormat(buffer, out int len, "G17", NumberFormatInfo.InvariantInfo))
434+
{
435+
return len;
436+
}
437+
#endif
438+
// fallback (TFM or unexpected size)
407439
var s = value.ToString("G17", NumberFormatInfo.InvariantInfo); // this looks inefficient, but is how Utf8Formatter works too, just: more direct
408440
return s.Length;
409441
}
@@ -412,16 +444,18 @@ internal static int FormatDouble(double value, Span<byte> destination)
412444
{
413445
if (double.IsInfinity(value))
414446
{
415-
if (double.IsPositiveInfinity(value))
416-
{
417-
if (!"+inf"u8.TryCopyTo(destination)) ThrowFormatFailed();
418-
}
419-
else
420-
{
421-
if (!"-inf"u8.TryCopyTo(destination)) ThrowFormatFailed();
422-
}
447+
if (!(double.IsPositiveInfinity(value) ? "+inf"u8 : "-inf"u8).TryCopyTo(destination)) ThrowFormatFailed();
423448
return 4;
424449
}
450+
451+
#if NET8_0_OR_GREATER // can use IUtf8Formattable
452+
if (!value.TryFormat(destination, out int len, "G17", NumberFormatInfo.InvariantInfo))
453+
{
454+
ThrowFormatFailed();
455+
}
456+
457+
return len;
458+
#else
425459
var s = value.ToString("G17", NumberFormatInfo.InvariantInfo); // this looks inefficient, but is how Utf8Formatter works too, just: more direct
426460
if (s.Length > destination.Length) ThrowFormatFailed();
427461

@@ -431,6 +465,7 @@ internal static int FormatDouble(double value, Span<byte> destination)
431465
destination[i] = (byte)chars[i];
432466
}
433467
return chars.Length;
468+
#endif
434469
}
435470

436471
internal static int MeasureInt64(long value)

src/StackExchange.Redis/PhysicalConnection.cs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,9 @@ internal static void WriteBulkString(in RedisValue value, PipeWriter? maybeNullW
845845
case RedisValue.StorageType.UInt64:
846846
WriteUnifiedUInt64(writer, value.OverlappedValueUInt64);
847847
break;
848-
case RedisValue.StorageType.Double: // use string
848+
case RedisValue.StorageType.Double:
849+
WriteUnifiedDouble(writer, value.OverlappedValueDouble);
850+
break;
849851
case RedisValue.StorageType.String:
850852
WriteUnifiedPrefixedString(writer, null, (string?)value);
851853
break;
@@ -1341,9 +1343,9 @@ private static void WriteUnifiedInt64(PipeWriter writer, long value)
13411343
// note from specification: A client sends to the Redis server a RESP Array consisting of just Bulk Strings.
13421344
// (i.e. we can't just send ":123\r\n", we need to send "$3\r\n123\r\n"
13431345

1344-
// ${asc-len}\r\n = 3 + MaxInt32TextLen
1346+
// ${asc-len}\r\n = 4/5 (asc-len at most 2 digits)
13451347
// {asc}\r\n = MaxInt64TextLen + 2
1346-
var span = writer.GetSpan(5 + Format.MaxInt32TextLen + Format.MaxInt64TextLen);
1348+
var span = writer.GetSpan(7 + Format.MaxInt64TextLen);
13471349

13481350
span[0] = (byte)'$';
13491351
var bytes = WriteRaw(span, value, withLengthPrefix: true, offset: 1);
@@ -1354,20 +1356,41 @@ private static void WriteUnifiedUInt64(PipeWriter writer, ulong value)
13541356
{
13551357
// note from specification: A client sends to the Redis server a RESP Array consisting of just Bulk Strings.
13561358
// (i.e. we can't just send ":123\r\n", we need to send "$3\r\n123\r\n"
1357-
1358-
// ${asc-len}\r\n = 3 + MaxInt32TextLen
1359-
// {asc}\r\n = MaxInt64TextLen + 2
1360-
var span = writer.GetSpan(5 + Format.MaxInt32TextLen + Format.MaxInt64TextLen);
1361-
13621359
Span<byte> valueSpan = stackalloc byte[Format.MaxInt64TextLen];
1360+
13631361
var len = Format.FormatUInt64(value, valueSpan);
1362+
// ${asc-len}\r\n = 4/5 (asc-len at most 2 digits)
1363+
// {asc}\r\n = {len} + 2
1364+
var span = writer.GetSpan(7 + len);
1365+
span[0] = (byte)'$';
1366+
int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1);
1367+
valueSpan.Slice(0, len).CopyTo(span.Slice(offset));
1368+
offset += len;
1369+
offset = WriteCrlf(span, offset);
1370+
writer.Advance(offset);
1371+
}
1372+
1373+
private static void WriteUnifiedDouble(PipeWriter writer, double value)
1374+
{
1375+
#if NET8_0_OR_GREATER
1376+
Span<byte> valueSpan = stackalloc byte[Format.MaxDoubleTextLen];
1377+
var len = Format.FormatDouble(value, valueSpan);
1378+
1379+
// ${asc-len}\r\n = 4/5 (asc-len at most 2 digits)
1380+
// {asc}\r\n = {len} + 2
1381+
var span = writer.GetSpan(7 + len);
13641382
span[0] = (byte)'$';
13651383
int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1);
13661384
valueSpan.Slice(0, len).CopyTo(span.Slice(offset));
13671385
offset += len;
13681386
offset = WriteCrlf(span, offset);
13691387
writer.Advance(offset);
1388+
#else
1389+
// fallback: drop to string
1390+
WriteUnifiedPrefixedString(writer, null, Format.ToString(value));
1391+
#endif
13701392
}
1393+
13711394
internal static void WriteInteger(PipeWriter writer, long value)
13721395
{
13731396
// note: client should never write integer; only server does this

src/StackExchange.Redis/RedisValue.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public bool IsNullOrEmpty
149149
/// <param name="y">The second <see cref="RedisValue"/> to compare.</param>
150150
public static bool operator !=(RedisValue x, RedisValue y) => !(x == y);
151151

152-
private double OverlappedValueDouble
152+
internal double OverlappedValueDouble
153153
{
154154
[MethodImpl(MethodImplOptions.AggressiveInlining)]
155155
get => BitConverter.Int64BitsToDouble(_overlappedBits64);
@@ -849,7 +849,7 @@ private static string ToHex(ReadOnlySpan<byte> src)
849849
len = Format.FormatUInt64(value.OverlappedValueUInt64, span);
850850
return span.Slice(0, len).ToArray();
851851
case StorageType.Double:
852-
span = stackalloc byte[128];
852+
span = stackalloc byte[Format.MaxDoubleTextLen];
853853
len = Format.FormatDouble(value.OverlappedValueDouble, span);
854854
return span.Slice(0, len).ToArray();
855855
case StorageType.String:
@@ -986,7 +986,8 @@ internal RedisValue Simplify()
986986
if (Format.TryParseInt64(s, out i64)) return i64;
987987
if (Format.TryParseUInt64(s, out u64)) return u64;
988988
}
989-
if (Format.TryParseDouble(s, out var f64)) return f64;
989+
// note: don't simplify inf/nan, as that causes equality semantic problems
990+
if (Format.TryParseDouble(s, out var f64) && !IsSpecialDouble(f64)) return f64;
990991
break;
991992
case StorageType.Raw:
992993
var b = _memory.Span;
@@ -995,7 +996,8 @@ internal RedisValue Simplify()
995996
if (Format.TryParseInt64(b, out i64)) return i64;
996997
if (Format.TryParseUInt64(b, out u64)) return u64;
997998
}
998-
if (TryParseDouble(b, out f64)) return f64;
999+
// note: don't simplify inf/nan, as that causes equality semantic problems
1000+
if (TryParseDouble(b, out f64) && !IsSpecialDouble(f64)) return f64;
9991001
break;
10001002
case StorageType.Double:
10011003
// is the double actually an integer?
@@ -1006,6 +1008,8 @@ internal RedisValue Simplify()
10061008
return this;
10071009
}
10081010

1011+
private static bool IsSpecialDouble(double d) => double.IsNaN(d) || double.IsInfinity(d);
1012+
10091013
/// <summary>
10101014
/// Convert to a signed <see cref="long"/> if possible.
10111015
/// </summary>

tests/StackExchange.Redis.Tests/KeyAndValueTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ public void TestValues()
6464

6565
internal static void CheckSame(RedisValue x, RedisValue y)
6666
{
67+
if (x.TryParse(out double value) && double.IsNaN(value))
68+
{
69+
// NaN has atypical equality rules
70+
Assert.True(y.TryParse(out value) && double.IsNaN(value));
71+
return;
72+
}
6773
Assert.True(Equals(x, y), "Equals(x, y)");
6874
Assert.True(Equals(y, x), "Equals(y, x)");
6975
Assert.True(EqualityComparer<RedisValue>.Default.Equals(x, y), "EQ(x,y)");

0 commit comments

Comments
 (0)