Skip to content

STJ converter performance: caches, fast-path resolution, streamed read - #208

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

manuc66 merged 11 commits into
feature/split/pr1-readme-securityfrom
feature/split/pr2-stj-converter-perf

Conversation

@manuc66

@manuc66 manuc66 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Problem

The STJ converter did repeated work per call: re-scanning converters, walking the multi-level hierarchy, re-reading raw bytes, and paying UTF-16 round-trips on the discriminator write path.

Fix

  • 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.
  • Benchmark net10 and fix the NativeAOT toolchain build; add more benchmark scenarios; extract the detailed tables into PERFORMANCE.md.
  • Guard the reflection-based benchmarks so they fail loudly under the NativeAOT job instead of silently measuring the default options.

Tests

  • Existing STJ suite passes (190 tests on net8.0 and net10.0); the AOT parity suite passes, including the new non-ASCII escaping test; new benchmark scenarios validate round-trips before measuring (benchmarks run on net10.0).
  • PERFORMANCE.md now documents reproduction (prerequisites, working commands, disclaimers) and embeds a verbatim sample run: the reported numbers were reproduced on the documented machine on 2026-08-16 within run-to-run noise.

Honest note(s)

  • The numbers in PERFORMANCE.md reflect this PR's final state, including the streamed (UTF-8) discriminator write path.
  • The reflection-based engines (converter, resolver, Newtonsoft) cannot be measured under the NativeAOT job: the native host disables reflection, so their benchmark methods throw and produce no numbers there.
  • The benchmark project runs on net10.0 only; the parity/unit tests run on both net8.0 and net10.0.

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 36323f4 into feature/split/pr1-readme-security Aug 16, 2026
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