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<T> 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.
This commit is contained in:
Loretta 2026-02-16 15:06:01 +01:00
parent 03f5809e8a
commit 11e71336b0
4 changed files with 218 additions and 20 deletions

View File

@ -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)"
]
}
}

View File

@ -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<int>) 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<T> (→ 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<T>");
// 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)
/// <summary>
/// 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).
/// </summary>
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; }
/// <summary>
/// Type name safe for typeof() — nullable ref type annotation stripped (typeof(T?) invalid for ref types).
/// </summary>
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; }
/// <summary>
/// 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).
/// </summary>
public int InterningFlags { get; }
/// <summary>True if the Complex property type has [AcBinarySerializable] → has a generated writer.</summary>
public bool HasGeneratedWriter { get; }
/// <summary>True if the Complex property type implements IId&lt;T&gt; → needs ref tracking in write pass.</summary>
public bool IsIId { get; }
/// <summary>Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter.</summary>
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

View File

@ -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;
/// <summary>
/// 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.
/// </summary>
public bool IsDirectObjectWrite => !UseMetadata && !HasPropertyFilter;
public byte MinStringInternLength => Options.MinStringInternLength;
public byte MaxStringInternLength => Options.MaxStringInternLength;
public BinaryPropertyFilter? PropertyFilter => Options.PropertyFilter;

View File

@ -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;