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:
parent
1af939ac4d
commit
6f88306e54
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,29 +93,46 @@ 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
|
||||
|
|
|
|||
|
|
@ -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,86 +984,24 @@ 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;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 2+ occurrence → write ObjectRef
|
||||
context.WriteByte(BinaryTypeCode.ObjectRef);
|
||||
context.WriteVarUInt((uint)entry.CacheIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
// First occurrence of a cached IId object — write full object + cache index
|
||||
cachedObjectCacheIndex = planEntry.CacheMapIndex;
|
||||
}
|
||||
|
||||
case IdAccessorType.Int64:
|
||||
else
|
||||
{
|
||||
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;
|
||||
// 2+ occurrence → write ObjectRef (no children, no properties)
|
||||
context.WriteByte(BinaryTypeCode.ObjectRef);
|
||||
context.WriteVarUInt((uint)planEntry.CacheMapIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue