Refactor: property index-based binary serialization

Switch to deterministic property index-based serialization/deserialization for improved performance and cross-platform consistency. Properties are now ordered alphabetically and accessed by index, enabling O(1) lookups and eliminating string/dictionary overhead. Introduce thread-local metadata caches, refactor metadata and populate logic, optimize string interning and enum handling, and remove legacy name-based code paths. Update diagnostics and documentation for clarity and maintainability.
This commit is contained in:
Loretta 2025-12-30 12:34:06 +01:00
parent 0552268ac1
commit a72f9883b4
10 changed files with 877 additions and 709 deletions

View File

@ -230,10 +230,7 @@ public class AcBinarySerializerDiagnosticTests
/// <summary>
/// CRITICAL BUG REPRODUCTION: StockTakingItems is null (loadRelations = false)
/// This test verifies what happens when:
/// 1. Metadata header registers ALL properties including StockTakingItems
/// 2. Body SKIPS StockTakingItems because it's null
/// 3. Deserializer reads the body and must correctly map indices
/// This test verifies that property-index-based serialization correctly handles null properties.
/// </summary>
[TestMethod]
public void Diagnostic_NullStockTakingItems_VerifyPropertyIndices()
@ -262,20 +259,13 @@ public class AcBinarySerializerDiagnosticTests
var marker = binary[pos++];
Console.WriteLine($"Marker: 0x{marker:X2}");
// Read property count from metadata header
if ((marker & 0x10) != 0) // HasMetadata flag
// Skip any header data (strings interning, etc.)
// New format uses PropertyIndex directly - no metadata header with property names
// Find Object marker (0x19)
while (pos < binary.Length && binary[pos] != 0x19)
{
var propCount = binary[pos++];
Console.WriteLine($"\n=== METADATA HEADER ===");
Console.WriteLine($"Property count in header: {propCount}");
for (int i = 0; i < propCount; i++)
{
var strLen = binary[pos++];
var propName = System.Text.Encoding.UTF8.GetString(binary, pos, strLen);
pos += strLen;
Console.WriteLine($" Header property [{i}]: '{propName}'");
}
pos++;
}
Console.WriteLine($"\n=== BODY (starts at position {pos}) ===");
@ -284,6 +274,7 @@ public class AcBinarySerializerDiagnosticTests
var bodyStart = pos;
var objectMarker = binary[pos++];
Console.WriteLine($"Object marker: 0x{objectMarker:X2} (expected 0x19 for Object)");
Assert.AreEqual(0x19, objectMarker, "Object marker should be 0x19");
// Read ref ID (if reference handling is enabled)
// VarInt: if top bit is set, continue reading
@ -306,11 +297,11 @@ public class AcBinarySerializerDiagnosticTests
var bodyPropCount = binary[pos++];
Console.WriteLine($"Property count in body: {bodyPropCount}");
Console.WriteLine($"\n=== BODY PROPERTIES ===");
Console.WriteLine($"\n=== BODY PROPERTIES (PropertyIndex format) ===");
for (int i = 0; i < bodyPropCount && pos < binary.Length; i++)
{
var propIndex = binary[pos++];
Console.WriteLine($" Body property [{i}]: index={propIndex}, next bytes: 0x{binary[pos]:X2} 0x{(pos + 1 < binary.Length ? binary[pos + 1] : 0):X2}");
var propIndex = binary[pos++]; // This is now PropertyIndex (alphabetical order)
Console.WriteLine($" Body property [{i}]: PropertyIndex={propIndex}");
// Skip the value (simplified - just log)
var valueType = binary[pos];
@ -342,16 +333,6 @@ public class AcBinarySerializerDiagnosticTests
}
}
// Find where 0xD6 (Creator = 6) appears in the body
Console.WriteLine($"\n=== 0xD6 OCCURRENCES ===");
for (int i = bodyStart; i < binary.Length; i++)
{
if (binary[i] == 0xD6)
{
Console.WriteLine($"Found 0xD6 (TinyInt 6 = Creator value) at position {i}");
}
}
// Deserialize and verify
var result = binary.BinaryTo<SimStockTaking>();

View File

