Optimize serializer with write plan for interning & refs

Implement write plan mechanism for string interning and IId object reference tracking. Scan pass now builds pre-computed WriteDuplicateEntry instructions, eliminating hot path IdentityMap lookups and redundant getter calls in the write pass. Update BinarySerializationContext, tracking visit indices and managing write plan array. Refactor ScanInternString and TryTrack methods to record visit indices and build write instructions for all duplicate occurrences. Update write pass logic to consume write plan entries. Add debug validation for scan/write pass order. Update benchmarks and test harness. Set UseGeneratedCode default to false. Improves performance for scenarios with interning and reference tracking.
This commit is contained in:
Loretta 2026-02-15 17:28:06 +01:00
parent 1af939ac4d
commit 6f88306e54
8 changed files with 316 additions and 125 deletions

View File

@ -26,7 +26,9 @@ public class JitDisassemblyBenchmark
{
private TestOrder _order = null!;
private AcBinarySerializerOptions _fastModeOptions = null!;
private byte[] _serialized = null!;
private AcBinarySerializerOptions _defaultOptions = null!;
private byte[] _serializedFastMode = null!;
private byte[] _serializedDefault = null!;
[GlobalSetup]
public void Setup()
@ -45,12 +47,13 @@ public class JitDisassemblyBenchmark
sharedUser: sharedUser);
_fastModeOptions = AcBinarySerializerOptions.FastMode;
_serialized = AcBinarySerializer.Serialize(_order, _fastModeOptions);
_defaultOptions = AcBinarySerializerOptions.Default;
_serializedFastMode = AcBinarySerializer.Serialize(_order, _fastModeOptions);
_serializedDefault = AcBinarySerializer.Serialize(_order, _defaultOptions);
}
/// <summary>
/// FastMode serialize — the primary hot path.
/// Disassembly will show WriteObject → WritePropertyMarkerless/WritePropertyOrSkip call chain.
/// FastMode serialize — no ref tracking, no string interning.
/// </summary>
[Benchmark(Baseline = true)]
public byte[] Serialize_FastMode()
@ -59,11 +62,30 @@ public class JitDisassemblyBenchmark
}
/// <summary>
/// FastMode deserialize — for comparison.
/// FastMode deserialize.
/// </summary>
[Benchmark]
public TestOrder Deserialize_FastMode()
{
return AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _fastModeOptions);
return AcBinaryDeserializer.Deserialize<TestOrder>(_serializedFastMode, _fastModeOptions);
}
/// <summary>
/// Default serialize — ref tracking + string interning (scan pass + write pass).
/// Shows IdentityMap lookup overhead in hot path.
/// </summary>
[Benchmark]
public byte[] Serialize_Default()
{
return AcBinarySerializer.Serialize(_order, _defaultOptions);
}
/// <summary>
/// Default deserialize — ref tracking + string interning.
/// </summary>
[Benchmark]
public TestOrder Deserialize_Default()
{
return AcBinaryDeserializer.Deserialize<TestOrder>(_serializedDefault, _defaultOptions);
}
}

View File

@ -212,15 +212,15 @@ public static class Program
{
// AcBinary variants
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None }, SerializerAcBinaryNoIntern),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
new AcBinaryBenchmark(testData.Order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None }, SerializerAcBinaryNoIntern),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
// AcJson
new AcJsonBenchmark(testData.Order, AcJsonSerializerOptions.Default, SerializerAcJsonDefault),

View File

