From 4b0e9ff674a7ceca9c8d8170ad48a407805f2816 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 18 May 2026 19:18:29 +1000 Subject: [PATCH] avoid stringReference.ToString() in JsonTextReader quoted-number path --- src/Argon/JsonReader.cs | 91 +++++++++++++++++++ src/Argon/JsonTextReader.cs | 8 +- src/Argon/Utilities/StringReference.cs | 3 + .../Benchmarks/ReadQuotedNumbers.cs | 74 +++++++++++++++ .../JsonTextReaderTests/ReadTests.cs | 15 +++ src/Benchmark.Tests/Program.cs | 2 +- 6 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 src/ArgonTests/Benchmarks/ReadQuotedNumbers.cs diff --git a/src/Argon/JsonReader.cs b/src/Argon/JsonReader.cs index 371e49e6d..c4173dce5 100644 --- a/src/Argon/JsonReader.cs +++ b/src/Argon/JsonReader.cs @@ -359,6 +359,25 @@ JsonContainerType Peek() => throw JsonReaderException.Create(this, $"Could not convert string to integer: {s}."); } + internal int? ReadInt32String(ReadOnlySpan s) + { + if (s.IsEmpty) + { + SetNullToken(); + return null; + } + + if (int.TryParse(s, NumberStyles.Integer, InvariantCulture, out var i)) + { + SetToken(i); + return i; + } + + var text = s.ToString(); + SetToken(text); + throw JsonReaderException.Create(this, $"Could not convert string to integer: {text}."); + } + /// /// Reads the next JSON token from the source as a . /// @@ -558,6 +577,25 @@ bool ReadArrayElementIntoByteArrayReportDone(List buffer) throw JsonReaderException.Create(this, $"Error reading double. Unexpected token: {token}."); } + internal double? ReadDoubleString(ReadOnlySpan s) + { + if (s.IsEmpty) + { + SetNullToken(); + return null; + } + + if (double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, InvariantCulture, out var d)) + { + SetToken(d); + return d; + } + + var text = s.ToString(); + SetToken(text); + throw JsonReaderException.Create(this, $"Could not convert string to double: {text}."); + } + internal double? ReadDoubleString(string? s) { if (s.IsNullOrEmpty()) @@ -631,6 +669,32 @@ bool ReadArrayElementIntoByteArrayReportDone(List buffer) throw JsonReaderException.Create(this, $"Could not convert string to boolean: {s}."); } + internal bool? ReadBooleanString(ReadOnlySpan s) + { + if (s.IsEmpty) + { + SetNullToken(); + return null; + } + + var trimmed = s.Trim(); + if (trimmed.Equals("true".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + SetToken(true); + return true; + } + + if (trimmed.Equals("false".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + SetToken(false); + return false; + } + + var text = s.ToString(); + SetToken(text); + throw JsonReaderException.Create(this, $"Could not convert string to boolean: {text}."); + } + /// /// Reads the next JSON token from the source as a of . /// @@ -704,6 +768,33 @@ bool ReadArrayElementIntoByteArrayReportDone(List buffer) throw JsonReaderException.Create(this, $"Could not convert string to decimal: {s}."); } + internal decimal? ReadDecimalString(ReadOnlySpan s) + { + if (s.IsEmpty) + { + SetNullToken(); + return null; + } + + if (decimal.TryParse(s, NumberStyles.Number, InvariantCulture, out var d)) + { + SetToken(d); + return d; + } + + // fallback handles strings like "96.014e-05" not supported by decimal.TryParse + var chars = s.ToArray(); + if (ConvertUtils.DecimalTryParse(chars, 0, chars.Length, out d) == ParseResult.Success) + { + SetToken(d); + return d; + } + + var text = s.ToString(); + SetToken(text); + throw JsonReaderException.Create(this, $"Could not convert string to decimal: {text}."); + } + /// /// Reads the next JSON token from the source as a of . /// diff --git a/src/Argon/JsonTextReader.cs b/src/Argon/JsonTextReader.cs index e67ba7494..e3375185b 100644 --- a/src/Argon/JsonTextReader.cs +++ b/src/Argon/JsonTextReader.cs @@ -648,7 +648,7 @@ JsonReaderException CreateUnexpectedCharacterException(char c) => case '"': case '\'': ParseString(currentChar, ReadType.Read); - return ReadBooleanString(stringReference.ToString()); + return ReadBooleanString(stringReference.AsSpan()); case 'n': HandleNull(); return null; @@ -864,11 +864,11 @@ void ProcessValueComma() switch (readType) { case ReadType.ReadAsInt32: - return ReadInt32String(stringReference.ToString()); + return ReadInt32String(stringReference.AsSpan()); case ReadType.ReadAsDecimal: - return ReadDecimalString(stringReference.ToString()); + return ReadDecimalString(stringReference.AsSpan()); case ReadType.ReadAsDouble: - return ReadDoubleString(stringReference.ToString()); + return ReadDoubleString(stringReference.AsSpan()); default: throw new ArgumentOutOfRangeException(nameof(readType)); } diff --git a/src/Argon/Utilities/StringReference.cs b/src/Argon/Utilities/StringReference.cs index 8554cc529..8c536ac4e 100644 --- a/src/Argon/Utilities/StringReference.cs +++ b/src/Argon/Utilities/StringReference.cs @@ -12,6 +12,9 @@ readonly struct StringReference(char[] chars, int startIndex, int length) public int Length { get; } = length; + public ReadOnlySpan AsSpan() => + Chars.AsSpan(StartIndex, Length); + public override string ToString() => new(Chars, StartIndex, Length); } \ No newline at end of file diff --git a/src/ArgonTests/Benchmarks/ReadQuotedNumbers.cs b/src/ArgonTests/Benchmarks/ReadQuotedNumbers.cs new file mode 100644 index 000000000..7e4e6a21d --- /dev/null +++ b/src/ArgonTests/Benchmarks/ReadQuotedNumbers.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +using BenchmarkDotNet.Attributes; + +[MemoryDiagnoser] +public class ReadQuotedNumbers +{ + string json = null!; + + [GlobalSetup] + public void Setup() + { + var builder = new StringBuilder("["); + for (var i = 0; i < 500; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append('"').Append(i * 12345).Append('"'); + } + + builder.Append(']'); + json = builder.ToString(); + } + + [Benchmark] + public long ReadAsInt32() + { + using var stringReader = new StringReader(json); + using var reader = new JsonTextReader(stringReader); + long sum = 0; + reader.Read(); + while (reader.ReadAsInt32() is { } value) + { + sum += value; + } + + return sum; + } + + [Benchmark] + public decimal ReadAsDecimal() + { + using var stringReader = new StringReader(json); + using var reader = new JsonTextReader(stringReader); + decimal sum = 0; + reader.Read(); + while (reader.ReadAsDecimal() is { } value) + { + sum += value; + } + + return sum; + } + + [Benchmark] + public double ReadAsDouble() + { + using var stringReader = new StringReader(json); + using var reader = new JsonTextReader(stringReader); + double sum = 0; + reader.Read(); + while (reader.ReadAsDouble() is { } value) + { + sum += value; + } + + return sum; + } +} diff --git a/src/ArgonTests/JsonTextReaderTests/ReadTests.cs b/src/ArgonTests/JsonTextReaderTests/ReadTests.cs index 8c14e3d02..4864a3606 100644 --- a/src/ArgonTests/JsonTextReaderTests/ReadTests.cs +++ b/src/ArgonTests/JsonTextReaderTests/ReadTests.cs @@ -571,6 +571,21 @@ public void ReadAsDouble_Null() Assert.Null(reader.ReadAsDouble()); } + [Fact] + public void ReadAsInt32_QuotedNumber_Success() + { + var reader = new JsonTextReader(new StringReader("'42'")); + Assert.Equal(42, reader.ReadAsInt32()); + } + + [Fact] + public void ReadAsDecimal_QuotedNumber_Failure() + { + var reader = new JsonTextReader(new StringReader("'blah'")); + var exception = Assert.Throws(() => reader.ReadAsDecimal()); + Assert.Equal("Could not convert string to decimal: blah. Path '', line 1, position 6.", exception.Message); + } + [Fact] public void ReadAsDouble_Success() { diff --git a/src/Benchmark.Tests/Program.cs b/src/Benchmark.Tests/Program.cs index 8a395cd5e..63432b0e9 100644 --- a/src/Benchmark.Tests/Program.cs +++ b/src/Benchmark.Tests/Program.cs @@ -11,7 +11,7 @@ public static void Main(string[] args) var attribute = (AssemblyFileVersionAttribute)typeof(JsonConvert).Assembly.GetCustomAttribute(typeof(AssemblyFileVersionAttribute))!; Console.WriteLine($"Json.NET Version: {attribute.Version}"); - var switcher = new BenchmarkSwitcher([typeof(WriteEscapedJavaScriptString), typeof(SerializeJTokenList)]); + var switcher = new BenchmarkSwitcher([typeof(WriteEscapedJavaScriptString), typeof(SerializeJTokenList), typeof(ReadQuotedNumbers)]); if (args.Length == 0) { switcher.Run(["*"]);