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 // AcBinary variants
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault), new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef), 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.FastMode, SerializerAcBinaryFastMode), 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 // MemoryPack
new MemoryPackBenchmark(testData.Order, SerializerMemoryPack), new MemoryPackBenchmark(testData.Order, SerializerMemoryPack),

View File

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

View File

@ -1047,12 +1047,13 @@ public static partial class AcBinarySerializer
var hasPropertyFilter = context.HasPropertyFilter; var hasPropertyFilter = context.HasPropertyFilter;
// Source-generated fast path: bypass the entire switch/delegate loop. // Source-generated fast path: bypass the entire switch/delegate loop.
// Only when no caching features are active (no string interning, no reference handling) // Reference handling is safe: ref tracking happens in WriteObject (before WriteProperties)
// to avoid scan pass / write pass mismatch with interned strings and tracked references. // 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) if (context.UseGeneratedCode)
{ {
var generatedWriter = wrapper.GeneratedWriter; 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); generatedWriter.WriteProperties(value, context, nextDepth);
return; return;
@ -1493,7 +1494,7 @@ public static partial class AcBinarySerializer
#region Specialized Array Writers #region Specialized Array Writers
/// <summary> /// <summary>
/// Optimized array writer with specialized paths for primitive arrays. /// Optimized array writer with specialized paths for primitive collections.
/// </summary> /// </summary>
private static void WriteArray<TOutput>(IEnumerable enumerable, TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, BinarySerializationContext<TOutput> context, int depth) private static void WriteArray<TOutput>(IEnumerable enumerable, TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase where TOutput : struct, IBinaryOutputBase
@ -1505,10 +1506,10 @@ public static partial class AcBinarySerializer
var metadata = wrapper.Metadata; var metadata = wrapper.Metadata;
var elementType = metadata.CollectionElementType; var elementType = metadata.CollectionElementType;
// Optimized path for primitive arrays // Optimized path for primitive T[] and List<T> — unified via ReadOnlySpan<T>
if (elementType != null && metadata.SourceType.IsArray) if (elementType != null)
{ {
if (TryWritePrimitiveArray(enumerable, elementType, context)) if (TryWritePrimitiveCollection(enumerable, elementType, context))
return; return;
} }
@ -1542,18 +1543,123 @@ public static partial class AcBinarySerializer
} }
/// <summary> /// <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> /// </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 where TOutput : struct, IBinaryOutputBase
{ {
// String array needs context for interning — keep generic path if (ReferenceEquals(elementType, IntType))
if (ReferenceEquals(elementType, StringType) && enumerable is string[] stringArray)
{ {
context.WriteVarUInt((uint)stringArray.Length); ReadOnlySpan<int> span;
for (var i = 0; i < stringArray.Length; i++) 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) if (s == null)
context.WriteByte(BinaryTypeCode.Null); context.WriteByte(BinaryTypeCode.Null);
else else
@ -1562,89 +1668,6 @@ public static partial class AcBinarySerializer
return true; 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; return false;
} }

View File

@ -86,14 +86,6 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
public bool UseGeneratedCode { get; set; } = true; 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> /// <summary>
/// Controls how string interning is applied during serialization. /// Controls how string interning is applied during serialization.
/// None: No interning, all strings written inline. /// 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). /// All: All strings within length limits are interned (legacy behavior).
/// Default: All /// Default: All
/// </summary> /// </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> /// <summary>
/// Minimum string length to consider for interning. /// Minimum string length to consider for interning.