From 5a174ced4c5acf4115eda0eb19028cde1dbc79df Mon Sep 17 00:00:00 2001 From: Loretta Date: Sat, 7 Feb 2026 18:21:10 +0100 Subject: [PATCH] Refactor: add pooled context for zero-alloc deserialization Refactored binary deserialization to use a pooled BinaryDeserializationContextClass, eliminating per-call heap allocations and enabling cache reuse for string and intern caches. Introduced DeserializationContextClassPool for efficient context management. Updated all deserialization entry points to use the pool with proper disposal. Added efficient ReadOnlySequence support. Changed AcBinarySerializerOptions.UseMetadata default to false. These changes reduce GC pressure and improve performance, especially for high-throughput and WASM scenarios. --- ...serializer.BinaryDeserializationContext.cs | 35 ++-- ...lizer.BinaryDeserializationContextClass.cs | 96 ++++++++- .../AcBinaryDeserializer.CrossType.cs | 14 +- .../Binaries/AcBinaryDeserializer.cs | 191 +++++++++++++++++- .../Binaries/AcBinarySerializerOptions.cs | 2 +- 5 files changed, 301 insertions(+), 37 deletions(-) diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs index dc0d113..3b5ad3a 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContext.cs @@ -11,22 +11,21 @@ public static partial class AcBinaryDeserializer { /// /// Binary deserialization context. Public for generated serializers. - /// Uses composition with BinaryDeserializationContextClass for IId-based tracking. + /// Uses composition with pooled BinaryDeserializationContextClass for zero-alloc deserialization. + /// String cache and intern cache index are delegated to ContextClass for pool reuse. /// internal ref struct BinaryDeserializationContext { private readonly ReadOnlySpan _buffer; private int _position; - private Dictionary? _stringCache; // Marker-based interning: sequential cache (no footer needed) // StringInternFirst/ObjectRefFirst markers register values in order private object?[]? _internCache; // Shared cache for interned strings AND IId objects - private int _nextCacheIndex; // Next index to assign when registering /// /// Heap-allocated context class for IId-based reference tracking. - /// Also holds Options - all options-derived properties delegate to ContextClass.Options. + /// Pooled via DeserializationContextClassPool — holds caches, options, metadata. /// public readonly BinaryDeserializationContextClass ContextClass; @@ -56,33 +55,23 @@ public static partial class AcBinaryDeserializer /// public readonly bool IsChainMode => ChainTracker != null; - public BinaryDeserializationContext(ReadOnlySpan data) - : this(data, AcBinarySerializerOptions.Default, new BinaryDeserializationContextClass()) - { - } - - public BinaryDeserializationContext(ReadOnlySpan data, AcBinarySerializerOptions options) - : this(data, options, new BinaryDeserializationContextClass()) - { - } - - public BinaryDeserializationContext(ReadOnlySpan data, AcBinarySerializerOptions options, BinaryDeserializationContextClass contextClass) + /// + /// Creates a deserialization context with a pooled ContextClass. + /// ContextClass must already be Reset() with options before passing in. + /// + public BinaryDeserializationContext(ReadOnlySpan data, BinaryDeserializationContextClass contextClass) { _buffer = data; _position = 0; - _stringCache = null; // Marker-based interning fields _internCache = null; - _nextCacheIndex = 0; HasMetadata = false; IsMergeMode = false; RemoveOrphanedItems = false; ChainTracker = null; ContextClass = contextClass; - // Reset ContextClass with options - this sets Options and clears any previous state - ContextClass.Reset(options); } public void ReadHeader() @@ -422,9 +411,9 @@ public static partial class AcBinaryDeserializer var slice = _buffer.Slice(_position, length); var hash = ComputeStringHashFull(slice); - _stringCache ??= new Dictionary(128); + var stringCache = ContextClass.GetOrCreateStringCache(); - if (_stringCache.TryGetValue(hash, out var cached)) + if (stringCache.TryGetValue(hash, out var cached)) { // Hash includes all bytes for short strings, so collision is extremely unlikely // For longer strings, we still verify length as a sanity check @@ -437,7 +426,7 @@ public static partial class AcBinaryDeserializer } var value = Utf8NoBom.GetString(slice); - _stringCache[hash] = value; + stringCache[hash] = value; _position += length; return value; } @@ -513,7 +502,7 @@ public static partial class AcBinaryDeserializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public void RegisterNextInternedValue(object value) { - _internCache![_nextCacheIndex++] = value; + _internCache![ContextClass.NextCacheIndexRef++] = value; } /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContextClass.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContextClass.cs index 366187c..e5d056a 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContextClass.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializationContextClass.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Runtime.CompilerServices; @@ -7,11 +8,44 @@ namespace AyCode.Core.Serializers.Binaries; public static partial class AcBinaryDeserializer { + /// + /// Pool for BinaryDeserializationContextClass instances. + /// Eliminates per-call heap allocation — mirrors BinarySerializationContextPool pattern. + /// + private static class DeserializationContextClassPool + { + private static readonly ConcurrentQueue Pool = new(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static BinaryDeserializationContextClass Get(AcBinarySerializerOptions options) + { + if (Pool.TryDequeue(out var ctx)) + { + ctx.Reset(options); + return ctx; + } + + var newCtx = new BinaryDeserializationContextClass(); + newCtx.Reset(options); + return newCtx; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Return(BinaryDeserializationContextClass ctx) + { + if (Pool.Count < ctx.Options.MaxContextPoolSize) + { + ctx.Clear(); + Pool.Enqueue(ctx); + } + } + } + /// /// Heap-allocated context class for binary deserialization. /// Inherits from AcSerializerContextBase for unified metadata caching and IId-based reference tracking. /// Used in composition with the ref struct BinaryDeserializationContext. - /// Holds pooled arrays for intern cache reuse across deserializations. + /// Holds pooled arrays and caches for reuse across deserializations. /// internal sealed class BinaryDeserializationContextClass : AcSerializerContextBase { @@ -31,6 +65,55 @@ public static partial class AcBinaryDeserializer // Small arrays: keep across calls. Large arrays: return to pool in Clear(). private const int SmallArrayThreshold = 256; + // String cache - moved from ref struct for pool reuse (WASM optimization) + private Dictionary? _stringCache; + + // Intern cache index counter - moved from ref struct for pool reuse + private int _nextCacheIndex; + + // Linearized buffer for ReadOnlySequence input + private byte[]? _linearizedBuffer; + + /// + /// String cache for WASM optimization. Reused across deserializations. + /// + internal Dictionary? StringCache => _stringCache; + + /// + /// Gets or lazily creates the string cache with initial capacity. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Dictionary GetOrCreateStringCache() + { + return _stringCache ??= new Dictionary(128); + } + + /// + /// Next intern cache index to assign when registering interned values. + /// + internal ref int NextCacheIndexRef + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => ref _nextCacheIndex; + } + + /// + /// Rents a linearized buffer for ReadOnlySequence multi-segment input. + /// Buffer is pooled and reused across deserializations. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal byte[] RentLinearizedBuffer(int minSize) + { + if (_linearizedBuffer != null && _linearizedBuffer.Length >= minSize) + return _linearizedBuffer; + + if (_linearizedBuffer != null) + ArrayPool.Shared.Return(_linearizedBuffer); + + _linearizedBuffer = ArrayPool.Shared.Rent(minSize); + return _linearizedBuffer; + } + /// /// Inline metadata bejegyzések flat array-ben. /// A propNameHash alapján lineárisan keresünk (kis számú típus per stream). @@ -146,6 +229,10 @@ public static partial class AcBinaryDeserializer base.Clear(); _metadataEntryCount = 0; + _nextCacheIndex = 0; + + // String cache: clear content but keep dictionary allocated for reuse + _stringCache?.Clear(); // Intern cache: clear GC roots, return large arrays to pool if (_pooledInternCache != null) @@ -172,6 +259,13 @@ public static partial class AcBinaryDeserializer _pooledDupData = null; _pooledDupDataLength = 0; } + + // Linearized buffer: no GC roots (byte[]), keep small, return large + if (_linearizedBuffer != null && _linearizedBuffer.Length > SmallArrayThreshold) + { + ArrayPool.Shared.Return(_linearizedBuffer); + _linearizedBuffer = null; + } } public override void Reset(AcBinarySerializerOptions options) diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs index 4bc79be..a496ae6 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.CrossType.cs @@ -55,7 +55,8 @@ public static partial class AcBinaryDeserializer // Cross-type path: use index mapping var indexMapping = GetIndexMapping(sourceType, destType, options.PropertyMapper); - var context = new BinaryDeserializationContext(data, options); + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(data, ctxClass); try { @@ -73,6 +74,10 @@ public static partial class AcBinaryDeserializer $"Failed to deserialize binary data from '{sourceType.Name}' to '{destType.Name}': {ex.Message}", context.Position, destType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } /// @@ -136,7 +141,8 @@ public static partial class AcBinaryDeserializer // Cross-type path: use index mapping var indexMapping = GetIndexMapping(sourceType, destType, options.PropertyMapper); - var context = new BinaryDeserializationContext(data, options); + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(data, ctxClass); try { @@ -171,6 +177,10 @@ public static partial class AcBinaryDeserializer $"Failed to populate target of type '{destType.Name}' with data from '{sourceType.Name}': {ex.Message}", context.Position, destType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs index d66fb5a..f92f6f3 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections; using System.Collections.Concurrent; using System.Collections.Frozen; @@ -135,7 +136,8 @@ public static partial class AcBinaryDeserializer return (T?)(object?)DeserializeExpression(data, targetType, options); } - var context = new BinaryDeserializationContext(data, options); + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(data, ctxClass); try { @@ -154,6 +156,10 @@ public static partial class AcBinaryDeserializer $"Failed to deserialize binary data to type '{targetType.Name}': {ex.Message}", context.Position, targetType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } /// @@ -176,7 +182,8 @@ public static partial class AcBinaryDeserializer return DeserializeExpression(data, targetType, options); } - var context = new BinaryDeserializationContext(data, options); + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(data, ctxClass); try { @@ -195,6 +202,144 @@ public static partial class AcBinaryDeserializer $"Failed to deserialize binary data to type '{targetType.Name}': {ex.Message}", context.Position, targetType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } + } + + /// + /// Deserialize binary data from a ReadOnlySequence (e.g., from SignalR/Pipes). + /// Single-segment: zero-copy via FirstSpan. Multi-segment: linearize into pooled buffer. + /// + public static T? Deserialize(ReadOnlySequence data) + => Deserialize(data, AcBinarySerializerOptions.Default); + + /// + /// Deserialize binary data from a ReadOnlySequence with options. + /// + public static T? Deserialize(ReadOnlySequence data, AcBinarySerializerOptions options) + { + if (data.Length == 0) return default; + + if (data.IsSingleSegment) + return Deserialize(data.FirstSpan, options); + + var ctxClass = DeserializationContextClassPool.Get(options); + try + { + var buffer = ctxClass.RentLinearizedBuffer((int)data.Length); + data.CopyTo(buffer); + return Deserialize(buffer.AsSpan(0, (int)data.Length), ctxClass); + } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } + } + + /// + /// Deserialize binary data from a ReadOnlySequence to specified type. + /// + public static object? Deserialize(ReadOnlySequence data, Type targetType) + => Deserialize(data, targetType, AcBinarySerializerOptions.Default); + + /// + /// Deserialize binary data from a ReadOnlySequence to specified type with options. + /// + public static object? Deserialize(ReadOnlySequence data, Type targetType, AcBinarySerializerOptions options) + { + if (data.Length == 0) return null; + + if (data.IsSingleSegment) + return Deserialize(data.FirstSpan, targetType, options); + + var ctxClass = DeserializationContextClassPool.Get(options); + try + { + var buffer = ctxClass.RentLinearizedBuffer((int)data.Length); + data.CopyTo(buffer); + return Deserialize(buffer.AsSpan(0, (int)data.Length), targetType, ctxClass); + } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } + } + + /// + /// Internal: Deserialize with pre-pooled ContextClass (used by ReadOnlySequence multi-segment path). + /// + private static T? Deserialize(ReadOnlySpan data, BinaryDeserializationContextClass ctxClass) + { + if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return default; + + var targetType = typeof(T); + if (AcSerializerCommon.IsExpressionType(targetType)) + return (T?)(object?)DeserializeExpression(data, targetType, ctxClass); + + var context = new BinaryDeserializationContext(data, ctxClass); + try + { + context.ReadHeader(); + return (T?)ReadValue(ref context, targetType, 0); + } + catch (AcBinaryDeserializationException) { throw; } + catch (Exception ex) + { + throw new AcBinaryDeserializationException( + $"Failed to deserialize binary data to type '{targetType.Name}': {ex.Message}", + context.Position, targetType, ex); + } + } + + /// + /// Internal: Deserialize with pre-pooled ContextClass (used by ReadOnlySequence multi-segment path). + /// + private static object? Deserialize(ReadOnlySpan data, Type targetType, BinaryDeserializationContextClass ctxClass) + { + if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return null; + + if (AcSerializerCommon.IsExpressionType(targetType)) + return DeserializeExpression(data, targetType, ctxClass); + + var context = new BinaryDeserializationContext(data, ctxClass); + try + { + context.ReadHeader(); + return ReadValue(ref context, targetType, 0); + } + catch (AcBinaryDeserializationException) { throw; } + catch (Exception ex) + { + throw new AcBinaryDeserializationException( + $"Failed to deserialize binary data to type '{targetType.Name}': {ex.Message}", + context.Position, targetType, ex); + } + } + + /// + /// Internal: DeserializeExpression with pre-pooled ContextClass. + /// + private static Expression? DeserializeExpression(ReadOnlySpan data, Type targetExpressionType, BinaryDeserializationContextClass ctxClass) + { + var context = new BinaryDeserializationContext(data, ctxClass); + try + { + context.ReadHeader(); + var node = (AcExpressionNode?)ReadValue(ref context, typeof(AcExpressionNode), 0); + if (node == null) return null; + + var entityType = AcSerializerCommon.GetExpressionEntityType(targetExpressionType); + return AcExpressionRebuilder.FromNode(node, entityType); + } + catch (AcBinaryDeserializationException) { throw; } + catch (Exception ex) + { + throw new AcBinaryDeserializationException( + $"Failed to deserialize Expression from binary data: {ex.Message}", + context.Position, targetExpressionType, ex); + } } /// @@ -203,7 +348,8 @@ public static partial class AcBinaryDeserializer /// private static Expression? DeserializeExpression(ReadOnlySpan data, Type targetExpressionType, AcBinarySerializerOptions options) { - var context = new BinaryDeserializationContext(data, options); + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(data, ctxClass); try { @@ -225,6 +371,10 @@ public static partial class AcBinaryDeserializer $"Failed to deserialize Expression from binary data: {ex.Message}", context.Position, targetExpressionType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } /// @@ -255,7 +405,8 @@ public static partial class AcBinaryDeserializer if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return; var targetType = target.GetType(); - var context = new BinaryDeserializationContext(data, options); + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(data, ctxClass); try { @@ -297,6 +448,10 @@ public static partial class AcBinaryDeserializer $"Failed to populate object of type '{targetType.Name}': {ex.Message}", context.Position, targetType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } /// @@ -320,7 +475,8 @@ public static partial class AcBinaryDeserializer var opts = options ?? AcBinarySerializerOptions.Default; var targetType = target.GetType(); - var context = new BinaryDeserializationContext(data, opts) + var ctxClass = DeserializationContextClassPool.Get(opts); + var context = new BinaryDeserializationContext(data, ctxClass) { IsMergeMode = true, RemoveOrphanedItems = opts.RemoveOrphanedItems @@ -382,6 +538,10 @@ public static partial class AcBinaryDeserializer $"Failed to populate/merge object of type '{targetType.Name}': {ex.Message}", context.Position, targetType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } #endregion @@ -408,7 +568,8 @@ public static partial class AcBinaryDeserializer // Copy data to array for chain storage var dataArray = data.ToArray(); var chainTracker = new AcSerializerCommon.ChainReferenceTracker(); - var context = new BinaryDeserializationContext(dataArray, options) { ChainTracker = chainTracker }; + var ctxClass = DeserializationContextClassPool.Get(options); + var context = new BinaryDeserializationContext(dataArray, ctxClass) { ChainTracker = chainTracker }; try { @@ -417,9 +578,9 @@ public static partial class AcBinaryDeserializer // Position-based string interning - no validation needed return new BinaryDeserializeChain(dataArray, options, chainTracker, (T?)result); } - catch + finally { - throw; + DeserializationContextClassPool.Return(ctxClass); } } @@ -453,7 +614,8 @@ public static partial class AcBinaryDeserializer ThrowIfDisposed(); var targetType = typeof(TResult); - var context = new BinaryDeserializationContext(_data, _options) { ChainTracker = _chainTracker }; + var ctxClass = DeserializationContextClassPool.Get(_options); + var context = new BinaryDeserializationContext(_data, ctxClass) { ChainTracker = _chainTracker }; try { @@ -469,6 +631,10 @@ public static partial class AcBinaryDeserializer $"Failed to deserialize to type '{targetType.Name}' in chain: {ex.Message}", 0, targetType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } public IDeserializeChain ThenPopulate(object target) @@ -477,7 +643,8 @@ public static partial class AcBinaryDeserializer ThrowIfDisposed(); var targetType = target.GetType(); - var context = new BinaryDeserializationContext(_data, _options) { ChainTracker = _chainTracker }; + var ctxClass = DeserializationContextClassPool.Get(_options); + var context = new BinaryDeserializationContext(_data, ctxClass) { ChainTracker = _chainTracker }; try { @@ -517,6 +684,10 @@ public static partial class AcBinaryDeserializer $"Failed to populate object of type '{targetType.Name}' in chain: {ex.Message}", 0, targetType, ex); } + finally + { + DeserializationContextClassPool.Return(ctxClass); + } } private void ThrowIfDisposed() diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs index 927b822..f58ef46 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs @@ -82,7 +82,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions /// allowing the deserializer to match properties by name between different types. /// Default: false (no overhead) /// - public bool UseMetadata { get; init; } = true; + public bool UseMetadata { get; init; } = false; /// /// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).