Unify SGen wrapper slot tracking for metadata and refs

Refactored binary serializer to use a single wrapper slot per SGen type for both metadata registration and reference tracking. Removed slot-based IdentityMap arrays and scan pass tracking methods, replacing them with wrapper-based TryTrack logic. Updated generated code to use wrapper slots for all slot-based operations. Changed UseMetadata registration to use a MetadataSeen flag on the wrapper. Added fast slot-indexed wrapper access in context base. Default UseMetadata option is now false. Simplifies and optimizes SGen tracking, reducing dictionary lookups and unifying tracking logic.
This commit is contained in:
Loretta 2026-02-21 11:50:23 +01:00
parent fe35e60649
commit 48c737024f
6 changed files with 114 additions and 221 deletions

View File

@ -311,9 +311,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine($"internal sealed class {ci.ClassName}_GeneratedWriter : IGeneratedBinaryWriter");
sb.AppendLine("{");
sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedWriter Instance = new();");
sb.AppendLine($" internal static readonly int s_metadataSlot = AcBinarySerializer.AllocateMetadataSlot();");
if (ci.IsIId || ci.EnableRefHandling)
sb.AppendLine($" internal static readonly int s_trackingSlot = AcBinarySerializer.AllocateTrackingSlot();");
sb.AppendLine($" internal static readonly int s_wrapperSlot = AcBinarySerializer.AllocateWrapperSlot();");
sb.AppendLine($" internal static readonly int s_typeNameHash = {ci.TypeNameHash};");
sb.Append( $" internal static readonly int[] s_propertyHashes = new int[] {{ ");
sb.Append(string.Join(", ", ci.PropertyNameHashes));
@ -384,33 +382,46 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine();
sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);");
// Self ref tracking — matches runtime ScanValue UseTypeReferenceHandling block
// Self ref tracking — inline TryTrack via wrapper (no bridge method overhead)
// Only emitted when the corresponding feature flag is enabled.
if (ci.IsIId)
{
// IId type: track when ReferenceHandling != None
var trackMethod = ci.IdTypeName switch
var tryTrackMethod = ci.IdTypeName switch
{
"int" => "ScanTrackObjectInt32",
"long" => "ScanTrackObjectInt64",
"System.Guid" => "ScanTrackObjectGuid",
_ => "ScanTrackObjectInt32"
"int" => "TryTrackInt32",
"long" => "TryTrackInt64",
"System.Guid" => "TryTrackGuid",
_ => "TryTrackInt32"
};
sb.AppendLine();
sb.AppendLine(" if (context.ReferenceHandling != ReferenceHandlingMode.None)");
sb.AppendLine(" {");
sb.AppendLine($" if (!context.{trackMethod}(s_trackingSlot, obj.Id))");
sb.AppendLine($" var wrapper = context.GetWrapperBySlot(s_wrapperSlot, typeof({ci.FullTypeName}));");
sb.AppendLine(" var visitIndex = context.ScanVisitIndex++;");
sb.AppendLine($" if (!wrapper.{tryTrackMethod}(obj.Id, visitIndex, ref context.NextCacheIndexRef, out var cacheIndex, out var firstVisitIndex))");
sb.AppendLine(" {");
sb.AppendLine(" if (firstVisitIndex >= 0)");
sb.AppendLine(" context.AddWriteDuplicateEntry(firstVisitIndex, cacheIndex, isFirst: true, value: null);");
sb.AppendLine(" context.AddWriteDuplicateEntry(visitIndex, cacheIndex, isFirst: false, value: null);");
sb.AppendLine(" return;");
sb.AppendLine(" }");
sb.AppendLine(" }");
}
else if (ci.EnableRefHandling)
{
// Non-IId type: track when ReferenceHandling == All
// Non-IId type: track via wrapper.TryTrackInt32 with RuntimeHelpers.GetHashCode
sb.AppendLine();
sb.AppendLine(" if (context.ReferenceHandling == ReferenceHandlingMode.All)");
sb.AppendLine(" {");
sb.AppendLine(" if (!context.ScanTrackObjectInt32(s_trackingSlot, System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value)))");
sb.AppendLine($" var wrapper = context.GetWrapperBySlot(s_wrapperSlot, typeof({ci.FullTypeName}));");
sb.AppendLine(" var visitIndex = context.ScanVisitIndex++;");
sb.AppendLine(" if (!wrapper.TryTrackInt32(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value), visitIndex, ref context.NextCacheIndexRef, out var cacheIndex, out var firstVisitIndex))");
sb.AppendLine(" {");
sb.AppendLine(" if (firstVisitIndex >= 0)");
sb.AppendLine(" context.AddWriteDuplicateEntry(firstVisitIndex, cacheIndex, isFirst: true, value: null);");
sb.AppendLine(" context.AddWriteDuplicateEntry(visitIndex, cacheIndex, isFirst: false, value: null);");
sb.AppendLine(" return;");
sb.AppendLine(" }");
sb.AppendLine(" }");
}
@ -891,7 +902,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
else
{
// UseMetadata: register type for first/repeated tracking via slot (no GetWrapper needed)
sb.AppendLine($"{i} var isFirstMeta_{p.Name} = context.UseMetadata && context.RegisterMetadataTypeBySlot({writer}.s_metadataSlot);");
sb.AppendLine($"{i} var isFirstMeta_{p.Name} = context.UseMetadata && AcBinarySerializer.BinarySerializationContext<TOutput>.RegisterMetadataType(context.GetWrapperBySlot({writer}.s_wrapperSlot, typeof({p.TypeNameForTypeof})));");
// Inline ref tracking: guard depends on IId vs non-IId to match scan pass behavior.
var refGuard = p.IsIId
@ -1041,7 +1052,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
else
{
// UseMetadata: register element type for first/repeated tracking
sb.AppendLine($"{i} var isFirstMeta_e_{p.Name} = context.UseMetadata && context.RegisterMetadataTypeBySlot({writer}.s_metadataSlot);");
sb.AppendLine($"{i} var isFirstMeta_e_{p.Name} = context.UseMetadata && AcBinarySerializer.BinarySerializationContext<TOutput>.RegisterMetadataType(context.GetWrapperBySlot({writer}.s_wrapperSlot, typeof({p.ElementFullTypeName})));");
sb.AppendLine($"{i} if ({elemRefGuard} && context.TryConsumeWritePlanEntry(out var epe_{p.Name}))");
sb.AppendLine($"{i} {{");

View File

@ -36,6 +36,13 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
/// </summary>
private readonly Dictionary<Type, TypeMetadataWrapper<TMetadata>> _wrappers = new();
/// <summary>
/// Slot-indexed wrapper cache for SGen types. Indexed by AllocateWrapperSlot() value.
/// Avoids dictionary lookup — direct array access for types with compile-time known slot index.
/// Not cleared on pool return: wrapper references are stable across serialization calls.
/// </summary>
private TypeMetadataWrapper<TMetadata>?[]? _wrapperSlots;
/// <summary>
/// Factory function to create metadata. Implemented by derived class.
/// </summary>
@ -81,6 +88,44 @@ public abstract class AcSerializerContextBase<TMetadata, TOptions>
return wrapper;
}
/// <summary>
/// Gets or creates a wrapper for the specified type using a pre-allocated slot index.
/// SGen types call this with their compile-time known slot — avoids dictionary lookup.
/// First call per slot per context: falls back to GetWrapper + stores in slot array.
/// Subsequent calls: direct array index (~1-2ns vs ~15-25ns dictionary lookup).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TypeMetadataWrapper<TMetadata> GetWrapperBySlot(int slot, Type type)
{
var slots = _wrapperSlots;
if (slots != null && (uint)slot < (uint)slots.Length)
{
var wrapper = slots[slot];
if (wrapper != null)
return wrapper;
}
return GetWrapperBySlotSlow(slot, type);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private TypeMetadataWrapper<TMetadata> GetWrapperBySlotSlow(int slot, Type type)
{
var wrapper = GetWrapper(type);
_wrapperSlots![slot] = wrapper;
return wrapper;
}
/// <summary>
/// Pre-allocates the wrapper slot array with the known total slot count.
/// Called once from derived context constructor after all AllocateWrapperSlot() calls have completed.
/// </summary>
protected void InitializeWrapperSlots(int count)
{
if (count > 0)
_wrapperSlots = new TypeMetadataWrapper<TMetadata>?[count];
}
#endregion
#region Wrapper Iteration (for footer writing)

View File

@ -89,26 +89,16 @@ public static partial class AcBinarySerializer
#endregion
private IdentityMap<string, InternEntry>? _stringInternMap;
private readonly ulong[] _metadataSeenBits = new ulong[4]; // 256 SGen slot (bit per type, first/repeated tracking)
private HashSet<int>? _metadataSeenOverflow; // fallback for slot >= 256
private int _nextCacheIndex; // Next dense cache index to assign (starts at 0, uses ++_nextCacheIndex)
private int _nextCacheIndex;
public int NextFirstIndex; // Next first occurrence index for scan pass. Direct access for performance.
#region Slot-based IdentityMaps for SGen scan pass (replaces wrapper-based tracking)
private IdentityMap<int, InternEntry>?[]? _slottedIdMapsInt32;
private IdentityMap<long, InternEntry>?[]? _slottedIdMapsInt64;
private IdentityMap<Guid, InternEntry>?[]? _slottedIdMapsGuid;
#endregion
#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;
public int ScanVisitIndex;
/// <summary>Write plan entry count for write pass cursor.</summary>
internal int WritePlanCount => _writePlanCount;
@ -119,7 +109,7 @@ public static partial class AcBinarySerializer
/// <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)
public void AddWriteDuplicateEntry(int visitIndex, int cacheMapIndex, bool isFirst, string? value)
{
if (_writePlan == null)
{
@ -261,7 +251,7 @@ public static partial class AcBinarySerializer
public BinarySerializationContext(AcBinarySerializerOptions options)
{
Reset(options);
//FastWire = options.WireMode == WireMode.Fast;
InitializeWrapperSlots(Volatile.Read(ref s_nextWrapperSlot));
}
/// <summary>
@ -287,12 +277,6 @@ public static partial class AcBinarySerializer
}
_stringInternMap?.Reset();
_metadataSeenBits.AsSpan().Clear();
_metadataSeenOverflow?.Clear();
ResetSlottedMaps(_slottedIdMapsInt32);
ResetSlottedMaps(_slottedIdMapsInt64);
ResetSlottedMaps(_slottedIdMapsGuid);
_nextCacheIndex = 0;
NextFirstIndex = 0;
@ -782,175 +766,22 @@ public static partial class AcBinarySerializer
#endregion
#region Slot-based Scan Pass Ref Tracking (SGen)
/// <summary>
/// SGen scan pass: tracks an object by Int32 Id (IId or RuntimeHelpers.GetHashCode for non-IId).
/// Increments ScanVisitIndex, manages IdentityMap via slot, and builds WriteDuplicateEntry on duplicates.
/// Returns true if first occurrence (caller should scan children), false if duplicate (skip children).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ScanTrackObjectInt32(int slot, int id)
{
var visitIndex = ScanVisitIndex++;
if (id == 0) return true; // default Id — no tracking
ref var maps = ref _slottedIdMapsInt32;
if (maps == null || maps.Length <= slot)
GrowSlottedMaps(ref maps, slot);
var map = maps[slot] ??= new IdentityMap<int, InternEntry>();
if (!map.TryAdd(id, out var si))
{
ref var entry = ref map.GetValueRef(si);
if (entry.CacheIndex == -1)
{
entry.CacheIndex = ++_nextCacheIndex;
entry.IsFirstWrite = true;
AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value: null);
}
AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null);
return false;
}
ref var ne = ref map.GetValueRef(si);
ne.FirstIndex = visitIndex;
ne.CacheIndex = -1;
return true;
}
/// <summary>
/// SGen scan pass: tracks an object by Int64 Id.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ScanTrackObjectInt64(int slot, long id)
{
var visitIndex = ScanVisitIndex++;
if (id == 0) return true;
ref var maps = ref _slottedIdMapsInt64;
if (maps == null || maps.Length <= slot)
GrowSlottedMaps(ref maps, slot);
var map = maps[slot] ??= new IdentityMap<long, InternEntry>();
if (!map.TryAdd(id, out var si))
{
ref var entry = ref map.GetValueRef(si);
if (entry.CacheIndex == -1)
{
entry.CacheIndex = ++_nextCacheIndex;
entry.IsFirstWrite = true;
AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value: null);
}
AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null);
return false;
}
ref var ne = ref map.GetValueRef(si);
ne.FirstIndex = visitIndex;
ne.CacheIndex = -1;
return true;
}
/// <summary>
/// SGen scan pass: tracks an object by Guid Id.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ScanTrackObjectGuid(int slot, Guid id)
{
var visitIndex = ScanVisitIndex++;
if (id == Guid.Empty) return true;
ref var maps = ref _slottedIdMapsGuid;
if (maps == null || maps.Length <= slot)
GrowSlottedMaps(ref maps, slot);
var map = maps[slot] ??= new IdentityMap<Guid, InternEntry>();
if (!map.TryAdd(id, out var si))
{
ref var entry = ref map.GetValueRef(si);
if (entry.CacheIndex == -1)
{
entry.CacheIndex = ++_nextCacheIndex;
entry.IsFirstWrite = true;
AddWriteDuplicateEntry(entry.FirstIndex, entry.CacheIndex, isFirst: true, value: null);
}
AddWriteDuplicateEntry(visitIndex, entry.CacheIndex, isFirst: false, value: null);
return false;
}
ref var ne = ref map.GetValueRef(si);
ne.FirstIndex = visitIndex;
ne.CacheIndex = -1;
return true;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void GrowSlottedMaps<TMap>(ref TMap?[]? maps, int slot) where TMap : class
{
var newSize = Math.Max(slot + 1, 16);
if (maps == null)
{
maps = new TMap?[newSize];
return;
}
var newMaps = new TMap?[newSize];
maps.AsSpan().CopyTo(newMaps);
maps = newMaps;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ResetSlottedMaps<TKey, TValue>(IdentityMap<TKey, TValue>?[]? maps)
where TKey : notnull where TValue : struct
{
if (maps == null) return;
for (var i = 0; i < maps.Length; i++)
maps[i]?.Reset();
}
#endregion
#region UseMetadata Type Tracking
/// <summary>
/// Regisztrálja a típust UseMetadata módban.
/// Visszaadja true-t ha ez az első előfordulás (inline hash-eket kell írni),
/// false-t ha ismételt (csak propNameHash kell).
/// Registers a type for UseMetadata first/repeated tracking.
/// Returns true on first occurrence (caller should write inline property hashes),
/// false on repeated (caller writes only typeNameHash).
/// Used by both runtime and SGen paths via wrapper.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool RegisterMetadataType(TypeMetadataWrapper<BinarySerializeTypeMetadata> wrapper)
{
if (wrapper.MetadataFooterIndex >= 0)
return false; // ismételt
if (wrapper.MetadataSeen)
return false;
wrapper.MetadataFooterIndex = 0; // jelöljük hogy már regisztrálva
return true; // első előfordulás
}
/// <summary>
/// SGen inline metadata regisztráció — slot-alapú, nem kell TypeMetadataWrapper.
/// Minden SGen típus kap egy compile-time slot indexet (AllocateMetadataSlot).
/// Első 256 slot: bit művelet (ulong[4]), felette: HashSet fallback.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool RegisterMetadataTypeBySlot(int slot)
{
if (slot < 256)
{
ref var bits = ref _metadataSeenBits[slot >> 6];
var mask = 1UL << (slot & 63);
if ((bits & mask) != 0) return false;
bits |= mask;
return true;
}
_metadataSeenOverflow ??= new HashSet<int>();
return _metadataSeenOverflow.Add(slot);
wrapper.MetadataSeen = true;
return true;
}
/// <summary>

View File

@ -251,29 +251,16 @@ public static partial class AcBinarySerializer
GeneratedWriterRegistry.Register(type, writer);
}
#region UseMetadata Slot Allocation
#region SGen Slot Allocation
private static int s_nextMetadataSlot;
private static int s_nextWrapperSlot;
/// <summary>
/// Allocates a unique slot index for SGen UseMetadata first/repeated tracking.
/// Called once per SGen type at startup (ModuleInitializer). Thread-safe.
/// Slot is used by BinarySerializationContext.RegisterMetadataTypeBySlot.
/// Allocates a unique slot index for SGen wrapper cache.
/// Indexes _wrapperSlots array on AcSerializerContextBase.
/// Used for: IdentityMap ref tracking (scan pass), MetadataSeen (write pass).
/// </summary>
internal static int AllocateMetadataSlot() => Interlocked.Increment(ref s_nextMetadataSlot) - 1;
#endregion
#region Scan Pass Tracking Slot Allocation
private static int s_nextTrackingSlot;
/// <summary>
/// Allocates a unique slot index for SGen scan pass ref tracking IdentityMaps.
/// Called once per SGen type at startup (ModuleInitializer). Thread-safe.
/// Slot indexes the per-context IdentityMap arrays in BinarySerializationContext.
/// </summary>
internal static int AllocateTrackingSlot() => Interlocked.Increment(ref s_nextTrackingSlot) - 1;
internal static int AllocateWrapperSlot() => Interlocked.Increment(ref s_nextWrapperSlot) - 1;
#endregion
@ -553,6 +540,25 @@ public static partial class AcBinarySerializer
WriteObject(value, wrapper, context, depth, isNested: depth > 0);
}
/// <summary>
/// Bridge for generated writers: writes a non-null complex OBJECT using slot-based wrapper lookup.
/// SGen types pass their compile-time known slot index — avoids GetWrapper dictionary lookup.
/// First call per slot per context: populates slot from GetWrapper. Subsequent calls: direct array index.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void WriteObjectGenerated<TOutput>(object value, Type type, int wrapperSlot, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase
{
if (depth > context.MaxDepth)
{
context.WriteByte(BinaryTypeCode.Null);
return;
}
var wrapper = context.GetWrapperBySlot(wrapperSlot, type);
WriteObject(value, wrapper, context, depth, isNested: depth > 0);
}
#endregion
#region Value Writing

