From 11e71336b0997f33c69177c02ced502cf10fd92a Mon Sep 17 00:00:00 2001 From: Loretta Date: Mon, 16 Feb 2026 15:06:01 +0100 Subject: [PATCH] Direct object write mode for binary serialization generator Introduces direct object write mode in source-generated binary serialization, allowing generated writers to bypass the runtime WriteObject pipeline for eligible complex properties. Adds detection of generated writer presence and IId implementation, inlines reference tracking logic, and replaces .GetType() with typeof() for type safety. String interning attribute detection and precomputed interning flags ensure safe cursor alignment. Expands PropInfo structure and enables fast path for all generated writers. Improves serialization performance, correctness, and reduces runtime overhead. Adds new web fetch domains to settings. --- .claude/settings.local.json | 5 +- .../AcBinarySourceGenerator.cs | 221 ++++++++++++++++-- ...rySerializer.BinarySerializationContext.cs | 7 + .../Binaries/AcBinarySerializer.cs | 5 +- 4 files changed, 218 insertions(+), 20 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f906261..350631e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,10 @@ "Bash(dir:*)", "Bash(git stash:*)", "WebFetch(domain:github.com)", - "Bash(del \"H:\\\\Applications\\\\Aycode\\\\Source\\\\AyCode.Core\\\\AyCode.Core\\\\Serializers\\\\Binaries\\\\IBinaryOutput.cs\")" + "Bash(del \"H:\\\\Applications\\\\Aycode\\\\Source\\\\AyCode.Core\\\\AyCode.Core\\\\Serializers\\\\Binaries\\\\IBinaryOutput.cs\")", + "Bash(sort:*)", + "WebFetch(domain:neuecc.medium.com)", + "WebFetch(domain:raw.githubusercontent.com)" ] } } diff --git a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs index 38ea653..b89c018 100644 --- a/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs +++ b/AyCode.Core.Serializers.SourceGenerator/AcBinarySourceGenerator.cs @@ -59,11 +59,67 @@ public class AcBinarySourceGenerator : IIncrementalGenerator }); if (hasIgnore) continue; + // String interning attribútum detektálás (null = no attr, true/false = explicit) + bool? stringInternAttr = null; + if (GetKind(p.Type) == PropertyTypeKind.String) + { + var attr = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute"); + if (attr != null && attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Primitive) + { + stringInternAttr = (bool)attr.ConstructorArguments[0].Value!; + } + } + + // For typeof(): strip trailing '?' from nullable reference types (typeof(T?) is invalid for ref types) + // Nullable value types (int?, Guid?) keep '?' because typeof(int?) == typeof(Nullable) is valid + var typeDisplayName = p.Type.ToDisplayString(); + var typeNameForTypeof = (p.Type.NullableAnnotation == NullableAnnotation.Annotated && !p.Type.IsValueType) + ? typeDisplayName.TrimEnd('?') + : typeDisplayName; + + // Direct object write detection for Complex property types: + // Check if the property type has [AcBinarySerializable] (→ has generated writer) + // and if it implements IId (→ needs ref tracking in generated code) + var kind = GetKind(p.Type); + bool hasGenWriter = false; + bool propTypeIsIId = false; + string? writerClassName = null; + if (kind == PropertyTypeKind.Complex) + { + // Resolve to the actual type symbol (strip nullable annotation for ref types) + // For SharedTag? → SharedTag. OriginalDefinition handles generic types. + var resolvedType = p.Type is INamedTypeSymbol namedPropType + ? namedPropType.OriginalDefinition + : p.Type; + + hasGenWriter = resolvedType.GetAttributes().Any(a => + a.AttributeClass?.ToDisplayString() == AttributeName); + // Skip nested types (we don't generate writers for them) + if (hasGenWriter && resolvedType is INamedTypeSymbol nt && nt.ContainingType != null) + hasGenWriter = false; + + if (hasGenWriter) + { + propTypeIsIId = resolvedType.AllInterfaces.Any(i => + i.IsGenericType && + i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId"); + // Writer class: {Namespace}.{TypeName}_GeneratedWriter + var ns = resolvedType.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : resolvedType.ContainingNamespace.ToDisplayString(); + writerClassName = string.IsNullOrEmpty(ns) + ? $"{resolvedType.Name}_GeneratedWriter" + : $"{ns}.{resolvedType.Name}_GeneratedWriter"; + } + } + properties.Add(new PropInfo( p.Name, - p.Type.ToDisplayString(), - GetKind(p.Type), - p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type))); + typeDisplayName, + typeNameForTypeof, + kind, + p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type), + stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName)); } } @@ -105,6 +161,9 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine("#nullable enable"); sb.AppendLine("using System.Runtime.CompilerServices;"); sb.AppendLine("using AyCode.Core.Serializers.Binaries;"); + // ReferenceHandlingMode is needed when any Complex property has direct object write + if (ci.Properties.Any(p => p.HasGeneratedWriter)) + sb.AppendLine("using AyCode.Core.Serializers;"); sb.AppendLine(); if (!string.IsNullOrEmpty(ci.Namespace)) sb.AppendLine($"namespace {ci.Namespace};"); @@ -137,7 +196,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator { sb.AppendLine($"{i}if ({a}.HasValue)"); sb.AppendLine($"{i}{{"); - EmitVal(sb, Underlying(p.TypeKind), $"{a}.Value", i + " "); + EmitVal(sb, Underlying(p.TypeKind), $"{a}.Value", p.TypeNameForTypeof, i + " "); sb.AppendLine($"{i}}}"); sb.AppendLine($"{i}else context.WriteByte(BinaryTypeCode.PropertySkip);"); return; @@ -155,29 +214,37 @@ public class AcBinarySourceGenerator : IIncrementalGenerator switch (p.TypeKind) { case PropertyTypeKind.String: + if (p.InterningFlags == 0) + sb.AppendLine($"{i}context.StringInternEligible = false;"); + else + sb.AppendLine($"{i}context.StringInternEligible = ({p.InterningFlags} & (1 << (int)context.Options.UseStringInterning)) != 0;"); sb.AppendLine($"{i}AcBinarySerializer.WriteStringGenerated({a}, context);"); break; case PropertyTypeKind.Complex: - // Complex object: use WriteObjectGenerated (skips type dispatch in WriteValueNonPrimitive) - if (p.IsNullable) + // Complex object: direct write bypasses GetWrapper + WriteObject pipeline entirely + // when the property type has a generated writer. Falls back to WriteObjectGenerated otherwise. + if (p.HasGeneratedWriter) + EmitDirectObjectWrite(sb, p, a, i); + else if (p.IsNullable) { sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); - sb.AppendLine($"{i}else AcBinarySerializer.WriteObjectGenerated({a}, {a}.GetType(), context, depth);"); + sb.AppendLine($"{i}else AcBinarySerializer.WriteObjectGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); } else - sb.AppendLine($"{i}AcBinarySerializer.WriteObjectGenerated({a}, {a}.GetType(), context, depth);"); + sb.AppendLine($"{i}AcBinarySerializer.WriteObjectGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); break; case PropertyTypeKind.Collection: + // typeof() instead of GetType() — avoids virtual dispatch if (p.IsNullable) { sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); - sb.AppendLine($"{i}else AcBinarySerializer.WriteValueGenerated({a}, {a}.GetType(), context, depth);"); + sb.AppendLine($"{i}else AcBinarySerializer.WriteValueGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); } else - sb.AppendLine($"{i}AcBinarySerializer.WriteValueGenerated({a}, {a}.GetType(), context, depth);"); + sb.AppendLine($"{i}AcBinarySerializer.WriteValueGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); break; default: - EmitSkip(sb, p.TypeKind, a, i); + EmitSkip(sb, p.TypeKind, a, p.TypeNameForTypeof, i); break; } } @@ -224,7 +291,93 @@ public class AcBinarySourceGenerator : IIncrementalGenerator } } - private static void EmitSkip(StringBuilder sb, PropertyTypeKind k, string a, string i) + /// + /// Emits direct object write — bypasses GetWrapper + WriteObject entirely. + /// Writes marker bytes + calls child GeneratedWriter.WriteProperties inline. + /// For IId types: inlines TryConsumeWritePlanEntry for ref tracking cursor alignment. + /// Falls back to WriteObjectGenerated when context.IsDirectObjectWrite is false (UseMetadata/PropertyFilter). + /// + private static void EmitDirectObjectWrite(StringBuilder sb, PropInfo p, string a, string i) + { + var writer = p.WriterClassName; + var nextDepth = "depth + 1"; + + if (p.IsNullable) + { + sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);"); + sb.AppendLine($"{i}else if (context.IsDirectObjectWrite)"); + } + else + { + sb.AppendLine($"{i}if (context.IsDirectObjectWrite)"); + } + + sb.AppendLine($"{i}{{"); + + // MaxDepth check — matches WriteObjectGenerated + sb.AppendLine($"{i} if (depth > context.MaxDepth) context.WriteByte(BinaryTypeCode.Null);"); + sb.AppendLine($"{i} else"); + sb.AppendLine($"{i} {{"); + + if (p.IsIId) + { + // IId type: inline ref tracking from WriteObject + // context.UseTypeReferenceHandling gate: ReferenceHandling != None && IsIId + // For IId types, IsIId is always true, so: ReferenceHandling != None + sb.AppendLine($"{i} if (context.ReferenceHandling != ReferenceHandlingMode.None && context.TryConsumeWritePlanEntry(out var pe_{p.Name}))"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} if (!pe_{p.Name}.IsFirst)"); + sb.AppendLine($"{i} {{"); + // 2+ occurrence → ObjectRef + cacheIndex (no properties written) + sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.ObjectRef);"); + sb.AppendLine($"{i} context.WriteVarUInt((uint)pe_{p.Name}.CacheMapIndex);"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i} else"); + sb.AppendLine($"{i} {{"); + // First occurrence → ObjectRefFirst + cacheIndex + properties + sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.ObjectRefFirst);"); + sb.AppendLine($"{i} context.WriteVarUInt((uint)pe_{p.Name}.CacheMapIndex);"); + sb.AppendLine($"{i} {writer}.Instance.WriteProperties({a}, context, {nextDepth});"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i} else"); + sb.AppendLine($"{i} {{"); + // No ref tracking entry at this visit → plain Object marker + sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Object);"); + sb.AppendLine($"{i} {writer}.Instance.WriteProperties({a}, context, {nextDepth});"); + sb.AppendLine($"{i} }}"); + } + else + { + // Non-IId type: usually no ref tracking needed. + // Exception: ReferenceHandling == All tracks ALL objects → scan pass increments ScanVisitIndex, + // so write pass must also consume from WritePlan to keep cursor aligned. + // Guard: fall back to WriteObjectGenerated for All mode (rare). OnlyId mode is safe (non-IId skipped). + sb.AppendLine($"{i} if (context.ReferenceHandling == ReferenceHandlingMode.All)"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} AcBinarySerializer.WriteObjectGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i} else"); + sb.AppendLine($"{i} {{"); + sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.Object);"); + sb.AppendLine($"{i} {writer}.Instance.WriteProperties({a}, context, {nextDepth});"); + sb.AppendLine($"{i} }}"); + } + + sb.AppendLine($"{i} }}"); + sb.AppendLine($"{i}}}"); + + // Fallback for non-direct mode (UseMetadata=true or HasPropertyFilter=true) + if (p.IsNullable) + sb.AppendLine($"{i}else AcBinarySerializer.WriteObjectGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); + else + { + sb.AppendLine($"{i}else"); + sb.AppendLine($"{i} AcBinarySerializer.WriteObjectGenerated({a}, typeof({p.TypeNameForTypeof}), context, depth);"); + } + } + + private static void EmitSkip(StringBuilder sb, PropertyTypeKind k, string a, string typeName, string i) { switch (k) { @@ -293,12 +446,12 @@ public class AcBinarySourceGenerator : IIncrementalGenerator sb.AppendLine($"{i}context.WriteByte(BinaryTypeCode.DateTimeOffset); context.WriteDateTimeOffsetBits({a});"); break; default: - sb.AppendLine($"{i}AcBinarySerializer.WriteValueGenerated({a}, {a}.GetType(), context, depth);"); + sb.AppendLine($"{i}AcBinarySerializer.WriteValueGenerated({a}, typeof({typeName}), context, depth);"); break; } } - private static void EmitVal(StringBuilder sb, PropertyTypeKind k, string a, string i) + private static void EmitVal(StringBuilder sb, PropertyTypeKind k, string a, string typeName, string i) { switch (k) { @@ -310,7 +463,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator case PropertyTypeKind.Decimal: sb.AppendLine($"{i}context.WriteByte(BinaryTypeCode.Decimal); context.WriteDecimalBits({a});"); break; case PropertyTypeKind.DateTime: sb.AppendLine($"{i}context.WriteByte(BinaryTypeCode.DateTime); context.WriteDateTimeBits({a});"); break; case PropertyTypeKind.Guid: sb.AppendLine($"{i}context.WriteByte(BinaryTypeCode.Guid); context.WriteGuidBits({a});"); break; - default: EmitSkip(sb, k, a, i); break; + default: EmitSkip(sb, k, a, typeName, i); break; } } @@ -410,10 +563,44 @@ internal sealed class PropInfo { public string Name { get; } public string TypeName { get; } + /// + /// Type name safe for typeof() — nullable ref type annotation stripped (typeof(T?) invalid for ref types). + /// + public string TypeNameForTypeof { get; } public PropertyTypeKind TypeKind { get; } public bool IsNullable { get; } - public PropInfo(string n, string tn, PropertyTypeKind tk, bool nullable) - { Name = n; TypeName = tn; TypeKind = tk; IsNullable = nullable; } + /// + /// Pre-computed interning flags matching runtime BinaryPropertyAccessorBase._interningFlags. + /// Bit layout: bit N = eligible when StringInterningMode == N. + /// None=0 → bit 0 never set. Attribute=1 → bit 1. All=2 → bit 2. + /// No attr: 0b100 (4), [AcStringIntern(true)]: 0b110 (6), [AcStringIntern(false)]: 0b000 (0). + /// + public int InterningFlags { get; } + + /// True if the Complex property type has [AcBinarySerializable] → has a generated writer. + public bool HasGeneratedWriter { get; } + /// True if the Complex property type implements IId<T> → needs ref tracking in write pass. + public bool IsIId { get; } + /// Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter. + public string? WriterClassName { 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) + { + Name = n; + TypeName = tn; + TypeNameForTypeof = tnForTypeof; + TypeKind = tk; + IsNullable = nullable; + HasGeneratedWriter = hasGeneratedWriter; + IsIId = isIId; + WriterClassName = writerClassName; + // Mirror runtime _interningFlags computation from BinaryPropertyAccessorBase + int flags = 0; + if (stringInternAttr == true) flags |= (1 << 1); // Attribute bit + if (stringInternAttr != false) flags |= (1 << 2); // All bit + InterningFlags = flags; + } } internal enum PropertyTypeKind diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs index 5cd4e8e..1cb9a79 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinarySerializationContext.cs @@ -220,6 +220,13 @@ public static partial class AcBinarySerializer public bool HasCaching => UseStringInterning || ReferenceHandling != ReferenceHandlingMode.None; public bool UseMetadata => Options.UseMetadata; public bool UseGeneratedCode => Options.UseGeneratedCode; + + /// + /// True when generated writers can bypass WriteObject entirely and write markers + properties inline. + /// Requires: no UseMetadata (no inline metadata tracking), no PropertyFilter (no per-prop filtering). + /// Reference handling is safe because generated code inlines TryConsumeWritePlanEntry for IId types. + /// + public bool IsDirectObjectWrite => !UseMetadata && !HasPropertyFilter; public byte MinStringInternLength => Options.MinStringInternLength; public byte MaxStringInternLength => Options.MaxStringInternLength; public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter; diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index 7a20084..0d0a37f 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -1069,11 +1069,12 @@ public static partial class AcBinarySerializer // Source-generated fast path: bypass the entire switch/delegate loop. // Reference handling is safe: ref tracking happens in WriteObject (before WriteProperties) // and child objects go through WriteValueGenerated → WriteObject → runtime ref tracking. - // String interning is NOT safe: generated code doesn't set StringInternEligible → cursor mismatch. + // String interning is safe: generated code uses pre-computed interningFlags bit-check + // matching runtime UseStringPropertyInterning — cursor alignment guaranteed for all modes. if (context.UseGeneratedCode) { var generatedWriter = wrapper.GeneratedWriter; - if (generatedWriter != null && !hasPropertyFilter && !context.UseMetadata && !context.UseStringInterning) + if (generatedWriter != null && !hasPropertyFilter && !context.UseMetadata) { generatedWriter.WriteProperties(value, context, nextDepth); return;