Refactor AcBinarySerializer to use declared type dispatch

- All serialization APIs now use the declared type (typeof(T) or explicit Type) for dispatch, not value.GetType()
- Added non-generic overloads for Serialize, SerializeChunked, and SerializeChunkedFramed with Type parameter for runtime scenarios
- ScanForDuplicates accepts optional TypeMetadataWrapper to avoid redundant lookups
- Simplified generated writer path and improved wrapper usage
- Benchmarks updated to use new API and cache serialized data
- Minor cleanups: removed unused usings, improved comments, inlined logic
- Ensures consistent, predictable, and more performant type dispatch across all serialization entry points
This commit is contained in:
Loretta 2026-05-26 07:56:25 +02:00
parent d4e4c4480a
commit cf92370bea
4 changed files with 52 additions and 98 deletions

View File

@ -29,6 +29,7 @@ public sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
}
@ -42,6 +43,7 @@ public sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
{
var bytes = AcBinarySerializer.Serialize(_order, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -31,6 +31,7 @@ public sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark wher
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
// Measure ONLY the BufferWriter infrastructure setup on the serialize side (excluding the

View File

@ -1,6 +1,5 @@
using System.Collections;
using System.Runtime.CompilerServices;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
@ -18,25 +17,20 @@ public static partial class AcBinarySerializer
/// so no dictionary lookup overhead. SGen types call generated ScanForDuplicates
/// which bypasses the entire runtime scan path.
/// </summary>
private static void ScanForDuplicates<TOutput>(object value, Type type, BinarySerializationContext<TOutput> context)
private static void ScanForDuplicates<TOutput>(object value, Type type, BinarySerializationContext<TOutput> context, TypeMetadataWrapper<BinarySerializeTypeMetadata>? wrapper = null)
where TOutput : struct, IBinaryOutputBase
{
if (!context.HasCaching)
return;
if (!context.HasCaching) return;
var wrapper = context.GetWrapper(type);
wrapper ??= context.GetWrapper(type);
// SGen path: wrapper.GeneratedWriter is cached (no registry lookup per call).
// Generated ScanForDuplicates handles HasCaching + ScanObject + SortWritePlan.
var genWriter = wrapper.GeneratedWriter;
if (genWriter != null && context.Options.UseGeneratedCode)
{
genWriter.ScanObject(value, context);
context.SortWritePlan();
return;
}
ScanValue(value, wrapper, context);
if (genWriter != null && context.Options.UseGeneratedCode) genWriter.ScanObject(value, context);
else ScanValue(value, wrapper, context);
context.SortWritePlan();
}

View File

@ -281,8 +281,7 @@ public static partial class AcBinarySerializer
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;
internal static IGeneratedBinaryWriter? TryGet(Type type) => Writers.GetValueOrDefault(type);
}
/// <summary>
@ -312,85 +311,40 @@ public static partial class AcBinarySerializer
/// Uses ArrayBinaryOutput for byte[] result path.
/// </summary>
public static byte[] Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, AcBinarySerializerOptions options)
{
if (value == null) return [BinaryTypeCode.Null];
=> Serialize(value, typeof(T), options);
var runtimeType = value.GetType();
var context = AcquireArrayOutputContext(options);
try
{
// SGen fast path: skip IQueryable/Expression check + WriteValue dispatch chain.
// If root type has a GeneratedWriter it cannot be IQueryable/Expression/primitive/collection.
if (options.UseGeneratedCode)
{
var wrapper = context.GetWrapper(runtimeType);
if (wrapper.GeneratedWriter != null)
{
ScanForDuplicates(value, runtimeType, context);
context.WriteHeader();
WriteObject(value, wrapper, context);
if (options.UseCompression != Lz4CompressionMode.None)
return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression);
return context.Output.ToArray(context._buffer, context._position);
}
}
// Full path: IQueryable/Expression conversion, primitive/collection dispatch
var actualValue = ConvertExpressionValue(value, ref runtimeType);
ScanForDuplicates(actualValue, runtimeType, context);
context.WriteHeader();
WriteValue(actualValue, runtimeType, context);
if (options.UseCompression != Lz4CompressionMode.None)
return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression);
return context.Output.ToArray(context._buffer, context._position);
}
finally
{
ReturnContext(context, options);
}
}
public static byte[] Serialize(object? value, AcBinarySerializerOptions options)
=> value == null ? [BinaryTypeCode.Null] : Serialize(value, value.GetType(), options);
/// <summary>
/// Non-generic <c>Type</c>-based <see cref="Serialize{T}(T, AcBinarySerializerOptions)"/>. For
/// runtime-typed scenarios (plugin frameworks, ASP.NET ModelBinding, MVC formatters). The
/// <paramref name="type"/> parameter is the declared-type hint; the body uses
/// <c>value.GetType()</c> for the runtime polymorphism path, identical to the generic version.
/// <paramref name="type"/> parameter is the declared type used for dispatch — identical semantics
/// to the generic version where <c>typeof(T)</c> is the dispatch type.
/// </summary>
public static byte[] Serialize(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, AcBinarySerializerOptions options)
{
if (value == null) return [BinaryTypeCode.Null];
var runtimeType = value.GetType();
var context = AcquireArrayOutputContext(options);
try
{
if (options.UseGeneratedCode)
{
var wrapper = context.GetWrapper(runtimeType);
if (wrapper.GeneratedWriter != null)
{
ScanForDuplicates(value, runtimeType, context);
context.WriteHeader();
WriteObject(value, wrapper, context);
// Full path: IQueryable/Expression conversion, primitive/collection dispatch
var actualValue = value; //ConvertExpressionValue(value, ref runtimeType);
var wrapper = context.GetWrapper(type);
if (options.UseCompression != Lz4CompressionMode.None)
return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression);
return context.Output.ToArray(context._buffer, context._position);
}
}
var actualValue = ConvertExpressionValue(value, ref runtimeType);
ScanForDuplicates(actualValue, runtimeType, context);
ScanForDuplicates(actualValue, type, context, wrapper);
context.WriteHeader();
WriteValue(actualValue, runtimeType, context);
if (options.UseCompression != Lz4CompressionMode.None)
return Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression);
return context.Output.ToArray(context._buffer, context._position);
// SGen fast path: skip IQueryable/Expression check + WriteValue dispatch chain.
// If root type has a GeneratedWriter it cannot be IQueryable/Expression/primitive/collection.
if (wrapper.GeneratedWriter != null && options.UseGeneratedCode) WriteObject(actualValue, wrapper, context);
else WriteValue(actualValue, type, context);
return options.UseCompression != Lz4CompressionMode.None
? Lz4.Compress(context.Output.AsSpan(context._buffer, context._position), options.UseCompression)
: context.Output.ToArray(context._buffer, context._position);
}
finally
{
@ -405,8 +359,10 @@ public static partial class AcBinarySerializer
internal static void ScanOnly<T>(T value, AcBinarySerializerOptions options)
{
if (value == null) return;
var runtimeType = value.GetType();
var runtimeType = typeof(T);
var context = AcquireArrayOutputContext(options);
try
{
ScanForDuplicates(value, runtimeType, context);
@ -432,8 +388,9 @@ public static partial class AcBinarySerializer
return 1;
}
var runtimeType = value.GetType();
var runtimeType = typeof(T);
var context = BinarySerializationContextPool<BufferWriterBinaryOutput>.Get(options);
context.Output = new BufferWriterBinaryOutput(writer, options.BufferWriterChunkSize);
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
@ -494,7 +451,7 @@ public static partial class AcBinarySerializer
return 1;
}
var runtimeType = value.GetType();
var runtimeType = type;
var context = BinarySerializationContextPool<BufferWriterBinaryOutput>.Get(options);
context.Output = new BufferWriterBinaryOutput(writer, options.BufferWriterChunkSize);
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
@ -568,10 +525,12 @@ public static partial class AcBinarySerializer
/// </param>
/// <returns>Total serialized bytes written.</returns>
public static int SerializeChunked<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{
if (pipe is null) throw new ArgumentNullException(nameof(pipe));
return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false);
}
=> SerializeToPipeWriterCore(value, typeof(T), pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false);
// SerializeChunked non-generic
public static int SerializeChunked(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
=> SerializeToPipeWriterCore(value, type, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: false);
/// <summary>
/// Serialize to any <see cref="System.IO.Pipelines.PipeWriter"/> as a chunked stream — pure
@ -597,7 +556,7 @@ public static partial class AcBinarySerializer
/// <param name="options">Serializer options (type wrappers, reference handling, interning, etc.).</param>
/// <returns>Total serialized bytes written.</returns>
public static int SerializeChunked<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
=> SerializeToPipeWriterCore(value, typeof(T), pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
/// <summary>
/// Non-generic <c>Type</c>-based counterpart to
@ -605,7 +564,7 @@ public static partial class AcBinarySerializer
/// For runtime-typed scenarios (MVC formatters, plugin frameworks).
/// </summary>
public static int SerializeChunked(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore<object?>(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
=> SerializeToPipeWriterCore(value,type, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: false);
/// <summary>
/// Serialize a value into a chunked stream where each chunk carries a self-describing
@ -635,10 +594,8 @@ public static partial class AcBinarySerializer
/// <param name="flushTimeout">See <see cref="SerializeChunked{T}(T, System.IO.Pipelines.Pipe, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>.</param>
/// <returns>Total serialized data bytes (excluding framing overhead).</returns>
public static int SerializeChunkedFramed<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.Pipe pipe, AcBinarySerializerOptions options, FlushPolicy flushPolicy = FlushPolicy.DoubleBuffered, TimeSpan? flushTimeout = null)
{
if (pipe is null) throw new ArgumentNullException(nameof(pipe));
return SerializeToPipeWriterCore(value, pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: true);
}
=> SerializeToPipeWriterCore(value, typeof(T), pipe.Writer, options, flushPolicy, flushTimeout, multiMessage: true);
/// <summary>
/// Serialize to any <see cref="System.IO.Pipelines.PipeWriter"/> with per-chunk frame headers
@ -650,14 +607,14 @@ public static partial class AcBinarySerializer
/// <see cref="SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.</para>
/// </summary>
public static int SerializeChunkedFramed<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
=> SerializeToPipeWriterCore(value, typeof(T), pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
/// <summary>
/// Non-generic <c>Type</c>-based counterpart to
/// <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>.
/// </summary>
public static int SerializeChunkedFramed(object? value, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options)
=> SerializeToPipeWriterCore<object?>(value, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
=> SerializeToPipeWriterCore(value, type, pipeWriter, options, FlushPolicy.DoubleBuffered, flushTimeout: null, multiMessage: true);
/// <summary>
/// Internal flush-tunable framed PipeWriter overload — used by <c>AyCode.Services</c>
@ -666,7 +623,7 @@ public static partial class AcBinarySerializer
/// <paramref name="flushPolicy"/> on a guaranteed parallel-capable writer.
/// </summary>
internal static int SerializeChunkedFramed<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
=> SerializeToPipeWriterCore(value, typeof(T), pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
/// <summary>
/// Internal legacy alias for <see cref="SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions, FlushPolicy, TimeSpan?)"/>
@ -675,14 +632,14 @@ public static partial class AcBinarySerializer
/// (framed wire format with <c>[201][UINT16][data]</c> per chunk + <c>[202]</c> end marker).
/// </summary>
internal static int Serialize<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout)
=> SerializeToPipeWriterCore(value, pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
=> SerializeToPipeWriterCore(value, typeof(T), pipeWriter, options, flushPolicy, flushTimeout, multiMessage: true);
/// <summary>
/// Common pipe-output serialization core. Same loop for both raw (<see cref="SerializeChunked{T}"/>)
/// and framed (<see cref="SerializeChunkedFramed{T}"/>) modes — the only difference flows through
/// <paramref name="multiMessage"/> into the <see cref="AsyncPipeWriterOutput"/> ctor.
/// </summary>
private static int SerializeToPipeWriterCore<T>(T value, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout, bool multiMessage)
private static int SerializeToPipeWriterCore(object? value, Type type, System.IO.Pipelines.PipeWriter pipeWriter, AcBinarySerializerOptions options, FlushPolicy flushPolicy, TimeSpan? flushTimeout, bool multiMessage)
{
if (value == null)
{
@ -707,7 +664,7 @@ public static partial class AcBinarySerializer
return 1;
}
var runtimeType = value.GetType();
var runtimeType = type;
var context = BinarySerializationContextPool<AsyncPipeWriterOutput>.Get(options);
context.Output = new AsyncPipeWriterOutput(pipeWriter, options.BufferWriterChunkSize, multiMessage, flushPolicy, flushTimeout);
@ -760,7 +717,7 @@ public static partial class AcBinarySerializer
{
if (value == null) return 1;
var runtimeType = value.GetType();
var runtimeType = typeof(T);
var context = AcquireArrayOutputContext(options);
try
@ -785,7 +742,7 @@ public static partial class AcBinarySerializer
{
if (value == null) return BinarySerializationResult.FromImmutable([BinaryTypeCode.Null]);
var runtimeType = value.GetType();
var runtimeType = typeof(T);
var context = AcquireArrayOutputContext(options);
try
@ -983,7 +940,7 @@ public static partial class AcBinarySerializer
// Only Nullable<T> can be a value type in the Object accessor path.
if (type.IsValueType)
{
if (TryWritePrimitive(value, value.GetType(), context))
if (TryWritePrimitive(value, type, context))
return;
}