Generate IGeneratedBinaryWriter for fast serialization

Refactor source generator to emit per-type IGeneratedBinaryWriter classes for [AcBinarySerializable] types, with auto-registration at startup. Integrate generated writers into AcBinarySerializer for direct, delegate-free property writing, bypassing the runtime property loop when possible. Add registry, bridge methods, and update TypeMetadataWrapper for fast lookup. Expand tests to verify generated writers and round-trip correctness. This enables major serialization performance gains and reduces code size for supported types.
This commit is contained in:
Loretta 2026-02-14 20:50:38 +01:00
parent 896f720109
commit 4ef65ee501
6 changed files with 759 additions and 878 deletions

View File

@ -0,0 +1,141 @@
using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.GeneratedWriters;
/// <summary>
/// Hand-written generated binary writer for TestOrder.
/// Demonstrates the pattern that the source generator will produce.
///
/// Bypasses the runtime switch/delegate property loop:
/// - Direct obj.Property access instead of Func&lt;&gt;.Invoke()
/// - No switch dispatch per property
/// - No boxing for value types
/// - Small method (~500B native) vs 27KB WriteObject — better ICache
///
/// Properties are written in alphabetical order to match the runtime serializer.
/// Complex/Collection properties fall back to the runtime serializer via WriteValue.
/// </summary>
internal sealed class TestOrderWriter : IGeneratedBinaryWriter
{
internal static readonly TestOrderWriter Instance = new();
public void WriteProperties<TOutput>(
object value,
AcBinarySerializer.BinarySerializationContext<TOutput> context,
int depth)
where TOutput : struct, IBinaryOutputBase
{
var obj = Unsafe.As<TestOrder>(value);
var nextDepth = depth;
// Properties in alphabetical order (matching runtime serializer):
// AuditMetadata: MetadataInfo? (complex, nullable)
WriteComplexOrNull(obj.AuditMetadata, context, nextDepth);
// Category: SharedCategory? (complex, nullable)
WriteComplexOrNull(obj.Category, context, nextDepth);
// CreatedAt: DateTime
context.WriteByte(BinaryTypeCode.DateTime);
context.WriteDateTimeBits(obj.CreatedAt);
// Id: int
WriteInt32OrSkip(obj.Id, context);
// Items: List<TestOrderItem> (collection)
WriteComplexOrNull(obj.Items, context, nextDepth);
// MetadataList: List<MetadataInfo> (collection)
WriteComplexOrNull(obj.MetadataList, context, nextDepth);
// NoMergeItems: List<TestOrderItem> (collection)
WriteComplexOrNull(obj.NoMergeItems, context, nextDepth);
// OrderMetadata: MetadataInfo? (complex, nullable)
WriteComplexOrNull(obj.OrderMetadata, context, nextDepth);
// OrderNumber: string
AcBinarySerializer.WriteStringGenerated(obj.OrderNumber, context);
// Owner: SharedUser? (complex, nullable)
WriteComplexOrNull(obj.Owner, context, nextDepth);
// PaidDateUtc: DateTime? (nullable)
var paidDate = obj.PaidDateUtc;
if (paidDate.HasValue)
{
context.WriteByte(BinaryTypeCode.DateTime);
context.WriteDateTimeBits(paidDate.Value);
}
else
{
context.WriteByte(BinaryTypeCode.Null);
}
// PrimaryTag: SharedTag? (complex, nullable)
WriteComplexOrNull(obj.PrimaryTag, context, nextDepth);
// SecondaryTag: SharedTag? (complex, nullable)
WriteComplexOrNull(obj.SecondaryTag, context, nextDepth);
// Status: TestStatus (enum)
context.WriteByte(BinaryTypeCode.Enum);
var enumVal = (int)obj.Status;
if (BinaryTypeCode.TryEncodeTinyInt(enumVal, out var tinyEnum))
context.WriteByte(tinyEnum);
else
{
context.WriteByte(BinaryTypeCode.Int32);
context.WriteVarInt(enumVal);
}
// Tags: List<SharedTag> (collection)
WriteComplexOrNull(obj.Tags, context, nextDepth);
// TotalAmount: decimal
if (obj.TotalAmount == 0m)
context.WriteByte(BinaryTypeCode.PropertySkip);
else
{
context.WriteByte(BinaryTypeCode.Decimal);
context.WriteDecimalBits(obj.TotalAmount);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteInt32OrSkip<TOutput>(int value, AcBinarySerializer.BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
if (value == 0)
{
context.WriteByte(BinaryTypeCode.PropertySkip);
return;
}
if (BinaryTypeCode.TryEncodeTinyInt(value, out var tiny))
{
context.WriteByte(BinaryTypeCode.Int32);
context.WriteByte(tiny);
return;
}
context.WriteByte(BinaryTypeCode.Int32);
context.WriteVarInt(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase
{
if (value == null)
{
context.WriteByte(BinaryTypeCode.PropertySkip);
return;
}
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context, depth);
}
}

View File

@ -4,98 +4,37 @@ using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for Source Generator based serialization integration.
/// Tests for Source Generator based IGeneratedBinaryWriter integration.
/// </summary>
[TestClass]
public class GeneratedSerializerIntegrationTests
{
[TestMethod]
public void GeneratedSerializerType_Exists_ForMarkedTypes()
public void GeneratedWriterType_Exists_ForMarkedTypes()
{
// Arrange - types marked with [AcBinarySerializable]
var type = typeof(GeneratedSerializerTestModel);
var writerTypeName = $"{type.FullName}_GeneratedWriter";
var writerType = type.Assembly.GetType(writerTypeName);
// Act - find the generated serializer type directly
var generatedTypeName = $"{type.FullName}_AcBinarySerializer";
var serializerType = type.Assembly.GetType(generatedTypeName);
// Assert
Assert.IsNotNull(serializerType,
$"Generated serializer type '{generatedTypeName}' should exist for [AcBinarySerializable] marked type");
Assert.IsNotNull(writerType,
$"Generated writer type '{writerTypeName}' should exist for [AcBinarySerializable] marked type");
}
[TestMethod]
public void GeneratedSerializerType_HasCorrectMethods()
public void GeneratedWriterType_ImplementsInterface()
{
// Arrange
var type = typeof(SimpleGeneratedModel);
var writerTypeName = $"{type.FullName}_GeneratedWriter";
var writerType = type.Assembly.GetType(writerTypeName);
// Act - find the generated serializer type directly
var generatedTypeName = $"{type.FullName}_AcBinarySerializer";
var serializerType = type.Assembly.GetType(generatedTypeName);
// Assert
Assert.IsNotNull(serializerType, $"Generated serializer type '{generatedTypeName}' should exist");
var serializeMethod = serializerType.GetMethod("Serialize",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
Assert.IsNotNull(serializeMethod, "Serialize method should exist");
var deserializeMethod = serializerType.GetMethod("Deserialize",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
Assert.IsNotNull(deserializeMethod, "Deserialize method should exist");
var propertyNamesField = serializerType.GetField("PropertyNames",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
Assert.IsNotNull(propertyNamesField, "PropertyNames field should exist");
var propertyNames = propertyNamesField.GetValue(null) as string[];
Assert.IsNotNull(propertyNames, "PropertyNames should not be null");
Assert.AreEqual(3, propertyNames.Length, "SimpleGeneratedModel has 3 properties");
// Verify alphabetical order
Assert.AreEqual("Age", propertyNames[0]);
Assert.AreEqual("FirstName", propertyNames[1]);
Assert.AreEqual("LastName", propertyNames[2]);
Assert.IsNotNull(writerType, $"Generated writer type '{writerTypeName}' should exist");
Assert.IsTrue(typeof(IGeneratedBinaryWriter).IsAssignableFrom(writerType),
"Generated writer should implement IGeneratedBinaryWriter");
}
[TestMethod]
public void GeneratedSerializerPropertyNames_MatchRuntimeOrder()
public void Serialization_WorksCorrectly_WithGeneratedWriterPresent()
{
// This test verifies that the generated property order matches the runtime serializer's order
// This is critical for binary compatibility!
var type = typeof(GeneratedSerializerTestModel);
// Get generated property names
var generatedTypeName = $"{type.FullName}_AcBinarySerializer";
var serializerType = type.Assembly.GetType(generatedTypeName);
Assert.IsNotNull(serializerType);
var propertyNamesField = serializerType.GetField("PropertyNames",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
var generatedNames = propertyNamesField?.GetValue(null) as string[];
Assert.IsNotNull(generatedNames);
// Get runtime property names using the same logic as TypeMetadataBase
var runtimeProps = type.GetProperties(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite && !p.GetIndexParameters().Any())
.OrderBy(p => p.Name, StringComparer.Ordinal)
.Select(p => p.Name)
.ToArray();
// Assert they match
CollectionAssert.AreEqual(runtimeProps, generatedNames,
"Generated property names must match runtime property order for binary compatibility");
}
[TestMethod]
public void Serialization_WorksCorrectly_WithGeneratedSerializerPresent()
{
// This test ensures that regular serialization still works even when
// generated serializers are present (they are not yet integrated into the hot path)
var original = new GeneratedSerializerTestModel
{
Id = 42,
@ -108,11 +47,9 @@ public class GeneratedSerializerIntegrationTests
BigNumber = 9999999999L
};
// Serialize and deserialize using the regular path
var bytes = AcBinarySerializer.Serialize(original, AcBinarySerializerOptions.WithoutReferenceHandling);
var deserialized = AcBinaryDeserializer.Deserialize<GeneratedSerializerTestModel>(bytes);
// Assert
Assert.IsNotNull(deserialized);
Assert.AreEqual(original.Id, deserialized.Id);
Assert.AreEqual(original.Name, deserialized.Name);
@ -125,25 +62,73 @@ public class GeneratedSerializerIntegrationTests
}
[TestMethod]
public void NestedType_GeneratedSerializer_IsFound()
public void GeneratedWriter_PrimitiveClass_RoundTrip()
{
// Test that nested types (like QuickBenchmark.TestClassWithRepeatedValues)
// have their generated serializers properly named and discoverable
var original = new PrimitiveTestClass
{
IntValue = 42,
StringValue = "TestName",
BoolValue = true,
DoubleValue = 3.14,
DateTimeValue = new DateTime(2025, 1, 5, 10, 30, 0, DateTimeKind.Utc),
GuidValue = Guid.NewGuid(),
DecimalValue = 99.99m,
LongValue = 9999999999L,
FloatValue = 1.5f,
ByteValue = 42,
ShortValue = 123,
EnumValue = TestStatus.Active,
NullableInt = 7,
NullableIntNull = null
};
var type = typeof(QuickBenchmark.TestClassWithRepeatedValues);
var ns = type.Namespace ?? "";
var options = AcBinarySerializerOptions.FastMode;
var bytes = AcBinarySerializer.Serialize(original, options);
var deserialized = AcBinaryDeserializer.Deserialize<PrimitiveTestClass>(bytes, options);
// For nested types, the generated class is at namespace level with just the type name
var simpleName = $"{(string.IsNullOrEmpty(ns) ? "" : ns + ".")}{type.Name}_AcBinarySerializer";
var serializerType = type.Assembly.GetType(simpleName);
Assert.IsNotNull(deserialized);
Assert.AreEqual(original.IntValue, deserialized.IntValue);
Assert.AreEqual(original.StringValue, deserialized.StringValue);
Assert.AreEqual(original.BoolValue, deserialized.BoolValue);
Assert.AreEqual(original.DoubleValue, deserialized.DoubleValue);
Assert.AreEqual(original.DateTimeValue, deserialized.DateTimeValue);
Assert.AreEqual(original.GuidValue, deserialized.GuidValue);
Assert.AreEqual(original.DecimalValue, deserialized.DecimalValue);
Assert.AreEqual(original.LongValue, deserialized.LongValue);
Assert.AreEqual(original.NullableInt, deserialized.NullableInt);
Assert.IsNull(deserialized.NullableIntNull);
}
Assert.IsNotNull(serializerType,
$"Generated serializer for nested type should be found at '{simpleName}'");
[TestMethod]
public void GeneratedWriter_ComplexHierarchy_RoundTrip()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
// Verify it has the expected methods
Assert.IsNotNull(serializerType.GetMethod("Serialize",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static));
Assert.IsNotNull(serializerType.GetMethod("Deserialize",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static));
var order = TestDataFactory.CreateOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 2,
sharedTag: sharedTag,
sharedUser: sharedUser);
var options = AcBinarySerializerOptions.FastMode;
var bytes = AcBinarySerializer.Serialize(order, options);
var deserialized = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, options);
Assert.IsNotNull(deserialized);
Assert.AreEqual(order.Id, deserialized.Id);
Assert.AreEqual(order.OrderNumber, deserialized.OrderNumber);
Assert.AreEqual(order.Status, deserialized.Status);
Assert.AreEqual(order.TotalAmount, deserialized.TotalAmount);
Assert.AreEqual(order.Items.Count, deserialized.Items.Count);
for (var i = 0; i < order.Items.Count; i++)
{
Assert.AreEqual(order.Items[i].Id, deserialized.Items[i].Id);
Assert.AreEqual(order.Items[i].Pallets.Count, deserialized.Items[i].Pallets.Count);
}
}
}

View File

@ -218,6 +218,44 @@ public static partial class AcBinarySerializer
}
#endif
/// <summary>
/// Registers a source-generated binary writer for the specified type.
/// Once registered, WriteObject bypasses the runtime switch/delegate property loop
/// and calls the generated writer directly — eliminating Func&lt;&gt;.Invoke() overhead.
/// Call once at startup (e.g., in a static constructor or module initializer).
/// </summary>
/// <typeparam name="T">The type to register the writer for.</typeparam>
/// <param name="writer">The generated writer instance (typically a singleton).</param>
internal static void RegisterGeneratedWriter<T>(IGeneratedBinaryWriter writer)
{
ArgumentNullException.ThrowIfNull(writer);
GeneratedWriterRegistry.Register(typeof(T), writer);
}
/// <summary>
/// Registers a source-generated binary writer for the specified type.
/// </summary>
internal static void RegisterGeneratedWriter(Type type, IGeneratedBinaryWriter writer)
{
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(writer);
GeneratedWriterRegistry.Register(type, writer);
}
/// <summary>
/// Thread-safe registry for generated writers. Looked up once per TypeMetadataWrapper creation.
/// </summary>
internal static class GeneratedWriterRegistry
{
private static readonly ConcurrentDictionary<Type, IGeneratedBinaryWriter> Writers = new();
internal static void Register(Type type, IGeneratedBinaryWriter writer) => Writers[type] = writer;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static IGeneratedBinaryWriter? TryGet(Type type) =>
Writers.TryGetValue(type, out var writer) ? writer : null;
}
/// <summary>
/// Serialize object to binary with default options.
/// </summary>
@ -429,6 +467,38 @@ public static partial class AcBinarySerializer
#endregion
#region Generated Writer Bridge Methods
/// <summary>
/// Bridge for generated writers to call the runtime WriteValue for complex/collection properties.
/// Generated writers handle primitives directly; complex types delegate here.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void WriteValueGenerated<TOutput>(object? value, Type type, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase
{
WriteValue(value, type, context, depth);
}
/// <summary>
/// Bridge for generated writers to call the runtime WriteString.
/// Matches WritePropertyOrSkip String case exactly: null → PropertySkip, empty → StringEmpty.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void WriteStringGenerated<TOutput>(string? value, BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
if (string.IsNullOrEmpty(value))
{
context.WriteByte(value == null ? BinaryTypeCode.PropertySkip : BinaryTypeCode.StringEmpty);
return;
}
WriteString(value, context);
}
#endregion
#region Value Writing
private static void WriteValue<TOutput>(object? value, Type type, BinarySerializationContext<TOutput> context, int depth)
@ -1035,6 +1105,16 @@ public static partial class AcBinarySerializer
var propCount = properties.Length;
var hasPropertyFilter = context.HasPropertyFilter;
// Source-generated fast path: bypass the entire switch/delegate loop.
// Only when no caching features are active (no string interning, no reference handling)
// to avoid scan pass / write pass mismatch with interned strings and tracked references.
var generatedWriter = wrapper.GeneratedWriter;
if (generatedWriter != null && !hasPropertyFilter && !context.UseMetadata && !context.HasCaching)
{
generatedWriter.WriteProperties(value, context, nextDepth);
return;
}
if (!context.UseMetadata)
{
// Markerless loop: no extra branching per property for the common case.

View File

@ -0,0 +1,29 @@
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Binaries;
/// <summary>
/// Interface for source-generated binary property writers.
/// Implementations bypass the runtime switch/delegate property loop in WriteObject.
/// Each generated writer handles all properties of a specific type using direct obj.Property access.
///
/// Performance gains over runtime path:
/// - No Func&lt;&gt;.Invoke() delegate calls (~5-8ns/property saved)
/// - No switch dispatch (~2-3ns/property saved)
/// - No boxing for value type properties
/// - Small per-type code (~300B) vs 27KB monolithic WriteObject — better ICache behavior
/// </summary>
internal interface IGeneratedBinaryWriter
{
/// <summary>
/// Writes all properties of the given object to the serialization context.
/// Called from WriteObject when a generated writer is available for the type.
/// The implementation uses direct property access (obj.Id, obj.Name, etc.) instead of delegates.
/// </summary>
/// <param name="value">The object whose properties to write. Implementation casts to the concrete type.</param>
/// <param name="context">The serialization context (owns buffer, position, options).</param>
/// <param name="depth">Current depth in the object graph (for nested object serialization).</param>
/// <typeparam name="TOutput">Output strategy (ArrayBinaryOutput or BufferWriterBinaryOutput).</typeparam>
void WriteProperties<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase;
}

View File

@ -58,6 +58,13 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
/// </summary>
internal TypeMetadataWrapper<TMetadata>?[]? PropertyTypeWrappers;
/// <summary>
/// Source-generated binary writer for this type. Bypasses the runtime switch/delegate loop.
/// Set via AcBinarySerializer.RegisterGeneratedWriter. Null = use runtime path.
/// Checked once per object in WriteObject (not per property).
/// </summary>
internal IGeneratedBinaryWriter? GeneratedWriter;
/// <summary>
/// Options-filtered subset of metadata.ReferenceProperties for the scan pass.
/// Built lazily on first scan pass call, stable during session, cleared in ResetTracking.
@ -135,6 +142,9 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
// Pre-allocate PropertyTypeWrappers — eliminates null/resize checks from hot path
if (metadata.ComplexPropertyCount > 0)
PropertyTypeWrappers = new TypeMetadataWrapper<TMetadata>?[metadata.ComplexPropertyCount];
// Lookup generated writer from registry (once per wrapper creation, not per serialization)
GeneratedWriter = AcBinarySerializer.GeneratedWriterRegistry.TryGet(metadata.SourceType);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]