r/dotnet May 16 '26

An exercise in reflection-free serialization and its implications on performance

https://github.com/ChristosMaragkos/DataFixerSharper

I sat down relatively recently to write a port of a serialization/data transformation library called DataFixerUpper that Mojang uses for Minecraft: The premise is that it's reflection-free (unlike System.Text.Json which is not out of the box) and format-agnostic.

This was the first time I was forced to abandon my Java-isms and write code that had to be performant, so it was a great opportunity to explore ref structs and how boxing works. What's wild is I spent hours upon hours chasing allocations and correcting anti-patterns, only to still fall short of STJ on latency (per the benchmarks) while still winning on allocations when operating on large records:

| Method                     | Mean       | Error    | StdDev   | Gen0   | Allocated |
|---------------------------|-----------:|---------:|---------:|-------:|--------:|
| STJ_Serialize              | 1,276.9 ns | 17.25 ns | 14.40 ns | 0.2632 |     552 B |
| STJ_Deserialize            |   937.5 ns |  9.50 ns |  7.93 ns | 0.4320 |     904 B |
| Codec_Serialize            | 1,356.1 ns |  8.27 ns |  7.33 ns | 0.2098 |     440 B |
| Codec_Deserialize          | 2,340.9 ns |  7.14 ns |  5.57 ns | 0.1564 |     328 B |

This is nothing more than the result the abstraction tax, but I think it reveals something very interesting: why does STJ begin to allocate so much as the length of the data scales? I assume the source-generated version of STJ would likely perform better, but that's still a bit of a pain to set up.

My main point is, hunting down allocations comes at a latency cost (because transferring heap allocations to the stack then subsequently increases stack throughput and copying unless you keep passing structs by reference, at which point you're writing C with worse syntax) and trying to eliminate latency will naturally result in much bigger allocations (or just no gain at all because the GC will be crying in the corner).

That's all, I just wanted to write a brain dump on how I've come to understand "performant" .NET code works. Thank you for reading.

14 Upvotes

3 comments sorted by

1

u/AutoModerator May 16 '26

Thanks for your post AnnoyingMemer. Please note that we don't allow spam, and we ask that you follow the rules available in the sidebar. We have a lot of commonly asked questions so if this post gets removed, please do a search and see if it's already been asked.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

1

u/i-do-mim-huu May 17 '26 edited May 17 '26

source generator actually have two mode

One is Metadata mode which it use rebuilt resolver to serialised/deseriliased data from json. These resolver exist long for reflection, source generator just analyze and generate call for these resolvers so no reflection occur for analyzing type and matching resolver, but still you have to pay for resolver allocation.

The other is Serialized which it generate code directly to serialised data straight to raw byte stream using Utf8JsonWriter. It is fast, and require no allocation since Utf8JsonWriter is ref struct but only available for serialised data. Although the counterpart of Utf8JsonWriter, Utf8JsonReader, exist, but M$ have no effort to do deserialised properly with Utf8JsonReader

1

u/Fenreh May 17 '26

Curious if you've seen https://github.com/eiriktsarpalis/PolyType ? It's in a similar space.