@ -2,51 +2,44 @@ using System.Collections;
using System.Collections.Frozen;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using static AyCode.Core.Helpers.JsonUtilities;
using AyCode.Core.Serializers;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer
{
internal sealed class BinaryDeserializeTypeMetadata : TypeMetadataBase
internal sealed class BinaryDeserializeTypeMetadata : BinaryTypeMetadataBase
{
private readonly FrozenDictionary<string, BinaryPropertySetterInfo> _properties;
/// <summary>
/// Properties array ordered alphabetically by name for index-based lookup.
/// This matches the serializer's ordering, enabling O(1) array access.
/// </summary>
public BinaryPropertySetterInfo[] PropertiesArray { get; }
/// <summary>
/// Whether this type implements IId interface.
/// </summary>
public bool IsIId { get; }
/// <summary>
/// Compiled getter for the Id property (if IsIId is true).
/// </summary>
public Func<object, object?>? IdGetter { get; }
public BinaryDeserializeTypeMetadata(Type type) : base(type)
{
PropertiesArray = GetSerializableProperties(type, requiresRead: true, requiresWrite: true)
.Where(static p => p.GetMethod is { IsPublic: true } && p.SetMethod is { IsPublic: true })
.Select(static p => new BinaryPropertySetterInfo(p, p.DeclaringType!))
.ToArray();
var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
PropertiesArray = new BinaryPropertySetterInfo[orderedProperties.Length];
for (var i = 0; i < orderedProperties.Length; i++)
{
PropertiesArray[i] = new BinaryPropertySetterInfo(orderedProperties[i], type);
}
_properties = PropertiesArray.Length == 0
? FrozenDictionary<string, BinaryPropertySetterInfo>.Empty
: PropertiesArray.ToFrozenDictionary(static p => p.Name, static p => p, StringComparer.Ordinal);
// Check if type implements IId
var (isIId, _) = GetIdInfo(type);
IsIId = isIId;
if (isIId)
{
var idProp = type.GetProperty("Id");
if (idProp != null)
IdGetter = AcSerializerCommon.CreateCompiledGetter(type, idProp);
}
}
/// <summary>
/// Gets property by index (O(1) array access). Used for index-based deserialization.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public BinaryPropertySetterInfo? GetPropertyByIndex(int index)
=> (uint)index < (uint)PropertiesArray.Length ? PropertiesArray[index] : null;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetProperty(string name, out BinaryPropertySetterInfo? propertyInfo)
=> _properties.TryGetValue(name, out propertyInfo);
@ -87,7 +80,7 @@ public static partial class AcBinaryDeserializer
Type? elementType,
Type? elementIdType,
Func<object, object?>? elementIdGetter)
: base(CreateDummyProperty(), typeof(object))
: base(CreateDummyProperty(), typeof(DummyClass))
{
_isManualConstruction = true;
_manualName = name;

View File

@ -0,0 +1,497 @@
using System.Collections;
using System.Runtime.CompilerServices;
using AyCode.Core.Helpers;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer
{
#region Populate Object Methods
private static void PopulateObject(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
PopulateObject(ref context, target, metadata, depth);
}
private static void PopulateObject(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth)
{
var propertyCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var targetType = target.GetType();
var targetTypeName = targetType.Name;
for (int i = 0; i < propertyCount; i++)
{
var propertyIndexStartPosition = context.Position;
// Read property index directly - no string lookup needed!
// PropertyIndex is deterministic (alphabetically ordered) and consistent across platforms
var propIndex = (int)context.ReadVarUInt();
// O(1) array lookup instead of dictionary lookup
var propInfo = metadata.GetPropertyByIndex(propIndex);
if (propInfo == null)
{
// Skip unknown property (schema evolution - new property on sender side)
SkipValue(ref context);
continue;
}
// OPTIMIZATION: Reuse existing nested objects instead of creating new ones
var peekCode = context.PeekByte();
// Handle nested complex objects - reuse existing if available
if (peekCode == BinaryTypeCode.Object && propInfo.IsComplexType)
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectNested(ref context, existingObj, propInfo.PropertyType, nextDepth);
continue;
}
}
// Handle collections - reuse existing collection and populate items
if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
continue;
}
}
// Default: read value and set (for primitives, strings, null cases)
var positionBeforeRead = context.Position;
try
{
// OPTIMIZATION: Use typed setters for primitives to avoid boxing
if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
continue;
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
catch (InvalidCastException ex)
{
// Add context about which property and what byte code was at the read position
throw new AcBinaryDeserializationException(
$"Type mismatch for property '{propInfo.Name}' (index {i}/{propertyCount}, propIndex={propIndex}) on '{targetTypeName}'. " +
$"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " +
$"Depth: {depth}. " +
$"Target type: {targetType.FullName}, Assembly: {targetType.Assembly.GetName().Name} v{targetType.Assembly.GetName().Version}. " +
$"All target properties: [{string.Join(", ", metadata.PropertiesArray.Select(p => $"{p.Name}:{p.PropertyType.Name}"))}]. " +
$"Error: {ex.Message}",
positionBeforeRead,
propInfo.PropertyType,
ex);
}
}
}
/// <summary>
/// Populate nested object, reusing existing object and recursively updating properties.
/// </summary>
private static void PopulateObjectNested(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
PopulateObject(ref context, target, metadata, depth);
}
private static void PopulateObjectMerge(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
var propertyCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
for (int i = 0; i < propertyCount; i++)
{
// Read property index directly - O(1) array lookup
var propIndex = (int)context.ReadVarUInt();
var propInfo = metadata.GetPropertyByIndex(propIndex);
if (propInfo == null)
{
SkipValue(ref context);
continue;
}
var peekCode = context.PeekByte();
// Handle IId collection merge
if (propInfo.IsIIdCollection && peekCode == BinaryTypeCode.Array)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
MergeIIdCollection(ref context, existingList, propInfo, depth);
continue;
}
}
// Handle nested object merge
if (peekCode == BinaryTypeCode.Object && !propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectMerge(ref context, existingObj, propInfo.PropertyType, nextDepth);
continue;
}
}
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
}
#endregion
#region Populate List Methods
private static void PopulateList(ref BinaryDeserializationContext context, IList targetList, Type listType, int depth)
{
var elementType = GetCollectionElementType(listType) ?? typeof(object);
var acObservable = targetList as IAcObservableCollection;
acObservable?.BeginUpdate();
try
{
targetList.Clear();
var count = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
// ChainMode: Get IId info for element type
var isIId = false;
Type? idType = null;
Func<object, object?>? idGetter = null;
if (context.IsChainMode)
{
var idInfo = GetIdInfo(elementType);
isIId = idInfo.IsId;
idType = idInfo.IdType;
if (isIId && idType != null)
{
var idProp = elementType.GetProperty("Id");
if (idProp != null)
idGetter = AcSerializerCommon.CreateCompiledGetter(elementType, idProp);
}
}
for (int i = 0; i < count; i++)
{
var value = ReadValue(ref context, elementType, nextDepth);
// ChainMode: Check if we already have this IId object
if (context.IsChainMode && value != null && idGetter != null && idType != null)
{
var id = idGetter(value);
if (id != null && !IsDefaultValue(id, idType))
{
if (context.ChainTracker!.TryGetObject(elementType, id, out var existingObj))
{
// Use existing object instead of new one
targetList.Add(existingObj);
continue;
}
}
}
targetList.Add(value);
}
}
finally
{
acObservable?.EndUpdate();
}
}
private static void PopulateListMerge(ref BinaryDeserializationContext context, IList targetList, Type listType, int depth)
{
var elementType = GetCollectionElementType(listType) ?? typeof(object);
var (isId, idType) = GetIdInfo(elementType);
if (!isId || idType == null)
{
// No IId, just replace
PopulateList(ref context, targetList, listType, depth);
return;
}
// IId merge logic
var idProp = elementType.GetProperty("Id");
if (idProp == null)
{
PopulateList(ref context, targetList, listType, depth);
return;
}
var idGetter = CreateCompiledGetter(elementType, idProp);
var propInfo = new BinaryPropertySetterInfo(
"Items", elementType, true, elementType, idType, idGetter);
MergeIIdCollection(ref context, targetList, propInfo, depth);
}
/// <summary>
/// Optimized list populate that reuses existing items when possible.
/// </summary>
private static void PopulateListOptimized(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth)
{
var elementType = propInfo.ElementType ?? typeof(object);
var count = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var acObservable = existingList as IAcObservableCollection;
acObservable?.BeginUpdate();
try
{
var existingCount = existingList.Count;
var elementMetadata = IsComplexType(elementType) ? GetTypeMetadata(elementType) : null;
for (int i = 0; i < count; i++)
{
var peekCode = context.PeekByte();
// If we have an existing item at this index and the incoming is an object, reuse it
if (i < existingCount && peekCode == BinaryTypeCode.Object && elementMetadata != null)
{
var existingItem = existingList[i];
if (existingItem != null)
{
context.ReadByte(); // consume Object marker
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, existingItem);
}
}
PopulateObject(ref context, existingItem, elementMetadata, nextDepth);
continue;
}
}
// Read new value
var value = ReadValue(ref context, elementType, nextDepth);
if (i < existingCount)
{
// Replace existing item
existingList[i] = value;
}
else
{
// Add new item
existingList.Add(value);
}
}
// Remove extra items if new list is shorter
while (existingList.Count > count)
{
existingList.RemoveAt(existingList.Count - 1);
}
}
finally
{
acObservable?.EndUpdate();
}
}
#endregion
#region Merge Methods
/// <summary>
/// Optimized IId collection merge with capacity hints and reduced boxing.
/// </summary>
private static void MergeIIdCollection(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth)
{
var elementType = propInfo.ElementType!;
var idGetter = propInfo.ElementIdGetter!;
var idType = propInfo.ElementIdType!;
var count = existingList.Count;
var acObservable = existingList as IAcObservableCollection;
acObservable?.BeginUpdate();
try
{
// Build lookup dictionary with capacity hint
Dictionary<object, object>? existingById = null;
if (count > 0)
{
existingById = new Dictionary<object, object>(count);
for (var idx = 0; idx < count; idx++)
{
var item = existingList[idx];
if (item != null)
{
var id = idGetter(item);
if (id != null && !IsDefaultValue(id, idType))
existingById[id] = item;
}
}
}
var arrayCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var elementMetadata = GetTypeMetadata(elementType);
// Track which IDs we see in source (for orphan removal)
HashSet<object>? sourceIds = context.RemoveOrphanedItems && existingById != null
? new HashSet<object>(arrayCount)
: null;
for (int i = 0; i < arrayCount; i++)
{
var itemCode = context.PeekByte();
if (itemCode != BinaryTypeCode.Object)
{
var value = ReadValue(ref context, elementType, nextDepth);
if (value != null)
existingList.Add(value);
continue;
}
context.ReadByte(); // consume Object marker
var newItem = CreateInstance(elementType, elementMetadata);
if (newItem == null) continue;
// Read ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
context.RegisterObject(refId, newItem);
}
PopulateObject(ref context, newItem, elementMetadata, nextDepth);
var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType))
{
// Track this ID as seen in source
sourceIds?.Add(itemId);
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem))
{
// Copy properties to existing item
CopyProperties(newItem, existingItem, elementMetadata);
continue;
}
}
existingList.Add(newItem);
}
// Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>();
foreach (var kvp in existingById)
{
if (!sourceIds.Contains(kvp.Key))
{
itemsToRemove.Add(kvp.Value);
}
}
// Remove orphaned items
foreach (var item in itemsToRemove)
{
existingList.Remove(item);
}
}
}
finally
{
acObservable?.EndUpdate();
}
}
private static void CopyProperties(object source, object target, BinaryDeserializeTypeMetadata metadata)
{
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);
}
}
/// <summary>
/// Determines if a type is a complex type (not primitive, string, or simple value type).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsComplexType(Type type)
{
if (type.IsPrimitive) return false;
if (ReferenceEquals(type, StringType)) return false;
if (type.IsEnum) return false;
if (ReferenceEquals(type, GuidType)) return false;
if (ReferenceEquals(type, DateTimeType)) return false;
if (ReferenceEquals(type, DecimalType)) return false;
if (ReferenceEquals(type, TimeSpanType)) return false;
if (ReferenceEquals(type, DateTimeOffsetType)) return false;
if (Nullable.GetUnderlyingType(type) != null) return false;
return true;
}
#endregion
}