@ -92,6 +92,85 @@ public static partial class AcBinarySerializer
private int _nextCacheIndex; // Next dense cache index to assign (starts at 0, uses ++_nextCacheIndex)
public int NextFirstIndex; // Next first occurrence index for scan pass. Direct access for performance.
#region WriteDuplicateEntry scan pass output for write pass cursor
private WriteDuplicateEntry[]? _writePlan;
private int _writePlanCount;
/// <summary>Unified scan visit counter. Increments on every IId object and internable string visit.</summary>
internal int ScanVisitIndex;
/// <summary>Write plan entry count for write pass cursor.</summary>
internal int WritePlanCount => _writePlanCount;
/// <summary>Write plan array for write pass cursor. Sorted by VisitIndex after scan pass.</summary>
internal WriteDuplicateEntry[]? WritePlan => _writePlan;
/// <summary>
/// Adds a pre-computed write instruction for a duplicate string or IId object reference.
/// </summary>
internal void AddWriteDuplicateEntry(int visitIndex, int cacheMapIndex, bool isFirst, string? value)
{
if (_writePlan == null)
{
_writePlan = ArrayPool<WriteDuplicateEntry>.Shared.Rent(16);
}
else if (_writePlanCount >= _writePlan.Length)
{
var newArray = ArrayPool<WriteDuplicateEntry>.Shared.Rent(_writePlan.Length * 2);
_writePlan.AsSpan(0, _writePlanCount).CopyTo(newArray);
ArrayPool<WriteDuplicateEntry>.Shared.Return(_writePlan, clearArray: true);
_writePlan = newArray;
}
ref var entry = ref _writePlan[_writePlanCount++];
entry.VisitIndex = visitIndex;
entry.CacheMapIndex = cacheMapIndex;
entry.IsFirst = isFirst;
entry.Value = value;
}
/// <summary>
/// Sorts write plan by VisitIndex for sequential cursor consumption in write pass.
/// Called once after scan pass completes.
/// </summary>
internal void SortWritePlan()
{
if (_writePlanCount > 1)
_writePlan.AsSpan(0, _writePlanCount).Sort(static (a, b) => a.VisitIndex.CompareTo(b.VisitIndex));
}
/// <summary>Write pass cursor index into sorted _writePlan array.</summary>
internal int WritePlanCursor;
/// <summary>Write pass visit counter. Mirrors ScanVisitIndex ordering.</summary>
internal int WriteVisitIndex;
/// <summary>
/// Tries to consume the next write plan entry at the current WriteVisitIndex.
/// Returns true if the entry matches (duplicate exists at this visit point).
/// Always increments WriteVisitIndex.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal bool TryConsumeWritePlanEntry(out WriteDuplicateEntry entry)
{
var visitIndex = WriteVisitIndex++;
if (_writePlan != null && WritePlanCursor < _writePlanCount)
{
ref var candidate = ref _writePlan[WritePlanCursor];
if (candidate.VisitIndex == visitIndex)
{
entry = candidate;
WritePlanCursor++;
return true;
}
}
entry = default;
return false;
}
#endregion
/// <summary>
/// Next cache index reference for scan pass. Direct ref access for TryTrack methods.
/// </summary>
@ -182,6 +261,21 @@ public static partial class AcBinarySerializer
_stringInternMap?.Reset();
_nextCacheIndex = 0;
NextFirstIndex = 0;
ScanVisitIndex = 0;
WritePlanCursor = 0;
WriteVisitIndex = 0;
// Clear write plan string references to avoid GC pinning, keep array if small enough
if (_writePlan != null)
{
_writePlan.AsSpan(0, _writePlanCount).Clear();
if (_writePlan.Length > PropertyIndexBufferMaxCache)
{
ArrayPool<WriteDuplicateEntry>.Shared.Return(_writePlan);
_writePlan = null;
}
}
_writePlanCount = 0;
if (_propertyIndexBuffer != null && _propertyIndexBuffer.Length > PropertyIndexBufferMaxCache)
{
@ -590,10 +684,13 @@ public static partial class AcBinarySerializer
/// <summary>
/// Scan pass: tracks a string for interning. Assigns CacheIndex immediately on 2nd occurrence.
/// Builds WriteDuplicateEntry for all duplicate occurrences.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ScanInternString(string value)
{
var visitIndex = ScanVisitIndex++;
_stringInternMap ??= new IdentityMap<string, InternEntry>();
if (!_stringInternMap.TryAdd(value, out var slotIndex))
@ -602,15 +699,19 @@ public static partial class AcBinarySerializer
ref var entry = ref _stringInternMap.GetValueRef(slotIndex);
if (entry.CacheIndex == -1)
{
// 2nd occurrence: assign CacheIndex + add StringFirst entry at first visit position
entry.CacheIndex = ++_nextCacheIndex;
entry.IsFirstWrite = true;
AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value);
}
// 2nd+ occurrence: add StringRef entry at current position
AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null);
return;
}
// 1st occurrence: store FirstIndex for validation, CacheIndex = -1 (not cached yet)
// 1st occurrence: store scan visit index, CacheIndex = -1 (not cached yet)
ref var newEntry = ref _stringInternMap.GetValueRef(slotIndex);
newEntry.FirstIndex = NextFirstIndex++;
newEntry.FirstIndex = visitIndex;
newEntry.CacheIndex = -1;
}

View File

