Refactor type metadata caching for serializers/deserializers

Major overhaul of type metadata caching:
- Introduced generic TypeMetadataBase<T> with built-in ThreadLocal and global caching, replacing all per-class static caches.
- Updated all serializer/deserializer metadata classes to use the new base, unifying and simplifying cache logic.
- Increased ThreadLocal cache size for better performance with many types.
- Standardized property collection order and improved property array usage.
- Updated documentation and comments to reflect new caching strategy.

These changes improve performance, scalability, and maintainability while reducing code duplication.
This commit is contained in:
Loretta 2026-01-03 09:50:30 +01:00
parent 60ca154c6f
commit 9ad84ec21e
9 changed files with 111 additions and 152 deletions

View File

@ -8,7 +8,7 @@ namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinaryDeserializer
{
internal sealed class BinaryDeserializeTypeMetadata : BinaryTypeMetadataBase
internal sealed class BinaryDeserializeTypeMetadata : DeserializeTypeMetadataBase<BinaryDeserializeTypeMetadata>
{
/// <summary>
/// Properties array ordered alphabetically by name for index-based lookup.
@ -19,7 +19,7 @@ public static partial class AcBinaryDeserializer
public BinaryDeserializeTypeMetadata(Type type) : base(type)
{
var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
PropertiesArray = new BinaryPropertySetterInfo[orderedProperties.Length];
for (var i = 0; i < orderedProperties.Length; i++)
{

View File

@ -41,30 +41,13 @@ public class AcBinaryDeserializationException : Exception
/// </summary>
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);
@ -769,7 +752,7 @@ public static partial class AcBinaryDeserializer
}
/// <summary>
/// Sima string olvasása - NEM regisztrál az intern táblába.
/// Sima string olvas<EFBFBD>sa - NEM regisztr<74>l az intern t<>bl<62>ba.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadPlainString(ref BinaryDeserializationContext context)
@ -780,7 +763,7 @@ public static partial class AcBinaryDeserializer
}
/// <summary>
/// Új internált string olvasása és regisztrálása az intern táblába.
/// <EFBFBD>j intern<72>lt string olvas<61>sa <20>s regisztr<74>l<EFBFBD>sa az intern t<>bl<62>ba.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ReadAndRegisterInternedString(ref BinaryDeserializationContext context)
@ -869,9 +852,9 @@ public static partial class AcBinaryDeserializer
// 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)
if (localCache.Count >= TypeMetadataBase.MaxLocalCacheSize)
{
localCache.Clear();
}
@ -1250,14 +1233,14 @@ public static partial class AcBinaryDeserializer
context.Skip(16);
return;
case BinaryTypeCode.String:
// Sima string - nem regisztrálunk
// Sima string - nem regisztr<EFBFBD>lunk
SkipPlainString(ref context);
return;
case BinaryTypeCode.StringInterned:
context.ReadVarUInt();
return;
case BinaryTypeCode.StringInternNew:
// Új internált string - regisztrálni kell még skip esetén is
// <EFBFBD>j intern<72>lt string - regisztr<74>lni kell m<>g skip eset<65>n is
SkipAndRegisterInternedString(ref context);
return;
case BinaryTypeCode.ByteArray:
@ -1285,7 +1268,7 @@ public static partial class AcBinaryDeserializer
}
/// <summary>
/// Sima string kihagyása - NEM regisztrál.
/// Sima string kihagy<EFBFBD>sa - NEM regisztr<74>l.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SkipPlainString(ref BinaryDeserializationContext context)
@ -1298,7 +1281,7 @@ public static partial class AcBinaryDeserializer
}
/// <summary>
/// Új internált string kihagyása - DE regisztrálni kell!
/// <EFBFBD>j intern<72>lt string kihagy<67>sa - DE regisztr<74>lni kell!
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SkipAndRegisterInternedString(ref BinaryDeserializationContext context)
@ -1369,38 +1352,11 @@ public static partial class AcBinaryDeserializer
/// <summary>
/// Gets type metadata with ThreadLocal caching for hot path optimization.
/// Uses built-in cache from BinaryDeserializeTypeMetadata base class (zero ref parameter overhead).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static BinaryDeserializeTypeMetadata GetTypeMetadata(Type type)
{
// 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;
}
=> BinaryDeserializeTypeMetadata.GetOrCreateMetadata(type, static t => new BinaryDeserializeTypeMetadata(t));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static object? CreateInstance(Type type, BinaryDeserializeTypeMetadata metadata)

View File

