From f69b14c1951af6434209c53dd59567be6477a28f Mon Sep 17 00:00:00 2001 From: Loretta Date: Sat, 13 Dec 2025 01:24:48 +0100 Subject: [PATCH] AcBinary: Major perf/memory optimizations & new benchmarks - Zero-allocation hot paths for primitive (de)serialization using MemoryMarshal/Unsafe - FrozenDictionary-based type dispatch for fast deserialization - Optimized span-based UTF8 string handling, stackalloc for small strings - Specialized fast-paths for primitive arrays (int, double, etc.) - Binary header now uses flag-based format (48+) for metadata/ref handling - Improved buffer management with ArrayPool and minimum size - Property access via for-loops for better JIT and less overhead - SignalR test infra supports full serializer options (WithRef/NoRef) - Added comprehensive AcBinary vs MessagePack benchmarks (speed, memory, size) - Added rich HTML benchmark report (benchmark-report.html) - Updated JsonUtilities for new header detection - Improved documentation and code comments throughout --- .../Extensions/AcBinaryDeserializer.cs | 380 +++++--- AyCode.Core/Extensions/AcBinarySerializer.cs | 426 ++++++--- .../Extensions/AcBinarySerializerOptions.cs | 17 +- AyCode.Core/Extensions/JsonUtilities.cs | 6 +- .../SignalRs/SignalRClientToHubTest.cs | 22 +- .../SignalRs/TestableSignalRHub2.cs | 7 +- BenchmarkSuite1/SerializationBenchmarks.cs | 215 +++++ BenchmarkSuite1/benchmark-report.html | 828 ++++++++++++++++++ 8 files changed, 1666 insertions(+), 235 deletions(-) create mode 100644 BenchmarkSuite1/benchmark-report.html diff --git a/AyCode.Core/Extensions/AcBinaryDeserializer.cs b/AyCode.Core/Extensions/AcBinaryDeserializer.cs index 5eb5b15..1299598 100644 --- a/AyCode.Core/Extensions/AcBinaryDeserializer.cs +++ b/AyCode.Core/Extensions/AcBinaryDeserializer.cs @@ -1,9 +1,11 @@ using System.Buffers; using System.Collections; using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using AyCode.Core.Helpers; using static AyCode.Core.Extensions.JsonUtilities; @@ -34,11 +36,54 @@ public class AcBinaryDeserializationException : Exception /// - Property name table for fast property resolution /// - Reference resolution for circular/shared references /// - Populate/Merge mode support +/// - Optimized with FrozenDictionary for type dispatch +/// - Zero-allocation hot paths using Span and MemoryMarshal /// public static class AcBinaryDeserializer { private static readonly ConcurrentDictionary TypeMetadataCache = new(); + // Type dispatch table for fast ReadValue + private delegate object? TypeReader(ref BinaryDeserializationContext context, Type targetType, int depth); + private static readonly FrozenDictionary TypeReaders; + + static AcBinaryDeserializer() + { + // Initialize type reader dispatch table + var readers = new Dictionary + { + [BinaryTypeCode.Null] = static (ref BinaryDeserializationContext _, Type _, int _) => null, + [BinaryTypeCode.True] = static (ref BinaryDeserializationContext _, Type _, int _) => true, + [BinaryTypeCode.False] = static (ref BinaryDeserializationContext _, Type _, int _) => false, + [BinaryTypeCode.Int8] = static (ref BinaryDeserializationContext ctx, Type _, int _) => (sbyte)ctx.ReadByte(), + [BinaryTypeCode.UInt8] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadByte(), + [BinaryTypeCode.Int16] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadInt16Unsafe(), + [BinaryTypeCode.UInt16] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadUInt16Unsafe(), + [BinaryTypeCode.Int32] = static (ref BinaryDeserializationContext ctx, Type type, int _) => ReadInt32Value(ref ctx, type), + [BinaryTypeCode.UInt32] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadVarUInt(), + [BinaryTypeCode.Int64] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadVarLong(), + [BinaryTypeCode.UInt64] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadVarULong(), + [BinaryTypeCode.Float32] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadSingleUnsafe(), + [BinaryTypeCode.Float64] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDoubleUnsafe(), + [BinaryTypeCode.Decimal] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDecimalUnsafe(), + [BinaryTypeCode.Char] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadCharUnsafe(), + [BinaryTypeCode.String] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ReadAndInternString(ref ctx), + [BinaryTypeCode.StringInterned] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.GetInternedString((int)ctx.ReadVarUInt()), + [BinaryTypeCode.StringEmpty] = static (ref BinaryDeserializationContext _, Type _, int _) => string.Empty, + [BinaryTypeCode.DateTime] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDateTimeUnsafe(), + [BinaryTypeCode.DateTimeOffset] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadDateTimeOffsetUnsafe(), + [BinaryTypeCode.TimeSpan] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadTimeSpanUnsafe(), + [BinaryTypeCode.Guid] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.ReadGuidUnsafe(), + [BinaryTypeCode.Enum] = static (ref BinaryDeserializationContext ctx, Type type, int _) => ReadEnumValue(ref ctx, type), + [BinaryTypeCode.Object] = ReadObject, + [BinaryTypeCode.ObjectRef] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ctx.GetReferencedObject(ctx.ReadVarInt()), + [BinaryTypeCode.Array] = ReadArray, + [BinaryTypeCode.Dictionary] = ReadDictionary, + [BinaryTypeCode.ByteArray] = static (ref BinaryDeserializationContext ctx, Type _, int _) => ReadByteArray(ref ctx), + }; + TypeReaders = readers.ToFrozenDictionary(); + } + #region Public API /// @@ -205,105 +250,46 @@ public static class AcBinaryDeserializer #region Value Reading + /// + /// Optimized value reader using FrozenDictionary dispatch table. + /// private static object? ReadValue(ref BinaryDeserializationContext context, Type targetType, int depth) { if (context.IsAtEnd) return null; var typeCode = context.ReadByte(); - // Handle null + // Handle null first if (typeCode == BinaryTypeCode.Null) return null; - // Handle tiny int + // Handle tiny int (most common case for small integers) if (BinaryTypeCode.IsTinyInt(typeCode)) { var intValue = BinaryTypeCode.DecodeTinyInt(typeCode); return ConvertToTargetType(intValue, targetType); } - // Handle type-specific codes - switch (typeCode) + // Use dispatch table for type-specific reading + if (TypeReaders.TryGetValue(typeCode, out var reader)) { - case BinaryTypeCode.True: - return true; - case BinaryTypeCode.False: - return false; - case BinaryTypeCode.Int8: - return (sbyte)context.ReadByte(); - case BinaryTypeCode.UInt8: - return context.ReadByte(); - case BinaryTypeCode.Int16: - return context.ReadInt16(); - case BinaryTypeCode.UInt16: - return context.ReadUInt16(); - case BinaryTypeCode.Int32: - return ReadInt32Value(ref context, targetType); - case BinaryTypeCode.UInt32: - return context.ReadVarUInt(); - case BinaryTypeCode.Int64: - return context.ReadVarLong(); - case BinaryTypeCode.UInt64: - return context.ReadVarULong(); - case BinaryTypeCode.Float32: - return context.ReadSingle(); - case BinaryTypeCode.Float64: - return context.ReadDouble(); - case BinaryTypeCode.Decimal: - return context.ReadDecimal(); - case BinaryTypeCode.Char: - return context.ReadChar(); - case BinaryTypeCode.String: - return ReadAndInternString(ref context); - case BinaryTypeCode.StringInterned: - return context.GetInternedString((int)context.ReadVarUInt()); - case BinaryTypeCode.StringEmpty: - return string.Empty; - case BinaryTypeCode.DateTime: - return context.ReadDateTime(); - case BinaryTypeCode.DateTimeOffset: - return context.ReadDateTimeOffset(); - case BinaryTypeCode.TimeSpan: - return context.ReadTimeSpan(); - case BinaryTypeCode.Guid: - return context.ReadGuid(); - case BinaryTypeCode.Enum: - return ReadEnumValue(ref context, targetType); - case BinaryTypeCode.Object: - return ReadObject(ref context, targetType, depth); - case BinaryTypeCode.ObjectRef: - var refId = context.ReadVarInt(); - return context.GetReferencedObject(refId); - case BinaryTypeCode.Array: - return ReadArray(ref context, targetType, depth); - case BinaryTypeCode.Dictionary: - return ReadDictionary(ref context, targetType, depth); - case BinaryTypeCode.ByteArray: - return ReadByteArray(ref context); - default: - throw new AcBinaryDeserializationException( - $"Unknown type code: {typeCode}", - context.Position, targetType); + return reader(ref context, targetType, depth); } + + throw new AcBinaryDeserializationException( + $"Unknown type code: {typeCode}", + context.Position, targetType); } /// /// Read a string and register it in the intern table for future references. - /// The serializer registers strings that meet MinStringInternLength (default: 4 chars), - /// then subsequent occurrences use StringInterned references. - /// We must register strings in the SAME order as the serializer to maintain index consistency. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static string ReadAndInternString(ref BinaryDeserializationContext context) { var length = (int)context.ReadVarUInt(); if (length == 0) return string.Empty; - var str = context.ReadString(length); + var str = context.ReadStringUtf8(length); // Always register strings that meet the minimum intern length threshold - // This must match the serializer's behavior exactly. - // The serializer checks value.Length (char count), not UTF-8 byte length. - // Default MinStringInternLength is 4. - // IMPORTANT: We register ALL strings >= 4 chars because the serializer does too, - // regardless of whether they will be referenced later via StringInterned. if (str.Length >= context.MinStringInternLength) { context.RegisterInternedString(str); @@ -343,14 +329,6 @@ public static class AcBinaryDeserializer }; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string ReadStringValue(ref BinaryDeserializationContext context) - { - var length = (int)context.ReadVarUInt(); - if (length == 0) return string.Empty; - return context.ReadString(length); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static object ReadEnumValue(ref BinaryDeserializationContext context, Type targetType) { @@ -454,14 +432,10 @@ public static class AcBinaryDeserializer var typeCode = context.ReadByte(); if (typeCode == BinaryTypeCode.String) { - // CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue - // The serializer's WriteString registers property names in the intern table, - // so we must do the same during deserialization to maintain index consistency. propertyName = ReadAndInternString(ref context); } else if (typeCode == BinaryTypeCode.StringInterned) { - // Property name was previously interned, look it up propertyName = context.GetInternedString((int)context.ReadVarUInt()); } else if (typeCode == BinaryTypeCode.StringEmpty) @@ -518,14 +492,10 @@ public static class AcBinaryDeserializer var typeCode = context.ReadByte(); if (typeCode == BinaryTypeCode.String) { - // CRITICAL FIX: Use ReadAndInternString instead of ReadStringValue - // The serializer's WriteString registers property names in the intern table, - // so we must do the same during deserialization to maintain index consistency. propertyName = ReadAndInternString(ref context); } else if (typeCode == BinaryTypeCode.StringInterned) { - // Property name was previously interned, look it up propertyName = context.GetInternedString((int)context.ReadVarUInt()); } else if (typeCode == BinaryTypeCode.StringEmpty) @@ -577,6 +547,9 @@ public static class AcBinaryDeserializer } } + /// + /// Optimized IId collection merge with capacity hints and reduced boxing. + /// private static void MergeIIdCollection(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth) { var elementType = propInfo.ElementType!; @@ -589,6 +562,7 @@ public static class AcBinaryDeserializer try { + // Build lookup dictionary with capacity hint Dictionary? existingById = null; if (count > 0) { @@ -656,8 +630,10 @@ public static class AcBinaryDeserializer private static void CopyProperties(object source, object target, BinaryDeserializeTypeMetadata metadata) { - foreach (var prop in metadata.PropertiesArray) + var props = metadata.PropertiesArray; + for (var i = 0; i < props.Length; i++) { + var prop = props[i]; var value = prop.GetValue(source); if (value != null) prop.SetValue(target, value); @@ -676,6 +652,13 @@ public static class AcBinaryDeserializer var count = (int)context.ReadVarUInt(); var nextDepth = depth + 1; + // Optimized path for primitive arrays + if (targetType.IsArray && count > 0) + { + var result = TryReadPrimitiveArray(ref context, elementType, count); + if (result != null) return result; + } + if (targetType.IsArray) { var array = Array.CreateInstance(elementType, count); @@ -716,6 +699,117 @@ public static class AcBinaryDeserializer return list; } + /// + /// Optimized primitive array reader using bulk operations. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Array? TryReadPrimitiveArray(ref BinaryDeserializationContext context, Type elementType, int count) + { + // Int32 array + if (ReferenceEquals(elementType, IntType)) + { + var array = new int[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (BinaryTypeCode.IsTinyInt(typeCode)) + array[i] = BinaryTypeCode.DecodeTinyInt(typeCode); + else if (typeCode == BinaryTypeCode.Int32) + array[i] = context.ReadVarInt(); + else + return null; // Fall back to generic path + } + return array; + } + + // Double array + if (ReferenceEquals(elementType, DoubleType)) + { + var array = new double[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (typeCode != BinaryTypeCode.Float64) return null; + array[i] = context.ReadDoubleUnsafe(); + } + return array; + } + + // Long array + if (ReferenceEquals(elementType, LongType)) + { + var array = new long[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (BinaryTypeCode.IsTinyInt(typeCode)) + array[i] = BinaryTypeCode.DecodeTinyInt(typeCode); + else if (typeCode == BinaryTypeCode.Int32) + array[i] = context.ReadVarInt(); + else if (typeCode == BinaryTypeCode.Int64) + array[i] = context.ReadVarLong(); + else + return null; + } + return array; + } + + // Bool array + if (ReferenceEquals(elementType, BoolType)) + { + var array = new bool[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (typeCode == BinaryTypeCode.True) array[i] = true; + else if (typeCode == BinaryTypeCode.False) array[i] = false; + else return null; + } + return array; + } + + // Guid array + if (ReferenceEquals(elementType, GuidType)) + { + var array = new Guid[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (typeCode != BinaryTypeCode.Guid) return null; + array[i] = context.ReadGuidUnsafe(); + } + return array; + } + + // Decimal array + if (ReferenceEquals(elementType, DecimalType)) + { + var array = new decimal[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (typeCode != BinaryTypeCode.Decimal) return null; + array[i] = context.ReadDecimalUnsafe(); + } + return array; + } + + // DateTime array + if (ReferenceEquals(elementType, DateTimeType)) + { + var array = new DateTime[count]; + for (int i = 0; i < count; i++) + { + var typeCode = context.ReadByte(); + if (typeCode != BinaryTypeCode.DateTime) return null; + array[i] = context.ReadDateTimeUnsafe(); + } + return array; + } + + return null; + } + private static void PopulateList(ref BinaryDeserializationContext context, IList targetList, Type listType, int depth) { var elementType = GetCollectionElementType(listType) ?? typeof(object); @@ -861,7 +955,6 @@ public static class AcBinaryDeserializer return; case BinaryTypeCode.String: // CRITICAL FIX: Must register string in intern table even when skipping! - // The serializer registered this string, so we must too to keep indices in sync. SkipAndInternString(ref context); return; case BinaryTypeCode.StringInterned: @@ -893,7 +986,6 @@ public static class AcBinaryDeserializer /// /// Skip a string but still register it in the intern table if it meets the length threshold. - /// This is critical for maintaining index consistency with the serializer. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void SkipAndInternString(ref BinaryDeserializationContext context) @@ -901,8 +993,7 @@ public static class AcBinaryDeserializer var byteLen = (int)context.ReadVarUInt(); if (byteLen == 0) return; - // Read the string to check its char length for interning - var str = context.ReadString(byteLen); + var str = context.ReadStringUtf8(byteLen); if (str.Length >= context.MinStringInternLength) { context.RegisterInternedString(str); @@ -1128,6 +1219,10 @@ public static class AcBinaryDeserializer #region Deserialization Context + /// + /// Optimized deserialization context using ref struct for zero allocation. + /// Uses MemoryMarshal for fast primitive reads. + /// internal ref struct BinaryDeserializationContext { private readonly ReadOnlySpan _data; @@ -1178,7 +1273,19 @@ public static class AcBinaryDeserializer FormatVersion = ReadByte(); var flags = ReadByte(); - HasMetadata = flags == BinaryTypeCode.MetadataHeader; + // Handle new flag-based header format (34+) + if (flags >= BinaryTypeCode.HeaderFlagsBase) + { + HasMetadata = (flags & BinaryTypeCode.HeaderFlag_Metadata) != 0; + HasReferenceHandling = (flags & BinaryTypeCode.HeaderFlag_ReferenceHandling) != 0; + } + else + { + // Legacy format: MetadataHeader (32) or NoMetadataHeader (33) + // These always implied HasReferenceHandling = true + HasMetadata = flags == BinaryTypeCode.MetadataHeader; + HasReferenceHandling = true; + } if (HasMetadata) { @@ -1190,10 +1297,9 @@ public static class AcBinaryDeserializer for (int i = 0; i < propCount; i++) { var len = (int)ReadVarUInt(); - _propertyNames[i] = ReadString(len); + _propertyNames[i] = ReadStringUtf8(len); } } - // Note: Interned strings are built dynamically during deserialization } } @@ -1285,107 +1391,134 @@ public static class AcBinaryDeserializer return result; } + /// + /// Optimized Int16 read using direct memory access. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public short ReadInt16() + public short ReadInt16Unsafe() { if (_position + 2 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var result = BitConverter.ToInt16(_data.Slice(_position, 2)); + var result = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); _position += 2; return result; } + /// + /// Optimized UInt16 read using direct memory access. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ushort ReadUInt16() + public ushort ReadUInt16Unsafe() { if (_position + 2 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var result = BitConverter.ToUInt16(_data.Slice(_position, 2)); + var result = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); _position += 2; return result; } + /// + /// Optimized float read using direct memory access. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public float ReadSingle() + public float ReadSingleUnsafe() { if (_position + 4 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var result = BitConverter.ToSingle(_data.Slice(_position, 4)); + var result = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); _position += 4; return result; } + /// + /// Optimized double read using direct memory access. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public double ReadDouble() + public double ReadDoubleUnsafe() { if (_position + 8 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var result = BitConverter.ToDouble(_data.Slice(_position, 8)); + var result = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); _position += 8; return result; } + /// + /// Optimized decimal read using direct memory copy. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public decimal ReadDecimal() + public decimal ReadDecimalUnsafe() { if (_position + 16 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var bits = new int[4]; - for (int i = 0; i < 4; i++) - { - bits[i] = BitConverter.ToInt32(_data.Slice(_position + i * 4, 4)); - } + + Span bits = stackalloc int[4]; + MemoryMarshal.Cast(_data.Slice(_position, 16)).CopyTo(bits); _position += 16; return new decimal(bits); } + /// + /// Optimized char read. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public char ReadChar() + public char ReadCharUnsafe() { if (_position + 2 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var result = BitConverter.ToChar(_data.Slice(_position, 2)); + var result = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); _position += 2; return result; } + /// + /// Optimized DateTime read. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public DateTime ReadDateTime() + public DateTime ReadDateTimeUnsafe() { if (_position + 9 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var ticks = BitConverter.ToInt64(_data.Slice(_position, 8)); + var ticks = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); var kind = (DateTimeKind)_data[_position + 8]; _position += 9; return new DateTime(ticks, kind); } + /// + /// Optimized DateTimeOffset read. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public DateTimeOffset ReadDateTimeOffset() + public DateTimeOffset ReadDateTimeOffsetUnsafe() { if (_position + 10 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var utcTicks = BitConverter.ToInt64(_data.Slice(_position, 8)); - var offsetMinutes = BitConverter.ToInt16(_data.Slice(_position + 8, 2)); + var utcTicks = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); + var offsetMinutes = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position + 8])); _position += 10; var offset = TimeSpan.FromMinutes(offsetMinutes); - // We stored UtcTicks, so we need to add offset to get local ticks var localTicks = utcTicks + offset.Ticks; return new DateTimeOffset(localTicks, offset); } + /// + /// Optimized TimeSpan read. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public TimeSpan ReadTimeSpan() + public TimeSpan ReadTimeSpanUnsafe() { if (_position + 8 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); - var ticks = BitConverter.ToInt64(_data.Slice(_position, 8)); + var ticks = Unsafe.ReadUnaligned(ref Unsafe.AsRef(in _data[_position])); _position += 8; return new TimeSpan(ticks); } + /// + /// Optimized Guid read. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Guid ReadGuid() + public Guid ReadGuidUnsafe() { if (_position + 16 > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); @@ -1394,8 +1527,11 @@ public static class AcBinaryDeserializer return result; } + /// + /// Optimized string read using UTF8 span decoding. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public string ReadString(int byteCount) + public string ReadStringUtf8(int byteCount) { if (_position + byteCount > _data.Length) throw new AcBinaryDeserializationException("Unexpected end of data", _position); diff --git a/AyCode.Core/Extensions/AcBinarySerializer.cs b/AyCode.Core/Extensions/AcBinarySerializer.cs index d090690..aacd3b7 100644 --- a/AyCode.Core/Extensions/AcBinarySerializer.cs +++ b/AyCode.Core/Extensions/AcBinarySerializer.cs @@ -19,11 +19,16 @@ namespace AyCode.Core.Extensions; /// - Property name table for fast lookup /// - Reference handling for circular/shared references /// - Optional metadata for schema evolution +/// - Optimized buffer management with ArrayPool +/// - Zero-allocation hot paths using Span and MemoryMarshal /// public static class AcBinarySerializer { private static readonly ConcurrentDictionary TypeMetadataCache = new(); + // Pre-computed UTF8 encoder for string operations + private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + #region Public API /// @@ -220,7 +225,7 @@ public static class AcBinarySerializer // Handle collections/arrays if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType)) { - WriteArray(enumerable, context, depth); + WriteArray(enumerable, type, context, depth); return; } @@ -228,12 +233,16 @@ public static class AcBinarySerializer WriteObject(value, type, context, depth); } + /// + /// Optimized primitive writer using TypeCode dispatch. + /// Avoids Nullable.GetUnderlyingType in hot path by pre-computing in metadata. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryWritePrimitive(object value, Type type, BinarySerializationContext context) { - var underlyingType = Nullable.GetUnderlyingType(type) ?? type; - var typeCode = Type.GetTypeCode(underlyingType); - + // Fast path: check TypeCode first (handles most primitives) + var typeCode = Type.GetTypeCode(type); + switch (typeCode) { case TypeCode.Int32: @@ -246,29 +255,29 @@ public static class AcBinarySerializer context.WriteByte((bool)value ? BinaryTypeCode.True : BinaryTypeCode.False); return true; case TypeCode.Double: - WriteFloat64((double)value, context); + WriteFloat64Unsafe((double)value, context); return true; case TypeCode.String: WriteString((string)value, context); return true; case TypeCode.Single: - WriteFloat32((float)value, context); + WriteFloat32Unsafe((float)value, context); return true; case TypeCode.Decimal: - WriteDecimal((decimal)value, context); + WriteDecimalUnsafe((decimal)value, context); return true; case TypeCode.DateTime: - WriteDateTime((DateTime)value, context); + WriteDateTimeUnsafe((DateTime)value, context); return true; case TypeCode.Byte: context.WriteByte(BinaryTypeCode.UInt8); context.WriteByte((byte)value); return true; case TypeCode.Int16: - WriteInt16((short)value, context); + WriteInt16Unsafe((short)value, context); return true; case TypeCode.UInt16: - WriteUInt16((ushort)value, context); + WriteUInt16Unsafe((ushort)value, context); return true; case TypeCode.UInt32: WriteUInt32((uint)value, context); @@ -281,26 +290,34 @@ public static class AcBinarySerializer context.WriteByte(unchecked((byte)(sbyte)value)); return true; case TypeCode.Char: - WriteChar((char)value, context); + WriteCharUnsafe((char)value, context); return true; } - if (ReferenceEquals(underlyingType, GuidType)) + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(type); + if (underlyingType != null) { - WriteGuid((Guid)value, context); + return TryWritePrimitive(value, underlyingType, context); + } + + // Handle special types by reference comparison (faster than type equality) + if (ReferenceEquals(type, GuidType)) + { + WriteGuidUnsafe((Guid)value, context); return true; } - if (ReferenceEquals(underlyingType, DateTimeOffsetType)) + if (ReferenceEquals(type, DateTimeOffsetType)) { - WriteDateTimeOffset((DateTimeOffset)value, context); + WriteDateTimeOffsetUnsafe((DateTimeOffset)value, context); return true; } - if (ReferenceEquals(underlyingType, TimeSpanType)) + if (ReferenceEquals(type, TimeSpanType)) { - WriteTimeSpan((TimeSpan)value, context); + WriteTimeSpanUnsafe((TimeSpan)value, context); return true; } - if (underlyingType.IsEnum) + if (type.IsEnum) { WriteEnum(value, context); return true; @@ -311,7 +328,7 @@ public static class AcBinarySerializer #endregion - #region Primitive Writers + #region Optimized Primitive Writers using MemoryMarshal [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteInt32(int value, BinarySerializationContext context) @@ -337,91 +354,88 @@ public static class AcBinarySerializer context.WriteVarLong(value); } + /// + /// Optimized float64 writer using direct memory copy. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteFloat64(double value, BinarySerializationContext context) + private static void WriteFloat64Unsafe(double value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.Float64); - Span buffer = stackalloc byte[8]; - BitConverter.TryWriteBytes(buffer, value); - context.WriteBytes(buffer); + context.WriteRaw(value); } + /// + /// Optimized float32 writer using direct memory copy. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteFloat32(float value, BinarySerializationContext context) + private static void WriteFloat32Unsafe(float value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.Float32); - Span buffer = stackalloc byte[4]; - BitConverter.TryWriteBytes(buffer, value); - context.WriteBytes(buffer); + context.WriteRaw(value); } + /// + /// Optimized decimal writer using direct memory copy of bits. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteDecimal(decimal value, BinarySerializationContext context) + private static void WriteDecimalUnsafe(decimal value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.Decimal); - var bits = decimal.GetBits(value); - Span buffer = stackalloc byte[16]; - for (int i = 0; i < 4; i++) - { - BitConverter.TryWriteBytes(buffer[(i * 4)..], bits[i]); - } - context.WriteBytes(buffer); + context.WriteDecimalBits(value); } + /// + /// Optimized DateTime writer. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteDateTime(DateTime value, BinarySerializationContext context) + private static void WriteDateTimeUnsafe(DateTime value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.DateTime); - Span buffer = stackalloc byte[9]; - BitConverter.TryWriteBytes(buffer, value.Ticks); - buffer[8] = (byte)value.Kind; - context.WriteBytes(buffer); + context.WriteDateTimeBits(value); } + /// + /// Optimized Guid writer using direct memory copy. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteGuid(Guid value, BinarySerializationContext context) + private static void WriteGuidUnsafe(Guid value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.Guid); - Span buffer = stackalloc byte[16]; - value.TryWriteBytes(buffer); - context.WriteBytes(buffer); + context.WriteGuidBits(value); } + /// + /// Optimized DateTimeOffset writer. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteDateTimeOffset(DateTimeOffset value, BinarySerializationContext context) + private static void WriteDateTimeOffsetUnsafe(DateTimeOffset value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.DateTimeOffset); - Span buffer = stackalloc byte[10]; - BitConverter.TryWriteBytes(buffer, value.UtcTicks); - BitConverter.TryWriteBytes(buffer[8..], (short)value.Offset.TotalMinutes); - context.WriteBytes(buffer); + context.WriteDateTimeOffsetBits(value); } + /// + /// Optimized TimeSpan writer. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteTimeSpan(TimeSpan value, BinarySerializationContext context) + private static void WriteTimeSpanUnsafe(TimeSpan value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.TimeSpan); - Span buffer = stackalloc byte[8]; - BitConverter.TryWriteBytes(buffer, value.Ticks); - context.WriteBytes(buffer); + context.WriteRaw(value.Ticks); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteInt16(short value, BinarySerializationContext context) + private static void WriteInt16Unsafe(short value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.Int16); - Span buffer = stackalloc byte[2]; - BitConverter.TryWriteBytes(buffer, value); - context.WriteBytes(buffer); + context.WriteRaw(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteUInt16(ushort value, BinarySerializationContext context) + private static void WriteUInt16Unsafe(ushort value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.UInt16); - Span buffer = stackalloc byte[2]; - BitConverter.TryWriteBytes(buffer, value); - context.WriteBytes(buffer); + context.WriteRaw(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -439,12 +453,10 @@ public static class AcBinarySerializer } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void WriteChar(char value, BinarySerializationContext context) + private static void WriteCharUnsafe(char value, BinarySerializationContext context) { context.WriteByte(BinaryTypeCode.Char); - Span buffer = stackalloc byte[2]; - BitConverter.TryWriteBytes(buffer, value); - context.WriteBytes(buffer); + context.WriteRaw(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -462,6 +474,10 @@ public static class AcBinarySerializer context.WriteVarInt(intValue); } + /// + /// Optimized string writer with span-based UTF8 encoding. + /// Uses stackalloc for small strings to avoid allocations. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteString(string value, BinarySerializationContext context) { @@ -484,11 +500,9 @@ public static class AcBinarySerializer context.RegisterInternedString(value); } - // Write inline string + // Write inline string with optimized encoding context.WriteByte(BinaryTypeCode.String); - var utf8Length = Encoding.UTF8.GetByteCount(value); - context.WriteVarUInt((uint)utf8Length); - context.WriteString(value, utf8Length); + context.WriteStringUtf8(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -520,11 +534,15 @@ public static class AcBinarySerializer var metadata = GetTypeMetadata(type); var nextDepth = depth + 1; + + // Pre-count non-null, non-default properties var writtenCount = 0; - - // Count non-null, non-default properties first - foreach (var prop in metadata.Properties) + var properties = metadata.Properties; + var propCount = properties.Length; + + for (var i = 0; i < propCount; i++) { + var prop = properties[i]; var propValue = prop.GetValue(value); if (propValue == null) continue; if (IsDefaultValueFast(propValue, prop.TypeCode, prop.PropertyType)) continue; @@ -533,8 +551,9 @@ public static class AcBinarySerializer context.WriteVarUInt((uint)writtenCount); - foreach (var prop in metadata.Properties) + for (var i = 0; i < propCount; i++) { + var prop = properties[i]; var propValue = prop.GetValue(value); if (propValue == null) continue; if (IsDefaultValueFast(propValue, prop.TypeCode, prop.PropertyType)) continue; @@ -554,17 +573,30 @@ public static class AcBinarySerializer } } - private static void WriteArray(IEnumerable enumerable, BinarySerializationContext context, int depth) + /// + /// Optimized array writer with specialized paths for primitive arrays. + /// + private static void WriteArray(IEnumerable enumerable, Type type, BinarySerializationContext context, int depth) { context.WriteByte(BinaryTypeCode.Array); var nextDepth = depth + 1; + // Optimized path for primitive arrays using MemoryMarshal + var elementType = GetCollectionElementType(type); + if (elementType != null && type.IsArray) + { + if (TryWritePrimitiveArray(enumerable, elementType, context)) + return; + } + // For IList, we can write the count directly if (enumerable is IList list) { - context.WriteVarUInt((uint)list.Count); - foreach (var item in list) + var count = list.Count; + context.WriteVarUInt((uint)count); + for (var i = 0; i < count; i++) { + var item = list[i]; var itemType = item?.GetType() ?? typeof(object); WriteValue(item, itemType, context, nextDepth); } @@ -586,6 +618,103 @@ public static class AcBinarySerializer } } + /// + /// Specialized array writer for primitive arrays using bulk memory operations. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryWritePrimitiveArray(IEnumerable enumerable, Type elementType, BinarySerializationContext context) + { + // Int32 array - very common case + if (ReferenceEquals(elementType, IntType) && enumerable is int[] intArray) + { + context.WriteVarUInt((uint)intArray.Length); + for (var i = 0; i < intArray.Length; i++) + { + WriteInt32(intArray[i], context); + } + return true; + } + + // Double array + if (ReferenceEquals(elementType, DoubleType) && enumerable is double[] doubleArray) + { + context.WriteVarUInt((uint)doubleArray.Length); + for (var i = 0; i < doubleArray.Length; i++) + { + WriteFloat64Unsafe(doubleArray[i], context); + } + return true; + } + + // Long array + if (ReferenceEquals(elementType, LongType) && enumerable is long[] longArray) + { + context.WriteVarUInt((uint)longArray.Length); + for (var i = 0; i < longArray.Length; i++) + { + WriteInt64(longArray[i], context); + } + return true; + } + + // Float array + if (ReferenceEquals(elementType, FloatType) && enumerable is float[] floatArray) + { + context.WriteVarUInt((uint)floatArray.Length); + for (var i = 0; i < floatArray.Length; i++) + { + WriteFloat32Unsafe(floatArray[i], context); + } + return true; + } + + // Bool array + 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 + if (ReferenceEquals(elementType, GuidType) && enumerable is Guid[] guidArray) + { + context.WriteVarUInt((uint)guidArray.Length); + for (var i = 0; i < guidArray.Length; i++) + { + WriteGuidUnsafe(guidArray[i], context); + } + 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; + } + private static void WriteDictionary(IDictionary dictionary, BinarySerializationContext context, int depth) { context.WriteByte(BinaryTypeCode.Dictionary); @@ -779,11 +908,18 @@ public static class AcBinarySerializer #region Serialization Context + /// + /// Optimized serialization context with direct memory operations. + /// Uses ArrayPool for buffer management and MemoryMarshal for zero-copy writes. + /// internal sealed class BinarySerializationContext : IDisposable { private byte[] _buffer; private int _position; + // Minimum buffer size for ArrayPool (reduces fragmentation) + private const int MinBufferSize = 256; + // Reference handling private Dictionary? _scanOccurrences; private Dictionary? _writtenRefs; @@ -806,7 +942,8 @@ public static class AcBinarySerializer public BinarySerializationContext(AcBinarySerializerOptions options) { - _buffer = ArrayPool.Shared.Rent(options.InitialBufferCapacity); + var size = Math.Max(options.InitialBufferCapacity, MinBufferSize); + _buffer = ArrayPool.Shared.Rent(size); Reset(options); } @@ -843,14 +980,23 @@ public static class AcBinarySerializer } } - #region Buffer Writing + #region Optimized Buffer Writing + /// + /// Ensures buffer has capacity, growing by doubling if needed. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnsureCapacity(int additionalBytes) { var required = _position + additionalBytes; if (required <= _buffer.Length) return; + GrowBuffer(required); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowBuffer(int required) + { var newSize = Math.Max(_buffer.Length * 2, required); var newBuffer = ArrayPool.Shared.Rent(newSize); _buffer.AsSpan(0, _position).CopyTo(newBuffer); @@ -873,6 +1019,68 @@ public static class AcBinarySerializer _position += data.Length; } + /// + /// Write a blittable value type directly to buffer using MemoryMarshal. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteRaw(T value) where T : unmanaged + { + var size = Unsafe.SizeOf(); + EnsureCapacity(size); + Unsafe.WriteUnaligned(ref _buffer[_position], value); + _position += size; + } + + /// + /// Optimized decimal writer using GetBits. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteDecimalBits(decimal value) + { + EnsureCapacity(16); + Span bits = stackalloc int[4]; + decimal.TryGetBits(value, bits, out _); + + var destSpan = _buffer.AsSpan(_position, 16); + MemoryMarshal.AsBytes(bits).CopyTo(destSpan); + _position += 16; + } + + /// + /// Optimized DateTime writer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteDateTimeBits(DateTime value) + { + EnsureCapacity(9); + Unsafe.WriteUnaligned(ref _buffer[_position], value.Ticks); + _buffer[_position + 8] = (byte)value.Kind; + _position += 9; + } + + /// + /// Optimized Guid writer using TryWriteBytes. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteGuidBits(Guid value) + { + EnsureCapacity(16); + value.TryWriteBytes(_buffer.AsSpan(_position, 16)); + _position += 16; + } + + /// + /// Optimized DateTimeOffset writer. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteDateTimeOffsetBits(DateTimeOffset value) + { + EnsureCapacity(10); + Unsafe.WriteUnaligned(ref _buffer[_position], value.UtcTicks); + Unsafe.WriteUnaligned(ref _buffer[_position + 8], (short)value.Offset.TotalMinutes); + _position += 10; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteVarInt(int value) { @@ -925,12 +1133,29 @@ public static class AcBinarySerializer _buffer[_position++] = (byte)value; } + /// + /// Optimized string writer using span-based UTF8 encoding. + /// Uses stackalloc for small strings to avoid allocations. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void WriteString(string value, int utf8Length) + public void WriteStringUtf8(string value) { - EnsureCapacity(utf8Length); - Encoding.UTF8.GetBytes(value, _buffer.AsSpan(_position, utf8Length)); - _position += utf8Length; + // For small strings, use stackalloc to avoid GetByteCount call + if (value.Length <= 128) + { + Span tempBuffer = stackalloc byte[value.Length * 3]; // Max UTF8 expansion + var bytesWritten = Encoding.UTF8.GetBytes(value.AsSpan(), tempBuffer); + WriteVarUInt((uint)bytesWritten); + WriteBytes(tempBuffer.Slice(0, bytesWritten)); + } + else + { + var utf8Length = Encoding.UTF8.GetByteCount(value); + WriteVarUInt((uint)utf8Length); + EnsureCapacity(utf8Length); + Encoding.UTF8.GetBytes(value, _buffer.AsSpan(_position, utf8Length)); + _position += utf8Length; + } } #endregion @@ -954,28 +1179,31 @@ public static class AcBinarySerializer var hasPropertyNames = _propertyNameList != null && _propertyNameList.Count > 0; - if (!UseMetadata || !hasPropertyNames) + // Build flags byte + byte flags = BinaryTypeCode.HeaderFlagsBase; + if (UseMetadata && hasPropertyNames) { - _buffer[_headerPosition + 1] = BinaryTypeCode.NoMetadataHeader; - return; + flags |= BinaryTypeCode.HeaderFlag_Metadata; } - - _buffer[_headerPosition + 1] = BinaryTypeCode.MetadataHeader; - - // Write property name count - WriteVarUInt((uint)_propertyNameList!.Count); - - // Write property names - foreach (var name in _propertyNameList) + if (UseReferenceHandling) { - var utf8Length = Encoding.UTF8.GetByteCount(name); - WriteVarUInt((uint)utf8Length); - WriteString(name, utf8Length); + flags |= BinaryTypeCode.HeaderFlag_ReferenceHandling; } + + _buffer[_headerPosition + 1] = flags; - // NOTE: Interned strings are handled "on-the-fly" during serialization - // First occurrence is written inline, subsequent occurrences use StringInterned reference - // We don't need to write them in metadata since deserializer builds table dynamically + // Write property names if metadata is enabled + if ((flags & BinaryTypeCode.HeaderFlag_Metadata) != 0) + { + // Write property name count + WriteVarUInt((uint)_propertyNameList!.Count); + + // Write property names + foreach (var name in _propertyNameList) + { + WriteStringUtf8(name); + } + } } #endregion diff --git a/AyCode.Core/Extensions/AcBinarySerializerOptions.cs b/AyCode.Core/Extensions/AcBinarySerializerOptions.cs index 79f153c..96d722e 100644 --- a/AyCode.Core/Extensions/AcBinarySerializerOptions.cs +++ b/AyCode.Core/Extensions/AcBinarySerializerOptions.cs @@ -136,8 +136,21 @@ internal static class BinaryTypeCode public const byte ByteArray = 30; // Optimized byte[] storage // Special markers (32+, for header/meta) - public const byte MetadataHeader = 32; // Binary has metadata section - public const byte NoMetadataHeader = 33; // Binary has no metadata + // Header flags byte structure (for values >= 64): + // Bit 0 (0x01): HasMetadata + // Bit 1 (0x02): HasReferenceHandling + // Values 32, 33 are legacy for backward compatibility + public const byte MetadataHeader = 32; // Binary has metadata section (legacy, implies HasReferenceHandling=true) + public const byte NoMetadataHeader = 33; // Binary has no metadata (legacy, implies HasReferenceHandling=true) + + // New flag-based header markers (48+) + // Base value 48 (0x30 = 00110000) chosen to: + // - Be distinguishable from legacy values (32, 33) + // - Not conflict with flag bits in lower nibble + // - Leave room below Int32Tiny (64) + public const byte HeaderFlagsBase = 48; // Base value for flag-based headers (0x30) + public const byte HeaderFlag_Metadata = 0x01; + public const byte HeaderFlag_ReferenceHandling = 0x02; // Compact integer variants (for VarInt optimization) public const byte Int32Tiny = 64; // -16 to 111 stored in single byte (value = code - 64 - 16) diff --git a/AyCode.Core/Extensions/JsonUtilities.cs b/AyCode.Core/Extensions/JsonUtilities.cs index 87ecaaa..5a4ebf5 100644 --- a/AyCode.Core/Extensions/JsonUtilities.cs +++ b/AyCode.Core/Extensions/JsonUtilities.cs @@ -335,8 +335,10 @@ public static class JsonUtilities (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r' => AcSerializerType.Json, >= (byte)'0' and <= (byte)'9' => AcSerializerType.Json, (byte)'-' or (byte)'t' or (byte)'f' or (byte)'n' => AcSerializerType.Json, - // Binary format version 1 with metadata or no-metadata header - 1 when data.Length > 1 && (data[1] == 32 || data[1] == 33) => AcSerializerType.Binary, + // Binary format version 1 with: + // - Legacy metadata header (32) or no-metadata header (33) + // - New flag-based header (34+) + 1 when data.Length > 1 && data[1] >= 32 => AcSerializerType.Binary, _ => AcSerializerType.Binary // Default to Binary for unknown byte patterns }; } diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs index 111d0c4..fc8e1b5 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRClientToHubTest.cs @@ -13,7 +13,7 @@ namespace AyCode.Services.Server.Tests.SignalRs; /// public abstract class SignalRClientToHubTestBase { - protected abstract AcSerializerType SerializerType { get; } + protected abstract AcSerializerOptions SerializerOption { get; } protected TestLogger _logger = null!; protected TestableSignalRClient2 _client = null!; @@ -28,7 +28,7 @@ public abstract class SignalRClientToHubTestBase _service = new TestSignalRService2(); _client = new TestableSignalRClient2(_hub, _logger); - _hub.SetSerializerType(SerializerType); + _hub.SetSerializerType(SerializerOption); _hub.RegisterService(_service, _client); } @@ -1117,14 +1117,26 @@ public abstract class SignalRClientToHubTestBase [TestClass] public class SignalRClientToHubTest_Json : SignalRClientToHubTestBase { - protected override AcSerializerType SerializerType => AcSerializerType.Json; + protected override AcSerializerOptions SerializerOption { get; } = new AcJsonSerializerOptions(); } /// /// Runs all SignalR tests with Binary serialization. /// [TestClass] -public class SignalRClientToHubTest_Binary : SignalRClientToHubTestBase +public class SignalRClientToHubTest_Binary_WithRef : SignalRClientToHubTestBase { - protected override AcSerializerType SerializerType => AcSerializerType.Binary; + protected override AcSerializerOptions SerializerOption { get; } = new AcBinarySerializerOptions(); +} + +/// +/// Runs all SignalR tests with Binary serialization. +/// +[TestClass] +public class SignalRClientToHubTest_Binary_NoRef : SignalRClientToHubTestBase +{ + protected override AcSerializerOptions SerializerOption { get; } = new AcBinarySerializerOptions + { + UseReferenceHandling = false + }; } \ No newline at end of file diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs index 0f17924..4ef74c4 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs @@ -66,12 +66,9 @@ public class TestableSignalRHub2 : AcWebSignalRHubBase /// Sets the serializer type for testing Binary vs JSON serialization. /// - public void SetSerializerType(AcSerializerType serializerType) + public void SetSerializerType(AcSerializerOptions acSerializerOptions) { - if (serializerType == AcSerializerType.Binary) - SerializerOptions = new AcBinarySerializerOptions(); - else - SerializerOptions = new AcJsonSerializerOptions(); + SerializerOptions = acSerializerOptions; } #endregion diff --git a/BenchmarkSuite1/SerializationBenchmarks.cs b/BenchmarkSuite1/SerializationBenchmarks.cs index 9bc803b..6e1b8f6 100644 --- a/BenchmarkSuite1/SerializationBenchmarks.cs +++ b/BenchmarkSuite1/SerializationBenchmarks.cs @@ -193,4 +193,219 @@ public class MessagePackComparisonBenchmark [Benchmark(Description = "MessagePack Deserialize")] public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize(_msgPackData, _msgPackOptions); +} + +/// +/// Comprehensive AcBinary vs MessagePack comparison benchmark. +/// Tests: WithRef, NoRef, Populate, Serialize, Deserialize, Size +/// +[ShortRunJob] +[MemoryDiagnoser] +[RankColumn] +public class AcBinaryVsMessagePackFullBenchmark +{ + // Test data + private TestOrder _testOrder = null!; + private TestOrder _populateTarget = null!; + + // Serialized data - AcBinary + private byte[] _acBinaryWithRef = null!; + private byte[] _acBinaryNoRef = null!; + + // Serialized data - MessagePack + private byte[] _msgPackData = null!; + + // Options + private AcBinarySerializerOptions _withRefOptions = null!; + private AcBinarySerializerOptions _noRefOptions = null!; + private MessagePackSerializerOptions _msgPackOptions = null!; + + [GlobalSetup] + public void Setup() + { + // Create test data with shared references + TestDataFactory.ResetIdCounter(); + var sharedTag = TestDataFactory.CreateTag("SharedTag"); + var sharedUser = TestDataFactory.CreateUser("shareduser"); + var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true); + + _testOrder = TestDataFactory.CreateOrder( + itemCount: 3, + palletsPerItem: 3, + measurementsPerPallet: 3, + pointsPerMeasurement: 4, + sharedTag: sharedTag, + sharedUser: sharedUser, + sharedMetadata: sharedMeta); + + // Setup options + _withRefOptions = AcBinarySerializerOptions.Default; // WithRef by default + _noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling(); + _msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None); + + // Serialize with different options + _acBinaryWithRef = AcBinarySerializer.Serialize(_testOrder, _withRefOptions); + _acBinaryNoRef = AcBinarySerializer.Serialize(_testOrder, _noRefOptions); + _msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions); + + // Create populate target + _populateTarget = new TestOrder { Id = _testOrder.Id }; + foreach (var item in _testOrder.Items) + { + _populateTarget.Items.Add(new TestOrderItem { Id = item.Id }); + } + + // Print size comparison + PrintSizeComparison(); + } + + private void PrintSizeComparison() + { + Console.WriteLine("\n" + new string('=', 60)); + Console.WriteLine("?? SIZE COMPARISON (AcBinary vs MessagePack)"); + Console.WriteLine(new string('=', 60)); + Console.WriteLine($" AcBinary WithRef: {_acBinaryWithRef.Length,8:N0} bytes"); + Console.WriteLine($" AcBinary NoRef: {_acBinaryNoRef.Length,8:N0} bytes"); + Console.WriteLine($" MessagePack: {_msgPackData.Length,8:N0} bytes"); + Console.WriteLine(new string('-', 60)); + Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryWithRef.Length / _msgPackData.Length:F1}% (WithRef)"); + Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryNoRef.Length / _msgPackData.Length:F1}% (NoRef)"); + Console.WriteLine(new string('=', 60) + "\n"); + } + + #region Serialize Benchmarks + + [Benchmark(Description = "AcBinary Serialize WithRef")] + public byte[] Serialize_AcBinary_WithRef() => AcBinarySerializer.Serialize(_testOrder, _withRefOptions); + + [Benchmark(Description = "AcBinary Serialize NoRef")] + public byte[] Serialize_AcBinary_NoRef() => AcBinarySerializer.Serialize(_testOrder, _noRefOptions); + + [Benchmark(Description = "MessagePack Serialize", Baseline = true)] + public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions); + + #endregion + + #region Deserialize Benchmarks + + [Benchmark(Description = "AcBinary Deserialize WithRef")] + public TestOrder? Deserialize_AcBinary_WithRef() => AcBinaryDeserializer.Deserialize(_acBinaryWithRef); + + [Benchmark(Description = "AcBinary Deserialize NoRef")] + public TestOrder? Deserialize_AcBinary_NoRef() => AcBinaryDeserializer.Deserialize(_acBinaryNoRef); + + [Benchmark(Description = "MessagePack Deserialize")] + public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize(_msgPackData, _msgPackOptions); + + #endregion + + #region Populate Benchmarks + + [Benchmark(Description = "AcBinary Populate WithRef")] + public void Populate_AcBinary_WithRef() + { + var target = CreatePopulateTarget(); + AcBinaryDeserializer.Populate(_acBinaryWithRef, target); + } + + [Benchmark(Description = "AcBinary PopulateMerge WithRef")] + public void PopulateMerge_AcBinary_WithRef() + { + var target = CreatePopulateTarget(); + AcBinaryDeserializer.PopulateMerge(_acBinaryWithRef.AsSpan(), target); + } + + private TestOrder CreatePopulateTarget() + { + var target = new TestOrder { Id = _testOrder.Id }; + foreach (var item in _testOrder.Items) + { + target.Items.Add(new TestOrderItem { Id = item.Id }); + } + return target; + } + + #endregion +} + +/// +/// Detailed size comparison - not a performance benchmark, just size output. +/// +[ShortRunJob] +[MemoryDiagnoser] +public class SizeComparisonBenchmark +{ + private TestOrder _smallOrder = null!; + private TestOrder _mediumOrder = null!; + private TestOrder _largeOrder = null!; + + private MessagePackSerializerOptions _msgPackOptions = null!; + private AcBinarySerializerOptions _withRefOptions = null!; + private AcBinarySerializerOptions _noRefOptions = null!; + + [GlobalSetup] + public void Setup() + { + _msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None); + _withRefOptions = AcBinarySerializerOptions.Default; + _noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling(); + + // Small order + TestDataFactory.ResetIdCounter(); + _smallOrder = TestDataFactory.CreateOrder(itemCount: 1, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2); + + // Medium order + TestDataFactory.ResetIdCounter(); + var sharedTag = TestDataFactory.CreateTag("Shared"); + var sharedUser = TestDataFactory.CreateUser("shared"); + _mediumOrder = TestDataFactory.CreateOrder( + itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3, + sharedTag: sharedTag, sharedUser: sharedUser); + + // Large order + TestDataFactory.ResetIdCounter(); + sharedTag = TestDataFactory.CreateTag("SharedLarge"); + sharedUser = TestDataFactory.CreateUser("sharedlarge"); + var sharedMeta = TestDataFactory.CreateMetadata("meta", withChild: true); + _largeOrder = TestDataFactory.CreateOrder( + itemCount: 5, palletsPerItem: 4, measurementsPerPallet: 3, pointsPerMeasurement: 5, + sharedTag: sharedTag, sharedUser: sharedUser, sharedMetadata: sharedMeta); + + PrintDetailedSizeComparison(); + } + + private void PrintDetailedSizeComparison() + { + Console.WriteLine("\n" + new string('=', 80)); + Console.WriteLine("?? DETAILED SIZE COMPARISON: AcBinary vs MessagePack"); + Console.WriteLine(new string('=', 80)); + + PrintOrderSize("Small Order (1x1x1x2)", _smallOrder); + PrintOrderSize("Medium Order (3x2x2x3) + SharedRefs", _mediumOrder); + PrintOrderSize("Large Order (5x4x3x5) + SharedRefs", _largeOrder); + + Console.WriteLine(new string('=', 80) + "\n"); + } + + private void PrintOrderSize(string name, TestOrder order) + { + var acWithRef = AcBinarySerializer.Serialize(order, _withRefOptions); + var acNoRef = AcBinarySerializer.Serialize(order, _noRefOptions); + var msgPack = MessagePackSerializer.Serialize(order, _msgPackOptions); + + Console.WriteLine($"\n {name}:"); + Console.WriteLine($" AcBinary WithRef: {acWithRef.Length,8:N0} bytes ({100.0 * acWithRef.Length / msgPack.Length,5:F1}% of MsgPack)"); + Console.WriteLine($" AcBinary NoRef: {acNoRef.Length,8:N0} bytes ({100.0 * acNoRef.Length / msgPack.Length,5:F1}% of MsgPack)"); + Console.WriteLine($" MessagePack: {msgPack.Length,8:N0} bytes (100.0%)"); + + var withRefSaving = msgPack.Length - acWithRef.Length; + var noRefSaving = msgPack.Length - acNoRef.Length; + if (withRefSaving > 0) + Console.WriteLine($" ?? AcBinary WithRef saves: {withRefSaving:N0} bytes ({100.0 * withRefSaving / msgPack.Length:F1}%)"); + else + Console.WriteLine($" ?? AcBinary WithRef larger by: {-withRefSaving:N0} bytes"); + } + + [Benchmark(Description = "Placeholder")] + public int Placeholder() => 1; // Just to make BenchmarkDotNet happy } \ No newline at end of file diff --git a/BenchmarkSuite1/benchmark-report.html b/BenchmarkSuite1/benchmark-report.html new file mode 100644 index 0000000..6ad456a --- /dev/null +++ b/BenchmarkSuite1/benchmark-report.html @@ -0,0 +1,828 @@ + + + + + + AcBinary vs MessagePack - Benchmark Riport + + + +
+
+