@ -24,7 +24,7 @@ public static partial class AcBinarySerializer
var wrapper = context.GetWrapper(type);
ScanValue(value, wrapper, context, 0);
//context.CurrentProperty = null;
context.SortWritePlan();
}
private static void ScanValue<TOutput>(object? value, TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper, BinarySerializationContext<TOutput> context, int depth)
@ -93,30 +93,47 @@ public static partial class AcBinarySerializer
// 3. Nested objects reachable from other paths are scanned through those paths
if (context.UseTypeReferenceHandling(metadata))
{
var visitIndex = context.ScanVisitIndex++;
// Direct tracking call - avoid extra indirection through context
bool isFirst;
int cacheIndex;
int firstVisitIndex;
switch (metadata.IdAccessorType)
{
case IdAccessorType.Int32:
var id32 = wrapper.RefIdGetterInt32!(value);
isFirst = wrapper.TryTrackInt32(id32, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
isFirst = wrapper.TryTrackInt32(id32, visitIndex, ref context.NextCacheIndexRef, out cacheIndex, out firstVisitIndex);
break;
case IdAccessorType.Int64:
var id64 = wrapper.RefIdGetterInt64!(value);
isFirst = wrapper.TryTrackInt64(id64, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
isFirst = wrapper.TryTrackInt64(id64, visitIndex, ref context.NextCacheIndexRef, out cacheIndex, out firstVisitIndex);
break;
case IdAccessorType.Guid:
var idGuid = wrapper.RefIdGetterGuid!(value);
isFirst = wrapper.TryTrackGuid(idGuid, context.NextFirstIndex++, ref context.NextCacheIndexRef, out _);
isFirst = wrapper.TryTrackGuid(idGuid, visitIndex, ref context.NextCacheIndexRef, out cacheIndex, out firstVisitIndex);
break;
default:
isFirst = true;
cacheIndex = -1;
firstVisitIndex = -1;
break;
}
if (!isFirst)
{
// Build WriteDuplicateEntry for IId references
if (firstVisitIndex >= 0)
{
// Exact 2nd occurrence: add RefFirst entry at first visit position
context.AddWriteDuplicateEntry(firstVisitIndex, cacheIndex, isFirst: true, value: null);
}
// 2nd+ occurrence: add ObjRef at current position
context.AddWriteDuplicateEntry(visitIndex, cacheIndex, isFirst: false, value: null);
return; // 2nd occurrence → skip children (symmetric with write pass ObjectRef)
}
}
// Recursive scan on reference properties only
// Use typed getter for strings (much faster than reflection GetValue)

View File

@ -4,6 +4,7 @@ using AyCode.Core.Serializers.Expressions;
using System.Buffers;
using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
@ -899,7 +900,7 @@ public static partial class AcBinarySerializer
/// <summary>
/// Optimized string writer with FixStr for short strings.
/// Marker-based interning: write String marker, rewrite to StringInternFirst at end if needed.
/// Uses pre-computed WriteDuplicateEntry cursor for interning (no IdentityMap lookup).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteString<TOutput>(string value, BinarySerializationContext<TOutput> context)
@ -911,37 +912,29 @@ public static partial class AcBinarySerializer
return;
}
if (context.UseStringInterning && context.IsValidForInterningString(value.Length))// && context.CurrentProperty!.UseStringPropertyInterning(context.Options.UseStringInterning))
if (context.UseStringInterning && context.IsValidForInterningString(value.Length))
{
ref var interEntry = ref context.GetInternedStringEntry(value, out bool found);
if (found && interEntry.CacheIndex >= 0)
if (context.TryConsumeWritePlanEntry(out var planEntry))
{
if (interEntry.IsFirstWrite)
ValidateWritePlanString(in planEntry, value);
if (planEntry.IsFirst)
{
// 1st serialize occurrence of a cached string - write StringInternFirst + cacheIndex + data
interEntry.IsFirstWrite = false;
// StringFirst: write interned string + cache index + data (Value holds the string)
context.WriteByte(BinaryTypeCode.StringInternFirst);
context.WriteVarUInt((uint)interEntry.CacheIndex);
context.WriteStringUtf8(value);
context.WriteVarUInt((uint)planEntry.CacheMapIndex);
context.WriteStringUtf8(planEntry.Value ?? value);
}
else
{
// 2+ serialize occurrence: write index reference
// StringRef: write index reference only (no getter call, no string data)
context.WriteByte(BinaryTypeCode.StringInterned);
context.WriteVarUInt((uint)interEntry.CacheIndex);
context.WriteVarUInt((uint)planEntry.CacheMapIndex);
}
return;
}
// CacheIndex < 0 or not found → single occurrence, fall through to FixStr/String path
// No plan entry → single occurrence, fall through to FixStr/String path
#if DEBUG
//context.OnStringInterned?.Invoke(
// context.CurrentProperty != null ? $"{context.CurrentProperty.DeclaringType.Name}.{context.CurrentProperty.Name}" : null,
// value);
context.OnStringInterned?.Invoke(
null,
value);
context.OnStringInterned?.Invoke(null, value);
#endif
}
@ -991,89 +984,27 @@ public static partial class AcBinarySerializer
isFirstMetadataOccurrence = BinarySerializationContext<TOutput>.RegisterMetadataType(wrapper);
}
// Reference handling: lookup entry from scan pass, check IsFirstWrite
// Reference handling: consume pre-computed write plan entry from scan pass cursor
var cachedObjectCacheIndex = -1; // -1 = not cached, 0+ = cache index for first write
if (context.UseTypeReferenceHandling(metadata))
{
// Lookup by Id (IId types) or by object identity hash (non-IId types)
// Both use IdAccessorType.Int32 - for non-IId, RefIdGetterInt32 returns RuntimeHelpers.GetHashCode
switch (metadata.IdAccessorType)
if (context.TryConsumeWritePlanEntry(out var planEntry))
{
case IdAccessorType.Int32:
ValidateWritePlanObject(in planEntry, value, wrapper);
if (planEntry.IsFirst)
{
var id = wrapper.RefIdGetterInt32!(value);
// For IId: skip default Id (0). For non-IId (hash): hash is never 0 for valid objects
if ((!metadata.IsIId || id != 0) && wrapper.TryGetEntryInt32(id, out var slotIndex))
{
ref var entry = ref wrapper.GetEntryRefInt32(slotIndex);
if (entry.CacheIndex >= 0)
{
if (entry.IsFirstWrite)
{
entry.IsFirstWrite = false;
cachedObjectCacheIndex = entry.CacheIndex;
// First occurrence of a cached IId object — write full object + cache index
cachedObjectCacheIndex = planEntry.CacheMapIndex;
}
else
{
// 2+ occurrence → write ObjectRef
// 2+ occurrence → write ObjectRef (no children, no properties)
context.WriteByte(BinaryTypeCode.ObjectRef);
context.WriteVarUInt((uint)entry.CacheIndex);
context.WriteVarUInt((uint)planEntry.CacheMapIndex);
return;
}
}
}
break;
}
case IdAccessorType.Int64:
{
var id = wrapper.RefIdGetterInt64!(value);
if (id != 0 && wrapper.TryGetEntryInt64(id, out var slotIndex))
{
ref var entry = ref wrapper.GetEntryRefInt64(slotIndex);
if (entry.CacheIndex >= 0)
{
if (entry.IsFirstWrite)
{
entry.IsFirstWrite = false;
cachedObjectCacheIndex = entry.CacheIndex;
}
else
{
context.WriteByte(BinaryTypeCode.ObjectRef);
context.WriteVarUInt((uint)entry.CacheIndex);
return;
}
}
}
break;
}
case IdAccessorType.Guid:
{
var id = wrapper.RefIdGetterGuid!(value);
if (id != Guid.Empty && wrapper.TryGetEntryGuid(id, out var slotIndex))
{
ref var entry = ref wrapper.GetEntryRefGuid(slotIndex);
if (entry.CacheIndex >= 0)
{
if (entry.IsFirstWrite)
{
entry.IsFirstWrite = false;
cachedObjectCacheIndex = entry.CacheIndex;
}
else
{
context.WriteByte(BinaryTypeCode.ObjectRef);
context.WriteVarUInt((uint)entry.CacheIndex);
return;
}
}
}
break;
}
}
}
// Marker kiírása:
// - Cached object first occurrence: ObjectRefFirst/ObjectWithMetadataRefFirst + cacheIndex
@ -1781,4 +1712,85 @@ public static partial class AcBinarySerializer
// Type metadata helpers moved to AcBinarySerializer.BinarySerializeTypeMetadata.cs
#endregion
#region WritePlan Debug Validation
/// <summary>
/// DEBUG ONLY: Validates that the WritePlan entry's string value matches the actual string being written.
/// Catches scan/write pass visit index misalignment immediately.
/// </summary>
[Conditional("DEBUG")]
private static void ValidateWritePlanString(in WriteDuplicateEntry planEntry, string actualValue)
{
if (planEntry.IsFirst && planEntry.Value != null && !string.Equals(planEntry.Value, actualValue, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"plan string=\"{planEntry.Value}\", actual=\"{actualValue}\". " +
$"Scan/write pass visit order diverged.");
}
}
/// <summary>
/// DEBUG ONLY: Validates that the WritePlan entry's IId matches the actual object being written.
/// Uses the typed Id getter to extract the Id and compares against the scan pass IdentityMap.
/// </summary>
[Conditional("DEBUG")]
private static void ValidateWritePlanObject(
in WriteDuplicateEntry planEntry,
object value,
TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper)
{
var metadata = wrapper.Metadata;
switch (metadata.IdAccessorType)
{
case IdAccessorType.Int32:
{
var actualId = wrapper.RefIdGetterInt32!(value);
if (!wrapper.TryGetEntryInt32(actualId, out var slotIndex))
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"Int32 Id={actualId} not found in IdentityMap. Scan/write pass visit order diverged.");
ref var entry = ref wrapper.GetEntryRefInt32(slotIndex);
if (entry.CacheIndex != planEntry.CacheMapIndex)
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"Int32 Id={actualId} CacheIndex={entry.CacheIndex}, plan CacheMapIndex={planEntry.CacheMapIndex}.");
break;
}
case IdAccessorType.Int64:
{
var actualId = wrapper.RefIdGetterInt64!(value);
if (!wrapper.TryGetEntryInt64(actualId, out var slotIndex))
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"Int64 Id={actualId} not found in IdentityMap. Scan/write pass visit order diverged.");
ref var entry = ref wrapper.GetEntryRefInt64(slotIndex);
if (entry.CacheIndex != planEntry.CacheMapIndex)
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"Int64 Id={actualId} CacheIndex={entry.CacheIndex}, plan CacheMapIndex={planEntry.CacheMapIndex}.");
break;
}
case IdAccessorType.Guid:
{
var actualId = wrapper.RefIdGetterGuid!(value);
if (!wrapper.TryGetEntryGuid(actualId, out var slotIndex))
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"Guid Id={actualId} not found in IdentityMap. Scan/write pass visit order diverged.");
ref var entry = ref wrapper.GetEntryRefGuid(slotIndex);
if (entry.CacheIndex != planEntry.CacheMapIndex)
throw new InvalidOperationException(
$"WritePlan cursor mismatch at VisitIndex={planEntry.VisitIndex}: " +
$"Guid Id={actualId} CacheIndex={entry.CacheIndex}, plan CacheMapIndex={planEntry.CacheMapIndex}.");
break;
}
}
}
#endregion
}

