Skip to content

STJ converter performance: caches, fast-path resolution, streamed read (to master) - #214

Merged
manuc66 merged 11 commits into
masterfrom
feature/split/pr2-stj-converter-perf
Aug 16, 2026
Merged

STJ converter performance: caches, fast-path resolution, streamed read (to master)#214
manuc66 merged 11 commits into
masterfrom
feature/split/pr2-stj-converter-perf

Conversation

@manuc66

@manuc66 manuc66 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Problem

PR #208 (STJ converter performance) was merged into its base branch feature/split/pr1-readme-security instead of master, so the performance work never reached master. This PR re-targets the same content so it lands in master.

Fix

The 11 commits that master is missing, unchanged:

  • Cache the converter list and fast-path single-level type resolution.
  • Deserialize from the parsed JsonElement instead of re-reading raw bytes.
  • Avoid the UTF-16 string round-trip on the discriminator write path.
  • Run benchmarks on net10 and fix the NativeAOT toolchain build; more scenarios; PERFORMANCE.md extracted.
  • Harden the reflection-based benchmarks under the Native AOT job; fix the PERFORMANCE.md link; document reproduction with a verified sample run.

Tests

  • Existing STJ suite passes; benchmark scenarios validate round-trips before measuring (net8.0 and net10.0).

Honest note(s)

manuc66 added 11 commits August 15, 2026 00:40
The GetType walk re-scanned serializer.Converters and allocated a List and a
HashSet on every deserialized object, even for single-level hierarchies. Cache
the IJsonSubtypes list per JsonSerializerOptions (System.Text.Json freezes
options on first use) and resolve the first level without allocating; only the
nested multi-level walk keeps its cycle-protection set. Also compare string/int
discriminators directly against the mapping instead of round-tripping through
GetRawText() + JsonSerializer.Deserialize.

Measured (BenchmarkDotNet, net10, DefaultJob): Converter_Deserialize 1.925us /
1000 B before, 1.672us / 648 B after. All 190 STJ tests still pass.
Move the benchmark project to net10.0 so the JIT and NativeAOT jobs measure
the same runtime, and drop PublishAot from the host build: it disabled
reflection for the whole process, making the reflection-based converter and
resolver benchmarks unavailable. Use the Net10_0 NativeAOT preset instead of
CreateBuilder().UseNuGet(), which required an explicit TargetFrameworkMoniker
in this BenchmarkDotNet version.
… engines

The feature table suggests the converter and the generator are equivalent, but
the decisive difference is not speed: the generator reads its registrations from
attributes at compile time and can only route types visible to the compilation,
while the converter's Build() accepts runtime registrations and is the only
engine for plugins and third-party types you cannot annotate.
ReadObject parsed the JSON into a JsonDocument to find the discriminator, then
DeserializerHelper re-serialized the payload from the raw Utf8JsonReader — a
second full materialization the generator does not do. Deserialize the resolved
subtype from the already-parsed RootElement instead, matching the generator's
path, and drop the now-unused DeserializerHelper.

Measured (BenchmarkDotNet, net10, DefaultJob): Converter_Deserialize 1.672us
before, 1.499us after. All STJ and AOT parity tests still pass.
WriteObjectWithDiscriminator received the payload as a string, produced by
JsonSerializer.Serialize (UTF-16) or Encoding.UTF8.GetString, then re-parsed it.
Serialize straight into an ArrayBufferWriter and parse the UTF-8 bytes directly,
matching how System.Text.Json handles bytes internally and skipping two
encodings conversions.

Adds a parity test serializing a subtype with non-ASCII characters (accent,
snowman, surrogate-pair emoji) asserting the exact escaped form, so a regression
in the JsonDocument write path is caught.

Measured (BenchmarkDotNet, net10, DefaultJob): Converter_Serialize 1.220us /
664 B before, 1.142us / 856 B after. All STJ and AOT parity tests still pass.
The benchmark suite measured only a single flat object. Add benchmarks for the
use-cases that matter in practice: collections of polymorphic objects, nested
multi-level hierarchies, and property-presence discrimination, plus a
Newtonsoft.Json baseline mirroring the single-object and collection scenarios.

Nested-hierarchy serialization is benchmarked only on the generated engine: the
converter falls back to the plain runtime-type contract there (documented in
the README), so a converter write benchmark would not measure discriminator
injection.

Disambiguate JsonSubtypesConverterBuilder between the JsonSubTypes (Newtonsoft)
and JsonSubTypes.Text.Json packages with explicit aliases.
The README keeps only the conclusions of the benchmark run and links to the new
PERFORMANCE.md for the methodology, the machine and all scenario tables. This
matches the project's doc style: usage guidance in the README, maintainer-level
detail in a linked document.
The converter, resolver and Newtonsoft benchmarks passed their JsonSerializerOptions
(or ran JsonConvert) even when reflection was disabled in the Native AOT host:
serialization silently measured the default options and the deserialization
benchmarks threw on a null payload, so neither produced a usable number but the
doc claimed they report NA. Route their options through a guard that throws
NotSupportedException when reflection is disabled, and state that in
PERFORMANCE.md instead of the NA claim.
… perf work

The README linked to PERFORMANCE without the .md extension, which 404s on GitHub.
The STJ converter file kept using System.IO after the MemoryStream write path was
replaced by ArrayBufferWriter. The Unreleased changelog section had no entry for
the converter performance work.
Anyone arriving on the repo should be able to reproduce the measurements:
the documented command previously launched BenchmarkDotNet interactively (it
prompted for a selection), so it needed the --filter argument forwarded with
--. State the prerequisites (net10 SDK, native compiler for the NativeAOT job),
add the usual micro-benchmark disclaimers, note the runtime used, and embed a
verbatim sample run taken from a fresh execution on the documented machine that
reproduces the reported tables within run-to-run noise.
@manuc66
manuc66 merged commit 28747cc into master Aug 16, 2026
7 checks passed
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.14%. Comparing base (68750fa) to head (911e3d3).
⚠️ Report is 15 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #214      +/-   ##
==========================================
- Coverage   93.20%   93.14%   -0.07%     
==========================================
  Files          19       18       -1     
  Lines        1560     1575      +15     
  Branches      301      306       +5     
==========================================
+ Hits         1454     1467      +13     
- Misses         50       51       +1     
- Partials       56       57       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant