Source-generated scan pass for SGen binary serialization

Implements a fully source-generated scan pass for duplicate/reference tracking in SGen binary serialization. The generator now emits ScanObject and ScanForDuplicates methods for each writer, handling null/depth checks, slot-based ref tracking (by Id or object hash), and recursive property scanning (strings, complex types, collections). String interning and reference tracking are feature-flagged via attributes. The runtime scan path now delegates to generated code when available, eliminating reflection and delegate overhead. Adds slot-based IdentityMap arrays to the serialization context for efficient duplicate detection. Also updates metadata, attributes, and test stubs to support these features.
This commit is contained in:
Loretta 2026-02-19 16:40:16 +01:00
parent deffb77de4
commit 77ea512c1f
10 changed files with 594 additions and 30 deletions

View File

@ -80,6 +80,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
bool hasGenWriter = false;
bool propTypeIsIId = false;
string? writerClassName = null;
string? propIdTypeName = null;
int childTypeNameHash = 0;
int[]? childPropertyHashes = null;
if (kind == PropertyTypeKind.Complex)
@ -95,9 +96,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
if (hasGenWriter)
{
propTypeIsIId = resolvedType.AllInterfaces.Any(i =>
var iidIface = resolvedType.AllInterfaces.FirstOrDefault(i =>
i.IsGenericType &&
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
propTypeIsIId = iidIface != null;
if (iidIface != null)
propIdTypeName = iidIface.TypeArguments[0].ToDisplayString();
// Writer class: {Namespace}.{FlatName}_GeneratedWriter
var flatName = BuildFlatName((INamedTypeSymbol)resolvedType);
var ns = resolvedType.ContainingNamespace.IsGlobalNamespace
@ -118,6 +123,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
bool elemHasGenWriter = false;
bool elemIsIId = false;
string? elemWriterClassName = null;
string? elemIdTypeName = null;
string? collKind = null;
string? elemFullTypeName = null;
int elementTypeNameHash = 0;
@ -160,9 +166,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
a.AttributeClass?.ToDisplayString() == AttributeName);
if (elemHasGenWriter)
{
elemIsIId = resolvedElem.AllInterfaces.Any(ifc =>
var elemIidIface = resolvedElem.AllInterfaces.FirstOrDefault(ifc =>
ifc.IsGenericType &&
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
elemIsIId = elemIidIface != null;
if (elemIidIface != null)
elemIdTypeName = elemIidIface.TypeArguments[0].ToDisplayString();
var elemFlatName = BuildFlatName((INamedTypeSymbol)resolvedElem);
var ens = resolvedElem.ContainingNamespace.IsGlobalNamespace
? string.Empty : resolvedElem.ContainingNamespace.ToDisplayString();
@ -184,17 +194,24 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
typeNameForTypeof,
kind,
p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type),
stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName,
elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, collKind, elemFullTypeName,
stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName, propIdTypeName,
elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, elemIdTypeName, collKind, elemFullTypeName,
childTypeNameHash, childPropertyHashes,
elementTypeNameHash, elementPropertyHashes));
}
}
// IId<T>: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering
var isIId = typeSymbol.AllInterfaces.Any(i =>
var isIId = false;
string? idTypeName = null;
var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i =>
i.IsGenericType &&
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
if (iidInterface != null)
{
isIId = true;
idTypeName = iidInterface.TypeArguments[0].ToDisplayString();
}
if (isIId)
properties.Sort((a, b) =>
@ -210,7 +227,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var className = BuildFlatName(typeSymbol);
var typeNameHash = ComputeFnvHash(typeSymbol.Name);
var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray();
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, typeNameHash, propertyNameHashes);
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, typeNameHash, propertyNameHashes);
}
private static void Execute(ImmutableArray<SerializableClassInfo?> classes, SourceProductionContext context)
@ -227,14 +244,13 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
private static string GenWriter(SerializableClassInfo ci)
{
var sb = new StringBuilder(2048);
var sb = new StringBuilder(4096);
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.Runtime.CompilerServices;");
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
// ReferenceHandlingMode is needed when any Complex/Collection property has direct object write
if (ci.Properties.Any(p => p.HasGeneratedWriter || p.ElementHasGeneratedWriter))
sb.AppendLine("using AyCode.Core.Serializers;");
// ReferenceHandlingMode is needed for ScanObject self ref tracking and direct object write/scan
sb.AppendLine("using AyCode.Core.Serializers;");
sb.AppendLine();
if (!string.IsNullOrEmpty(ci.Namespace))
sb.AppendLine($"namespace {ci.Namespace};");
@ -243,6 +259,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine("{");
sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedWriter Instance = new();");
sb.AppendLine($" internal static readonly int s_metadataSlot = AcBinarySerializer.AllocateMetadataSlot();");
sb.AppendLine($" internal static readonly int s_trackingSlot = AcBinarySerializer.AllocateTrackingSlot();");
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));
@ -259,10 +276,108 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
}
sb.AppendLine(" }");
sb.AppendLine();
// ScanObject — full scan pass (null/depth + self ref tracking + property scan)
GenScanProperties(sb, ci);
sb.AppendLine();
// ScanForDuplicates — instance method on IGeneratedBinaryWriter, called from Serialize
sb.AppendLine(" public void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context)");
sb.AppendLine(" where TOutput : struct, IBinaryOutputBase");
sb.AppendLine(" {");
sb.AppendLine(" if (!context.HasCaching) return;");
sb.AppendLine(" ScanObject(value, context, 0);");
sb.AppendLine(" context.SortWritePlan();");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Generates the ScanObject method — full scan pass entry point for this type.
/// Includes: null/depth check, self ref tracking (IId or All mode), property scan.
/// Only emits code for reference properties (strings + complex types) — primitives are skipped.
/// </summary>
private static void GenScanProperties(StringBuilder sb, SerializableClassInfo ci)
{
sb.AppendLine(" public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth) where TOutput : struct, IBinaryOutputBase");
sb.AppendLine(" {");
// Null/depth guard — matches runtime ScanValue entry
sb.AppendLine(" if (value == null || depth > context.MaxDepth) return;");
sb.AppendLine();
sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);");
// Self ref tracking — matches runtime ScanValue UseTypeReferenceHandling block
if (ci.IsIId)
{
// IId type: track when ReferenceHandling != None
var trackMethod = ci.IdTypeName switch
{
"int" => "ScanTrackObjectInt32",
"long" => "ScanTrackObjectInt64",
"System.Guid" => "ScanTrackObjectGuid",
_ => "ScanTrackObjectInt32"
};
sb.AppendLine();
sb.AppendLine(" if (context.ReferenceHandling != ReferenceHandlingMode.None)");
sb.AppendLine(" {");
sb.AppendLine($" if (!context.{trackMethod}(s_trackingSlot, obj.Id))");
sb.AppendLine(" return;");
sb.AppendLine(" }");
}
else
{
// Non-IId type: track when ReferenceHandling == All
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(" return;");
sb.AppendLine(" }");
}
// Collect scannable properties
var scanProps = ci.Properties.Where(p =>
p.TypeKind == PropertyTypeKind.String ||
p.TypeKind == PropertyTypeKind.Complex ||
p.TypeKind == PropertyTypeKind.Collection).ToList();
// Hoist UseStringInterning + IsValidForInterningString checks if any string scanning needed
var hasStringScan = scanProps.Any(p =>
(p.TypeKind == PropertyTypeKind.String && p.InterningFlags != 0) ||
(p.TypeKind == PropertyTypeKind.Collection && p.ElementKind == PropertyTypeKind.String && p.InterningFlags != 0));
if (hasStringScan)
{
// Hoist the shift once — per-property InterningFlags check uses internBit directly.
// Cannot combine flags (OR) because different properties may have different flags
// and Attribute mode must NOT scan All-only properties.
sb.AppendLine();
sb.AppendLine(" var internBit = 1 << (int)context.Options.UseStringInterning;");
sb.AppendLine(" int minIntern = 0, maxIntern = 0;");
sb.AppendLine(" if (internBit > 1) { minIntern = context.MinStringInternLength; maxIntern = context.MaxStringInternLength; }");
}
var hasAnyScanProp = false;
foreach (var p in scanProps)
{
sb.AppendLine();
hasAnyScanProp = true;
EmitScanProp(sb, p, " ", ci.FullTypeName);
}
if (!hasAnyScanProp)
{
sb.AppendLine(" // No reference properties to scan");
}
sb.AppendLine(" }");
}
private static void EmitProp(StringBuilder sb, PropInfo p, string i, string fullTypeName)
{
var a = $"obj.{p.Name}";
@ -396,6 +511,200 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
/// <summary>
/// Emits direct object write — bypasses GetWrapper + WriteObject entirely.
#region Scan Pass Code Generation
/// <summary>
/// Emits scan pass code for a single property.
/// String: interning check + ScanInternString.
/// Complex (SGen): ref tracking via slot IdentityMap + recursive ScanProperties.
/// Complex (no SGen): fallback to ScanValueGenerated (runtime wrapper lookup).
/// Collection: iterate elements with same patterns.
/// </summary>
private static void EmitScanProp(StringBuilder sb, PropInfo p, string i, string fullTypeName)
{
var a = $"obj.{p.Name}";
// PropertyFilter: must match write pass — if filter skips property, scan must skip too
// Only for non-markerless properties (matching EmitProp behavior)
if (!IsMarkerless(p.TypeKind))
{
sb.AppendLine($"{i}if (context.HasPropertyFilter)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var fc_{p.Name} = new BinaryPropertyFilterContext(obj, typeof({fullTypeName}), \"{p.Name}\", typeof({p.TypeNameForTypeof}), static o => (({fullTypeName})o).{p.Name});");
sb.AppendLine($"{i} if (!context.PropertyFilter!(in fc_{p.Name}))");
sb.AppendLine($"{i} goto scanskip_{p.Name};");
sb.AppendLine($"{i}}}");
}
switch (p.TypeKind)
{
case PropertyTypeKind.String:
EmitScanString(sb, p, a, i);
break;
case PropertyTypeKind.Complex:
if (p.HasGeneratedWriter)
EmitScanComplexSGen(sb, p, a, i);
else
EmitScanComplexRuntime(sb, p, a, i);
break;
case PropertyTypeKind.Collection:
EmitScanCollection(sb, p, a, i);
break;
}
if (!IsMarkerless(p.TypeKind))
sb.AppendLine($"{i}scanskip_{p.Name}:;");
}
/// <summary>
/// Emits scan pass code for a string property: interning flags check + ScanInternString.
/// </summary>
private static void EmitScanString(StringBuilder sb, PropInfo p, string a, string i)
{
if (p.InterningFlags == 0)
{
// Never interned (explicit [AcStringIntern(false)] or no flags) — skip entirely
return;
}
// Per-property InterningFlags check with hoisted internBit (context.Options read once)
sb.AppendLine($"{i}if (({p.InterningFlags} & internBit) != 0)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var str_{p.Name} = {a};");
sb.AppendLine($"{i} if (str_{p.Name} != null)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var slen_{p.Name} = str_{p.Name}.Length;");
sb.AppendLine($"{i} if (slen_{p.Name} >= minIntern && (maxIntern == 0 || slen_{p.Name} <= maxIntern))");
sb.AppendLine($"{i} context.ScanInternString(str_{p.Name});");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i}}}");
}
/// <summary>
/// Emits scan pass code for a Complex property with SGen writer.
/// No parent-side ref tracking — child ScanObject handles its own (ScanTrackObjectXxx).
/// </summary>
private static void EmitScanComplexSGen(StringBuilder sb, PropInfo p, string a, string i)
{
var writer = p.WriterClassName;
var childVar = $"sc_{p.Name}";
// Null check only — ScanObject handles depth + ref tracking internally
sb.AppendLine($"{i}var {childVar} = {a};");
sb.AppendLine($"{i}if ({childVar} != null)");
sb.AppendLine($"{i} {writer}.Instance.ScanObject({childVar}, context, depth + 1);");
}
/// <summary>
/// Emits scan pass code for a Complex property without SGen writer (runtime fallback).
/// </summary>
private static void EmitScanComplexRuntime(StringBuilder sb, PropInfo p, string a, string i)
{
var childVar = $"sc_{p.Name}";
sb.AppendLine($"{i}var {childVar} = {a};");
sb.AppendLine($"{i}if ({childVar} != null)");
sb.AppendLine($"{i} AcBinarySerializer.ScanValueGenerated({childVar}, typeof({p.TypeNameForTypeof}), context, depth + 1);");
}
/// <summary>
/// Emits scan pass code for a Collection property.
/// Handles string collections (interning) and complex element collections (SGen or runtime fallback).
/// </summary>
private static void EmitScanCollection(StringBuilder sb, PropInfo p, string a, string i)
{
// String element collection
if (p.ElementKind == PropertyTypeKind.String)
{
if (p.InterningFlags == 0) return; // never interned
sb.AppendLine($"{i}var scol_{p.Name} = {a};");
sb.AppendLine($"{i}if (scol_{p.Name} != null && ({p.InterningFlags} & internBit) != 0)");
sb.AppendLine($"{i}{{");
if (p.CollectionKind == "Array")
{
sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Length; si_{p.Name}++)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];");
}
else if (p.CollectionKind == "List")
{
sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Count; si_{p.Name}++)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];");
}
else
{
sb.AppendLine($"{i} foreach (var se_{p.Name} in scol_{p.Name})");
sb.AppendLine($"{i} {{");
}
sb.AppendLine($"{i} if (se_{p.Name} != null)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var slen_{p.Name} = se_{p.Name}.Length;");
sb.AppendLine($"{i} if (slen_{p.Name} >= minIntern && (maxIntern == 0 || slen_{p.Name} <= maxIntern))");
sb.AppendLine($"{i} context.ScanInternString(se_{p.Name});");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i}}}");
return;
}
// Complex element collection with SGen writer
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter && p.CollectionKind != null)
{
var writer = p.ElementWriterClassName;
sb.AppendLine($"{i}var scol_{p.Name} = {a};");
sb.AppendLine($"{i}if (scol_{p.Name} != null)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var snd_{p.Name} = depth + 1;");
if (p.CollectionKind == "Array")
{
sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Length; si_{p.Name}++)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];");
}
else if (p.CollectionKind == "List")
{
sb.AppendLine($"{i} for (var si_{p.Name} = 0; si_{p.Name} < scol_{p.Name}.Count; si_{p.Name}++)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var se_{p.Name} = scol_{p.Name}[si_{p.Name}];");
}
else // Counted (foreach)
{
sb.AppendLine($"{i} foreach (var se_{p.Name} in scol_{p.Name})");
sb.AppendLine($"{i} {{");
}
var e = $"se_{p.Name}";
// Null check only — ScanObject handles depth + ref tracking internally
sb.AppendLine($"{i} if ({e} == null) continue;");
sb.AppendLine($"{i} {writer}.Instance.ScanObject({e}, context, snd_{p.Name});");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i}}}");
return;
}
// Complex element collection without SGen writer — runtime fallback
if (p.ElementKind == PropertyTypeKind.Complex)
{
sb.AppendLine($"{i}var scol_{p.Name} = {a};");
sb.AppendLine($"{i}if (scol_{p.Name} != null)");
sb.AppendLine($"{i} AcBinarySerializer.ScanValueGenerated(scol_{p.Name}, typeof({p.TypeNameForTypeof}), context, depth);");
return;
}
// Primitive element collection — no scanning needed
}
#endregion
/// <summary>
/// Emits inline object write for a Complex property that has a generated writer.
/// Writes marker bytes + inline metadata (UseMetadata) + calls child GeneratedWriter.WriteProperties.
/// IId types: guard ReferenceHandling != None (tracked in OnlyId + All).
/// Non-IId types: guard ReferenceHandling == All (tracked only in All mode).
@ -888,12 +1197,16 @@ internal sealed class SerializableClassInfo
public string ClassName { get; }
public string FullTypeName { get; }
public List<PropInfo> Properties { get; }
/// <summary>True if this type implements IId&lt;T&gt;</summary>
public bool IsIId { get; }
/// <summary>The Id type name ("int", "long", "System.Guid") if IsIId, null otherwise</summary>
public string? IdTypeName { get; }
/// <summary>FNV-1a hash of ClassName (matches runtime SourceType.Name hash)</summary>
public int TypeNameHash { get; }
/// <summary>FNV-1a hash of each property name, in property order</summary>
public int[] PropertyNameHashes { get; }
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, int typeNameHash, int[] propertyNameHashes)
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; }
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, bool isIId, string? idTypeName, int typeNameHash, int[] propertyNameHashes)
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; }
}
internal sealed class PropInfo
@ -920,6 +1233,8 @@ internal sealed class PropInfo
public bool IsIId { get; }
/// <summary>Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter.</summary>
public string? WriterClassName { get; }
/// <summary>Id type name ("int", "long", "System.Guid") for IId child types. Null if not IId.</summary>
public string? IdTypeName { get; }
// Collection element metadata — set when TypeKind == Collection and element type is Complex with generated writer
/// <summary>Element type kind for collection properties. Only meaningful when TypeKind == Collection.</summary>
@ -930,6 +1245,8 @@ internal sealed class PropInfo
public bool ElementIsIId { get; }
/// <summary>Generated writer class name for collection element type.</summary>
public string? ElementWriterClassName { get; }
/// <summary>Id type name for collection element IId types. Null if not IId.</summary>
public string? ElementIdTypeName { get; }
/// <summary>Collection type: "List", "Array", or null (unknown — fallback to runtime).</summary>
public string? CollectionKind { get; }
/// <summary>Full element type name for generated code (e.g. "SharedTag").</summary>
@ -946,9 +1263,9 @@ internal sealed class PropInfo
public int[]? ElementPropertyHashes { get; }
public PropInfo(string n, string tn, string tnForTypeof, PropertyTypeKind tk, bool nullable,
bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null,
bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null, string? idTypeName = null,
PropertyTypeKind elementKind = PropertyTypeKind.Unknown, bool elementHasGenWriter = false, bool elementIsIId = false,
string? elementWriterClassName = null, string? collectionKind = null, string? elementFullTypeName = null,
string? elementWriterClassName = null, string? elementIdTypeName = null, string? collectionKind = null, string? elementFullTypeName = null,
int childTypeNameHash = 0, int[]? childPropertyHashes = null,
int elementTypeNameHash = 0, int[]? elementPropertyHashes = null)
{
@ -960,10 +1277,12 @@ internal sealed class PropInfo
HasGeneratedWriter = hasGeneratedWriter;
IsIId = isIId;
WriterClassName = writerClassName;
IdTypeName = idTypeName;
ElementKind = elementKind;
ElementHasGeneratedWriter = elementHasGenWriter;
ElementIsIId = elementIsIId;
ElementWriterClassName = elementWriterClassName;
ElementIdTypeName = elementIdTypeName;
CollectionKind = collectionKind;
ElementFullTypeName = elementFullTypeName;
ChildTypeNameHash = childTypeNameHash;

View File

@ -90,6 +90,18 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
context.WriteDecimalBits(obj.TotalAmount);
}
public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth) where TOutput : struct, IBinaryOutputBase
{
throw new NotImplementedException();
}
public void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
{
if (!context.HasCaching) return;
ScanObject(value, context, 0);
context.SortWritePlan();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase

View File

@ -19,7 +19,7 @@ public abstract class AcSerializerOptions
set => _referenceHandling = value;
}
private ReferenceHandlingMode _referenceHandling = ReferenceHandlingMode.All;
private ReferenceHandlingMode _referenceHandling = ReferenceHandlingMode.OnlyId;
private readonly byte _maxDepth = byte.MaxValue;
private readonly bool _throwOnCircularReference = true;

View File

@ -14,4 +14,28 @@ namespace AyCode.Core.Serializers.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
public sealed class AcBinarySerializableAttribute : Attribute
{
public bool EnableMetadataFeature { get; }
public bool EnableIdTrackingFeature { get; }
public bool EnableRefHandlingFeature { get; }
public bool EnableInternStringFeature { get; }
public AcBinarySerializableAttribute() : this(true)
{
}
public AcBinarySerializableAttribute(bool enableAllFeatures)
{
EnableMetadataFeature = enableAllFeatures;
EnableIdTrackingFeature = enableAllFeatures;
EnableRefHandlingFeature = enableAllFeatures;
EnableInternStringFeature = enableAllFeatures;
}
public AcBinarySerializableAttribute(bool enableMetadataFeature, bool enableIdTrackingFeature, bool enableRefHandlingFeature, bool enableInternStringFeature)
{
EnableMetadataFeature = enableMetadataFeature;
EnableIdTrackingFeature = enableIdTrackingFeature;
EnableRefHandlingFeature = enableRefHandlingFeature;
EnableInternStringFeature = enableInternStringFeature;
}
}

View File

@ -94,6 +94,14 @@ 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 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;
@ -281,6 +289,9 @@ public static partial class AcBinarySerializer
_stringInternMap?.Reset();
_metadataSeenBits.AsSpan().Clear();
_metadataSeenOverflow?.Clear();
ResetSlottedMaps(_slottedIdMapsInt32);
ResetSlottedMaps(_slottedIdMapsInt64);
ResetSlottedMaps(_slottedIdMapsGuid);
_nextCacheIndex = 0;
NextFirstIndex = 0;
ScanVisitIndex = 0;
@ -769,6 +780,140 @@ 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>