View File

@ -84,7 +84,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// </summary>
public bool UseMetadata { get; set; } = false;
public bool UseGeneratedCode { get; set; } = true;
public bool UseGeneratedCode { get; set; } = false;
/// <summary>
/// When true, checks for duplicate property name hashes during serialization (UseMetadata mode).

View File

@ -28,7 +28,7 @@ public enum IdAccessorType : byte
/// </summary>
public struct InternEntry
{
/// <summary>Order of first occurrence during scan pass (0, 1, 2, ...). Used for CacheIndex assignment.</summary>
/// <summary>Scan visit index of first occurrence. Used to create WriteDuplicateEntry on 2nd occurrence.</summary>
public int FirstIndex;
/// <summary>Dense cache index (0, 1, 2, ...) assigned after scan pass. -1 = not cached, 0+ = cache index.</summary>
public int CacheIndex;
@ -36,6 +36,23 @@ public struct InternEntry
public bool IsFirstWrite;
}
/// <summary>
/// Pre-computed write instruction for duplicate strings and IId object references.
/// Built during scan pass, sorted by VisitIndex, consumed sequentially by write pass cursor.
/// Eliminates IdentityMap lookups and redundant getter calls from the write hot path.
/// </summary>
public struct WriteDuplicateEntry
{
/// <summary>Sequential visit index matching the write pass traversal order.</summary>
public int VisitIndex;
/// <summary>Cache/intern index to write (intern index for strings, cache index for IId objects).</summary>
public int CacheMapIndex;
/// <summary>True = first occurrence (StringFirst / RefFirst). False = subsequent reference (StringRef / ObjRef).</summary>
public bool IsFirst;
/// <summary>Non-null for StringFirst: the interned string value (avoids getter call). Null for all other cases.</summary>
public string? Value;
}
/// <summary>
/// Interface for identity maps used in serialization tracking.
/// Enables type-safe Reset() without knowing the generic type parameter.