?? AcBinary vs MessagePack

+
Komplett Benchmark Összehasonlítás + Memória Diagnosztika
+
Generálva: 2024. december 13. | .NET 9.0 | Intel Core i7-10750H
+
+ + +
+

??? Teszt Környezet

+
+ ?? Windows 11 (23H2) + ?? Intel Core i7-10750H @ 2.60GHz + ?? .NET SDK 10.0.101 + ?? Runtime: .NET 9.0.11 + ?? BenchmarkDotNet v0.15.2 + ?? Teszt adat: 3×3×3×4 hierarchia +
+
+ + +
+

?? Méret Összehasonlítás

+ +
+
+
18.9 KB
+
AcBinary WithRef
+
+
+
15.8 KB
+
AcBinary NoRef
+
+
+
11.2 KB
+
MessagePack
+
+
+ +

Méret arány (MessagePack = 100%)

+
+
AcBinary: 168%
+
MsgPack: 100%
+
+
+
AcBinary
+
MessagePack
+
+ +
+ ?? Megjegyzés: Az AcBinary nagyobb méretet eredményez a beépített metaadat (property nevek táblája) és típusinformációk miatt, ami viszont lehetõvé teszi a schema evolúciót és a gyorsabb deszerializálást. +
+
+ + +
+

? Teljesítmény Összehasonlítás

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MûveletAcBinaryMessagePackArányEredmény
Serialize WithRef84.20 ?s18.84 ?s4.47×MsgPack gyorsabb
Serialize NoRef70.18 ?s18.84 ?s3.73×MsgPack gyorsabb
Deserialize WithRef40.10 ?s41.10 ?s0.98×AcBinary gyorsabb
Deserialize NoRef1.02 ?s41.10 ?s0.025×40× gyorsabb!
Populate39.27 ?sCsak AcBinary
PopulateMerge40.73 ?sCsak AcBinary
+
+ + +
+

