diff --git a/AyCode.Benchmark/JitDisassemblyBenchmark.cs b/AyCode.Benchmark/JitDisassemblyBenchmark.cs index 69d7828..0c77de2 100644 --- a/AyCode.Benchmark/JitDisassemblyBenchmark.cs +++ b/AyCode.Benchmark/JitDisassemblyBenchmark.cs @@ -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); } /// - /// FastMode serialize — the primary hot path. - /// Disassembly will show WriteObject → WritePropertyMarkerless/WritePropertyOrSkip call chain. + /// FastMode serialize — no ref tracking, no string interning. /// [Benchmark(Baseline = true)] public byte[] Serialize_FastMode() @@ -59,11 +62,30 @@ public class JitDisassemblyBenchmark } /// - /// FastMode deserialize — for comparison. + /// FastMode deserialize. /// [Benchmark] public TestOrder Deserialize_FastMode() { - return AcBinaryDeserializer.Deserialize(_serialized, _fastModeOptions); + return AcBinaryDeserializer.Deserialize(_serializedFastMode, _fastModeOptions); + } + + /// + /// Default serialize — ref tracking + string interning (scan pass + write pass). + /// Shows IdentityMap lookup overhead in hot path. + /// + [Benchmark] + public byte[] Serialize_Default() + { + return AcBinarySerializer.Serialize(_order, _defaultOptions); + } + + /// + /// Default deserialize — ref tracking + string interning. + /// + [Benchmark] + public TestOrder Deserialize_Default() + { + return AcBinaryDeserializer.Deserialize(_serializedDefault, _defaultOptions); } } diff --git a/AyCode.Core.Serializers.Console/Program.cs b/AyCode.Core.Serializers.Console/Program.cs index 04309a4..77ef1df 100644 --- a/AyCode.Core.Serializers.Console/Program.cs +++ b/AyCode.Core.Serializers.Console/Program.cs @@ -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), diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs index ca66ad8..b4cf08f 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs @@ -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; + + /// Unified scan visit counter. Increments on every IId object and internable string visit. + internal int ScanVisitIndex; + + /// Write plan entry count for write pass cursor. + internal int WritePlanCount => _writePlanCount; + + /// Write plan array for write pass cursor. Sorted by VisitIndex after scan pass. + internal WriteDuplicateEntry[]? WritePlan => _writePlan; + + /// + /// Adds a pre-computed write instruction for a duplicate string or IId object reference. + /// + internal void AddWriteDuplicateEntry(int visitIndex, int cacheMapIndex, bool isFirst, string? value) + { + if (_writePlan == null) + { + _writePlan = ArrayPool.Shared.Rent(16); + } + else if (_writePlanCount >= _writePlan.Length) + { + var newArray = ArrayPool.Shared.Rent(_writePlan.Length * 2); + _writePlan.AsSpan(0, _writePlanCount).CopyTo(newArray); + ArrayPool.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; + } + + /// + /// Sorts write plan by VisitIndex for sequential cursor consumption in write pass. + /// Called once after scan pass completes. + /// + internal void SortWritePlan() + { + if (_writePlanCount > 1) + _writePlan.AsSpan(0, _writePlanCount).Sort(static (a, b) => a.VisitIndex.CompareTo(b.VisitIndex)); + } + + /// Write pass cursor index into sorted _writePlan array. + internal int WritePlanCursor; + + /// Write pass visit counter. Mirrors ScanVisitIndex ordering. + internal int WriteVisitIndex; + + /// + /// 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. + /// + [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 + /// /// Next cache index reference for scan pass. Direct ref access for TryTrack methods. /// @@ -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.Shared.Return(_writePlan); + _writePlan = null; + } + } + _writePlanCount = 0; if (_propertyIndexBuffer != null && _propertyIndexBuffer.Length > PropertyIndexBufferMaxCache) { @@ -590,10 +684,13 @@ public static partial class AcBinarySerializer /// /// Scan pass: tracks a string for interning. Assigns CacheIndex immediately on 2nd occurrence. + /// Builds WriteDuplicateEntry for all duplicate occurrences. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void ScanInternString(string value) { + var visitIndex = ScanVisitIndex++; + _stringInternMap ??= new IdentityMap(); 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; } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs index bda59f9..0298b9b 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.ScanPass.cs @@ -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(object? value, TypeMetadataWrapper wrapper, BinarySerializationContext 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 diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index fa94686..66e42e1 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -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 /// /// 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). /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteString(string value, BinarySerializationContext 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.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 + + /// + /// DEBUG ONLY: Validates that the WritePlan entry's string value matches the actual string being written. + /// Catches scan/write pass visit index misalignment immediately. + /// + [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."); + } + } + + /// + /// 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. + /// + [Conditional("DEBUG")] + private static void ValidateWritePlanObject( + in WriteDuplicateEntry planEntry, + object value, + TypeMetadataWrapper 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 } diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs index a41a6df..bbc3c38 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs @@ -84,7 +84,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions /// public bool UseMetadata { get; set; } = false; - public bool UseGeneratedCode { get; set; } = true; + public bool UseGeneratedCode { get; set; } = false; /// /// When true, checks for duplicate property name hashes during serialization (UseMetadata mode). diff --git a/AyCode.Core/Serializers/IdentityMap.cs b/AyCode.Core/Serializers/IdentityMap.cs index 8a9ded6..b19616e 100644 --- a/AyCode.Core/Serializers/IdentityMap.cs +++ b/AyCode.Core/Serializers/IdentityMap.cs @@ -28,7 +28,7 @@ public enum IdAccessorType : byte /// public struct InternEntry { - /// Order of first occurrence during scan pass (0, 1, 2, ...). Used for CacheIndex assignment. + /// Scan visit index of first occurrence. Used to create WriteDuplicateEntry on 2nd occurrence. public int FirstIndex; /// Dense cache index (0, 1, 2, ...) assigned after scan pass. -1 = not cached, 0+ = cache index. public int CacheIndex; @@ -36,6 +36,23 @@ public struct InternEntry public bool IsFirstWrite; } +/// +/// 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. +/// +public struct WriteDuplicateEntry +{ + /// Sequential visit index matching the write pass traversal order. + public int VisitIndex; + /// Cache/intern index to write (intern index for strings, cache index for IId objects). + public int CacheMapIndex; + /// True = first occurrence (StringFirst / RefFirst). False = subsequent reference (StringRef / ObjRef). + public bool IsFirst; + /// Non-null for StringFirst: the interned string value (avoids getter call). Null for all other cases. + public string? Value; +} + /// /// Interface for identity maps used in serialization tracking. /// Enables type-safe Reset() without knowing the generic type parameter. diff --git a/AyCode.Core/Serializers/TypeMetadataWrapper.cs b/AyCode.Core/Serializers/TypeMetadataWrapper.cs index f654c4c..d2c6de6 100644 --- a/AyCode.Core/Serializers/TypeMetadataWrapper.cs +++ b/AyCode.Core/Serializers/TypeMetadataWrapper.cs @@ -209,11 +209,12 @@ public sealed class TypeMetadataWrapper where TMetadata : TypeMetadat /// /// 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). /// [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(); if (!map.TryAdd(id, out var slotIndex)) @@ -222,28 +223,36 @@ public sealed class TypeMetadataWrapper 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; } /// /// Tries to track Int64 Id. Returns true if first occurrence. + /// firstVisitIndex >= 0 only on exact 2nd occurrence. /// [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(); if (!map.TryAdd(id, out var slotIndex)) @@ -253,25 +262,32 @@ public sealed class TypeMetadataWrapper 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; } /// /// Tries to track Guid Id. Returns true if first occurrence. + /// firstVisitIndex >= 0 only on exact 2nd occurrence. /// [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(); if (!map.TryAdd(id, out var slotIndex)) @@ -281,15 +297,21 @@ public sealed class TypeMetadataWrapper 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; }