View File

@ -209,11 +209,12 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
/// <summary>
/// Tries to track Int32 Id. Returns true if first occurrence.
/// On 2+ occurrence: assigns CacheIndex immediately using ++nextCacheIndex.
/// firstVisitIndex >= 0 only on exact 2nd occurrence (CacheIndex transition from -1).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackInt32(int id, int firstIndex, ref int nextCacheIndex, out int cacheIndex)
public bool TryTrackInt32(int id, int visitIndex, ref int nextCacheIndex, out int cacheIndex, out int firstVisitIndex)
{
if (id == 0) { cacheIndex = -1; return true; } // Default Id - no tracking
if (id == 0) { cacheIndex = -1; firstVisitIndex = -1; return true; } // Default Id - no tracking
var map = IdentityMapInt32 ??= new IdentityMap<int, InternEntry>();
if (!map.TryAdd(id, out var slotIndex))
@ -222,28 +223,36 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
ref var entry = ref map.GetValueRef(slotIndex);
if (entry.CacheIndex == -1)
{
// Exact 2nd occurrence: assign CacheIndex + return first visit index
entry.CacheIndex = ++nextCacheIndex;
entry.IsFirstWrite = true;
firstVisitIndex = entry.FirstIndex;
}
else
{
firstVisitIndex = -1; // 3rd+ occurrence
}
cacheIndex = entry.CacheIndex;
return false;
}
// 1st occurrence: store FirstIndex for validation
// 1st occurrence: store scan visit index
ref var newEntry = ref map.GetValueRef(slotIndex);
newEntry.FirstIndex = firstIndex;
newEntry.FirstIndex = visitIndex;
newEntry.CacheIndex = -1;
cacheIndex = -1;
firstVisitIndex = -1;
return true;
}
/// <summary>
/// Tries to track Int64 Id. Returns true if first occurrence.
/// firstVisitIndex >= 0 only on exact 2nd occurrence.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackInt64(long id, int firstIndex, ref int nextCacheIndex, out int cacheIndex)
public bool TryTrackInt64(long id, int visitIndex, ref int nextCacheIndex, out int cacheIndex, out int firstVisitIndex)
{
if (id == 0) { cacheIndex = -1; return true; }
if (id == 0) { cacheIndex = -1; firstVisitIndex = -1; return true; }
var map = IdentityMapInt64 ??= new IdentityMap<long, InternEntry>();
if (!map.TryAdd(id, out var slotIndex))
@ -253,25 +262,32 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
{
entry.CacheIndex = ++nextCacheIndex;
entry.IsFirstWrite = true;
firstVisitIndex = entry.FirstIndex;
}
else
{
firstVisitIndex = -1;
}
cacheIndex = entry.CacheIndex;
return false;
}
ref var newEntry = ref map.GetValueRef(slotIndex);
newEntry.FirstIndex = firstIndex;
newEntry.FirstIndex = visitIndex;
newEntry.CacheIndex = -1;
cacheIndex = -1;
firstVisitIndex = -1;
return true;
}
/// <summary>
/// Tries to track Guid Id. Returns true if first occurrence.
/// firstVisitIndex >= 0 only on exact 2nd occurrence.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryTrackGuid(Guid id, int firstIndex, ref int nextCacheIndex, out int cacheIndex)
public bool TryTrackGuid(Guid id, int visitIndex, ref int nextCacheIndex, out int cacheIndex, out int firstVisitIndex)
{
if (id == Guid.Empty) { cacheIndex = -1; return true; }
if (id == Guid.Empty) { cacheIndex = -1; firstVisitIndex = -1; return true; }
var map = IdentityMapGuid ??= new IdentityMap<Guid, InternEntry>();
if (!map.TryAdd(id, out var slotIndex))
@ -281,15 +297,21 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
{
entry.CacheIndex = ++nextCacheIndex;
entry.IsFirstWrite = true;
firstVisitIndex = entry.FirstIndex;
}
else
{
firstVisitIndex = -1;
}
cacheIndex = entry.CacheIndex;
return false;
}
ref var newEntry = ref map.GetValueRef(slotIndex);
newEntry.FirstIndex = firstIndex;
newEntry.FirstIndex = visitIndex;
newEntry.CacheIndex = -1;
cacheIndex = -1;
firstVisitIndex = -1;
return true;
}