diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 7f2ed4432..3a50f1ef3 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -12,7 +12,8 @@ Current package versions: - Fix key-prefix omission in `SetIntersectionLength` and `SortedSet{Combine[WithScores]|IntersectionLength}` ([#2863 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2863)) - Add `Condition.SortedSet[Not]ContainsStarting` condition for transactions ([#2638 by ArnoKoll](https://github.com/StackExchange/StackExchange.Redis/pull/2638)) - Add support for XPENDING Idle time filter ([#2822 by david-brink-talogy](https://github.com/StackExchange/StackExchange.Redis/pull/2822)) -- +- Improve `double` formatting performance on net8+ ([#2928 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2928)) + ## 2.8.58 - 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)) diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index 329da4363..86aa9910d 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Net; +using System.Runtime.CompilerServices; using System.Text; #if UNIX_SOCKET @@ -168,24 +169,41 @@ internal static bool TryParseDouble(string? s, out double value) value = s[0] - '0'; return true; // RESP3 spec demands inf/nan handling - case 3 when CaseInsensitiveASCIIEqual("inf", s): - value = double.PositiveInfinity; - return true; - case 3 when CaseInsensitiveASCIIEqual("nan", s): - value = double.NaN; - return true; - case 4 when CaseInsensitiveASCIIEqual("+inf", s): - value = double.PositiveInfinity; - return true; - case 4 when CaseInsensitiveASCIIEqual("-inf", s): - value = double.NegativeInfinity; - return true; - case 4 when CaseInsensitiveASCIIEqual("+nan", s): - case 4 when CaseInsensitiveASCIIEqual("-nan", s): - value = double.NaN; + case 3 when TryParseInfNaN(s.AsSpan(), true, out value): + case 4 when s[0] == '+' && TryParseInfNaN(s.AsSpan(1), true, out value): + case 4 when s[0] == '-' && TryParseInfNaN(s.AsSpan(1), false, out value): return true; } return double.TryParse(s, NumberStyles.Any, NumberFormatInfo.InvariantInfo, out value); + + static bool TryParseInfNaN(ReadOnlySpan s, bool positive, out double value) + { + switch (s[0]) + { + case 'i': + case 'I': + if (s[1] is 'n' or 'N' && s[2] is 'f' or 'F') + { + value = positive ? double.PositiveInfinity : double.NegativeInfinity; + return true; + } + break; + case 'n': + case 'N': + if (s[1] is 'a' or 'A' && s[2] is 'n' or 'N') + { + value = double.NaN; + return true; + } + break; + } +#if NET6_0_OR_GREATER + Unsafe.SkipInit(out value); +#else + value = 0; +#endif + return false; + } } internal static bool TryParseUInt64(string s, out ulong value) => @@ -235,37 +253,41 @@ internal static bool TryParseDouble(ReadOnlySpan s, out double value) value = s[0] - '0'; return true; // RESP3 spec demands inf/nan handling - case 3 when CaseInsensitiveASCIIEqual("inf", s): - value = double.PositiveInfinity; - return true; - case 3 when CaseInsensitiveASCIIEqual("nan", s): - value = double.NaN; - return true; - case 4 when CaseInsensitiveASCIIEqual("+inf", s): - value = double.PositiveInfinity; - return true; - case 4 when CaseInsensitiveASCIIEqual("-inf", s): - value = double.NegativeInfinity; - return true; - case 4 when CaseInsensitiveASCIIEqual("+nan", s): - case 4 when CaseInsensitiveASCIIEqual("-nan", s): - value = double.NaN; + case 3 when TryParseInfNaN(s, true, out value): + case 4 when s[0] == '+' && TryParseInfNaN(s.Slice(1), true, out value): + case 4 when s[0] == '-' && TryParseInfNaN(s.Slice(1), false, out value): return true; } return Utf8Parser.TryParse(s, out value, out int bytes) & bytes == s.Length; - } - - private static bool CaseInsensitiveASCIIEqual(string xLowerCase, string y) - => string.Equals(xLowerCase, y, StringComparison.OrdinalIgnoreCase); - private static bool CaseInsensitiveASCIIEqual(string xLowerCase, ReadOnlySpan y) - { - if (y.Length != xLowerCase.Length) return false; - for (int i = 0; i < y.Length; i++) + static bool TryParseInfNaN(ReadOnlySpan s, bool positive, out double value) { - if (char.ToLower((char)y[i]) != xLowerCase[i]) return false; + switch (s[0]) + { + case (byte)'i': + case (byte)'I': + if (s[1] is (byte)'n' or (byte)'N' && s[2] is (byte)'f' or (byte)'F') + { + value = positive ? double.PositiveInfinity : double.NegativeInfinity; + return true; + } + break; + case (byte)'n': + case (byte)'N': + if (s[1] is (byte)'a' or (byte)'A' && s[2] is (byte)'n' or (byte)'N') + { + value = double.NaN; + return true; + } + break; + } +#if NET6_0_OR_GREATER + Unsafe.SkipInit(out value); +#else + value = 0; +#endif + return false; } - return true; } /// @@ -399,11 +421,21 @@ internal static unsafe string GetString(ReadOnlySpan span) internal const int MaxInt32TextLen = 11, // -2,147,483,648 (not including the commas) - MaxInt64TextLen = 20; // -9,223,372,036,854,775,808 (not including the commas) + MaxInt64TextLen = 20, // -9,223,372,036,854,775,808 (not including the commas), + MaxDoubleTextLen = 40; // we use G17, allow for sign/E/and allow plenty of panic room internal static int MeasureDouble(double value) { if (double.IsInfinity(value)) return 4; // +inf / -inf + +#if NET8_0_OR_GREATER // can use IUtf8Formattable + Span buffer = stackalloc byte[MaxDoubleTextLen]; + if (value.TryFormat(buffer, out int len, "G17", NumberFormatInfo.InvariantInfo)) + { + return len; + } +#endif + // fallback (TFM or unexpected size) var s = value.ToString("G17", NumberFormatInfo.InvariantInfo); // this looks inefficient, but is how Utf8Formatter works too, just: more direct return s.Length; } @@ -412,16 +444,18 @@ internal static int FormatDouble(double value, Span destination) { if (double.IsInfinity(value)) { - if (double.IsPositiveInfinity(value)) - { - if (!"+inf"u8.TryCopyTo(destination)) ThrowFormatFailed(); - } - else - { - if (!"-inf"u8.TryCopyTo(destination)) ThrowFormatFailed(); - } + if (!(double.IsPositiveInfinity(value) ? "+inf"u8 : "-inf"u8).TryCopyTo(destination)) ThrowFormatFailed(); return 4; } + +#if NET8_0_OR_GREATER // can use IUtf8Formattable + if (!value.TryFormat(destination, out int len, "G17", NumberFormatInfo.InvariantInfo)) + { + ThrowFormatFailed(); + } + + return len; +#else var s = value.ToString("G17", NumberFormatInfo.InvariantInfo); // this looks inefficient, but is how Utf8Formatter works too, just: more direct if (s.Length > destination.Length) ThrowFormatFailed(); @@ -431,6 +465,7 @@ internal static int FormatDouble(double value, Span destination) destination[i] = (byte)chars[i]; } return chars.Length; +#endif } internal static int MeasureInt64(long value) diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 324b952fd..c587241a0 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -845,7 +845,9 @@ internal static void WriteBulkString(in RedisValue value, PipeWriter? maybeNullW case RedisValue.StorageType.UInt64: WriteUnifiedUInt64(writer, value.OverlappedValueUInt64); break; - case RedisValue.StorageType.Double: // use string + case RedisValue.StorageType.Double: + WriteUnifiedDouble(writer, value.OverlappedValueDouble); + break; case RedisValue.StorageType.String: WriteUnifiedPrefixedString(writer, null, (string?)value); break; @@ -1341,9 +1343,9 @@ private static void WriteUnifiedInt64(PipeWriter writer, long value) // note from specification: A client sends to the Redis server a RESP Array consisting of just Bulk Strings. // (i.e. we can't just send ":123\r\n", we need to send "$3\r\n123\r\n" - // ${asc-len}\r\n = 3 + MaxInt32TextLen + // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) // {asc}\r\n = MaxInt64TextLen + 2 - var span = writer.GetSpan(5 + Format.MaxInt32TextLen + Format.MaxInt64TextLen); + var span = writer.GetSpan(7 + Format.MaxInt64TextLen); span[0] = (byte)'$'; var bytes = WriteRaw(span, value, withLengthPrefix: true, offset: 1); @@ -1354,20 +1356,41 @@ private static void WriteUnifiedUInt64(PipeWriter writer, ulong value) { // note from specification: A client sends to the Redis server a RESP Array consisting of just Bulk Strings. // (i.e. we can't just send ":123\r\n", we need to send "$3\r\n123\r\n" - - // ${asc-len}\r\n = 3 + MaxInt32TextLen - // {asc}\r\n = MaxInt64TextLen + 2 - var span = writer.GetSpan(5 + Format.MaxInt32TextLen + Format.MaxInt64TextLen); - Span valueSpan = stackalloc byte[Format.MaxInt64TextLen]; + var len = Format.FormatUInt64(value, valueSpan); + // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) + // {asc}\r\n = {len} + 2 + var span = writer.GetSpan(7 + len); + span[0] = (byte)'$'; + int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1); + valueSpan.Slice(0, len).CopyTo(span.Slice(offset)); + offset += len; + offset = WriteCrlf(span, offset); + writer.Advance(offset); + } + + private static void WriteUnifiedDouble(PipeWriter writer, double value) + { +#if NET8_0_OR_GREATER + Span valueSpan = stackalloc byte[Format.MaxDoubleTextLen]; + var len = Format.FormatDouble(value, valueSpan); + + // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) + // {asc}\r\n = {len} + 2 + var span = writer.GetSpan(7 + len); span[0] = (byte)'$'; int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1); valueSpan.Slice(0, len).CopyTo(span.Slice(offset)); offset += len; offset = WriteCrlf(span, offset); writer.Advance(offset); +#else + // fallback: drop to string + WriteUnifiedPrefixedString(writer, null, Format.ToString(value)); +#endif } + internal static void WriteInteger(PipeWriter writer, long value) { // note: client should never write integer; only server does this diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index a670607de..f198f3d08 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -149,7 +149,7 @@ public bool IsNullOrEmpty /// The second to compare. public static bool operator !=(RedisValue x, RedisValue y) => !(x == y); - private double OverlappedValueDouble + internal double OverlappedValueDouble { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => BitConverter.Int64BitsToDouble(_overlappedBits64); @@ -849,7 +849,7 @@ private static string ToHex(ReadOnlySpan src) len = Format.FormatUInt64(value.OverlappedValueUInt64, span); return span.Slice(0, len).ToArray(); case StorageType.Double: - span = stackalloc byte[128]; + span = stackalloc byte[Format.MaxDoubleTextLen]; len = Format.FormatDouble(value.OverlappedValueDouble, span); return span.Slice(0, len).ToArray(); case StorageType.String: @@ -986,7 +986,8 @@ internal RedisValue Simplify() if (Format.TryParseInt64(s, out i64)) return i64; if (Format.TryParseUInt64(s, out u64)) return u64; } - if (Format.TryParseDouble(s, out var f64)) return f64; + // note: don't simplify inf/nan, as that causes equality semantic problems + if (Format.TryParseDouble(s, out var f64) && !IsSpecialDouble(f64)) return f64; break; case StorageType.Raw: var b = _memory.Span; @@ -995,7 +996,8 @@ internal RedisValue Simplify() if (Format.TryParseInt64(b, out i64)) return i64; if (Format.TryParseUInt64(b, out u64)) return u64; } - if (TryParseDouble(b, out f64)) return f64; + // note: don't simplify inf/nan, as that causes equality semantic problems + if (TryParseDouble(b, out f64) && !IsSpecialDouble(f64)) return f64; break; case StorageType.Double: // is the double actually an integer? @@ -1006,6 +1008,8 @@ internal RedisValue Simplify() return this; } + private static bool IsSpecialDouble(double d) => double.IsNaN(d) || double.IsInfinity(d); + /// /// Convert to a signed if possible. /// diff --git a/tests/StackExchange.Redis.Tests/KeyAndValueTests.cs b/tests/StackExchange.Redis.Tests/KeyAndValueTests.cs index 1f8864083..6d37fbe7a 100644 --- a/tests/StackExchange.Redis.Tests/KeyAndValueTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyAndValueTests.cs @@ -64,6 +64,12 @@ public void TestValues() internal static void CheckSame(RedisValue x, RedisValue y) { + if (x.TryParse(out double value) && double.IsNaN(value)) + { + // NaN has atypical equality rules + Assert.True(y.TryParse(out value) && double.IsNaN(value)); + return; + } Assert.True(Equals(x, y), "Equals(x, y)"); Assert.True(Equals(y, x), "Equals(y, x)"); Assert.True(EqualityComparer.Default.Equals(x, y), "EQ(x,y)"); diff --git a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs index 7a56d16b0..7f6ad1561 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs @@ -136,12 +136,132 @@ static void Check(RedisValue known, RedisValue test) CheckString(-1099511627848.6001, "-1099511627848.6001"); Check(double.NegativeInfinity, double.NegativeInfinity); - Check(double.NegativeInfinity, "-inf"); CheckString(double.NegativeInfinity, "-inf"); Check(double.PositiveInfinity, double.PositiveInfinity); - Check(double.PositiveInfinity, "+inf"); CheckString(double.PositiveInfinity, "+inf"); + + Check(double.NaN, double.NaN); + CheckString(double.NaN, "NaN"); + } + + [Theory] + [InlineData("na")] + [InlineData("nan")] + [InlineData("nans")] + [InlineData("in")] + [InlineData("inf")] + [InlineData("info")] + public void SpecialCaseEqualityRules_String(string value) + { + RedisValue x = value, y = value; + Assert.Equal(x, y); + + Assert.True(x.Equals(y)); + Assert.True(y.Equals(x)); + Assert.True(x == y); + Assert.True(y == x); + Assert.False(x != y); + Assert.False(y != x); + Assert.Equal(x.GetHashCode(), y.GetHashCode()); + } + + [Theory] + [InlineData("na")] + [InlineData("nan")] + [InlineData("nans")] + [InlineData("in")] + [InlineData("inf")] + [InlineData("info")] + public void SpecialCaseEqualityRules_Bytes(string value) + { + byte[] bytes0 = Encoding.UTF8.GetBytes(value), + bytes1 = Encoding.UTF8.GetBytes(value); + Assert.NotSame(bytes0, bytes1); + RedisValue x = bytes0, y = bytes1; + + Assert.True(x.Equals(y)); + Assert.True(y.Equals(x)); + Assert.True(x == y); + Assert.True(y == x); + Assert.False(x != y); + Assert.False(y != x); + Assert.Equal(x.GetHashCode(), y.GetHashCode()); + } + + [Theory] + [InlineData("na")] + [InlineData("nan")] + [InlineData("nans")] + [InlineData("in")] + [InlineData("inf")] + [InlineData("info")] + public void SpecialCaseEqualityRules_Hybrid(string value) + { + byte[] bytes = Encoding.UTF8.GetBytes(value); + RedisValue x = bytes, y = value; + + Assert.True(x.Equals(y)); + Assert.True(y.Equals(x)); + Assert.True(x == y); + Assert.True(y == x); + Assert.False(x != y); + Assert.False(y != x); + Assert.Equal(x.GetHashCode(), y.GetHashCode()); + } + + [Theory] + [InlineData("na", "NA")] + [InlineData("nan", "NAN")] + [InlineData("nans", "NANS")] + [InlineData("in", "IN")] + [InlineData("inf", "INF")] + [InlineData("info", "INFO")] + public void SpecialCaseNonEqualityRules_String(string s, string t) + { + RedisValue x = s, y = t; + Assert.False(x.Equals(y)); + Assert.False(y.Equals(x)); + Assert.False(x == y); + Assert.False(y == x); + Assert.True(x != y); + Assert.True(y != x); + } + + [Theory] + [InlineData("na", "NA")] + [InlineData("nan", "NAN")] + [InlineData("nans", "NANS")] + [InlineData("in", "IN")] + [InlineData("inf", "INF")] + [InlineData("info", "INFO")] + public void SpecialCaseNonEqualityRules_Bytes(string s, string t) + { + RedisValue x = Encoding.UTF8.GetBytes(s), y = Encoding.UTF8.GetBytes(t); + Assert.False(x.Equals(y)); + Assert.False(y.Equals(x)); + Assert.False(x == y); + Assert.False(y == x); + Assert.True(x != y); + Assert.True(y != x); + } + + [Theory] + [InlineData("na", "NA")] + [InlineData("nan", "NAN")] + [InlineData("nans", "NANS")] + [InlineData("in", "IN")] + [InlineData("inf", "INF")] + [InlineData("info", "INFO")] + public void SpecialCaseNonEqualityRules_Hybrid(string s, string t) + { + RedisValue x = s, y = Encoding.UTF8.GetBytes(t); + Assert.False(x.Equals(y)); + Assert.False(y.Equals(x)); + Assert.False(x == y); + Assert.False(y == x); + Assert.True(x != y); + Assert.True(y != x); } private static void CheckString(RedisValue value, string expected)