View File

@ -43,6 +43,28 @@ public static partial class AcBinaryDeserializer
{
private static readonly ConcurrentDictionary<Type, BinaryDeserializeTypeMetadata> TypeMetadataCache = new();
private static readonly ConcurrentDictionary<Type, TypeConversionInfo> TypeConversionCache = new();
/// <summary>
/// ThreadLocal cache for hot path type metadata lookup.
/// Avoids ConcurrentDictionary overhead for frequently accessed types.
/// </summary>
[ThreadStatic]
private static Dictionary<Type, BinaryDeserializeTypeMetadata>? t_deserializeTypeMetadataLocalCache;
/// <summary>
/// ThreadLocal cache for type conversion info.
/// </summary>
[ThreadStatic]
private static Dictionary<Type, TypeConversionInfo>? t_typeConversionLocalCache;
/// <summary>
/// Maximum local cache size before clearing.
/// Clear() is preferred over new Dictionary() because:
/// 1. Reuses already allocated internal array (no new allocation)
/// 2. Reduces GC pressure
/// 3. Adaptive: if cache grew to 64 once, it stays at that capacity
/// </summary>
private const int MaxLocalCacheSize = 64;
// Type dispatch table for fast ReadValue
private delegate object? TypeReader(ref BinaryDeserializationContext context, Type targetType, int depth);
@ -819,13 +841,44 @@ public static partial class AcBinaryDeserializer
};
}
/// <summary>
/// Gets type conversion info with ThreadLocal caching.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TypeConversionInfo GetConversionInfo(Type targetType)
=> TypeConversionCache.GetOrAdd(targetType, static type =>
{
// Fast path: check ThreadLocal cache first
var localCache = t_typeConversionLocalCache;
if (localCache != null && localCache.TryGetValue(targetType, out var cached))
{
return cached;
}
// Slow path
return GetConversionInfoSlow(targetType);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static TypeConversionInfo GetConversionInfoSlow(Type targetType)
{
var info = TypeConversionCache.GetOrAdd(targetType, static type =>
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return new TypeConversionInfo(underlying, Type.GetTypeCode(underlying), underlying.IsEnum);
});
// Get or create local cache
var localCache = t_typeConversionLocalCache ??= new Dictionary<Type, TypeConversionInfo>();
// Clear when full - reuses internal array, better than new Dictionary()
if (localCache.Count >= MaxLocalCacheSize)
{
localCache.Clear();
}
localCache[targetType] = info;
return info;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object ReadEnumValue(ref BinaryDeserializationContext context, Type targetType)
@ -919,463 +972,6 @@ public static partial class AcBinaryDeserializer
return instance;
}
private static void PopulateObject(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
PopulateObject(ref context, target, metadata, depth);
}
private static void PopulateObject(ref BinaryDeserializationContext context, object target, BinaryDeserializeTypeMetadata metadata, int depth)
{
var propertyCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var targetType = target.GetType();
var targetTypeName = targetType.Name;
for (int i = 0; i < propertyCount; i++)
{
var propertyNameStartPosition = context.Position;
string propertyName;
int propIndex = -1;
if (context.HasMetadata)
{
propIndex = (int)context.ReadVarUInt();
propertyName = context.GetPropertyName(propIndex);
}
else
{
var typeCode = context.ReadByte();
if (typeCode == BinaryTypeCode.String)
{
propertyName = ReadAndInternString(ref context);
}
else if (typeCode == BinaryTypeCode.StringInterned)
{
propertyName = context.GetInternedString((int)context.ReadVarUInt());
}
else if (typeCode == BinaryTypeCode.StringEmpty)
{
propertyName = string.Empty;
}
else if (typeCode == BinaryTypeCode.StringInternNew)
{
propertyName = ReadAndRegisterInternedString(ref context);
}
else if (BinaryTypeCode.IsFixStr(typeCode))
{
// FixStr: short string with length encoded in type code
var length = BinaryTypeCode.DecodeFixStrLength(typeCode);
propertyName = length == 0 ? string.Empty : context.ReadStringUtf8(length);
}
else
{
throw new AcBinaryDeserializationException(
$"Expected string for property name, got: {typeCode} (0x{typeCode:X2}) at position {propertyNameStartPosition}. " +
$"Target: {targetTypeName}, PropertyIndex: {i}/{propertyCount}, Depth: {depth}",
context.Position, targetType);
}
}
if (!metadata.TryGetProperty(propertyName, out var propInfo) || propInfo == null)
{
// Skip unknown property
SkipValue(ref context);
continue;
}
// OPTIMIZATION: Reuse existing nested objects instead of creating new ones
var peekCode = context.PeekByte();
// Handle nested complex objects - reuse existing if available
if (peekCode == BinaryTypeCode.Object && propInfo.IsComplexType)
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectNested(ref context, existingObj, propInfo.PropertyType, nextDepth);
continue;
}
}
// Handle collections - reuse existing collection and populate items
if (peekCode == BinaryTypeCode.Array && propInfo.IsCollection)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
PopulateListOptimized(ref context, existingList, propInfo, nextDepth);
continue;
}
}
// Default: read value and set (for primitives, strings, null cases)
var positionBeforeRead = context.Position;
try
{
// OPTIMIZATION: Use typed setters for primitives to avoid boxing
if (TryReadAndSetTypedValue(ref context, target, propInfo, peekCode))
continue;
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
catch (InvalidCastException ex)
{
// Add context about which property and what byte code was at the read position
throw new AcBinaryDeserializationException(
$"Type mismatch for property '{propertyName}' (index {i}/{propertyCount}, headerIndex={propIndex}) on '{targetTypeName}'. " +
$"Expected type: '{propInfo.PropertyType.FullName}'. " +
$"PeekCode before read: {peekCode} (0x{peekCode:X2}). " +
$"Position before read: {positionBeforeRead}, current: {context.Position}. " +
$"Depth: {depth}. " +
$"Target type: {targetType.FullName}, Assembly: {targetType.Assembly.GetName().Name} v{targetType.Assembly.GetName().Version}. " +
$"All target properties: [{string.Join(", ", metadata.PropertiesArray.Select(p => $"{p.Name}:{p.PropertyType.Name}"))}]. " +
$"Error: {ex.Message}",
positionBeforeRead,
propInfo.PropertyType,
ex);
}
}
}
/// <summary>
/// Populate nested object, reusing existing object and recursively updating properties.
/// </summary>
private static void PopulateObjectNested(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
PopulateObject(ref context, target, metadata, depth);
}
private static void PopulateObjectMerge(ref BinaryDeserializationContext context, object target, Type targetType, int depth)
{
var metadata = GetTypeMetadata(targetType);
// Skip ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, target);
}
}
var propertyCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
for (int i = 0; i < propertyCount; i++)
{
string propertyName;
if (context.HasMetadata)
{
var propIndex = (int)context.ReadVarUInt();
propertyName = context.GetPropertyName(propIndex);
}
else
{
var typeCode = context.ReadByte();
if (typeCode == BinaryTypeCode.String)
{
propertyName = ReadAndInternString(ref context);
}
else if (typeCode == BinaryTypeCode.StringInterned)
{
propertyName = context.GetInternedString((int)context.ReadVarUInt());
}
else if (typeCode == BinaryTypeCode.StringEmpty)
{
propertyName = string.Empty;
}
else if (typeCode == BinaryTypeCode.StringInternNew)
{
propertyName = ReadAndRegisterInternedString(ref context);
}
else if (BinaryTypeCode.IsFixStr(typeCode))
{
// FixStr: short string with length encoded in type code
var length = BinaryTypeCode.DecodeFixStrLength(typeCode);
propertyName = length == 0 ? string.Empty : context.ReadStringUtf8(length);
}
else
{
throw new AcBinaryDeserializationException(
$"Expected string for property name, got: {typeCode} (0x{typeCode:X2})",
context.Position, targetType);
}
}
if (!metadata.TryGetProperty(propertyName, out var propInfo) || propInfo == null)
{
SkipValue(ref context);
continue;
}
var peekCode = context.PeekByte();
// Handle IId collection merge
if (propInfo.IsIIdCollection && peekCode == BinaryTypeCode.Array)
{
var existingCollection = propInfo.GetValue(target);
if (existingCollection is IList existingList)
{
context.ReadByte(); // consume Array marker
MergeIIdCollection(ref context, existingList, propInfo, depth);
continue;
}
}
// Handle nested object merge
if (peekCode == BinaryTypeCode.Object && !propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
{
var existingObj = propInfo.GetValue(target);
if (existingObj != null)
{
context.ReadByte(); // consume Object marker
PopulateObjectMerge(ref context, existingObj, propInfo.PropertyType, nextDepth);
continue;
}
}
var value = ReadValue(ref context, propInfo.PropertyType, nextDepth);
propInfo.SetValue(target, value);
}
}
/// <summary>
/// Optimized IId collection merge with capacity hints and reduced boxing.
/// </summary>
private static void MergeIIdCollection(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth)
{
var elementType = propInfo.ElementType!;
var idGetter = propInfo.ElementIdGetter!;
var idType = propInfo.ElementIdType!;
var count = existingList.Count;
var acObservable = existingList as IAcObservableCollection;
acObservable?.BeginUpdate();
try
{
// Build lookup dictionary with capacity hint
Dictionary<object, object>? existingById = null;
if (count > 0)
{
existingById = new Dictionary<object, object>(count);
for (var idx = 0; idx < count; idx++)
{
var item = existingList[idx];
if (item != null)
{
var id = idGetter(item);
if (id != null && !IsDefaultValue(id, idType))
existingById[id] = item;
}
}
}
var arrayCount = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var elementMetadata = GetTypeMetadata(elementType);
// Track which IDs we see in source (for orphan removal)
HashSet<object>? sourceIds = context.RemoveOrphanedItems && existingById != null
? new HashSet<object>(arrayCount)
: null;
for (int i = 0; i < arrayCount; i++)
{
var itemCode = context.PeekByte();
if (itemCode != BinaryTypeCode.Object)
{
var value = ReadValue(ref context, elementType, nextDepth);
if (value != null)
existingList.Add(value);
continue;
}
context.ReadByte(); // consume Object marker
var newItem = CreateInstance(elementType, elementMetadata);
if (newItem == null) continue;
// Read ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
context.RegisterObject(refId, newItem);
}
PopulateObject(ref context, newItem, elementMetadata, nextDepth);
var itemId = idGetter(newItem);
if (itemId != null && !IsDefaultValue(itemId, idType))
{
// Track this ID as seen in source
sourceIds?.Add(itemId);
if (existingById != null && existingById.TryGetValue(itemId, out var existingItem))
{
// Copy properties to existing item
CopyProperties(newItem, existingItem, elementMetadata);
continue;
}
}
existingList.Add(newItem);
}
// Remove orphaned items (items in destination but not in source)
if (context.RemoveOrphanedItems && existingById != null && sourceIds != null)
{
// Find items to remove (those not in sourceIds)
var itemsToRemove = new List<object>();
foreach (var kvp in existingById)
{
if (!sourceIds.Contains(kvp.Key))
{
itemsToRemove.Add(kvp.Value);
}
}
// Remove orphaned items
foreach (var item in itemsToRemove)
{
existingList.Remove(item);
}
}
}
finally
{
acObservable?.EndUpdate();
}
}
private static void CopyProperties(object source, object target, BinaryDeserializeTypeMetadata metadata)
{
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);
}
}
/// <summary>
/// Determines if a type is a complex type (not primitive, string, or simple value type).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsComplexType(Type type)
{
if (type.IsPrimitive) return false;
if (ReferenceEquals(type, StringType)) return false;
if (type.IsEnum) return false;
if (ReferenceEquals(type, GuidType)) return false;
if (ReferenceEquals(type, DateTimeType)) return false;
if (ReferenceEquals(type, DecimalType)) return false;
if (ReferenceEquals(type, TimeSpanType)) return false;
if (ReferenceEquals(type, DateTimeOffsetType)) return false;
if (Nullable.GetUnderlyingType(type) != null) return false;
return true;
}
/// <summary>
/// Optimized list populate that reuses existing items when possible.
/// </summary>
private static void PopulateListOptimized(ref BinaryDeserializationContext context, IList existingList, BinaryPropertySetterInfo propInfo, int depth)
{
var elementType = propInfo.ElementType ?? typeof(object);
var count = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
var acObservable = existingList as IAcObservableCollection;
acObservable?.BeginUpdate();
try
{
var existingCount = existingList.Count;
var elementMetadata = IsComplexType(elementType) ? GetTypeMetadata(elementType) : null;
for (int i = 0; i < count; i++)
{
var peekCode = context.PeekByte();
// If we have an existing item at this index and the incoming is an object, reuse it
if (i < existingCount && peekCode == BinaryTypeCode.Object && elementMetadata != null)
{
var existingItem = existingList[i];
if (existingItem != null)
{
context.ReadByte(); // consume Object marker
// Handle ref ID if present
if (context.HasReferenceHandling)
{
var refId = context.ReadVarInt();
if (refId > 0)
{
context.RegisterObject(refId, existingItem);
}
}
PopulateObject(ref context, existingItem, elementMetadata, nextDepth);
continue;
}
}
// Read new value
var value = ReadValue(ref context, elementType, nextDepth);
if (i < existingCount)
{
// Replace existing item
existingList[i] = value;
}
else
{
// Add new item
existingList.Add(value);
}
}
// Remove extra items if new list is shorter
while (existingList.Count > count)
{
existingList.RemoveAt(existingList.Count - 1);
}
}
finally
{
acObservable?.EndUpdate();
}
}
#endregion
#region Array Reading
@ -1557,93 +1153,6 @@ public static partial class AcBinaryDeserializer
return null;
}
private static void PopulateList(ref BinaryDeserializationContext context, IList targetList, Type listType, int depth)
{
var elementType = GetCollectionElementType(listType) ?? typeof(object);
var acObservable = targetList as IAcObservableCollection;
acObservable?.BeginUpdate();
try
{
targetList.Clear();
var count = (int)context.ReadVarUInt();
var nextDepth = depth + 1;
// ChainMode: Get IId info for element type
var isIId = false;
Type? idType = null;
Func<object, object?>? idGetter = null;
if (context.IsChainMode)
{
var idInfo = GetIdInfo(elementType);
isIId = idInfo.IsId;
idType = idInfo.IdType;
if (isIId && idType != null)
{
var idProp = elementType.GetProperty("Id");
if (idProp != null)
idGetter = AcSerializerCommon.CreateCompiledGetter(elementType, idProp);
}
}
for (int i = 0; i < count; i++)
{
var value = ReadValue(ref context, elementType, nextDepth);
// ChainMode: Check if we already have this IId object
if (context.IsChainMode && value != null && idGetter != null && idType != null)
{
var id = idGetter(value);
if (id != null && !IsDefaultValue(id, idType))
{
if (context.ChainTracker!.TryGetObject(elementType, id, out var existingObj))
{
// Use existing object instead of new one
targetList.Add(existingObj);
continue;
}
}
}
targetList.Add(value);
}
}
finally
{
acObservable?.EndUpdate();
}
}
private static void PopulateListMerge(ref BinaryDeserializationContext context, IList targetList, Type listType, int depth)
{
var elementType = GetCollectionElementType(listType) ?? typeof(object);
var (isId, idType) = GetIdInfo(elementType);
if (!isId || idType == null)
{
// No IId, just replace
PopulateList(ref context, targetList, listType, depth);
return;
}
// IId merge logic
var idProp = elementType.GetProperty("Id");
if (idProp == null)
{
PopulateList(ref context, targetList, listType, depth);
return;
}
var idGetter = CreateCompiledGetter(elementType, idProp);
var propInfo = new BinaryPropertySetterInfo(
"Items", elementType, true, elementType, idType, idGetter);
MergeIIdCollection(ref context, targetList, propInfo, depth);
}
#endregion
#region Dictionary Reading
@ -1830,38 +1339,8 @@ public static partial class AcBinaryDeserializer
var propCount = (int)context.ReadVarUInt();
for (int i = 0; i < propCount; i++)
{
// Skip property name - but must register in intern table!
if (context.HasMetadata)
{
context.ReadVarUInt();
}
else
{
var nameCode = context.ReadByte();
if (nameCode == BinaryTypeCode.String)
{
// CRITICAL FIX: Must register property name in intern table even when skipping!
SkipAndInternString(ref context);
}
else if (nameCode == BinaryTypeCode.StringInterned)
{
// Just read the index, no registration needed
context.ReadVarUInt();
}
else if (nameCode == BinaryTypeCode.StringInternNew)
{
// New interned string - must register even when skipping!
SkipAndRegisterInternedString(ref context);
}
else if (BinaryTypeCode.IsFixStr(nameCode))
{
// FixStr: short string, just skip the bytes
var length = BinaryTypeCode.DecodeFixStrLength(nameCode);
if (length > 0)
context.Skip(length);
}
// StringEmpty doesn't need any action
}
// Skip property index (VarUInt)
context.ReadVarUInt();
// Skip value
SkipValue(ref context);
@ -1891,9 +1370,40 @@ public static partial class AcBinaryDeserializer
#region Type Metadata
/// <summary>
/// Gets type metadata with ThreadLocal caching for hot path optimization.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static BinaryDeserializeTypeMetadata GetTypeMetadata(Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new BinaryDeserializeTypeMetadata(t));
{
// Fast path: check ThreadLocal cache first
var localCache = t_deserializeTypeMetadataLocalCache;
if (localCache != null && localCache.TryGetValue(type, out var cached))
{
return cached;
}
// Slow path: get from global cache and populate local cache
return GetTypeMetadataSlow(type);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static BinaryDeserializeTypeMetadata GetTypeMetadataSlow(Type type)
{
var metadata = TypeMetadataCache.GetOrAdd(type, static t => new BinaryDeserializeTypeMetadata(t));
// Get or create local cache
var localCache = t_deserializeTypeMetadataLocalCache ??= new Dictionary<Type, BinaryDeserializeTypeMetadata>();
// Clear when full - reuses internal array, better than new Dictionary()
if (localCache.Count >= MaxLocalCacheSize)
{
localCache.Clear();
}
localCache[type] = metadata;
return metadata;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? CreateInstance(Type type, BinaryDeserializeTypeMetadata metadata)

View File

@ -138,6 +138,7 @@ public static partial class AcBinarySerializer
_propertyNameList?.Clear();
_internedStringList?.Clear();
_internedStringUtf8?.Clear();
// Reset cached property indices
ResetCachedPropertyIndices();
@ -186,11 +187,17 @@ public static partial class AcBinarySerializer
#region String Interning
/// <summary>
/// Cached UTF8 bytes for interned strings to avoid re-encoding in FinalizeHeaderSections.
/// </summary>
private List<byte[]>? _internedStringUtf8;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int RegisterInternedString(string value)
{
_internedStrings ??= new Dictionary<string, int>(InitialInternCapacity, StringComparer.Ordinal);
_internedStringList ??= new List<string>(InitialInternCapacity);
_internedStringUtf8 ??= new List<byte[]>(InitialInternCapacity);
// Fast path: check bloom filter first
var hash = GetStringHash(value);
@ -200,6 +207,8 @@ public static partial class AcBinarySerializer
var newIndex = _internedStringList.Count;
_internedStrings[value] = newIndex;
_internedStringList.Add(value);
// Cache UTF8 bytes immediately
_internedStringUtf8.Add(GetUtf8BytesCached(value));
BloomFilterAdd(hash);
return newIndex;
}
@ -213,10 +222,29 @@ public static partial class AcBinarySerializer
index = _internedStringList.Count;
_internedStringList.Add(value);
// Cache UTF8 bytes immediately
_internedStringUtf8.Add(GetUtf8BytesCached(value));
BloomFilterAdd(hash);
return index;
}
/// <summary>
/// Get UTF8 bytes for a string, optimized for ASCII strings.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static byte[] GetUtf8BytesCached(string value)
{
// Fast path for ASCII strings - direct char to byte conversion
if (System.Text.Ascii.IsValid(value))
{
var bytes = new byte[value.Length];
System.Text.Ascii.FromUtf16(value.AsSpan(), bytes, out _);
return bytes;
}
// Standard path for multi-byte UTF8
return Utf8NoBom.GetBytes(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int GetStringHash(string value)
{
@ -989,14 +1017,27 @@ public static partial class AcBinarySerializer
var hasPropertyNames = UseMetadata && _propertyNameList is { Count: > 0 };
var hasInternTable = UseStringInterning && _internedStringList is { Count: > 0 };
// Calculate actual header size first
// Fast path: no header payload needed
if (!hasPropertyNames && !hasInternTable)
{
// Write header flags only
byte flags = BinaryTypeCode.HeaderFlagsBase;
if (UseReferenceHandling)
flags |= BinaryTypeCode.HeaderFlag_ReferenceHandling;
_buffer[_headerPosition] = AcBinarySerializerOptions.FormatVersion;
_buffer[_headerPosition + 1] = flags;
return;
}
// Calculate actual header size using cached UTF8 bytes
var actualSize = 0;
if (hasPropertyNames)
{
actualSize += GetVarUIntSize((uint)_propertyNameList!.Count);
foreach (var name in _propertyNameList)
{
var byteCount = Utf8NoBom.GetByteCount(name);
var byteCount = System.Text.Ascii.IsValid(name) ? name.Length : Utf8NoBom.GetByteCount(name);
actualSize += GetVarUIntSize((uint)byteCount) + byteCount;
}
}
@ -1004,10 +1045,11 @@ public static partial class AcBinarySerializer
if (hasInternTable)
{
actualSize += GetVarUIntSize((uint)_internedStringList!.Count);
foreach (var value in _internedStringList)
// Use cached UTF8 byte lengths
for (var i = 0; i < _internedStringUtf8!.Count; i++)
{
var byteCount = Utf8NoBom.GetByteCount(value);
actualSize += GetVarUIntSize((uint)byteCount) + byteCount;
var utf8Bytes = _internedStringUtf8[i];
actualSize += GetVarUIntSize((uint)utf8Bytes.Length) + utf8Bytes.Length;
}
}
@ -1031,7 +1073,7 @@ public static partial class AcBinarySerializer
}
}
// Write header payload directly to buffer (no ArrayBufferWriter allocation)
// Write header payload directly to buffer using cached UTF8 bytes
var headerPos = _headerPosition + 2;
if (hasPropertyNames)
@ -1039,30 +1081,54 @@ public static partial class AcBinarySerializer
headerPos = WriteVarUIntAt(headerPos, (uint)_propertyNameList!.Count);
foreach (var name in _propertyNameList)
{
headerPos = WriteStringAt(headerPos, name);
headerPos = WriteStringAtOptimized(headerPos, name);
}
}
if (hasInternTable)
{
headerPos = WriteVarUIntAt(headerPos, (uint)_internedStringList!.Count);
foreach (var value in _internedStringList)
// Use cached UTF8 bytes - no re-encoding needed!
for (var i = 0; i < _internedStringUtf8!.Count; i++)
{
headerPos = WriteStringAt(headerPos, value);
var utf8Bytes = _internedStringUtf8[i];
headerPos = WriteVarUIntAt(headerPos, (uint)utf8Bytes.Length);
utf8Bytes.CopyTo(_buffer.AsSpan(headerPos));
headerPos += utf8Bytes.Length;
}
}
// Write header flags
byte flags = BinaryTypeCode.HeaderFlagsBase;
byte flags2 = BinaryTypeCode.HeaderFlagsBase;
if (hasPropertyNames)
flags |= BinaryTypeCode.HeaderFlag_Metadata;
flags2 |= BinaryTypeCode.HeaderFlag_Metadata;
if (UseReferenceHandling)
flags |= BinaryTypeCode.HeaderFlag_ReferenceHandling;
flags2 |= BinaryTypeCode.HeaderFlag_ReferenceHandling;
if (hasInternTable)
flags |= BinaryTypeCode.HeaderFlag_StringInternTable;
flags2 |= BinaryTypeCode.HeaderFlag_StringInternTable;
_buffer[_headerPosition] = AcBinarySerializerOptions.FormatVersion;
_buffer[_headerPosition + 1] = flags;
_buffer[_headerPosition + 1] = flags2;
}
/// <summary>
/// Writes UTF8 string at specific position, optimized for ASCII strings.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int WriteStringAtOptimized(int pos, string value)
{
// Fast path for ASCII strings
if (System.Text.Ascii.IsValid(value))
{
pos = WriteVarUIntAt(pos, (uint)value.Length);
System.Text.Ascii.FromUtf16(value.AsSpan(), _buffer.AsSpan(pos, value.Length), out _);
return pos + value.Length;
}
// Standard path for multi-byte UTF8
var byteCount = Utf8NoBom.GetByteCount(value);
pos = WriteVarUIntAt(pos, (uint)byteCount);
Utf8NoBom.GetBytes(value.AsSpan(), _buffer.AsSpan(pos, byteCount));
return pos + byteCount;
}
/// <summary>
@ -1080,20 +1146,6 @@ public static partial class AcBinarySerializer
return pos;
}
/// <summary>
/// Writes UTF8 string at specific position (length-prefixed) and returns new position.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int WriteStringAt(int pos, string value)
{
var byteCount = Utf8NoBom.GetByteCount(value);
pos = WriteVarUIntAt(pos, (uint)byteCount);
Utf8NoBom.GetBytes(value.AsSpan(), _buffer.AsSpan(pos, byteCount));
return pos + byteCount;
}
// Remove old methods: WriteHeaderVarUInt, WriteHeaderString (no longer needed)
#endregion
#region Reference Handling