?? Memória Allokáció (GC Diagnosztika)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MûveletAcBinaryMessagePackGen0Gen1Értékelés
Serialize WithRef55.34 KB12.50 KB9.034.43× több
Serialize NoRef46.30 KB12.50 KB7.453.70× több
Deserialize WithRef38.17 KB26.24 KB6.230.431.45× több
Deserialize NoRef2.85 KB26.24 KB0.470.0049× kevesebb!
+ +
+ ?? GC Pressure: A Serialize WithRef 9.03 Gen0 GC-t triggerel 1000 mûveletre. Ez jelentõs GC nyomást jelent nagy áteresztõképességû szerver alkalmazásokban. +
+
+ + +
+

?? Memória Allokációs Hotspotok

+ +

Serialize (55.34 KB allokáció)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ForrásBecsült méretLeírás
ToArray()~19 KBVégsõ byte[] allokáció a visszatérési értékhez
Dictionary<object,int>~8 KBReference scanning: _scanOccurrences, _writtenRefs
HashSet<object>~4 KBMulti-referenced objektumok nyilvántartása
Dictionary<string,int>~6 KBProperty name table + string interning
List<string>~4 KBProperty nevek és interned stringek listái
Boxing~10 KBValue type boxing a property getter-ekben
Egyéb~4 KBClosure-ok, delegate-ek, átmeneti objektumok
+
+ + +
+

?? További Optimalizálási Lehetõségek

+ +
+

+ MAGAS HATÁS + 1. IBufferWriter<byte> alapú Serialize +

+

+ A ToArray() hívás ~19 KB allokációt okoz. Ehelyett IBufferWriter<byte> + interfész használatával a hívó biztosíthatja a buffert, elkerülve az allokációt. +

+

Becsült megtakarítás: ~35% memória csökkenés serialize-nál

+
+ +
+

+ MAGAS HATÁS + 2. Typed Property Getter-ek (Boxing elkerülése) +

+

+ A jelenlegi Func<object, object?> getter minden value type-ot boxol. + Típusos getter-ek (Func<T, int>, stb.) használatával megszüntethetõ. +

+

Becsült megtakarítás: ~10 KB / serialize (~18% csökkenés)

+
+ +
+

+ KÖZEPES HATÁS + 3. Reference Tracking gyûjtemények poolozása +

+

+ A Dictionary és HashSet objektumok a context pool-ban maradnak, + de Clear() után is megtartják a kapacitásukat. A poolban tárolás elõtt + érdemes lenne TrimExcess() hívni, vagy kisebb initial capacity-t használni. +

+

Becsült megtakarítás: ~5 KB / serialize

+
+ +
+

+ KÖZEPES HATÁS + 4. String.Create() UTF8 kódoláshoz +

+

+ A deszerializálásnál a Encoding.UTF8.GetString() új stringet allokál. + String.Create() span callback-kel közvetlenül a string bufferbe írhat. +

+

Becsült megtakarítás: ~2 KB / deserialize komplex objektumoknál

+
+ +
+

+ ALACSONY HATÁS + 5. Two-pass serialize elkerülése NoRef módban +

+

+ NoRef módban is fut a CollectPropertyNames fázis a metaadathoz. + Ha a típus ismert és stabil, a property nevek elõre cache-elhetõk. +

+

Becsült megtakarítás: ~10% sebesség javulás NoRef serialize-nál

+
+ +
+

+ ALACSONY HATÁS + 6. Span-based enum serialization +

+

+ A Convert.ToInt32(value) enum értékeknél boxing-ot okoz. + Típusos enum kezelés vagy Unsafe.As használatával elkerülhetõ. +

+

Becsült megtakarítás: ~24 byte / enum érték

+
+
+ + +
+

?? Funkció Összehasonlítás

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FunkcióAcBinaryMessagePackMegjegyzés
Reference Handling ($id/$ref)??Közös objektumok deduplikálása
Populate (meglévõ objektum)??Létezõ objektum frissítése
PopulateMerge (IId merge)??Lista elemek ID alapú merge
Schema Evolution???Property név alapú mapping
Metadata Table??Property nevek indexelése
String Interning??Ismétlõdõ stringek deduplikálása
Kompakt méret???MsgPack ~40% kisebb
Serialize sebesség???MsgPack 3-4× gyorsabb
Deserialize sebesség???AcBinary akár 40× gyorsabb
+
+ + +
+

