Unify and optimize primitive array/list serialization

Refactor AcBinarySerializer to use ReadOnlySpan<T> for bulk writing of primitive arrays and List<T>, replacing multiple specialized methods with a single TryWritePrimitiveCollection. This improves efficiency and reduces code duplication. Change default string interning mode to Attribute (opt-in). Update generated code path to allow reference tracking but not string interning. Adjust benchmarks to test correct serializer options. Reorder options for clarity.
This commit is contained in:
Loretta 2026-02-16 13:18:13 +01:00
parent dcd44cf705
commit 7284856dda
4 changed files with 155 additions and 132 deletions

View File

@ -212,15 +212,15 @@ public static class Program
{
// AcBinary variants
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None }, SerializerAcBinaryNoIntern),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
new AcBinaryBenchmark(testData.Order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None }, SerializerAcBinaryNoIntern),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
// MemoryPack
new MemoryPackBenchmark(testData.Order, SerializerMemoryPack),

View File

@ -558,44 +558,44 @@ public static partial class AcBinarySerializer
#region Bulk Array Writes inline
public void WriteDoubleArrayBulk(double[] array)
public void WriteDoubleArrayBulk(ReadOnlySpan<double> span)
{
EnsureCapacity(array.Length * 9);
for (var i = 0; i < array.Length; i++)
EnsureCapacity(span.Length * 9);
for (var i = 0; i < span.Length; i++)
{
_buffer[_position++] = BinaryTypeCode.Float64;
Unsafe.WriteUnaligned(ref _buffer[_position], array[i]);
Unsafe.WriteUnaligned(ref _buffer[_position], span[i]);
_position += 8;
}
}
public void WriteFloatArrayBulk(float[] array)
public void WriteFloatArrayBulk(ReadOnlySpan<float> span)
{
EnsureCapacity(array.Length * 5);
for (var i = 0; i < array.Length; i++)
EnsureCapacity(span.Length * 5);
for (var i = 0; i < span.Length; i++)
{
_buffer[_position++] = BinaryTypeCode.Float32;
Unsafe.WriteUnaligned(ref _buffer[_position], array[i]);
Unsafe.WriteUnaligned(ref _buffer[_position], span[i]);
_position += 4;
}
}
public void WriteGuidArrayBulk(Guid[] array)
public void WriteGuidArrayBulk(ReadOnlySpan<Guid> span)
{
EnsureCapacity(array.Length * 17);
for (var i = 0; i < array.Length; i++)
EnsureCapacity(span.Length * 17);
for (var i = 0; i < span.Length; i++)
{
_buffer[_position++] = BinaryTypeCode.Guid;
array[i].TryWriteBytes(_buffer.AsSpan(_position, 16));
span[i].TryWriteBytes(_buffer.AsSpan(_position, 16));
_position += 16;
}
}
public void WriteInt32ArrayOptimized(int[] array)
public void WriteInt32ArrayOptimized(ReadOnlySpan<int> span)
{
for (var i = 0; i < array.Length; i++)
for (var i = 0; i < span.Length; i++)
{
var value = array[i];
var value = span[i];
if (BinaryTypeCode.TryEncodeTinyInt(value, out var tiny))
{
WriteByte(tiny);
@ -608,11 +608,11 @@ public static partial class AcBinarySerializer
}
}
public void WriteLongArrayOptimized(long[] array)
public void WriteLongArrayOptimized(ReadOnlySpan<long> span)
{
for (var i = 0; i < array.Length; i++)
for (var i = 0; i < span.Length; i++)
{
var value = array[i];
var value = span[i];
if (value >= int.MinValue && value <= int.MaxValue)
{
var intValue = (int)value;

View File

@ -1047,12 +1047,13 @@ public static partial class AcBinarySerializer
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.
// Reference handling is safe: ref tracking happens in WriteObject (before WriteProperties)
// and child objects go through WriteValueGenerated → WriteObject → runtime ref tracking.
// String interning is NOT safe: generated code doesn't set StringInternEligible → cursor mismatch.
if (context.UseGeneratedCode)
{
var generatedWriter = wrapper.GeneratedWriter;
if (generatedWriter != null && !hasPropertyFilter && !context.UseMetadata && !context.HasCaching)
if (generatedWriter != null && !hasPropertyFilter && !context.UseMetadata && !context.UseStringInterning)
{
generatedWriter.WriteProperties(value, context, nextDepth);
return;
@ -1493,7 +1494,7 @@ public static partial class AcBinarySerializer
#region Specialized Array Writers
/// <summary>
/// Optimized array writer with specialized paths for primitive arrays.
/// Optimized array writer with specialized paths for primitive collections.
/// </summary>
private static void WriteArray<TOutput>(IEnumerable enumerable, TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase
@ -1505,10 +1506,10 @@ public static partial class AcBinarySerializer
var metadata = wrapper.Metadata;
var elementType = metadata.CollectionElementType;
// Optimized path for primitive arrays
if (elementType != null && metadata.SourceType.IsArray)
// Optimized path for primitive T[] and List<T> — unified via ReadOnlySpan<T>
if (elementType != null)
{
if (TryWritePrimitiveArray(enumerable, elementType, context))
if (TryWritePrimitiveCollection(enumerable, elementType, context))
return;
}
@ -1542,18 +1543,123 @@ public static partial class AcBinarySerializer
}
/// <summary>
/// Specialized array writer for primitive arrays using bulk memory operations.
/// Unified primitive collection writer for both T[] and List&lt;T&gt;.
/// Extracts ReadOnlySpan&lt;T&gt; from either source (zero-cost implicit conversion for arrays,
/// CollectionsMarshal.AsSpan for lists) and writes using bulk context methods with EnsureCapacity.
/// </summary>
private static bool TryWritePrimitiveArray<TOutput>(IEnumerable enumerable, Type elementType, BinarySerializationContext<TOutput> context)
private static bool TryWritePrimitiveCollection<TOutput>(IEnumerable enumerable, Type elementType, BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
// String array needs context for interning — keep generic path
if (ReferenceEquals(elementType, StringType) && enumerable is string[] stringArray)
if (ReferenceEquals(elementType, IntType))
{
context.WriteVarUInt((uint)stringArray.Length);
for (var i = 0; i < stringArray.Length; i++)
ReadOnlySpan<int> span;
if (enumerable is int[] arr) span = arr;
else if (enumerable is List<int> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
context.WriteInt32ArrayOptimized(span);
return true;
}
if (ReferenceEquals(elementType, LongType))
{
ReadOnlySpan<long> span;
if (enumerable is long[] arr) span = arr;
else if (enumerable is List<long> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
context.WriteLongArrayOptimized(span);
return true;
}
if (ReferenceEquals(elementType, DoubleType))
{
ReadOnlySpan<double> span;
if (enumerable is double[] arr) span = arr;
else if (enumerable is List<double> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
context.WriteDoubleArrayBulk(span);
return true;
}
if (ReferenceEquals(elementType, FloatType))
{
ReadOnlySpan<float> span;
if (enumerable is float[] arr) span = arr;
else if (enumerable is List<float> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
context.WriteFloatArrayBulk(span);
return true;
}
if (ReferenceEquals(elementType, BoolType))
{
ReadOnlySpan<bool> span;
if (enumerable is bool[] arr) span = arr;
else if (enumerable is List<bool> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
for (var i = 0; i < span.Length; i++)
context.WriteByte(span[i] ? BinaryTypeCode.True : BinaryTypeCode.False);
return true;
}
if (ReferenceEquals(elementType, GuidType))
{
ReadOnlySpan<Guid> span;
if (enumerable is Guid[] arr) span = arr;
else if (enumerable is List<Guid> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
context.WriteGuidArrayBulk(span);
return true;
}
if (ReferenceEquals(elementType, DecimalType))
{
ReadOnlySpan<decimal> span;
if (enumerable is decimal[] arr) span = arr;
else if (enumerable is List<decimal> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
for (var i = 0; i < span.Length; i++)
WriteDecimalUnsafe(span[i], context);
return true;
}
if (ReferenceEquals(elementType, DateTimeType))
{
ReadOnlySpan<DateTime> span;
if (enumerable is DateTime[] arr) span = arr;
else if (enumerable is List<DateTime> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
for (var i = 0; i < span.Length; i++)
WriteDateTimeUnsafe(span[i], context);
return true;
}
if (ReferenceEquals(elementType, StringType))
{
ReadOnlySpan<string?> span;
if (enumerable is string?[] arr) span = arr;
else if (enumerable is List<string?> list) span = CollectionsMarshal.AsSpan(list);
else return false;
context.WriteVarUInt((uint)span.Length);
for (var i = 0; i < span.Length; i++)
{
var s = stringArray[i];
var s = span[i];
if (s == null)
context.WriteByte(BinaryTypeCode.Null);
else
@ -1562,89 +1668,6 @@ public static partial class AcBinarySerializer
return true;
}
// All other primitive arrays — inline write through context (zero virtual dispatch)
return TryWritePrimitiveArrayCore(enumerable, elementType, context);
}
/// <summary>
/// Core primitive array writes. Uses context write methods (zero virtual dispatch).
/// </summary>
private static bool TryWritePrimitiveArrayCore<TOutput>(IEnumerable enumerable, Type elementType, BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
// Int32 array - very common case
if (ReferenceEquals(elementType, IntType) && enumerable is int[] intArray)
{
context.WriteVarUInt((uint)intArray.Length);
context.WriteInt32ArrayOptimized(intArray);
return true;
}
// Double array - bulk write as raw bytes
if (ReferenceEquals(elementType, DoubleType) && enumerable is double[] doubleArray)
{
context.WriteVarUInt((uint)doubleArray.Length);
context.WriteDoubleArrayBulk(doubleArray);
return true;
}
// Long array
if (ReferenceEquals(elementType, LongType) && enumerable is long[] longArray)
{
context.WriteVarUInt((uint)longArray.Length);
context.WriteLongArrayOptimized(longArray);
return true;
}
// Float array - bulk write as raw bytes
if (ReferenceEquals(elementType, FloatType) && enumerable is float[] floatArray)
{
context.WriteVarUInt((uint)floatArray.Length);
context.WriteFloatArrayBulk(floatArray);
return true;
}
// Bool array - pack as bytes
if (ReferenceEquals(elementType, BoolType) && enumerable is bool[] boolArray)
{
context.WriteVarUInt((uint)boolArray.Length);
for (var i = 0; i < boolArray.Length; i++)
{
context.WriteByte(boolArray[i] ? BinaryTypeCode.True : BinaryTypeCode.False);
}
return true;
}
// Guid array - bulk write
if (ReferenceEquals(elementType, GuidType) && enumerable is Guid[] guidArray)
{
context.WriteVarUInt((uint)guidArray.Length);
context.WriteGuidArrayBulk(guidArray);
return true;
}
// Decimal array
if (ReferenceEquals(elementType, DecimalType) && enumerable is decimal[] decimalArray)
{
context.WriteVarUInt((uint)decimalArray.Length);
for (var i = 0; i < decimalArray.Length; i++)
{
WriteDecimalUnsafe(decimalArray[i], context);
}
return true;
}
// DateTime array
if (ReferenceEquals(elementType, DateTimeType) && enumerable is DateTime[] dateTimeArray)
{
context.WriteVarUInt((uint)dateTimeArray.Length);
for (var i = 0; i < dateTimeArray.Length; i++)
{
WriteDateTimeUnsafe(dateTimeArray[i], context);
}
return true;
}
return false;
}

View File

@ -86,14 +86,6 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
public bool UseGeneratedCode { get; set; } = true;
/// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).
/// Throws exception if FNV-1a hash collision is detected between property names of the same type.
/// Should be enabled during development/testing, can be disabled in production for performance.
/// Default: true (safety first)
/// </summary>
public bool CheckDuplicatePropName { get; init; } = true;
/// <summary>
/// Controls how string interning is applied during serialization.
/// None: No interning, all strings written inline.
@ -101,7 +93,15 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// All: All strings within length limits are interned (legacy behavior).
/// Default: All
/// </summary>
public StringInterningMode UseStringInterning { get; set; } = StringInterningMode.All;
public StringInterningMode UseStringInterning { get; set; } = StringInterningMode.Attribute;
/// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).
/// Throws exception if FNV-1a hash collision is detected between property names of the same type.
/// Should be enabled during development/testing, can be disabled in production for performance.
/// Default: true (safety first)
/// </summary>
public bool CheckDuplicatePropName { get; init; } = true;
/// <summary>
/// Minimum string length to consider for interning.