View File

@ -1,23 +1,27 @@
using System;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using AyCode.Core.Serializers;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinarySerializer
{
internal sealed class BinaryTypeMetadata : TypeMetadataBase
internal sealed class BinaryTypeMetadata : BinaryTypeMetadataBase
{
public BinaryPropertyAccessor[] Properties { get; }
public BinaryTypeMetadata(Type type) : base(type)
{
Properties = GetSerializableProperties(type, requiresRead: true, requiresWrite: false)
.Select(p => new BinaryPropertyAccessor(p, type))
.ToArray();
var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: false);
Properties = new BinaryPropertyAccessor[orderedProperties.Length];
for (var i = 0; i < orderedProperties.Length; i++)
{
var accessor = new BinaryPropertyAccessor(orderedProperties[i], type);
accessor.PropertyIndex = i;
Properties[i] = accessor;
}
}
}

View File

@ -26,6 +26,22 @@ namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinarySerializer
{
private static readonly ConcurrentDictionary<Type, BinaryTypeMetadata> TypeMetadataCache = new();
/// <summary>
/// ThreadLocal cache for hot path type metadata lookup.
/// Avoids ConcurrentDictionary overhead for frequently accessed types.
/// </summary>
[ThreadStatic]
private static Dictionary<Type, BinaryTypeMetadata>? t_typeMetadataLocalCache;
/// <summary>
/// Maximum local cache size before clearing.
/// Clear() is preferred over new Dictionary() because:
/// 1. Reuses already allocated internal array (no new allocation)
/// 2. Reduces GC pressure
/// 3. Adaptive: if cache grew to 64 once, it stays at that capacity
/// </summary>
private const int MaxLocalCacheSize = 64;
// Pre-computed UTF8 encoder for string operations
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
@ -190,10 +206,8 @@ public static partial class AcBinarySerializer
ScanReferences(value, context, 0);
}
if (options.UseMetadata && !IsPrimitiveOrStringFast(runtimeType))
{
RegisterMetadataForType(runtimeType, context);
}
// Property index-based serialization - no metadata registration needed!
// PropertyIndex is deterministic (alphabetically ordered) and consistent across platforms
// Estimate and reserve header space to avoid body shift later
var estimatedHeaderSize = context.EstimateHeaderPayloadSize();
@ -387,7 +401,7 @@ public static partial class AcBinarySerializer
/// <summary>
/// Optimized primitive writer using TypeCode dispatch.
/// Avoids Nullable.GetUnderlyingType in hot path by pre-computing in metadata.
/// Avoids Nullable.GetUnderlyingType in hot path by using cached type info.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryWritePrimitive(object value, Type type, BinarySerializationContext context)
@ -446,13 +460,6 @@ public static partial class AcBinarySerializer
return true;
}
// Handle nullable types
var underlyingType = Nullable.GetUnderlyingType(type);
if (underlyingType != null)
{
return TryWritePrimitive(value, underlyingType, context);
}
// Handle special types by reference comparison (faster than type equality)
if (ReferenceEquals(type, GuidType))
{
@ -475,6 +482,15 @@ public static partial class AcBinarySerializer
return true;
}
// Handle nullable types - use cached check instead of GetUnderlyingType
// For nullable, value is already unwrapped when boxed, so we can use value.GetType()
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
{
// When boxed, nullable value types are unwrapped to their underlying type
// So we can just call TryWritePrimitive with the actual runtime type
return TryWritePrimitive(value, value.GetType(), context);
}
return false;
}
@ -607,10 +623,10 @@ public static partial class AcBinarySerializer
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteEnum(object value, BinarySerializationContext context
)
private static void WriteEnum(object value, BinarySerializationContext context)
{
var intValue = Convert.ToInt32(value);
// Use direct unboxing instead of Convert.ToInt32 to avoid NumberFormatInfo overhead
var intValue = GetEnumAsInt32Fast(value);
if (BinaryTypeCode.TryEncodeTinyInt(intValue, out var tiny))
{
context.WriteByte(BinaryTypeCode.Enum);
@ -622,6 +638,33 @@ public static partial class AcBinarySerializer
context.WriteVarInt(intValue);
}
/// <summary>
/// Fast enum to int conversion avoiding Convert.ToInt32 overhead.
/// Handles all common enum underlying types.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int GetEnumAsInt32Fast(object enumValue)
{
var type = enumValue.GetType();
var underlyingType = type.GetEnumUnderlyingType();
if (ReferenceEquals(underlyingType, IntType))
return (int)enumValue;
if (ReferenceEquals(underlyingType, typeof(byte)))
return (byte)enumValue;
if (ReferenceEquals(underlyingType, typeof(sbyte)))
return (sbyte)enumValue;
if (ReferenceEquals(underlyingType, typeof(short)))
return (short)enumValue;
if (ReferenceEquals(underlyingType, typeof(ushort)))
return (ushort)enumValue;
if (ReferenceEquals(underlyingType, typeof(uint)))
return unchecked((int)(uint)enumValue);
// Fallback for rare cases (long, ulong)
return Convert.ToInt32(enumValue);
}
/// <summary>
/// Optimized string writer with FixStr for short strings.
/// Uses stackalloc for small strings to avoid allocations.
@ -706,18 +749,10 @@ public static partial class AcBinarySerializer
if (IsPropertyDefaultOrNull(value, prop))
continue;
// Write property name/index
if (context.UseMetadata)
{
var propIndex = prop.CachedPropertyNameIndex >= 0
? prop.CachedPropertyNameIndex
: context.GetPropertyNameIndex(prop.Name);
context.WriteVarUInt((uint)propIndex);
}
else
{
context.WritePreencodedPropertyName(prop.NameUtf8);
}
// Write property index directly - no dictionary lookup needed!
// PropertyIndex is deterministic (based on alphabetical ordering)
// and consistent across all platforms (WASM, iOS, Android, Windows)
context.WriteVarUInt((uint)prop.PropertyIndex);
// Write property value
WritePropertyValue(value, prop, context, nextDepth);
@ -1059,9 +1094,41 @@ public static partial class AcBinarySerializer
return null;
}
/// <summary>
/// Gets type metadata with ThreadLocal caching for hot path optimization.
/// Falls back to ConcurrentDictionary for cache misses.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static BinaryTypeMetadata GetTypeMetadata(Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new BinaryTypeMetadata(t));
{
// Fast path: check ThreadLocal cache first
var localCache = t_typeMetadataLocalCache;
if (localCache != null && localCache.TryGetValue(type, out var cached))
{
return cached;
}
// Slow path: get from global cache and populate local cache
return GetTypeMetadataSlow(type);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static BinaryTypeMetadata GetTypeMetadataSlow(Type type)
{
var metadata = TypeMetadataCache.GetOrAdd(type, static t => new BinaryTypeMetadata(t));
// Get or create local cache
var localCache = t_typeMetadataLocalCache ??= new Dictionary<Type, BinaryTypeMetadata>();
// Clear when full - reuses internal array, better than new Dictionary()
if (localCache.Count >= MaxLocalCacheSize)
{
localCache.Clear();
}
localCache[type] = metadata;
return metadata;
}
// Type metadata helpers moved to AcBinarySerializer.BinaryTypeMetadata.cs

View File

@ -16,6 +16,13 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
/// </summary>
internal int CachedPropertyNameIndex = -1;
/// <summary>
/// Deterministic property index based on alphabetical ordering of property names.
/// This is computed once during metadata creation and is consistent across all platforms.
/// Used for fast serialization without dictionary lookup.
/// </summary>
public int PropertyIndex { get; internal set; } = -1;
/// <summary>
/// The accessor type for fast typed getter dispatch.
/// </summary>

View File

@ -0,0 +1,41 @@
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
/// <summary>
/// Base class for binary serializer/deserializer type metadata.
/// Provides common functionality for IId detection and property ordering.
/// Properties are ordered alphabetically by name for deterministic serialization across platforms.
/// </summary>
public abstract class BinaryTypeMetadataBase : TypeMetadataBase
{
/// <summary>
/// Whether this type implements IId interface.
/// </summary>
public bool IsIId { get; }
/// <summary>
/// The Id property type if IsIId is true.
/// </summary>
public Type? IdType { get; }
/// <summary>
/// Compiled getter for the Id property (if IsIId is true).
/// </summary>
public Func<object, object?>? IdGetter { get; }
protected BinaryTypeMetadataBase(Type type) : base(type)
{
var (isIId, idType) = GetIdInfo(type);
IsIId = isIId;
IdType = idType;
if (isIId)
{
var idProp = type.GetProperty("Id");
if (idProp != null)
IdGetter = AcSerializerCommon.CreateCompiledGetter(type, idProp);
}
}
}

View File

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Reflection;
using static AyCode.Core.Helpers.JsonUtilities;
@ -9,6 +10,13 @@ namespace AyCode.Core.Serializers;
/// </summary>
public abstract class TypeMetadataBase
{
/// <summary>
/// Cache for serializable properties per type.
/// Key: (Type, requiresWrite) - since requiresRead is always true in practice.
/// Value: PropertyInfo[] ordered alphabetically by name for deterministic serialization.
/// </summary>
private static readonly ConcurrentDictionary<(Type, bool), PropertyInfo[]> OrderedPropertiesCache = new();
/// <summary>
/// Compiled parameterless constructor for the type.
/// Null if the type is abstract or has no parameterless constructor.
@ -21,21 +29,29 @@ public abstract class TypeMetadataBase
}
/// <summary>
/// Gets the properties that should be serialized for a type.
/// Gets serializable properties ordered alphabetically by name.
/// Results are cached per type and requiresWrite combination.
/// Ordering ensures deterministic serialization across all platforms (Windows, WASM, iOS, Android).
/// </summary>
/// <param name="type">The type to analyze.</param>
/// <param name="requiresRead">Whether the property must be readable.</param>
/// <param name="requiresWrite">Whether the property must be writable.</param>
/// <returns>Enumerable of properties that meet the criteria.</returns>
protected static IEnumerable<PropertyInfo> GetSerializableProperties(
/// <param name="requiresRead">Whether the property must be readable (always true in practice).</param>
/// <param name="requiresWrite">Whether the property must be writable (true for deserialization).</param>
/// <returns>Array of properties ordered by name (cached).</returns>
protected static PropertyInfo[] GetSerializableProperties(
Type type,
bool requiresRead = true,
bool requiresWrite = false)
{
return type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => (!requiresRead || p.CanRead) &&
(!requiresWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p));
return OrderedPropertiesCache.GetOrAdd((type, requiresWrite), static key =>
{
var (t, needsWrite) = key;
return t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead &&
(!needsWrite || p.CanWrite) &&
p.GetIndexParameters().Length == 0 &&
!HasJsonIgnoreAttribute(p))
.OrderBy(static p => p.Name, StringComparer.Ordinal)
.ToArray();
});
}
}