Improve Perf - #402
Merged
Merged
Conversation
…locations Implements the high priority findings from the perf review in todo.md, which also records the before/after benchmark numbers quoted here. Core read/write: - ReadStringIntoBuffer skips runs of ordinary chars with a SearchValues scan rather than running every char of every string through the switch. The '\0' kept at charsUsed doubles as the scan sentinel, so the read-more-data path is unchanged. net8+ only, scalar walk retained below that. 3.6x faster. - The span WritePropertyName overload copied the name into a string so the position could hold it, allocating exactly as much as the string overload. JsonPosition now also carries the chars directly, in a buffer reused by every property at that depth. 1.7x faster, 27% less allocation. - DefaultJsonNameTable.TextEquals uses SequenceEqual. 1.9x faster. - The escape writer re-runs its vectorized scan after each escape instead of walking the remainder of the string one char at a time. Serialization: - Converter resolution is memoized for the duration of a serialize/deserialize call, rather than calling virtual CanConvert across the converter list for every value. Not cacheable on the contract, since contracts are shared across serializers with different converter lists. 2.2x serialize, 1.8x deserialize. - $type names are cached per run instead of being concatenated and re-parsed through RemoveAssemblyDetails for every object. 3.3x faster, 66% less allocation. - CalculatePropertyDetails no longer resolves the same contract twice for a property that is populated in place. - JsonObjectContract.IndexOfCreatorParameter replaces an IndexOf scan that made construction through a parameterized constructor quadratic in its parameter count. 1.6x faster on a 16 parameter record. - SetPropertyPresence does one dictionary lookup instead of two on net6+. Linq to JSON and JsonPath: - JObject.Properties iterates InnerList instead of building a LINQ Cast wrapper and boxing an enumerator. Same for CopyTo. 1.3x faster, 27% less allocation. - JTokenWriter.WriteValue(int) uses the shared BoxedPrimitives boxes. Left the narrower integer overloads alone: widening them to int would change the CLR type stored in JValue.Value, which is a behaviour change, not a perf one. - A JsonPath =~ expression caches its constructed Regex along with the timeout it was built for, instead of going through the static Regex cache per token. - JArray overrides GetItem to index its backing list directly. Supporting changes: - Adds HotPathBenchmarks covering the fixes that existing benchmarks did not, registered in Benchmark.Tests. - Fixes the ArgonTests build, which the xunit.v3 4.0.0 bump broke: the obsolete CollectionBehavior(DisableTestParallelization) is an error, so no tests ran. - Adds tests pairing the span WritePropertyName overload with Path, the only behaviour this change alters in how a value is stored and read back. - FloatParseHandlingDecimal builds its input from a decimal literal instead of casting Math.PI. The double to decimal conversion now returns full precision, which had turned the test into a check of conversion precision rather than of the XML/JSON round trip. Full suite green: 2358/2358 net10.0, 2337/2337 net48, 9/9 F#.
…d low priority perf items JPath: parse numbers from a slice of the expression rather than accumulating them into a StringBuilder, and return an unescaped quoted string as a Substring, only building a StringBuilder from the first escape on. The span number parsers need Polyfill, which Argon.JsonPath does not reference, so TryParseInt64 and TryParseDouble fall back to a string on net4x. Tests for the quoted string parse: text between two escapes, an escape as the final character, and the empty string. The existing tests only covered a single escape or none. Benchmarks for every medium and low priority item, and the measured results in todo.md. The timings carry a caveat: the machine alternated between two performance modes roughly 2x apart during the session, so runs were ordered ABBA and only differences that hold within a mode are reported as wins. Allocation numbers are deterministic and unaffected. Also strips BOMs that earlier edits added to nine files. .editorconfig sets charset = utf-8 for *.cs, and none of these files had one before.
.NET 11 makes double and float to decimal conversion correctly rounded instead of truncating to 15 and 7 significant digits (dotnet/runtime#130566, merged for 11.0-preview7, breaking change documented in dotnet/docs#55743). The (decimal) cast was already exact on every runtime; only Convert.ToDecimal changed, and Convert.ToDecimal(Math.PI) now returns 3.1415926535897931159979634685 rather than 3.14159265358979. That left 13 tests failing on net11.0. Argon is unchanged. It uses Convert.ToDecimal for JToken's decimal conversion operator, JValue.Compare and dynamic arithmetic with a decimal operand, and following the platform is right: reintroducing the old truncation would mean deliberately re-adding an inaccuracy the BCL just removed, and would diverge from a (decimal) cast written in the caller's own code. The failures were all in the tests: - Test data that encoded the old truncation now uses decimal literals, so it says what it means rather than depending on the runtime: the serialization event tests built input with Convert.ToDecimal(Math.PI), the DataTable and DataSet tests assigned the double 64.0021 to a decimal typed column, and FloatParseHandling asserted the parsed 1E-06 against Convert.ToDecimal. This is the same fix the runtime team applied to their own two affected tests. - The three copies of the SelectToken documentation sample sum prices read from JSON as doubles, so the total carries the binary expansion of 99.95. They assert on the rounded total. - JValueAddition's decimal assertions compare to 10 decimal places, far more precision than those expressions test. JValueEquals keeps its two genuinely runtime dependent assertions under NET11_0_OR_GREATER: a JValue holding the decimal 1.1 no longer compares equal to the double 1.1. Full suite is green on every framework: 11790 tests, 0 failures.
The last open perf item was to replace the per char switch in ReadNumberIntoBuffer with IndexOfAnyExcept over a SearchValues of the number chars, and it was explicitly marked benchmark first. It was implemented behind a temporary toggle so both scans could be compared in one process, passed the full suite on every framework, and then measured: digits scalar vectorized 1 11.5-12.0 us 14.6 us 25% slower 3 31.4-31.6 us 32.9 us 4% slower 8 44.6-44.9 us 38.4-40.2 us 10% faster 18 71.2-72.1 us 49.9-54.8 us 27% faster Reading 500 numbers, job medium with 3 launches, from the two runs that agreed with each other. The crossover sits between 3 and 8 digits: the vectorized scan has setup cost to earn back and a short number never gives it the chance. Ids, counts and small quantities are most of the numbers in real JSON, so the common case is the one that gets slower. Not taken. A hybrid, scalar for the first 8 chars and vectorized for the rest, also passed the suite but could not be measured to a conclusion: across five runs the same code measured 1.17x to 1.78x apart run to run, wider than the effect. Noted in todo.md as the thing to try first if this is revisited on a quiet machine. The experiment is reverted. NumberScanBenchmark stays, parameterized by digits per number, as the cost profile of number reading by length.
The docs job runs dotnet tool install in the repo directory, where global.json pins a preview SDK. The windows runner image does not carry it, so SDK resolution fails before the tool is installed and the job exits 155. The job has not passed in the last 60 runs, going back to July, on main and on branches alike, and always for this reason: the pin has been ahead of the image the whole time. Installing the SDK the pin asks for keeps the job working across future pin bumps rather than waiting for the runner image to catch up. Running mdsnippets locally produces no markdown changes, so the first green run has nothing to push.
# Conflicts: # src/ArgonTests/AssemblyInfo.cs # src/ArgonTests/Converters/DataSetConverterTests.cs # src/ArgonTests/Converters/DataTableConverterTests.cs # src/ArgonTests/Documentation/LinqToJsonTests.cs # src/ArgonTests/Documentation/Samples/JsonPath/QueryJsonSelectTokenWithLinq.cs # src/ArgonTests/JsonTextReaderTests/FloatTests.cs # src/ArgonTests/Linq/DynamicTests.cs # src/ArgonTests/Linq/JsonPath/JPathExecuteTests.cs # src/ArgonTests/Serialization/SerializationEventTests.cs
This was referenced Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.