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<byte> support. Changed AcBinarySerializerOptions.UseMetadata default to false. These changes reduce GC pressure and improve performance, especially for high-throughput and WASM scenarios.
This commit is contained in:
Loretta 2026-02-07 18:21:10 +01:00
parent c1dc203dad
commit 5a174ced4c
5 changed files with 301 additions and 37 deletions

View File

@ -11,22 +11,21 @@ public static partial class AcBinaryDeserializer
{
/// <summary>
/// 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.
/// </summary>
internal ref struct BinaryDeserializationContext
{
private readonly ReadOnlySpan<byte> _buffer;
private int _position;
private Dictionary<int, string>? _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
/// <summary>
/// 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.
/// </summary>
public readonly BinaryDeserializationContextClass ContextClass;
@ -56,33 +55,23 @@ public static partial class AcBinaryDeserializer
/// </summary>
public readonly bool IsChainMode => ChainTracker != null;
public BinaryDeserializationContext(ReadOnlySpan<byte> data)
: this(data, AcBinarySerializerOptions.Default, new BinaryDeserializationContextClass())
{
}
public BinaryDeserializationContext(ReadOnlySpan<byte> data, AcBinarySerializerOptions options)
: this(data, options, new BinaryDeserializationContextClass())
{
}
public BinaryDeserializationContext(ReadOnlySpan<byte> data, AcBinarySerializerOptions options, BinaryDeserializationContextClass contextClass)
/// <summary>
/// Creates a deserialization context with a pooled ContextClass.
/// ContextClass must already be Reset() with options before passing in.
/// </summary>
public BinaryDeserializationContext(ReadOnlySpan<byte> 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<int, string>(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;
}
/// <summary>

View File

@ -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
{
/// <summary>
/// Pool for BinaryDeserializationContextClass instances.
/// Eliminates per-call heap allocation — mirrors BinarySerializationContextPool pattern.
/// </summary>
private static class DeserializationContextClassPool
{
private static readonly ConcurrentQueue<BinaryDeserializationContextClass> 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);
}
}
}
/// <summary>
/// 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.
/// </summary>
internal sealed class BinaryDeserializationContextClass : AcSerializerContextBase<BinaryDeserializeTypeMetadata, AcBinarySerializerOptions>
{
@ -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<int, string>? _stringCache;
// Intern cache index counter - moved from ref struct for pool reuse
private int _nextCacheIndex;
// Linearized buffer for ReadOnlySequence<byte> input
private byte[]? _linearizedBuffer;
/// <summary>
/// String cache for WASM optimization. Reused across deserializations.
/// </summary>
internal Dictionary<int, string>? StringCache => _stringCache;
/// <summary>
/// Gets or lazily creates the string cache with initial capacity.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal Dictionary<int, string> GetOrCreateStringCache()
{
return _stringCache ??= new Dictionary<int, string>(128);
}
/// <summary>
/// Next intern cache index to assign when registering interned values.
/// </summary>
internal ref int NextCacheIndexRef
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => ref _nextCacheIndex;
}
/// <summary>
/// Rents a linearized buffer for ReadOnlySequence multi-segment input.
/// Buffer is pooled and reused across deserializations.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal byte[] RentLinearizedBuffer(int minSize)
{
if (_linearizedBuffer != null && _linearizedBuffer.Length >= minSize)
return _linearizedBuffer;
if (_linearizedBuffer != null)
ArrayPool<byte>.Shared.Return(_linearizedBuffer);
_linearizedBuffer = ArrayPool<byte>.Shared.Rent(minSize);
return _linearizedBuffer;
}
/// <summary>
/// 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<byte>.Shared.Return(_linearizedBuffer);
_linearizedBuffer = null;
}
}
public override void Reset(AcBinarySerializerOptions options)

View File

@ -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);
}
}
/// <summary>
@ -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);
}
}
/// <summary>

View File

@ -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);
}
}
/// <summary>
@ -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);
}
}
/// <summary>
/// Deserialize binary data from a ReadOnlySequence (e.g., from SignalR/Pipes).
/// Single-segment: zero-copy via FirstSpan. Multi-segment: linearize into pooled buffer.
/// </summary>
public static T? Deserialize<T>(ReadOnlySequence<byte> data)
=> Deserialize<T>(data, AcBinarySerializerOptions.Default);
/// <summary>
/// Deserialize binary data from a ReadOnlySequence with options.
/// </summary>
public static T? Deserialize<T>(ReadOnlySequence<byte> data, AcBinarySerializerOptions options)
{
if (data.Length == 0) return default;
if (data.IsSingleSegment)
return Deserialize<T>(data.FirstSpan, options);
var ctxClass = DeserializationContextClassPool.Get(options);
try
{
var buffer = ctxClass.RentLinearizedBuffer((int)data.Length);
data.CopyTo(buffer);
return Deserialize<T>(buffer.AsSpan(0, (int)data.Length), ctxClass);
}
finally
{
DeserializationContextClassPool.Return(ctxClass);
}
}
/// <summary>
/// Deserialize binary data from a ReadOnlySequence to specified type.
/// </summary>
public static object? Deserialize(ReadOnlySequence<byte> data, Type targetType)
=> Deserialize(data, targetType, AcBinarySerializerOptions.Default);
/// <summary>
/// Deserialize binary data from a ReadOnlySequence to specified type with options.
/// </summary>
public static object? Deserialize(ReadOnlySequence<byte> 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);
}
}
/// <summary>
/// Internal: Deserialize with pre-pooled ContextClass (used by ReadOnlySequence multi-segment path).
/// </summary>
private static T? Deserialize<T>(ReadOnlySpan<byte> 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);
}
}
/// <summary>
/// Internal: Deserialize with pre-pooled ContextClass (used by ReadOnlySequence multi-segment path).
/// </summary>
private static object? Deserialize(ReadOnlySpan<byte> 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);
}
}
/// <summary>
/// Internal: DeserializeExpression with pre-pooled ContextClass.
/// </summary>
private static Expression? DeserializeExpression(ReadOnlySpan<byte> 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);
}
}
/// <summary>
@ -203,7 +348,8 @@ public static partial class AcBinaryDeserializer
/// </summary>
private static Expression? DeserializeExpression(ReadOnlySpan<byte> 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);
}
}
/// <summary>
@ -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);
}
}
/// <summary>
@ -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<T>(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<T> 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()

View File

@ -82,7 +82,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// allowing the deserializer to match properties by name between different types.
/// Default: false (no overhead)
/// </summary>
public bool UseMetadata { get; init; } = true;
public bool UseMetadata { get; init; } = false;
/// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).