Skip to content

Improve Perf - #402

Merged
SimonCropp merged 14 commits into
mainfrom
perf
Aug 28, 2026
Merged

Improve Perf#402
SimonCropp merged 14 commits into
mainfrom
perf

Conversation

@SimonCropp

Copy link
Copy Markdown
Member

No description provided.

…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.
@SimonCropp SimonCropp added this to the 0.36.0 milestone Aug 27, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant