Optimize cache index assignment during scan pass

Refactored AcBinarySerializer to assign cache indices immediately upon detecting duplicates during the scan pass, eliminating the need for a separate post-processing step. Updated TryTrack methods to take a ref nextCacheIndex for inline assignment. Removed AssignCacheIndicesInOrder and related code, simplified string interning, and made RegisterMetadataType static. This reduces allocations and improves performance by making cache index assignment a single-pass operation.
This commit is contained in:
Loretta 2026-02-06 15:48:48 +01:00
parent e5d4b1091f
commit 9b4fa1159a
5 changed files with 59 additions and 283 deletions

View File

@ -96,9 +96,18 @@ public static partial class AcBinarySerializer
//private readonly AcSerializerCommon.SerializationReferenceTracker _refTracker = new();
private IdentityMap<string, InternEntry>? _stringInternMap;
private int _nextCacheIndex; // Next dense cache index to assign
private int _nextCacheIndex; // Next dense cache index to assign (starts at 0, uses ++_nextCacheIndex)
private int _nextFirstIndex; // Next first occurrence index to assign (scan pass)
/// <summary>
/// Next cache index reference for scan pass. Direct ref access for TryTrack methods.
/// </summary>
public ref int NextCacheIndexRef
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => ref _nextCacheIndex;
}
/// <summary>
/// Next first occurrence index for scan pass. Direct access for performance.
/// </summary>
@ -259,7 +268,7 @@ public static partial class AcBinarySerializer
}
/// <summary>
/// Scan pass: tracks a string for interning. Marks as cached on 2nd occurrence.
/// Scan pass: tracks a string for interning. Assigns CacheIndex immediately on 2nd occurrence.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ScanInternString(string value)
@ -268,14 +277,17 @@ public static partial class AcBinarySerializer
if (!_stringInternMap.TryAdd(value, out var slotIndex))
{
// 2+ occurrence: mark as cached
// 2+ occurrence: assign CacheIndex immediately
ref var entry = ref _stringInternMap.GetValueRef(slotIndex);
if (entry.CacheIndex == -1)
entry.CacheIndex = -2; // -2 = cached, pending CacheIndex assignment
{
entry.CacheIndex = ++_nextCacheIndex;
entry.IsFirstWrite = true;
}
return;
}
// 1st occurrence: store FirstIndex
// 1st occurrence: store FirstIndex for validation, CacheIndex = -1 (not cached yet)
ref var newEntry = ref _stringInternMap.GetValueRef(slotIndex);
newEntry.FirstIndex = _nextFirstIndex++;
newEntry.CacheIndex = -1;
@ -291,220 +303,6 @@ public static partial class AcBinarySerializer
/// </summary>
public int GetCacheCount() => _nextCacheIndex;
/// <summary>
/// Assigns CacheIndex values in FirstIndex order after scan pass.
/// Collects all cached entries (CacheIndex == -2), sorts by FirstIndex, assigns 0, 1, 2...
/// Optimized: single pass collection, no allocations for wrapper iteration.
/// </summary>
public void AssignCacheIndicesInOrder()
{
// Fast path: no caching at all
if (_stringInternMap == null && !HasAnyIdentityMap())
{
return;
}
// Count cached entries in single pass
var cachedCount = CountAllCachedEntries();
if (cachedCount == 0)
return;
// Collect entries for sorting
Span<(int SlotIndex, int FirstIndex, int MapType)> entries = cachedCount <= 64
? stackalloc (int, int, int)[cachedCount]
: new (int, int, int)[cachedCount];
var idx = 0;
// Collect from string intern map (mapType = 0)
if (_stringInternMap != null)
{
var count = _stringInternMap.Count;
for (var i = 0; i < count; i++)
{
ref var entry = ref _stringInternMap.GetValueRefAt(i);
if (entry.CacheIndex == -2)
entries[idx++] = (i, entry.FirstIndex, 0);
}
}
// Collect from wrapper identity maps - use foreach, no allocation
var wrapperIdx = 1;
foreach (var wrapper in GetWrappers())
{
var baseMapType = wrapperIdx * 3;
CollectCachedEntries(wrapper.IdentityMapInt32, baseMapType + 0, ref entries, ref idx);
CollectCachedEntries(wrapper.IdentityMapInt64, baseMapType + 1, ref entries, ref idx);
CollectCachedEntries(wrapper.IdentityMapGuid, baseMapType + 2, ref entries, ref idx);
wrapperIdx++;
}
// Sort by FirstIndex
var usedEntries = entries.Slice(0, idx);
usedEntries.Sort((a, b) => a.FirstIndex.CompareTo(b.FirstIndex));
// Assign CacheIndex in sorted order
for (var i = 0; i < idx; i++)
{
var (slotIndex, _, mapType) = usedEntries[i];
if (mapType == 0)
{
ref var entry = ref _stringInternMap!.GetValueRefAt(slotIndex);
entry.CacheIndex = _nextCacheIndex++;
entry.IsFirstWrite = true;
}
else
{
// Find wrapper by index
var wrapperIndex = mapType / 3 - 1;
var mapIndex = mapType % 3;
var wIdx = 0;
foreach (var wrapper in GetWrappers())
{
if (wIdx == wrapperIndex)
{
switch (mapIndex)
{
case 0:
ref var entry32 = ref wrapper.IdentityMapInt32!.GetValueRefAt(slotIndex);
entry32.CacheIndex = _nextCacheIndex++;
entry32.IsFirstWrite = true;
break;
case 1:
ref var entry64 = ref wrapper.IdentityMapInt64!.GetValueRefAt(slotIndex);
entry64.CacheIndex = _nextCacheIndex++;
entry64.IsFirstWrite = true;
break;
case 2:
ref var entryGuid = ref wrapper.IdentityMapGuid!.GetValueRefAt(slotIndex);
entryGuid.CacheIndex = _nextCacheIndex++;
entryGuid.IsFirstWrite = true;
break;
}
break;
}
wIdx++;
}
}
}
#if DEBUG
// DEBUG: Print string intern map contents
if (_stringInternMap != null)
{
Console.WriteLine($"\n=== AssignCacheIndicesInOrder completed ===");
Console.WriteLine($"Total strings in map: {_stringInternMap.Count}");
Console.WriteLine($"Total cached (CacheIndex >= 0): {cachedCount}");
Console.WriteLine($"NextCacheIndex: {_nextCacheIndex}");
Console.WriteLine("String entries:");
for (var i = 0; i < _stringInternMap.Count; i++)
{
ref var entry = ref _stringInternMap.GetValueRefAt(i);
var key = _stringInternMap.GetKeyAt(i);
Console.WriteLine($" [{i}] Key=\"{key}\" FirstIndex={entry.FirstIndex} CacheIndex={entry.CacheIndex} IsFirstWrite={entry.IsFirstWrite}");
}
Console.WriteLine();
}
#endif
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool HasAnyIdentityMap()
{
foreach (var wrapper in GetWrappers())
{
if (wrapper.IdentityMapInt32 != null || wrapper.IdentityMapInt64 != null || wrapper.IdentityMapGuid != null)
return true;
}
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int CountAllCachedEntries()
{
var cachedCount = 0;
if (_stringInternMap != null)
{
var count = _stringInternMap.Count;
for (var i = 0; i < count; i++)
{
ref var entry = ref _stringInternMap.GetValueRefAt(i);
if (entry.CacheIndex == -2)
cachedCount++;
}
}
foreach (var wrapper in GetWrappers())
{
cachedCount += CountCachedEntries(wrapper.IdentityMapInt32);
cachedCount += CountCachedEntries(wrapper.IdentityMapInt64);
cachedCount += CountCachedEntries(wrapper.IdentityMapGuid);
}
return cachedCount;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int CountCachedEntries<TKey>(IdentityMap<TKey, InternEntry>? map) where TKey : notnull
{
if (map == null) return 0;
var count = 0;
var mapCount = map.Count;
for (var i = 0; i < mapCount; i++)
{
ref var entry = ref map.GetValueRefAt(i);
if (entry.CacheIndex == -2)
count++;
}
return count;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CollectCachedEntries<TKey>(
IdentityMap<TKey, InternEntry>? map,
int mapType,
ref Span<(int SlotIndex, int FirstIndex, int MapType)> entries,
ref int idx) where TKey : notnull
{
if (map == null) return;
var count = map.Count;
for (var i = 0; i < count; i++)
{
ref var entry = ref map.GetValueRefAt(i);
if (entry.CacheIndex == -2)
entries[idx++] = (i, entry.FirstIndex, mapType);
}
}
#endregion
#region Object Reference Tracking (IId + Non-IId)
/// <summary>
/// Tries to track an IId object (Int32 Id).
/// Returns true if first occurrence, false if already seen.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackObject(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, object obj, out int cacheIndex)
{
return TryTrack(wrapper, obj, _nextFirstIndex++, out cacheIndex);
}
/// <summary>
/// Tries to track an IId object (Int64 Id).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackObjectLong(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, object obj, out int cacheIndex)
{
return TryTrackLong(wrapper, obj, _nextFirstIndex++, out cacheIndex);
}
/// <summary>
/// Tries to track an IId object (Guid Id).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackObjectGuid(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, object obj, out int cacheIndex)
{
return TryTrackGuid(wrapper, obj, _nextFirstIndex++, out cacheIndex);
}
#endregion
@ -516,7 +314,7 @@ public static partial class AcBinarySerializer
/// false-t ha ismételt (csak propNameHash kell).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool RegisterMetadataType(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper)
public static bool RegisterMetadataType(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper)
{
if (wrapper.MetadataFooterIndex >= 0)
return false; // ismételt

View File

@ -9,7 +9,7 @@ public static partial class AcBinarySerializer
/// First pass: scans object graph to identify duplicates (strings + objects).
/// Only traverses reference properties (complex types + strings).
/// Stops traversing an object after its 2nd occurrence.
/// After scan: assigns CacheIndex in FirstIndex order.
/// CacheIndex is assigned immediately on 2nd occurrence (no post-processing needed).
/// </summary>
private static void ScanForDuplicates(object value, Type type, BinarySerializationContext context)
{
@ -17,7 +17,7 @@ public static partial class AcBinarySerializer
return;
ScanValue(value, type, context, 0);
context.AssignCacheIndicesInOrder();
// No AssignCacheIndicesInOrder() needed - CacheIndex assigned inline on 2nd occurrence
}
private static void ScanValue(object? value, Type type, BinarySerializationContext context, int depth)
@ -70,15 +70,15 @@ public static partial class AcBinarySerializer
{
case IdAccessorType.Int32:
var id32 = wrapper.RefIdGetterInt32!(value);
isFirst = wrapper.TryTrackInt32(id32, context.NextFirstIndex++, out _);
isFirst = wrapper.TryTrackInt32(id32, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
break;
case IdAccessorType.Int64:
var id64 = wrapper.RefIdGetterInt64!(value);
isFirst = wrapper.TryTrackInt64(id64, context.NextFirstIndex++, out _);
isFirst = wrapper.TryTrackInt64(id64, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
break;
case IdAccessorType.Guid:
var idGuid = wrapper.RefIdGetterGuid!(value);
isFirst = wrapper.TryTrackGuid(idGuid, context.NextFirstIndex++, out _);
isFirst = wrapper.TryTrackGuid(idGuid, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
break;
default:
isFirst = true;
@ -90,13 +90,26 @@ public static partial class AcBinarySerializer
}
// Recursive scan on reference properties only
// Use typed getter for strings (much faster than reflection GetValue)
var refProperties = metadata.ReferenceProperties;
var nextDepth2 = depth + 1;
for (var i = 0; i < refProperties.Length; i++)
{
var propValue = refProperties[i].GetValue(value);
if (propValue != null)
ScanValue(propValue, refProperties[i].PropertyType, context, nextDepth2);
var prop = refProperties[i];
if (prop.AccessorType == PropertyAccessorType.String)
{
// Fast path: typed getter for string
var str2 = prop.GetString(value);
if (str2 != null && context.IsValidForInterningString(str2.Length))
context.ScanInternString(str2);
}
else
{
// Object property: use generic getter
var propValue = prop.GetValue(value);
if (propValue != null)
ScanValue(propValue, prop.PropertyType, context, nextDepth2);
}
}
}
}

View File

@ -799,7 +799,7 @@ public static partial class AcBinarySerializer
var isFirstMetadataOccurrence = false;
if (context.UseMetadata)
{
isFirstMetadataOccurrence = context.RegisterMetadataType(wrapper);
isFirstMetadataOccurrence = BinarySerializationContext.RegisterMetadataType(wrapper);
}
// Reference handling: lookup entry from scan pass, check IsFirstWrite

View File

@ -1,12 +1,8 @@
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers;
/// <summary>
/// Abstract base class for all serialization contexts (Binary, JSON, Toon).
/// Provides common serialization operations: Id extraction, tracking first occurrence.
/// Provides common serialization operations.
/// Derived classes are sealed for JIT devirtualization (direct call speed).
/// </summary>
/// <typeparam name="TMetadata">The concrete metadata type for serialization.</typeparam>
@ -15,45 +11,6 @@ public abstract class SerializationContextBase<TMetadata, TOptions> : AcSerializ
where TMetadata : TypeMetadataBase
where TOptions : AcSerializerOptions
{
#region Tracking - InternEntry based (serializer side)
/// <summary>
/// Tries to track an object with int RefId.
/// Returns true if first occurrence, false if already seen.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrack(TypeMetadataWrapper<TMetadata> wrapper, object obj, int firstIndex, out int cacheIndex)
{
Debug.Assert(wrapper.Metadata.IdAccessorType == IdAccessorType.Int32);
var id = wrapper.RefIdGetterInt32!(obj);
return wrapper.TryTrackInt32(id, firstIndex, out cacheIndex);
}
/// <summary>
/// Tries to track an object with long RefId.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackLong(TypeMetadataWrapper<TMetadata> wrapper, object obj, int firstIndex, out int cacheIndex)
{
Debug.Assert(wrapper.Metadata.IdAccessorType == IdAccessorType.Int64);
var id = wrapper.RefIdGetterInt64!(obj);
return wrapper.TryTrackInt64(id, firstIndex, out cacheIndex);
}
/// <summary>
/// Tries to track an object with Guid RefId.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackGuid(TypeMetadataWrapper<TMetadata> wrapper, object obj, int firstIndex, out int cacheIndex)
{
Debug.Assert(wrapper.Metadata.IdAccessorType == IdAccessorType.Guid);
var id = wrapper.RefIdGetterGuid!(obj);
return wrapper.TryTrackGuid(id, firstIndex, out cacheIndex);
}
#endregion
#region Reset
/// <summary>

View File

@ -140,26 +140,28 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
/// <summary>
/// Tries to track Int32 Id. Returns true if first occurrence.
/// On 2+ occurrence: marks as cached (-2), returns existing CacheIndex.
/// CacheIndex is assigned later by AssignCacheIndicesInOrder().
/// On 2+ occurrence: assigns CacheIndex immediately using ++nextCacheIndex.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackInt32(int id, int firstIndex, out int cacheIndex)
public bool TryTrackInt32(int id, int firstIndex, ref int nextCacheIndex, out int cacheIndex)
{
if (id == 0) { cacheIndex = -1; return true; } // Default Id - no tracking
var map = IdentityMapInt32 ??= new IdentityMap<int, InternEntry>();
if (!map.TryAdd(id, out var slotIndex))
{
// 2+ occurrence: mark as cached
// 2+ occurrence: assign CacheIndex immediately
ref var entry = ref map.GetValueRef(slotIndex);
if (entry.CacheIndex == -1)
entry.CacheIndex = -2; // -2 = cached, pending assignment
{
entry.CacheIndex = ++nextCacheIndex;
entry.IsFirstWrite = true;
}
cacheIndex = entry.CacheIndex;
return false;
}
// 1st occurrence: store FirstIndex
// 1st occurrence: store FirstIndex for validation
ref var newEntry = ref map.GetValueRef(slotIndex);
newEntry.FirstIndex = firstIndex;
newEntry.CacheIndex = -1;
@ -171,7 +173,7 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
/// Tries to track Int64 Id. Returns true if first occurrence.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackInt64(long id, int firstIndex, out int cacheIndex)
public bool TryTrackInt64(long id, int firstIndex, ref int nextCacheIndex, out int cacheIndex)
{
if (id == 0) { cacheIndex = -1; return true; }
@ -180,7 +182,10 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
{
ref var entry = ref map.GetValueRef(slotIndex);
if (entry.CacheIndex == -1)
entry.CacheIndex = -2;
{
entry.CacheIndex = ++nextCacheIndex;
entry.IsFirstWrite = true;
}
cacheIndex = entry.CacheIndex;
return false;
}
@ -196,7 +201,7 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
/// Tries to track Guid Id. Returns true if first occurrence.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackGuid(Guid id, int firstIndex, out int cacheIndex)
public bool TryTrackGuid(Guid id, int firstIndex, ref int nextCacheIndex, out int cacheIndex)
{
if (id == Guid.Empty) { cacheIndex = -1; return true; }
@ -205,7 +210,10 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
{
ref var entry = ref map.GetValueRef(slotIndex);
if (entry.CacheIndex == -1)
entry.CacheIndex = -2;
{
entry.CacheIndex = ++nextCacheIndex;
entry.IsFirstWrite = true;
}
cacheIndex = entry.CacheIndex;
return false;
}