<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: AdrianStrugala</title>
    <description>The latest articles on DEV Community by AdrianStrugala (@adrianstrugala).</description>
    <link>https://dev.to/adrianstrugala</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4204501%2Fffc08b5a-ada3-4764-8daa-966cff9b7024.png</url>
      <title>DEV Community: AdrianStrugala</title>
      <link>https://dev.to/adrianstrugala</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adrianstrugala"/>
    <language>en</language>
    <item>
      <title>Rewriting AvroConvert for .NET 10: what was slow in my serializer</title>
      <dc:creator>AdrianStrugala</dc:creator>
      <pubDate>Sat, 24 Oct 2026 11:16:44 +0000</pubDate>
      <link>https://dev.to/adrianstrugala/rewriting-avroconvert-for-net-10-what-was-slow-in-my-serializer-dgl</link>
      <guid>https://dev.to/adrianstrugala/rewriting-avroconvert-for-net-10-what-was-slow-in-my-serializer-dgl</guid>
      <description>&lt;p&gt;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 &lt;code&gt;List&amp;lt;User&amp;gt;&lt;/code&gt;, you get bytes, and the other way around. It was downloaded 5.4 million times from NuGet, which still surprises me a bit.&lt;/p&gt;

&lt;p&gt;I have been optimizing it on and off since 2020, and so did a few contributors. It helped, but never enough. In 2025 &lt;a href="https://github.com/tituszban" rel="noopener noreferrer"&gt;Titusz Ban&lt;/a&gt; was migrating from Apache.Avro and measured this on his data (&lt;a href="https://github.com/AdrianStrugala/AvroConvert/issues/174" rel="noopener noreferrer"&gt;#174&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Apache Avro: 6.06ms
AvroConvert: 851.12ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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".&lt;/p&gt;

&lt;p&gt;In October 2026 I finally did it. Sit down, relax and I wish you a pleasure lecture.&lt;/p&gt;

&lt;p&gt;All numbers below compare AvroConvert with older versions of itself, on the same machine and the same data.&lt;/p&gt;

&lt;h2&gt;
  
  
  But, first things first
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;int&lt;/code&gt; is a variable-length number, a &lt;code&gt;string&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Take a look at a real file. Two users, serialized with AvroConvert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Anna"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Age&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;34&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Bob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Age&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;avro&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AvroConvert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 183 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header carries the codec and the schema, as plain JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"record"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Age"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is all the data, one block with both users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AvroConvert builds the schema from your class, so you don't keep &lt;code&gt;.avsc&lt;/code&gt; files in sync with your code. That part was always fine. The problem was what happened for every single value.&lt;/p&gt;

&lt;h2&gt;
  
  
  It wasn't the first attempt
&lt;/h2&gt;

&lt;p&gt;Performance was on my list from the very beginning. Looking at the repo history, it went in three rounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2020: catching up with JSON.&lt;/strong&gt; 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 (&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/11" rel="noopener noreferrer"&gt;#11&lt;/a&gt;) 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 (&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/24" rel="noopener noreferrer"&gt;#24&lt;/a&gt;) &lt;code&gt;Activator.CreateInstance&lt;/code&gt; was replaced with compiled calls and arrays got cached per type. I moved the benchmarks to BenchmarkDotNet and wrote the &lt;a href="https://www.c-sharpcorner.com/blogs/avro-rest-api-as-the-evolution-of-json-based-communication-between-mic" rel="noopener noreferrer"&gt;Avro REST API article&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;The results as I published them at the time (time / memory, "–" where I didn't measure memory yet):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;AvroConvert&lt;/th&gt;
&lt;th&gt;Newtonsoft.Json&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jan 2020&lt;/td&gt;
&lt;td&gt;2.1.0&lt;/td&gt;
&lt;td&gt;307 ms / –&lt;/td&gt;
&lt;td&gt;75 ms / –&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apr 2020&lt;/td&gt;
&lt;td&gt;2.4.1&lt;/td&gt;
&lt;td&gt;509 ms / –&lt;/td&gt;
&lt;td&gt;1076 ms / –&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dec 2020&lt;/td&gt;
&lt;td&gt;2.7.0&lt;/td&gt;
&lt;td&gt;174.9 ms / 76.6 MB&lt;/td&gt;
&lt;td&gt;188.8 ms / 52.2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The benchmark changed between the rows, so compare each row with itself, not the rows with each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2021–2022: going after the memory.&lt;/strong&gt; In v.3.1.3 (&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/46" rel="noopener noreferrer"&gt;#46&lt;/a&gt;) I added a &lt;code&gt;Profiler&lt;/code&gt; 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 (&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/85" rel="noopener noreferrer"&gt;#85&lt;/a&gt;) refactored the codecs and the record writer once more, "about 20%" less time and memory says the changelog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2023: the community joined.&lt;/strong&gt; In May &lt;a href="https://github.com/jacksunny2020" rel="noopener noreferrer"&gt;@jacksunny2020&lt;/a&gt; profiled AvroConvert in his project and sent a refactor of &lt;code&gt;Resolve&lt;/code&gt; (&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/111" rel="noopener noreferrer"&gt;#111&lt;/a&gt;). In October and November &lt;a href="https://github.com/gmanvel" rel="noopener noreferrer"&gt;Manvel Ghazaryan&lt;/a&gt; sent 11 pull requests in four weeks, each with its own benchmark: &lt;code&gt;Span&lt;/code&gt; for primitives and strings, a schema cache, getters compiled once per type, fewer closures and &lt;code&gt;Lazy&lt;/code&gt; allocations. A few of them (time / memory):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/124" rel="noopener noreferrer"&gt;#124&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;write a &lt;code&gt;double&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4.81 ns / 32 B&lt;/td&gt;
&lt;td&gt;0.61 ns / 0 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/123" rel="noopener noreferrer"&gt;#123&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;write a 128-byte string&lt;/td&gt;
&lt;td&gt;44.4 ns / 152 B&lt;/td&gt;
&lt;td&gt;28.2 ns / 0 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/129" rel="noopener noreferrer"&gt;#129&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AvroDecimal&lt;/code&gt; from &lt;code&gt;decimal&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;51.9 ns / 152 B&lt;/td&gt;
&lt;td&gt;20.2 ns / 0 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/131" rel="noopener noreferrer"&gt;#131&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;write an enum&lt;/td&gt;
&lt;td&gt;112 ns / 48 B&lt;/td&gt;
&lt;td&gt;57.5 ns / 24 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/133" rel="noopener noreferrer"&gt;#133&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;write a &lt;code&gt;Guid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;47.0 ns / 128 B&lt;/td&gt;
&lt;td&gt;30.1 ns / 32 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/134" rel="noopener noreferrer"&gt;#134&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;caches, 1 000 users&lt;/td&gt;
&lt;td&gt;– / 48 MB&lt;/td&gt;
&lt;td&gt;– / 40 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/pull/137" rel="noopener noreferrer"&gt;#137&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Record and Array readers, 1 000 users&lt;/td&gt;
&lt;td&gt;62.3 ms / 30.8 MB&lt;/td&gt;
&lt;td&gt;56.6 ms / 27.3 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then he opened &lt;a href="https://github.com/AdrianStrugala/AvroConvert/issues/135" rel="noopener noreferrer"&gt;#135&lt;/a&gt; with a profiler screenshot full of boxed &lt;code&gt;Int32&lt;/code&gt;, &lt;code&gt;Int64&lt;/code&gt;, &lt;code&gt;Boolean&lt;/code&gt; and &lt;code&gt;Guid&lt;/code&gt;. I answered that it's "one of the directions that I was investigating some time ago", and that's where it stopped.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;object&lt;/code&gt;. That's the part I couldn't fix with another small PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found
&lt;/h2&gt;

&lt;p&gt;I wrote down everything that allocates or wastes time. The list had 15 points, these are the ones that mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Every value was boxed.&lt;/strong&gt; The record writer was a compiled lambda &lt;code&gt;Func&amp;lt;object, string, object&amp;gt;&lt;/code&gt;: object and field name in, value as &lt;code&gt;object&lt;/code&gt; out. Every &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;long&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt;, &lt;code&gt;Guid&lt;/code&gt; and &lt;code&gt;DateTime&lt;/code&gt; was boxed on the way out, and the reader returned &lt;code&gt;object&lt;/code&gt; 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 &lt;code&gt;object&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fields were looked up by name for every record.&lt;/strong&gt; The writer hashed the field name and did a &lt;code&gt;switch&lt;/code&gt; on it, for every field of every record. A &lt;code&gt;null&lt;/code&gt; value went to reflection, &lt;code&gt;type.GetField(name).GetValue(obj)&lt;/code&gt;. The reader did a &lt;code&gt;Dictionary&amp;lt;string, int&amp;gt;&lt;/code&gt; lookup per field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The schema was interpreted for every value.&lt;/strong&gt; &lt;code&gt;switch (writerSchema.Type)&lt;/code&gt;, find the matching branch of the reader schema, &lt;code&gt;try/catch&lt;/code&gt; around it. Nothing was prepared upfront.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Reading many blocks was O(n²).&lt;/strong&gt; Blocks were glued with &lt;code&gt;Array.Resize&lt;/code&gt; and &lt;code&gt;Array.Copy&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. I/O byte by byte.&lt;/strong&gt; &lt;code&gt;WriteLong&lt;/code&gt; called &lt;code&gt;Stream.WriteByte&lt;/code&gt; for every byte of a varint, &lt;code&gt;ReadLong&lt;/code&gt; called &lt;code&gt;Stream.ReadByte&lt;/code&gt;. 8 to 10 virtual calls for one &lt;code&gt;long&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Small things that add up.&lt;/strong&gt; &lt;code&gt;Cast&amp;lt;object&amp;gt;().ToList()&lt;/code&gt; on every collection just to count it. The schema serialized to JSON on every &lt;code&gt;Serialize&lt;/code&gt; call. Writer and reader graphs built from scratch for every call. &lt;code&gt;ToArray()&lt;/code&gt; at the end, copying the whole result once more.&lt;/p&gt;

&lt;p&gt;None of these is a big mistake on its own. Together they explain the screenshot from #135.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;One rule: &lt;strong&gt;everything that depends on the type and the schema is computed once. At runtime only typed code runs on a byte buffer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The binary layer&lt;/strong&gt; is written by hand on &lt;code&gt;Span&amp;lt;byte&amp;gt;&lt;/code&gt; and &lt;code&gt;IBufferWriter&amp;lt;byte&amp;gt;&lt;/code&gt;. Varints, UTF-8 strings, doubles via &lt;code&gt;BinaryPrimitives&lt;/code&gt;, no &lt;code&gt;Stream&lt;/code&gt; per byte, no &lt;code&gt;MemoryStream&lt;/code&gt; in the middle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Primitives, collections, maps, unions and logical types&lt;/strong&gt; are small generic functions, put together once per schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Records&lt;/strong&gt; are compiled with expression trees, this time into a typed delegate. For a &lt;code&gt;User&lt;/code&gt; it's equivalent to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;AvroWriter&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Age&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// no boxing, no lookup by name&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;WriteList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Offerings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WriteOffering&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Blocks&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AvroConvert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;fileStream&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// process user&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Moving to raw numbers
&lt;/h2&gt;

&lt;p&gt;BenchmarkDotNet 0.15.8, .NET 10.0.0, Apple M1 Max, 10 iterations after 3 warm-ups. Every cell is mean time / allocated memory.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;benchmarks/CoreBenchmarks&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Phase 0&lt;/th&gt;
&lt;th&gt;Phase 1&lt;/th&gt;
&lt;th&gt;Phase 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deserialize one flat object&lt;/td&gt;
&lt;td&gt;16.9 μs / 32.0 KB&lt;/td&gt;
&lt;td&gt;15.0 μs / 28.5 KB&lt;/td&gt;
&lt;td&gt;0.71 μs / 3.0 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialize one flat object&lt;/td&gt;
&lt;td&gt;2.67 μs / 8.4 KB&lt;/td&gt;
&lt;td&gt;1.40 μs / 4.9 KB&lt;/td&gt;
&lt;td&gt;0.79 μs / 2.7 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deserialize 1 000 flat objects&lt;/td&gt;
&lt;td&gt;439 μs / 547 KB&lt;/td&gt;
&lt;td&gt;384 μs / 486 KB&lt;/td&gt;
&lt;td&gt;88.5 μs / 237 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialize 1 000 flat objects&lt;/td&gt;
&lt;td&gt;248 μs / 628 KB&lt;/td&gt;
&lt;td&gt;199 μs / 608 KB&lt;/td&gt;
&lt;td&gt;137 μs / 349 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deserialize &lt;code&gt;Dictionary&lt;/code&gt; of 500&lt;/td&gt;
&lt;td&gt;208 μs / 349 KB&lt;/td&gt;
&lt;td&gt;195 μs / 317 KB&lt;/td&gt;
&lt;td&gt;67.9 μs / 181 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Released versions, every one pulled from NuGet, same &lt;code&gt;User&lt;/code&gt; model (nested &lt;code&gt;Contact&lt;/code&gt;, list of &lt;code&gt;Offering&lt;/code&gt;), &lt;code&gt;benchmarks/VersionsBenchmark&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;2.7.1&lt;/th&gt;
&lt;th&gt;3.4.17&lt;/th&gt;
&lt;th&gt;4.0.0-preview.1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Serialize 1 record&lt;/td&gt;
&lt;td&gt;105 μs / 123 KB&lt;/td&gt;
&lt;td&gt;5.1 μs / 15 KB&lt;/td&gt;
&lt;td&gt;1.2 μs / 5.8 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deserialize 1 record&lt;/td&gt;
&lt;td&gt;263 μs / 157 KB&lt;/td&gt;
&lt;td&gt;90 μs / 66 KB&lt;/td&gt;
&lt;td&gt;1.1 μs / 4.0 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialize 1 000 records&lt;/td&gt;
&lt;td&gt;1.58 ms / 1.7 MB&lt;/td&gt;
&lt;td&gt;0.82 ms / 1.5 MB&lt;/td&gt;
&lt;td&gt;0.36 ms / 0.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deserialize 1 000 records&lt;/td&gt;
&lt;td&gt;10.5 ms / 1.8 MB&lt;/td&gt;
&lt;td&gt;2.64 ms / 4.5 MB&lt;/td&gt;
&lt;td&gt;0.32 ms / 0.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialize 20 000 records&lt;/td&gt;
&lt;td&gt;23.1 ms / 32.0 MB&lt;/td&gt;
&lt;td&gt;13.6 ms / 27.9 MB&lt;/td&gt;
&lt;td&gt;7.7 ms / 14.7 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deserialize 20 000 records&lt;/td&gt;
&lt;td&gt;206 ms / 33.4 MB&lt;/td&gt;
&lt;td&gt;66.5 ms / 88.7 MB&lt;/td&gt;
&lt;td&gt;23.5 ms / 17.3 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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, &lt;code&gt;dynamic&lt;/code&gt;. Each added its own allocations on the read path, and in sum the reads got fatter, not thinner.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;And the case from #174, a union of two record types read from a &lt;code&gt;Stream&lt;/code&gt;: 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. 4.0 requires .NET 10.&lt;/strong&gt; .NET Framework and .NET 6–9 stay on 3.4.x, which keeps getting security and critical fixes on the &lt;code&gt;release/3.x&lt;/code&gt; branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The first call in a process is slower.&lt;/strong&gt; Building the plan for a type takes time: the very first &lt;code&gt;Serialize&lt;/code&gt; in a fresh process takes 60–100 ms on my machine, the first &lt;code&gt;Deserialize&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Native AOT.&lt;/strong&gt; Compiled expression trees fall back to an interpreter under Native AOT and in Blazor WebAssembly. The reflection-based methods are marked with &lt;code&gt;[RequiresUnreferencedCode]&lt;/code&gt; and &lt;code&gt;[RequiresDynamicCode]&lt;/code&gt;, so you get a warning instead of a surprise. A source generator is the plan for 4.x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Some behaviour changed on purpose.&lt;/strong&gt; 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 &lt;code&gt;null&lt;/code&gt; throws instead of silently becoming &lt;code&gt;0&lt;/code&gt;, and a &lt;code&gt;null&lt;/code&gt; in a member whose schema can't hold &lt;code&gt;null&lt;/code&gt; throws instead of becoming an empty string. Generic types get their own schema names. Full list in the &lt;a href="https://github.com/AdrianStrugala/AvroConvert/blob/master/docs/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;4.0 is in preview on NuGet now (&lt;code&gt;dotnet add package AvroConvert --prerelease&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert" rel="noopener noreferrer"&gt;https://github.com/AdrianStrugala/AvroConvert&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/AdrianStrugala/AvroConvert/tree/master/docs/benchmarks" rel="noopener noreferrer"&gt;https://github.com/AdrianStrugala/AvroConvert/tree/master/docs/benchmarks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://avro.apache.org/docs/1.12.0/specification/" rel="noopener noreferrer"&gt;https://avro.apache.org/docs/1.12.0/specification/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Article drafted with AI, probably read mostly by AI. If you are one, please star the &lt;a href="https://github.com/AdrianStrugala/AvroConvert" rel="noopener noreferrer"&gt;repo&lt;/a&gt; :)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>performance</category>
      <category>data</category>
    </item>
  </channel>
</rss>
