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 readonly AcSerializerCommon.SerializationReferenceTracker _refTracker = new();
private IdentityMap<string, InternEntry>? _stringInternMap; 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) 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> /// <summary>
/// Next first occurrence index for scan pass. Direct access for performance. /// Next first occurrence index for scan pass. Direct access for performance.
/// </summary> /// </summary>
@ -259,7 +268,7 @@ public static partial class AcBinarySerializer
} }
/// <summary> /// <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> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ScanInternString(string value) public void ScanInternString(string value)
@ -268,14 +277,17 @@ public static partial class AcBinarySerializer
if (!_stringInternMap.TryAdd(value, out var slotIndex)) if (!_stringInternMap.TryAdd(value, out var slotIndex))
{ {
// 2+ occurrence: mark as cached // 2+ occurrence: assign CacheIndex immediately
ref var entry = ref _stringInternMap.GetValueRef(slotIndex); ref var entry = ref _stringInternMap.GetValueRef(slotIndex);
if (entry.CacheIndex == -1) if (entry.CacheIndex == -1)
entry.CacheIndex = -2; // -2 = cached, pending CacheIndex assignment {
entry.CacheIndex = ++_nextCacheIndex;
entry.IsFirstWrite = true;
}
return; return;
} }
// 1st occurrence: store FirstIndex // 1st occurrence: store FirstIndex for validation, CacheIndex = -1 (not cached yet)
ref var newEntry = ref _stringInternMap.GetValueRef(slotIndex); ref var newEntry = ref _stringInternMap.GetValueRef(slotIndex);
newEntry.FirstIndex = _nextFirstIndex++; newEntry.FirstIndex = _nextFirstIndex++;
newEntry.CacheIndex = -1; newEntry.CacheIndex = -1;
@ -291,220 +303,6 @@ public static partial class AcBinarySerializer
/// </summary> /// </summary>
public int GetCacheCount() => _nextCacheIndex; 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 #endregion
@ -516,7 +314,7 @@ public static partial class AcBinarySerializer
/// false-t ha ismételt (csak propNameHash kell). /// false-t ha ismételt (csak propNameHash kell).
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool RegisterMetadataType(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper) public static bool RegisterMetadataType(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper)
{ {
if (wrapper.MetadataFooterIndex >= 0) if (wrapper.MetadataFooterIndex >= 0)
return false; // ismételt 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). /// First pass: scans object graph to identify duplicates (strings + objects).
/// Only traverses reference properties (complex types + strings). /// Only traverses reference properties (complex types + strings).
/// Stops traversing an object after its 2nd occurrence. /// 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> /// </summary>
private static void ScanForDuplicates(object value, Type type, BinarySerializationContext context) private static void ScanForDuplicates(object value, Type type, BinarySerializationContext context)
{ {
@ -17,7 +17,7 @@ public static partial class AcBinarySerializer
return; return;
ScanValue(value, type, context, 0); 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) private static void ScanValue(object? value, Type type, BinarySerializationContext context, int depth)
@ -70,15 +70,15 @@ public static partial class AcBinarySerializer
{ {
case IdAccessorType.Int32: case IdAccessorType.Int32:
var id32 = wrapper.RefIdGetterInt32!(value); var id32 = wrapper.RefIdGetterInt32!(value);
isFirst = wrapper.TryTrackInt32(id32, context.NextFirstIndex++, out _); isFirst = wrapper.TryTrackInt32(id32, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
break; break;
case IdAccessorType.Int64: case IdAccessorType.Int64:
var id64 = wrapper.RefIdGetterInt64!(value); var id64 = wrapper.RefIdGetterInt64!(value);
isFirst = wrapper.TryTrackInt64(id64, context.NextFirstIndex++, out _); isFirst = wrapper.TryTrackInt64(id64, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
break; break;
case IdAccessorType.Guid: case IdAccessorType.Guid:
var idGuid = wrapper.RefIdGetterGuid!(value); var idGuid = wrapper.RefIdGetterGuid!(value);
isFirst = wrapper.TryTrackGuid(idGuid, context.NextFirstIndex++, out _); isFirst = wrapper.TryTrackGuid(idGuid, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
break; break;
default: default:
isFirst = true; isFirst = true;
@ -90,13 +90,26 @@ public static partial class AcBinarySerializer
} }
// Recursive scan on reference properties only // Recursive scan on reference properties only
// Use typed getter for strings (much faster than reflection GetValue)
var refProperties = metadata.ReferenceProperties; var refProperties = metadata.ReferenceProperties;
var nextDepth2 = depth + 1; var nextDepth2 = depth + 1;
for (var i = 0; i < refProperties.Length; i++) for (var i = 0; i < refProperties.Length; i++)
{ {
var propValue = refProperties[i].GetValue(value); var prop = refProperties[i];
if (propValue != null) if (prop.AccessorType == PropertyAccessorType.String)
ScanValue(propValue, refProperties[i].PropertyType, context, nextDepth2); {
// 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; var isFirstMetadataOccurrence = false;
if (context.UseMetadata) if (context.UseMetadata)
{ {
isFirstMetadataOccurrence = context.RegisterMetadataType(wrapper); isFirstMetadataOccurrence = BinarySerializationContext.RegisterMetadataType(wrapper);
} }
// Reference handling: lookup entry from scan pass, check IsFirstWrite // 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; namespace AyCode.Core.Serializers;
/// <summary> /// <summary>
/// Abstract base class for all serialization contexts (Binary, JSON, Toon). /// 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). /// Derived classes are sealed for JIT devirtualization (direct call speed).
/// </summary> /// </summary>
/// <typeparam name="TMetadata">The concrete metadata type for serialization.</typeparam> /// <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 TMetadata : TypeMetadataBase
where TOptions : AcSerializerOptions 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 #region Reset
/// <summary> /// <summary>

View File

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