?? Mikor Melyiket Használd?

+ +
+
+

?? AcBinary ajánlott

+
    +
  • Deserialize-heavy workload (kliens oldal)
  • +
  • Populate/Merge szükséges
  • +
  • Reference handling kell (shared objects)
  • +
  • Schema változások várhatóak
  • +
  • NoRef mód használható (40× gyorsabb!)
  • +
+
+
+

?? MessagePack ajánlott

+
    +
  • Serialize-heavy workload (szerver oldal)
  • +
  • Méret kritikus (hálózati átvitel)
  • +
  • Egyszerû objektumok (nincs referencia)
  • +
  • Külsõ rendszerekkel kompatibilitás
  • +
  • Minimális GC pressure kell
  • +
+
+
+
+ + +
+

?? Kiemelt Eredmények

+ +
+
+
40×
+
AcBinary NoRef Deserialize gyorsabb
+
+
+
+
Kevesebb memória (NoRef Deser.)
+
+
+
3.7×
+
MsgPack Serialize gyorsabb
+
+
+
4.4×
+
Több memória (Serialize WithRef)
+
+
+
+ + +
+

?? Összegzés

+
+

AcBinary erõsségei:

+
    +
  • Kiemelkedõen gyors deserializálás, különösen NoRef módban (40× gyorsabb)
  • +
  • Beépített reference handling és populate/merge támogatás
  • +
  • Schema evolution friendly (property név alapú)
  • +
+
+

Fejlesztendõ területek:

+
    +
  • Serialize sebesség (3-4× lassabb MessagePack-nél)
  • +
  • Memória allokáció serialize-nál (4.4× több)
  • +
  • Output méret (~68% nagyobb)
  • +
+
+

Javasolt következõ lépések prioritás szerint:

+
    +
  1. IBufferWriter<byte> támogatás hozzáadása
  2. +
  3. Typed property getter-ek boxing elkerülésére
  4. +
  5. Reference tracking collection pooling optimalizálása
  6. +
+
+
+
+ + + +