View File

@ -24,6 +24,8 @@ public static partial class AcBinarySerializer
/// </summary>
public Type? GeneratedSerializerType { get; }
public bool EnableInternString { get; }
/// <summary>
/// Lazy-computed FNV-1a hash of the type name (SourceType.Name).
/// Only computed once per type, on first access when UseMetadata=true.
@ -78,7 +80,7 @@ public static partial class AcBinarySerializer
for (var i = 0; i < Properties.Length; i++)
{
var prop = Properties[i];
if (prop.IsComplexType || prop.AccessorType == PropertyAccessorType.String)
if (prop.IsComplexType || (EnableInternString && prop.AccessorType == PropertyAccessorType.String))
list.Add(prop);
}
@ -120,12 +122,16 @@ public static partial class AcBinarySerializer
// Use pre-computed WritableProperties directly - no method call overhead!
var orderedProperties = WritableProperties;
// Read [AcBinarySerializable] once per type — passed to property accessors
var serializableAttr = type.GetCustomAttribute<AcBinarySerializableAttribute>(inherit: false);
EnableInternString = serializableAttr == null || serializableAttr.EnableInternStringFeature;
Properties = new BinaryPropertyAccessor[orderedProperties.Length];
var complexCount = 0;
for (var i = 0; i < orderedProperties.Length; i++)
{
var accessor = new BinaryPropertyAccessor(orderedProperties[i], type);
var accessor = new BinaryPropertyAccessor(orderedProperties[i], type, EnableInternString);
accessor.PropertyIndex = i;
Properties[i] = accessor;
@ -176,8 +182,8 @@ public static partial class AcBinarySerializer
/// </summary>
internal sealed class BinaryPropertyAccessor : BinaryPropertyAccessorBase
{
public BinaryPropertyAccessor(PropertyInfo prop, Type declaringType)
: base(prop, declaringType)
public BinaryPropertyAccessor(PropertyInfo prop, Type declaringType, bool enableInternString)
: base(prop, declaringType, enableInternString)
{
}
}

View File

@ -14,6 +14,9 @@ public static partial class AcBinarySerializer
/// - Consistent with write pass which writes ObjectRef (no children) for 2nd occurrence
/// - Strings/objects skipped here are never written anyway (parent is ObjectRef)
/// CacheIndex is assigned immediately on 2nd occurrence (no post-processing needed).
/// Uses wrapper.GeneratedWriter when available — the wrapper is already cached,
/// so no dictionary lookup overhead. SGen types call generated ScanForDuplicates
/// which bypasses the entire runtime scan path.
/// </summary>
private static void ScanForDuplicates<TOutput>(object value, Type type, BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
@ -22,8 +25,18 @@ public static partial class AcBinarySerializer
return;
var wrapper = context.GetWrapper(type);
ScanValue(value, wrapper, context, 0);
// SGen path: wrapper.GeneratedWriter is cached (no registry lookup per call).
// Generated ScanForDuplicates handles HasCaching + ScanObject + SortWritePlan.
var genWriter = wrapper.GeneratedWriter;
if (genWriter != null && context.Options.UseGeneratedCode)
{
genWriter.ScanObject(value, context, 0);
context.SortWritePlan();
return;
}
ScanValue(value, wrapper, context, 0);
context.SortWritePlan();
}
@ -86,6 +99,15 @@ public static partial class AcBinarySerializer
// Object → ref tracking + recursive scan
// SGen path: ScanObject handles its own ref tracking (ScanTrackObjectXxx)
// Must be checked BEFORE runtime ref tracking to avoid double ScanVisitIndex++
var genWriter = wrapper.GeneratedWriter;
if (genWriter != null && context.Options.UseGeneratedCode)
{
genWriter.ScanObject(value, context, depth);
return;
}
// Reference tracking for IId types (or all types when ReferenceHandling == All)
// 2nd occurrence → skip children because:
// 1. Write pass writes ObjectRef (no children) → strings/objects here are never in output
@ -135,8 +157,7 @@ public static partial class AcBinarySerializer
}
}
// Recursive scan on reference properties only
// Use typed getter for strings (much faster than reflection GetValue)
// Fallback: runtime property loop (no SGen writer for this type)
var refProperties = metadata.ReferenceProperties;
var hasPropertyFilter = context.HasPropertyFilter;
var nextDepth2 = depth + 1;
@ -144,7 +165,6 @@ public static partial class AcBinarySerializer
for (var i = 0; i < refProperties.Length; i++)
{
var prop = refProperties[i];
//context.CurrentProperty = prop;
// Must match write pass: filtered properties write PropertySkip (no value) →
// scanning them would assign CacheIndex for strings/objects never in output
@ -155,14 +175,12 @@ public static partial class AcBinarySerializer
{
if (!prop.UseStringPropertyInterning(context.Options.UseStringInterning)) continue;
// Fast path: typed getter for string
var str2 = prop.GetString(value);
if (str2 != null && context.IsValidForInterningString(str2.Length))
context.ScanInternString(str2);
}
else if (prop.IsStringCollectionProperty)
{
// String collection: per-property interning control, no GetWrapper needed
if (!prop.UseStringPropertyInterning(context.Options.UseStringInterning)) continue;
var propValue = prop.GetValue(value);
@ -171,7 +189,6 @@ public static partial class AcBinarySerializer
}
else
{
// Object property: use pre-cached wrapper, fallback to GetWrapper on miss/polymorphism
var propValue = prop.GetValue(value);
if (propValue != null)
{
@ -188,6 +205,17 @@ public static partial class AcBinarySerializer
}
}
/// <summary>
/// Public entry point for SGen-generated ScanProperties to call back into runtime ScanValue
/// for child types that don't have a generated writer (fallback to runtime path with wrapper lookup).
/// </summary>
internal static void ScanValueGenerated<TOutput>(object value, Type type, BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase
{
var wrapper = context.GetWrapper(type);
ScanValue(value, wrapper, context, depth);
}
/// <summary>
/// Scans string elements of a collection for interning. Uses IList fast path when available.
/// </summary>

View File

@ -264,6 +264,19 @@ public static partial class AcBinarySerializer
#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;
#endregion
/// <summary>
/// Thread-safe registry for generated writers. Looked up once per TypeMetadataWrapper creation.
/// </summary>

View File

@ -1,5 +1,6 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Attributes;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Binaries;
@ -53,13 +54,13 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
/// </summary>
public byte? ExpectedTypeCode { get; }
protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType)
protected BinaryPropertyAccessorBase(PropertyInfo prop, Type declaringType, bool enableInternString)
: base(prop, declaringType)
{
IsStringCollectionProperty = IsStringCollection(prop.PropertyType);
// All typed getters are initialized in PropertyAccessorBase
if (AccessorType == PropertyAccessorType.String || IsStringCollectionProperty)
if (enableInternString && (AccessorType == PropertyAccessorType.String || IsStringCollectionProperty))
{
// Cache [AcStringIntern] attribute (inherit: true to check base class properties)
var internAttr = prop.GetCustomAttribute<AcStringInternAttribute>(inherit: true);
@ -67,7 +68,7 @@ public abstract class BinaryPropertyAccessorBase : PropertyAccessorBase
var stringInternAttributeValue = internAttr?.Enabled;
byte flags = 0;
if (stringInternAttributeValue == true) flags |= (1 << (int)StringInterningMode.Attribute);
if (stringInternAttributeValue == true) flags |= (1 << (int)StringInterningMode.Attribute);
if (stringInternAttributeValue != false) flags |= (1 << (int)StringInterningMode.All);
_interningFlags = flags;
}

View File

@ -26,4 +26,20 @@ internal interface IGeneratedBinaryWriter
/// <typeparam name="TOutput">Output strategy (ArrayBinaryOutput or BufferWriterBinaryOutput).</typeparam>
void WriteProperties<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase;
/// <summary>
/// Full scan pass for this type: null/depth check, ref tracking (slot-based IdentityMap),
/// and recursive property scan (strings + complex types).
/// Replaces the entire runtime ScanValue for SGen types — no GetWrapper, no delegate invoke.
/// Called from ScanForDuplicates or from parent SGen ScanObject (child).
/// </summary>
void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
where TOutput : struct, IBinaryOutputBase;
/// <summary>
/// SGen scan pass entry point. Called from Serialize instead of runtime ScanForDuplicates.
/// HasCaching check + ScanObject + SortWritePlan — eliminates GetWrapper for the root type.
/// </summary>
void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase;
}