-
Notifications
You must be signed in to change notification settings - Fork 11
Performance Json Codegen Performance
- On a ten-member save-game record, a hand-written
JsonConverter<T>that writes and reads members one at a time is about 2.0x faster to serialize and 3.0x faster to deserialize than System.Text.Json's reflection path through the same package options. - The margin narrows as the payload grows but does not close: the same record carrying a
256-element
List<int>is still about 1.4x-1.6x to serialize and 1.6x-1.7x to deserialize. -
The package's option profile is not what makes the difference. Turning off
ReferenceHandler.IgnoreCycles, case-insensitive property matching and string enums -- the whole gap betweenCreateNormalJsonOptions()andCreateFastJsonOptions()-- moves the ratio by at most 0.15x. What codegen removes is per-member metadata dispatch, and that survives every option. - Every number here is a floor, for the reason given under Why these are lower bounds.
The fixture is Tests/Runtime/Performance/JsonCodegenPerformanceTests.cs. It exists because
#561 asks for a Roslyn JSON
generator "only if it was a measured win", so the number has to come before the design.
The record. Ten members shaped like a save slot rather than a micro-type: int, float,
bool, string, an enum, a Vector3, a List<int>, a long, a second int, and a nested object
of three more members. Two sizes, differing only in the list: 4 elements and 256.
The reference is that record through the package's own shipped options -- CreateNormalJsonOptions()
and CreateFastJsonOptions() -- which is what a consumer gets today.
The subject is the same record through a hand-written JsonConverter<T> registered ahead of the
package converters: cached UTF-8 property names, one member written or read at a time, the enum
resolved by a switch, no metadata lookup anywhere. That is the code a source generator emits, so
it is the honest upper bound on what codegen could buy -- a real generator cannot beat it and would
pay more than it in generality.
Before anything is timed, the two sides are held to byte-identical output and each is made to read the other's payload back into an equal object graph. A benchmark of two things that disagree is not a benchmark.
The protocol is the repository's counterbalanced ABBABAAB protocol
(WallstopStudios.UnityHelpers.Tests.Core.BenchmarkProtocol), three batches, twelve raw cycles per
side, a settled heap before every slot, geometric-mean ratio, per-cycle spread retained. A workload
whose spread exceeds 3% is logged but not published, because a wider one is a reading of the
machine.
Measured on Unity 6000.4.6f1, Editor Mono, 2026-08-29, on a machine that was also serving another editor session -- which is why most cells exceeded the 3% publication gate on the first two runs even though the ratios themselves reproduced. Three independent runs:
| Workload | Run 1 | Run 2 | Run 3 | Worst spread seen |
|---|---|---|---|---|
| Normal serialize, 4 | 2.0900x | 2.1168x | 2.0814x | 6.4% |
| Normal deserialize, 4 | 3.0124x | 3.0559x | 3.00x | 3.8% |
| Normal serialize, 256 | 1.5643x | 1.5477x | 1.5372x | 4.7% |
| Normal deserialize, 256 | 1.6889x | 1.6499x | 1.6614x | 11.5% |
| Fast serialize, 4 | 2.0027x | 2.0028x | 2.0026x | 6.0% |
| Fast deserialize, 4 | 3.0161x | 3.0054x | 3.00x | 4.2% |
| Fast serialize, 256 | 1.4040x | 1.4100x | 1.41x | 4.6% |
| Fast deserialize, 256 | 1.6676x | 1.6676x | 1.64x | 7.1% |
The run-to-run agreement is the part worth trusting: no cell moved more than 2.4% between runs, and
Fast serialize, 4 landed within 0.0002x of itself three times. The spreads say the machine was
busy; the ratios say the machine's business landed on both sides equally, which is what the
counterbalanced order is for.
One, one, and then four of the eight cells came inside the 3% gate on the three runs. The quietest run published this table, which is the fixture's own output verbatim:
| Workload | Ratio | Reference Spread | Subject Spread |
|---|---|---|---|
| Normal deserialize small | 3.00 | 0.0218 | 0.0181 |
| Fast deserialize small | 3.00 | 0.0228 | 0.0280 |
| Fast serialize large | 1.41 | 0.0269 | 0.0215 |
| Fast deserialize large | 1.64 | 0.0290 | 0.0223 |
A cell the gate rejects is still logged, ratio and both spreads included, so a busy machine costs the table a row rather than costing the run its evidence.
The test assembly's overrideReferences list does not carry System.Text.Encodings.Web, and
building a pre-escaped JsonEncodedText needs JavaScriptEncoder from it. The subject therefore
hands the writer raw UTF-8 name spans, which are re-scanned for characters needing escapes on every
single property write -- work a generated converter would have done once at compile time. That cost
lands on the subject, so every ratio above understates what codegen would actually deliver.
Two things follow, and they point in different directions.
Serialization is largely already solved, for free. System.Text.Json ships its own source
generator ([JsonSerializable] on a JsonSerializerContext), and in serialization mode it emits a
fast path shaped almost exactly like this fixture's subject: members written one at a time against
pre-encoded names. A game that annotates its hot save models gets most of the serialize column
without this package generating anything. That path has not been measured here -- Unity does not
run System.Text.Json's generator against package sources -- so treat it as documented behavior
rather than as a number.
Deserialization is where the unclaimed margin is. System.Text.Json's generator emits metadata, not a hand-written reader, for the read path; it removes reflective member access but still runs the generic object converter's property loop. The 3.0x measured here on the small record is the space a bespoke reader could claim, and it is the largest single number in the table.
- Do not reach for a custom converter on a model you serialize occasionally. A save file written once a minute does not care about 2x. The numbers above are per-call ratios on a hot loop.
-
Do reach for one on a model in a per-frame or per-message path -- network payloads, replay
frames, telemetry batches.
JsonSerializerOptions.Converterstakes yours ahead of the package's, andSerializer.CreateNormalJsonOptions()returns an instance you own and can add to. -
Cache the options instance. Both sides here reuse one; a fresh
JsonSerializerOptionsper call discards System.Text.Json's metadata caches and swamps everything this page measures. -
Do not pick
CreateFastJsonOptions()expecting this margin. It is worth having for its own reasons, but against a generated converter it moves the ratio by at most 0.15x.
- Editor Mono only. IL2CPP has not been re-measured, and its inlining of the reflection path's cached delegates differs from Mono's.
- The record is one shape. A model that is mostly strings, or mostly a dictionary, will not divide the same way -- the 256-element list row is the evidence that composition moves the answer.
- The fixture reports; it does not gate. Nothing in CI fails on these ratios.
📦 Unity Helpers | 📖 Documentation | 🐛 Issues | 📜 MIT License
- Inspector Button
- Inspector Conditional Display
- Inspector Grouping Attributes
- Inspector Inline Editor
- Inspector Overview
- Inspector Selection Attributes
- Inspector Settings
- Inspector Validation Attributes
- Utility Components
- Visual Components
- Data Structures
- Helper Utilities
- Math And Extensions
- Pooling Guide
- Random Generators
- Reflection Helpers
- Singletons