@ -7,7 +7,7 @@ namespace AyCode.Core.Serializers.Binaries;
public static partial class AcBinarySerializer
{
internal sealed class BinaryTypeMetadata : BinaryTypeMetadataBase
internal sealed class BinaryTypeMetadata : TypeMetadataBase<BinaryTypeMetadata>
{
public BinaryPropertyAccessor[] Properties { get; }
@ -17,7 +17,7 @@ public static partial class AcBinarySerializer
// This ensures read-only properties (like computed properties) are excluded
// and property indices are consistent between serialization and deserialization
var orderedProperties = GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
Properties = new BinaryPropertyAccessor[orderedProperties.Length];
for (var i = 0; i < orderedProperties.Length; i++)
{

View File

@ -25,23 +25,6 @@ namespace AyCode.Core.Serializers.Binaries;
/// </summary>
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);
@ -1096,39 +1079,11 @@ public static partial class AcBinarySerializer
/// <summary>
/// Gets type metadata with ThreadLocal caching for hot path optimization.
/// Falls back to ConcurrentDictionary for cache misses.
/// Uses built-in cache from BinaryTypeMetadata base class (zero ref parameter overhead).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static BinaryTypeMetadata GetTypeMetadata(Type type)
{
// 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;
}
=> BinaryTypeMetadata.GetOrCreateMetadata(type, static t => new BinaryTypeMetadata(t));
// Type metadata helpers moved to AcBinarySerializer.BinaryTypeMetadata.cs

View File

@ -1,14 +0,0 @@
namespace AyCode.Core.Serializers.Binaries;
/// <summary>
/// Base class for binary serializer/deserializer type metadata.
/// Extends DeserializeTypeMetadataBase (which provides IId caching).
/// Properties are ordered alphabetically by name for deterministic serialization across platforms.
/// </summary>
public abstract class BinaryTypeMetadataBase : DeserializeTypeMetadataBase
{
protected BinaryTypeMetadataBase(Type type) : base(type)
{
// IId info is now cached in DeserializeTypeMetadataBase
}
}

View File

@ -7,20 +7,22 @@ namespace AyCode.Core.Serializers;
/// Base class for deserializer type metadata.
/// Extends TypeMetadataBase with IId detection for efficient chain/merge operations.
/// Used by both JSON and Binary deserializers.
/// Generic version provides built-in ThreadLocal caching with zero ref parameter overhead.
/// </summary>
public abstract class DeserializeTypeMetadataBase : TypeMetadataBase
public abstract class DeserializeTypeMetadataBase<TMetadata> : TypeMetadataBase<TMetadata>
where TMetadata : DeserializeTypeMetadataBase<TMetadata>
{
/// <summary>
/// Whether this type implements IId interface.
/// Cached at metadata creation time to avoid runtime reflection.
/// </summary>
public bool IsIId { get; }
/// <summary>
/// The Id property type if IsIId is true, null otherwise.
/// </summary>
public Type? IdType { get; }
/// <summary>
/// Compiled getter for the Id property (if IsIId is true).
/// Pre-compiled delegate avoids reflection overhead during deserialization.
@ -33,7 +35,7 @@ public abstract class DeserializeTypeMetadataBase : TypeMetadataBase
var idInfo = GetIdInfo(type);
IsIId = idInfo.IsId;
IdType = idInfo.IdType;
if (IsIId)
{
var idProp = type.GetProperty("Id");

View File

@ -9,39 +9,37 @@ namespace AyCode.Core.Serializers.Jsons;
public static partial class AcJsonDeserializer
{
private static readonly ConcurrentDictionary<Type, JsonDeserializeTypeMetadata> TypeMetadataCache = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static JsonDeserializeTypeMetadata GetTypeMetadata(in Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new JsonDeserializeTypeMetadata(t));
=> JsonDeserializeTypeMetadata.GetOrCreateMetadata(type, static t => new JsonDeserializeTypeMetadata(t));
/// <summary>
/// JSON deserialization type metadata.
/// Extends DeserializeTypeMetadataBase which provides cached IId info.
/// </summary>
private sealed class JsonDeserializeTypeMetadata : DeserializeTypeMetadataBase
private sealed class JsonDeserializeTypeMetadata : DeserializeTypeMetadataBase<JsonDeserializeTypeMetadata>
{
public FrozenDictionary<string, PropertySetterInfo> PropertySettersFrozen { get; }
public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects)
public JsonDeserializeTypeMetadata(Type type) : base(type)
{
var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: true).ToList();
var props = GetSerializableProperties(type, requiresRead: true, requiresWrite: true);
var propertySetters = new Dictionary<string, PropertySetterInfo>(props.Count, StringComparer.OrdinalIgnoreCase);
var propsArray = new PropertySetterInfo[props.Count];
var propertySetters = new Dictionary<string, PropertySetterInfo>(props.Length, StringComparer.OrdinalIgnoreCase);
var propsArray = new PropertySetterInfo[props.Length];
var index = 0;
foreach (var prop in props)
{
var propInfo = new PropertySetterInfo(prop, type);
propertySetters[prop.Name] = propInfo;
propsArray[index++] = propInfo;
}
PropertiesArray = propsArray;
PropertySettersFrozen = propertySetters.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
// IId info (IsIId, IdType, IdGetter) is now cached in DeserializeTypeMetadataBase
}

View File

@ -7,13 +7,11 @@ namespace AyCode.Core.Serializers.Jsons;
public static partial class AcJsonSerializer
{
private static readonly ConcurrentDictionary<Type, JsonTypeMetadata> TypeMetadataCache = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static JsonTypeMetadata GetTypeMetadata(in Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new JsonTypeMetadata(t));
=> JsonTypeMetadata.GetOrCreateMetadata(type, static t => new JsonTypeMetadata(t));
private sealed class JsonTypeMetadata : TypeMetadataBase
private sealed class JsonTypeMetadata : TypeMetadataBase<JsonTypeMetadata>
{
public PropertyAccessor[] Properties { get; }

View File

@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers;
@ -10,6 +11,22 @@ namespace AyCode.Core.Serializers;
/// </summary>
public abstract class TypeMetadataBase
{
/// <summary>
/// Global shared cache for all metadata types.
/// Key: (Type sourceType, Type metadataType) - ensures each type can have multiple metadata representations
/// Value: TypeMetadataBase instance (BinaryTypeMetadata, JsonTypeMetadata, etc.)
/// This single cache is shared across Binary/JSON Serializers/Deserializers.
/// </summary>
protected static readonly ConcurrentDictionary<(Type, Type), TypeMetadataBase> GlobalMetadataCache = new();
/// <summary>
/// Maximum ThreadLocal cache size before clearing.
/// Set to 256 because if a system uses 256+ different types, they're likely all frequently accessed.
/// Minimal memory overhead (256 dictionary entries ≈ 8KB) vs significant lookup performance benefit.
/// Clear() reuses internal array - no new allocation, just resets count.
/// </summary>
internal const int MaxLocalCacheSize = 1024;
/// <summary>
/// Cache for serializable properties per type.
/// Key: (Type, requiresWrite) - since requiresRead is always true in practice.
@ -51,24 +68,14 @@ public abstract class TypeMetadataBase
{
var (t, needsWrite) = key;
// Build inheritance hierarchy (base -> derived)
var hierarchy = new Stack<Type>();
var currentType = t;
while (currentType != null && currentType != typeof(object))
{
hierarchy.Push(currentType);
currentType = currentType.BaseType;
}
// Collect properties level by level (base first)
// Collect properties from inheritance hierarchy (derived -> base order)
// Then sort alphabetically - ensures consistent ordering for serialization/deserialization
var allProperties = new List<PropertyInfo>();
while (hierarchy.Count > 0)
for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType)
{
var levelType = hierarchy.Pop();
// Get properties declared at this level only
var levelProperties = levelType
var levelProperties = currentType
.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(p => p.CanRead &&
(!needsWrite || p.CanWrite) &&
@ -83,3 +90,60 @@ public abstract class TypeMetadataBase
});
}
}
/// <summary>
/// Generic base class for type metadata with built-in ThreadLocal caching.
/// Provides better performance than TypeMetadataBase by eliminating ref parameter overhead.
/// Each TMetadata type gets its own ThreadStatic cache instance automatically.
/// </summary>
/// <typeparam name="TMetadata">The concrete metadata type (must inherit from this class).</typeparam>
public abstract class TypeMetadataBase<TMetadata> : TypeMetadataBase
where TMetadata : TypeMetadataBase<TMetadata>
{
/// <summary>
/// ThreadLocal cache for this specific metadata type.
/// Each TMetadata type gets its own static cache due to generic specialization.
/// </summary>
[ThreadStatic]
private static Dictionary<Type, TMetadata>? t_localCache;
protected TypeMetadataBase(Type type) : base(type)
{
}
/// <summary>
/// Gets or creates metadata for the specified type using two-level cache:
/// 1. ThreadLocal cache (fast path, no lock, no ref parameter)
/// 2. Global cache (slow path, ConcurrentDictionary with lock)
/// </summary>
/// <param name="sourceType">The type to get metadata for.</param>
/// <param name="factory">Factory function to create metadata if not cached.</param>
/// <returns>Cached or newly created metadata instance.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static TMetadata GetOrCreateMetadata(Type sourceType, Func<Type, TMetadata> factory)
{
// Fast path: check ThreadLocal cache first (no ref parameter overhead!)
if (t_localCache != null && t_localCache.TryGetValue(sourceType, out var cached))
return cached;
// Slow path: get from global cache and populate local cache
return GetOrCreateMetadataSlow(sourceType, factory);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static TMetadata GetOrCreateMetadataSlow(Type sourceType, Func<Type, TMetadata> factory)
{
// Get or create from global cache
var key = (sourceType, typeof(TMetadata));
var metadata = (TMetadata)GlobalMetadataCache.GetOrAdd(key, _ => factory(sourceType));
// Populate ThreadLocal cache
t_localCache ??= new Dictionary<Type, TMetadata>();
if (t_localCache.Count >= MaxLocalCacheSize)
t_localCache.Clear();
t_localCache[sourceType] = metadata;
return metadata;
}
}