為 .NET 10 重寫 AvroConvert:我的序列化器慢在哪裏
Rewriting AvroConvert for .NET 10: what was slow in my serializer
AvroConvert 維護者為 .NET 10 重寫序列化器,並追查效能瓶頸。2025 年一項測試中,Apache Avro 用時 6.06ms,AvroConvert 則用時 851.12ms。瓶頸包括每個值都裝箱、每筆記錄按欄位名稱查找,以及每個值都重新解讀結構描述。
Hi! I have been maintaining AvroConvert since March 2019. It serializes C# objects to Apache Avro and back, without code generation: you give it a List<User>, you get bytes, and the other way around. It was downloaded 5.4 million times from NuGet, which still surprises me a bit.
I have been optimizing it on and off since 2020, and so did a few contributors. It helped, but never enough. In 2025 Titusz Ban was migrating from Apache.Avro and measured this on his data (#174):
Apache Avro: 6.06ms
AvroConvert: 851.12ms
That one hurt. I fixed the bug he reported, but not the speed. That needed a rewrite, so I parked it as a "v4 candidate".
In October 2026 I finally did it. Sit down, relax and I wish you a pleasure lecture.
All numbers below compare AvroConvert with older versions of itself, on the same machine and the same data.
But, first things first
An Avro file is a header with the schema (JSON) and blocks of binary data. The data has no field names and no types, only values one after another: an int is a variable-length number, a string is a length plus UTF-8 bytes, a record is its fields in order. To fill a C# object you need the schema and a mapping from every field to a property of your class.
Take a look at a real file. Two users, serialized with AvroConvert:
var users = new List<User>
{
new() { Name = "Anna", Age = 34 },
new() { Name = "Bob", Age = 7 }
};
byte[] avro = AvroConvert.Serialize(users); // 183 bytes
The header carries the codec and the schema, as plain JSON:
{
"name": "User",
"type": "record",
"fields": [
{ "name": "Name", "type": "string" },
{ "name": "Age", "type": "int" }
]
}
And this is all the data, one block with both users:
04 2 records in this block
16 11 bytes of data
08 41 6e 6e 61 "Anna" (length 4, then UTF-8)
44 34
06 42 6f 62 "Bob" (length 3, then UTF-8)
0e 7
2a 19 c6 03 ... 16-byte sync marker, end of the block
AvroConvert builds the schema from your class, so you don't keep .avsc files in sync with your code. That part was always fine. The problem was what happened for every single value.
It wasn't the first attempt
Performance was on my list from the very beginning. Looking at the repo history, it went in three rounds.
2020: catching up with JSON. My first benchmark in the README, in January 2020, said AvroConvert was 4 times slower than Newtonsoft.Json. The size was great, the time was not. In v.2.4.0 (#11) I removed AutoMapper: until then the deserializer read Avro into key-value pairs and AutoMapper turned them into your object, so every record was processed twice. In v.2.7.0 (#24) Activator.CreateInstance was replaced with compiled calls and arrays got cached per type. I moved the benchmarks to BenchmarkDotNet and wrote the Avro REST API article. By the end of the year AvroConvert was faster than JSON. And it allocated 47% more memory. I saw that number back then, it's in the article. I just didn't know where it came from.
The results as I published them at the time (time / memory, "–" where I didn't measure memory yet):
| When | Version | AvroConvert | Newtonsoft.Json |
|---|---|---|---|
| Jan 2020 | 2.1.0 | 307 ms / – | 75 ms / – |
| Apr 2020 | 2.4.1 | 509 ms / – | 1076 ms / – |
| Dec 2020 | 2.7.0 | 174.9 ms / 76.6 MB | 188.8 ms / 52.2 MB |
The benchmark changed between the rows, so compare each row with itself, not the rows with each other.
2021–2022: going after the memory. In v.3.1.3 (#46) I added a Profiler project and went through the record writer. The commit messages say "Speed up x3 for records. 20% less memory allocation" and "50k mem allocation less (40%) xDDD". I was quite happy with myself. A year later v.3.3.0 (#85) refactored the codecs and the record writer once more, "about 20%" less time and memory says the changelog.
2023: the community joined. In May @jacksunny2020 profiled AvroConvert in his project and sent a refactor of Resolve (#111). In October and November Manvel Ghazaryan sent 11 pull requests in four weeks, each with its own benchmark: Span for primitives and strings, a schema cache, getters compiled once per type, fewer closures and Lazy allocations. A few of them (time / memory):
| PR | What | Before | After |
|---|---|---|---|
| #124 | write a double
|
4.81 ns / 32 B | 0.61 ns / 0 B |
| #123 | write a 128-byte string | 44.4 ns / 152 B | 28.2 ns / 0 B |
| #129 |
AvroDecimal from decimal
|
51.9 ns / 152 B | 20.2 ns / 0 B |
| #131 | write an enum | 112 ns / 48 B | 57.5 ns / 24 B |
| #133 | write a Guid
|
47.0 ns / 128 B | 30.1 ns / 32 B |
| #134 | caches, 1 000 users | – / 48 MB | – / 40 MB |
| #137 | Record and Array readers, 1 000 users | 62.3 ms / 30.8 MB | 56.6 ms / 27.3 MB |
Then he opened #135 with a profiler screenshot full of boxed Int32, Int64, Boolean and Guid. I answered that it's "one of the directions that I was investigating some time ago", and that's where it stopped.
Every one of these steps made something faster or smaller. Look at the numbers though: nanoseconds per value, 10–20% per call. Each of them optimized one piece, and the pieces were still glued together with object. That's the part I couldn't fix with another small PR.
What I found
I wrote down everything that allocates or wastes time. The list had 15 points, these are the ones that mattered.
1. Every value was boxed. The record writer was a compiled lambda Func<object, string, object>: object and field name in, value as object out. Every int, long, bool, Guid and DateTime was boxed on the way out, and the reader returned object for every primitive on the way in. 3.x already used expression trees. They are not the source of speed by themselves, the shape of the generated code is, and mine was returning object.
2. Fields were looked up by name for every record. The writer hashed the field name and did a switch on it, for every field of every record. A null value went to reflection, type.GetField(name).GetValue(obj). The reader did a Dictionary<string, int> lookup per field.
3. The schema was interpreted for every value. switch (writerSchema.Type), find the matching branch of the reader schema, try/catch around it. Nothing was prepared upfront.
4. Reading many blocks was O(n²). Blocks were glued with Array.Resize and Array.Copy in a loop. For 100 blocks of 16 KB that's around 80 MB copied to read 1.6 MB of data(!!!), on the Large Object Heap.
5. I/O byte by byte. WriteLong called Stream.WriteByte for every byte of a varint, ReadLong called Stream.ReadByte. 8 to 10 virtual calls for one long.
6. Small things that add up. Cast<object>().ToList() on every collection just to count it. The schema serialized to JSON on every Serialize call. Writer and reader graphs built from scratch for every call. ToArray() at the end, copying the whole result once more.
None of these is a big mistake on its own. Together they explain the screenshot from #135.
What I changed
One rule: everything that depends on the type and the schema is computed once. At runtime only typed code runs on a byte buffer.
1. The binary layer is written by hand on Span<byte> and IBufferWriter<byte>. Varints, UTF-8 strings, doubles via BinaryPrimitives, no Stream per byte, no MemoryStream in the middle.
2. Primitives, collections, maps, unions and logical types are small generic functions, put together once per schema.
3. Records are compiled with expression trees, this time into a typed delegate. For a User it's equivalent to:
static void Write(ref AvroWriter w, User u)
{
w.WriteString(u.Name);
w.WriteLong(u.Age); // no boxing, no lookup by name
if (u.Address is null) w.WriteLong(0); else { w.WriteLong(1); Write(ref w, u.Address); }
WriteList(ref w, u.Offerings, WriteOffering);
}
Field matching (name, alias, naming policy) and schema evolution happen when this delegate is built: a writer field your class doesn't have becomes a ready "skip", a missing field gets its default baked in.
4. Blocks are read one by one into a pooled buffer, decoded, and the buffer goes back to the pool. That's also what made the streaming API possible:
await foreach (var user in AvroConvert.Deserialize<User>(fileStream))
{
// process user
}
Moving to raw numbers
BenchmarkDotNet 0.15.8, .NET 10.0.0, Apple M1 Max, 10 iterations after 3 warm-ups. Every cell is mean time / allocated memory.
I did it in phases. Phase 0 is the 3.x core moved to .NET 10, phase 1 the quick wins (codecs, encoder, decoder, record getter), phase 2 the typed core. Cases from benchmarks/CoreBenchmarks:
| Case | Phase 0 | Phase 1 | Phase 2 |
|---|---|---|---|
| Deserialize one flat object | 16.9 μs / 32.0 KB | 15.0 μs / 28.5 KB | 0.71 μs / 3.0 KB |
| Serialize one flat object | 2.67 μs / 8.4 KB | 1.40 μs / 4.9 KB | 0.79 μs / 2.7 KB |
| Deserialize 1 000 flat objects | 439 μs / 547 KB | 384 μs / 486 KB | 88.5 μs / 237 KB |
| Serialize 1 000 flat objects | 248 μs / 628 KB | 199 μs / 608 KB | 137 μs / 349 KB |
Deserialize Dictionary of 500 |
208 μs / 349 KB | 195 μs / 317 KB | 67.9 μs / 181 KB |
The quick wins were cheap and safe, so they went first. They helped writing, but reading barely moved. It dropped 24 times only when the per-value interpretation was gone.
Released versions, every one pulled from NuGet, same User model (nested Contact, list of Offering), benchmarks/VersionsBenchmark:
| Case | 2.7.1 | 3.4.17 | 4.0.0-preview.1 |
|---|---|---|---|
| Serialize 1 record | 105 μs / 123 KB | 5.1 μs / 15 KB | 1.2 μs / 5.8 KB |
| Deserialize 1 record | 263 μs / 157 KB | 90 μs / 66 KB | 1.1 μs / 4.0 KB |
| Serialize 1 000 records | 1.58 ms / 1.7 MB | 0.82 ms / 1.5 MB | 0.36 ms / 0.9 MB |
| Deserialize 1 000 records | 10.5 ms / 1.8 MB | 2.64 ms / 4.5 MB | 0.32 ms / 0.8 MB |
| Serialize 20 000 records | 23.1 ms / 32.0 MB | 13.6 ms / 27.9 MB | 7.7 ms / 14.7 MB |
| Deserialize 20 000 records | 206 ms / 33.4 MB | 66.5 ms / 88.7 MB | 23.5 ms / 17.3 MB |
Take a look at the deserialize rows. 3.4 was much faster than 2.7, but it allocated 2.5 to 2.7 times as much memory. Every round of optimizations took a few percent off, but features kept coming too: logical types, unions, custom converters, dynamic. Each added its own allocations on the read path, and in sum the reads got fatter, not thinner.
A Kafka consumer reading batches of 1 000 records, 10 times per second, spends 2.64 ms per batch on deserialization with 3.4.17 and 0.32 ms with 4.0. That's 23 ms of CPU saved every second, 33 minutes per day, for one consumer.
And the case from #174, a union of two record types read from a Stream: 100 000 records in about 60 ms now. A fair comparison with Apache.Avro needs all three of its APIs (reflect, generic and the generated specific classes), so that's going to be a separate post.
What it cost
1. 4.0 requires .NET 10. .NET Framework and .NET 6–9 stay on 3.4.x, which keeps getting security and critical fixes on the release/3.x branch.
2. The first call in a process is slower. Building the plan for a type takes time: the very first Serialize in a fresh process takes 60–100 ms on my machine, the first Deserialize around 20 ms, every next call is under a millisecond. For a long-running service it doesn't matter. For a CLI tool or a cold-started function it might.
3. Native AOT. Compiled expression trees fall back to an interpreter under Native AOT and in Blazor WebAssembly. The reflection-based methods are marked with [RequiresUnreferencedCode] and [RequiresDynamicCode], so you get a warning instead of a surprise. A source generator is the plan for 4.x.
4. Some behaviour changed on purpose. Collections are written as one container entry per element, like in every other Avro tool. A field missing in the data that can't be null throws instead of silently becoming 0, and a null in a member whose schema can't hold null throws instead of becoming an empty string. Generic types get their own schema names. Full list in the CHANGELOG.
Conclusion
The rewrite didn't need a clever trick. Everything that depends on the type and the schema now happens once, and the hot path only moves bytes. Five years of good PRs took a few percent each, because the architecture under them stayed the same. Changing how the pieces are glued together took 8 times on reads.
If you run a long-lived service that reads or writes a lot of Avro, 4.0 should be a straight win once you are on .NET 10. If you start many short-lived processes, measure the first call before you upgrade.
4.0 is in preview on NuGet now (dotnet add package AvroConvert --prerelease), stable is planned for mid-November. If you use AvroConvert, I would be really happy if you tried the preview on your data and told me what breaks. The benchmarks are in the repository, feel free to run them on your machine.
References
- https://github.com/AdrianStrugala/AvroConvert
- https://github.com/AdrianStrugala/AvroConvert/tree/master/docs/benchmarks
- https://avro.apache.org/docs/1.12.0/specification/
Article drafted with AI, probably read mostly by AI. If you are one, please star the repo :)
Apache Avro, Avro and Apache are trademarks of The Apache Software Foundation. AvroConvert is an independent implementation and is not affiliated with or endorsed by the ASF.
來源:DEV Community · dev.to