View File

@ -82,7 +82,7 @@ public sealed class AcBinarySerializerOptions : AcSerializerOptions
/// allowing the deserializer to match properties by name between different types.
/// Default: false (no overhead)
/// </summary>
public bool UseMetadata { get; set; } = true;
public bool UseMetadata { get; set; } = false;
public bool UseGeneratedCode { get; set; } = true;

View File

@ -37,11 +37,11 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
internal readonly Func<object, Guid>? RefIdGetterGuid;
/// <summary>
/// UseMetadata: footer entry index for this type in the current serialization session.
/// -1 = not yet registered. Set by RegisterMetadataType, reset by ResetTracking.
/// Eliminates the need for Dictionary&lt;Type, int&gt; lookup in the serializer hot path.
/// UseMetadata: has this type been seen in the current serialization session?
/// false = not yet registered. Set by RegisterMetadataType, reset by ResetTracking.
/// Used by both runtime and SGen paths (single wrapper per type per context).
/// </summary>
internal int MetadataFooterIndex = -1;
internal bool MetadataSeen;
/// <summary>
/// UseMetadata cachemap: source property index → target PropertySetter.
@ -190,7 +190,7 @@ public sealed class TypeMetadataWrapper<TMetadata> where TMetadata : TypeMetadat
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ResetTracking(bool preRentBuckets = false)
{
MetadataFooterIndex = -1;
MetadataSeen = false;
CacheMap = null;
// Options may change between sessions (pool reuse) → rebuild on next scan